From 60e29f7b1d443a0481c13470f4b1ef1863e8f5ae Mon Sep 17 00:00:00 2001 From: fireshaper Date: Mon, 5 Oct 2026 01:27:41 -0400 Subject: [PATCH] Step 10L.3: no-strand rule Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 8 ++ apps/server/src/game/dispatch.ts | 6 ++ apps/server/src/game/messages.ts | 2 + apps/web/src/screens/DispatchSheet.tsx | 1 + apps/web/src/screens/Fleet.tsx | 1 + memory-bank/architecture.md | 2 +- memory-bank/progress.md | 2 +- packages/sim/src/dispatch.ts | 49 +++++++++ packages/sim/src/index.ts | 1 + packages/sim/test/no-strand.test.ts | 134 +++++++++++++++++++++++++ 10 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 packages/sim/test/no-strand.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 29450a5..35d560f 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1140,3 +1140,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The factor applies to the whole leg from the origin** (decision 0.5.78): `checkRoute` is the single range check, so dispatch, reposition, the board's eligibility and the map pick it up together. The destination's factor plays no part in that leg. - **The dispatch sheet quotes range from the origin**, rounded, e.g. "reaches 130M km from Moon", so the number matches what the check used. + +## Step 10L.3 + +- **The check runs after range and landing, before Priority, skills, expiry and credits** (the CR says after range and landing). The arrival time is the trip's own travel time (cruise, overhead, handling for the contract's mass; empty for a deadhead). +- **"Other discovered location" includes the origin**, so a ship can always be allowed to fly back where it came from if that is still in range. +- **`discoveredIds` is optional on a contract proposal**; absent means every location counts. Tutorial runs and older callers keep working, and the client's eligibility (which doesn't pass it yet) is lenient until 10L.9; the server's check is the one that counts. +- **The rule only looks at arrival plus margin**, as the CR says. A ship may land somewhere it can't leave at once but can within the margin: with today's ranges a Clipper flying Moon→Mars can land on a date when it can't leave Mars right away but can 48 hours later. The exhaustive test checks the margin time, not the arrival. +- **Tests use a fixture** (a Moon with factor 0.5 and a "moon-station" sharing its orbit) because no real ship and date strands cleanly enough to also show "passes once another location is discovered". diff --git a/apps/server/src/game/dispatch.ts b/apps/server/src/game/dispatch.ts index f7dcccc..6bcd727 100644 --- a/apps/server/src/game/dispatch.ts +++ b/apps/server/src/game/dispatch.ts @@ -229,11 +229,17 @@ export async function dispatchContract( const pilot = await requireOwnPilot(tx, playerId, input.pilotId); const player = await requirePlayer(tx, playerId); const company = await requireCompany(tx, playerId); + const discovered = await tx + .select({ locationId: discoveredLocations.locationId }) + .from(discoveredLocations) + .where(eq(discoveredLocations.playerId, playerId)); const proposal = { ship: shipState(ship, await shipUpgradeLevels(tx, ship.id)), pilot: pilotState(pilot), contract, + // The no-strand rule counts only discovered locations (decision 0.5.79). + discoveredIds: new Set(discovered.map((d) => d.locationId)), tutorial: false, credits: company.credits, now, diff --git a/apps/server/src/game/messages.ts b/apps/server/src/game/messages.ts index d922969..a7d65c4 100644 --- a/apps/server/src/game/messages.ts +++ b/apps/server/src/game/messages.ts @@ -11,6 +11,8 @@ export const VALIDATION_MESSAGES: Record = { "over-capacity": "Too heavy", "cannot-land": "Can't land there", "out-of-range": "Out of range", + "would-strand": "Would be stranded", "too-slow": "Too slow for the Priority window", "cargo-skill-too-low": "Cargo skill too low", "long-haul-too-low": "Long Haul too low", diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index cf7fcee..142a105 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -187,6 +187,7 @@ function ShipRow(props: { const DEADHEAD_REASONS: Partial> = { "cannot-land": "Can't land there", "out-of-range": "Out of range", + "would-strand": "Would be stranded", "insufficient-credits": "Not enough credits for fuel", "ship-in-shop": "Ship is in the shop", "pilot-resting": "Pilot is resting", diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index c6b6ae0..624dd2c 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -38,7 +38,7 @@ Living reference for how the code is organised. Update it after any database sch ### Errors -Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...). +Every non-2xx response has the body `{ error: { code, message } }`. `code` is one of `ERROR_CODES`: every sim dispatch and reposition validation reason (`DISPATCH_FAILURES`, `REPOSITION_FAILURES`), unchanged (CR-007 adds `too-slow`: the ship can't make a Priority contract's delivery window; CR-012 adds `would-strand`: from the destination, at arrival plus `noStrandMarginGameHours`, the ship couldn't reach any other discovered location), plus the server codes in `SERVER_ERROR_CODES` (`validation-error`, `unauthorized`, `not-found`, `insufficient-funds`, ...). ## API routes (`packages/schema/src/routes.ts`) diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 0657655..d60d38a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -271,7 +271,7 @@ Spec: `memory-bank/change-requests/CR-012-lighter-clipper-range.md` (received as - [x] **Step 10L.1 — Content: range factors and new ships.** `Location.launchRangeFactor` (Earth 1, Moon 1.3, Mars 1.15, Ceres 1.3, Jupiter 1). `ShipModel.role` (all six) and optional `ShipModel.handlingGameHoursPerTonne` (the sim's effective stats now start from it). New models: **Lighter** (42,400 cr, 1.6M km/h, 80 t, range 10, overhead 2 h, handling 0.012 h/t, fuel 350/M km) and **Clipper** (61,600 cr, 6.5M km/h, 30 t, range 198, overhead 2 h, fuel 600/M km); archetypes `lighter`, `clipper`. Config `noStrandMarginGameHours` 48. Manifest lists `ship-lighter` / `ship-clipper` as pending art. Tests: the CR's orderings replace Step 2.3's (`ships.test.ts`), range factors (`locations.test.ts`), Lighter handling (`effective-stats.test.ts`). - [x] **Step 10L.2 — Sim: effective range from origin.** `rangeFrom(ship, from)` = range × origin's `launchRangeFactor`; `checkRoute` compares the leg against it (dispatch, reposition, board eligibility and the map all go through it). The dispatch sheet quotes range from the origin. Tests (`range-from.test.ts`): 100 → 130 from the Moon and 115 from Mars; a leg between 1× and 1.3× fails from Earth and passes from the Moon; with every factor 1 the result matches the old check over 16 years of samples. -- [ ] Step 10L.3 — Sim: no-strand rule +- [x] **Step 10L.3 — Sim: no-strand rule.** `wouldStrand(stats, origin, destination, mass, departure, discoveredIds, content)`: at arrival plus `noStrandMarginGameHours` (48), can the ship reach any other discovered location from the destination with its range from there? `validateDispatch` (new optional `discoveredIds`; absent = all) and `validateReposition` fail with `would-strand` after range and landing. The server passes the player's discovered ids for contract dispatches; server and web reason maps gained the code (full wording in 10L.6/10L.9). Tests (`no-strand.test.ts`): a fixture heavy-Moon hop is refused, then passes once a reachable location is discovered; the same for a contract; across all six models, every ordered pair and monthly samples over 16 years, an allowed trip always leaves somewhere to fly at arrival plus margin. - [ ] Step 10L.4 — Content tuning: reach table - [ ] Step 10L.5 — Upgrade caps for the new lineup - [ ] Step 10L.6 — Schema and server diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index 9cf7120..004b0c3 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -47,6 +47,11 @@ export interface DispatchProposal { tutorial: boolean; credits: number; now: RealTime; + /** + * Locations the player has discovered, for the no-strand rule (decision 0.5.79). + * Absent: every location counts. + */ + discoveredIds?: ReadonlySet; } /** Every reason `validateDispatch` can fail, in check order. */ @@ -60,6 +65,7 @@ export const DISPATCH_FAILURES = [ "over-capacity", "cannot-land", "out-of-range", + "would-strand", "too-slow", "cargo-skill-too-low", "long-haul-too-low", @@ -107,6 +113,32 @@ export function contractTripGameHours( return travelTimeGameHours(model, distance, contract.massTonnes, content.config); } +/** + * The no-strand rule (decision 0.5.79): true when, at arrival plus the configured + * margin, the ship could not fly from the destination to any other discovered + * location. `discoveredIds` absent means every location counts. + */ +export function wouldStrand( + model: EffectiveStats, + origin: Location, + destination: Location, + massTonnes: number, + departure: GameTime, + discoveredIds: ReadonlySet | undefined, + content: SimContent, +): boolean { + const { config } = content; + const distance = routeDistance(origin, destination, departure); + const tripHours = travelTimeGameHours(model, distance, massTonnes, config); + const at = departure + (tripHours + config.noStrandMarginGameHours) * MS_PER_HOUR; + return !content.locations.some( + (next) => + next.id !== destination.id && + (discoveredIds?.has(next.id) ?? true) && + checkRoute(model, destination, next, at).ok, + ); +} + /** 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; @@ -129,6 +161,19 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent const route = checkRoute(model, origin, destination, departure); if (!route.ok) return fail(route.reason); + if ( + wouldStrand( + model, + origin, + destination, + contract.massTonnes, + departure, + proposal.discoveredIds, + content, + ) + ) { + return fail("would-strand"); + } // Priority (decision 0.5.56): the trip must fit the delivery window. if ( @@ -252,6 +297,7 @@ export const REPOSITION_FAILURES = [ "location-undiscovered", "cannot-land", "out-of-range", + "would-strand", "insufficient-credits", ] as const; export type RepositionFailure = (typeof REPOSITION_FAILURES)[number]; @@ -284,6 +330,9 @@ export function validateReposition( const departure = realToGame(now, content.config); const route = checkRoute(model, origin, destination, departure); if (!route.ok) return failWith(route.reason); + if (wouldStrand(model, origin, destination, 0, departure, discoveredIds, content)) { + return failWith("would-strand"); + } if (credits < tripFuelCost(model, origin, destination, departure)) { return failWith("insufficient-credits"); diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 7c0f982..515bc80 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -44,6 +44,7 @@ export { shipStats, tripFuelCost, validateDispatch, + wouldStrand, validateReposition, } from "./dispatch"; export type { diff --git a/packages/sim/test/no-strand.test.ts b/packages/sim/test/no-strand.test.ts new file mode 100644 index 0000000..09c317e --- /dev/null +++ b/packages/sim/test/no-strand.test.ts @@ -0,0 +1,134 @@ +import { content, type Location, type ShipModel } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + checkRoute, + effectiveStats, + MS_PER_HOUR, + routeDistance, + travelTimeGameHours, + realToGame, + validateDispatch, + validateReposition, + wouldStrand, + type Contract, + type PilotState, + type RepositionProposal, + type ShipState, + type SimContent, +} from "../src/index"; + +const loc = (id: string) => content.location(id)!; +const now = Date.parse("2100-01-01T00:30:00Z"); +const pilot: PilotState = { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, + restEndsAt: null, +}; + +/** + * Fixture: a short-legged ship and a heavy Moon (factor 0.5), so the ship can + * reach the Moon from Earth but can't climb back out. "moon-station" shares the + * Moon's orbit, so it is always in reach from there. + */ +const heavyMoon: Location = { ...loc("moon"), launchRangeFactor: 0.5 }; +const station: Location = { ...heavyMoon, id: "moon-station", name: "Moon Station" }; +const fixture: SimContent = { + ...content, + locations: [...content.locations.filter((l) => l.id !== "moon"), heavyMoon, station], + shipModels: [ + ...content.shipModels, + { ...content.shipModel("courier")!, id: "hopper", fuelRangeMillionKm: 0.45 }, + ], +}; +const hopper: ShipState = { id: "s1", modelId: "hopper", locationId: "earth", inFlight: false }; +const proposal: RepositionProposal = { + ship: hopper, + pilot, + destinationId: "moon", + discoveredIds: new Set(["earth", "moon"]), + credits: 100_000, + now, +}; + +const contract: Contract = { + id: "c1", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + goodId: "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + 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, +}; + +describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => { + it("rejects a deadhead that would leave the ship unable to fly anywhere", () => { + expect(validateReposition(proposal, fixture)).toEqual({ ok: false, reason: "would-strand" }); + }); + + it("passes the same trip once a reachable location is discovered", () => { + const discoveredIds = new Set(["earth", "moon", "moon-station"]); + expect(validateReposition({ ...proposal, discoveredIds }, fixture)).toEqual({ ok: true }); + }); + + it("applies to contract dispatches too", () => { + const base = { ship: hopper, pilot, contract, tutorial: false, credits: 100_000, now }; + expect( + validateDispatch({ ...base, discoveredIds: new Set(["earth", "moon"]) }, fixture), + ).toEqual({ ok: false, reason: "would-strand" }); + expect( + validateDispatch( + { ...base, discoveredIds: new Set(["earth", "moon", "moon-station"]) }, + fixture, + ), + ).toEqual({ ok: true }); + }); + + it("never strands a base ship on a trip it is allowed to take between the five locations", () => { + const start = realToGame(Date.parse(content.config.epoch.realStart), content.config); + const month = 30.44 * 24 * 3_600_000; + const ids = content.locations.map((l) => l.id); + const canLeave = (model: ShipModel, at: Location, t: number) => + content.locations.some((next) => next.id !== at.id && checkRoute(model, at, next, t).ok); + for (const base of content.shipModels) { + const model = effectiveStats(base, {}, content); + for (const from of ids) { + for (const to of ids) { + if (from === to) continue; + for (let i = 0; i <= 16 * 12; i++) { + const t = start + i * month; + if (!checkRoute(model, loc(from), loc(to), t).ok) continue; + if (wouldStrand(model, loc(from), loc(to), 0, t, undefined, content)) continue; + const hours = travelTimeGameHours( + model, + routeDistance(loc(from), loc(to), t), + 0, + content.config, + ); + const arrival = t + hours * MS_PER_HOUR; + const label = `${model.id} ${from}→${to} month ${i}`; + expect( + canLeave( + model, + loc(to), + arrival + content.config.noStrandMarginGameHours * MS_PER_HOUR, + ), + label, + ).toBe(true); + } + } + } + } + }); +});