Step 20.4: engine: event-queue catch-up
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Commands and the resolution loop replay arrivals and Independent pilots' decisions in time order, at most 7 days, in chunks with progress; the same time always gives the same save (decision 0.5.125). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
85e8aa4495
commit
4b2f1b1a85
11
DECISIONS.md
11
DECISIONS.md
|
|
@ -1692,3 +1692,14 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **Switching to Manual is immediate in the save**, and that already means "when the current trip ends": an Independent pilot only decides while idle, so a trip in progress finishes either way.
|
- **Switching to Manual is immediate in the save**, and that already means "when the current trip ends": an Independent pilot only decides while idle, so a trip in progress finishes either way.
|
||||||
- **Orders are kept even in Manual**, so switching back to Independent restores them.
|
- **Orders are kept even in Manual**, so switching back to Independent restores them.
|
||||||
- **Local checks**: `pnpm -s typecheck` printed nothing while the web package had type errors (the exit code was 2). Types are now checked with `pnpm typecheck` and an "error TS" search.
|
- **Local checks**: `pnpm -s typecheck` printed nothing while the web package had type errors (the exit code was 2). Types are now checked with `pnpm typecheck` and an "error TS" search.
|
||||||
|
|
||||||
|
## Step 20.4
|
||||||
|
|
||||||
|
- **Decision points are stored, not derived**: each pilot keeps `nextDecisionAt`. A landing sets it to a turnaround after the arrival, or after the rest it triggers; setting a pilot Independent sets it to a turnaround from then. Rest, the shop and the first-look window each push it later; a wait sets it to the next board period's first-look end. Shop endings and rest endings need no events of their own: the decision point checks them.
|
||||||
|
- **Board periods matter only to waiting pilots**, so they appear as those pilots' next decision points rather than as separate events.
|
||||||
|
- **Arrivals resolve at their arrival time** (`resolvedAt` = arrival), not when the app notices them. Without this, splitting time would change Auto top-up credits and every later decision.
|
||||||
|
- **Ids made during catch-up are `ind-<pilot>-<time>-<n>`**, for both dispatches and boards a pilot generates, so the same replay gives the same save. Player commands keep random ids.
|
||||||
|
- **The 7-day cap counts from the save's `lastTimeSeen`** when a catch-up starts. A pilot whose decision fell after the cap resumes from the catch-up's end, not from the past.
|
||||||
|
- **Splitting time leaves only `lastTimeSeen` different**: a piece that changes nothing isn't saved, so it doesn't move `lastTimeSeen`.
|
||||||
|
- **A refused pilot action**, which shouldn't happen because the sim chose by the same rules, leaves the pilot waiting for the next board period instead of stopping the replay.
|
||||||
|
- **Chunks are 500 events**, each its own save, so a week's catch-up never holds the screen for long.
|
||||||
|
|
|
||||||
|
|
@ -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: 5");
|
expect(text).toContain("Save version: 6");
|
||||||
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/);
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,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 5; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-020), `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 5; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-020; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-020), `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.
|
||||||
|
|
||||||
|
|
@ -138,3 +138,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). The game runs
|
||||||
Since CR-014 the client runs the game itself: `src/platform/game.ts` (storage, device clock, tab lock, engine), `src/api/engine-client.ts` (the `ApiClient` over engine commands), `src/GameRoot.tsx` (tab lock screen, resolution loop, the `window.deadhead` test hook in developer builds). `src/platform/notifications.ts` picks the notification scheduler; `src/platform/context.tsx` gives screens the `Game` (debug info). `src/screens/SaveSettings.tsx`: Export save and Import save. `src/components/SkillPointPrompt.tsx` (CR-017): the one-time skill point pop-up (tip id `skill-points`), shown once the tutorial is over, the permission prompt is answered and some pilot has unspent points.
|
Since CR-014 the client runs the game itself: `src/platform/game.ts` (storage, device clock, tab lock, engine), `src/api/engine-client.ts` (the `ApiClient` over engine commands), `src/GameRoot.tsx` (tab lock screen, resolution loop, the `window.deadhead` test hook in developer builds). `src/platform/notifications.ts` picks the notification scheduler; `src/platform/context.tsx` gives screens the `Game` (debug info). `src/screens/SaveSettings.tsx`: Export save and Import save. `src/components/SkillPointPrompt.tsx` (CR-017): the one-time skill point pop-up (tip id `skill-points`), shown once the tutorial is over, the permission prompt is answered and some pilot has unspent points.
|
||||||
|
|
||||||
On the device the client keeps the save (IndexedDB, or files on native) and, in local storage, device preferences, seen board ids and, in developer builds, the developer clock offset. The game works the same with or without a network.
|
On the device the client keeps the save (IndexedDB, or files on native) and, in local storage, device preferences, seen board ids and, in developer builds, the developer clock offset. The game works the same with or without a network.
|
||||||
|
|
||||||
|
### Independent pilots and catch-up (CR-020)
|
||||||
|
|
||||||
|
`packages/engine/src/game/catchup.ts`: `catchUp(save, until, { decideUntil, maxEvents, viewedContractId })` replays time as an event queue. It takes the next in-flight arrival or the next Independent pilot's `nextDecisionAt`, in time order: arrivals first at the same moment, then pilots by id. Each arrival resolves at its own time. A decision point checks rest, the shop and the first-look window (each pushes `nextDecisionAt` later), then asks the sim's `chooseAction` about the pilot's board. It acts through the normal `refuelShip`, `dispatchContract` and `dispatchReposition`, with ids made from the pilot and the moment (`ind-…`), so the same time always gives the same save. Waiting schedules the next board period's first-look end and counts empty periods away from home. A trip's landing schedules the next decision a turnaround after the arrival, or after the rest it triggers. Decisions stop `maxCatchUpRealDays` after the save's `lastTimeSeen` (`decideUntil`), and later ones resume from `until`. Every command runs `catchUp` to its `now` before it acts. `GameEngine.resolveDue(since, { chunkEvents, onProgress })` runs the loop's pass in chunks (500 events, one save each), with `onProgress(0…1)` in between. `GameEngine.setViewedContract(id)` tells pilots which contract the player has open. Dispatches an Independent pilot chose are flagged `independent`, and no notification is scheduled for them.
|
||||||
|
|
|
||||||
|
|
@ -465,7 +465,7 @@ Spec: `memory-bank/change-requests/CR-020-independent-pilots.md` (received as "C
|
||||||
- [x] **Step 20.1 — Content and config.** `config.independentPilots`: `dispatchOfficePrice` 36,000 (a Lighter), `firstLookRealMinutes` 3, `turnaroundGameMinutes` 15 (15 real seconds at ×60), `emptyPeriodsBeforeHome` 3, `maxCatchUpRealDays` 7 and `defaultSpendingFloor` 500. All must be positive, and the first-look window must be shorter than the board period. Two stale quarter-tank comments from CR-015 are gone. Tests (`packages/content/test/config.test.ts`): the defaults, with the Dispatch Office at the Lighter's price; zero and negative values rejected; a first-look window as long as the board period rejected.
|
- [x] **Step 20.1 — Content and config.** `config.independentPilots`: `dispatchOfficePrice` 36,000 (a Lighter), `firstLookRealMinutes` 3, `turnaroundGameMinutes` 15 (15 real seconds at ×60), `emptyPeriodsBeforeHome` 3, `maxCatchUpRealDays` 7 and `defaultSpendingFloor` 500. All must be positive, and the first-look window must be shorter than the board period. Two stale quarter-tank comments from CR-015 are gone. Tests (`packages/content/test/config.test.ts`): the defaults, with the Dispatch Office at the Lighter's price; zero and negative values rejected; a first-look window as long as the board period rejected.
|
||||||
- [x] **Step 20.2 — Sim: eligibility and job choice.** `packages/sim/src/independent.ts`: `PilotOrders` and `defaultOrders(home)`; `pastFirstLook(now)`; `legRefuel` (exactly the fuel a leg needs beyond what's aboard, rounded up to 0.001 fu, only where fuel is sold and only if credits stay at or above the spending floor); `independentOptions` (past the first-look window, not first contacts or the job the player has open, within the orders, passing `validateDispatch` with the leg's fuel bought, making at least the minimum profit). Fuel aboard is priced at the tank's average and bought fuel at the local price, less the pilot's share, then divided by trip game hours. `compareOptions`: highest profit per hour, then the shorter trip, then the lower id. `chooseAction` returns take, deadhead home (after `emptyPeriodsBeforeHome` empty periods away, when the fuel rules allow), or wait. Tests (`packages/sim/test/independent.test.ts`): first contacts never taken; nothing in the first-look window, the same job after; the open job skipped; each order (destination, class, Priority, minimum profit at the boundary); profit per hour beats total profit (Moon over Mars); fuel and share arithmetic; tie-breaks; a pilot short of credits above the floor takes the job flyable on fuel aboard; waiting, heading home after the third empty period, and not when home is out of reach.
|
- [x] **Step 20.2 — Sim: eligibility and job choice.** `packages/sim/src/independent.ts`: `PilotOrders` and `defaultOrders(home)`; `pastFirstLook(now)`; `legRefuel` (exactly the fuel a leg needs beyond what's aboard, rounded up to 0.001 fu, only where fuel is sold and only if credits stay at or above the spending floor); `independentOptions` (past the first-look window, not first contacts or the job the player has open, within the orders, passing `validateDispatch` with the leg's fuel bought, making at least the minimum profit). Fuel aboard is priced at the tank's average and bought fuel at the local price, less the pilot's share, then divided by trip game hours. `compareOptions`: highest profit per hour, then the shorter trip, then the lower id. `chooseAction` returns take, deadhead home (after `emptyPeriodsBeforeHome` empty periods away, when the fuel rules allow), or wait. Tests (`packages/sim/test/independent.test.ts`): first contacts never taken; nothing in the first-look window, the same job after; the open job skipped; each order (destination, class, Priority, minimum profit at the boundary); profit per hour beats total profit (Moon over Mars); fuel and share arithmetic; tie-breaks; a pilot short of credits above the floor takes the job flyable on fuel aboard; waiting, heading home after the third empty period, and not when home is out of reach.
|
||||||
- [x] **Step 20.3 — Engine: Dispatch Office, modes, orders.** Save version 5: pilots get `mode` (manual/independent) and `orders` (destinations or null for all, cargo classes, Priority, minimum profit, home bay); the company gets `dispatchOffice` and `npcSpendingFloor`. The 4 → 5 migration puts every pilot on Manual with default orders and home at Earth, with no office and the default floor. API: `Pilot.mode` and `Pilot.orders`; `PlayerState.dispatchOffice` holds `owned` and `spendingFloor`. Commands (`packages/engine/src/game/independent.ts`): `buyDispatchOffice` (after the tutorial, once, for its price), `setPilotMode` (Independent needs the office and is never the avatar), `setPilotOrders` (checked against locations, cargo classes and the player's bays), and `setSpendingFloor`. New error codes: `dispatch-office-required`, `dispatch-office-owned`, `avatar-not-independent`, `unknown-location`, `unknown-cargo-class`. New pilots are Manual: the avatar and the veteran are homed at Earth, hired pilots where they were hired. Tests (`packages/engine/test/independent-commands.test.ts`): Independent refused before the office, allowed after, and back to Manual; the office sells once, for its price, and not in the tutorial; the avatar is refused; orders round-trip and bad ones are refused; a hired pilot's default orders and home; the spending floor; a version-4 save imports with everyone Manual and no office. `packages/schema/test/save.test.ts`: 4 → 5 as above. Web and schema fixtures gained the new fields (`pilotExtras` in `apps/web/test/fixtures.ts`).
|
- [x] **Step 20.3 — Engine: Dispatch Office, modes, orders.** Save version 5: pilots get `mode` (manual/independent) and `orders` (destinations or null for all, cargo classes, Priority, minimum profit, home bay); the company gets `dispatchOffice` and `npcSpendingFloor`. The 4 → 5 migration puts every pilot on Manual with default orders and home at Earth, with no office and the default floor. API: `Pilot.mode` and `Pilot.orders`; `PlayerState.dispatchOffice` holds `owned` and `spendingFloor`. Commands (`packages/engine/src/game/independent.ts`): `buyDispatchOffice` (after the tutorial, once, for its price), `setPilotMode` (Independent needs the office and is never the avatar), `setPilotOrders` (checked against locations, cargo classes and the player's bays), and `setSpendingFloor`. New error codes: `dispatch-office-required`, `dispatch-office-owned`, `avatar-not-independent`, `unknown-location`, `unknown-cargo-class`. New pilots are Manual: the avatar and the veteran are homed at Earth, hired pilots where they were hired. Tests (`packages/engine/test/independent-commands.test.ts`): Independent refused before the office, allowed after, and back to Manual; the office sells once, for its price, and not in the tutorial; the avatar is refused; orders round-trip and bad ones are refused; a hired pilot's default orders and home; the spending floor; a version-4 save imports with everyone Manual and no office. `packages/schema/test/save.test.ts`: 4 → 5 as above. Web and schema fixtures gained the new fields (`pilotExtras` in `apps/web/test/fixtures.ts`).
|
||||||
- [ ] Step 20.4 — Engine: event-queue catch-up
|
- [x] **Step 20.4 — Engine: event-queue catch-up.** `catchUp` in `packages/engine/src/game/catchup.ts` (see architecture.md) replaces the plain arrival pass in every command, in company creation and import, and in the resolution loop. The loop's `resolveDue` now works in chunks with a progress callback and reports pilot decisions so the screen refreshes. Save version 6: pilots' `nextDecisionAt` and `emptyPeriods`, and dispatches' `independent` flag. Setting a pilot Independent schedules their first decision a turnaround later; setting them Manual clears it. Arrivals now resolve at their arrival time, not when the app noticed them. Independent trips are left out of notifications. Tests (`packages/engine/test/catch-up.test.ts`): two Independent pilots caught up 8 hours at once, and one hour at a time on a copy, give identical saves (over 20 trips); two pilots deciding together split the best two contracts by id; a pilot at the duty limit rests on landing and flies again only after the rest plus the turnaround; 10 days away stops decisions at 7 days, with work right up to the cap, and resumes from now; pilots' trips are never first contacts, never inside the first-look window, never after expiry, never the same contract twice; chunked catch-up reports rising progress to 1 and ends at the same save; no notification for a pilot's own trip.
|
||||||
- [ ] Step 20.5 — Engine: live play
|
- [ ] Step 20.5 — Engine: live play
|
||||||
- [ ] Step 20.6 — Client: Dispatch Office and pilot controls
|
- [ ] Step 20.6 — Client: Dispatch Office and pilot controls
|
||||||
- [ ] Step 20.7 — Client: board and logbook
|
- [ ] Step 20.7 — Client: board and logbook
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,218 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { SaveDocument } from "@deadhead/schema";
|
||||||
|
import {
|
||||||
|
boardPeriodIndex,
|
||||||
|
boardPeriodMs,
|
||||||
|
chooseAction,
|
||||||
|
isInShop,
|
||||||
|
isResting,
|
||||||
|
MS_PER_MINUTE,
|
||||||
|
type RealTime,
|
||||||
|
} from "@deadhead/sim";
|
||||||
|
import type { RandomSource } from "../core";
|
||||||
|
import { locationBoard } from "./boards";
|
||||||
|
import { dispatchContract, dispatchReposition } from "./dispatch";
|
||||||
|
import { refuelShip } from "./fleet";
|
||||||
|
import { scheduleDecision } from "./independent";
|
||||||
|
import { resolveArrivals } from "./resolve";
|
||||||
|
import { discoveredIds, pilotState, shipState } from "./state";
|
||||||
|
|
||||||
|
type SavedPilot = SaveDocument["pilots"][number];
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * MS_PER_MINUTE;
|
||||||
|
|
||||||
|
/** When the current board period's first-look window ends (decision 0.5.122). */
|
||||||
|
function firstLookEnd(t: RealTime): RealTime {
|
||||||
|
const period = boardPeriodMs(content.config);
|
||||||
|
return (
|
||||||
|
boardPeriodIndex(t, content.config) * period +
|
||||||
|
content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The next board period's first-look end: when a waiting pilot looks again. */
|
||||||
|
function nextBoardLook(t: RealTime): RealTime {
|
||||||
|
return firstLookEnd(t) + boardPeriodMs(content.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ids for what pilots do during catch-up come from the pilot and the moment,
|
||||||
|
* not chance, so replaying the same time gives the same save (decision 0.5.125).
|
||||||
|
*/
|
||||||
|
function replayRandom(pilotId: string, t: RealTime): RandomSource {
|
||||||
|
let n = 0;
|
||||||
|
return { id: () => `ind-${pilotId}-${t}-${n++}`, seed: () => 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatchUpOptions {
|
||||||
|
/** Pilots decide only up to this time (the 7-day cap); arrivals always resolve. */
|
||||||
|
decideUntil: RealTime;
|
||||||
|
/** Stop after this many events (a chunk); the rest waits for the next call. */
|
||||||
|
maxEvents?: number;
|
||||||
|
/** The contract the player has open: pilots leave it alone (decision 0.5.122). */
|
||||||
|
viewedContractId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatchUpResult {
|
||||||
|
/** Arrivals resolved. */
|
||||||
|
resolved: number;
|
||||||
|
/** Pilot decisions made (jobs, deadheads and waits). */
|
||||||
|
decisions: number;
|
||||||
|
/** Every event up to `until` was processed. */
|
||||||
|
done: boolean;
|
||||||
|
/** How far the replay got. */
|
||||||
|
reached: RealTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The earliest decision due by `limit`, ties to the lower pilot id. */
|
||||||
|
function nextDecision(save: SaveDocument, limit: RealTime): SavedPilot | null {
|
||||||
|
let best: SavedPilot | null = null;
|
||||||
|
for (const pilot of save.pilots) {
|
||||||
|
const t = pilot.nextDecisionAt;
|
||||||
|
if (t === null || t > limit || pilot.mode !== "independent") continue;
|
||||||
|
if (!best || t < best.nextDecisionAt! || (t === best.nextDecisionAt && pilot.id < best.id)) {
|
||||||
|
best = pilot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextArrival(save: SaveDocument, limit: RealTime): RealTime | null {
|
||||||
|
let earliest: RealTime | null = null;
|
||||||
|
for (const d of save.dispatches) {
|
||||||
|
if (d.status === "in-flight" && d.arrivalAt <= limit) {
|
||||||
|
earliest = earliest === null ? d.arrivalAt : Math.min(earliest, d.arrivalAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return earliest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One Independent pilot's decision point at `t` (CR-020 §2). Rest, the shop
|
||||||
|
* and the first-look window push it later; otherwise the sim chooses, and the
|
||||||
|
* choice goes through the same commands the player uses.
|
||||||
|
*/
|
||||||
|
function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: string | null): void {
|
||||||
|
const ship = save.ships.find((s) => s.id === pilot.assignedShipId);
|
||||||
|
if (pilot.mode !== "independent" || !ship || save.player.tutorialActive) {
|
||||||
|
pilot.nextDecisionAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Flying (the player sent them): the arrival schedules the next decision.
|
||||||
|
if (pilot.status === "in-flight" || ship.status === "in-flight" || !ship.locationId) {
|
||||||
|
pilot.nextDecisionAt = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isResting(pilot.restEndsAt, t)) return scheduleDecision(pilot, pilot.restEndsAt!);
|
||||||
|
if (isInShop(ship, t)) return scheduleDecision(pilot, ship.inShopUntil!);
|
||||||
|
if (t < firstLookEnd(t)) {
|
||||||
|
pilot.nextDecisionAt = firstLookEnd(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const random = replayRandom(pilot.id, t);
|
||||||
|
const board = locationBoard(save, ship.locationId, random, t).contracts;
|
||||||
|
const action = chooseAction(
|
||||||
|
{
|
||||||
|
ship: { ...shipState(ship), tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice } },
|
||||||
|
pilot: pilotState(pilot),
|
||||||
|
orders: pilot.orders,
|
||||||
|
board,
|
||||||
|
now: t,
|
||||||
|
credits: save.company.credits,
|
||||||
|
spendingFloor: save.company.npcSpendingFloor,
|
||||||
|
discoveredIds: discoveredIds(save),
|
||||||
|
emptyPeriods: pilot.emptyPeriods,
|
||||||
|
viewedContractId: viewed,
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (action.kind === "wait") {
|
||||||
|
if (ship.locationId !== pilot.orders.homeLocationId) pilot.emptyPeriods++;
|
||||||
|
pilot.nextDecisionAt = nextBoardLook(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The same commands, and so the same checks, as the player's (CR-020 §3). The
|
||||||
|
// sim chose with the same rules, so a refusal is a bug; the pilot then waits.
|
||||||
|
try {
|
||||||
|
if (action.buyFu > 0) refuelShip(save, { shipId: ship.id, amountFu: action.buyFu }, t);
|
||||||
|
if (action.kind === "take") {
|
||||||
|
dispatchContract(
|
||||||
|
save,
|
||||||
|
{ contractId: action.contractId, shipId: ship.id, pilotId: pilot.id },
|
||||||
|
random,
|
||||||
|
t,
|
||||||
|
{ independent: true },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatchReposition(
|
||||||
|
save,
|
||||||
|
{ shipId: ship.id, pilotId: pilot.id, destinationId: action.destinationId },
|
||||||
|
random,
|
||||||
|
t,
|
||||||
|
{ independent: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
pilot.nextDecisionAt = nextBoardLook(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pilot.emptyPeriods = 0;
|
||||||
|
pilot.nextDecisionAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays time up to `until` as an event queue (decision 0.5.125): arrivals
|
||||||
|
* and Independent pilots' decision points in time order; at the same moment
|
||||||
|
* arrivals first, then pilots by id. Each arrival resolves at its own time, so
|
||||||
|
* the result doesn't depend on how the time is split.
|
||||||
|
*/
|
||||||
|
export function catchUp(
|
||||||
|
save: SaveDocument,
|
||||||
|
until: RealTime,
|
||||||
|
options: CatchUpOptions,
|
||||||
|
): CatchUpResult {
|
||||||
|
const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
|
||||||
|
const decideLimit = Math.min(until, options.decideUntil);
|
||||||
|
let resolved = 0;
|
||||||
|
let decisions = 0;
|
||||||
|
let reached = Number.NEGATIVE_INFINITY;
|
||||||
|
for (let events = 0; events < maxEvents; events++) {
|
||||||
|
const arrival = nextArrival(save, until);
|
||||||
|
const pilot = nextDecision(save, decideLimit);
|
||||||
|
const decisionAt = pilot?.nextDecisionAt ?? null;
|
||||||
|
if (arrival === null && decisionAt === null) {
|
||||||
|
// Pilots who would have decided after the cap pick up from `until`.
|
||||||
|
if (decideLimit < until) {
|
||||||
|
for (const p of save.pilots) {
|
||||||
|
if (p.nextDecisionAt !== null && p.nextDecisionAt < until) p.nextDecisionAt = until;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { resolved, decisions, done: true, reached: until };
|
||||||
|
}
|
||||||
|
if (arrival !== null && (decisionAt === null || arrival <= decisionAt)) {
|
||||||
|
const landed = save.dispatches.filter(
|
||||||
|
(d) => d.status === "in-flight" && d.arrivalAt <= arrival,
|
||||||
|
);
|
||||||
|
resolved += resolveArrivals(save, arrival);
|
||||||
|
for (const d of landed) {
|
||||||
|
const flyer = save.pilots.find((p) => p.id === d.pilotId);
|
||||||
|
if (flyer?.mode === "independent") {
|
||||||
|
scheduleDecision(flyer, Math.max(arrival, flyer.restEndsAt ?? 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reached = arrival;
|
||||||
|
} else {
|
||||||
|
decide(save, pilot!, decisionAt!, options.viewedContractId ?? null);
|
||||||
|
decisions++;
|
||||||
|
reached = decisionAt!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { resolved, decisions, done: false, reached };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decision cap for a catch-up starting from the last saved time (decision 0.5.125). */
|
||||||
|
export function decideUntil(save: SaveDocument): RealTime {
|
||||||
|
return save.lastTimeSeen + content.config.independentPilots.maxCatchUpRealDays * DAY_MS;
|
||||||
|
}
|
||||||
|
|
@ -61,6 +61,8 @@ export function startingSave(
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
orders: newPilotOrders("earth"),
|
orders: newPilotOrders("earth"),
|
||||||
|
nextDecisionAt: null,
|
||||||
|
emptyPeriods: 0,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
bays: [],
|
bays: [],
|
||||||
|
|
@ -152,6 +154,8 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
orders: newPilotOrders(bayLocationId),
|
orders: newPilotOrders(bayLocationId),
|
||||||
|
nextDecisionAt: null,
|
||||||
|
emptyPeriods: 0,
|
||||||
});
|
});
|
||||||
save.company.loanBalance = content.dealLoanAmount;
|
save.company.loanBalance = content.dealLoanAmount;
|
||||||
Object.assign(save.player, { tutorialActive: false, loanerLocationId: null, dealAccepted: true });
|
Object.assign(save.player, { tutorialActive: false, loanerLocationId: null, dealAccepted: true });
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ function storeDispatch(
|
||||||
shipId: string | null;
|
shipId: string | null;
|
||||||
periodIndex: number | null;
|
periodIndex: number | null;
|
||||||
now: RealTime;
|
now: RealTime;
|
||||||
|
independent?: boolean;
|
||||||
},
|
},
|
||||||
): Dispatch {
|
): Dispatch {
|
||||||
const { record, shipId, now } = args;
|
const { record, shipId, now } = args;
|
||||||
|
|
@ -59,6 +60,7 @@ function storeDispatch(
|
||||||
shipId,
|
shipId,
|
||||||
pilotId: record.pilotId,
|
pilotId: record.pilotId,
|
||||||
tutorial: record.tutorial,
|
tutorial: record.tutorial,
|
||||||
|
independent: args.independent ?? false,
|
||||||
record,
|
record,
|
||||||
arrivalAt: record.arrivalAt,
|
arrivalAt: record.arrivalAt,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
@ -142,6 +144,7 @@ export function dispatchContract(
|
||||||
input: { contractId: string; shipId: string; pilotId: string },
|
input: { contractId: string; shipId: string; pilotId: string },
|
||||||
random: RandomSource,
|
random: RandomSource,
|
||||||
now: RealTime,
|
now: RealTime,
|
||||||
|
options: { independent?: boolean } = {},
|
||||||
): Dispatch {
|
): Dispatch {
|
||||||
const { contract, periodIndex } = claimableContract(save, "location", input.contractId);
|
const { contract, periodIndex } = claimableContract(save, "location", input.contractId);
|
||||||
const ship = requireShip(save, input.shipId);
|
const ship = requireShip(save, input.shipId);
|
||||||
|
|
@ -162,7 +165,13 @@ export function dispatchContract(
|
||||||
{ id: random.id(), ...proposal, worldSeed: save.player.worldSeed },
|
{ id: random.id(), ...proposal, worldSeed: save.player.worldSeed },
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
const dispatch = storeDispatch(save, { record, shipId: ship.id, periodIndex, now });
|
const dispatch = storeDispatch(save, {
|
||||||
|
record,
|
||||||
|
shipId: ship.id,
|
||||||
|
periodIndex,
|
||||||
|
now,
|
||||||
|
independent: options.independent,
|
||||||
|
});
|
||||||
setInFlight(save, ship.id, pilot.id);
|
setInFlight(save, ship.id, pilot.id);
|
||||||
return dispatch;
|
return dispatch;
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +182,7 @@ export function dispatchReposition(
|
||||||
input: { shipId: string; pilotId: string; destinationId: string },
|
input: { shipId: string; pilotId: string; destinationId: string },
|
||||||
random: RandomSource,
|
random: RandomSource,
|
||||||
now: RealTime,
|
now: RealTime,
|
||||||
|
options: { independent?: boolean } = {},
|
||||||
): Dispatch {
|
): Dispatch {
|
||||||
const ship = requireShip(save, input.shipId);
|
const ship = requireShip(save, input.shipId);
|
||||||
const pilot = requirePilot(save, input.pilotId);
|
const pilot = requirePilot(save, input.pilotId);
|
||||||
|
|
@ -190,7 +200,13 @@ export function dispatchReposition(
|
||||||
{ id: random.id(), ...proposal, worldSeed: save.player.worldSeed },
|
{ id: random.id(), ...proposal, worldSeed: save.player.worldSeed },
|
||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
const dispatch = storeDispatch(save, { record, shipId: ship.id, periodIndex: null, now });
|
const dispatch = storeDispatch(save, {
|
||||||
|
record,
|
||||||
|
shipId: ship.id,
|
||||||
|
periodIndex: null,
|
||||||
|
now,
|
||||||
|
independent: options.independent,
|
||||||
|
});
|
||||||
setInFlight(save, ship.id, pilot.id);
|
setInFlight(save, ship.id, pilot.id);
|
||||||
return dispatch;
|
return dispatch;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,8 @@ import { decodeSave, encodeSave } from "../save-code";
|
||||||
import { commandTable } from "./commands";
|
import { commandTable } from "./commands";
|
||||||
import { startingSave } from "./company";
|
import { startingSave } from "./company";
|
||||||
import { CommandError, fail } from "./errors";
|
import { CommandError, fail } from "./errors";
|
||||||
import { pruneSave, resolveArrivals } from "./resolve";
|
import { catchUp, decideUntil } from "./catchup";
|
||||||
|
import { pruneSave } from "./resolve";
|
||||||
import { playerState } from "./state";
|
import { playerState } from "./state";
|
||||||
|
|
||||||
/** The commands the engine runs: the whole route catalog (CR-014). */
|
/** The commands the engine runs: the whole route catalog (CR-014). */
|
||||||
|
|
@ -67,6 +68,9 @@ function describeIssues(error: z.ZodError): string {
|
||||||
|
|
||||||
const noCompany = () => fail("unauthorized", "There is no company on this device yet.");
|
const noCompany = () => fail("unauthorized", "There is no company on this device yet.");
|
||||||
|
|
||||||
|
/** Events per catch-up chunk: each chunk is one save, with the screen updated between. */
|
||||||
|
export const CATCH_UP_CHUNK_EVENTS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The game on the device (CR-014): every former server endpoint as a command
|
* The game on the device (CR-014): every former server endpoint as a command
|
||||||
* with the same request and response schemas. Each command first applies any
|
* with the same request and response schemas. Each command first applies any
|
||||||
|
|
@ -103,25 +107,68 @@ export class GameEngine {
|
||||||
return this.options.notifications?.requestPermission() ?? Promise.resolve();
|
return this.options.notifications?.requestPermission() ?? Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The contract the player has open (decision 0.5.122): pilots leave it alone. */
|
||||||
|
#viewedContractId: string | null = null;
|
||||||
|
|
||||||
|
/** Tells the engine which contract the player has open, or null when none (Step 20.5). */
|
||||||
|
setViewedContract(contractId: string | null): void {
|
||||||
|
this.#viewedContractId = contractId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The resolution loop's pass (Step 14.6): applies any due arrivals and counts
|
* The resolution loop's pass (Step 14.6, CR-020): replays everything due up
|
||||||
* rests and shop jobs that ended after `since`. Saves only when an arrival
|
* to now (arrivals and Independent pilots' decisions, decision 0.5.125) in
|
||||||
* was resolved; does nothing before a company exists.
|
* chunks of `chunkEvents` events, each its own save, reporting progress in
|
||||||
|
* between so the screen stays responsive. Counts rests and shop jobs that
|
||||||
|
* ended after `since`. Does nothing before a company exists.
|
||||||
*/
|
*/
|
||||||
resolveDue(
|
async resolveDue(
|
||||||
since: RealTime | null,
|
since: RealTime | null,
|
||||||
): Promise<{ resolved: number; eventsPassed: number; now: RealTime }> {
|
options: { chunkEvents?: number; onProgress?: (done: number) => void } = {},
|
||||||
return this.core.write((save, now) => {
|
): Promise<{ resolved: number; decisions: number; eventsPassed: number; now: RealTime }> {
|
||||||
if (!save)
|
const chunkEvents = options.chunkEvents ?? CATCH_UP_CHUNK_EVENTS;
|
||||||
return { save: save!, result: { resolved: 0, eventsPassed: 0, now }, unchanged: true };
|
let resolved = 0;
|
||||||
const resolved = resolveArrivals(save, now);
|
let decisions = 0;
|
||||||
if (resolved > 0) pruneSave(save, now);
|
let target: RealTime | null = null;
|
||||||
const ended = (t: RealTime | null) => since !== null && t !== null && t > since && t <= now;
|
let cap: RealTime | null = null;
|
||||||
const eventsPassed =
|
let start: RealTime | null = null;
|
||||||
save.pilots.filter((p) => ended(p.restEndsAt)).length +
|
for (;;) {
|
||||||
save.ships.filter((s) => ended(s.inShopUntil)).length;
|
const chunk = await this.core.write((save, now) => {
|
||||||
return { save, result: { resolved, eventsPassed, now }, unchanged: resolved === 0 };
|
if (!save) return { save: save!, result: null, unchanged: true };
|
||||||
});
|
// One catch-up: the same end and cap for every chunk.
|
||||||
|
target ??= now;
|
||||||
|
cap ??= decideUntil(save);
|
||||||
|
start ??= save.lastTimeSeen;
|
||||||
|
const before = JSON.stringify(save);
|
||||||
|
const result = catchUp(save, target, {
|
||||||
|
decideUntil: cap,
|
||||||
|
maxEvents: chunkEvents,
|
||||||
|
viewedContractId: this.#viewedContractId,
|
||||||
|
});
|
||||||
|
if (result.done) pruneSave(save, target);
|
||||||
|
return { save, result, unchanged: JSON.stringify(save) === before };
|
||||||
|
});
|
||||||
|
if (!chunk) return { resolved: 0, decisions: 0, eventsPassed: 0, now: this.core.now() };
|
||||||
|
resolved += chunk.resolved;
|
||||||
|
decisions += chunk.decisions;
|
||||||
|
const end: RealTime = target!;
|
||||||
|
if (chunk.done) {
|
||||||
|
options.onProgress?.(1);
|
||||||
|
return this.core.read((save) => {
|
||||||
|
const ended = (t: RealTime | null) =>
|
||||||
|
since !== null && t !== null && t > since && t <= end;
|
||||||
|
const eventsPassed = save
|
||||||
|
? save.pilots.filter((p) => ended(p.restEndsAt)).length +
|
||||||
|
save.ships.filter((s) => ended(s.inShopUntil)).length
|
||||||
|
: 0;
|
||||||
|
return { resolved, decisions, eventsPassed, now: end };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const from: RealTime = start!;
|
||||||
|
options.onProgress?.(end > from ? (chunk.reached - from) / (end - from) : 1);
|
||||||
|
// Let the screen breathe between chunks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Runs one command; refusals throw `CommandError` with the server's codes. */
|
/** Runs one command; refusals throw `CommandError` with the server's codes. */
|
||||||
|
|
@ -164,7 +211,7 @@ export class GameEngine {
|
||||||
// "Start your company": a new save when there is none; otherwise the existing one.
|
// "Start your company": a new save when there is none; otherwise the existing one.
|
||||||
return this.core.write((save, now) => {
|
return this.core.write((save, now) => {
|
||||||
const next = save ?? startingSave(random, now);
|
const next = save ?? startingSave(random, now);
|
||||||
resolveArrivals(next, now);
|
catchUp(next, now, { decideUntil: decideUntil(next) });
|
||||||
return {
|
return {
|
||||||
save: next,
|
save: next,
|
||||||
result: { playerId: next.saveId, state: playerState(next, now) },
|
result: { playerId: next.saveId, state: playerState(next, now) },
|
||||||
|
|
@ -192,7 +239,7 @@ export class GameEngine {
|
||||||
if (!parsed.ok) throw fail("invalid-save", parsed.error);
|
if (!parsed.ok) throw fail("invalid-save", parsed.error);
|
||||||
return this.core.write((_current, now) => {
|
return this.core.write((_current, now) => {
|
||||||
const next = parsed.save;
|
const next = parsed.save;
|
||||||
resolveArrivals(next, now);
|
catchUp(next, now, { decideUntil: decideUntil(next) });
|
||||||
pruneSave(next, now);
|
pruneSave(next, now);
|
||||||
return { save: next, result: { playerId: next.saveId, state: playerState(next, now) } };
|
return { save: next, result: { playerId: next.saveId, state: playerState(next, now) } };
|
||||||
});
|
});
|
||||||
|
|
@ -202,7 +249,11 @@ export class GameEngine {
|
||||||
return this.core.write((save, now) => {
|
return this.core.write((save, now) => {
|
||||||
if (!save) throw noCompany();
|
if (!save) throw noCompany();
|
||||||
const before = JSON.stringify(save);
|
const before = JSON.stringify(save);
|
||||||
resolveArrivals(save, now);
|
// Everything due first: arrivals and pilots' decisions (decision 0.5.125).
|
||||||
|
catchUp(save, now, {
|
||||||
|
decideUntil: decideUntil(save),
|
||||||
|
viewedContractId: this.#viewedContractId,
|
||||||
|
});
|
||||||
const result = handler({ save, now, random }, input);
|
const result = handler({ save, now, random }, input);
|
||||||
pruneSave(save, now);
|
pruneSave(save, now);
|
||||||
return { save, result, unchanged: JSON.stringify(save) === before };
|
return { save, result, unchanged: JSON.stringify(save) === before };
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@ export function hirePilot(
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
orders: newPilotOrders(locationId),
|
orders: newPilotOrders(locationId),
|
||||||
|
nextDecisionAt: null,
|
||||||
|
emptyPeriods: 0,
|
||||||
};
|
};
|
||||||
save.pilots.push(pilot);
|
save.pilots.push(pilot);
|
||||||
return toApiPilot(save, pilot, now);
|
return toApiPilot(save, pilot, now);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type {
|
||||||
PilotOrders,
|
PilotOrders,
|
||||||
SaveDocument,
|
SaveDocument,
|
||||||
} from "@deadhead/schema";
|
} from "@deadhead/schema";
|
||||||
import { defaultOrders, type RealTime } from "@deadhead/sim";
|
import { defaultOrders, MS_PER_MINUTE, type RealTime } from "@deadhead/sim";
|
||||||
import { fail } from "./errors";
|
import { fail } from "./errors";
|
||||||
import {
|
import {
|
||||||
companyView,
|
companyView,
|
||||||
|
|
@ -18,6 +18,17 @@ import {
|
||||||
toApiPilot,
|
toApiPilot,
|
||||||
} from "./state";
|
} from "./state";
|
||||||
|
|
||||||
|
/** The turnaround (CR-020 §2) in real milliseconds. */
|
||||||
|
export function turnaroundMs(): number {
|
||||||
|
const { turnaroundGameMinutes } = content.config.independentPilots;
|
||||||
|
return (turnaroundGameMinutes * MS_PER_MINUTE) / content.config.timeCompression;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pilot set to Independent, or back from a trip, acts after the turnaround. */
|
||||||
|
export function scheduleDecision(pilot: SaveDocument["pilots"][number], ableAt: RealTime): void {
|
||||||
|
pilot.nextDecisionAt = pilot.mode === "independent" ? ableAt + turnaroundMs() : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** A new pilot's orders (CR-020 §2): anything goes, home at `homeLocationId`. */
|
/** A new pilot's orders (CR-020 §2): anything goes, home at `homeLocationId`. */
|
||||||
export function newPilotOrders(homeLocationId: string): PilotOrders {
|
export function newPilotOrders(homeLocationId: string): PilotOrders {
|
||||||
const orders = defaultOrders(homeLocationId, content);
|
const orders = defaultOrders(homeLocationId, content);
|
||||||
|
|
@ -64,7 +75,13 @@ export function setPilotMode(
|
||||||
throw fail("dispatch-office-required", "Buy a Dispatch Office first.");
|
throw fail("dispatch-office-required", "Buy a Dispatch Office first.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pilot.mode = input.mode;
|
if (pilot.mode !== input.mode) {
|
||||||
|
pilot.mode = input.mode;
|
||||||
|
pilot.emptyPeriods = 0;
|
||||||
|
// Independent from now (after the turnaround); a flying pilot decides on landing.
|
||||||
|
scheduleDecision(pilot, now);
|
||||||
|
if (pilot.status === "in-flight") pilot.nextDecisionAt = null;
|
||||||
|
}
|
||||||
return toApiPilot(save, pilot, now);
|
return toApiPilot(save, pilot, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ export class ResolutionLoop {
|
||||||
try {
|
try {
|
||||||
const result = await this.engine.resolveDue(this.#since);
|
const result = await this.engine.resolveDue(this.#since);
|
||||||
this.#since = result.now;
|
this.#since = result.now;
|
||||||
if (result.resolved > 0 || result.eventsPassed > 0) this.onChange();
|
if (result.resolved > 0 || result.decisions > 0 || result.eventsPassed > 0) {
|
||||||
|
this.onChange();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.#running = null;
|
this.#running = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ export function upcomingEvents(save: SaveDocument, now: RealTime): GameEvent[] {
|
||||||
const events: GameEvent[] = [];
|
const events: GameEvent[] = [];
|
||||||
for (const d of save.dispatches) {
|
for (const d of save.dispatches) {
|
||||||
if (d.status !== "in-flight" || d.arrivalAt <= now) continue;
|
if (d.status !== "in-flight" || d.arrivalAt <= now) continue;
|
||||||
|
// Pilots' own trips aren't announced (decision 0.5.126).
|
||||||
|
if (d.independent) continue;
|
||||||
const ship = d.tutorial
|
const ship = d.tutorial
|
||||||
? "The company loaner"
|
? "The company loaner"
|
||||||
: (save.ships.find((s) => s.id === d.shipId)?.name ?? "A ship");
|
: (save.ships.find((s) => s.id === d.shipId)?.name ?? "A ship");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { content } from "@deadhead/content";
|
||||||
|
import type { SaveDocument } from "@deadhead/schema";
|
||||||
|
import { MS_PER_MINUTE } from "@deadhead/sim";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { turnaroundMs } from "../src/game/independent";
|
||||||
|
import { upcomingEvents } from "../src/index";
|
||||||
|
import {
|
||||||
|
addPilot,
|
||||||
|
addShip,
|
||||||
|
edit,
|
||||||
|
newTestApp,
|
||||||
|
putBoard,
|
||||||
|
readSave,
|
||||||
|
setCredits,
|
||||||
|
signUpOwner,
|
||||||
|
testContract,
|
||||||
|
TEST_NOW,
|
||||||
|
type TestApp,
|
||||||
|
} from "./support";
|
||||||
|
|
||||||
|
const HOUR = 60 * MS_PER_MINUTE;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
const FIRST_LOOK = content.config.independentPilots.firstLookRealMinutes * MS_PER_MINUTE;
|
||||||
|
|
||||||
|
/** An owner with the Dispatch Office and Independent pilots, each crewing a Courier at Earth. */
|
||||||
|
async function independentFleet(test: TestApp, pilotIds: string[] = ["pa"]) {
|
||||||
|
const { app } = test;
|
||||||
|
await signUpOwner(app);
|
||||||
|
await setCredits(app, content.config.independentPilots.dispatchOfficePrice + 50_000);
|
||||||
|
await app.call("buyDispatchOffice", {});
|
||||||
|
for (const id of pilotIds) {
|
||||||
|
await addShip(app, { id: `ship-${id}`, modelId: "courier", name: `Courier ${id}` });
|
||||||
|
await addPilot(app, { id, name: `Pilot ${id}`, assignedShipId: `ship-${id}` });
|
||||||
|
await app.call("setPilotMode", { pilotId: id, mode: "independent" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const independentTrips = (save: SaveDocument) => save.dispatches.filter((d) => d.independent);
|
||||||
|
|
||||||
|
/** A second engine holding a copy of `test`'s save, at the same time. */
|
||||||
|
async function copyOf(test: TestApp): Promise<TestApp> {
|
||||||
|
const { code } = await test.app.call("exportSave", {});
|
||||||
|
const copy = newTestApp();
|
||||||
|
copy.clock.now = test.clock.now;
|
||||||
|
await copy.app.call("importSave", { code });
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("event-queue catch-up (Step 20.4, decision 0.5.125)", () => {
|
||||||
|
it("gives the same save whether 8 hours are caught up at once or an hour at a time", async () => {
|
||||||
|
const once = newTestApp();
|
||||||
|
await independentFleet(once, ["pa", "pb"]);
|
||||||
|
const pieces = await copyOf(once);
|
||||||
|
|
||||||
|
once.clock.now += 8 * HOUR;
|
||||||
|
await once.app.call("getPlayerState", {});
|
||||||
|
for (let hour = 0; hour < 8; hour++) {
|
||||||
|
pieces.clock.now += HOUR;
|
||||||
|
await pieces.app.call("getPlayerState", {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = await readSave(once.app);
|
||||||
|
const b = await readSave(pieces.app);
|
||||||
|
expect(independentTrips(a).length).toBeGreaterThan(20);
|
||||||
|
expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never lets two pilots take the same contract: the earlier decision, then the lower id, wins", async () => {
|
||||||
|
const test = newTestApp();
|
||||||
|
// Just past the first look, so both pilots decide on the stored board.
|
||||||
|
test.clock.now = TEST_NOW + FIRST_LOOK;
|
||||||
|
await independentFleet(test, ["pb", "pa"]);
|
||||||
|
const best = testContract({ id: "earth:best", payout: 5_000 });
|
||||||
|
const next = testContract({ id: "earth:next", payout: 3_000 });
|
||||||
|
await putBoard(test.app, [best, next], test.clock.now);
|
||||||
|
|
||||||
|
test.clock.now += turnaroundMs();
|
||||||
|
await test.app.call("getPlayerState", {});
|
||||||
|
const trips = independentTrips(await readSave(test.app));
|
||||||
|
const taken = Object.fromEntries(trips.map((d) => [d.pilotId, d.record.contract?.id]));
|
||||||
|
expect(taken).toEqual({ pa: "earth:best", pb: "earth:next" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rests a pilot over the duty limit on arrival, and resumes after the rest", async () => {
|
||||||
|
const test = newTestApp();
|
||||||
|
test.clock.now = TEST_NOW + FIRST_LOOK;
|
||||||
|
await independentFleet(test);
|
||||||
|
// One trip from the limit.
|
||||||
|
await edit(test.app, (save) => {
|
||||||
|
save.pilots.find((p) => p.id === "pa")!.dutyHours = content.config.dutyLimitGameHours - 0.1;
|
||||||
|
});
|
||||||
|
test.clock.now += 2 * HOUR;
|
||||||
|
await test.app.call("getPlayerState", {});
|
||||||
|
const save = await readSave(test.app);
|
||||||
|
const [first, second] = independentTrips(save).sort(
|
||||||
|
(x, y) => x.record.departureAt - y.record.departureAt,
|
||||||
|
);
|
||||||
|
expect(first!.resolution!.pilotRestEndsAt).not.toBeNull();
|
||||||
|
const restEnds = first!.resolution!.pilotRestEndsAt!;
|
||||||
|
// Nothing while resting; the next job after the rest and the turnaround.
|
||||||
|
expect(second).toBeDefined();
|
||||||
|
expect(second!.record.departureAt).toBeGreaterThanOrEqual(restEnds + turnaroundMs());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops pilots deciding 7 days after the last saved time", async () => {
|
||||||
|
const test = newTestApp();
|
||||||
|
await independentFleet(test);
|
||||||
|
const start = (await readSave(test.app)).lastTimeSeen;
|
||||||
|
test.clock.now = start + 10 * DAY;
|
||||||
|
await test.app.call("getPlayerState", {});
|
||||||
|
const save = await readSave(test.app);
|
||||||
|
const departures = independentTrips(save).map((d) => d.record.departureAt);
|
||||||
|
const cap = start + content.config.independentPilots.maxCatchUpRealDays * DAY;
|
||||||
|
expect(Math.max(...departures)).toBeLessThanOrEqual(cap);
|
||||||
|
// They worked right up to the cap, and pick up again from now.
|
||||||
|
expect(Math.max(...departures)).toBeGreaterThan(cap - DAY);
|
||||||
|
expect(save.pilots.find((p) => p.id === "pa")!.nextDecisionAt).toBeGreaterThanOrEqual(
|
||||||
|
start + 10 * DAY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Every pilot dispatch goes through dispatchContract, which re-runs validateDispatch.
|
||||||
|
it("dispatches pilots only in ways that respect the player's priority", async () => {
|
||||||
|
const test = newTestApp();
|
||||||
|
await independentFleet(test, ["pa", "pb"]);
|
||||||
|
test.clock.now += 4 * HOUR;
|
||||||
|
await test.app.call("getPlayerState", {});
|
||||||
|
const save = await readSave(test.app);
|
||||||
|
const trips = independentTrips(save).filter((d) => d.record.contract);
|
||||||
|
expect(trips.length).toBeGreaterThan(10);
|
||||||
|
for (const d of trips) {
|
||||||
|
const contract = d.record.contract!;
|
||||||
|
expect(contract.firstContact).toBe(false);
|
||||||
|
// Past the first-look window of the contract's board period.
|
||||||
|
expect(d.record.departureAt - contract.offeredAt).toBeGreaterThanOrEqual(FIRST_LOOK);
|
||||||
|
expect(d.record.departureAt).toBeLessThan(contract.expiresAt);
|
||||||
|
// Each one is a distinct contract.
|
||||||
|
expect(trips.filter((o) => o.record.contract!.id === contract.id)).toHaveLength(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("catches up in chunks, reporting progress, with the same result as one go", async () => {
|
||||||
|
const once = newTestApp();
|
||||||
|
await independentFleet(once, ["pa", "pb"]);
|
||||||
|
const chunked = await copyOf(once);
|
||||||
|
once.clock.now += 3 * HOUR;
|
||||||
|
chunked.clock.now += 3 * HOUR;
|
||||||
|
await once.app.resolveDue(null);
|
||||||
|
const progress: number[] = [];
|
||||||
|
await chunked.app.resolveDue(null, { chunkEvents: 5, onProgress: (p) => progress.push(p) });
|
||||||
|
expect(progress.length).toBeGreaterThan(3);
|
||||||
|
expect(progress).toEqual([...progress].sort((x, y) => x - y));
|
||||||
|
expect(progress.at(-1)).toBe(1);
|
||||||
|
const a = await readSave(once.app);
|
||||||
|
const b = await readSave(chunked.app);
|
||||||
|
expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules no notification for a pilot's own trips (decision 0.5.126)", async () => {
|
||||||
|
const test = newTestApp();
|
||||||
|
test.clock.now = TEST_NOW + FIRST_LOOK;
|
||||||
|
await independentFleet(test);
|
||||||
|
test.clock.now += turnaroundMs();
|
||||||
|
await test.app.call("getPlayerState", {});
|
||||||
|
const save = await readSave(test.app);
|
||||||
|
const flying = independentTrips(save).filter((d) => d.status === "in-flight");
|
||||||
|
expect(flying).toHaveLength(1);
|
||||||
|
const events = upcomingEvents(save, test.clock.now);
|
||||||
|
expect(events.some((e) => e.at === flying[0]!.arrivalAt)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -40,6 +40,7 @@ async function accept(guest: Guest, contract: Contract, now: number) {
|
||||||
save.dispatches.push({
|
save.dispatches.push({
|
||||||
id,
|
id,
|
||||||
shipId: null,
|
shipId: null,
|
||||||
|
independent: false,
|
||||||
pilotId: guest.avatarId,
|
pilotId: guest.avatarId,
|
||||||
tutorial: true,
|
tutorial: true,
|
||||||
record,
|
record,
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,7 @@ export function insertDispatch(
|
||||||
save.dispatches.push({
|
save.dispatches.push({
|
||||||
id,
|
id,
|
||||||
shipId: ship.id,
|
shipId: ship.id,
|
||||||
|
independent: false,
|
||||||
pilotId: pilot.id,
|
pilotId: pilot.id,
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
record,
|
record,
|
||||||
|
|
@ -401,6 +402,8 @@ export function addPilot(
|
||||||
createdAt: TEST_NOW,
|
createdAt: TEST_NOW,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
orders: newPilotOrders("earth"),
|
orders: newPilotOrders("earth"),
|
||||||
|
nextDecisionAt: null,
|
||||||
|
emptyPeriods: 0,
|
||||||
...pilot,
|
...pilot,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,10 @@ export const savedPilotSchema = z.object({
|
||||||
/** Manual or Independent, and orders (decision 0.5.120). */
|
/** Manual or Independent, and orders (decision 0.5.120). */
|
||||||
mode: pilotModeSchema,
|
mode: pilotModeSchema,
|
||||||
orders: pilotOrdersSchema,
|
orders: pilotOrdersSchema,
|
||||||
|
/** An Independent pilot's next decision point (CR-020 §2); null while flying or Manual. */
|
||||||
|
nextDecisionAt: timeSchema.nullable(),
|
||||||
|
/** Board periods in a row an Independent pilot found nothing to take (decision 0.5.124). */
|
||||||
|
emptyPeriods: z.number().int().nonnegative(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** One ship-and-pilot pair's lasting capabilities (decision 0.5.115). */
|
/** One ship-and-pilot pair's lasting capabilities (decision 0.5.115). */
|
||||||
|
|
@ -149,6 +153,8 @@ export const savedDispatchSchema = z.object({
|
||||||
shipId: idSchema.nullable(),
|
shipId: idSchema.nullable(),
|
||||||
pilotId: idSchema,
|
pilotId: idSchema,
|
||||||
tutorial: z.boolean(),
|
tutorial: z.boolean(),
|
||||||
|
/** Flown by an Independent pilot's own choice (CR-020): no notification, in the logbook. */
|
||||||
|
independent: z.boolean(),
|
||||||
record: dispatchRecordSchema,
|
record: dispatchRecordSchema,
|
||||||
arrivalAt: timeSchema,
|
arrivalAt: timeSchema,
|
||||||
status: z.enum(["in-flight", "resolved"]),
|
status: z.enum(["in-flight", "resolved"]),
|
||||||
|
|
@ -158,7 +164,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 = 5;
|
export const CURRENT_SAVE_VERSION = 6;
|
||||||
|
|
||||||
export const saveDocumentSchema = z.object({
|
export const saveDocumentSchema = z.object({
|
||||||
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
saveVersion: z.literal(CURRENT_SAVE_VERSION),
|
||||||
|
|
@ -305,11 +311,28 @@ function independentPilots(save: Doc): Doc {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version 5 → 6 (CR-020 Step 20.4): catch-up state. No pilot has a decision
|
||||||
|
* pending or empty periods counted, and no dispatch was an Independent choice.
|
||||||
|
*/
|
||||||
|
function catchUpState(save: Doc): Doc {
|
||||||
|
return {
|
||||||
|
...save,
|
||||||
|
pilots: records(save.pilots).map((pilot) => ({
|
||||||
|
...pilot,
|
||||||
|
nextDecisionAt: null,
|
||||||
|
emptyPeriods: 0,
|
||||||
|
})),
|
||||||
|
dispatches: records(save.dispatches).map((dispatch) => ({ ...dispatch, independent: false })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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 },
|
{ from: 3, migrate: dropBoards },
|
||||||
{ from: 4, migrate: independentPilots },
|
{ from: 4, migrate: independentPilots },
|
||||||
|
{ from: 5, migrate: catchUpState },
|
||||||
];
|
];
|
||||||
|
|
||||||
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: 5,
|
saveVersion: 6,
|
||||||
saveId: "save-1",
|
saveId: "save-1",
|
||||||
lastTimeSeen: T + 3_600_000,
|
lastTimeSeen: T + 3_600_000,
|
||||||
player: {
|
player: {
|
||||||
|
|
@ -116,6 +116,8 @@ export function fullSave(): SaveDocument {
|
||||||
restEndsAt: null,
|
restEndsAt: null,
|
||||||
createdAt: T,
|
createdAt: T,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
|
nextDecisionAt: null,
|
||||||
|
emptyPeriods: 0,
|
||||||
orders: {
|
orders: {
|
||||||
destinationIds: null,
|
destinationIds: null,
|
||||||
cargoClassIds: ["standard", "heavy", "high-value", "dangerous"],
|
cargoClassIds: ["standard", "heavy", "high-value", "dangerous"],
|
||||||
|
|
@ -139,6 +141,8 @@ export function fullSave(): SaveDocument {
|
||||||
restEndsAt: T + 3_000_000,
|
restEndsAt: T + 3_000_000,
|
||||||
createdAt: T,
|
createdAt: T,
|
||||||
mode: "manual",
|
mode: "manual",
|
||||||
|
nextDecisionAt: null,
|
||||||
|
emptyPeriods: 0,
|
||||||
orders: {
|
orders: {
|
||||||
destinationIds: null,
|
destinationIds: null,
|
||||||
cargoClassIds: ["standard", "heavy", "high-value", "dangerous"],
|
cargoClassIds: ["standard", "heavy", "high-value", "dangerous"],
|
||||||
|
|
@ -171,6 +175,7 @@ export function fullSave(): SaveDocument {
|
||||||
shipId: "s1",
|
shipId: "s1",
|
||||||
pilotId: "avatar",
|
pilotId: "avatar",
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
|
independent: false,
|
||||||
record: record("d1", T + 9_000_000),
|
record: record("d1", T + 9_000_000),
|
||||||
arrivalAt: T + 9_000_000,
|
arrivalAt: T + 9_000_000,
|
||||||
status: "in-flight",
|
status: "in-flight",
|
||||||
|
|
@ -183,6 +188,7 @@ export function fullSave(): SaveDocument {
|
||||||
shipId: "s1",
|
shipId: "s1",
|
||||||
pilotId: "avatar",
|
pilotId: "avatar",
|
||||||
tutorial: false,
|
tutorial: false,
|
||||||
|
independent: false,
|
||||||
record: record("d0", T + 200_000),
|
record: record("d0", T + 200_000),
|
||||||
arrivalAt: T + 200_000,
|
arrivalAt: T + 200_000,
|
||||||
status: "resolved",
|
status: "resolved",
|
||||||
|
|
|
||||||
|
|
@ -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(5);
|
expect(CURRENT_SAVE_VERSION).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
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(5);
|
expect(save.saveVersion).toBe(6);
|
||||||
// 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,7 +116,7 @@ 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(5);
|
expect(save.saveVersion).toBe(6);
|
||||||
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]),
|
||||||
|
|
@ -142,7 +142,7 @@ describe("save migration 3 → 4: completable boards (CR-019 §3)", () => {
|
||||||
for (const board of old.contractBoards) delete board.fleet;
|
for (const board of old.contractBoards) delete board.fleet;
|
||||||
const result = migrateSave(old);
|
const result = migrateSave(old);
|
||||||
if (!result.ok) throw new Error(result.error);
|
if (!result.ok) throw new Error(result.error);
|
||||||
expect(result.save.saveVersion).toBe(5);
|
expect(result.save.saveVersion).toBe(6);
|
||||||
expect(result.save.contractBoards).toEqual([]);
|
expect(result.save.contractBoards).toEqual([]);
|
||||||
expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] });
|
expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] });
|
||||||
});
|
});
|
||||||
|
|
@ -164,7 +164,7 @@ describe("save migration 4 → 5: independent pilots (CR-020 §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);
|
||||||
expect(result.save.saveVersion).toBe(5);
|
expect(result.save.saveVersion).toBe(6);
|
||||||
expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 });
|
expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 });
|
||||||
for (const pilot of result.save.pilots) {
|
for (const pilot of result.save.pilots) {
|
||||||
expect(pilot.mode).toBe("manual");
|
expect(pilot.mode).toBe("manual");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue