Step 15.5: sim: stranding and emergency fuel
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
The no-strand rule now asks whether the ship could leave the destination on a full tank where fuel is sold, or on what is left after the leg where it isn't. Emergency fuel is offered to an idle ship that can't reach anywhere when the player can't afford the shortfall: the cheapest leg's fuel at 2.5x the local price. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
276cdbe611
commit
63192f9129
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
|
|
@ -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<string> | 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<string> },
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ export {
|
|||
tripFuel,
|
||||
validateDispatch,
|
||||
wouldStrand,
|
||||
emergencyFuel,
|
||||
fuelAfterLeg,
|
||||
type EmergencyFuel,
|
||||
validateReposition,
|
||||
} from "./dispatch";
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue