Step 19.3: engine: boards, filtering, and migration
CI / check (push) Failing after 6m38s Details

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 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-10 20:49:35 -04:00
parent e28597e09e
commit dbdca56413
10 changed files with 219 additions and 25 deletions

View File

@ -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. - **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. - **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 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.

View File

@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => {
expect(text).toContain("Build: test"); expect(text).toContain("Build: test");
// CR-014: the save, its format version and where it is stored; no player id. // 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: player-1");
expect(text).toContain("Save version: 3"); expect(text).toContain("Save version: 4");
expect(text).toContain("Storage: unknown"); expect(text).toContain("Storage: unknown");
expect(text).not.toContain("Player:"); expect(text).not.toContain("Player:");
expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/);

View File

@ -7,7 +7,7 @@ Living reference for how the code is organised. Update it after any save format
| Package | Role | Depends on | | 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/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/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 | | `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 | | `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 | | `getServerTime` | `GET /api/time` | — | `ServerTime` | no |
| `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` (playerId is the save id) | no | | `createGuest` | `POST /api/guest` | — | `{ playerId, state: PlayerState }` (playerId is the save id) | no |
| `getPlayerState` | `GET /api/state` | — | `PlayerState` | yes | | `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 | | `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 | | `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 | | `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) ## 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. 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.

View File

@ -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.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. - [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.4 — Client: cards and empty state
- [ ] Step 19.5 — Plan amendments and regression - [ ] Step 19.5 — Plan amendments and regression

View File

@ -5,6 +5,8 @@ import {
boardPeriodMs, boardPeriodMs,
generateContractBoard, generateContractBoard,
generateTutorialBoard, generateTutorialBoard,
isCompletable,
qualifyingFleet,
type Contract, type Contract,
type FleetMember, type FleetMember,
type RealTime, type RealTime,
@ -36,6 +38,7 @@ export function getOrCreateBoard(
generate: () => Contract[], generate: () => Contract[],
random: RandomSource, random: RandomSource,
now: RealTime, now: RealTime,
fleet: FleetMember[] | null = null,
): Contract[] { ): Contract[] {
const run = key.tutorialRun ?? 0; const run = key.tutorialRun ?? 0;
const existing = save.contractBoards.find( const existing = save.contractBoards.find(
@ -54,6 +57,7 @@ export function getOrCreateBoard(
periodIndex: key.periodIndex, periodIndex: key.periodIndex,
tutorialRun: run, tutorialRun: run,
contracts, contracts,
fleet,
createdAt: now, createdAt: now,
}); });
return contracts; 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 * The qualifying fleet for a location's board as the save stands (decision
* so the same fleet always gives the same board (decision 0.5.52, CR-009). * 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[] { export function boardFleet(
return [...save.ships] 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)) .sort((a, b) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
.map((ship) => ({ .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, modelId: ship.modelId,
skills: save.pilots.find((p) => p.assignedShipId === ship.id)?.skills ?? {},
upgrades: ship.upgrades, 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): * 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( export function locationBoard(
save: SaveDocument, save: SaveDocument,
@ -123,6 +156,7 @@ export function locationBoard(
throw fail("location-undiscovered", `Deliver to ${origin.name} to open its market.`); throw fail("location-undiscovered", `Deliver to ${origin.name} to open its market.`);
} }
const periodIndex = boardPeriodIndex(now, content.config); const periodIndex = boardPeriodIndex(now, content.config);
const fleet = boardFleet(save, locationId, (periodIndex + 1) * boardPeriodMs(content.config));
const contracts = getOrCreateBoard( const contracts = getOrCreateBoard(
save, save,
{ kind: "location", locationId, periodIndex }, { kind: "location", locationId, periodIndex },
@ -133,12 +167,14 @@ export function locationBoard(
discoveredIds: discovered, discoveredIds: discovered,
periodIndex, periodIndex,
worldSeed: save.player.worldSeed, worldSeed: save.player.worldSeed,
fleet: playerFleet(save), fleet,
}, },
content, content,
), ),
random, random,
now, now,
fleet,
); );
return boardView(save, contracts, periodIndex); const completable = contracts.filter((c) => isCompletable(c, fleet, discovered, content));
return boardView(save, completable, periodIndex);
} }

View File

@ -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);
});
});

View File

@ -279,6 +279,7 @@ export function putBoard(
periodIndex: boardPeriodIndex(now, content.config), periodIndex: boardPeriodIndex(now, content.config),
tutorialRun: 0, tutorialRun: 0,
contracts, contracts,
fleet: null,
createdAt: now, createdAt: now,
}); });
}); });

