Step 19.3: engine: boards, filtering, and migration
CI / check (push) Failing after 6m38s
Details
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:
parent
e28597e09e
commit
dbdca56413
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
modelId: ship.modelId,
|
const flight =
|
||||||
skills: save.pilots.find((p) => p.assignedShipId === ship.id)?.skills ?? {},
|
ship.locationId === null
|
||||||
upgrades: ship.upgrades,
|
? 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):
|
* 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue