From f8d2f538ca690c762b4832a7f0845c732af9be2e Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sun, 4 Oct 2026 17:04:55 -0400 Subject: [PATCH] Step 10G.4: Priority contracts fail too-slow ships Co-Authored-By: Claude Opus 5.5 --- apps/server/src/game/messages.ts | 1 + apps/web/src/screens/DispatchSheet.tsx | 1 + memory-bank/progress.md | 1 + packages/sim/src/dispatch.ts | 23 +++++++ packages/sim/src/index.ts | 2 + packages/sim/test/board-fleet.test.ts | 7 ++- packages/sim/test/priority.test.ts | 84 ++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 packages/sim/test/priority.test.ts diff --git a/apps/server/src/game/messages.ts b/apps/server/src/game/messages.ts index b7c78c0..2a8cee7 100644 --- a/apps/server/src/game/messages.ts +++ b/apps/server/src/game/messages.ts @@ -10,6 +10,7 @@ export const VALIDATION_MESSAGES: Record = { "over-capacity": "Too heavy", "cannot-land": "Can't land there", "out-of-range": "Out of range", + "too-slow": "Too slow for the Priority window", "cargo-skill-too-low": "Cargo skill too low", "long-haul-too-low": "Long Haul too low", "contract-expired": "Contract expired", diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 1281b6e..c0b0ecd 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -205,3 +205,4 @@ Spec: `memory-bank/change-requests/CR-007-cargo-goods.md` (supplied as "CR-005 / - [x] **Step 10G.1 — Content: goods catalog.** `goodSchema` (id, name, `cargoClassId`, `massTonnes {min,max}`, `payMultiplier`, optional `tier` 1–5, `priorityEligible`, optional `priorityName`, `producedAt`, `demandedAt`; min ≤ max refinement) and `data/goods.ts` with all 29 goods from CR-007 §3, plus `goodsProducedAt`/`goodsDemandedAt`. Locations: `produces`/`demands` now derive from the catalog (good ids); the old class lists are kept as `cargoClassMix` (decision 0.5.57) and the sim's class selection and the UI's produces/demands now read them, so nothing visible changes yet; `shippers` added (≥ 1 each). Config: `priorityChance` 0.15, `priorityPayoutMultiplier` 1.5, `priorityReferenceShipModelId` "freighter", `priorityWindowFactor` 1. Bundle: `goods`, `good(id)`. Tests: `goods.test.ts` (schema, references, tiers by class, Standard good everywhere, ≥ 2 Standard goods each way Earth↔Moon, location lists in sync, Priority config), schema tests for Good and the new fields. - [x] **Step 10G.2 — Sim: contracts carry goods.** `Contract` gains `goodId`, `shipper`, `priority`, `deliveryWindowGameHours` (null unless Priority); the API `contractSchema` mirrors them now (the schema package's type-parity test requires it), so test fixtures across packages gained a good of their class. `contractPayout` = class base × good `payMultiplier` × tierMultiplier^tier × mass × distance × first-contact × Priority bonus. `buildContract` takes a good (the class comes from it) plus shipper and Priority fields; `tierFor` (0.5.55: Standard 0, High-Value/Dangerous the good's tier, Heavy the random roll or up to a fit pilot's level); `routeGoods` (0.5.54 fallback chain). Interim choice until 10G.3: the first route good in catalog order, the origin's first shipper, never Priority; fit jobs only use goods whose tier the pilot meets. `SimContent` gains `goods`. Tests: `contract.test.ts` (tiers from goods, Heavy 1–5, ×2 pay multiplier doubles payout, Priority = bonus × normal). - [x] **Step 10G.3 — Sim: goods, Priority and shippers in board generation.** Class selection is unchanged (by `cargoClassMix`). Then `chooseGood` (band wins): a random good of the class on the route whose mass range overlaps the slot's band (the lightest band has no floor) with mass in both and within a fit ship's capacity; else the largest-range good as a bulk lot from the band. Masses under 10 t to 0.1 t, else whole tonnes. `rollPriority`: eligible goods become Priority with `priorityChance`; window = `priorityWindow` (reference model's trip time at offer × factor); a fit job is Priority only if its own ship makes the window. Shipper: random from the origin's list. Measured over 1,000 Earth boards (all discovered, seed 12345): Standard 76.04 % (baseline 76.79), High-Value 23.96 % (23.21); Priority on 13.6 % of eligible contracts. Bulk lots make Packaged Food and Electronics the most common goods in the big bands. Tests: `board-goods.test.ts` (determinism with and without a fleet; goods made at the origin and wanted at the destination except when no Standard good goes that way; mass rules; class mix ±3 points; Priority 10–20 % of eligible, only eligible goods, window = reference trip; shippers from the origin's list; `chooseGood` overlap, bulk and capacity cases). `board.test.ts` / `board-fleet.test.ts`: the lightest band's floor is 0 for goods. +- [x] **Step 10G.4 — Sim: Priority validation.** `too-slow` joins `DISPATCH_FAILURES` after the route checks: a Priority contract fails when `contractTripGameHours` (cruise + overhead + handling, distance at departure; exported) exceeds `deliveryWindowGameHours`. Server message and the dispatch sheet's short reason added (the API error code follows from the sim's list). `priorityWindow` exported. Tests: `priority.test.ts` (Priority mail Earth→Moon with the Freighter reference: Courier and Freighter pass, Bulk Hauler fails `too-slow`; the trip is measured like the window; a normal contract never fails as too slow); `board-fleet.test.ts` now also requires fit Priority jobs to be within the crew's reach. diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index aaf6c1a..f423a23 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -49,6 +49,7 @@ export const DISPATCH_FAILURES = [ "over-capacity", "cannot-land", "out-of-range", + "too-slow", "cargo-skill-too-low", "long-haul-too-low", "contract-expired", @@ -82,6 +83,19 @@ export function tripFuelCost( const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason }); +/** A ship's trip time for a contract, departing now: cruise, overhead and handling (game hours). */ +export function contractTripGameHours( + model: ShipModel, + contract: Contract, + now: RealTime, + content: SimContent, +): number { + const origin = findLocation(contract.originId, content); + const destination = findLocation(contract.destinationId, content); + const distance = routeDistance(origin, destination, realToGame(now, content.config)); + return travelTimeGameHours(model, distance, contract.massTonnes, content.config); +} + /** 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; @@ -104,6 +118,15 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent const route = checkRoute(model, origin, destination, departure); if (!route.ok) return fail(route.reason); + // Priority (decision 0.5.56): the trip must fit the delivery window. + if ( + contract.priority && + contract.deliveryWindowGameHours !== null && + contractTripGameHours(model, contract, now, content) > contract.deliveryWindowGameHours + ) { + return fail("too-slow"); + } + const pilotCheck = checkPilotEligibility(pilot.skills, contract); if (!pilotCheck.ok) return fail(pilotCheck.reason); diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 992aaf1..1e748cc 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -27,6 +27,7 @@ export { requiredLongHaulLevel, } from "./board"; export type { BoardInput, FleetMember, SimContent } from "./board"; +export { priorityWindow } from "./board"; export { generateTutorialBoard, loanerModel } from "./tutorial"; export type { TutorialBoardInput, TutorialLocationId } from "./tutorial"; @@ -37,6 +38,7 @@ export type { PilotCheck, SkillLevels } from "./pilot"; export { DISPATCH_FAILURES, REPOSITION_FAILURES, + contractTripGameHours, createDispatchRecord, createRepositionRecord, tripFuelCost, diff --git a/packages/sim/test/board-fleet.test.ts b/packages/sim/test/board-fleet.test.ts index 215c6c0..5421d4f 100644 --- a/packages/sim/test/board-fleet.test.ts +++ b/packages/sim/test/board-fleet.test.ts @@ -4,6 +4,7 @@ import { boardPeriodMs, checkPilotEligibility, checkRoute, + contractTripGameHours, generateContractBoard, realToGame, type BoardInput, @@ -25,7 +26,11 @@ function fits(contract: Contract, member: FleetMember): boolean { !contract.firstContact && contract.massTonnes <= model.cargoCapacityTonnes && checkRoute(model, earth, content.location(contract.destinationId)!, gameTime).ok && - checkPilotEligibility(member.skills, contract).ok + checkPilotEligibility(member.skills, contract).ok && + // Priority (CR-007): the ship must make the delivery window. + (!contract.priority || + contractTripGameHours(model, contract, contract.offeredAt, content) <= + contract.deliveryWindowGameHours!) ); } diff --git a/packages/sim/test/priority.test.ts b/packages/sim/test/priority.test.ts new file mode 100644 index 0000000..461a6ec --- /dev/null +++ b/packages/sim/test/priority.test.ts @@ -0,0 +1,84 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { buildContract } from "../src/board"; +import { + boardPeriodMs, + contractTripGameHours, + priorityWindow, + validateDispatch, + type Contract, +} from "../src/index"; + +const earth = content.location("earth")!; +const moon = content.location("moon")!; +const periodIndex = 4_000; +const offeredAt = periodIndex * boardPeriodMs(content.config); + +function mailContract(priority: boolean): Contract { + const massTonnes = 6; + return buildContract( + { + id: "earth:4000:0", + origin: earth, + destination: moon, + good: content.good("mail")!, + tier: 0, + massTonnes, + firstContact: false, + periodIndex, + shipper: earth.shippers[0]!, + priority, + deliveryWindowGameHours: priority + ? priorityWindow(earth, moon, massTonnes, offeredAt, content) + : null, + }, + content, + ); +} + +function check(modelId: string, contract: Contract) { + return validateDispatch( + { + ship: { id: "s", modelId, locationId: "earth", inFlight: false }, + pilot: { + id: "p", + isAvatar: true, + assignedShipId: "s", + inFlight: false, + skills: {}, + restEndsAt: null, + }, + contract, + tutorial: false, + credits: 1_000_000, + now: offeredAt, + }, + content, + ); +} + +describe("Priority validation (CR-007)", () => { + it("lets a Courier take a Priority Earth→Moon job but not a Bulk Hauler (Freighter reference)", () => { + const contract = mailContract(true); + expect(content.config.priorityReferenceShipModelId).toBe("freighter"); + expect(check("courier", contract)).toEqual({ ok: true }); + expect(check("freighter", contract)).toEqual({ ok: true }); + expect(check("bulk-hauler", contract)).toEqual({ ok: false, reason: "too-slow" }); + }); + + it("measures the trip the same way the window was set", () => { + const contract = mailContract(true); + const freighter = content.shipModel("freighter")!; + expect(contractTripGameHours(freighter, contract, offeredAt, content)).toBeCloseTo( + contract.deliveryWindowGameHours!, + ); + }); + + it("never fails a normal contract as too slow", () => { + const contract = mailContract(false); + expect(contract.deliveryWindowGameHours).toBeNull(); + for (const model of ["courier", "freighter", "bulk-hauler"]) { + expect(check(model, contract)).toEqual({ ok: true }); + } + }); +});