View File

@ -4,6 +4,7 @@ import {
fuelPriceAt, fuelPriceAt,
type Contract as SimContract, type Contract as SimContract,
type DispatchRecord, type DispatchRecord,
type FleetMember,
type Resolution, type Resolution,
} from "@deadhead/sim"; } from "@deadhead/sim";
import { z } from "zod"; import { z } from "zod";
@ -109,6 +110,14 @@ export const savedPilotSchema = z.object({
createdAt: timeSchema, 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({ export const savedBoardSchema = z.object({
id: idSchema, id: idSchema,
kind: z.enum(["location", "tutorial"]), kind: z.enum(["location", "tutorial"]),
@ -117,6 +126,11 @@ export const savedBoardSchema = z.object({
/** Tutorial boards: runs completed when generated; 0 for location boards. */ /** Tutorial boards: runs completed when generated; 0 for location boards. */
tutorialRun: z.number().int().nonnegative(), tutorialRun: z.number().int().nonnegative(),
contracts: z.array(contractSchema), 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, createdAt: timeSchema,
}); });
@ -135,7 +149,7 @@ export const savedDispatchSchema = z.object({
}); });
/** The save version this build writes. Bump it with a new migration. */ /** 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({ export const saveDocumentSchema = z.object({
saveVersion: z.literal(CURRENT_SAVE_VERSION), saveVersion: z.literal(CURRENT_SAVE_VERSION),
@ -190,7 +204,8 @@ type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
const _sameContract: Same<z.infer<typeof contractSchema>, SimContract> = true; const _sameContract: Same<z.infer<typeof contractSchema>, SimContract> = true;
const _sameRecord: Same<z.infer<typeof dispatchRecordSchema>, DispatchRecord> = true; const _sameRecord: Same<z.infer<typeof dispatchRecordSchema>, DispatchRecord> = true;
const _sameResolution: Same<z.infer<typeof resolutionSchema>, Resolution> = true; const _sameResolution: Same<z.infer<typeof resolutionSchema>, Resolution> = true;
void [_sameContract, _sameRecord, _sameResolution]; const _sameFleetMember: Same<z.infer<typeof fleetMemberSchema>, FleetMember> = true;
void [_sameContract, _sameRecord, _sameResolution, _sameFleetMember];
/** One step: a save of version `from` to version `from + 1`. */ /** One step: a save of version `from` to version `from + 1`. */
export interface SaveMigration { export interface SaveMigration {
@ -242,9 +257,18 @@ function manualRefueling(save: Doc): Doc {
* Every migration, in order: `from` → `from + 1`. A format change adds one * Every migration, in order: `from` → `from + 1`. A format change adds one
* here and bumps `CURRENT_SAVE_VERSION`. * 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[] = [ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
{ from: 1, migrate: addFuelTanks }, { from: 1, migrate: addFuelTanks },
{ from: 2, migrate: manualRefueling }, { from: 2, migrate: manualRefueling },
{ from: 3, migrate: dropBoards },
]; ];
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };

View File

@ -45,7 +45,7 @@ export function fullSave(): SaveDocument {
seed: 42, seed: 42,
}); });
return { return {
saveVersion: 3, saveVersion: 4,
saveId: "save-1", saveId: "save-1",
lastTimeSeen: T + 3_600_000, lastTimeSeen: T + 3_600_000,
player: { player: {
@ -138,6 +138,7 @@ export function fullSave(): SaveDocument {
periodIndex: 1, periodIndex: 1,
tutorialRun: 0, tutorialRun: 0,
contracts: [sampleContract], contracts: [sampleContract],
fleet: [{ modelId: "courier", skills: {} }],
createdAt: T, createdAt: T,
}, },
], ],

View File

@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture";
describe("save document (Step 14.2)", () => { describe("save document (Step 14.2)", () => {
it("validates a fully populated save", () => { it("validates a fully populated save", () => {
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); 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", () => { 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); if (!result.ok) throw new Error(result.error);
const { save } = result; const { save } = result;
const before = fullSave(); 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 // 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). // price, 5 cr/fu since CR-018 (decision 0.5.110).
expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" }); 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); const result = migrateSave(old);
if (!result.ok) throw new Error(result.error); if (!result.ok) throw new Error(result.error);
const { save } = result; 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.refuelPolicy)).toEqual(save.ships.map(() => "manual"));
expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual( expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual(
before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]), before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]),
); );
expect(save.company).toEqual(before.company); 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<string, unknown> & {
contractBoards: Record<string, unknown>[];
};
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: [] });
}); });
}); });