Step 10B.2: deadhead to any discovered location

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-03 14:46:30 -04:00
parent dff2c576cf
commit 553983590e
7 changed files with 53 additions and 36 deletions

View File

@ -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.

View File

@ -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,
}; };

View File

@ -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.",
}; };

View File

@ -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");

View File

@ -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

View File

@ -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);

View File

@ -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();