From 039df0ac5b65fe2982910ee1385758d77490edaa Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 18:49:14 -0400 Subject: [PATCH] Step 3.14: add reposition dispatch Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ memory-bank/progress.md | 2 +- packages/sim/src/dispatch.ts | 105 +++++++++++++++++++++++++-- packages/sim/test/reposition.test.ts | 70 ++++++++++++++++++ 4 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 packages/sim/test/reposition.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 0d4ef06..0e2810a 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -151,3 +151,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Arrival** = departure + travel time in game ms ÷ compression, rounded to whole real milliseconds. - **`fuelCost` is what the player pays:** 0 for tutorial runs. - **`expectedPayout` is the contract payout** before pilot pay; resolution deducts pilot pay. + +## Step 3.14 + +- **A reposition needs a player bay at the destination**, checked against `bayLocationIds`. Bays exist only at discovered locations, so this also guarantees the destination is discovered. +- **Reposition check order:** ship idle → pilot idle → pilot assigned → not already there → bay at destination → landing → range → fuel credits. There are no skill checks (decision 0.5.24). Tutorial loaners cannot reposition. +- **Contract and reposition records share one builder**; a reposition has `contract: null` and `expectedPayout: 0`. +- **"Zero XP" is enforced at resolution** (Step 3.17), because the record has no XP field. That step's tests cover it. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index f5d38a3..e757f1e 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -32,7 +32,7 @@ - [x] **Step 3.11 — Pilot eligibility.** `src/pilot.ts`: `checkPilotEligibility(skills, contract)` returns `ok` or `cargo-skill-too-low` / `long-haul-too-low`. - [x] **Step 3.12 — Dispatch validation.** `src/dispatch.ts`: `ShipState`, `PilotState`, `validateDispatch` (first failing reason, plan order), `tripFuelCost` (rounded). One test per failure reason plus tutorial cases. - [x] **Step 3.13 — Dispatch record creation.** `createDispatchRecord` in `src/dispatch.ts`: `DispatchRecord` with arrival from travel time, fuel cost, departure distance, a `tutorial` flag and a per-dispatch seed. -- [ ] Step 3.14 — Reposition dispatch +- [x] **Step 3.14 — Reposition dispatch.** `validateReposition` and `createRepositionRecord` in `src/dispatch.ts`: empty flight to a player bay, range/landing/fuel only, zero payout. Zero XP is covered by resolution (Step 3.17). - [ ] Step 3.15 — In-flight position and progress - [ ] Step 3.16 — XP and leveling - [ ] Step 3.17 — Dispatch resolution diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 070eb86..2d4b1e3 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -137,13 +137,25 @@ export interface RecordInput { worldSeed: Seed; } -/** Turns an already-validated dispatch into its record. Same inputs give the same record. */ -export function createDispatchRecord(input: RecordInput, content: SimContent): DispatchRecord { - const { id, ship, pilot, contract, tutorial, now, worldSeed } = input; +function buildRecord( + args: { + id: string; + ship: ShipState; + pilot: PilotState; + contract: Contract | null; + originId: string; + destinationId: string; + tutorial: boolean; + now: RealTime; + worldSeed: Seed; + }, + content: SimContent, +): DispatchRecord { + const { id, ship, pilot, contract, tutorial, now, worldSeed } = args; const { config } = content; const model = findShipModel(ship.modelId, content); - const origin = findLocation(contract.originId, content); - const destination = findLocation(contract.destinationId, content); + const origin = findLocation(args.originId, content); + const destination = findLocation(args.destinationId, content); const departure = realToGame(now, config); const distance = routeDistance(origin, destination, departure); const travelGameMs = travelTimeGameHours(model, distance) * MS_PER_HOUR; @@ -160,7 +172,88 @@ export function createDispatchRecord(input: RecordInput, content: SimContent): D arrivalAt: now + Math.round(travelGameMs / config.timeCompression), distanceMillionKm: distance, fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, departure), - expectedPayout: contract.payout, + expectedPayout: contract?.payout ?? 0, seed: deriveSeed(worldSeed, `dispatch:${id}`), }; } + +/** Turns an already-validated dispatch into its record. Same inputs give the same record. */ +export function createDispatchRecord(input: RecordInput, content: SimContent): DispatchRecord { + const { contract } = input; + return buildRecord( + { ...input, originId: contract.originId, destinationId: contract.destinationId }, + content, + ); +} + +export interface RepositionProposal { + ship: ShipState; + pilot: PilotState; + destinationId: string; + /** Locations where the player has a bay (bays exist only at discovered locations). */ + bayLocationIds: ReadonlySet; + credits: number; + now: RealTime; +} + +export type RepositionFailure = + | "ship-not-idle" + | "pilot-not-idle" + | "pilot-not-assigned" + | "already-there" + | "no-bay-at-destination" + | "cannot-land" + | "out-of-range" + | "insufficient-credits"; + +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). + */ +export function validateReposition( + proposal: RepositionProposal, + content: SimContent, +): RepositionCheck { + const { ship, pilot, destinationId, bayLocationIds, 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"); + + const model = findShipModel(ship.modelId, content); + const origin = findLocation(ship.locationId, content); + const destination = findLocation(destinationId, content); + const departure = realToGame(now, content.config); + const route = checkRoute(model, origin, destination, departure); + if (!route.ok) return failWith(route.reason); + + if (credits < tripFuelCost(model, origin, destination, departure)) { + return failWith("insufficient-credits"); + } + return { ok: true }; +} + +export interface RepositionRecordInput { + id: string; + ship: ShipState; + pilot: PilotState; + destinationId: string; + now: RealTime; + worldSeed: Seed; +} + +/** Record for a validated reposition: no contract, fuel only, zero payout. */ +export function createRepositionRecord( + input: RepositionRecordInput, + content: SimContent, +): DispatchRecord { + return buildRecord( + { ...input, contract: null, originId: input.ship.locationId, tutorial: false }, + content, + ); +} diff --git a/packages/sim/test/reposition.test.ts b/packages/sim/test/reposition.test.ts new file mode 100644 index 0000000..e6d6b73 --- /dev/null +++ b/packages/sim/test/reposition.test.ts @@ -0,0 +1,70 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + createRepositionRecord, + validateReposition, + type PilotState, + type RepositionProposal, + type ShipState, +} from "../src/index"; + +const now = Date.parse("2100-01-01T00:30:00Z"); +const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const pilot: PilotState = { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, +}; +const valid: RepositionProposal = { + ship, + pilot, + destinationId: "moon", + bayLocationIds: new Set(["earth", "moon"]), + credits: 10_000, + now, +}; + +const reason = (proposal: RepositionProposal) => { + const result = validateReposition(proposal, content); + return result.ok ? "valid" : result.reason; +}; + +describe("reposition", () => { + it("accepts an empty flight to a bay in range, with no skills needed", () => { + 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("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", () => { + expect(reason({ ...valid, destinationId: "earth" })).toBe("already-there"); + expect(reason({ ...valid, ship: { ...ship, 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", + ); + }); + + 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 }, + content, + ); + expect(record.contract).toBeNull(); + expect(record.expectedPayout).toBe(0); + expect(record.fuelCost).toBeGreaterThan(0); + expect([record.originId, record.destinationId]).toEqual(["earth", "moon"]); + expect(record.arrivalAt).toBeGreaterThan(record.departureAt); + }); +});