diff --git a/DECISIONS.md b/DECISIONS.md index 4aa3803..ca4359b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1598,3 +1598,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **Base fuel price halved to 5 cr/fu, with no further tuning** (user decision): the gate is "no route above 25%", which the halved price meets on every route but the Clipper's Mars → Earth. Most routes are now under 15% (the Courier's Earth → Moon is 10.5%); fuel is cheap by design. - **First Market purchase window 30–95 min** (user decision, was 45–95): halving fuel moved the median to 41.6 min, and full deal tanks (Step 18.3) to about 37. Buying sooner is what CR-018 is for. + +## Step 18.3 + +- **`config.deal.tankShare` removed** rather than set to 1: the deal's ships are now full like every other new ship, so there's no setting left to tune. +- **Save version 3** carries the Manual migration: a version-2 save can't tell a chosen Auto from the default, and CR-018 §3 says nobody chose it. +- **The simulated player refuels lazily**: before a job it compares the best job on the fuel aboard with the best after topping up (as far as credits go), and buys only if that opens a better job; with no job here it fills up to go looking. The milestones match always filling up (36.7 min first purchase), and no world needs emergency fuel. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index 29f7fdd..a04e32f 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: 2"); + expect(text).toContain("Save version: 3"); 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/memory-bank/architecture.md b/memory-bank/architecture.md index 7fc08e6..58726bb 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any save format | Package | Role | Depends on | |---|---|---| | `packages/content` | Static game data (locations, ship models, cargo, skills, `GameConfig`) as Zod-validated TypeScript, plus the `content` bundle with id lookups. | zod | -| `packages/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. No I/O, no `Date.now()`, no `Math.random()`. `balance/` holds the balance simulation (Step 12.1), outside `src/` and the coverage measure. Positions are fixed (CR-015): `locationPosition` reads the location's `position` from content. Fuel (`fuel.ts`, CR-015): `legFuel` = burn × economic distance(distance ÷ origin factor), `fullTankReach` (its inverse for a full tank), `fuelPriceAt`; `checkRoute(ship, from, to, config)` compares the distance with full-tank reach. Tanks (Step 15.4): `ShipState.tank` (`fuelFu`, `avgPrice`), `refuel`, `affordableRefuel`, `legFuelCost`; validation fails with `not-enough-fuel`; records carry `fuelBurnedFu` and `fuelCost` (burned × average price); `resolveDispatch` takes an optional `fuel` block and returns `topUp` (Auto top-up on landing). `wouldStrand(model, destination, fuelOnArrivalFu, discovered, content)` uses a full tank where the destination sells fuel, else the fuel left (Step 15.5); `emergencyFuel` gives the offer (amount, price, cost) or null. | content | +| `packages/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; new ships are on Manual since CR-018). `balance/fuel-share.ts` (CR-018): each ship's fuel share of the median payout per route. `wouldStrand(model, destination, fuelOnArrivalFu, discovered, content)` uses a full tank where the destination sells fuel, else the fuel left (Step 15.5); `emergencyFuel` gives the offer (amount, price, cost) or null. | content | | `packages/engine` | The game on the device (CR-014): commands over a save document, using storage, the clock and notifications only through injected interfaces. `notifications.ts`: the schedule is recomputed from the save after every saved change and replaces the platform's (`NotificationScheduler`: Capacitor on native, no-op on the web). `DeviceClock`: `max(device time + dev offset, last time seen)`, never backward. | content, schema, sim | | `packages/schema` | Zod schemas for every API request, response and error. Shared by the engine and the client. | sim (error codes), zod | | `apps/web` | React client; runs the engine on the device (CR-014). | content, engine, sim, schema | @@ -79,7 +79,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 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. +One document per company on the device, validated on every load and save: `saveVersion` (currently 3; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018), `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 4f6d70e..6786d51 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -430,6 +430,6 @@ Spec: `memory-bank/change-requests/CR-018-fuel-tuning.md` (received as "CR-017 - [x] **Step 18.1 — Sim: fuel-to-payout balance check.** `packages/sim/balance/fuel-share.ts`: for every purchasable ship model and every route it can fly, boards at every origin (all locations discovered, that ship as the fleet, 40 periods × 3 worlds), the contracts that fit its hold, and the leg's fuel at the origin's price against their median payout. The balance script prints the table. At the old price (10 cr/fu) the shares ran from 0.7% (Bulk Hauler Jupiter → Moon) to 77.5% (Clipper Mars → Earth); the Courier's Earth → Moon was 20.9%. No single price can put every row in 15–25%, so the user chose to halve the price and gate only rows above 25% (Step 18.2). Tests (`packages/sim/test/fuel-share.test.ts`): a row for every purchasable ship, each on a route it can fly with contracts offered, Earth → Moon for every ship; the Courier's Earth → Moon row is its leg fuel × Earth's price over the median; the table has one line per row. - [x] **Step 18.2 — Content: lower the base fuel price.** `baseFuelPricePerFu` 10 → 5 (Earth 8 cr/fu, Jupiter 3); location multiples unchanged. Every ship and route is now at most 25% of the median fitting payout except the Clipper's Mars → Earth (38.8%); the Courier's Earth → Moon is 10.5%. The first Market purchase target is now 30–95 min (user decision): cheaper fuel brings the Clipper's first long landing, and so the first purchase, forward. Balance medians: deal 3.7 min, first purchase 41.6, discovery 39.3, loan 128.1, no violations. Tests: `packages/sim/test/fuel-share.test.ts` (only the Clipper's Mars → Earth is above 25%); `balance.test.ts` (30–95 window); fuel prices updated in `tank.test.ts`, the save 1 → 2 migration test and the Locations fuel test. -- [ ] Step 18.3 — Engine: deal ships start full; Manual by default +- [x] **Step 18.3 — Engine: deal ships start full; Manual by default.** `deliveredTank` gives every new ship (deal or Market) a full tank at the bay's price on Manual; `config.deal.tankShare` is gone. Save version 3: the 2 → 3 migration puts every ship on Manual, leaving fuel, prices and credits as they were. The balance simulator gives the deal's ships full tanks, flies on Manual and taps "Refuel here first" only when the best job needs more fuel than is aboard, or to go looking for work. Balance medians (7 worlds): deal 3.7 min, first purchase 36.7 (30–95), discovery 36.6, loan 123.9, four ships 122.7 vs 122.9, no violations. Tests: `packages/engine/test/fuel.test.ts` (the deal's Courier and Clipper full at Earth's price on Manual; a Market ship full on Manual; landing on Manual buys nothing and changes the tank only by the leg; switched to Auto it still tops up on landing; an old save opens with every ship full on Manual); `packages/schema/test/save.test.ts` (2 → 3: every ship Manual, fuel and credits unchanged; 1 → 3 end to end); the deadhead and About tests updated. - [ ] Step 18.4 — Client: low-fuel warning and policy display - [ ] Step 18.5 — Plan amendments and regression diff --git a/packages/content/src/data/config.ts b/packages/content/src/data/config.ts index 1930bc6..9d6d952 100644 --- a/packages/content/src/data/config.ts +++ b/packages/content/src/data/config.ts @@ -31,7 +31,6 @@ export const gameConfig: GameConfig = { 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. diff --git a/packages/content/src/schemas.ts b/packages/content/src/schemas.ts index 8a199de..ea5712b 100644 --- a/packages/content/src/schemas.ts +++ b/packages/content/src/schemas.ts @@ -243,7 +243,6 @@ export const gameConfigSchema = z 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 6c73ad4..bad8c79 100644 --- a/packages/content/test/config.test.ts +++ b/packages/content/test/config.test.ts @@ -40,8 +40,6 @@ 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 ddc295f..d03fe97 100644 --- a/packages/content/test/schemas.test.ts +++ b/packages/content/test/schemas.test.ts @@ -119,7 +119,6 @@ const validGameConfig = { avatarShipModelId: "courier", veteranShipModelId: "clipper", bayLocationId: "earth", - tankShare: 0.25, }, }; diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts index 6129015..89bc7fa 100644 --- a/packages/engine/src/game/company.ts +++ b/packages/engine/src/game/company.ts @@ -121,8 +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), + // Halvorsen's ships come full, at Earth's price (CR-018, decision 0.5.111). + ...deliveredTank(modelId, bayLocationId), }); } save.bays.push({ id: random.id(), locationId: bayLocationId, createdAt: now }); diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index bc5b850..9f1e839 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -75,20 +75,22 @@ 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. */ +/** + * A new ship's tank: full, bought at `locationId`'s price, on Manual refueling + * (decisions 0.5.111, 0.5.112). + */ 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, + fuelFu: model.tankCapacityFu, fuelAvgPrice: fuelPriceAt(location, content.config), - refuelPolicy: "auto", + refuelPolicy: "manual", }; } diff --git a/packages/engine/test/fuel.test.ts b/packages/engine/test/fuel.test.ts index 7197c3b..63c450a 100644 --- a/packages/engine/test/fuel.test.ts +++ b/packages/engine/test/fuel.test.ts @@ -22,7 +22,7 @@ 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 () => { + it("delivers a Market ship with a full tank, bought at the bay's price, on Manual (CR-018)", async () => { const { app } = testApp(); const owner = await signUpOwner(app); await setCredits(app, content.shipModel("freighter")!.price); @@ -30,13 +30,13 @@ describe("fuel tanks in the engine (Step 15.4)", () => { expect(ship).toMatchObject({ fuelFu: tank("freighter"), fuelAvgPrice: price("earth"), - refuelPolicy: "auto", + refuelPolicy: "manual", }); // 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 () => { + it("hands over the deal's ships full, at Earth's price, on Manual (CR-018, decisions 0.5.111, 0.5.112)", async () => { const { app } = testApp(); await signUp(app); await edit(app, (save) => { @@ -45,15 +45,17 @@ describe("fuel tanks in the engine (Step 15.4)", () => { }); 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.fuelFu, ship.modelId).toBeCloseTo(tank(ship.modelId)); expect(ship.fuelAvgPrice).toBe(price("earth")); + expect(ship.refuelPolicy).toBe("manual"); } }); - it("burns the leg from the tank and, with Auto, tops up on landing at the local price", async () => { + it("burns the leg from the tank and, switched to Auto, tops up on landing at the local price", async () => { const { app, clock } = testApp(); const owner = await signUpOwner(app); await setCredits(app, 10_000); + await app.call("setRefuelPolicy", { shipId: owner.shipId, policy: "auto" }); const contract = testContract(); await putBoard(app, [contract]); const { dispatch } = await app.call("createDispatch", { @@ -75,13 +77,13 @@ describe("fuel tanks in the engine (Step 15.4)", () => { expect(state.ships[0]!.fuelFu).toBeCloseTo(tank("courier")); }); - it("leaves the tank as it landed with Manual", async () => { + it("leaves the tank as it landed, and spends nothing on fuel, on Manual (the default)", 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"; - }); + expect((await readSave(app)).ships.find((s) => s.id === owner.shipId)!.refuelPolicy).toBe( + "manual", + ); const contract = testContract(); await putBoard(app, [contract]); const { dispatch } = await app.call("createDispatch", { @@ -120,14 +122,14 @@ function versionOneSave() { } describe("fuel commands (Step 15.6)", () => { - it("opens an older save with every ship full, on Auto top-up", async () => { + it("opens an older save with every ship full, on Manual (CR-018)", async () => { const { app } = testApp(); const { state } = await app.call("importSave", { code: await encodeSave(versionOneSave() as never), }); for (const ship of state.ships) { expect(ship.fuelFu, ship.name).toBeCloseTo(ship.stats.tankCapacityFu); - expect(ship.refuelPolicy).toBe("auto"); + expect(ship.refuelPolicy).toBe("manual"); } }); diff --git a/packages/engine/test/ported/reposition.test.ts b/packages/engine/test/ported/reposition.test.ts index 658edca..fb95b8d 100644 --- a/packages/engine/test/ported/reposition.test.ts +++ b/packages/engine/test/ported/reposition.test.ts @@ -33,7 +33,8 @@ 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 }); - // Fuel came out of the tank; landing on Auto top-up bought it back at the Moon (CR-015). + // Fuel came out of the tank (CR-015); on Manual, the default since CR-018, + // nothing is bought on landing. const [summary] = state.summaries; expect(state.summaries).toHaveLength(1); expect(summary).toMatchObject({ @@ -41,11 +42,12 @@ describe("deadhead dispatch", () => { fuelCost: dispatch.fuelCost, credits: 0, pilotXp: 0, - topUp: { locationId: "moon" }, + topUp: null, }); - 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); + expect(state.company.credits).toBe(10_000); + expect(state.ships[0]!.fuelFu).toBeCloseTo( + state.ships[0]!.stats.tankCapacityFu - dispatch.fuelBurnedFu, + ); }); it("refuses an undiscovered destination and changes nothing", async () => { diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index 13a250b..e183e27 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -135,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 = 2; +export const CURRENT_SAVE_VERSION = 3; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -229,11 +229,23 @@ function addFuelTanks(save: Doc): Doc { return { ...save, ships, dispatches }; } +/** + * Version 2 → 3 (CR-018 §3): every ship goes to Manual refueling. Auto was the + * default, not a choice; fuel, prices and credits are untouched. + */ +function manualRefueling(save: Doc): Doc { + const ships = records(save.ships).map((ship) => ({ ...ship, refuelPolicy: "manual" })); + return { ...save, ships }; +} + /** * 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 const SAVE_MIGRATIONS: readonly SaveMigration[] = [ + { from: 1, migrate: addFuelTanks }, + { from: 2, migrate: manualRefueling }, +]; 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 c47edf7..c654f7a 100644 --- a/packages/schema/test/save-fixture.ts +++ b/packages/schema/test/save-fixture.ts @@ -45,7 +45,7 @@ export function fullSave(): SaveDocument { seed: 42, }); return { - saveVersion: 2, + saveVersion: 3, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index a608c2a..76ceaf2 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(2); + expect(CURRENT_SAVE_VERSION).toBe(3); }); it("rejects an invalid save with a readable error naming the field", () => { @@ -79,17 +79,17 @@ function versionOne(): Record { } describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => { - it("fills every ship's tank, on Auto top-up, and changes nothing else", () => { + it("fills every ship's tank and changes nothing else (then 2 → 3 puts it on Manual)", () => { const result = migrateSave(versionOne()); if (!result.ok) throw new Error(result.error); const { save } = result; const before = fullSave(); - expect(save.saveVersion).toBe(2); + expect(save.saveVersion).toBe(3); // The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base // price, 5 cr/fu since CR-018 (decision 0.5.110). - expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "auto" }); + expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" }); // The Clipper is at Earth: a full 1,170 fu at Earth's 8 cr/fu. - expect(save.ships[1]).toMatchObject({ fuelFu: 1170, fuelAvgPrice: 8, refuelPolicy: "auto" }); + expect(save.ships[1]).toMatchObject({ fuelFu: 1170, fuelAvgPrice: 8, refuelPolicy: "manual" }); // 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); @@ -103,3 +103,25 @@ describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => { expect(save.pilots).toEqual(before.pilots); }); }); + +describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => { + it("puts every ship on Manual and leaves fuel and credits as they were", () => { + const before = fullSave(); + const old = JSON.parse(JSON.stringify(before)) as Record & { + ships: Record[]; + }; + old.saveVersion = 2; + old.ships[0]!.refuelPolicy = "auto"; + old.ships[1]!.refuelPolicy = "auto"; + const result = migrateSave(old); + if (!result.ok) throw new Error(result.error); + const { save } = result; + expect(save.saveVersion).toBe(3); + expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual")); + expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual( + before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]), + ); + expect(save.company).toEqual(before.company); + expect({ ...save, ships: [] }).toEqual({ ...before, ships: [] }); + }); +}); diff --git a/packages/sim/balance/simulate.ts b/packages/sim/balance/simulate.ts index bae1c9d..c82cf4b 100644 --- a/packages/sim/balance/simulate.ts +++ b/packages/sim/balance/simulate.ts @@ -332,7 +332,8 @@ export function simulate(options: SimulationOptions): SimulationResult { tank: flightShip.tank, capacityFu: effectiveStats(model(flightShip.modelId), flightShip.upgrades, content) .tankCapacityFu, - refuelPolicy: "auto", + // New ships are on Manual (CR-018, decision 0.5.112). + refuelPolicy: "manual", credits, } : undefined, @@ -464,8 +465,8 @@ export function simulate(options: SimulationOptions): SimulationResult { 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), + // Market ships and the deal's ships come full (CR-015, decision 0.5.111). + fuelFu: model(modelId).tankCapacityFu, avgPrice: fuelPriceAt(loc(locationId), config), }, upgrades: {}, @@ -586,24 +587,36 @@ 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); - // CR-015 §7 (Step 15.9): on Auto top-up with money in the bank, no ship - // should ever be stuck needing emergency fuel. + // CR-015 §7 (Step 15.9): with money in the bank, no ship should ever be + // stuck needing emergency fuel. if ( credits > 0 && emergencyFuel({ ship, credits, discoveredIds: discovered }, content) !== null ) { violate("needs-emergency-fuel", `${ship.id} ${ship.modelId} at ${ship.locationId}`); } - const choices = eligibleContracts(ship, pilot) - .map((c) => ({ c, value: rate(c, stats, pilot) })) - .filter((o) => o.value > 0) - .sort((a, b) => b.value - a.value); - if (choices[0]) { - dispatch(ship, pilot, choices[0].c); + const best = (candidate: SimShip) => + eligibleContracts(candidate, pilot) + .map((c) => ({ c, value: rate(c, stats, pilot) })) + .filter((o) => o.value > 0) + .sort((a, b) => b.value - a.value)[0]; + // Manual refueling (CR-018): the player taps "Refuel here first" only + // when the best job needs more fuel than is on board. + let choice = best(ship); + const topped = toppedUp(ship, stats.tankCapacityFu); + const better = topped && best(topped.ship); + if (topped && better && (!choice || better.value > choice.value)) { + credits -= topped.cost; + ship.tank = topped.ship.tank; + choice = better; + } + if (choice) { + dispatch(ship, pilot, choice.c); acted = true; continue; } + // No job here: fuel up to go looking for one. + topUpHere(ship, stats.tankCapacityFu); const canDeadhead = (to: string, fullTank: boolean) => to !== ship.locationId && validateReposition( @@ -667,14 +680,21 @@ export function simulate(options: SimulationOptions): SimulationResult { return acted; } + /** The ship with its tank topped up where it is, as far as credits go, and the cost; null if nothing to buy. */ + function toppedUp(ship: SimShip, capacityFu: number): { ship: SimShip; cost: number } | null { + const location = loc(ship.locationId); + if (!location.sellsFuel || ship.tank.fuelFu >= capacityFu) return null; + const bought = affordableRefuel(ship.tank, capacityFu, fuelPriceAt(location, config), credits); + if (bought.boughtFu <= 0) return null; + return { ship: { ...ship, tank: bought.tank }, cost: bought.cost }; + } + /** 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; + const topped = toppedUp(ship, capacityFu); + if (!topped) return; + credits -= topped.cost; + ship.tank = topped.ship.tank; } function checkCashflow() { diff --git a/packages/sim/test/balance.test.ts b/packages/sim/test/balance.test.ts index 3c1cb04..d1020ea 100644 --- a/packages/sim/test/balance.test.ts +++ b/packages/sim/test/balance.test.ts @@ -67,7 +67,7 @@ describe("balance simulation (Step 12.1)", () => { } }); - it("never needs emergency fuel on Auto top-up with positive credits (CR-015, Step 15.9)", () => { + it("never needs emergency fuel with positive credits (CR-015, Step 15.9; Manual since CR-018)", () => { for (const result of [...lighterFirst, ...freighterFirst]) { const stuck = result.violations.filter((v) => v.rule === "needs-emergency-fuel"); expect(stuck, `${result.policy}`).toEqual([]); @@ -81,11 +81,11 @@ 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. Since - // CR-015 the deal Courier's quarter tank counts, with credits to buy the rest at Earth. + // CR-015 the deal Courier's tank counts (full since CR-018), with credits to buy more at Earth. const courier = content.shipModel("courier")!; const [earth, moon] = [content.location("earth")!, content.location("moon")!]; const runFu = tripFuel(courier, earth, moon, content.config); - const inTank = courier.tankCapacityFu * content.config.deal.tankShare; + const inTank = courier.tankCapacityFu; const earthPrice = fuelPriceAt(earth, content.config); for (const result of lighterFirst) { expect(result.tutorialRuns).toBe(content.config.tutorialRunsBeforeDeal);