From 1e3b45b52996ae902bcb4ebeaa9c7a20390e1315 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 2 Oct 2026 18:47:24 -0400 Subject: [PATCH] Step 3.12: add dispatch validation Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 ++ memory-bank/progress.md | 2 +- packages/sim/src/dispatch.ts | 106 ++++++++++++++++++++++ packages/sim/src/index.ts | 1 + packages/sim/test/dispatch.test.ts | 139 +++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 packages/sim/src/dispatch.ts create mode 100644 packages/sim/test/dispatch.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index ef26bf7..c2ddf02 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -136,3 +136,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Pilot skills are a `{ skillId: level }` map**; a missing skill counts as level 0. - **The Long Haul skill id is the constant `long-haul`** in sim, because the rule (decision 0.5.7) is specifically about that skill. - **The cargo skill is checked before Long Haul** when reporting a failure reason. + +## Step 3.12 + +- **Minimal state shapes:** `ShipState` { id, modelId, locationId, inFlight } and `PilotState` { id, isAvatar, assignedShipId, inFlight, skills }. The server maps its database rows to these. +- **Tutorial dispatch:** the caller passes the loaner as the ship (a `ShipState` built from the player's `tutorial` object, decision 0.5.26) and sets `tutorial: true`. The pilot must be the avatar, and the fuel check is skipped. The validator does not check that a tutorial contract came from the tutorial board; the server does that. +- **Checks run in the plan's order**, and the first failure is returned: ship idle → ship at origin → pilot idle → pilot assigned → capacity → landing → range → cargo skill → Long Haul → expiry → fuel credits. +- **Fuel is charged in whole credits** (`tripFuelCost` rounds), using the distance at departure (`now`). diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 3685c21..6133e0b 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -30,7 +30,7 @@ - [x] **Step 3.9 — Contract board generation.** `src/board.ts`: `generateContractBoard` (seeded per world, bay and period), plus `boardPeriodIndex`, `requiredLongHaulLevel` and the `SimContent` input type. First contact ≈10% over 1,000 boards. - [x] **Step 3.10 — Tutorial contract board.** `src/tutorial.ts`: `generateTutorialBoard` (Earth↔Moon from the loaner's location, Standard cargo capped at Courier capacity, payout × tutorial share); `loanerModel`. `SimContent` gained `shipModels`. - [x] **Step 3.11 — Pilot eligibility.** `src/pilot.ts`: `checkPilotEligibility(skills, contract)` returns `ok` or `cargo-skill-too-low` / `long-haul-too-low`. -- [ ] Step 3.12 — Dispatch validation +- [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. - [ ] Step 3.13 — Dispatch record creation - [ ] Step 3.14 — Reposition dispatch - [ ] Step 3.15 — In-flight position and progress diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts new file mode 100644 index 0000000..ce25e70 --- /dev/null +++ b/packages/sim/src/dispatch.ts @@ -0,0 +1,106 @@ +import type { Location, ShipModel } from "@deadhead/content"; +import type { SimContent } from "./board"; +import { type GameTime, type RealTime, realToGame } from "./clock"; +import type { Contract } from "./contract"; +import { routeDistance } from "./distance"; +import { checkRoute } from "./eligibility"; +import { fuelCost } from "./fuel"; +import { checkPilotEligibility, type SkillLevels } from "./pilot"; + +export interface ShipState { + id: string; + modelId: string; + locationId: string; + inFlight: boolean; +} + +export interface PilotState { + id: string; + /** The player's own pilot: exempt from pilot pay (decision 0.5.12). */ + isAvatar: boolean; + assignedShipId: string | null; + inFlight: boolean; + skills: SkillLevels; +} + +export interface DispatchProposal { + /** For a tutorial run, the company loaner (decision 0.5.26: it is not in the ships list). */ + ship: ShipState; + pilot: PilotState; + contract: Contract; + /** Tutorial runs are flown by the avatar in the loaner, and the company pays fuel. */ + tutorial: boolean; + credits: number; + now: RealTime; +} + +export type DispatchFailure = + | "ship-not-idle" + | "ship-not-at-origin" + | "pilot-not-idle" + | "pilot-not-assigned" + | "over-capacity" + | "cannot-land" + | "out-of-range" + | "cargo-skill-too-low" + | "long-haul-too-low" + | "contract-expired" + | "insufficient-credits"; + +export type DispatchCheck = { ok: true } | { ok: false; reason: DispatchFailure }; + +export function findShipModel(id: string, content: SimContent): ShipModel { + const model = content.shipModels.find((s) => s.id === id); + if (!model) throw new Error(`Unknown ship model ${id}`); + return model; +} + +export function findLocation(id: string, content: SimContent): Location { + const location = content.locations.find((l) => l.id === id); + if (!location) throw new Error(`Unknown location ${id}`); + return location; +} + +/** Fuel charged for a trip, in whole credits, using the distance at departure. */ +export function tripFuelCost( + model: ShipModel, + origin: Location, + destination: Location, + departure: GameTime, +): number { + return Math.round(fuelCost(model, origin, routeDistance(origin, destination, departure))); +} + +const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason }); + +/** Validates a proposed contract dispatch, returning the first failing reason. */ +export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck { + const { ship, pilot, contract, tutorial, credits, now } = proposal; + const model = findShipModel(ship.modelId, content); + const origin = findLocation(contract.originId, content); + const destination = findLocation(contract.destinationId, content); + const departure = realToGame(now, content.config); + + if (ship.inFlight) return fail("ship-not-idle"); + if (ship.locationId !== contract.originId) return fail("ship-not-at-origin"); + if (pilot.inFlight) return fail("pilot-not-idle"); + // Decision 0.5.23: during the tutorial the avatar flies only the loaner. + if (tutorial ? !pilot.isAvatar : pilot.assignedShipId !== ship.id) { + return fail("pilot-not-assigned"); + } + if (contract.massTonnes > model.cargoCapacityTonnes) return fail("over-capacity"); + + const route = checkRoute(model, origin, destination, departure); + if (!route.ok) return fail(route.reason); + + const pilotCheck = checkPilotEligibility(pilot.skills, contract); + if (!pilotCheck.ok) return fail(pilotCheck.reason); + + if (now >= contract.expiresAt) return fail("contract-expired"); + + if (!tutorial) { + if (credits < tripFuelCost(model, origin, destination, departure)) + return fail("insufficient-credits"); + } + return { ok: true }; +} diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 8adf1ef..a3b367a 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -9,3 +9,4 @@ export * from "./contract"; export * from "./board"; export * from "./tutorial"; export * from "./pilot"; +export * from "./dispatch"; diff --git a/packages/sim/test/dispatch.test.ts b/packages/sim/test/dispatch.test.ts new file mode 100644 index 0000000..6d75ace --- /dev/null +++ b/packages/sim/test/dispatch.test.ts @@ -0,0 +1,139 @@ +import { content as defaultContent, type ShipModel } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + validateDispatch, + type Contract, + type DispatchProposal, + type PilotState, + type ShipState, + type SimContent, +} from "../src/index"; + +/** Default content plus a test-only ship that cannot land on atmospheric worlds. */ +const vacuumModel: ShipModel = { + ...defaultContent.shipModel("courier")!, + id: "test-vacuum", + landingClass: "vacuum", +}; +const content: SimContent = { + ...defaultContent, + shipModels: [...defaultContent.shipModels, vacuumModel], +}; + +const now = Date.parse("2100-01-01T00:30:00Z"); + +const contract: Contract = { + id: "c1", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + tier: 0, + massTonnes: 10, + payout: 2000, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: Date.parse("2100-01-01T00:00:00Z"), + expiresAt: Date.parse("2100-01-01T01:00:00Z"), + firstContact: false, +}; +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: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now }; + +const reason = (proposal: DispatchProposal) => { + const result = validateDispatch(proposal, content); + return result.ok ? "valid" : result.reason; +}; + +describe("dispatch validation", () => { + it("accepts a fully valid dispatch", () => { + expect(reason(valid)).toBe("valid"); + }); + + it("rejects a ship that is in flight", () => { + expect(reason({ ...valid, ship: { ...ship, inFlight: true } })).toBe("ship-not-idle"); + }); + + it("rejects a ship that is not at the contract origin", () => { + expect(reason({ ...valid, ship: { ...ship, locationId: "moon" } })).toBe("ship-not-at-origin"); + }); + + it("rejects a pilot who is in flight", () => { + expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle"); + }); + + it("rejects a pilot assigned to another ship", () => { + expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: "s2" } })).toBe( + "pilot-not-assigned", + ); + }); + + it("rejects cargo heavier than the ship's capacity", () => { + expect(reason({ ...valid, contract: { ...contract, massTonnes: 50 } })).toBe("over-capacity"); + }); + + it("rejects a destination the ship cannot land at", () => { + expect( + reason({ + ...valid, + ship: { ...ship, modelId: "test-vacuum", locationId: "moon" }, + contract: { ...contract, originId: "moon", destinationId: "earth" }, + }), + ).toBe("cannot-land"); + }); + + it("rejects a destination out of the ship's range", () => { + expect(reason({ ...valid, contract: { ...contract, destinationId: "mars" } })).toBe( + "out-of-range", + ); + }); + + it("rejects a pilot without the required cargo skill", () => { + const heavy = { + ...contract, + cargoTypeId: "heavy", + tier: 1, + requiredSkill: { skillId: "heavy-cargo", level: 1 }, + }; + expect(reason({ ...valid, contract: heavy })).toBe("cargo-skill-too-low"); + }); + + it("rejects a pilot without the required Long Haul level", () => { + expect(reason({ ...valid, contract: { ...contract, requiredLongHaulLevel: 1 } })).toBe( + "long-haul-too-low", + ); + }); + + it("rejects an expired contract", () => { + expect(reason({ ...valid, now: contract.expiresAt })).toBe("contract-expired"); + }); + + it("rejects a dispatch the player cannot afford fuel for", () => { + expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits"); + }); + + describe("tutorial runs", () => { + const loaner: ShipState = { + id: "loaner", + modelId: "courier", + locationId: "earth", + inFlight: false, + }; + const avatar: PilotState = { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null }; + const tutorial: DispatchProposal = { ...valid, ship: loaner, pilot: avatar, tutorial: true }; + + it("accepts the avatar in the loaner without credits for fuel", () => { + expect(reason({ ...tutorial, credits: 0 })).toBe("valid"); + }); + + it("rejects a hired pilot in the loaner", () => { + expect(reason({ ...tutorial, pilot })).toBe("pilot-not-assigned"); + }); + }); +});