Step 10B.2: deadhead to any discovered location
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dff2c576cf
commit
553983590e
|
|
@ -621,3 +621,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
## Step 10B.1
|
## Step 10B.1
|
||||||
|
|
||||||
- **The board generator's input is `location`** (the freight market's location). Sim never knew about bays for boards; the old field name `origin` was the only "bay-board" naming, and comments now say "location board". Generation rules and seeds (`board:<location>:<period>`) are unchanged.
|
- **The board generator's input is `location`** (the freight market's location). Sim never knew about bays for boards; the old field name `origin` was the only "bay-board" naming, and comments now say "location board". Generation rules and seeds (`board:<location>:<period>`) are unchanged.
|
||||||
|
|
||||||
|
## Step 10B.2
|
||||||
|
|
||||||
|
- **Deadhead checks, in order:** ship idle, pilot idle, pilot assigned to the ship, destination not the current location (`already-there`), destination discovered (`location-undiscovered`), landing, range, fuel credits. Bays play no part.
|
||||||
|
- **`no-bay-at-destination` is gone** from sim reasons and API error codes; `location-undiscovered` (already a server code) is now also a sim reason.
|
||||||
|
- **The server change landed in this step** (it must pass `discoveredIds` to compile), so the Step 6.11 "no bay at destination" test became "undiscovered destination" here; Step 10B.5 adds the stranded-ship scenario.
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,9 @@ import { and, eq, sql } from "drizzle-orm";
|
||||||
import type { Queryable } from "../db/client";
|
import type { Queryable } from "../db/client";
|
||||||
import {
|
import {
|
||||||
acceptedContracts,
|
acceptedContracts,
|
||||||
bays,
|
|
||||||
companies,
|
companies,
|
||||||
contractBoards,
|
contractBoards,
|
||||||
|
discoveredLocations,
|
||||||
dispatches,
|
dispatches,
|
||||||
pilots,
|
pilots,
|
||||||
players,
|
players,
|
||||||
|
|
@ -246,7 +246,7 @@ export async function setInFlight(tx: Queryable, shipId: string, pilotId: string
|
||||||
.where(eq(pilots.id, pilotId));
|
.where(eq(pilots.id, pilotId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flies a ship and its pilot empty to another player bay, paying fuel only (Step 6.11). */
|
/** Deadhead: flies a ship and its pilot empty to another discovered location, paying fuel only (Steps 6.11, 10B.2). */
|
||||||
export async function dispatchReposition(
|
export async function dispatchReposition(
|
||||||
tx: Queryable,
|
tx: Queryable,
|
||||||
playerId: string,
|
playerId: string,
|
||||||
|
|
@ -257,16 +257,16 @@ export async function dispatchReposition(
|
||||||
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
|
const pilot = await requireOwnPilot(tx, playerId, input.pilotId);
|
||||||
const player = await requirePlayer(tx, playerId);
|
const player = await requirePlayer(tx, playerId);
|
||||||
const company = await requireCompany(tx, playerId);
|
const company = await requireCompany(tx, playerId);
|
||||||
const bayRows = await tx
|
const discovered = await tx
|
||||||
.select({ locationId: bays.locationId })
|
.select({ locationId: discoveredLocations.locationId })
|
||||||
.from(bays)
|
.from(discoveredLocations)
|
||||||
.where(eq(bays.playerId, playerId));
|
.where(eq(discoveredLocations.playerId, playerId));
|
||||||
|
|
||||||
const proposal = {
|
const proposal = {
|
||||||
ship: shipState(ship),
|
ship: shipState(ship),
|
||||||
pilot: pilotState(pilot),
|
pilot: pilotState(pilot),
|
||||||
destinationId: input.destinationId,
|
destinationId: input.destinationId,
|
||||||
bayLocationIds: new Set(bayRows.map((b) => b.locationId)),
|
discoveredIds: new Set(discovered.map((d) => d.locationId)),
|
||||||
credits: company.credits,
|
credits: company.credits,
|
||||||
now,
|
now,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -14,5 +14,5 @@ export const VALIDATION_MESSAGES: Record<DispatchFailure | RepositionFailure, st
|
||||||
"contract-expired": "This contract has expired.",
|
"contract-expired": "This contract has expired.",
|
||||||
"insufficient-credits": "Not enough credits for fuel.",
|
"insufficient-credits": "Not enough credits for fuel.",
|
||||||
"already-there": "The ship is already there.",
|
"already-there": "The ship is already there.",
|
||||||
"no-bay-at-destination": "You have no bay at that destination.",
|
"location-undiscovered": "You can only fly empty to places you have discovered.",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -48,17 +48,17 @@ describe("reposition dispatch", () => {
|
||||||
expect(state.company.credits).toBe(10_000 - dispatch.fuelCost);
|
expect(state.company.credits).toBe(10_000 - dispatch.fuelCost);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refuses a destination without a player bay and changes nothing", async () => {
|
it("refuses an undiscovered destination and changes nothing", async () => {
|
||||||
const { app } = testApp();
|
const { app } = testApp();
|
||||||
const { guest, shipId } = await ownerWithMoonBay(false);
|
const { guest, shipId } = await ownerWithMoonBay(false);
|
||||||
const response = await call(app, guest, "POST", "/api/dispatches", {
|
const response = await call(app, guest, "POST", "/api/dispatches", {
|
||||||
kind: "reposition",
|
kind: "reposition",
|
||||||
shipId,
|
shipId,
|
||||||
pilotId: guest.avatarId,
|
pilotId: guest.avatarId,
|
||||||
destinationId: "moon",
|
destinationId: "mars",
|
||||||
});
|
});
|
||||||
expect(response.statusCode).toBe(409);
|
expect(response.statusCode).toBe(409);
|
||||||
expect(errorResponseSchema.parse(response.json()).error.code).toBe("no-bay-at-destination");
|
expect(errorResponseSchema.parse(response.json()).error.code).toBe("location-undiscovered");
|
||||||
const state = await getState(app, guest);
|
const state = await getState(app, guest);
|
||||||
expect(state.company.credits).toBe(10_000);
|
expect(state.company.credits).toBe(10_000);
|
||||||
expect(state.ships[0]!.status).toBe("idle");
|
expect(state.ships[0]!.status).toBe("idle");
|
||||||
|
|
|
||||||
|
|
@ -135,7 +135,7 @@ Spec: `memory-bank/change-requests/CR-001-faster-start.md`. Decisions recorded a
|
||||||
Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions recorded as plan §0.5.35–0.5.36.
|
Spec: `memory-bank/change-requests/CR-002-open-markets.md`. New decisions recorded as plan §0.5.35–0.5.36.
|
||||||
|
|
||||||
- [x] **Step 10B.1 — Sim: boards at any location.** `BoardInput.location` (was `origin`) is the board's location; no bay notion in sim board generation. Moon board test added.
|
- [x] **Step 10B.1 — Sim: boards at any location.** `BoardInput.location` (was `origin`) is the board's location; no bay notion in sim board generation. Moon board test added.
|
||||||
- [ ] Step 10B.2 — Sim: deadhead validation
|
- [x] **Step 10B.2 — Sim: deadhead validation.** `validateReposition` takes `discoveredIds` (not bays): any origin, destination discovered (`location-undiscovered`) and not the current location, range and landing. `REPOSITION_FAILURES` swaps `no-bay-at-destination` for `location-undiscovered`. Server passes the player's discovered locations.
|
||||||
- [ ] Step 10B.3 — Database: boards keyed by location
|
- [ ] Step 10B.3 — Database: boards keyed by location
|
||||||
- [ ] Step 10B.4 — Server: board endpoint for any discovered location
|
- [ ] Step 10B.4 — Server: board endpoint for any discovered location
|
||||||
- [ ] Step 10B.5 — Server: no more stranded ships
|
- [ ] Step 10B.5 — Server: no more stranded ships
|
||||||
|
|
|
||||||
|
|
@ -194,8 +194,8 @@ export interface RepositionProposal {
|
||||||
ship: ShipState;
|
ship: ShipState;
|
||||||
pilot: PilotState;
|
pilot: PilotState;
|
||||||
destinationId: string;
|
destinationId: string;
|
||||||
/** Locations where the player has a bay (bays exist only at discovered locations). */
|
/** Locations the player has discovered; only these can be deadheaded to (CR-002). */
|
||||||
bayLocationIds: ReadonlySet<string>;
|
discoveredIds: ReadonlySet<string>;
|
||||||
credits: number;
|
credits: number;
|
||||||
now: RealTime;
|
now: RealTime;
|
||||||
}
|
}
|
||||||
|
|
@ -206,7 +206,7 @@ export const REPOSITION_FAILURES = [
|
||||||
"pilot-not-idle",
|
"pilot-not-idle",
|
||||||
"pilot-not-assigned",
|
"pilot-not-assigned",
|
||||||
"already-there",
|
"already-there",
|
||||||
"no-bay-at-destination",
|
"location-undiscovered",
|
||||||
"cannot-land",
|
"cannot-land",
|
||||||
"out-of-range",
|
"out-of-range",
|
||||||
"insufficient-credits",
|
"insufficient-credits",
|
||||||
|
|
@ -216,21 +216,22 @@ export type RepositionFailure = (typeof REPOSITION_FAILURES)[number];
|
||||||
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };
|
export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates an empty flight to one of the player's bays. Only range and landing
|
* Validates a deadhead (CR-002): an empty flight from wherever the ship is to
|
||||||
* apply; no skills are needed (decision 0.5.24).
|
* any other discovered location. Only range and landing apply; no skills are
|
||||||
|
* needed (decision 0.5.24) and bays play no part.
|
||||||
*/
|
*/
|
||||||
export function validateReposition(
|
export function validateReposition(
|
||||||
proposal: RepositionProposal,
|
proposal: RepositionProposal,
|
||||||
content: SimContent,
|
content: SimContent,
|
||||||
): RepositionCheck {
|
): RepositionCheck {
|
||||||
const { ship, pilot, destinationId, bayLocationIds, credits, now } = proposal;
|
const { ship, pilot, destinationId, discoveredIds, credits, now } = proposal;
|
||||||
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason });
|
||||||
|
|
||||||
if (ship.inFlight) return failWith("ship-not-idle");
|
if (ship.inFlight) return failWith("ship-not-idle");
|
||||||
if (pilot.inFlight) return failWith("pilot-not-idle");
|
if (pilot.inFlight) return failWith("pilot-not-idle");
|
||||||
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
|
if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned");
|
||||||
if (destinationId === ship.locationId) return failWith("already-there");
|
if (destinationId === ship.locationId) return failWith("already-there");
|
||||||
if (!bayLocationIds.has(destinationId)) return failWith("no-bay-at-destination");
|
if (!discoveredIds.has(destinationId)) return failWith("location-undiscovered");
|
||||||
|
|
||||||
const model = findShipModel(ship.modelId, content);
|
const model = findShipModel(ship.modelId, content);
|
||||||
const origin = findLocation(ship.locationId, content);
|
const origin = findLocation(ship.locationId, content);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
|
|
||||||
const now = Date.parse("2100-01-01T00:30:00Z");
|
const now = Date.parse("2100-01-01T00:30:00Z");
|
||||||
const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
|
const atEarth: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false };
|
||||||
const pilot: PilotState = {
|
const pilot: PilotState = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
isAvatar: false,
|
isAvatar: false,
|
||||||
|
|
@ -17,11 +17,12 @@ const pilot: PilotState = {
|
||||||
inFlight: false,
|
inFlight: false,
|
||||||
skills: {},
|
skills: {},
|
||||||
};
|
};
|
||||||
|
/** No bays anywhere: deadheads depend only on discovery, range and landing (CR-002). */
|
||||||
const valid: RepositionProposal = {
|
const valid: RepositionProposal = {
|
||||||
ship,
|
ship: atEarth,
|
||||||
pilot,
|
pilot,
|
||||||
destinationId: "moon",
|
destinationId: "moon",
|
||||||
bayLocationIds: new Set(["earth", "moon"]),
|
discoveredIds: new Set(["earth", "moon"]),
|
||||||
credits: 10_000,
|
credits: 10_000,
|
||||||
now,
|
now,
|
||||||
};
|
};
|
||||||
|
|
@ -31,25 +32,34 @@ const reason = (proposal: RepositionProposal) => {
|
||||||
return result.ok ? "valid" : result.reason;
|
return result.ok ? "valid" : result.reason;
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("reposition", () => {
|
describe("deadhead (fly empty)", () => {
|
||||||
it("accepts an empty flight to a bay in range, with no skills needed", () => {
|
it("lets a Courier idle at the Moon, with no Moon bay, deadhead to Earth", () => {
|
||||||
|
const atMoon = { ...atEarth, locationId: "moon" };
|
||||||
|
expect(reason({ ...valid, ship: atMoon, destinationId: "earth" })).toBe("valid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs no skills", () => {
|
||||||
expect(reason(valid)).toBe("valid");
|
expect(reason(valid)).toBe("valid");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails validation when the destination has no player bay", () => {
|
it("refuses an undiscovered destination with location-undiscovered", () => {
|
||||||
expect(reason({ ...valid, bayLocationIds: new Set(["earth"]) })).toBe("no-bay-at-destination");
|
expect(reason({ ...valid, destinationId: "mars" })).toBe("location-undiscovered");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still applies range and fuel checks", () => {
|
it("refuses the ship's own location", () => {
|
||||||
expect(
|
|
||||||
reason({ ...valid, destinationId: "mars", bayLocationIds: new Set(["earth", "mars"]) }),
|
|
||||||
).toBe("out-of-range");
|
|
||||||
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects flying to where the ship already is, or with a busy or unassigned crew", () => {
|
|
||||||
expect(reason({ ...valid, destinationId: "earth" })).toBe("already-there");
|
expect(reason({ ...valid, destinationId: "earth" })).toBe("already-there");
|
||||||
expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle");
|
});
|
||||||
|
|
||||||
|
it("refuses an out-of-range destination with the out-of-range reason", () => {
|
||||||
|
const discovered = new Set(["earth", "moon", "mars"]);
|
||||||
|
expect(reason({ ...valid, destinationId: "mars", discoveredIds: discovered })).toBe(
|
||||||
|
"out-of-range",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still needs fuel credits, an idle crew and an assigned pilot", () => {
|
||||||
|
expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits");
|
||||||
|
expect(reason({ ...valid, ship: { ...atEarth, inFlight: true } })).toBe("ship-not-idle");
|
||||||
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
|
expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle");
|
||||||
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(
|
expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe(
|
||||||
"pilot-not-assigned",
|
"pilot-not-assigned",
|
||||||
|
|
@ -58,7 +68,7 @@ describe("reposition", () => {
|
||||||
|
|
||||||
it("creates a record with no contract, zero payout and a fuel charge", () => {
|
it("creates a record with no contract, zero payout and a fuel charge", () => {
|
||||||
const record = createRepositionRecord(
|
const record = createRepositionRecord(
|
||||||
{ id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 },
|
{ id: "r1", ship: atEarth, pilot, destinationId: "moon", now, worldSeed: 7 },
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
expect(record.contract).toBeNull();
|
expect(record.contract).toBeNull();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue