diff --git a/DECISIONS.md b/DECISIONS.md index f6acdb8..4113d34 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1459,3 +1459,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **`checkRoute` and `rangeFrom` take the config**, since reach depends on the economic-distance curve; every caller already had the content bundle at hand. - **A trip is still charged at departure in this step**, now its leg fuel at the origin's price: the tank state arrives in 15.4. The balance targets all pass with it. - **The Market shows "Reach" (a full tank from Earth)** instead of "Range"; the upgrade screen shows the tank in fu. The fuel gauge and prices come in 15.7. + +## Step 15.4 + +- **The engine's save fields and migration came in this step, not 15.6**: once the sim requires fuel on board, an engine without tanks can't dispatch anything. 15.6 keeps the refuel, policy and emergency fuel commands and their tests. +- **Deal ships come with a quarter tank** (user decision; `config.deal.tankShare` 0.25). With full tanks the deal's Clipper flew to Mars at once and landed at minute 36; Market purchases happen when it lands, so the first one moved to 79 min, outside 45–75. A quarter tank leaves the Clipper earning locally until the player can fuel a Mars run, as before tanks: first purchase 64, discovery 49, loan 136, four ships 136 vs 136. Market ships still arrive full (CR-015 §3). +- **Auto top-up spends the credits after this delivery's pay**: land, get paid, refuel. `affordableRefuel` never charges more than the credits on hand, rounding included. +- **Costs are whole credits; fuel amounts are fractional fu**: a leg burns what the curve says, and the UI rounds fu for display. +- **`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". diff --git a/apps/web/e2e/12-4-offline-progress.e2e.ts b/apps/web/e2e/12-4-offline-progress.e2e.ts index 417df6b..935dbac 100644 --- a/apps/web/e2e/12-4-offline-progress.e2e.ts +++ b/apps/web/e2e/12-4-offline-progress.e2e.ts @@ -28,8 +28,11 @@ test.describe("end to end (Phase 12)", () => { const summary = (await state(later)).summaries.find((s) => s.dispatchId === dispatchId)!; await expect(logbook(later)).toContainText(`Delivered ${summary.massTonnes} t`); const after = await state(later); - // Credits went up by the delivery's net payout; the ship is at the destination. - expect(after.company.credits).toBe(before.company.credits + summary.credits); + // Credits went up by the delivery's net payout, less any Auto top-up on landing + // (CR-015); the ship is at the destination. + expect(after.company.credits).toBe( + before.company.credits + summary.credits - (summary.topUp?.cost ?? 0), + ); expect(after.ships.find((s) => s.id === flight.shipId)!.locationId).toBe(flight.destinationId); }); }); diff --git a/apps/web/e2e/12-5-discovery.e2e.ts b/apps/web/e2e/12-5-discovery.e2e.ts index c5d9f24..111f52a 100644 --- a/apps/web/e2e/12-5-discovery.e2e.ts +++ b/apps/web/e2e/12-5-discovery.e2e.ts @@ -1,5 +1,7 @@ import { expect, test } from "@playwright/test"; +import type { Dispatch } from "@deadhead/schema"; import { + acknowledgeAll, addCredits, advanceTo, board, @@ -7,6 +9,7 @@ import { logbook, nextMarsWindow, playToDeal, + run, serverNow, state, tryDispatch, @@ -29,6 +32,19 @@ test.describe("end to end (Phase 12)", () => { } await addCredits(page, 100_000); + // The deal's Clipper comes with a quarter tank (CR-015): an Earth–Moon round + // trip on Auto top-up lands it back at Earth full. + for (const destinationId of ["moon", "earth"]) { + const { dispatch } = await run<{ dispatch: Dispatch }>(page, "createDispatch", { + kind: "reposition", + shipId: clipper.id, + pilotId: veteran.id, + destinationId, + }); + await advanceTo(page, dispatch.arrivalAt); + } + await acknowledgeAll(page); + // The board offers a first contact the Clipper can fly (decision 0.5.85). const offer = (await board(page, "earth")).contracts.find( (c) => c.firstContact && c.destinationId === "mars", diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index bcc7ad8..97aa17c 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -59,6 +59,7 @@ export function simShip(ship: Ship): ShipState { inFlight: ship.status === "in-flight", upgrades: ship.upgrades, inShopUntil: ship.inShopUntil, + tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, }; } diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index a17c8c1..9ea4e0c 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -10,7 +10,7 @@ import { LONG_HAUL_SKILL_ID, needsRest, realToGame, - tripFuelCost, + tripFuel, tripGameHours, validateDispatch, wouldStrand, @@ -58,7 +58,7 @@ export const INELIGIBLE_REASONS: Record = { "cargo-skill-too-low": "Cargo skill too low", "long-haul-too-low": "Long Haul too low", "contract-expired": "Contract expired", - "insufficient-credits": "Not enough credits for fuel", + "not-enough-fuel": "Not enough fuel", }; /** The no-strand rule in words (CR-012), for the dispatch sheet and deadhead picker. */ @@ -82,7 +82,8 @@ export function explainProblems( contract: Contract, model: ShipModel, pilot: Pilot, - fuelCost: number, + /** The leg's fuel and what the ship has on board, in fu (CR-015). */ + fuel: { neededFu: number; onBoardFu: number }, gameNow: number, now: number, ): string[] { @@ -137,8 +138,10 @@ export function explainProblems( problems.push(`Needs Long Haul ${contract.requiredLongHaulLevel} (pilot has ${longHaul})`); } if (now >= contract.expiresAt) problems.push(INELIGIBLE_REASONS["contract-expired"]); - if (state.company.credits < fuelCost) { - problems.push(`Not enough credits for fuel (${formatCredits(fuelCost)})`); + if (fuel.onBoardFu < fuel.neededFu) { + problems.push( + `Not enough fuel (needs ${Math.ceil(fuel.neededFu)} fu, has ${Math.floor(fuel.onBoardFu)} fu)`, + ); } return problems; } @@ -192,12 +195,14 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) contract.destinationId, contract.massTonnes, ); - const fuelCost = tripFuelCost( + const fuelFu = tripFuel( model, location(contract.originId), location(contract.destinationId), content.config, ); + // The fuel used, at the tank's average price (decision 0.5.98). + const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice); // Profit-share pay for a hired pilot; the avatar is exempt (CR-010). const pilotPay = pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0; @@ -217,7 +222,6 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) }, contract, tutorial: false, - credits: state.company.credits, now, discoveredIds: discoveredIdsOf(state), }, @@ -227,7 +231,15 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) if (check.ok) { problems = []; } else { - const all = explainProblems(state, contract, model, pilot, fuelCost, gameNow, now); + const all = explainProblems( + state, + contract, + model, + pilot, + { neededFu: fuelFu, onBoardFu: ship.fuelFu }, + gameNow, + now, + ); // In the shop (CR-009): first, with the time left. if (isInShop(ship, now)) all.unshift( diff --git a/apps/web/src/screens/Fleet.tsx b/apps/web/src/screens/Fleet.tsx index 348a557..2a68733 100644 --- a/apps/web/src/screens/Fleet.tsx +++ b/apps/web/src/screens/Fleet.tsx @@ -2,7 +2,7 @@ import { content } from "@deadhead/content"; import type { Dispatch, Ship } from "@deadhead/schema"; import { dispatchProgress, - tripFuelCost, + tripFuel, validateReposition, type RepositionFailure, } from "@deadhead/sim"; @@ -188,7 +188,7 @@ 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", + "not-enough-fuel": "Not enough fuel", "ship-in-shop": "Ship is in the shop", "pilot-resting": "Pilot is resting", "pilot-not-idle": "Pilot is flying", @@ -226,7 +226,6 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v }, destinationId: id, discoveredIds, - credits: state.company.credits, now, }, content, @@ -238,7 +237,10 @@ export function DeadheadPicker({ ship, onClose }: { ship: Ship; onClose: () => v : check.reason === "would-strand" ? strandedText(id) : (DEADHEAD_REASONS[check.reason] ?? check.reason), - fuelCost: tripFuelCost(model, location(origin), location(id), content.config), + // The fuel used, at the tank's average price (decision 0.5.98). + fuelCost: Math.round( + tripFuel(model, location(origin), location(id), content.config) * ship.fuelAvgPrice, + ), tripMs: tripRealMs(model, origin, id, 0), }; }); diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index 0244f38..29f7fdd 100644 --- a/apps/web/test/about.test.tsx +++ b/apps/web/test/about.test.tsx @@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => { expect(text).toContain("Build: test"); // CR-014: the save, its format version and where it is stored; no player id. expect(text).toContain("Save: player-1"); - expect(text).toContain("Save version: 1"); + expect(text).toContain("Save version: 2"); expect(text).toContain("Storage: unknown"); expect(text).not.toContain("Player:"); expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); diff --git a/apps/web/test/away-goals.test.tsx b/apps/web/test/away-goals.test.tsx index 43e9145..dd64d38 100644 --- a/apps/web/test/away-goals.test.tsx +++ b/apps/web/test/away-goals.test.tsx @@ -35,6 +35,7 @@ function returningPlayer(acknowledged: boolean): PlayerState { credits: 538, deadhead: false, fuelCost: 0, + topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx index 69a1dc0..b9f725f 100644 --- a/apps/web/test/away-summary.test.tsx +++ b/apps/web/test/away-summary.test.tsx @@ -27,6 +27,7 @@ const summaries: ResolutionSummary[] = [ credits: 51_200, deadhead: false, fuelCost: 0, + topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", @@ -48,6 +49,7 @@ const summaries: ResolutionSummary[] = [ credits: 240, deadhead: false, fuelCost: 0, + topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", @@ -90,6 +92,7 @@ describe("while you were away", () => { destinationId: "moon", deadhead: true, fuelCost: 230, + topUp: null, goodId: null, massTonnes: null, shipper: null, diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx index 09fb9cd..1175db4 100644 --- a/apps/web/test/deal-modal.test.tsx +++ b/apps/web/test/deal-modal.test.tsx @@ -103,6 +103,7 @@ describe("the deal", () => { credits: 96, deadhead: false, fuelCost: 0, + topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", diff --git a/apps/web/test/dev-tools.test.tsx b/apps/web/test/dev-tools.test.tsx index b95b681..9be06f7 100644 --- a/apps/web/test/dev-tools.test.tsx +++ b/apps/web/test/dev-tools.test.tsx @@ -19,6 +19,7 @@ describe("developer tools in Settings (Step 10K.11)", () => { arrivalAt: now + 5 * 60_000, distanceMillionKm: 0.4, fuelCost: 0, + fuelBurnedFu: 0, expectedPayout: 0, seed: 1, status: "in-flight", diff --git a/apps/web/test/dispatch-sheet.test.tsx b/apps/web/test/dispatch-sheet.test.tsx index ec94278..68a7596 100644 --- a/apps/web/test/dispatch-sheet.test.tsx +++ b/apps/web/test/dispatch-sheet.test.tsx @@ -5,7 +5,7 @@ import { realToGame, routeDistance, travelTimeGameHours, - tripFuelCost, + tripFuel, pilotShare, } from "@deadhead/sim"; import { screen, waitFor, within } from "@testing-library/react"; @@ -120,7 +120,8 @@ describe("dispatch sheet", () => { (travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) * MS_PER_HOUR) / content.config.timeCompression; - const fuel = tripFuelCost(model, earth, mars, content.config); + // The fixtures' tanks were filled at 16 cr/fu (CR-015). + const fuel = Math.round(tripFuel(model, earth, mars, content.config) * 16); const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config); expect(within(freighter).getByRole("radio")).toBeEnabled(); diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index ceb9d6a..b3af56f 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -11,7 +11,10 @@ import { effectiveStats } from "@deadhead/sim"; export function shipExtras( modelId: string, upgrades: Record = {}, -): Pick { +): Pick< + Ship, + "upgrades" | "stats" | "inShopUntil" | "shop" | "fuelFu" | "fuelAvgPrice" | "refuelPolicy" +> { const model = content.shipModel(modelId); if (!model) throw new Error(`Unknown ship model ${modelId}`); const s = effectiveStats(model, upgrades, content); @@ -27,6 +30,10 @@ export function shipExtras( }, inShopUntil: null, shop: null, + // A full tank bought at Earth (CR-015). + fuelFu: s.tankCapacityFu, + fuelAvgPrice: 16, + refuelPolicy: "auto", }; } diff --git a/apps/web/test/fleet.test.tsx b/apps/web/test/fleet.test.tsx index 6136470..af02b26 100644 --- a/apps/web/test/fleet.test.tsx +++ b/apps/web/test/fleet.test.tsx @@ -22,6 +22,7 @@ const flight: Dispatch = { arrivalAt: NOW + 180_000, distanceMillionKm: 0.39, fuelCost: 50, + fuelBurnedFu: 0, expectedPayout: 0, seed: 1, status: "in-flight", @@ -149,7 +150,14 @@ describe("fleet screen", () => { const record = createRepositionRecord( { id: "r", - ship: { id: "s-idle", modelId: "courier", locationId: "moon", inFlight: false }, + // The fixture's tank was filled at 16 cr/fu (CR-015). + ship: { + id: "s-idle", + modelId: "courier", + locationId: "moon", + inFlight: false, + tank: { fuelFu: 40, avgPrice: 16 }, + }, pilot: { id: "avatar", isAvatar: true, diff --git a/apps/web/test/hq.test.tsx b/apps/web/test/hq.test.tsx index 71d456e..6c51143 100644 --- a/apps/web/test/hq.test.tsx +++ b/apps/web/test/hq.test.tsx @@ -32,6 +32,7 @@ const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({ arrivalAt, distanceMillionKm: 0.39, fuelCost: 50, + fuelBurnedFu: 0, expectedPayout: 0, seed: 1, status: "in-flight", @@ -149,6 +150,7 @@ describe("loan on HQ", () => { credits: 538, deadhead: false, fuelCost: 0, + topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", diff --git a/apps/web/test/map-art-loading.test.ts b/apps/web/test/map-art-loading.test.ts index c309d9c..bdf6717 100644 --- a/apps/web/test/map-art-loading.test.ts +++ b/apps/web/test/map-art-loading.test.ts @@ -20,6 +20,7 @@ const flight = (id: string, shipId: string, tutorial = false): Dispatch => ({ arrivalAt: NOW + 60_000, distanceMillionKm: 0.4, fuelCost: 0, + fuelBurnedFu: 0, expectedPayout: 0, seed: 1, status: "in-flight", diff --git a/apps/web/test/map-interactions.test.tsx b/apps/web/test/map-interactions.test.tsx index 09fe440..4391e97 100644 --- a/apps/web/test/map-interactions.test.tsx +++ b/apps/web/test/map-interactions.test.tsx @@ -22,6 +22,7 @@ const flight: Dispatch = { arrivalAt: NOW + 5 * 60_000, distanceMillionKm: 100, fuelCost: 0, + fuelBurnedFu: 0, expectedPayout: 0, seed: 1, status: "in-flight", diff --git a/apps/web/test/map-layout.test.ts b/apps/web/test/map-layout.test.ts index ba2a185..184c7c4 100644 --- a/apps/web/test/map-layout.test.ts +++ b/apps/web/test/map-layout.test.ts @@ -123,6 +123,7 @@ describe("bodies on screen (decisions 0.5.38, 0.5.39)", () => { arrivalAt: now + 60_000, distanceMillionKm: 0.4, fuelCost: 0, + fuelBurnedFu: 0, expectedPayout: 0, seed: 1, status: "in-flight", diff --git a/apps/web/test/map-scene.test.ts b/apps/web/test/map-scene.test.ts index ec1513e..f5c79bf 100644 --- a/apps/web/test/map-scene.test.ts +++ b/apps/web/test/map-scene.test.ts @@ -18,6 +18,7 @@ const flight: Dispatch = { arrivalAt: NOW + 3_600_000, distanceMillionKm: 150, fuelCost: 0, + fuelBurnedFu: 0, expectedPayout: 0, seed: 1, status: "in-flight", diff --git a/apps/web/test/pilot-share.test.tsx b/apps/web/test/pilot-share.test.tsx index 2afd208..dce6df7 100644 --- a/apps/web/test/pilot-share.test.tsx +++ b/apps/web/test/pilot-share.test.tsx @@ -1,6 +1,6 @@ import { content } from "@deadhead/content"; import type { Contract, Pilot, ResolutionSummary, Ship } from "@deadhead/schema"; -import { pilotShare, tripFuelCost } from "@deadhead/sim"; +import { pilotShare, tripFuel } from "@deadhead/sim"; import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -65,6 +65,7 @@ const summary: ResolutionSummary = { tutorial: false, deadhead: false, fuelCost: 150, + topUp: null, goodId: "consumer-goods", massTonnes: 10, shipper: "Pacific Orbital Logistics", @@ -106,11 +107,14 @@ describe("pilot share in the client (Step 10J.6)", () => { ); await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); const dialog = screen.getByRole("dialog"); - const fuel = tripFuelCost( - content.shipModel("courier")!, - content.location("earth")!, - content.location("moon")!, - content.config, + // The fixtures' tanks were filled at 16 cr/fu (CR-015). + const fuel = Math.round( + tripFuel( + content.shipModel("courier")!, + content.location("earth")!, + content.location("moon")!, + content.config, + ) * 16, ); const share = pilotShare(job.payout, fuel, content.config); diff --git a/apps/web/test/player-state.test.tsx b/apps/web/test/player-state.test.tsx index 72ee840..85447a0 100644 --- a/apps/web/test/player-state.test.tsx +++ b/apps/web/test/player-state.test.tsx @@ -20,6 +20,7 @@ function inFlight(arrivalAt: number): Dispatch { arrivalAt, distanceMillionKm: 0.39, fuelCost: 0, + fuelBurnedFu: 0, expectedPayout: 240, seed: 1, status: "in-flight", diff --git a/apps/web/test/rest-summary-hq.test.tsx b/apps/web/test/rest-summary-hq.test.tsx index d7d42da..b231d75 100644 --- a/apps/web/test/rest-summary-hq.test.tsx +++ b/apps/web/test/rest-summary-hq.test.tsx @@ -36,6 +36,7 @@ const summary = (extra: Partial): ResolutionSummary => ({ credits: 500, deadhead: false, fuelCost: 0, + topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index 50195f9..6f60be1 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -144,6 +144,7 @@ describe("scripted delivery summary", () => { credits: 461, deadhead: false, fuelCost: 0, + topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index dc6ae04..79b9292 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. | 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). | 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 | @@ -76,7 +76,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re ## Save document (`packages/schema/src/save.ts`, CR-014) -One document per company on the device, validated on every load and save: `saveVersion` (currently 1), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map and shop fields), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing. +One document per company on the device, validated on every load and save: `saveVersion` (currently 2; 1 → 2 added fuel tanks, CR-015), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map, shop fields, and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing. Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index d199158..cf3134a 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -373,7 +373,7 @@ Spec: `memory-bank/change-requests/CR-015-fixed-map-fuel-tanks.md` (received as - [x] **Step 15.1 — Content: fixed positions.** Locations lose `body` and gain `position` (`{ radiusMillionKm, angleDegrees }`): Earth 150/210°, Mars 228/270°, Ceres Station 414/285°, Jupiter Cloud Platform 778/300°; the Moon is Earth's position moved 0.384M km straight out (`offsetPosition`), recorded as `orbitParent: { locationId: "earth", offsetMillionKm: 0.384 }`. GameConfig's epoch keeps only a displayed start date (`gameStart`, was `ephemerisDate`; same value). `astronomy-engine` is removed from the sim and the lockfile. Because positions had to come from somewhere once it was gone, the sim's `locationPosition` already reads the fixed position (its game-time argument is ignored until 15.2), and the web map draws from it (`bodyPosition` is gone; the Moon is placed from `orbitParent`). Fixed distances broke CR-012's tuning, so ranges move into CR-015 §4's bands now: Clipper 198 → 250M km (it couldn't reach Mars at 201), Freighter 450 → 460, the Courier's fuel-tank override 450 % → 1,400 % per level (5 → 75 → 145 → 215: Mars from Earth only at level 3). The balance re-check then put the first Market purchase at 86.7 min (quantized by the Clipper's first Mars landing), so the Lighter's price drops 42,400 → 36,000 (first purchase 53.3, four ships 115 vs 130 min, loan 135 min). Tests: `packages/content/test/fixed-map.test.ts` (every location positioned; the Moon defined as an offset from Earth; child bodies match their parent plus offset; the six §2 distances within 2 % and symmetric; no `astronomy-engine` in the lockfile). Date-sampled tests became yes/no: `reach-table.test.ts` is now CR-015 §4, one test per cell; `courier-mars`, `eligibility`, the distance test ("varies" replaced by "the same at every time"), the upgrade-cap value; the engine's would-strand test and the web stranding test use a maxed-tank Freighter at the Moon going to Jupiter with Mars and Ceres undiscovered (no base ship can strand on the fixed map); the Market test for "Other dates" is removed (the wording goes in 15.8). - [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. -- [ ] Step 15.4 — Sim: tank, burn, and refuel +- [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 - [ ] Step 15.6 — Engine: commands and save migration - [ ] Step 15.7 — Client: fuel in the UI diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index aa48fcf..21fa8a6 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -23,7 +23,14 @@ export const gameConfig: GameConfig = { loanRepaymentShare: 0.25, // The deal (CR-013): Halvorsen's Courier for the avatar, his Clipper for the // veteran, and the Earth bay. The loan is computed from their prices. - deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, + // The ships come with a quarter tank (CR-015, user decision): the Clipper earns + // locally before the player can fuel a Mars run, as before fuel tanks. + deal: { + avatarShipModelId: "courier", + veteranShipModelId: "clipper", + bayLocationId: "earth", + tankShare: 0.25, + }, contractBoardPeriodRealMinutes: 10, // Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips. boardRetentionPeriods: 24, diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 696d12e..ac7a963 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -238,6 +238,8 @@ export const gameConfigSchema = z avatarShipModelId: z.string().min(1), veteranShipModelId: z.string().min(1), bayLocationId: z.string().min(1), + /** How full the deal's ships' tanks are (CR-015, user decision): Halvorsen's leftovers. */ + tankShare: fraction, }), contractBoardPeriodRealMinutes: z.number().positive(), /** Past board periods whose boards and accepted contracts are kept (Step 7.3). */ diff --git a/packages/content/test/config.test.ts b/packages/content/test/config.test.ts index 883b1c4..404d515 100644 --- a/packages/content/test/config.test.ts +++ b/packages/content/test/config.test.ts @@ -40,6 +40,8 @@ describe("game config", () => { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth", + // Halvorsen's ships come with a quarter tank (CR-015, user decision). + tankShare: 0.25, }); expect(content.dealLoanAmount).toBe(price("courier") + price("clipper") + earth.bayLeasePrice); }); diff --git a/packages/content/test/schemas.test.ts b/packages/content/test/schemas.test.ts index 804c9f3..0dd33cf 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -114,7 +114,12 @@ const validGameConfig = { baseFuelPricePerFu: 10, emergencyFuelMultiplier: 2.5, economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 }, - deal: { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth" }, + deal: { + avatarShipModelId: "courier", + veteranShipModelId: "clipper", + bayLocationId: "earth", + tankShare: 0.25, + }, }; const validScripted = { diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index 8fdb8b0..2d5f9f3 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -90,6 +90,8 @@ export function commandTable(): CommandTable { break; case "reset-upgrades": ship.upgrades = {}; + // A smaller tank holds no more than it can (CR-015). + ship.fuelFu = Math.min(ship.fuelFu, content.shipModel(ship.modelId)!.tankCapacityFu); break; case "finish-shop": // Done now: the "out of the shop" note still shows, as for a finished job. diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts index 342ac77..6129015 100644 --- a/packages/engine/src/game/company.ts +++ b/packages/engine/src/game/company.ts @@ -4,7 +4,7 @@ import type { RealTime } from "@deadhead/sim"; import type { RandomSource } from "../core"; import { fail } from "./errors"; import { companyName, shipName } from "./names"; -import { companyView, isDealAvailable } from "./state"; +import { companyView, deliveredTank, isDealAvailable } from "./state"; export const AVATAR_NAME = "Captain"; @@ -121,6 +121,8 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi fromDeal: true, createdAt: now, lastArrivalAt: now, + // Halvorsen's ships come with what's left in their tanks (CR-015, user decision). + ...deliveredTank(modelId, bayLocationId, content.config.deal.tankShare), }); } save.bays.push({ id: random.id(), locationId: bayLocationId, createdAt: now }); diff --git a/packages/engine/src/game/dispatch.ts b/packages/engine/src/game/dispatch.ts index 73b7ec1..eed5488 100644 --- a/packages/engine/src/game/dispatch.ts +++ b/packages/engine/src/game/dispatch.ts @@ -43,7 +43,7 @@ function claimableContract( throw fail("unknown-contract", "No such contract on your boards."); } -/** Stores a dispatch, marks its contract accepted, and charges fuel. */ +/** Stores a dispatch, marks its contract accepted, and takes the leg's fuel from the tank. */ function storeDispatch( save: SaveDocument, args: { @@ -75,7 +75,7 @@ function storeDispatch( acceptedAt: now, }); } - save.company.credits -= record.fuelCost; + if (shipId !== null) requireShip(save, shipId).fuelFu -= record.fuelBurnedFu; return toApiDispatch(dispatch); } @@ -85,8 +85,8 @@ function setInFlight(save: SaveDocument, shipId: string, pilotId: string): void } /** - * The avatar flies the company loaner on a tutorial contract (Step 6.4). The - * company pays fuel, and only one tutorial run may be in flight. + * The avatar flies the company loaner on a tutorial contract (Step 6.4). It + * uses no fuel (decision 0.5.94), and only one tutorial run may be in flight. */ export function dispatchTutorial( save: SaveDocument, @@ -112,10 +112,7 @@ export function dispatchTutorial( inFlight: false, }; const pilot = pilotState(avatar); - const check = validateDispatch( - { ship: loaner, pilot, contract, tutorial: true, credits: save.company.credits, now }, - content, - ); + const check = validateDispatch({ ship: loaner, pilot, contract, tutorial: true, now }, content); if (!check.ok) throw fail(check.reason, VALIDATION_MESSAGES[check.reason]); const record = createDispatchRecord( @@ -156,7 +153,6 @@ export function dispatchContract( // The no-strand rule counts only discovered locations (decision 0.5.79). discoveredIds: discoveredIds(save), tutorial: false, - credits: save.company.credits, now, }; const check = validateDispatch(proposal, content); @@ -185,7 +181,6 @@ export function dispatchReposition( pilot: pilotState(pilot), destinationId: input.destinationId, discoveredIds: discoveredIds(save), - credits: save.company.credits, now, }; const check = validateReposition(proposal, content); diff --git a/packages/engine/src/game/fleet.ts b/packages/engine/src/game/fleet.ts index 6a40e97..6cebeac 100644 --- a/packages/engine/src/game/fleet.ts +++ b/packages/engine/src/game/fleet.ts @@ -13,6 +13,7 @@ import { fail } from "./errors"; import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages"; import { pilotName, shipName } from "./names"; import { + deliveredTank, hasBayAt, requirePilot, requireShip, @@ -71,6 +72,8 @@ export function buyShip( fromDeal: false, createdAt: now, lastArrivalAt: now, + // Delivered with a full tank, included in the price (decision 0.5.94). + ...deliveredTank(model.id, bay.locationId), }; save.ships.push(ship); return toApiShip(save, ship, now); diff --git a/packages/engine/src/game/messages.ts b/packages/engine/src/game/messages.ts index a7d65c4..b89507e 100644 --- a/packages/engine/src/game/messages.ts +++ b/packages/engine/src/game/messages.ts @@ -17,7 +17,7 @@ export const VALIDATION_MESSAGES: Record p.id === dispatch.pilotId); if (!pilot) throw new Error(`Dispatch ${dispatch.id} has no pilot`); + const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined; + const model = ship ? content.shipModel(ship.modelId) : undefined; const result = resolveDispatch( { record: dispatch.record, @@ -31,6 +34,15 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number { pilotDutyHours: pilot.dutyHours, pilotSkills: pilot.skills, now, + fuel: + ship && model + ? { + tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, + capacityFu: effectiveStats(model, ship.upgrades, content).tankCapacityFu, + refuelPolicy: ship.refuelPolicy, + credits: save.company.credits, + } + : undefined, }, content, ); @@ -41,6 +53,12 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number { Object.assign(dispatch, { status: "resolved", resolution: r, resolvedAt: now }); save.company.credits += r.credits; save.company.loanBalance -= r.loanRepayment; + // Auto top-up on landing (decision 0.5.97). + if (r.topUp && ship) { + save.company.credits -= r.topUp.cost; + ship.fuelFu = r.topUp.tank.fuelFu; + ship.fuelAvgPrice = r.topUp.tank.avgPrice; + } pilot.xp += r.pilotXp; pilot.status = "idle"; @@ -50,7 +68,6 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number { // Only unassigned pilots store a location (decision 0.5.27): the tutorial avatar. if (pilot.assignedShipId === null) pilot.locationId = r.shipLocationId; - const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined; if (ship) { ship.status = "idle"; ship.locationId = r.shipLocationId; diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index 37283f3..4fed5fc 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -12,6 +12,7 @@ import { completedGoals, dutyLimit, effectiveStats, + fuelPriceAt, isInShop, isResting, pilotLevel, @@ -74,6 +75,23 @@ export function spend(save: SaveDocument, amount: number, what: string): void { save.company.credits -= amount; } +/** A ship's tank on delivery: `share` of its capacity, bought at `locationId`'s price. */ +export function deliveredTank( + modelId: string, + locationId: string, + share = 1, +): Pick { + const model = content.shipModel(modelId); + const location = content.location(locationId); + if (!model || !location) + throw new Error(`Unknown ship model or location: ${modelId}, ${locationId}`); + return { + fuelFu: model.tankCapacityFu * share, + fuelAvgPrice: fuelPriceAt(location, content.config), + refuelPolicy: "auto", + }; +} + export function shipState(ship: SavedShip): ShipState { return { id: ship.id, @@ -83,6 +101,7 @@ export function shipState(ship: SavedShip): ShipState { inFlight: ship.status === "in-flight", upgrades: ship.upgrades, inShopUntil: ship.inShopUntil, + tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, }; } @@ -157,6 +176,9 @@ export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): S ship.shopUpgradeId && ship.shopLevel !== null && ship.inShopUntil !== null ? { upgradeId: ship.shopUpgradeId, level: ship.shopLevel, until: ship.inShopUntil } : null, + fuelFu: ship.fuelFu, + fuelAvgPrice: ship.fuelAvgPrice, + refuelPolicy: ship.refuelPolicy, }; } @@ -234,6 +256,13 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState { tutorial: d.tutorial, deadhead: d.record.contract === null, fuelCost: d.record.fuelCost, + topUp: d.resolution?.topUp + ? { + locationId: d.resolution.topUp.locationId, + boughtFu: d.resolution.topUp.boughtFu, + cost: d.resolution.topUp.cost, + } + : null, goodId: d.record.contract?.goodId ?? null, massTonnes: d.record.contract?.massTonnes ?? null, shipper: d.record.contract?.shipper ?? null, diff --git a/packages/engine/test/fuel.test.ts b/packages/engine/test/fuel.test.ts new file mode 100644 index 0000000..bb75cba --- /dev/null +++ b/packages/engine/test/fuel.test.ts @@ -0,0 +1,96 @@ +import { content } from "@deadhead/content"; +import { fuelPriceAt } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { + edit, + getState, + putBoard, + readSave, + setCredits, + signUp, + signUpOwner, + testContract, + useTestApp, +} from "./support"; + +const testApp = useTestApp(); +const tank = (id: string) => content.shipModel(id)!.tankCapacityFu; +const price = (id: string) => fuelPriceAt(content.location(id)!, content.config); + +describe("fuel tanks in the engine (Step 15.4)", () => { + it("delivers a Market ship with a full tank, bought at the bay's price", async () => { + const { app } = testApp(); + const owner = await signUpOwner(app); + await setCredits(app, content.shipModel("freighter")!.price); + const { ship } = await app.call("buyShip", { modelId: "freighter", bayId: owner.earthBayId }); + expect(ship).toMatchObject({ + fuelFu: tank("freighter"), + fuelAvgPrice: price("earth"), + refuelPolicy: "auto", + }); + // The tank was included in the price. + expect((await getState(app)).company.credits).toBe(0); + }); + + it("hands over the deal's ships with a quarter tank (CR-015, user decision)", async () => { + const { app } = testApp(); + await signUp(app); + await edit(app, (save) => { + save.player.tutorialRunsCompleted = content.config.tutorialRunsBeforeDeal; + save.player.loanerLocationId = "earth"; + }); + const state = await app.call("acceptDeal", {}); + for (const ship of state.ships) { + expect(ship.fuelFu, ship.modelId).toBeCloseTo(tank(ship.modelId) * 0.25); + expect(ship.fuelAvgPrice).toBe(price("earth")); + } + }); + + it("burns the leg from the tank and, with Auto, tops up on landing at the local price", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app); + await setCredits(app, 10_000); + const contract = testContract(); + await putBoard(app, [contract]); + const { dispatch } = await app.call("createDispatch", { + kind: "contract", + contractId: contract.id, + shipId: owner.shipId, + pilotId: owner.avatarId, + }); + expect((await getState(app)).company.credits).toBe(10_000); + + clock.now = dispatch.arrivalAt; + const state = await getState(app); + const [summary] = state.summaries; + const bought = summary!.topUp!; + expect(bought.locationId).toBe("moon"); + expect(bought.boughtFu).toBeCloseTo(dispatch.fuelBurnedFu); + expect(bought.cost).toBe(Math.round(dispatch.fuelBurnedFu * price("moon"))); + expect(state.company.credits).toBe(10_000 + summary!.credits - bought.cost); + expect(state.ships[0]!.fuelFu).toBeCloseTo(tank("courier")); + }); + + it("leaves the tank as it landed with Manual", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app); + await setCredits(app, 10_000); + await edit(app, (save) => { + save.ships.find((s) => s.id === owner.shipId)!.refuelPolicy = "manual"; + }); + const contract = testContract(); + await putBoard(app, [contract]); + const { dispatch } = await app.call("createDispatch", { + kind: "contract", + contractId: contract.id, + shipId: owner.shipId, + pilotId: owner.avatarId, + }); + clock.now = dispatch.arrivalAt; + const state = await getState(app); + expect(state.summaries[0]!.topUp).toBeNull(); + expect(state.company.credits).toBe(10_000 + state.summaries[0]!.credits); + const saved = (await readSave(app)).ships.find((s) => s.id === owner.shipId)!; + expect(saved.fuelFu).toBeCloseTo(tank("courier") - dispatch.fuelBurnedFu); + }); +}); diff --git a/packages/engine/test/ported/concurrency.test.ts b/packages/engine/test/ported/concurrency.test.ts index e2c4980..2b075e0 100644 --- a/packages/engine/test/ported/concurrency.test.ts +++ b/packages/engine/test/ported/concurrency.test.ts @@ -67,7 +67,11 @@ describe("concurrency and money safety", () => { expect(responses.map((r) => r.statusCode).sort()).toEqual([200, 409]); const state = await getState(app, guest); expect(state.activeDispatches).toHaveLength(1); - expect(state.company.credits).toBe(10_000 - state.activeDispatches[0]!.fuelCost); + // One leg's fuel left the tank, once; no credits were charged (CR-015). + expect(state.company.credits).toBe(10_000); + expect(state.ships[0]!.fuelFu).toBeCloseTo( + state.ships[0]!.stats.tankCapacityFu - state.activeDispatches[0]!.fuelBurnedFu, + ); }); it("leases one bay when the same lease is sent twice at once", async () => { diff --git a/packages/engine/test/ported/contract-dispatch.test.ts b/packages/engine/test/ported/contract-dispatch.test.ts index 3e8ff31..db3f674 100644 --- a/packages/engine/test/ported/contract-dispatch.test.ts +++ b/packages/engine/test/ported/contract-dispatch.test.ts @@ -70,7 +70,7 @@ async function expectRejected( } describe("contract dispatch", () => { - it("dispatches, charges fuel, stores the record and removes the contract from the board", async () => { + it("dispatches, burns fuel from the tank, stores the record and removes the contract from the board", async () => { const { app, clock } = testApp(); const o = await owner(); const contract = testContract(); @@ -87,10 +87,14 @@ describe("contract dispatch", () => { expectedPayout: contract.payout, tutorial: false, }); + expect(record.fuelBurnedFu).toBeGreaterThan(0); expect(record.fuelCost).toBeGreaterThan(0); + // Fuel comes out of the tank; no credits change hands (decision 0.5.94). const state = await getState(app, o.guest); - expect(state.company.credits).toBe(10_000 - record.fuelCost); + expect(state.company.credits).toBe(10_000); + const full = content.shipModel("courier")!.tankCapacityFu; + expect(state.ships[0]!.fuelFu).toBeCloseTo(full - record.fuelBurnedFu); expect(state.ships[0]).toMatchObject({ status: "in-flight", dispatchId: record.id }); expect(state.pilots[0]!.status).toBe("in-flight"); const board = contractBoardResponseSchema.parse( @@ -186,13 +190,15 @@ describe("contract dispatch", () => { await expectRejected(o, await dispatch(o, contract.id), "contract-expired"); }); - it("rejects insufficient-credits", async () => { + it("rejects not-enough-fuel (CR-015)", async () => { const o = await owner(); const contract = testContract(); await putBoard(testApp().app, [contract]); - await setCredits(testApp().app, 0); + await edit(testApp().app, (save) => { + save.ships.find((s) => s.id === o.shipId)!.fuelFu = 0.5; + }); const response = await dispatch(o, contract.id); - expect(errorCode(response)).toBe("insufficient-credits"); + expect(errorCode(response)).toBe("not-enough-fuel"); expect((await readSave(testApp().app)).dispatches).toEqual([]); }); diff --git a/packages/engine/test/ported/location-board.test.ts b/packages/engine/test/ported/location-board.test.ts index 87cbd0d..fbb3d92 100644 --- a/packages/engine/test/ported/location-board.test.ts +++ b/packages/engine/test/ported/location-board.test.ts @@ -91,7 +91,13 @@ describe("location contract boards", () => { (contract) => validateDispatch( { - ship: { id: owner.shipId, modelId: "courier", locationId: "earth", inFlight: false }, + ship: { + id: owner.shipId, + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: { fuelFu: content.shipModel("courier")!.tankCapacityFu, avgPrice: 16 }, + }, pilot: { id: owner.avatarId, isAvatar: true, @@ -102,7 +108,6 @@ describe("location contract boards", () => { }, contract, tutorial: false, - credits: 1_000_000, now: clock.now, }, content, diff --git a/packages/engine/test/ported/reposition.test.ts b/packages/engine/test/ported/reposition.test.ts index da6083e..658edca 100644 --- a/packages/engine/test/ported/reposition.test.ts +++ b/packages/engine/test/ported/reposition.test.ts @@ -33,15 +33,19 @@ describe("deadhead dispatch", () => { expect(state.bays.map((b) => b.locationId)).toEqual(["earth"]); expect(state.ships[0]).toMatchObject({ status: "idle", locationId: "moon" }); expect(state.pilots[0]).toMatchObject({ status: "idle", locationId: "moon", xp: 0 }); - expect(state.company.credits).toBe(10_000 - dispatch.fuelCost); - // The logbook marks it as a deadhead with its fuel (no payout to show). + // Fuel came out of the tank; landing on Auto top-up bought it back at the Moon (CR-015). + const [summary] = state.summaries; expect(state.summaries).toHaveLength(1); - expect(state.summaries[0]).toMatchObject({ + expect(summary).toMatchObject({ deadhead: true, fuelCost: dispatch.fuelCost, credits: 0, pilotXp: 0, + topUp: { locationId: "moon" }, }); + expect(summary!.topUp!.boughtFu).toBeCloseTo(dispatch.fuelBurnedFu); + expect(state.company.credits).toBe(10_000 - summary!.topUp!.cost); + expect(state.ships[0]!.fuelFu).toBeCloseTo(state.ships[0]!.stats.tankCapacityFu); }); it("refuses an undiscovered destination and changes nothing", async () => { diff --git a/packages/engine/test/support.ts b/packages/engine/test/support.ts index 7a7aa2b..af76f36 100644 --- a/packages/engine/test/support.ts +++ b/packages/engine/test/support.ts @@ -24,6 +24,7 @@ import { SaveStorage, type CommandName, } from "../src/index"; +import { deliveredTank } from "../src/game/state"; /** * Helpers for the ported server tests (CR-014 Step 14.5): the same names and @@ -305,10 +306,11 @@ export function makeOwner( inShopUntil: null, shopUpgradeId: null, shopLevel: null, - // Stands in for the deal's Courier (not a Market purchase, CR-013). + // Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank. fromDeal: true, createdAt: TEST_NOW, lastArrivalAt: TEST_NOW, + ...deliveredTank("courier", "earth"), }); Object.assign( save.pilots.find((p) => p.id === avatarId)!, @@ -370,6 +372,8 @@ export function addShip( fromDeal: false, createdAt: TEST_NOW, lastArrivalAt: TEST_NOW, + // A full tank, bought where the ship is (decision 0.5.94). + ...deliveredTank(ship.modelId, ship.locationId ?? "earth"), ...ship, }); }); diff --git a/packages/schema/package.json b/packages/schema/package.json index 0730f93..5c27afd 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@deadhead/content": "workspace:*", "@deadhead/sim": "workspace:*", "zod": "^4.6.5" } diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index 08deb6d..75829b0 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -66,6 +66,11 @@ export const shipSchema = z.object({ shop: z .object({ upgradeId: idSchema, level: z.number().int().positive(), until: timeSchema }) .nullable(), + /** Fuel on board, in fu, and its average purchase price in credits per fu (CR-015). */ + fuelFu: z.number().nonnegative(), + fuelAvgPrice: z.number().nonnegative(), + /** Auto top-up on landing, or Manual (decision 0.5.97). */ + refuelPolicy: z.enum(["auto", "manual"]), }); export type Ship = z.infer; @@ -143,6 +148,8 @@ export const dispatchSchema = z.object({ departureAt: timeSchema, arrivalAt: timeSchema, distanceMillionKm: z.number().nonnegative(), + /** Fuel taken from the tank at departure, in fu (CR-015). */ + fuelBurnedFu: z.number().nonnegative(), fuelCost: credits.nonnegative(), expectedPayout: credits, seed: z.number().int().nonnegative(), @@ -161,8 +168,12 @@ export const resolutionSummarySchema = z.object({ tutorial: z.boolean(), /** An empty reposition flight: no cargo, no pay (CR-002). */ deadhead: z.boolean(), - /** Fuel paid at departure. */ + /** The fuel used, at the tank's average price (decision 0.5.98). */ fuelCost: credits.nonnegative(), + /** Auto top-up on landing (decision 0.5.97), or null. */ + topUp: z + .object({ locationId: idSchema, boughtFu: z.number().positive(), cost: credits.nonnegative() }) + .nullable(), /** What was delivered and for whom (CR-007); null for a deadhead. */ goodId: idSchema.nullable(), massTonnes: z.number().positive().nullable(), diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index 0c48542..13a250b 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -1,4 +1,11 @@ -import type { Contract as SimContract, DispatchRecord, Resolution } from "@deadhead/sim"; +import { content } from "@deadhead/content"; +import { + effectiveStats, + fuelPriceAt, + type Contract as SimContract, + type DispatchRecord, + type Resolution, +} from "@deadhead/sim"; import { z } from "zod"; import { contractSchema, idSchema, timeSchema } from "./entities"; @@ -23,14 +30,32 @@ export const dispatchRecordSchema = z.object({ departureAt: timeSchema, arrivalAt: timeSchema, distanceMillionKm: z.number().nonnegative(), + /** Fuel taken from the tank at departure (CR-015); 0 for tutorial runs and pre-tank trips. */ + fuelBurnedFu: z.number().nonnegative(), fuelCost: credits.nonnegative(), expectedPayout: credits, seed: z.number().int().nonnegative(), }); +/** A ship's tank (decision 0.5.94): fuel and its average purchase price. */ +const tankSchema = z.object({ + fuelFu: z.number().nonnegative(), + avgPrice: z.number().nonnegative(), +}); + /** What resolving a dispatch did, kept for the "while you were away" summary. */ export const resolutionSchema = z.object({ credits, + /** Auto top-up on landing (decision 0.5.97). */ + topUp: z + .object({ + locationId: idSchema, + boughtFu: z.number().positive(), + price: z.number().positive(), + cost: credits.nonnegative(), + tank: tankSchema, + }) + .nullable(), pilotPay: credits.nonnegative(), loanRepayment: credits.nonnegative(), pilotXp: z.number().int().nonnegative(), @@ -59,6 +84,12 @@ export const savedShipSchema = z.object({ createdAt: timeSchema, /** Its latest resolved arrival (the delivery time if none): picks the default Contracts location. */ lastArrivalAt: timeSchema, + /** Fuel on board, in fu (decision 0.5.94). */ + fuelFu: z.number().nonnegative(), + /** Credits per fu, weighted by what was bought where (decision 0.5.98). */ + fuelAvgPrice: z.number().nonnegative(), + /** Auto top-up on landing, or Manual (decision 0.5.97). */ + refuelPolicy: z.enum(["auto", "manual"]), }); export const savedPilotSchema = z.object({ @@ -104,7 +135,7 @@ export const savedDispatchSchema = z.object({ }); /** The save version this build writes. Bump it with a new migration. */ -export const CURRENT_SAVE_VERSION = 1; +export const CURRENT_SAVE_VERSION = 2; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -167,12 +198,42 @@ export interface SaveMigration { migrate(save: Record): Record; } +type Doc = Record; +const records = (value: unknown): Doc[] => (Array.isArray(value) ? (value as Doc[]) : []); + /** - * Every migration, in order. Version 1 is the first local save format, so - * there is nothing before it yet; a format change adds `{ from: 1, … }` here - * and bumps `CURRENT_SAVE_VERSION`. + * Version 1 → 2 (CR-015 §5, fuel tanks): every ship gets a full tank, bought at + * the price where it sits (the base price while in flight), on Auto top-up. + * Earlier trips paid fuel in credits, so their records burned no tank fuel and + * their resolutions had no top-up. Arrival times are untouched. */ -export const SAVE_MIGRATIONS: readonly SaveMigration[] = []; +function addFuelTanks(save: Doc): Doc { + const { config } = content; + const ships = records(save.ships).map((ship) => { + const model = content.shipModel(String(ship.modelId)); + const upgrades = (ship.upgrades ?? {}) as Record; + const capacity = model ? effectiveStats(model, upgrades, content).tankCapacityFu : 0; + const at = ship.locationId ? content.location(String(ship.locationId)) : undefined; + return { + ...ship, + fuelFu: capacity, + fuelAvgPrice: at ? fuelPriceAt(at, config) : config.baseFuelPricePerFu, + refuelPolicy: "auto", + }; + }); + const dispatches = records(save.dispatches).map((dispatch) => ({ + ...dispatch, + record: { ...(dispatch.record as Doc), fuelBurnedFu: 0 }, + resolution: dispatch.resolution ? { ...(dispatch.resolution as Doc), topUp: null } : null, + })); + return { ...save, ships, dispatches }; +} + +/** + * Every migration, in order: `from` → `from + 1`. A format change adds one + * here and bumps `CURRENT_SAVE_VERSION`. + */ +export const SAVE_MIGRATIONS: readonly SaveMigration[] = [{ from: 1, migrate: addFuelTanks }]; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; type Stepped = { ok: true; save: Record } | { ok: false; error: string }; diff --git a/packages/schema/test/save-fixture.ts b/packages/schema/test/save-fixture.ts index ad3fbfe..c47edf7 100644 --- a/packages/schema/test/save-fixture.ts +++ b/packages/schema/test/save-fixture.ts @@ -39,12 +39,13 @@ export function fullSave(): SaveDocument { departureAt: T, arrivalAt, distanceMillionKm: 0.38, + fuelBurnedFu: 7.6, fuelCost: 160, expectedPayout: 1500, seed: 42, }); return { - saveVersion: 1, + saveVersion: 2, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -70,6 +71,9 @@ export function fullSave(): SaveDocument { fromDeal: true, createdAt: T, lastArrivalAt: T + 200_000, + fuelFu: 52.4, + fuelAvgPrice: 15.2, + refuelPolicy: "auto", }, { id: "s2", @@ -84,6 +88,9 @@ export function fullSave(): SaveDocument { fromDeal: true, createdAt: T, lastArrivalAt: T, + fuelFu: 1170, + fuelAvgPrice: 16, + refuelPolicy: "manual", }, ], pilots: [ @@ -157,6 +164,13 @@ export function fullSave(): SaveDocument { status: "resolved", resolution: { credits: 1000, + topUp: { + locationId: "moon", + boughtFu: 7.6, + price: 11, + cost: 84, + tank: { fuelFu: 40, avgPrice: 11 }, + }, pilotPay: 0, loanRepayment: 340, pilotXp: 10, diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index 2278793..4e3b1d7 100644 --- a/packages/schema/test/save.test.ts +++ b/packages/schema/test/save.test.ts @@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture"; describe("save document (Step 14.2)", () => { it("validates a fully populated save", () => { expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); - expect(CURRENT_SAVE_VERSION).toBe(1); + expect(CURRENT_SAVE_VERSION).toBe(2); }); it("rejects an invalid save with a readable error naming the field", () => { @@ -36,7 +36,7 @@ describe("save document (Step 14.2)", () => { if (!future.ok) expect(future.error).toContain("newer version"); }); - it("migrates a version-1 save to a valid current save", () => { + it("reads a current save as it is", () => { const result = migrateSave(JSON.parse(JSON.stringify(fullSave()))); expect(result).toEqual({ ok: true, save: fullSave() }); }); @@ -56,3 +56,49 @@ describe("save document (Step 14.2)", () => { }); }); }); + +/** The fixture as a version-1 save: before fuel tanks (CR-015). */ +type Loose = Record & { + ships: Record[]; + dispatches: { record: Record; resolution: Record | null }[]; +}; + +function versionOne(): Record { + const save = JSON.parse(JSON.stringify(fullSave())) as Loose; + save.saveVersion = 1; + for (const ship of save.ships) { + delete ship.fuelFu; + delete ship.fuelAvgPrice; + delete ship.refuelPolicy; + } + for (const dispatch of save.dispatches) { + delete dispatch.record.fuelBurnedFu; + if (dispatch.resolution) delete dispatch.resolution.topUp; + } + return save; +} + +describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => { + it("fills every ship's tank, on Auto top-up, and changes nothing else", () => { + const result = migrateSave(versionOne()); + if (!result.ok) throw new Error(result.error); + const { save } = result; + const before = fullSave(); + expect(save.saveVersion).toBe(2); + // The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base price. + expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 10, refuelPolicy: "auto" }); + // The Clipper is at Earth: a full 1,170 fu at Earth's 16 cr/fu. + expect(save.ships[1]).toMatchObject({ fuelFu: 1170, fuelAvgPrice: 16, refuelPolicy: "auto" }); + // Earlier trips paid fuel in credits: nothing came out of a tank, nothing topped up. + for (const dispatch of save.dispatches) { + expect(dispatch.record.fuelBurnedFu).toBe(0); + if (dispatch.resolution) expect(dispatch.resolution.topUp).toBeNull(); + } + // In-flight dispatches keep their arrival times; everything else is as it was. + expect(save.dispatches.map((d) => d.arrivalAt)).toEqual( + before.dispatches.map((d) => d.arrivalAt), + ); + expect(save.company).toEqual(before.company); + expect(save.pilots).toEqual(before.pilots); + }); +}); diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index 913385b..52d6509 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -69,6 +69,9 @@ const ship = { }, inShopUntil: null, shop: { upgradeId: "cargo-bay", level: 1, until: 4_102_444_000_000 }, + fuelFu: 32.3, + fuelAvgPrice: 15.2, + refuelPolicy: "auto", }; const pilot = { id: "p1", @@ -118,6 +121,7 @@ const dispatch = { departureAt: 4_000_000, arrivalAt: 4_130_000, distanceMillionKm: 0.39, + fuelBurnedFu: 7.8, fuelCost: 234, expectedPayout: 480, seed: 123456, @@ -134,6 +138,7 @@ const summary = { credits: 240, deadhead: false, fuelCost: 0, + topUp: null, goodId: "consumer-goods", massTonnes: 12, shipper: "Pacific Orbital Logistics", diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index b1f6e98..dee0b02 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -10,7 +10,10 @@ */ import { content as defaultContent, type ShipModel } from "@deadhead/content"; import { + affordableRefuel, boardPeriodIndex, + fuelPriceAt, + type Tank, fullTankReach, boardPeriodMs, checkRoute, @@ -31,7 +34,7 @@ import { resolveDispatch, restEndsAt, skillPointsAvailable, - tripFuelCost, + tripFuel, validateDispatch, validateReposition, validateUpgrade, @@ -105,6 +108,8 @@ export interface SimulationResult { } interface SimShip extends ShipState { + /** Every simulated ship has a tank, on Auto top-up (decision 0.5.97). */ + tank: Tank; upgrades: UpgradeLevels; inShopUntil: RealTime | null; fromDeal: boolean; @@ -244,25 +249,32 @@ export function simulate(options: SimulationOptions): SimulationResult { /** Credits per real minute the company keeps from a contract (payout − fuel − pilot pay). */ function rate(contract: Contract, stats: ShipModel, pilot: SimPilot): number { - const fuel = tripFuelCost(stats, loc(contract.originId), loc(contract.destinationId), config); + const origin = loc(contract.originId); + const fuel = + tripFuel(stats, origin, loc(contract.destinationId), config) * fuelPriceAt(origin, config); const pay = pilot.isAvatar ? 0 : pilotShare(contract.payout, fuel, config); const tripMs = (contractTripGameHours(stats, contract, content) * MS_PER_HOUR) / config.timeCompression; return (contract.payout - fuel - pay) / Math.max(1, tripMs / MS_PER_MINUTE); } - /** Contracts this crew may take now; `budget` defaults to the credits on hand. */ - function eligibleContracts(ship: SimShip, pilot: SimPilot, budget = credits): Contract[] { + /** A ship as it would be with a full tank (for "is there any way out" checks). */ + function filled(ship: SimShip): SimShip { + const capacity = effectiveStats(model(ship.modelId), ship.upgrades, content).tankCapacityFu; + return { ...ship, tank: { ...ship.tank, fuelFu: capacity } }; + } + + /** Contracts this crew may take now, with the fuel on board (or a full tank). */ + function eligibleContracts(ship: SimShip, pilot: SimPilot, fullTank = false): Contract[] { if (!discovered.has(ship.locationId)) return []; return board(ship.locationId).filter( (contract) => validateDispatch( { - ship, + ship: fullTank ? filled(ship) : ship, pilot: pilotState(pilot), contract, tutorial: false, - credits: budget, now, discoveredIds: discovered, }, @@ -288,7 +300,7 @@ export function simulate(options: SimulationOptions): SimulationResult { ); if (isResting(pilot.restEndsAt, now)) violate("no-dispatch-while-resting", pilot.id); accepted.add(contract.id); - credits -= record.fuelCost; + ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu }; log( `${ship.modelId} ${contract.originId}→${contract.destinationId} ${contract.goodId} ${contract.massTonnes}t pays ${contract.payout}${contract.firstContact ? " FIRST CONTACT" : ""} fuel ${record.fuelCost} arrives +${Math.round((record.arrivalAt - now) / MS_PER_MINUTE)}m credits ${credits}`, ); @@ -304,6 +316,7 @@ export function simulate(options: SimulationOptions): SimulationResult { while (flights.length > 0 && flights[0]!.record.arrivalAt <= now) { const flight = flights.shift()!; const pilot = pilots.find((p) => p.id === flight.pilotId)!; + const flightShip = flight.shipId ? ships.find((s) => s.id === flight.shipId)! : undefined; const result = resolveDispatch( { record: flight.record, @@ -313,6 +326,15 @@ export function simulate(options: SimulationOptions): SimulationResult { pilotDutyHours: pilot.dutyHours, pilotSkills: pilot.skills, now, + fuel: flightShip + ? { + tank: flightShip.tank, + capacityFu: effectiveStats(model(flightShip.modelId), flightShip.upgrades, content) + .tankCapacityFu, + refuelPolicy: "auto", + credits, + } + : undefined, }, content, ); @@ -320,6 +342,10 @@ export function simulate(options: SimulationOptions): SimulationResult { const r = result.resolution; credits += r.credits; loanBalance -= r.loanRepayment; + if (r.topUp && flightShip) { + credits -= r.topUp.cost; + flightShip.tank = r.topUp.tank; + } pilot.xp += r.pilotXp; pilot.inFlight = false; pilot.dutyHours = r.pilotDutyHours; @@ -372,12 +398,12 @@ export function simulate(options: SimulationOptions): SimulationResult { inFlight: false, }; const avatar = pilots[0]!; - // Company pays fuel; the player keeps the tutorial share: best share per minute. + // Tutorial runs use no fuel; the player keeps the tutorial share: best share per minute. const best = contracts .filter( (c) => validateDispatch( - { ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, credits, now }, + { ship: loaner, pilot: pilotState(avatar), contract: c, tutorial: true, now }, content, ).ok, ) @@ -435,6 +461,11 @@ export function simulate(options: SimulationOptions): SimulationResult { modelId, locationId, inFlight: false, + tank: { + // Market ships come full; the deal's ships with a quarter tank (CR-015). + fuelFu: model(modelId).tankCapacityFu * (fromDeal ? config.deal.tankShare : 1), + avgPrice: fuelPriceAt(loc(locationId), config), + }, upgrades: {}, inShopUntil: null, fromDeal, @@ -553,6 +584,7 @@ export function simulate(options: SimulationOptions): SimulationResult { if (!pilot || ship.inFlight || pilot.inFlight || isResting(pilot.restEndsAt, now)) continue; if (ship.inShopUntil !== null && ship.inShopUntil > now) continue; const stats = effectiveStats(model(ship.modelId), ship.upgrades, content); + topUpHere(ship, stats.tankCapacityFu); const choices = eligibleContracts(ship, pilot) .map((c) => ({ c, value: rate(c, stats, pilot) })) .filter((o) => o.value > 0) @@ -562,25 +594,24 @@ export function simulate(options: SimulationOptions): SimulationResult { acted = true; continue; } - const canDeadhead = (to: string, budget: number) => + const canDeadhead = (to: string, fullTank: boolean) => to !== ship.locationId && validateReposition( { - ship, + ship: fullTank ? filled(ship) : ship, pilot: pilotState(pilot), destinationId: to, discoveredIds: discovered, - credits: budget, now, }, content, ).ok; - const deadheads = [...discovered].filter((to) => canDeadhead(to, credits)); - // A dead end is no work and no way out whatever the balance (CR-002); - // running short of cash is the soft-lock check's business. + const deadheads = [...discovered].filter((to) => canDeadhead(to, false)); + // A dead end is no work and no way out even on a full tank (CR-002); + // running short of cash for fuel is the soft-lock check's business. if ( - eligibleContracts(ship, pilot, Infinity).length === 0 && - ![...discovered].some((to) => canDeadhead(to, Infinity)) + eligibleContracts(ship, pilot, true).length === 0 && + ![...discovered].some((to) => canDeadhead(to, true)) ) { violate("idle-ship-with-nowhere-to-go", `${ship.id} ${ship.modelId} at ${ship.locationId}`); } @@ -607,7 +638,7 @@ export function simulate(options: SimulationOptions): SimulationResult { }, content, ); - credits -= record.fuelCost; + ship.tank = { ...ship.tank, fuelFu: ship.tank.fuelFu - record.fuelBurnedFu }; ship.inFlight = true; pilot.inFlight = true; flights.push({ record, shipId: ship.id, pilotId: pilot.id }); @@ -626,6 +657,16 @@ export function simulate(options: SimulationOptions): SimulationResult { return acted; } + /** Tops the tank up where the ship is, as far as credits go (the player's "Refuel here first"). */ + function topUpHere(ship: SimShip, capacityFu: number) { + const location = loc(ship.locationId); + if (!location.sellsFuel || ship.tank.fuelFu >= capacityFu) return; + const bought = affordableRefuel(ship.tank, capacityFu, fuelPriceAt(location, config), credits); + if (bought.boughtFu <= 0) return; + credits -= bought.cost; + ship.tank = bought.tank; + } + function checkCashflow() { if (tutorialActive || flights.length > 0) return; const affordable = ships.some((ship) => { @@ -635,8 +676,8 @@ export function simulate(options: SimulationOptions): SimulationResult { discovered.has(ship.locationId) && board(ship.locationId).some( (c) => - tripFuelCost(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <= - credits, + tripFuel(model(ship.modelId), loc(c.originId), loc(c.destinationId), config) <= + ship.tank.fuelFu + credits / fuelPriceAt(loc(ship.locationId), config), ) ); }); diff --git a/packages/sim/src/dispatch.ts b/packages/sim/src/dispatch.ts index d45499e..d99659b 100644 --- a/packages/sim/src/dispatch.ts +++ b/packages/sim/src/dispatch.ts @@ -5,7 +5,7 @@ import type { Contract } from "./contract"; import { routeDistance } from "./distance"; import { isResting } from "./duty"; import { checkRoute } from "./eligibility"; -import { fuelCost } from "./fuel"; +import { legFuel, legFuelCost, type Tank } from "./fuel"; import { checkPilotEligibility, type SkillLevels } from "./pilot"; import { deriveSeed, type Seed } from "./rng"; import { travelTimeGameHours } from "./travel"; @@ -20,6 +20,8 @@ export interface ShipState { upgrades?: UpgradeLevels; /** In the upgrade shop until this real time (decision 0.5.66); absent or null: not. */ inShopUntil?: RealTime | null; + /** The ship's fuel (decision 0.5.94); absent for the tutorial loaner, which uses none. */ + tank?: Tank; } /** A ship's effective stats: its model plus its upgrades (decision 0.5.63). */ @@ -43,9 +45,8 @@ export interface DispatchProposal { ship: ShipState; pilot: PilotState; contract: Contract; - /** Tutorial runs are flown by the avatar in the loaner, and the company pays fuel. */ + /** Tutorial runs are flown by the avatar in the loaner, and use no fuel (decision 0.5.94). */ tutorial: boolean; - credits: number; now: RealTime; /** * Locations the player has discovered, for the no-strand rule (decision 0.5.79). @@ -70,7 +71,7 @@ export const DISPATCH_FAILURES = [ "cargo-skill-too-low", "long-haul-too-low", "contract-expired", - "insufficient-credits", + "not-enough-fuel", ] as const; export type DispatchFailure = (typeof DISPATCH_FAILURES)[number]; @@ -88,14 +89,25 @@ export function findLocation(id: string, content: SimContent): Location { return location; } -/** Fuel charged for a trip, in whole credits. */ -export function tripFuelCost( +/** Fuel a trip burns from the tank, in fu (decision 0.5.95). */ +export function tripFuel( model: ShipModel, origin: Location, destination: Location, config: GameConfig, ): number { - return Math.round(fuelCost(model, origin, routeDistance(origin, destination), config)); + return legFuel(model, origin, routeDistance(origin, destination), config); +} + +/** Whether the ship has the fuel on board for this leg (decision 0.5.95). */ +function hasFuelFor( + ship: ShipState, + model: ShipModel, + origin: Location, + destination: Location, + content: SimContent, +): boolean { + return (ship.tank?.fuelFu ?? 0) >= tripFuel(model, origin, destination, content.config); } const fail = (reason: DispatchFailure): DispatchCheck => ({ ok: false, reason }); @@ -134,7 +146,7 @@ export function wouldStrand( /** 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 { ship, pilot, contract, tutorial, now } = proposal; const model = shipStats(ship, content); const origin = findLocation(contract.originId, content); const destination = findLocation(contract.destinationId, content); @@ -171,9 +183,8 @@ export function validateDispatch(proposal: DispatchProposal, content: SimContent if (now >= contract.expiresAt) return fail("contract-expired"); - if (!tutorial) { - if (credits < tripFuelCost(model, origin, destination, content.config)) - return fail("insufficient-credits"); + if (!tutorial && !hasFuelFor(ship, model, origin, destination, content)) { + return fail("not-enough-fuel"); } return { ok: true }; } @@ -189,9 +200,11 @@ export interface DispatchRecord { tutorial: boolean; departureAt: RealTime; arrivalAt: RealTime; - /** Route distance at departure, in million km. */ + /** Route distance, in million km. */ distanceMillionKm: number; - /** Credits charged to the player; 0 for tutorial runs (the company pays). */ + /** Fuel taken from the tank at departure, in fu; 0 for tutorial runs (decision 0.5.94). */ + fuelBurnedFu: number; + /** That fuel at the tank's average price, in credits (decision 0.5.98); 0 for tutorial runs. */ fuelCost: number; expectedPayout: number; /** Seed for anything rolled about this trip. */ @@ -230,6 +243,7 @@ function buildRecord( const distance = routeDistance(origin, destination); const mass = contract?.massTonnes ?? 0; const travelGameMs = travelTimeGameHours(model, distance, mass, config) * MS_PER_HOUR; + const burned = tutorial ? 0 : legFuel(model, origin, distance, config); return { id, @@ -242,7 +256,8 @@ function buildRecord( departureAt: now, arrivalAt: now + Math.round(travelGameMs / config.timeCompression), distanceMillionKm: distance, - fuelCost: tutorial ? 0 : tripFuelCost(model, origin, destination, content.config), + fuelBurnedFu: burned, + fuelCost: ship.tank ? legFuelCost(burned, ship.tank) : 0, expectedPayout: contract?.payout ?? 0, seed: deriveSeed(worldSeed, `dispatch:${id}`), }; @@ -263,7 +278,6 @@ export interface RepositionProposal { destinationId: string; /** Locations the player has discovered; only these can be deadheaded to (CR-002). */ discoveredIds: ReadonlySet; - credits: number; now: RealTime; } @@ -279,7 +293,7 @@ export const REPOSITION_FAILURES = [ "cannot-land", "out-of-range", "would-strand", - "insufficient-credits", + "not-enough-fuel", ] as const; export type RepositionFailure = (typeof REPOSITION_FAILURES)[number]; @@ -294,7 +308,7 @@ export function validateReposition( proposal: RepositionProposal, content: SimContent, ): RepositionCheck { - const { ship, pilot, destinationId, discoveredIds, credits, now } = proposal; + const { ship, pilot, destinationId, discoveredIds, now } = proposal; const failWith = (reason: RepositionFailure): RepositionCheck => ({ ok: false, reason }); if (ship.inFlight) return failWith("ship-not-idle"); @@ -314,8 +328,8 @@ export function validateReposition( return failWith("would-strand"); } - if (credits < tripFuelCost(model, origin, destination, content.config)) { - return failWith("insufficient-credits"); + if (!hasFuelFor(ship, model, origin, destination, content)) { + return failWith("not-enough-fuel"); } return { ok: true }; } diff --git a/packages/sim/src/fuel.ts b/packages/sim/src/fuel.ts index 4e2e96a..984f7cc 100644 --- a/packages/sim/src/fuel.ts +++ b/packages/sim/src/fuel.ts @@ -48,15 +48,50 @@ export function fuelPriceAt(location: Location, config: GameConfig): number { return config.baseFuelPricePerFu * location.fuelPrice; } -/** - * Fuel cost in credits for a leg: its fuel at the origin's price. (Step 15.3; - * Step 15.4 replaces the charge with fuel burned from the ship's tank.) - */ -export function fuelCost( - ship: ShipModel, - origin: Location, - distanceMillionKm: number, - config: GameConfig, -): number { - return legFuel(ship, origin, distanceMillionKm, config) * fuelPriceAt(origin, config); +/** What a ship's tank holds (decision 0.5.94): fuel, and its average purchase price. */ +export interface Tank { + fuelFu: number; + /** Credits per fu, weighted by how much was bought at each price (decision 0.5.98). */ + avgPrice: number; +} + +export interface Refuel { + tank: Tank; + boughtFu: number; + /** Whole credits. */ + cost: number; +} + +/** + * Adds up to `amountFu` at `price`, never past `capacityFu` (decision 0.5.97), + * and keeps the tank's weighted average price. + */ +export function refuel(tank: Tank, capacityFu: number, amountFu: number, price: number): Refuel { + const boughtFu = Math.max(0, Math.min(amountFu, capacityFu - tank.fuelFu)); + const fuelFu = tank.fuelFu + boughtFu; + const avgPrice = + fuelFu > 0 ? (tank.fuelFu * tank.avgPrice + boughtFu * price) / fuelFu : tank.avgPrice; + return { + tank: { fuelFu, avgPrice }, + boughtFu, + cost: Math.min(Math.round(boughtFu * price), Math.ceil(boughtFu * price)), + }; +} + +/** The most fuel up to a full tank that `credits` can pay for (Auto top-up, decision 0.5.97). */ +export function affordableRefuel( + tank: Tank, + capacityFu: number, + price: number, + credits: number, +): Refuel { + const affordable = Math.max(0, credits) / price; + const result = refuel(tank, capacityFu, affordable, price); + // Rounding never charges more than the player has. + return { ...result, cost: Math.min(result.cost, Math.max(0, Math.floor(credits))) }; +} + +/** What a leg's fuel cost the player (decision 0.5.98): fu burned × the tank's average price. */ +export function legFuelCost(burnedFu: number, tank: Tank): number { + return Math.round(burnedFu * tank.avgPrice); } diff --git a/packages/sim/src/index.ts b/packages/sim/src/index.ts index ad5fec5..9e824f9 100644 --- a/packages/sim/src/index.ts +++ b/packages/sim/src/index.ts @@ -13,12 +13,16 @@ export type { Position } from "./positions"; export { routeDistance } from "./distance"; export { travelTimeGameHours } from "./travel"; export { + affordableRefuel, economicDistance, - fuelCost, fuelPriceAt, fullTankReach, legFuel, + legFuelCost, plainDistance, + refuel, + type Refuel, + type Tank, } from "./fuel"; export { canLandAt, checkRoute, rangeFrom } from "./eligibility"; @@ -49,7 +53,7 @@ export { createDispatchRecord, createRepositionRecord, shipStats, - tripFuelCost, + tripFuel, validateDispatch, wouldStrand, validateReposition, @@ -79,7 +83,7 @@ export { splitLoanRepayment } from "./loan"; export type { LoanSplit } from "./loan"; export { resolveDispatch } from "./resolution"; -export type { Resolution, ResolveInput, ResolveResult } from "./resolution"; +export type { RefuelPolicy, Resolution, ResolveInput, ResolveResult, TopUp } from "./resolution"; export { dutyAfterTrip, dutyLimit, isResting, needsRest, restEndsAt, tripGameHours } from "./duty"; diff --git a/packages/sim/src/resolution.ts b/packages/sim/src/resolution.ts index a89c371..00fd64c 100644 --- a/packages/sim/src/resolution.ts +++ b/packages/sim/src/resolution.ts @@ -1,18 +1,37 @@ import type { SimContent } from "./board"; import type { RealTime } from "./clock"; import type { DispatchRecord } from "./dispatch"; +import { findLocation } from "./dispatch"; import { dutyAfterTrip, dutyLimit, needsRest, restEndsAt, tripGameHours } from "./duty"; +import { affordableRefuel, fuelPriceAt, type Tank } from "./fuel"; import { splitLoanRepayment } from "./loan"; import { pilotShare } from "./pay"; import type { SkillLevels } from "./pilot"; import { deliveryXp } from "./xp"; +/** Auto top-up (default) fills the tank on landing; Manual never does (decision 0.5.97). */ +export type RefuelPolicy = "auto" | "manual"; + +/** Fuel an Auto top-up bought on landing (decision 0.5.97). */ +export interface TopUp { + locationId: string; + boughtFu: number; + /** Credits per fu. */ + price: number; + /** Whole credits, paid on top of the delivery's `credits`. */ + cost: number; + /** The tank afterwards. */ + tank: Tank; +} + export interface Resolution { /** - * Credits to add: payout minus pilot pay, minus the loan repayment. - * Fuel was already charged at departure. + * Credits to add: payout minus pilot pay, minus the loan repayment. Fuel + * came out of the tank at departure; any top-up is `topUp.cost`. */ credits: number; + /** Auto top-up on landing, or null (Manual, nothing bought, or no tank). */ + topUp: TopUp | null; /** A hired pilot's share of this delivery's profit; 0 for the avatar, tutorial runs and deadheads (CR-010). */ pilotPay: number; /** Credits that went to the lender out of this payout (decision 0.5.32). */ @@ -42,6 +61,30 @@ export interface ResolveInput { pilotDutyHours: number; pilotSkills: SkillLevels; now: RealTime; + /** + * The ship's tank on landing, its policy, and the company's credits before this + * resolution (Auto top-up buys what they cover). Absent: no top-up (the + * tutorial loaner uses no fuel). + */ + fuel?: { tank: Tank; capacityFu: number; refuelPolicy: RefuelPolicy; credits: number }; +} + +/** Auto top-up on landing (decision 0.5.97): as much as the credits after this trip cover. */ +function topUpOnLanding(input: ResolveInput, net: number, content: SimContent): TopUp | null { + const { fuel, record } = input; + if (!fuel || record.tutorial || fuel.refuelPolicy !== "auto") return null; + const location = findLocation(record.destinationId, content); + if (!location.sellsFuel) return null; + const price = fuelPriceAt(location, content.config); + const bought = affordableRefuel(fuel.tank, fuel.capacityFu, price, fuel.credits + net); + if (bought.boughtFu <= 0) return null; + return { + locationId: location.id, + boughtFu: bought.boughtFu, + price, + cost: bought.cost, + tank: bought.tank, + }; } /** @@ -73,6 +116,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol resolution: { ...base, credits: 0, + topUp: topUpOnLanding(input, 0, content), pilotPay: 0, loanRepayment: 0, pilotXp: 0, @@ -97,6 +141,7 @@ export function resolveDispatch(input: ResolveInput, content: SimContent): Resol resolution: { ...base, credits: net, + topUp: topUpOnLanding(input, net, content), pilotPay, loanRepayment: repayment, pilotXp: deliveryXp(record.distanceMillionKm, contract.tier, content.config), diff --git a/packages/sim/test/balance.test.ts b/packages/sim/test/balance.test.ts index c16b90d..cd8b173 100644 --- a/packages/sim/test/balance.test.ts +++ b/packages/sim/test/balance.test.ts @@ -1,7 +1,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import { simulate, type Milestones, type SimulationResult } from "../balance/simulate"; import { content } from "@deadhead/content"; -import { MS_PER_HOUR, tripFuelCost } from "../src/index"; +import { fuelPriceAt, MS_PER_HOUR, tripFuel } from "../src/index"; /** * Balance targets (plan Step 12.1 as amended by CR-001 to CR-013), checked on @@ -72,17 +72,20 @@ describe("balance simulation (Step 12.1)", () => { }); it("leaves fuel for at least three Courier runs from Earth after the deal (Step 2.5, measured)", () => { - // Step 12.2: the tutorial share and starting credits as actually earned. + // Step 12.2: the tutorial share and starting credits as actually earned. Since + // CR-015 the deal Courier's quarter tank counts, with credits to buy the rest at Earth. const courier = content.shipModel("courier")!; const [earth, moon] = [content.location("earth")!, content.location("moon")!]; - const fuel = tripFuelCost(courier, earth, moon, content.config); + const runFu = tripFuel(courier, earth, moon, content.config); + const inTank = courier.tankCapacityFu * content.config.deal.tankShare; + const earthPrice = fuelPriceAt(earth, content.config); for (const result of lighterFirst) { expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal); expect(result.tutorialShareEarned).toBeGreaterThan(0); expect(result.creditsAtDeal).toBe( content.config.startingCredits + result.tutorialShareEarned, ); - expect(result.creditsAtDeal!).toBeGreaterThanOrEqual(3 * fuel); + expect(inTank + result.creditsAtDeal! / earthPrice).toBeGreaterThanOrEqual(3 * runFu); } }); diff --git a/packages/sim/test/dispatch.test.ts b/packages/sim/test/dispatch.test.ts index 47c2250..39a013e 100644 --- a/packages/sim/test/dispatch.test.ts +++ b/packages/sim/test/dispatch.test.ts @@ -7,8 +7,12 @@ import { type PilotState, type ShipState, type SimContent, + tripFuel, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + /** Default content plus a test-only ship that cannot land on atmospheric worlds. */ const vacuumModel: ShipModel = { ...defaultContent.shipModel("courier")!, @@ -40,7 +44,13 @@ const contract: Contract = { expiresAt: Date.parse("2100-01-01T01:00:00Z"), firstContact: false, }; -const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const ship: ShipState = { + id: "s1", + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: PLENTY, +}; const pilot: PilotState = { id: "p1", isAvatar: false, @@ -49,7 +59,7 @@ const pilot: PilotState = { skills: {}, restEndsAt: null, }; -const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now }; +const valid: DispatchProposal = { ship, pilot, contract, tutorial: false, now }; const reason = (proposal: DispatchProposal) => { const result = validateDispatch(proposal, content); @@ -123,8 +133,20 @@ describe("dispatch validation", () => { 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"); + it("rejects a dispatch without the fuel on board, and passes once refueled (Step 15.4)", () => { + const needed = tripFuel( + defaultContent.shipModel("courier")!, + defaultContent.location("earth")!, + defaultContent.location("moon")!, + content.config, + ); + const withFuel = (fuelFu: number) => ({ + ...valid, + ship: { ...ship, tank: { fuelFu, avgPrice: 16 } }, + }); + expect(reason(withFuel(needed * 0.99))).toBe("not-enough-fuel"); + expect(reason({ ...valid, ship: { ...ship, tank: undefined } })).toBe("not-enough-fuel"); + expect(reason(withFuel(needed))).toBe("valid"); }); describe("tutorial runs", () => { @@ -137,8 +159,9 @@ describe("dispatch validation", () => { 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("accepts the avatar in the loaner, which carries no fuel (decision 0.5.94)", () => { + expect(tutorial.ship.tank).toBeUndefined(); + expect(reason(tutorial)).toBe("valid"); }); it("rejects a hired pilot in the loaner", () => { diff --git a/packages/sim/test/duty-dispatch.test.ts b/packages/sim/test/duty-dispatch.test.ts index a0e2b46..2db9b96 100644 --- a/packages/sim/test/duty-dispatch.test.ts +++ b/packages/sim/test/duty-dispatch.test.ts @@ -18,6 +18,9 @@ import { type ShipState, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const now = Date.parse("2100-01-01T00:30:00Z"); const contract: Contract = { id: "c1", @@ -37,7 +40,13 @@ const contract: Contract = { expiresAt: Date.parse("2100-01-01T01:00:00Z"), firstContact: false, }; -const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const ship: ShipState = { + id: "s1", + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: PLENTY, +}; const pilot: PilotState = { id: "p1", isAvatar: false, @@ -46,7 +55,7 @@ const pilot: PilotState = { skills: {}, restEndsAt: null, }; -const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now }; +const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, now }; const limit = dutyLimit({}, content.config); const resolveAt = (record: DispatchRecord, dutyBefore: number, at = record.arrivalAt) => { @@ -78,7 +87,6 @@ describe("duty in validation (Step 10D.3)", () => { pilot: { ...pilot, restEndsAt: now + 1 }, destinationId: "moon", discoveredIds: new Set(["earth", "moon"]), - credits: 10_000, now, }, content, diff --git a/packages/sim/test/effective-stats.test.ts b/packages/sim/test/effective-stats.test.ts index 372dfe7..f941c01 100644 --- a/packages/sim/test/effective-stats.test.ts +++ b/packages/sim/test/effective-stats.test.ts @@ -12,6 +12,9 @@ import { type ShipState, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const now = Date.parse("2100-01-01T00:30:00Z"); const courier = content.shipModel("courier")!; const freighter = content.shipModel("freighter")!; @@ -34,7 +37,13 @@ const contract: Contract = { expiresAt: Date.parse("2100-01-01T01:00:00Z"), firstContact: false, }; -const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const ship: ShipState = { + id: "s1", + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: PLENTY, +}; const pilot: PilotState = { id: "p1", isAvatar: false, @@ -43,7 +52,7 @@ const pilot: PilotState = { skills: {}, restEndsAt: null, }; -const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, credits: 10_000, now }; +const proposal: DispatchProposal = { ship, pilot, contract, tutorial: false, now }; const reason = (p: DispatchProposal) => { const result = validateDispatch(p, content); return result.ok ? "valid" : result.reason; diff --git a/packages/sim/test/first-contact-fit.test.ts b/packages/sim/test/first-contact-fit.test.ts index 8a067ff..9c4e6e7 100644 --- a/packages/sim/test/first-contact-fit.test.ts +++ b/packages/sim/test/first-contact-fit.test.ts @@ -7,6 +7,9 @@ import { type FleetMember, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const earth = content.location("earth")!; const start = Date.parse(content.config.epoch.realStart); const firstPeriod = boardPeriodIndex(start, content.config); @@ -26,7 +29,7 @@ const board = (periodIndex: number, discovered: string[], fleet: FleetMember[]) const clipperCanFly = (contract: ReturnType[number]) => validateDispatch( { - ship: { id: "s", modelId: "clipper", locationId: "earth", inFlight: false }, + ship: { id: "s", modelId: "clipper", locationId: "earth", inFlight: false, tank: PLENTY }, pilot: { id: "p", isAvatar: false, @@ -37,7 +40,6 @@ const clipperCanFly = (contract: ReturnType[number]) => }, contract, tutorial: false, - credits: 1e9, now: contract.offeredAt, discoveredIds: new Set(["earth", "moon"]), }, diff --git a/packages/sim/test/flight.test.ts b/packages/sim/test/flight.test.ts index 392b4c2..4883129 100644 --- a/packages/sim/test/flight.test.ts +++ b/packages/sim/test/flight.test.ts @@ -7,10 +7,13 @@ import { locationPosition, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const record = createRepositionRecord( { id: "r1", - ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, + ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY }, pilot: { id: "p1", isAvatar: false, diff --git a/packages/sim/test/fuel.test.ts b/packages/sim/test/fuel.test.ts index a8d3b9f..4474fff 100644 --- a/packages/sim/test/fuel.test.ts +++ b/packages/sim/test/fuel.test.ts @@ -2,7 +2,6 @@ import { content } from "@deadhead/content"; import { describe, expect, it } from "vitest"; import { economicDistance, - fuelCost, fullTankReach, legFuel, plainDistance, @@ -33,24 +32,23 @@ describe("economic distance (decision 0.5.84)", () => { }); }); -describe("fuel cost", () => { - it("costs more Earth→Moon than Moon→Earth for the same ship", () => { +describe("leg fuel", () => { + it("burns more Earth→Moon than Moon→Earth for the same ship", () => { const distance = routeDistance(earth, moon); - expect(fuelCost(courier, earth, distance, config)).toBeGreaterThan( - fuelCost(courier, moon, distance, config), + expect(legFuel(courier, earth, distance, config)).toBeGreaterThan( + legFuel(courier, moon, distance, config), ); }); it("scales with economic distance", () => { - expect(fuelCost(courier, earth, 10, config) / fuelCost(courier, earth, 5, config)).toBeCloseTo( + expect(legFuel(courier, earth, 10, config) / legFuel(courier, earth, 5, config)).toBeCloseTo( 2 ** exponent, ); }); - it("matches a hand calculation (Step 15.3: the leg's fuel at the origin's price)", () => { - // 20 fu per M km × economic distance of 0.5M km, at 10 cr/fu × Earth's 1.6. - const expected = 20 * ref * (0.5 / ref) ** exponent * 10 * 1.6; - expect(fuelCost(courier, earth, 0.5, config)).toBeCloseTo(expected); + it("matches a hand calculation", () => { + // 20 fu per M km × economic distance of 0.5M km. + expect(legFuel(courier, earth, 0.5, config)).toBeCloseTo(20 * ref * (0.5 / ref) ** exponent); }); }); diff --git a/packages/sim/test/loan.test.ts b/packages/sim/test/loan.test.ts index bbe0822..0c2408a 100644 --- a/packages/sim/test/loan.test.ts +++ b/packages/sim/test/loan.test.ts @@ -8,6 +8,9 @@ import { pilotShare, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + describe("loan repayment split", () => { it("takes min(balance, credits × share), rounded down to whole credits", () => { expect(splitLoanRepayment(1000, 5000, 0.3)).toEqual({ repayment: 300, net: 700 }); @@ -61,7 +64,7 @@ describe("resolution with a loan", () => { const record = createDispatchRecord( { id: "d1", - ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, + ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY }, pilot: { id: "p1", isAvatar: false, diff --git a/packages/sim/test/no-strand.test.ts b/packages/sim/test/no-strand.test.ts index 76f3fb4..cbb3bcc 100644 --- a/packages/sim/test/no-strand.test.ts +++ b/packages/sim/test/no-strand.test.ts @@ -14,6 +14,9 @@ import { type SimContent, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const loc = (id: string) => content.location(id)!; const now = Date.parse("2100-01-01T00:30:00Z"); const pilot: PilotState = { @@ -46,13 +49,18 @@ const fixture: SimContent = { }, ], }; -const hopper: ShipState = { id: "s1", modelId: "hopper", locationId: "earth", inFlight: false }; +const hopper: ShipState = { + id: "s1", + modelId: "hopper", + locationId: "earth", + inFlight: false, + tank: PLENTY, +}; const proposal: RepositionProposal = { ship: hopper, pilot, destinationId: "moon", discoveredIds: new Set(["earth", "moon"]), - credits: 100_000, now, }; @@ -86,7 +94,7 @@ describe("no-strand rule (Step 10L.3, decision 0.5.79)", () => { }); it("applies to contract dispatches too", () => { - const base = { ship: hopper, pilot, contract, tutorial: false, credits: 100_000, now }; + const base = { ship: hopper, pilot, contract, tutorial: false, now }; expect( validateDispatch({ ...base, discoveredIds: new Set(["earth", "moon"]) }, fixture), ).toEqual({ ok: false, reason: "would-strand" }); diff --git a/packages/sim/test/priority.test.ts b/packages/sim/test/priority.test.ts index 7b95ed7..c5851de 100644 --- a/packages/sim/test/priority.test.ts +++ b/packages/sim/test/priority.test.ts @@ -9,6 +9,9 @@ import { type Contract, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const earth = content.location("earth")!; const moon = content.location("moon")!; const periodIndex = 4_000; @@ -37,7 +40,7 @@ function mailContract(priority: boolean): Contract { function check(modelId: string, contract: Contract) { return validateDispatch( { - ship: { id: "s", modelId, locationId: "earth", inFlight: false }, + ship: { id: "s", modelId, locationId: "earth", inFlight: false, tank: PLENTY }, pilot: { id: "p", isAvatar: true, @@ -48,7 +51,6 @@ function check(modelId: string, contract: Contract) { }, contract, tutorial: false, - credits: 1_000_000, now: offeredAt, }, content, diff --git a/packages/sim/test/profit-share.test.ts b/packages/sim/test/profit-share.test.ts index 545a58a..a4a6e27 100644 --- a/packages/sim/test/profit-share.test.ts +++ b/packages/sim/test/profit-share.test.ts @@ -13,6 +13,9 @@ import { type ShipState, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const now = Date.parse("2100-01-01T00:30:00Z"); const contract: Contract = { id: "c1", @@ -32,7 +35,13 @@ const contract: Contract = { expiresAt: now + 3_600_000, firstContact: false, }; -const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const ship: ShipState = { + id: "s1", + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: PLENTY, +}; const pilot: PilotState = { id: "p1", isAvatar: false, diff --git a/packages/sim/test/record.test.ts b/packages/sim/test/record.test.ts index bebca8b..07f8920 100644 --- a/packages/sim/test/record.test.ts +++ b/packages/sim/test/record.test.ts @@ -8,6 +8,9 @@ import { type RecordInput, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const now = Date.parse("2100-01-01T00:30:00Z"); const contract: Contract = { id: "c1", @@ -29,7 +32,7 @@ const contract: Contract = { }; const input: RecordInput = { id: "d1", - ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }, + ship: { id: "s1", modelId: "courier", locationId: "earth", inFlight: false, tank: PLENTY }, pilot: { id: "p1", isAvatar: false, diff --git a/packages/sim/test/reposition.test.ts b/packages/sim/test/reposition.test.ts index fa3460c..0b77508 100644 --- a/packages/sim/test/reposition.test.ts +++ b/packages/sim/test/reposition.test.ts @@ -8,8 +8,17 @@ import { type ShipState, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const now = Date.parse("2100-01-01T00:30:00Z"); -const atEarth: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const atEarth: ShipState = { + id: "s1", + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: PLENTY, +}; const pilot: PilotState = { id: "p1", isAvatar: false, @@ -24,7 +33,6 @@ const valid: RepositionProposal = { pilot, destinationId: "moon", discoveredIds: new Set(["earth", "moon"]), - credits: 10_000, now, }; @@ -58,8 +66,10 @@ describe("deadhead (fly empty)", () => { ); }); - it("still needs fuel credits, an idle crew and an assigned pilot", () => { - expect(reason({ ...valid, credits: 0 })).toBe("insufficient-credits"); + it("still needs fuel on board, an idle crew and an assigned pilot", () => { + expect(reason({ ...valid, ship: { ...atEarth, tank: { fuelFu: 0, avgPrice: 16 } } })).toBe( + "not-enough-fuel", + ); expect(reason({ ...valid, ship: { ...atEarth, inFlight: true } })).toBe("ship-not-idle"); expect(reason({ ...valid, pilot: { ...pilot, inFlight: true } })).toBe("pilot-not-idle"); expect(reason({ ...valid, pilot: { ...pilot, assignedShipId: null } })).toBe( diff --git a/packages/sim/test/resolution.test.ts b/packages/sim/test/resolution.test.ts index 98c4108..0922ad0 100644 --- a/packages/sim/test/resolution.test.ts +++ b/packages/sim/test/resolution.test.ts @@ -11,6 +11,9 @@ import { pilotShare, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const now = Date.parse("2100-01-01T00:30:00Z"); const contract: Contract = { id: "c1", @@ -30,7 +33,13 @@ const contract: Contract = { expiresAt: Date.parse("2100-01-01T01:00:00Z"), firstContact: false, }; -const ship: ShipState = { id: "s1", modelId: "courier", locationId: "earth", inFlight: false }; +const ship: ShipState = { + id: "s1", + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: PLENTY, +}; const pilot: PilotState = { id: "p1", isAvatar: false, diff --git a/packages/sim/test/static-routes.test.ts b/packages/sim/test/static-routes.test.ts index 2d271f6..1760073 100644 --- a/packages/sim/test/static-routes.test.ts +++ b/packages/sim/test/static-routes.test.ts @@ -3,6 +3,9 @@ import { describe, expect, it } from "vitest"; import { buildContract } from "../src/board"; import { createDispatchRecord, type Contract } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const earth = content.location("earth")!; const ceres = content.location("ceres-station")!; const good = content.goods.find( @@ -31,7 +34,13 @@ function offered(periodIndex: number): Contract { } function flown(contract: Contract, now: number) { - const ship = { id: "s1", modelId: "freighter", locationId: "earth", inFlight: false }; + const ship = { + id: "s1", + modelId: "freighter", + locationId: "earth", + inFlight: false, + tank: PLENTY, + }; const pilot = { id: "p1", isAvatar: false, diff --git a/packages/sim/test/tank.test.ts b/packages/sim/test/tank.test.ts new file mode 100644 index 0000000..e8648f7 --- /dev/null +++ b/packages/sim/test/tank.test.ts @@ -0,0 +1,168 @@ +import { content } from "@deadhead/content"; +import { describe, expect, it } from "vitest"; +import { + affordableRefuel, + createDispatchRecord, + fuelPriceAt, + legFuel, + legFuelCost, + pilotShare, + refuel, + resolveDispatch, + routeDistance, + type Contract, + type PilotState, + type ResolveInput, + type ShipState, +} from "../src/index"; + +const { config } = content; +const courier = content.shipModel("courier")!; +const [earth, moon] = [content.location("earth")!, content.location("moon")!]; +const now = Date.parse("2100-01-01T00:30:00Z"); +const MOON_PRICE = fuelPriceAt(moon, config); + +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, +}; +const pilot: PilotState = { + id: "p1", + isAvatar: false, + assignedShipId: "s1", + inFlight: false, + skills: {}, + restEndsAt: null, +}; +/** A Courier at Earth with a full tank bought at Earth's price. */ +const ship: ShipState = { + id: "s1", + modelId: "courier", + locationId: "earth", + inFlight: false, + tank: { fuelFu: courier.tankCapacityFu, avgPrice: fuelPriceAt(earth, config) }, +}; +const record = createDispatchRecord( + { id: "d1", ship, pilot, contract, tutorial: false, now, worldSeed: 7 }, + content, +); +/** The tank on landing: what was left after the leg's fuel came out at departure. */ +const landedTank = { ...ship.tank!, fuelFu: ship.tank!.fuelFu - record.fuelBurnedFu }; + +function landing(policy: "auto" | "manual", credits: number): ResolveInput { + return { + record, + pilotIsAvatar: false, + discoveredIds: new Set(["earth", "moon"]), + loanBalance: 0, + pilotDutyHours: 0, + pilotSkills: {}, + now: record.arrivalAt, + fuel: { tank: landedTank, capacityFu: courier.tankCapacityFu, refuelPolicy: policy, credits }, + }; +} +function resolve(input: ResolveInput) { + const result = resolveDispatch(input, content); + if (result.status !== "resolved") throw new Error("expected a resolution"); + return result.resolution; +} + +describe("fuel tanks (Step 15.4)", () => { + it("burns less from the Moon: the leg is shortened by 1.3 before the pay curve (0.5.95)", () => { + const d = routeDistance(earth, moon); + // The CR's ÷ 1.3, applied before the economic-distance curve (user decision). + expect(legFuel(courier, moon, d, config)).toBeCloseTo(legFuel(courier, earth, d / 1.3, config)); + expect(legFuel(courier, moon, d, config)).toBeCloseTo( + legFuel(courier, earth, d, config) / 1.3 ** config.economicDistance.exponent, + ); + }); + + it("averages the price of what is in the tank", () => { + const filled = refuel({ fuelFu: 50, avgPrice: 2 }, 200, 50, 1); + expect(filled.tank).toEqual({ fuelFu: 100, avgPrice: 1.5 }); + expect(filled.boughtFu).toBe(50); + expect(filled.cost).toBe(50); + }); + + it("never fills past capacity", () => { + const filled = refuel({ fuelFu: 90, avgPrice: 2 }, 100, 50, 1); + expect(filled.boughtFu).toBe(10); + expect(filled.tank.fuelFu).toBe(100); + }); + + it("buys exactly what the credits cover, up to a full tank", () => { + // Half a tank's worth of credits at 10 cr/fu. + const half = affordableRefuel({ fuelFu: 0, avgPrice: 0 }, 100, 10, 500); + expect(half.boughtFu).toBe(50); + expect(half.cost).toBe(500); + expect(affordableRefuel({ fuelFu: 0, avgPrice: 0 }, 100, 10, 5_000).boughtFu).toBe(100); + expect(affordableRefuel({ fuelFu: 0, avgPrice: 0 }, 100, 10, 0).boughtFu).toBe(0); + }); + + it("takes the leg's fuel from the tank and costs it at the tank's average price", () => { + const burned = legFuel(courier, earth, routeDistance(earth, moon), config); + expect(record.fuelBurnedFu).toBeCloseTo(burned); + expect(record.fuelCost).toBe(legFuelCost(burned, ship.tank!)); + expect(record.fuelCost).toBe(Math.round(burned * 16)); + }); + + it("pays a hired pilot from fuel burned × average price (decision 0.5.98)", () => { + expect(resolve(landing("manual", 0)).pilotPay).toBe( + pilotShare(contract.payout, record.fuelCost, config), + ); + }); + + it("tops up to a full tank on landing with Auto, paying the local price", () => { + const { topUp } = resolve(landing("auto", 10_000)); + expect(topUp).not.toBeNull(); + expect(topUp!.locationId).toBe("moon"); + expect(topUp!.tank.fuelFu).toBeCloseTo(courier.tankCapacityFu); + expect(topUp!.boughtFu).toBeCloseTo(record.fuelBurnedFu); + expect(topUp!.price).toBe(MOON_PRICE); + expect(topUp!.cost).toBe(Math.round(record.fuelBurnedFu * MOON_PRICE)); + }); + + it("buys nothing with Manual", () => { + expect(resolve(landing("manual", 10_000)).topUp).toBeNull(); + }); + + it("buys only what the credits after this delivery cover", () => { + const delivery = resolve(landing("manual", 0)).credits; + // Credits that, with this delivery's pay, cover half the fuel burned. + const halfCost = (record.fuelBurnedFu / 2) * MOON_PRICE; + const { topUp } = resolve(landing("auto", halfCost - delivery)); + expect(topUp!.boughtFu).toBeCloseTo(record.fuelBurnedFu / 2); + expect(topUp!.cost).toBeLessThanOrEqual(Math.floor(halfCost)); + }); + + it("leaves the tutorial loaner's fuel alone", () => { + const loaner: ShipState = { + id: "loaner", + modelId: "company-courier", + locationId: "earth", + inFlight: false, + }; + const avatar = { ...pilot, id: "avatar", isAvatar: true, assignedShipId: null }; + const tutorialRun = createDispatchRecord( + { id: "t1", ship: loaner, pilot: avatar, contract, tutorial: true, now, worldSeed: 7 }, + content, + ); + expect(tutorialRun.fuelBurnedFu).toBe(0); + expect(tutorialRun.fuelCost).toBe(0); + expect(resolve({ ...landing("auto", 10_000), record: tutorialRun }).topUp).toBeNull(); + }); +}); diff --git a/packages/sim/test/upgrade-validation.test.ts b/packages/sim/test/upgrade-validation.test.ts index bd709cc..c96c2ad 100644 --- a/packages/sim/test/upgrade-validation.test.ts +++ b/packages/sim/test/upgrade-validation.test.ts @@ -11,10 +11,19 @@ import { type UpgradeProposal, } from "../src/index"; +/** Fuel enough for any leg in these tests (decision 0.5.94), bought at 10 cr/fu. */ +const PLENTY = { fuelFu: 10_000, avgPrice: 10 }; + const now = Date.parse("2100-01-01T00:30:00Z"); const freighter = content.shipModel("freighter")!; const engines = content.upgrade("engines")!; -const ship: ShipState = { id: "s1", modelId: "freighter", locationId: "earth", inFlight: false }; +const ship: ShipState = { + id: "s1", + modelId: "freighter", + locationId: "earth", + inFlight: false, + tank: PLENTY, +}; const valid: UpgradeProposal = { ship, upgrade: engines, @@ -104,7 +113,6 @@ describe("ships in the shop can't fly (Step 10I.3)", () => { pilot, contract, tutorial: false, - credits: 1e6, now: time, }, content, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a94b712..2ebc709 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,6 +163,9 @@ importers: packages/schema: dependencies: + '@deadhead/content': + specifier: workspace:* + version: link:../content '@deadhead/sim': specifier: workspace:* version: link:../sim