From 553983590e8f718b3ee7ada6cf8a831c73e5be4c Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 3 Oct 2026 14:46:30 -0400 Subject: [PATCH] Step 10B.2: deadhead to any discovered location Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 6 ++++ apps/server/src/game/dispatch.ts | 14 ++++----- apps/server/src/game/messages.ts | 2 +- apps/server/test/reposition.test.ts | 6 ++-- memory-bank/progress.md | 2 +- packages/sim/src/dispatch.ts | 15 +++++----- packages/sim/test/reposition.test.ts | 44 +++++++++++++++++----------- 7 files changed, 53 insertions(+), 36 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index ae8025e..ac09c81 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -621,3 +621,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` ## 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::`) 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. diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index 1876ca8..546cbdf 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -17,9 +17,9 @@ import { and, eq, sql } from "drizzle-orm"; import type { Queryable } from "../db/client"; import { acceptedContracts, - bays, companies, contractBoards, + discoveredLocations, dispatches, pilots, players, @@ -246,7 +246,7 @@ export async function setInFlight(tx: Queryable, shipId: string, pilotId: string .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( tx: Queryable, playerId: string, @@ -257,16 +257,16 @@ export async function dispatchReposition( const pilot = await requireOwnPilot(tx, playerId, input.pilotId); const player = await requirePlayer(tx, playerId); const company = await requireCompany(tx, playerId); - const bayRows = await tx - .select({ locationId: bays.locationId }) - .from(bays) - .where(eq(bays.playerId, playerId)); + const discovered = await tx + .select({ locationId: discoveredLocations.locationId }) + .from(discoveredLocations) + .where(eq(discoveredLocations.playerId, playerId)); const proposal = { ship: shipState(ship), pilot: pilotState(pilot), destinationId: input.destinationId, - bayLocationIds: new Set(bayRows.map((b) => b.locationId)), + discoveredIds: new Set(discovered.map((d) => d.locationId)), credits: company.credits, now, }; diff --git a/apps/server/src/game/messages.ts b/apps/server/src/game/messages.ts index d0b0a44..a9f2af6 100644 --- a/apps/server/src/game/messages.ts +++ b/apps/server/src/game/messages.ts @@ -14,5 +14,5 @@ export const VALIDATION_MESSAGES: Record { 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 { guest, shipId } = await ownerWithMoonBay(false); const response = await call(app, guest, "POST", "/api/dispatches", { kind: "reposition", shipId, pilotId: guest.avatarId, - destinationId: "moon", + destinationId: "mars", }); 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); expect(state.company.credits).toBe(10_000); expect(state.ships[0]!.status).toBe("idle"); diff --git a/memory-bank/progress.md b/memory-bank/progress.md index b441a6a..8dafb01 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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. - [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.4 — Server: board endpoint for any discovered location - [ ] Step 10B.5 — Server: no more stranded ships diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 7bafde0..2dab792 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -194,8 +194,8 @@ export interface RepositionProposal { ship: ShipState; pilot: PilotState; destinationId: string; - /** Locations where the player has a bay (bays exist only at discovered locations). */ - bayLocationIds: ReadonlySet; + /** Locations the player has discovered; only these can be deadheaded to (CR-002). */ + discoveredIds: ReadonlySet; credits: number; now: RealTime; } @@ -206,7 +206,7 @@ export const REPOSITION_FAILURES = [ "pilot-not-idle", "pilot-not-assigned", "already-there", - "no-bay-at-destination", + "location-undiscovered", "cannot-land", "out-of-range", "insufficient-credits", @@ -216,21 +216,22 @@ export type RepositionFailure = (typeof REPOSITION_FAILURES)[number]; export type RepositionCheck = { ok: true } | { ok: false; reason: RepositionFailure }; /** - * Validates an empty flight to one of the player's bays. Only range and landing - * apply; no skills are needed (decision 0.5.24). + * Validates a deadhead (CR-002): an empty flight from wherever the ship is to + * 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( proposal: RepositionProposal, content: SimContent, ): 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 }); if (ship.inFlight) return failWith("ship-not-idle"); if (pilot.inFlight) return failWith("pilot-not-idle"); if (pilot.assignedShipId !== ship.id) return failWith("pilot-not-assigned"); 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 origin = findLocation(ship.locationId, content); diff --git a/packages/sim/test/reposition.test.ts b/packages/sim/test/reposition.test.ts index e6d6b73..30954a2 100644 --- a/packages/sim/test/reposition.test.ts +++ b/packages/sim/test/reposition.test.ts @@ -9,7 +9,7 @@ import { } from "../src/index"; 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 = { id: "p1", isAvatar: false, @@ -17,11 +17,12 @@ const pilot: PilotState = { inFlight: false, skills: {}, }; +/** No bays anywhere: deadheads depend only on discovery, range and landing (CR-002). */ const valid: RepositionProposal = { - ship, + ship: atEarth, pilot, destinationId: "moon", - bayLocationIds: new Set(["earth", "moon"]), + discoveredIds: new Set(["earth", "moon"]), credits: 10_000, now, }; @@ -31,25 +32,34 @@ const reason = (proposal: RepositionProposal) => { return result.ok ? "valid" : result.reason; }; -describe("reposition", () => { - it("accepts an empty flight to a bay in range, with no skills needed", () => { +describe("deadhead (fly empty)", () => { + 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"); }); - it("fails validation when the destination has no player bay", () => { - expect(reason({ ...valid, bayLocationIds: new Set(["earth"]) })).toBe("no-bay-at-destination"); + it("refuses an undiscovered destination with location-undiscovered", () => { + expect(reason({ ...valid, destinationId: "mars" })).toBe("location-undiscovered"); }); - it("still applies range and fuel checks", () => { - 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", () => { + it("refuses the ship's own location", () => { 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, assignedShipId: null } })).toBe( "pilot-not-assigned", @@ -58,7 +68,7 @@ describe("reposition", () => { it("creates a record with no contract, zero payout and a fuel charge", () => { const record = createRepositionRecord( - { id: "r1", ship, pilot, destinationId: "moon", now, worldSeed: 7 }, + { id: "r1", ship: atEarth, pilot, destinationId: "moon", now, worldSeed: 7 }, content, ); expect(record.contract).toBeNull();