diff --git a/DECISIONS.md b/DECISIONS.md index 4113d34..242c576 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1469,3 +1469,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **`credits` left the dispatch and deadhead proposals**: nothing in validation depends on cash any more, so keeping the field would have been misleading. `insufficient-credits` is replaced by `not-enough-fuel` in both failure lists. - **The migration fills tanks at the price where each ship sits** (the base price while in flight), so the first legs after an update cost what fuel there costs. - **The balance simulator models the player's "Refuel here first"**: before choosing work, an idle ship tops up where it is as far as credits go. The no-strand and soft-lock checks now ask "with a full tank" and "with the fuel on board plus what the credits buy" instead of "with any credits". + +## Step 15.5 + +- **"The fuel it could have there" is a full tank wherever fuel is sold**, regardless of the player's credits: the no-strand rule is about whether a ship can ever leave, and running short of cash is what emergency fuel is for. +- **Emergency fuel covers the cheapest leg the ship could fly on a full tank**, among discovered places it can land. A leg no tank could fly doesn't count, so a ship that can't leave even when full gets no offer (the no-strand rule prevents that state). +- **"Can't afford" means the credits don't cover the shortfall at the local price**, rounded up to whole credits; at a location that sells no fuel the player can't buy at all, so emergency fuel is offered whenever the tank is short. diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts index be9ecf9..abef0e5 100644 --- a/apps/web/e2e/support.ts +++ b/apps/web/e2e/support.ts @@ -111,7 +111,7 @@ export function nextMarsWindow(from: number): number { for (let t = from; t < from + 1_500 * gameDay; t += gameDay) { if ( checkRoute(clipper, earth, mars, content.config).ok && - !wouldStrand(clipper, mars, new Set(["earth", "moon", "mars"]), content) + !wouldStrand(clipper, mars, 0, new Set(["earth", "moon", "mars"]), content) ) { // Well inside the window: a few game days in. return t + 3 * gameDay; diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 9ea4e0c..2916da6 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -111,7 +111,13 @@ export function explainProblems( : INELIGIBLE_REASONS["cannot-land"], ); } else if ( - wouldStrand(model, location(contract.destinationId), discoveredIdsOf(state), content) + wouldStrand( + model, + location(contract.destinationId), + fuel.onBoardFu - fuel.neededFu, + discoveredIdsOf(state), + content, + ) ) { problems.push(strandedText(contract.destinationId)); } diff --git a/apps/web/test/stranding-reason.test.tsx b/apps/web/test/stranding-reason.test.tsx index 15aa3f4..72dad6b 100644 --- a/apps/web/test/stranding-reason.test.tsx +++ b/apps/web/test/stranding-reason.test.tsx @@ -19,7 +19,7 @@ const STRANDED = "Would be stranded — can't fly out of Jupiter Cloud Platform // Jupiter (667 × 1.0) neither Earth nor the Moon is, and Mars and Ceres aren't discovered. if ( !checkRoute(freighter, moon, jupiter, content.config).ok || - !wouldStrand(freighter, jupiter, discovered, content) + !wouldStrand(freighter, jupiter, 0, discovered, content) ) { throw new Error("the scenario no longer strands"); } diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 79b9292..86d32d9 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any save format | Package | Role | Depends on | |---|---|---| | `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod | -| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. Positions are fixed (CR-015): `locationPosition` reads the location's `position` from content. Fuel (`fuel.ts`, CR-015): `legFuel` = burn × economic distance(distance ÷ origin factor), `fullTankReach` (its inverse for a full tank), `fuelPriceAt`; `checkRoute(ship, from, to, config)` compares the distance with full-tank reach. Tanks (Step 15.4): `ShipState.tank` (`fuelFu`, `avgPrice`), `refuel`, `affordableRefuel`, `legFuelCost`; validation fails with `not-enough-fuel`; records carry `fuelBurnedFu` and `fuelCost` (burned × average price); `resolveDispatch` takes an optional `fuel` block and returns `topUp` (Auto top-up on landing). | content | +| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. Positions are fixed (CR-015): `locationPosition` reads the location's `position` from content. Fuel (`fuel.ts`, CR-015): `legFuel` = burn × economic distance(distance ÷ origin factor), `fullTankReach` (its inverse for a full tank), `fuelPriceAt`; `checkRoute(ship, from, to, config)` compares the distance with full-tank reach. Tanks (Step 15.4): `ShipState.tank` (`fuelFu`, `avgPrice`), `refuel`, `affordableRefuel`, `legFuelCost`; validation fails with `not-enough-fuel`; records carry `fuelBurnedFu` and `fuelCost` (burned × average price); `resolveDispatch` takes an optional `fuel` block and returns `topUp` (Auto top-up on landing). `wouldStrand(model, destination, fuelOnArrivalFu, discovered, content)` uses a full tank where the destination sells fuel, else the fuel left (Step 15.5); `emergencyFuel` gives the offer (amount, price, cost) or null. | content | | `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `notifications.ts`: the schedule is recomputed from the save after every saved change and replaces the platform's (`NotificationScheduler`: Capacitor on native, no-op on the web). `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim | | `packages/schema` | Zod schemas for every API request, response and error. Shared by the engine and the client. | sim (error codes), zod | | `apps/web` | React client; runs the engine on the device (CR-014). | content, engine, sim, schema | diff --git a/memory-bank/progress.md b/memory-bank/progress.md index cf3134a..8abe4b3 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -374,7 +374,7 @@ Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as - [x] **Step 15.2 — Sim: static positions and distances.** No time arguments left where a time only fed a position: `locationPosition(location)`, `routeDistance(from, to)`, `checkRoute(ship, from, to)`, `tripFuelCost(model, origin, destination, config)`, `contractTripGameHours(model, contract, content)`, `priorityWindow(origin, destination, mass, content)`, and `wouldStrand(model, destination, discoveredIds, content)` (it had taken cargo mass and departure only to find where planets would be on arrival). `dispatchPosition` runs along the fixed line. Board generation, records and validation no longer convert real to game time for distance. GameConfig's `noStrandMarginGameHours` is gone (nothing moves in 48 game hours now). Web: `distanceAt`, `tripRealMs`, `locationMapPoint`, `reachersByOrigin`, `tooSlowForAll` and `shipReach` lose their time argument; `shipReach` checks each leg once instead of sampling 16 game years (its `otherDates` is always empty and goes with its wording in 15.8). Callers were updated with a small argument-dropping codemod, then the unused time variables removed. Tests: `distance.test.ts` per the CR (Earth→Moon under 1M km, symmetric, same value for the same inputs; the "varies" test is gone), `positions.test.ts` ("never moves"), new `static-routes.test.ts` (an Earth → Ceres contract pays the same in any board period; a Freighter's trip takes the same time, distance and fuel whenever it departs); `no-strand.test.ts` now checks that no base ship is ever blocked on a leg it can fly; `range-from` and `upgrade-caps` lose their date loops. - [x] **Step 15.3 — Content: fuel.** ShipModel: `fuelRangeMillionKm` and `fuelCostPerMillionKm` replaced by `tankCapacityFu` and `burnFuPerMillionKm` (Courier 40/20, Lighter 110/35, Clipper 1,170/45, Freighter 1,540/40, Bulk Hauler 5,900/80, loaner 40/20). Location: `launchCostMultiplier` replaced by `fuelPrice` (× base: Jupiter 0.6, Ceres 0.9, Moon 1.1, Mars 1.4, Earth 1.6) and `sellsFuel` (all true). GameConfig: `baseFuelPricePerFu` 10, `emergencyFuelMultiplier` 2.5. Extended Fuel Tanks raises `tankCapacityFu`: 11 %/level by default, Courier 380 %, Freighter 9 %. Sim (`fuel.ts`): `legFuel` (burn × economic distance of distance ÷ origin factor, decision 0.5.95 as the user decided), `plainDistance` (the curve's inverse), `fullTankReach` (factor × plainDistance(tank ÷ burn)), `fuelPriceAt`; `rangeFrom` and `checkRoute` now take the config and use full-tank reach. Until Step 15.4 adds tanks to ships, a trip is still charged at departure: its fuel at the origin's price. API `stats` carry `tankCapacityFu` and `burnFuPerMillionKm` instead of range. Web: the Market's "Range" is now "Reach" (a full tank from Earth), the upgrade screen shows "Tank" in fu. Balance re-check passes: first purchase 64 min, discovery 64, loan 156, four ships 151 vs 158. Tests: `locations.test.ts` (fuel prices ordered Jupiter < Ceres < Moon < Mars < Earth; every base location sells fuel); `fuel.test.ts` (leg fuel, the curve's inverse, full-tank reach burns exactly a tank and scales by the origin factor, the interim cost by hand); the §4 table (`reach-table.test.ts`) now runs on full-tank reach; `courier-mars`, `upgrade-caps` and the content distinctness test in tank form; `ships.test.ts` (the Clipper burns most per M km). Also removed four empty `import {}` lines left by Step 15.2's cleanup. - [x] **Step 15.4 — Sim: tank, burn, and refuel.** Sim (`fuel.ts`): `Tank` (fuel and weighted average price), `refuel` (up to capacity, new average, whole-credit cost), `affordableRefuel` (the most the credits cover, never over), `legFuelCost` (burned × average price). Ships carry `tank` in `ShipState`; dispatch and deadhead validation fail with `not-enough-fuel` (replacing `insufficient-credits`) when fuel on board is short; proposals no longer take credits. Records gain `fuelBurnedFu`; `fuelCost` is that fuel at the tank's average price (pilot pay uses it, decision 0.5.98); tutorial runs burn none. `tripFuelCost` became `tripFuel` (fu). Resolution takes an optional `fuel` block (tank, capacity, policy, credits) and returns `topUp` (Auto: the most the credits after this delivery cover, at the destination's price; Manual or no tank: none). Pulled forward from 15.6 so the game keeps working: saved ships have `fuelFu`, `fuelAvgPrice`, `refuelPolicy`; save version 2 with the 1 → 2 migration (every ship full at the price where it is, the base price in flight, Auto; old records burned 0 fu, old resolutions no top-up); the engine takes fuel from the tank at departure (no credits) and applies the top-up on landing; Market ships arrive full at the bay's price; the deal's Courier and Clipper come with a quarter tank (`config.deal.tankShare`, user decision: with full tanks the Clipper left for Mars at once and the first purchase moved to 79 min); the API ship carries the fuel fields and summaries carry `topUp`. Web: the sim sees each ship's tank, the dispatch sheet and deadhead picker cost fuel at the average price and say "Not enough fuel (needs N fu, has M fu)". The balance simulator flies tanks too (Auto top-up, refuel before choosing work); results: first purchase 64 min, discovery 49, loan 136, four ships 136 vs 136. Tests: `packages/sim/test/tank.test.ts` (Moon leg = Earth amount at distance ÷ 1.3; 50 fu at 1.0 into 50 fu at 2.0 averages 1.5; never past capacity; affordable refuel exact; record burn and cost; pilot pay from burned × average; Auto top-up to full at the local price; Manual buys nothing; half the credits buys half; the tutorial loaner untouched); `dispatch.test.ts` (`not-enough-fuel`, passes after refuelling); `packages/schema/test/save.test.ts` (1 → 2 migration: tanks full at the right price, Auto, nothing else changed); `packages/engine/test/fuel.test.ts` (Market ship full; deal ships a quarter; burn then Auto top-up with the exact credits; Manual leaves the tank). Updated tests that expected fuel in credits (contract dispatch, deadhead, concurrency, browser 12.4); browser 12.5 fills the Clipper with an Earth–Moon round trip before its Mars run. -- [ ] Step 15.5 — Sim: stranding and emergency fuel +- [x] **Step 15.5 — Sim: stranding and emergency fuel.** `wouldStrand(model, destination, fuelOnArrivalFu, discoveredIds, content)` (decision 0.5.79 as amended): from the destination, some other discovered location must be within reach on a full tank where fuel is sold, otherwise on what is left after the leg (`fuelAfterLeg`). Dispatch and deadhead validation pass the fuel left; the dispatch sheet's explanation does too. `emergencyFuel({ ship, credits, discoveredIds })` (decision 0.5.99) returns `{ amountFu, price, cost }` or null: offered only for an idle ship whose fuel can't reach any other discovered location and whose company can't afford the shortfall for the cheapest leg it could fly on a full tank; the amount is that shortfall, the price 2.5 × local. Tests: `no-strand.test.ts` (no base ship is blocked on any leg it can fly between the five locations; with a fixture Mars that sells no fuel, a full-tank Clipper Earth → Mars is refused as `would-strand`, the same leg passes where Mars sells fuel, and fuel to spare after the leg passes); `emergency-fuel.test.ts` (offered when stuck and broke, with the exact amount, price and cost; not when the tank reaches somewhere, not when the credits cover the shortfall (one credit short does offer it), not in flight; only the cheapest leg's fuel when there are several ways out). - [ ] Step 15.6 — Engine: commands and save migration - [ ] Step 15.7 — Client: fuel in the UI - [ ] Step 15.8 — Client: fixed map diff --git a/packages/engine/test/ported/lighter-clipper.test.ts b/packages/engine/test/ported/lighter-clipper.test.ts index 5d75d6d..b38f5af 100644 --- a/packages/engine/test/ported/lighter-clipper.test.ts +++ b/packages/engine/test/ported/lighter-clipper.test.ts @@ -56,7 +56,7 @@ describe("Lighter and Clipper (Step 10L.6)", () => { ]; expect(checkRoute(freighter, moon, jupiter, content.config).ok).toBe(true); const discovered = new Set(["earth", "moon", "jupiter-cloud-platform"]); - expect(wouldStrand(freighter, jupiter, discovered, content)).toBe(true); + expect(wouldStrand(freighter, jupiter, 0, discovered, content)).toBe(true); const before = await getState(app, owner); const response = await call(app, owner, "POST", "/api/dispatches", { diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index d99659b..d167c8c 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -4,8 +4,8 @@ import { MS_PER_HOUR, type RealTime } from "./clock"; import type { Contract } from "./contract"; import { routeDistance } from "./distance"; import { isResting } from "./duty"; -import { checkRoute } from "./eligibility"; -import { legFuel, legFuelCost, type Tank } from "./fuel"; +import { canLandAt, checkRoute } from "./eligibility"; +import { fuelPriceAt, legFuel, legFuelCost, type Tank } from "./fuel"; import { checkPilotEligibility, type SkillLevels } from "./pilot"; import { deriveSeed, type Seed } from "./rng"; import { travelTimeGameHours } from "./travel"; @@ -125,25 +125,77 @@ export function contractTripGameHours( } /** - * The no-strand rule (decision 0.5.79): true when the ship could not fly from - * the destination to any other discovered location. `discoveredIds` absent - * means every location counts. The map is fixed (0.5.93), so arrival time - * doesn't matter. + * The no-strand rule (decision 0.5.79 as amended by CR-015): true when, from + * the destination, the ship couldn't fly to any other discovered location with + * the fuel it could have there: a full tank where fuel is sold, otherwise what + * is left after the leg. `discoveredIds` absent means every location counts. */ export function wouldStrand( model: ShipModel, destination: Location, + fuelOnArrivalFu: number, discoveredIds: ReadonlySet | undefined, content: SimContent, ): boolean { + const available = destination.sellsFuel ? model.tankCapacityFu : fuelOnArrivalFu; return !content.locations.some( (next) => next.id !== destination.id && (discoveredIds?.has(next.id) ?? true) && - checkRoute(model, destination, next, content.config).ok, + canLandAt(model, next) && + tripFuel(model, destination, next, content.config) <= available, ); } +/** Fuel left in the tank after flying origin → destination (may be negative: not enough). */ +export function fuelAfterLeg( + ship: ShipState, + model: ShipModel, + origin: Location, + destination: Location, + config: GameConfig, +): number { + return (ship.tank?.fuelFu ?? 0) - tripFuel(model, origin, destination, config); +} + +/** Emergency fuel on offer (decision 0.5.99): how much, at what price, added to the loan. */ +export interface EmergencyFuel { + amountFu: number; + /** Credits per fu: the local price × the emergency multiplier. */ + price: number; + /** Whole credits, added to the loan balance. */ + cost: number; +} + +/** + * Emergency fuel (decision 0.5.99): offered when an idle ship's tank can't reach + * any other discovered location and the player can't afford the fuel for the + * cheapest leg it could fly on a full tank. It is exactly that shortfall, at the + * local price × the emergency multiplier. Null when not on offer. + */ +export function emergencyFuel( + input: { ship: ShipState; credits: number; discoveredIds: ReadonlySet }, + content: SimContent, +): EmergencyFuel | null { + const { ship, credits, discoveredIds } = input; + if (ship.inFlight) return null; + const model = shipStats(ship, content); + const here = findLocation(ship.locationId, content); + const onBoard = ship.tank?.fuelFu ?? 0; + const legs = content.locations + .filter((next) => next.id !== here.id && discoveredIds.has(next.id) && canLandAt(model, next)) + .map((next) => tripFuel(model, here, next, content.config)) + .filter((fuel) => fuel <= model.tankCapacityFu); + if (legs.length === 0) return null; + const cheapest = Math.min(...legs); + if (cheapest <= onBoard) return null; + const amountFu = cheapest - onBoard; + const localPrice = fuelPriceAt(here, content.config); + if (here.sellsFuel && credits >= Math.ceil(amountFu * localPrice)) return null; + const price = localPrice * content.config.emergencyFuelMultiplier; + return { amountFu, price, cost: Math.round(amountFu * price) }; +} + /** Validates a proposed contract dispatch, returning the first failing reason. */ export function validateDispatch(proposal: DispatchProposal, content: SimContent): DispatchCheck { const { ship, pilot, contract, tutorial, now } = proposal; @@ -165,7 +217,8 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent const route = checkRoute(model, origin, destination, content.config); if (!route.ok) return fail(route.reason); - if (wouldStrand(model, destination, proposal.discoveredIds, content)) { + const leftFu = fuelAfterLeg(ship, model, origin, destination, content.config); + if (wouldStrand(model, destination, leftFu, proposal.discoveredIds, content)) { return fail("would-strand"); } @@ -324,7 +377,8 @@ export function validateReposition( const destination = findLocation(destinationId, content); const route = checkRoute(model, origin, destination, content.config); if (!route.ok) return failWith(route.reason); - if (wouldStrand(model, destination, discoveredIds, content)) { + const leftFu = fuelAfterLeg(ship, model, origin, destination, content.config); + if (wouldStrand(model, destination, leftFu, discoveredIds, content)) { return failWith("would-strand"); } diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index 9e824f9..cbb2f3e 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -56,6 +56,9 @@ export { tripFuel, validateDispatch, wouldStrand, + emergencyFuel, + fuelAfterLeg, + type EmergencyFuel, validateReposition, } from "./dispatch"; export type { diff --git a/packages/sim/test/emergency-fuel.test.ts b/packages/sim/test/emergency-fuel.test.ts new file mode 100644 index 0000000..faaa6e9 --- /dev/null +++ b/packages/sim/test/emergency-fuel.test.ts @@ -0,0 +1,66 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { emergencyFuel, fuelPriceAt, tripFuel, type ShipState } from "../src/index"; + +const { config } = content; +const courier = content.shipModel("courier")!; +const [earth, moon] = [content.location("earth")!, content.location("moon")!]; +const discoveredIds = new Set(["earth", "moon"]); +/** A Courier at the Moon: its only discovered way out is Earth. */ +const legToEarth = tripFuel(courier, moon, earth, config); +const moonPrice = fuelPriceAt(moon, config); +const atMoon = (fuelFu: number, inFlight = false): ShipState => ({ + id: "s1", + modelId: "courier", + locationId: "moon", + inFlight, + tank: { fuelFu, avgPrice: moonPrice }, +}); + +describe("emergency fuel (Step 15.5, decision 0.5.99)", () => { + it("is offered when the tank can't reach anywhere and the credits can't buy the shortfall", () => { + const offer = emergencyFuel({ ship: atMoon(1), credits: 0, discoveredIds }, content); + expect(offer).not.toBeNull(); + // Exactly the cheapest leg's fuel minus what is on board, at 2.5 × the local price. + expect(offer!.amountFu).toBeCloseTo(legToEarth - 1); + expect(offer!.price).toBeCloseTo(moonPrice * 2.5); + expect(offer!.cost).toBe(Math.round((legToEarth - 1) * moonPrice * 2.5)); + }); + + it("is not offered when the tank can already reach somewhere", () => { + expect( + emergencyFuel({ ship: atMoon(legToEarth), credits: 0, discoveredIds }, content), + ).toBeNull(); + }); + + it("is not offered when the player can afford the fuel", () => { + const shortfallCost = Math.ceil((legToEarth - 1) * moonPrice); + expect( + emergencyFuel({ ship: atMoon(1), credits: shortfallCost, discoveredIds }, content), + ).toBeNull(); + expect( + emergencyFuel({ ship: atMoon(1), credits: shortfallCost - 1, discoveredIds }, content), + ).not.toBeNull(); + }); + + it("is not offered in flight", () => { + expect(emergencyFuel({ ship: atMoon(1, true), credits: 0, discoveredIds }, content)).toBeNull(); + }); + + it("covers only the cheapest leg when there are several ways out", () => { + // From Earth, the Moon is the cheapest leg for a Clipper (Mars costs far more). + const clipper = content.shipModel("clipper")!; + const ship: ShipState = { + id: "s2", + modelId: "clipper", + locationId: "earth", + inFlight: false, + tank: { fuelFu: 0, avgPrice: 0 }, + }; + const offer = emergencyFuel( + { ship, credits: 0, discoveredIds: new Set(["earth", "moon", "mars"]) }, + content, + ); + expect(offer!.amountFu).toBeCloseTo(tripFuel(clipper, earth, moon, config)); + }); +}); diff --git a/packages/sim/test/no-strand.test.ts b/packages/sim/test/no-strand.test.ts index cbb3bcc..b7e7446 100644 --- a/packages/sim/test/no-strand.test.ts +++ b/packages/sim/test/no-strand.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { checkRoute, economicDistance, + tripFuel, effectiveStats, validateDispatch, validateReposition, @@ -106,18 +107,61 @@ describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => { ).toEqual({ ok: true }); }); - it("never blocks a base ship on a leg it can fly between the five locations (fixed map)", () => { + it("never blocks a base ship on a leg it can fly between the five locations (Step 15.5)", () => { + // Every base location sells fuel, so a full tank is always on offer at the destination. const ids = content.locations.map((l) => l.id); for (const base of content.shipModels) { const model = effectiveStats(base, {}, content); for (const from of ids) { for (const to of ids) { if (from === to || !checkRoute(model, loc(from), loc(to), content.config).ok) continue; - expect(wouldStrand(model, loc(to), undefined, content), `${model.id} ${from}→${to}`).toBe( - false, - ); + expect( + wouldStrand(model, loc(to), 0, undefined, content), + `${model.id} ${from}→${to}`, + ).toBe(false); } } } }); }); + +describe("no-strand rule with a dry destination (Step 15.5, CR-015 §3)", () => { + // Fixture: Mars sells no fuel, so a Clipper must leave Earth with enough to get out again. + const dryMars: Location = { ...loc("mars"), sellsFuel: false }; + const dry: SimContent = { + ...content, + locations: [...content.locations.filter((l) => l.id !== "mars"), dryMars], + }; + const clipper = effectiveStats(content.shipModel("clipper")!, {}, content); + const discoveredIds = new Set(["earth", "moon", "mars"]); + const ship = (fuelFu: number): ShipState => ({ + id: "s1", + modelId: "clipper", + locationId: "earth", + inFlight: false, + tank: { fuelFu, avgPrice: 16 }, + }); + const deadhead = (fuelFu: number, at: SimContent) => + validateReposition( + { ship: ship(fuelFu), pilot, destinationId: "mars", discoveredIds, now }, + at, + ); + + it("blocks a leg that would leave too little fuel to get out", () => { + // A full tank flies Earth → Mars but leaves too little to leave Mars. + expect(deadhead(clipper.tankCapacityFu, dry)).toEqual({ ok: false, reason: "would-strand" }); + }); + + it("allows the same leg where the destination sells fuel", () => { + expect(deadhead(clipper.tankCapacityFu, content)).toEqual({ ok: true }); + }); + + it("allows it with fuel to spare after the leg, as an upgraded tank would have", () => { + const toMars = tripFuel(clipper, loc("earth"), dryMars, content.config); + const outOfMars = tripFuel(clipper, dryMars, loc("earth"), content.config); + expect(wouldStrand(clipper, dryMars, outOfMars, discoveredIds, dry)).toBe(false); + expect(wouldStrand(clipper, dryMars, clipper.tankCapacityFu - toMars, discoveredIds, dry)).toBe( + true, + ); + }); +});