From dbdca5641306b95e12a2878a7b22b0dc782d9429 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 10 Oct 2026 20:49:35 -0400 Subject: [PATCH] Step 19.3: engine: boards, filtering, and migration Location boards are generated for the qualifying fleet and store it; reads hide jobs it can no longer complete. Save version 4 drops stored boards (decisions 0.5.115, 0.5.117). Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 9 +- apps/web/test/about.test.tsx | 2 +- memory-bank/architecture.md | 6 +- memory-bank/progress.md | 2 +- packages/engine/src/game/boards.ts | 60 ++++++++-- .../engine/test/completable-boards.test.ts | 103 ++++++++++++++++++ packages/engine/test/support.ts | 1 + packages/schema/src/save.ts | 28 ++++- packages/schema/test/save-fixture.ts | 3 +- packages/schema/test/save.test.ts | 30 ++++- 10 files changed, 219 insertions(+), 25 deletions(-) create mode 100644 packages/engine/test/completable-boards.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 86d2e3e..395e336 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1636,4 +1636,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **No fleet means no filter** in the sim API (tools, the class-mix test). The engine always passes one, and an empty fleet gets an empty board. - **Contract ids number the kept jobs** (`origin:period:n`, n = 0…), as before. - **The simulated player's fuel reserve** is now the larger of 5,000 cr and what filling every landed ship costs where it is. Under Manual refuelling, the flat 5,000 let a purchase leave a Clipper at Venus unable to buy fuel (seed 777). -- **The fuel-share check's pilot is skilled in everything**, so the check measures what fits the *ship*; an unskilled pilot would now see only local, low-paying jobs. +- **The fuel-share check's pilot is skilled in everything**, so the check measures what fits the _ship_; an unskilled pilot would now see only local, low-paying jobs. + +## Step 19.3 + +- **The display filter uses the qualifying fleet as it is now**, not the stored snapshot: that is what hides a job once its only capable ship has left. The snapshot is kept so the generated board can be reproduced, and for debugging. +- **When every ship leaves a location, its board falls back to the whole fleet** (decision 0.5.115), so its jobs show again for deciding where to go. That is why the hiding test keeps the Courier at Earth. +- **Dispatch doesn't consult the filter**: a contract hidden from the board fails `validateDispatch` for every pair anyway, with its reason. +- **Discarding boards mid-period can re-use contract ids**: a regenerated board numbers its jobs from 0 again, so a job accepted before the update hides the regenerated job with its id for the rest of that period. That lasts at most one 10-minute period after the update. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index a04e32f..1632667 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: 3"); + expect(text).toContain("Save version: 4"); 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 58726bb..cc4eaf6 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; 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/sim` | Pure, deterministic game rules: clock, seeded RNG, positions, distances, travel, fuel, eligibility, contract boards, dispatch validation/records, flight progress, XP, resolution. Completable boards (CR-019, `completable.ts`): `permanentFailure`/`completability`/`isCompletable` check a contract against ship-and-pilot capability snapshots (`FleetMember`: model, upgrades, skills, fuel); `qualifyingFleet` picks the pairs for a location; `generateContractBoard` with a fleet keeps only completable candidates (up to `boardMaxAttempts`). 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 | @@ -49,7 +49,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re | `getServerTime` | `GET /api/time` | — | `ServerTime` | no | | `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` (playerId is the save id) | no | | `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes | -| `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` | yes | +| `getContractBoard` | `GET /api/locations/:locationId/board` | `locationId` | `{ contracts, periodEndsAt }` (only jobs the qualifying fleet can complete, CR-019) | yes | | `getTutorialBoard` | `GET /api/tutorial/board` | — | `{ contracts, periodEndsAt }` | yes | | `createDispatch` | `POST /api/dispatches` | `{ kind: "contract", contractId, shipId, pilotId }` / `{ kind: "tutorial", contractId }` / `{ kind: "reposition", shipId, pilotId, destinationId }` | `{ dispatch }` | yes | | `startUpgrade` | `POST /api/ships/:shipId/upgrades` | `shipId`, `upgradeId` (next level; ship idle at an HQ) | `{ ship }` | yes | @@ -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 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. +One document per company on the device, validated on every load and save: `saveVersion` (currently 4; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019), `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 d56c9ec..ff61894 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -442,6 +442,6 @@ Spec: `memory-bank/change-requests/CR-019-completable-boards.md` (received as "C - [x] **Step 19.1 — Sim: completability check.** `packages/sim/src/completable.ts`: `permanentFailure(contract, member, discoveredIds, content)` gives a pair's first failing permanent check: over-capacity, cannot-land, out-of-range, would-strand, too-slow, cargo-skill-too-low, long-haul-too-low. It uses the same rules and order as `validateDispatch`, with the tank counted full where the origin sells fuel and the fuel on board where it doesn't. `completability` returns whether any pair passes plus each pair's failure; `isCompletable` is the shorthand. `qualifyingFleet` builds the pairs for a location (ships there or landing before the period ends, with their pilot or any unassigned pilot there if the player has a bay there; else the whole fleet). `FleetMember` (now with `fuelFu`) moved here from `board.ts`. Tests (`packages/sim/test/completable.test.ts`): one per permanent check (landing and stranding via content with a hardened Earth and a dry Mars); a dry origin counts only the fuel on board; Cargo Bay upgrades count; any one pair passing makes a job completable, with per-pair failures; rest, the shop and an empty tank block dispatch but not completability; the qualifying fleet's local pairs, ships landing before and after the period ends, spare pilots only with a bay, the whole-fleet fallback, and upgrades and fuel carried into the snapshot. - [x] **Step 19.2 — Sim: generation keeps only completable jobs.** `generateContractBoard` with a fleet draws candidates slot by slot (CR-006's band plan), round and round. It keeps those `isCompletable` for the fleet, numbered in order kept, until the board is full or `config.boardMaxAttempts` (200) candidates have been tried; first contacts count only when kept. Without a fleet it is unfiltered as before (tools and the class-mix baseline). The balance simulator keeps a fuel reserve big enough to fill every landed ship before buying another; one world had stranded a Clipper at Venus after a purchase. The fuel-share check's pilot is skilled in everything: only the ship limits what fits, and now no route is above 25%, the Clipper's Mars → Earth included. Balance medians: deal 3.7 min, first purchase 36.7, discovery 36.6, loan 123.3, no violations. Tests (`packages/sim/test/board-completable.test.ts`): 1,000 boards over five assorted fleets and five origins never offer an uncompletable job; a level-0 avatar's Courier at Earth always gets 8 Standard Moon jobs it fits; a Courier with only Earth known gets short boards of Moon first contacts, without error; identical inputs give identical boards; first contacts are filtered too. The CR-006 tests now expect an empty board for an empty fleet, full Courier boards with no previews, and band counts kept for a fleet that can do everything. `board-goods.test.ts`: a full-capability fleet filters nothing. The Venus board test gives its pilot Long Haul 1. -- [ ] Step 19.3 — Engine: boards, filtering, and migration +- [x] **Step 19.3 — Engine: boards, filtering, and migration.** `boardFleet(save, locationId, periodEndsAt)` in `packages/engine/src/game/boards.ts` (replacing `playerFleet`) builds the qualifying fleet from the save: ships in creation order with upgrades, fuel, and the destination and arrival of a flying ship, plus pilots and bays. A location board is generated for it and stores it as `fleet` on the saved board (`fleetMemberSchema`; null for tutorial and hand-made boards). Every read hides jobs the current qualifying fleet can't complete. Save version 4: the 3 → 4 migration drops stored boards. Also fixes Step 19.2's unformatted DECISIONS.md line. Tests (`packages/engine/test/completable-boards.test.ts`): a Freighter's heavy jobs vanish from Earth's board once it deadheads to the Moon, leaving the Courier's jobs unchanged; a Freighter bought mid-period adds nothing until the next period, then heavy jobs appear; the stored board carries the fleet snapshot; a version-3 save opens with no boards and regenerates the same board on view. `packages/schema/test/save.test.ts`: 3 → 4 drops boards and changes nothing else. - [ ] Step 19.4 — Client: cards and empty state - [ ] Step 19.5 — Plan amendments and regression diff --git a/packages/engine/src/game/boards.ts b/packages/engine/src/game/boards.ts index abd6b0a..a4c12c5 100644 --- a/packages/engine/src/game/boards.ts +++ b/packages/engine/src/game/boards.ts @@ -5,6 +5,8 @@ import { boardPeriodMs, generateContractBoard, generateTutorialBoard, + isCompletable, + qualifyingFleet, type Contract, type FleetMember, type RealTime, @@ -36,6 +38,7 @@ export function getOrCreateBoard( generate: () => Contract[], random: RandomSource, now: RealTime, + fleet: FleetMember[] | null = null, ): Contract[] { const run = key.tutorialRun ?? 0; const existing = save.contractBoards.find( @@ -54,6 +57,7 @@ export function getOrCreateBoard( periodIndex: key.periodIndex, tutorialRun: run, contracts, + fleet, createdAt: now, }); return contracts; @@ -93,22 +97,51 @@ export function tutorialBoard(save: SaveDocument, random: RandomSource, now: Rea } /** - * The player's ships with their pilots' skills and upgrades, in a stable order - * so the same fleet always gives the same board (decision 0.5.52, CR-009). + * The qualifying fleet for a location's board as the save stands (decision + * 0.5.115): ships in a stable order so the same fleet always gives the same + * board, with upgrades (CR-009), fuel, and where flying ships are headed. */ -export function playerFleet(save: SaveDocument): FleetMember[] { - return [...save.ships] +export function boardFleet( + save: SaveDocument, + locationId: string, + periodEndsAt: RealTime, +): FleetMember[] { + const ships = [...save.ships] .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - .map((ship) => ({ - modelId: ship.modelId, - skills: save.pilots.find((p) => p.assignedShipId === ship.id)?.skills ?? {}, - upgrades: ship.upgrades, - })); + .map((ship) => { + const flight = + ship.locationId === null + ? save.dispatches.find((d) => d.shipId === ship.id && d.status === "in-flight") + : undefined; + return { + id: ship.id, + modelId: ship.modelId, + upgrades: ship.upgrades, + fuelFu: ship.fuelFu, + locationId: ship.locationId, + destinationId: flight?.record.destinationId ?? null, + arrivalAt: flight?.arrivalAt ?? null, + }; + }); + return qualifyingFleet({ + locationId, + ships, + pilots: save.pilots.map((p) => ({ + id: p.id, + skills: p.skills, + assignedShipId: p.assignedShipId, + locationId: p.locationId, + })), + bayLocationIds: new Set(save.bays.map((bay) => bay.locationId)), + periodEndsAt, + }).members; } /** * Any discovered location's board for the current period (Step 6.9, CR-002): - * no bay needed, generated from the discoveries and fleet of that moment (CR-006). + * no bay needed, generated from the discoveries and qualifying fleet of that + * moment and stored with it (CR-019). Reads hide jobs the qualifying fleet can + * no longer complete (decision 0.5.117); new capability shows next period. */ export function locationBoard( save: SaveDocument, @@ -123,6 +156,7 @@ export function locationBoard( throw fail("location-undiscovered", `Deliver to ${origin.name} to open its market.`); } const periodIndex = boardPeriodIndex(now, content.config); + const fleet = boardFleet(save, locationId, (periodIndex + 1) * boardPeriodMs(content.config)); const contracts = getOrCreateBoard( save, { kind: "location", locationId, periodIndex }, @@ -133,12 +167,14 @@ export function locationBoard( discoveredIds: discovered, periodIndex, worldSeed: save.player.worldSeed, - fleet: playerFleet(save), + fleet, }, content, ), random, now, + fleet, ); - return boardView(save, contracts, periodIndex); + const completable = contracts.filter((c) => isCompletable(c, fleet, discovered, content)); + return boardView(save, completable, periodIndex); } diff --git a/packages/engine/test/completable-boards.test.ts b/packages/engine/test/completable-boards.test.ts new file mode 100644 index 0000000..9ea472e --- /dev/null +++ b/packages/engine/test/completable-boards.test.ts @@ -0,0 +1,103 @@ +import { content } from "@deadhead/content"; +import { boardPeriodMs } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { encodeSave } from "../src/index"; +import { + addPilot, + addShip, + readSave, + setCredits, + signUpOwner, + TEST_NOW, + useTestApp, +} from "./support"; + +const testApp = useTestApp(); +const COURIER_HOLD = content.shipModel("courier")!.cargoCapacityTonnes; +const PERIOD = boardPeriodMs(content.config); + +describe("completable boards in the engine (Step 19.3, decisions 0.5.114–0.5.117)", () => { + it("hides a heavy job once the only ship that could take it has left", async () => { + const { app, clock } = testApp(); + await signUpOwner(app); + await addShip(app, { id: "f1", modelId: "freighter", name: "Big Wren" }); + await addPilot(app, { id: "p1", name: "Ada", assignedShipId: "f1" }); + + const before = (await app.call("getContractBoard", { locationId: "earth" })).contracts; + const heavy = before.filter((c) => c.massTonnes > COURIER_HOLD); + expect(heavy.length).toBeGreaterThan(0); + + // The Freighter flies empty to the Moon; the Courier is still at Earth. + await app.call("createDispatch", { + kind: "reposition", + shipId: "f1", + pilotId: "p1", + destinationId: "moon", + }); + clock.now += 1_000; + const after = (await app.call("getContractBoard", { locationId: "earth" })).contracts; + expect(after.filter((c) => c.massTonnes > COURIER_HOLD)).toEqual([]); + // What the Courier can take is still there, unchanged. + expect(after).toEqual(before.filter((c) => c.massTonnes <= COURIER_HOLD)); + }); + + it("adds a bigger ship's jobs at the next board period, not mid-period", async () => { + const { app, clock } = testApp(); + const owner = await signUpOwner(app); + const first = (await app.call("getContractBoard", { locationId: "earth" })).contracts; + expect(first.every((c) => c.massTonnes <= COURIER_HOLD)).toBe(true); + + await setCredits(app, content.shipModel("freighter")!.price); + const { ship } = await app.call("buyShip", { modelId: "freighter", bayId: owner.earthBayId }); + await addPilot(app, { id: "p1", name: "Ada", assignedShipId: ship.id }); + clock.now += 1_000; + expect((await app.call("getContractBoard", { locationId: "earth" })).contracts).toEqual(first); + + // Next period: the board is made for the Courier and the Freighter. + clock.now = (Math.floor(TEST_NOW / PERIOD) + 1) * PERIOD + 1_000; + let heavy = 0; + for (let period = 0; period < 5; period++, clock.now += PERIOD) { + const board = (await app.call("getContractBoard", { locationId: "earth" })).contracts; + heavy += board.filter((c) => c.massTonnes > COURIER_HOLD).length; + } + expect(heavy).toBeGreaterThan(0); + }); + + it("stores the qualifying fleet with each board it generates", async () => { + const { app } = testApp(); + await signUpOwner(app); + await app.call("getContractBoard", { locationId: "earth" }); + const [board] = (await readSave(app)).contractBoards.filter((b) => b.kind === "location"); + expect(board!.fleet).toEqual([ + { + modelId: "courier", + skills: {}, + upgrades: {}, + fuelFu: content.shipModel("courier")!.tankCapacityFu, + }, + ]); + }); + + it("opens an older save with no stored boards, and regenerates them on view", async () => { + const { app } = testApp(); + await signUpOwner(app); + const before = (await app.call("getContractBoard", { locationId: "earth" })).contracts; + const old = await readSave(app); + expect(old.contractBoards.length).toBeGreaterThan(0); + // As version 3 wrote it: boards without a fleet snapshot. + const v3 = { + ...old, + saveVersion: 3, + contractBoards: old.contractBoards.map(({ fleet: _fleet, ...rest }) => rest), + }; + + const device = testApp().app; + await device.call("importSave", { code: await encodeSave(v3 as never) }); + expect((await readSave(device)).contractBoards).toEqual([]); + // The same inputs regenerate the same board. + expect((await device.call("getContractBoard", { locationId: "earth" })).contracts).toEqual( + before, + ); + expect((await readSave(device)).contractBoards).toHaveLength(1); + }); +}); diff --git a/packages/engine/test/support.ts b/packages/engine/test/support.ts index af76f36..2e60f72 100644 --- a/packages/engine/test/support.ts +++ b/packages/engine/test/support.ts @@ -279,6 +279,7 @@ export function putBoard( periodIndex: boardPeriodIndex(now, content.config), tutorialRun: 0, contracts, + fleet: null, createdAt: now, }); }); diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index e183e27..9f65500 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -4,6 +4,7 @@ import { fuelPriceAt, type Contract as SimContract, type DispatchRecord, + type FleetMember, type Resolution, } from "@deadhead/sim"; import { z } from "zod"; @@ -109,6 +110,14 @@ export const savedPilotSchema = z.object({ createdAt: timeSchema, }); +/** One ship-and-pilot pair's lasting capabilities (decision 0.5.115). */ +export const fleetMemberSchema = z.object({ + modelId: idSchema, + skills: z.record(idSchema, z.number().int().nonnegative()), + upgrades: z.record(idSchema, z.number().int().positive()).optional(), + fuelFu: z.number().nonnegative().optional(), +}); + export const savedBoardSchema = z.object({ id: idSchema, kind: z.enum(["location", "tutorial"]), @@ -117,6 +126,11 @@ export const savedBoardSchema = z.object({ /** Tutorial boards: runs completed when generated; 0 for location boards. */ tutorialRun: z.number().int().nonnegative(), contracts: z.array(contractSchema), + /** + * Location boards: the qualifying fleet they were generated for (CR-019), + * so a board can be reproduced. Null for tutorial and hand-made boards. + */ + fleet: z.array(fleetMemberSchema).nullable(), createdAt: timeSchema, }); @@ -135,7 +149,7 @@ export const savedDispatchSchema = z.object({ }); /** The save version this build writes. Bump it with a new migration. */ -export const CURRENT_SAVE_VERSION = 3; +export const CURRENT_SAVE_VERSION = 4; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -190,7 +204,8 @@ type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; const _sameContract: Same, SimContract> = true; const _sameRecord: Same, DispatchRecord> = true; const _sameResolution: Same, Resolution> = true; -void [_sameContract, _sameRecord, _sameResolution]; +const _sameFleetMember: Same, FleetMember> = true; +void [_sameContract, _sameRecord, _sameResolution, _sameFleetMember]; /** One step: a save of version `from` to version `from + 1`. */ export interface SaveMigration { @@ -242,9 +257,18 @@ function manualRefueling(save: Doc): Doc { * Every migration, in order: `from` → `from + 1`. A format change adds one * here and bumps `CURRENT_SAVE_VERSION`. */ +/** + * Version 3 → 4 (CR-019 §3): stored boards are dropped. They were made before + * boards kept only jobs the fleet can complete, and regenerate on next view. + */ +function dropBoards(save: Doc): Doc { + return { ...save, contractBoards: [] }; +} + export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, + { from: 3, migrate: dropBoards }, ]; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; diff --git a/packages/schema/test/save-fixture.ts b/packages/schema/test/save-fixture.ts index c654f7a..2c4f296 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: 3, + saveVersion: 4, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -138,6 +138,7 @@ export function fullSave(): SaveDocument { periodIndex: 1, tutorialRun: 0, contracts: [sampleContract], + fleet: [{ modelId: "courier", skills: {} }], createdAt: T, }, ], diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index 76ceaf2..d407888 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(3); + expect(CURRENT_SAVE_VERSION).toBe(4); }); it("rejects an invalid save with a readable error naming the field", () => { @@ -84,7 +84,7 @@ describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => { if (!result.ok) throw new Error(result.error); const { save } = result; const before = fullSave(); - expect(save.saveVersion).toBe(3); + expect(save.saveVersion).toBe(4); // 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: "manual" }); @@ -116,12 +116,34 @@ describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => { const result = migrateSave(old); if (!result.ok) throw new Error(result.error); const { save } = result; - expect(save.saveVersion).toBe(3); + expect(save.saveVersion).toBe(4); 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: [] }); + // Boards go in 3 → 4 (CR-019); everything else is as it was. + expect({ ...save, ships: [], contractBoards: [] }).toEqual({ + ...before, + ships: [], + contractBoards: [], + }); + }); +}); + +describe("save migration 3 → 4: completable boards (CR-019 §3)", () => { + it("drops stored boards and leaves everything else as it was", () => { + const before = fullSave(); + const old = JSON.parse(JSON.stringify(before)) as Record & { + contractBoards: Record[]; + }; + old.saveVersion = 3; + // A version-3 board had no fleet snapshot. + for (const board of old.contractBoards) delete board.fleet; + const result = migrateSave(old); + if (!result.ok) throw new Error(result.error); + expect(result.save.saveVersion).toBe(4); + expect(result.save.contractBoards).toEqual([]); + expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] }); }); });