From 71ea3c6b8ee8a831c434a826aef79e12df411934 Mon Sep 17 00:00:00 2001 From: fireshaper Date: Sat, 17 Oct 2026 00:15:41 -0400 Subject: [PATCH] Step 26.5: engine: events in play and catch-up Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 12 + apps/web/test/about.test.tsx | 2 +- apps/web/test/away-goals.test.tsx | 1 + apps/web/test/away-summary.test.tsx | 1 + apps/web/test/board-logbook.test.tsx | 1 + apps/web/test/dashboard.test.tsx | 1 + apps/web/test/deal-modal.test.tsx | 1 + apps/web/test/fixtures.ts | 14 +- apps/web/test/fleet-sleep.test.tsx | 1 + apps/web/test/logbook-screen.test.tsx | 1 + apps/web/test/pilot-share.test.tsx | 1 + apps/web/test/tutorial-board.test.tsx | 1 + memory-bank/architecture.md | 6 +- memory-bank/progress.md | 2 +- packages/engine/src/game/catchup.ts | 22 ++ packages/engine/src/game/commands.ts | 18 +- packages/engine/src/game/company.ts | 5 + packages/engine/src/game/dispatch.ts | 8 + packages/engine/src/game/events.ts | 247 +++++++++++++++ packages/engine/src/game/fleet.ts | 9 +- packages/engine/src/game/resolve.ts | 1 + packages/engine/src/game/state.ts | 6 + .../engine/test/ported/tutorial-board.test.ts | 2 + packages/engine/test/start.test.ts | 1 + packages/engine/test/support.ts | 8 + packages/engine/test/trip-events.test.ts | 294 ++++++++++++++++++ packages/schema/src/entities.ts | 9 + packages/schema/src/errors.ts | 4 + packages/schema/src/events.ts | 69 ++++ packages/schema/src/index.ts | 1 + packages/schema/src/logbook.ts | 26 ++ packages/schema/src/requests.ts | 7 + packages/schema/src/routes.ts | 20 ++ packages/schema/src/save.ts | 43 ++- packages/schema/test/routes.test.ts | 4 + packages/schema/test/save-fixture.ts | 15 +- packages/schema/test/save.test.ts | 46 ++- packages/schema/test/schemas.test.ts | 4 + packages/sim/src/away.ts | 17 + 39 files changed, 914 insertions(+), 17 deletions(-) create mode 100644 packages/engine/src/game/events.ts create mode 100644 packages/engine/test/trip-events.test.ts create mode 100644 packages/schema/src/events.ts diff --git a/DECISIONS.md b/DECISIONS.md index 59f144b..92aa475 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1928,3 +1928,15 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The hull reason comes after the pilot checks and before expiry and fuel**, like a ship condition the player can fix: the dispatch sheet still lists every problem, and the fuel reason stays last ("Refuel here first" relies on it meaning nothing else is wrong). - **`ShipState.hullConditionPct` is optional** (absent = 100), so the tutorial loaner and older test fixtures need nothing new. - **Condition is kept to one decimal**, as rolled hits are, so repeated hits add up exactly. + +## Step 26.5 + +- **The event lives on the saved dispatch** (`event`, `eventApplied`), next to the sim's record, which stays the record of the trip as flown: applying an option updates its `arrivalAt` (and fuel), so rest and duty count the longer trip. +- **Events come first at any moment in catch-up**, before shop ends, arrivals and decisions: a radio call whose window ends at the arrival must move the arrival before the landing resolves. +- **A radio call is "waiting" from its fire time until its window ends** and nothing has applied it. Answering before it fires is `radio-call-not-found`; after, `radio-call-closed` (catch-up has already applied the default by then). +- **The player can answer an Independent pilot's call** (decision 0.5.151); the call says it's theirs (`independent`). +- **Fleet pacing counts every radio call in the save**, flying or landed (seven days of them), so a call that just fired keeps the next one 45 minutes away. +- **The pacing counters**: a trip with an event resets the ship's `quietTrips` to 0, a quiet one adds 1; every trip after the deal adds 1 to `tripsSinceDeal`. Tutorial runs touch neither. +- **A repair is paid when it starts**, like an upgrade, and the hull is whole when the shop time ends; the shop time counts as rest when it's long enough (decision 0.5.135), which a repair of 20 points or more is. +- **Migration:** a company that has made the deal starts past its grace trips (it has flown plenty), but still gets the derelict pod first. +- **Fix from Step 25.1:** a goal already ready when a catch-up starts was logged at whatever moment first touched the save, so one long catch-up and several short ones logged it at different times. It's now logged at the save's last-seen time, before anything else. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index 077b8f4..94a5115 100644 --- a/apps/web/test/about.test.tsx +++ b/apps/web/test/about.test.tsx @@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => { expect(text).toContain("Build: test"); // CR-014: the save, its format version and where it is stored; no player id. expect(text).toContain("Save: player-1"); - expect(text).toContain("Save version: 10"); + expect(text).toContain("Save version: 11"); expect(text).toContain("Storage: unknown"); expect(text).not.toContain("Player:"); expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); diff --git a/apps/web/test/away-goals.test.tsx b/apps/web/test/away-goals.test.tsx index 450ac21..4d16e6a 100644 --- a/apps/web/test/away-goals.test.tsx +++ b/apps/web/test/away-goals.test.tsx @@ -45,6 +45,7 @@ function returningPlayer(acknowledged: boolean): PlayerState { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + event: null, restEndsAt: null, resolvedAt: 1, }, diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx index 5683272..ffae1e5 100644 --- a/apps/web/test/away-summary.test.tsx +++ b/apps/web/test/away-summary.test.tsx @@ -54,6 +54,7 @@ const pending: ResolutionSummary = { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + event: null, restEndsAt: null, resolvedAt: NOW - 60_000, }; diff --git a/apps/web/test/board-logbook.test.tsx b/apps/web/test/board-logbook.test.tsx index b9ce0fb..8f89812 100644 --- a/apps/web/test/board-logbook.test.tsx +++ b/apps/web/test/board-logbook.test.tsx @@ -157,6 +157,7 @@ describe("pilot logbook (Step 21.7)", () => { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + event: null, restEndsAt: null, resolvedAt: PERIOD_START, ...over, diff --git a/apps/web/test/dashboard.test.tsx b/apps/web/test/dashboard.test.tsx index b8800ac..278ab54 100644 --- a/apps/web/test/dashboard.test.tsx +++ b/apps/web/test/dashboard.test.tsx @@ -163,6 +163,7 @@ describe("loan on HQ", () => { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + event: null, restEndsAt: null, resolvedAt: 1, }, diff --git a/apps/web/test/deal-modal.test.tsx b/apps/web/test/deal-modal.test.tsx index 41ef0cf..3daf844 100644 --- a/apps/web/test/deal-modal.test.tsx +++ b/apps/web/test/deal-modal.test.tsx @@ -113,6 +113,7 @@ describe("the deal", () => { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + event: null, restEndsAt: null, resolvedAt: 1, }, diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index b877565..d6844bb 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -13,7 +13,15 @@ export function shipExtras( upgrades: Record = {}, ): Pick< Ship, - "upgrades" | "stats" | "inShopUntil" | "shop" | "fuelFu" | "fuelAvgPrice" | "refuelPolicy" + | "upgrades" + | "stats" + | "inShopUntil" + | "shop" + | "fuelFu" + | "fuelAvgPrice" + | "refuelPolicy" + | "hullConditionPct" + | "repairing" > { const model = content.shipModel(modelId); if (!model) throw new Error(`Unknown ship model ${modelId}`); @@ -34,6 +42,9 @@ export function shipExtras( fuelFu: s.tankCapacityFu, fuelAvgPrice: 16, refuelPolicy: "auto", + // A whole hull, not in for repair (CR-026). + hullConditionPct: 100, + repairing: false, }; } @@ -95,6 +106,7 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS goals: [], dispatchOffice: { owned: false, spendingFloor: 500 }, // Awake: a day after the usual test time (CR-024). + radioCalls: [], fleetSleep: { afterHours: 24, sleepsAt: Date.parse("2100-01-02T00:00:00Z"), asleep: false }, welcomeSeen: true, ...overrides, diff --git a/apps/web/test/fleet-sleep.test.tsx b/apps/web/test/fleet-sleep.test.tsx index 0de8975..41d1175 100644 --- a/apps/web/test/fleet-sleep.test.tsx +++ b/apps/web/test/fleet-sleep.test.tsx @@ -42,6 +42,7 @@ const summary: ResolutionSummary = { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + event: null, restEndsAt: null, resolvedAt: SLEPT - 60_000, }; diff --git a/apps/web/test/logbook-screen.test.tsx b/apps/web/test/logbook-screen.test.tsx index 9ae1ad8..f7ce87f 100644 --- a/apps/web/test/logbook-screen.test.tsx +++ b/apps/web/test/logbook-screen.test.tsx @@ -138,6 +138,7 @@ const pending: ResolutionSummary = { pilotXp: 12, discoveredLocationId: null, scriptedId: null, + event: null, restEndsAt: null, resolvedAt: NOW - HOUR, }; diff --git a/apps/web/test/pilot-share.test.tsx b/apps/web/test/pilot-share.test.tsx index f07f426..c487315 100644 --- a/apps/web/test/pilot-share.test.tsx +++ b/apps/web/test/pilot-share.test.tsx @@ -78,6 +78,7 @@ const summary: ResolutionSummary = { pilotXp: 10, discoveredLocationId: null, scriptedId: null, + event: null, restEndsAt: null, resolvedAt: NOW, }; diff --git a/apps/web/test/tutorial-board.test.tsx b/apps/web/test/tutorial-board.test.tsx index efc53a5..6995210 100644 --- a/apps/web/test/tutorial-board.test.tsx +++ b/apps/web/test/tutorial-board.test.tsx @@ -154,6 +154,7 @@ describe("scripted delivery summary", () => { pilotXp: 10, discoveredLocationId: null, scriptedId: "unmarked-crates", + event: null, restEndsAt: null, resolvedAt: NOW, }, diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index dcbbe33..3dbd9cb 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -66,6 +66,8 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re | `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes | | `setSleepThreshold` | `POST /api/fleet/sleep-after` | `hours` (one of `fleetSleep.optionsHours`, else `validation-error`) | `PlayerState` | yes; a manual decision (CR-024) | | `getLogbook` | `GET /api/logbook` | — | `{ entries, absence: { from, to }, lastAbsence, problems }` (CR-025: the last 7 days, the absence since the summary was last closed, the one before, and what needs the player now) | yes | +| `answerRadioCall` | `POST /api/dispatches/:dispatchId/radio` | `dispatchId`, `optionId` | `{ radioCalls }` (still waiting) | yes; a manual decision; `radio-call-not-found` before it fires, `radio-call-closed` after its window or once applied, `radio-option-unavailable` (CR-026) | +| `repairShip` | `POST /api/ships/:shipId/repair` | `shipId` | `{ ship }` (in the shop) | yes; a manual decision; at an HQ, below 100% (`hull-intact`), charged now (CR-026) | | `backToWork` | `POST /api/fleet/wake` | — | `PlayerState` (awake) | yes; a manual decision that does nothing else: wakes the fleet (CR-024) | | `exportSave` | engine only (CR-014) | — | `{ code }`: `DEADHEAD1.` + base64url(gzip(save JSON)) | yes | | `importSave` | engine only (CR-014) | `code` (whitespace ignored) | `{ playerId, state }`; `invalid-save` if it isn't a good save | no | @@ -84,7 +86,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re ## Save document (`packages/schema/src/save.ts`, CR-014) -One document per company on the device, validated on every load and save: `saveVersion` (currently 10; 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-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023; 8 → 9 added company `lastManualDecisionAt` (null until the engine's first catch-up sets it) and `sleepAfterHours`, CR-024; 9 → 10 added the `logbook`, player `awaySince`, `lastAbsence` and `readyGoalsLogged`, and ship `shopOpen`, turning resolved dispatches into entries, CR-025), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan, the Dispatch Office and spending floor, and since CR-024 the last manual decision and the sleep threshold), `ships` (with `upgrades` as a level map, shop fields including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`, `logbook` (CR-025: entries of kind `flight` (deliveries and deadheads, with payout, pilot pay, fuel used, loan repayment and XP), `refuel` (top-up, bought, pilot or emergency), `rest`, `upgrade`, `discovery`, `level-up` and `goal-ready`, each with time, pilot and ship; ids made from what they record; kept `logbookRetentionRealDays` (7) and pruned on save). Written by `packages/engine/src/game/logbook.ts` helpers from arrivals, refuelling, rest, shop ends (a catch-up event for every `shopOpen` ship), and goals becoming ready (after every catch-up event and command). 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 11; 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-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023; 8 → 9 added company `lastManualDecisionAt` (null until the engine's first catch-up sets it) and `sleepAfterHours`, CR-024; 9 → 10 added the `logbook`, player `awaySince`, `lastAbsence` and `readyGoalsLogged`, and ship `shopOpen`, turning resolved dispatches into entries, CR-025; 10 → 11 added ship `hullConditionPct` (100), `quietTrips` and `shopRepair`, dispatch `event` and `eventApplied` (null), company `firstEventDone` and `tripsSinceDeal`, CR-026), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan, the Dispatch Office and spending floor, and since CR-024 the last manual decision and the sleep threshold), `ships` (with `upgrades` as a level map, shop fields including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`, `logbook` (CR-025: entries of kind `flight` (deliveries and deadheads, with payout, pilot pay, fuel used, loan repayment and XP), `refuel` (top-up, bought, pilot or emergency), `rest`, `upgrade`, `discovery`, `level-up` and `goal-ready`, each with time, pilot and ship; ids made from what they record; kept `logbookRetentionRealDays` (7) and pruned on save). Written by `packages/engine/src/game/logbook.ts` helpers from arrivals, refuelling, rest, shop ends (a catch-up event for every `shopOpen` ship), and goals becoming ready (after every catch-up event and command). 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. @@ -150,4 +152,6 @@ Notifications (`packages/engine/src/notifications.ts`) also plan the fleet's sle `packages/sim/src/away.ts`: `buildAwaySummary({ from, to, entries, pilots, problems })` builds "While you were away" from logbook entries (CR-025): duration, totals, ≤3 highlights in priority order with an overflow count, ≤5 pilot lines by net with an overflow line and the idle count, and a direct list for short absences. `flightNet` is a flight's net. Its `LogbookEntry` type is the save's `LogEntry`. +`packages/engine/src/game/events.ts` (CR-026): `rollForDispatch` rolls a contract's or deadhead's event at dispatch and keeps the pacing counters (ship `quietTrips`, company `tripsSinceDeal` and `firstEventDone`); `applyTripEvent` applies an option (new arrival on the dispatch and its record, hull hit, extra fuel, an `event` logbook entry); catch-up settles due events (`nextEventDue`: an automatic event at its fire time, an unanswered radio call at its window end, before anything else at that moment); `radioCalls` lists the waiting calls in `PlayerState.radioCalls`; `answerRadioCall` and `repairShip` are the commands; `finishRepair` ends a repair at its shop end (hull 100%, a `repair` entry). Resolution passes the applied event to the sim, so pay and XP change. + `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 at the fleet's sleep time (`sleepsAt` in `state.ts`: `company.lastManualDecisionAt` plus `sleepAfterHours`, CR-024), and later ones resume from `until`, so the next manual decision wakes pilots from then. Arrivals and shop ends always resolve. A null `lastManualDecisionAt` (a save just migrated) is set to `until` first. 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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 05ef57e..e567c0c 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -551,7 +551,7 @@ Spec: `memory-bank/change-requests/CR-026-random-events-hull.md` (received as "C - [x] **Step 26.2 — Sim: event roll at dispatch.** `rollTripEvent` (`packages/sim/src/events.ts`, pure, seeded from the trip's seed) returns null or a `TripEvent`: its id, kind, fire time (20–70% of the trip), window end (radio: 30 real minutes or arrival, whichever is first; automatic: the fire time), every option's pre-rolled `EventOutcome` (delay in real ms, hull loss after the hull rating, pay lost, fee, bonus, extra XP %, extra fuel), whether each is available (10% free hold, fuel for the burn) and the default (another available option when the cautious one can't be taken). Quiet in the tutorial, the first 3 trips after the deal and a ship's 2 trips after an event; the first event is the derelict pod on the first contract of 6+ game hours; otherwise `eventChance` (decision 0.5.145, capped at 35% after the route multipliers) and a weighted pick among the events allowed on the trip (`eventWeight`: conditions, skill halving, the hull factor); a radio call within 45 real minutes of another in the fleet is dropped. Tests (`packages/sim/test/events.test.ts`): the same trip gives the same event; none in the tutorial or the grace trips; the first event is the pod on 6+ hours only; the cooldown; fleet pacing at 44 and 46 minutes, the window, the fire point; 100,000 trips (Earth↔Moon ≤ 3%, long hauls ≤ 35%, every event only where allowed); Dangerous Cargo two levels above the tier makes leaks under 40% as common. - [x] **Step 26.3 — Sim: applying outcomes.** `applyEventOption` turns an option's pre-rolled outcome into an `AppliedEvent`: the new arrival (the delay added to the original arrival; a speed-up never below half the trip or before the event), hull loss, pay lost, fee, bonus, the Priority bonus lost when the new trip misses the window, extra XP and extra fuel; it refuses an unavailable option. `payoutAfterEvent` takes losses, the fee and a lost Priority bonus off the payout (never below zero) and adds the bonus. `resolveDispatch` takes the applied event: the pilot's share and the loan come from the new payout, and XP gets the extra percent. Tests (`packages/sim/test/events.test.ts`): a 3-hour detour moves arrival by exactly 3 game hours; a tailwind stops at half the trip; pay never below zero, bonus on top; a missed Priority window costs only the bonus; a full hold makes salvage unavailable and refused; an option's outcome is its pre-rolled one; resolution pays the bonus and extra XP and shares the new payout. - [x] **Step 26.4 — Sim: hull condition and repairs.** `packages/sim/src/hull.ts`: `hullRatingReduction` (10% a point, at most 50%), `damageAfterRating`, `damageChance` (cut by the rating, doubled below 25%), `hullAfterDamage` (never below 5%), `repairCost` (missing points × model price × 0.5%) and `repairShopMs` (half a minute a point, at least 5), `hullBarsDangerous` (below 50%). The event roll uses them. `validateDispatch` has a new reason, `hull-too-damaged`, for Dangerous cargo on a hull below 50% (after the skill checks, before expiry and fuel), with messages in the engine and the dispatch sheet; it isn't a permanent check, so boards still show those jobs (CR-019). Tests (`packages/sim/test/hull.test.ts`): rating 3 turns a 20% hit into 14%, and the cut stops at half; chances on a rated and a battered hull; the 5% floor; Dangerous refused at 40%, allowed at 50% and for other cargo at 40%; repair cost and time. -- [ ] Step 26.5 — Engine: events in play and catch-up +- [x] **Step 26.5 — Engine: events in play and catch-up.** Contract and deadhead dispatches (the player's and Independent pilots') roll their event with `rollForDispatch` and store it on the dispatch, counting the pacing. Catch-up settles events in time order before shop ends, arrivals and decisions at the same moment: automatic events at their fire time, radio calls with their default when the window closes unanswered. `applyTripEvent` sets the new arrival, the hull (never below 5%) and extra fuel, and logs an `event` entry; resolution pays the event's bonuses and losses. `answerRadioCall` (manual decision) applies the chosen option within the window; `repairShip` (manual decision) charges the repair at an HQ, puts the ship in the shop (counting as rest) and `finishRepair` makes it 100% at the shop end with a `repair` entry. API: `PlayerState.radioCalls`, ship `hullConditionPct` and `repairing`, summary `event`. Save version 11 (10 → 11 per CR-026 §6). Also fixed: goals already ready when a catch-up starts are logged at the save's last-seen time, so they're the same however the catch-up is split. Tests: `packages/engine/test/trip-events.test.ts` (answering in the window, refused before and after; the default and its 3-hour detour when unanswered; an automatic tip paid on delivery; an Independent pilot's call defaults and can be answered; one go equals 7-minute pieces with a defaulted call and an automatic event; the first event after the grace trips is the derelict pod, with the counters; grace trips are quiet; a repair at an HQ: cost, shop, 100% and the entry, refused when intact; refused away from an HQ); `packages/schema/test/save.test.ts` (10 → 11). - [ ] Step 26.6 — Client: radio calls - [ ] Step 26.7 — Client: hull and results - [ ] Step 26.8 — Notifications diff --git a/packages/engine/src/game/catchup.ts b/packages/engine/src/game/catchup.ts index f7cac9a..73bb28e 100644 --- a/packages/engine/src/game/catchup.ts +++ b/packages/engine/src/game/catchup.ts @@ -12,6 +12,7 @@ import { import type { RandomSource } from "../core"; import { locationBoard } from "./boards"; import { dispatchContract, dispatchReposition } from "./dispatch"; +import { eventDueAt, nextEventDue, settleDueEvent } from "./events"; import { finishShop, refuelShip } from "./fleet"; import { logReadyGoals } from "./logbook"; import { scheduleDecision } from "./independent"; @@ -190,6 +191,9 @@ export function catchUp( ): CatchUpResult { // A save just migrated to version 9 counts its first catch-up as the last decision (CR-024 §3). save.company.lastManualDecisionAt ??= until; + // Goals already ready before this catch-up count from when the save was last seen, so + // the time is the same however the catch-up is split (decision 0.5.141). + logReadyGoals(save, save.lastTimeSeen); const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY; // Pilots decide before the 7-day cap and before the fleet goes quiet (decision 0.5.137). const decideLimit = Math.min(until, options.decideUntil, sleepsAt(save, until) - 1); @@ -197,6 +201,24 @@ export function catchUp( let decisions = 0; let reached = Number.NEGATIVE_INFINITY; for (let events = 0; events < maxEvents; events++) { + // A trip's event comes first at any moment: it can move the arrival (decision 0.5.143). + const due = nextEventDue(save, until); + if (due) { + const at = eventDueAt(due)!; + const shopFirst = nextShopEnd(save, until); + const arrivalFirst = nextArrival(save, until); + const decisionFirst = nextDecision(save, decideLimit)?.nextDecisionAt ?? null; + const earliest = Math.min( + shopFirst?.inShopUntil ?? Infinity, + arrivalFirst ?? Infinity, + decisionFirst ?? Infinity, + ); + if (at <= earliest) { + settleDueEvent(save, due); + reached = at; + continue; + } + } const shop = nextShopEnd(save, until); const shopAt = shop?.inShopUntil ?? null; const arrival = nextArrival(save, until); diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index de0711e..2fc00aa 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -1,5 +1,6 @@ import { content } from "@deadhead/content"; import { awayProblems } from "./away"; +import { answerRadioCall, repairShip } from "./events"; import { tutorialBoard, locationBoard } from "./boards"; import { acceptDeal, claimGoal, maxedUpgrades, renameCompany, repayLoan } from "./company"; import { dispatchContract, dispatchReposition, dispatchTutorial } from "./dispatch"; @@ -20,7 +21,14 @@ import { orderEmergencyFuel, unassignPilot, } from "./fleet"; -import { companyView, goalStates, playerState, requirePilot, requireShip } from "./state"; +import { + companyView, + goalStates, + playerState, + requirePilot, + requireShip, + toApiShip, +} from "./state"; import { buyDispatchOffice, setPilotMode, setPilotOrders, setSpendingFloor } from "./independent"; /** Every command that works on an existing company's save (the server's endpoints). */ @@ -69,6 +77,14 @@ export function commandTable(): CommandTable { acknowledgeSummaries: ({ save, now }, input) => ({ acknowledged: acknowledge(save, input, now), }), + // Radio calls and repairs (decisions 0.5.144, 0.5.148). + answerRadioCall: ({ save, now }, input) => ({ + radioCalls: answerRadioCall(save, input, now), + }), + repairShip: ({ save, now }, input) => { + repairShip(save, input, now); + return { ship: toApiShip(save, requireShip(save, input.shipId), now) }; + }, // The logbook and the absence (decisions 0.5.140, 0.5.141). getLogbook: ({ save, now }) => ({ entries: save.logbook, diff --git a/packages/engine/src/game/company.ts b/packages/engine/src/game/company.ts index 0c64c05..44de26b 100644 --- a/packages/engine/src/game/company.ts +++ b/packages/engine/src/game/company.ts @@ -49,6 +49,8 @@ export function startingSave( // The company's creation is its first decision (CR-024 Step 24.1). lastManualDecisionAt: now, sleepAfterHours: content.config.fleetSleep.defaultHours, + firstEventDone: false, + tripsSinceDeal: 0, }, ships: [], pilots: [ @@ -135,6 +137,9 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi shopLevel: null, shopPilotId: null, shopOpen: false, + shopRepair: false, + hullConditionPct: 100, + quietTrips: content.config.events.shipCooldownTrips, fromDeal: true, createdAt: now, lastArrivalAt: now, diff --git a/packages/engine/src/game/dispatch.ts b/packages/engine/src/game/dispatch.ts index e69c374..2741326 100644 --- a/packages/engine/src/game/dispatch.ts +++ b/packages/engine/src/game/dispatch.ts @@ -10,10 +10,12 @@ import { type DispatchRecord, type RealTime, type ShipState, + type TripEvent, } from "@deadhead/sim"; import type { RandomSource } from "../core"; import { acceptedContractIds } from "./boards"; import { fail } from "./errors"; +import { rollForDispatch } from "./events"; import { VALIDATION_MESSAGES } from "./messages"; import { discoveredIds, @@ -52,6 +54,7 @@ function storeDispatch( periodIndex: number | null; now: RealTime; independent?: boolean; + event?: TripEvent | null; }, ): Dispatch { const { record, shipId, now } = args; @@ -67,6 +70,8 @@ function storeDispatch( resolution: null, resolvedAt: null, acknowledgedAt: null, + event: args.event ?? null, + eventApplied: null, }; save.dispatches.push(dispatch); if (record.contract && args.periodIndex !== null) { @@ -171,6 +176,8 @@ export function dispatchContract( periodIndex, now, independent: options.independent, + // Rolled now, outcomes and all (decision 0.5.143). + event: rollForDispatch(save, record, ship, pilot), }); setInFlight(save, ship.id, pilot.id); return dispatch; @@ -206,6 +213,7 @@ export function dispatchReposition( periodIndex: null, now, independent: options.independent, + event: rollForDispatch(save, record, ship, pilot), }); setInFlight(save, ship.id, pilot.id); return dispatch; diff --git a/packages/engine/src/game/events.ts b/packages/engine/src/game/events.ts new file mode 100644 index 0000000..8b1e183 --- /dev/null +++ b/packages/engine/src/game/events.ts @@ -0,0 +1,247 @@ +import { content } from "@deadhead/content"; +import type { RadioCall, SaveDocument } from "@deadhead/schema"; +import { + applyEventOption, + effectiveStats, + hullAfterDamage, + isInShop, + repairCost, + repairShopMs, + restEndsAt, + rollTripEvent, + type DispatchRecord, + type RealTime, + type TripEvent, +} from "@deadhead/sim"; +import { fail } from "./errors"; +import { logEntry, shipPilotId } from "./logbook"; +import { hasBayAt, requireShip, spend } from "./state"; + +type SavedDispatch = SaveDocument["dispatches"][number]; +type SavedShip = SaveDocument["ships"][number]; +type SavedPilot = SaveDocument["pilots"][number]; + +/** Fire times of every radio call in the save: the fleet's pacing (decision 0.5.145). */ +function fleetRadioCalls(save: SaveDocument): RealTime[] { + return save.dispatches.flatMap((d) => (d.event?.kind === "radio" ? [d.event.fireAt] : [])); +} + +/** + * Rolls a new trip's event (decisions 0.5.143–0.5.145) and keeps the pacing + * counters: the ship's quiet trips, the company's trips since the deal, and + * whether it has had its first event. Tutorial runs never get one. + */ +export function rollForDispatch( + save: SaveDocument, + record: DispatchRecord, + ship: SavedShip, + pilot: SavedPilot, +): TripEvent | null { + const model = content.shipModel(ship.modelId)!; + const stats = effectiveStats(model, ship.upgrades, content); + const event = rollTripEvent( + { + record, + ship: { + hullRating: stats.hullRating, + hullConditionPct: ship.hullConditionPct, + cargoCapacityTonnes: stats.cargoCapacityTonnes, + fuelAfterDepartureFu: ship.fuelFu - record.fuelBurnedFu, + quietTrips: ship.quietTrips, + }, + pilotSkills: pilot.skills, + fleetRadioCalls: fleetRadioCalls(save), + company: { + firstEventDone: save.company.firstEventDone, + tripsSinceDeal: save.company.tripsSinceDeal, + }, + }, + content, + ); + if (save.player.dealAccepted) save.company.tripsSinceDeal++; + if (event) { + ship.quietTrips = 0; + save.company.firstEventDone = true; + } else { + ship.quietTrips++; + } + return event; +} + +/** + * Applies an event's option to its trip (decision 0.5.143): the new arrival, + * the hull hit (never below 5%), extra fuel from the tank, and a logbook + * entry. Pay and XP change when the trip resolves. + */ +export function applyTripEvent( + save: SaveDocument, + dispatch: SavedDispatch, + optionId: string, + answered: boolean, + at: RealTime, +): void { + const event = dispatch.event!; + const applied = applyEventOption({ record: dispatch.record, event, optionId, answered }, content); + const ship = dispatch.shipId ? save.ships.find((s) => s.id === dispatch.shipId) : undefined; + const delayMs = applied.arrivalAt - dispatch.arrivalAt; + dispatch.eventApplied = applied; + dispatch.arrivalAt = applied.arrivalAt; + dispatch.record = { ...dispatch.record, arrivalAt: applied.arrivalAt }; + if (ship) { + ship.hullConditionPct = hullAfterDamage( + ship.hullConditionPct, + applied.hullLoss, + content.config, + ); + if (applied.extraFuelFu > 0) { + const burned = Math.min(applied.extraFuelFu, ship.fuelFu); + ship.fuelFu -= burned; + dispatch.record = { + ...dispatch.record, + fuelBurnedFu: dispatch.record.fuelBurnedFu + burned, + fuelCost: dispatch.record.fuelCost + Math.round(burned * ship.fuelAvgPrice), + }; + } + } + logEntry(save, `${dispatch.id}:event`, { + at, + pilotId: dispatch.pilotId, + shipId: dispatch.shipId, + kind: "event", + dispatchId: dispatch.id, + eventId: event.eventId, + optionId, + good: event.good, + answered, + delayMs, + hullLoss: applied.hullLoss, + payLoss: applied.payLoss, + fee: applied.fee, + bonus: applied.bonus, + priorityLost: applied.priorityLost, + xpBonusPct: applied.xpBonusPct, + extraFuelFu: applied.extraFuelFu, + }); +} + +/** When an event is due: an automatic one when it fires, a radio call when its window ends. */ +export function eventDueAt(dispatch: SavedDispatch): RealTime | null { + const event = dispatch.event; + if (!event || dispatch.eventApplied || dispatch.status !== "in-flight") return null; + return event.kind === "automatic" ? event.fireAt : event.windowEndsAt; +} + +/** The earliest event due by `limit`, ties to the lower dispatch id (decision 0.5.129). */ +export function nextEventDue(save: SaveDocument, limit: RealTime): SavedDispatch | null { + let best: SavedDispatch | null = null; + let bestAt = Number.POSITIVE_INFINITY; + for (const d of save.dispatches) { + const at = eventDueAt(d); + if (at === null || at > limit) continue; + if (at < bestAt || (at === bestAt && best !== null && d.id < best.id)) { + best = d; + bestAt = at; + } + } + return best; +} + +/** Applies a due event: the automatic outcome, or an unanswered call's default. */ +export function settleDueEvent(save: SaveDocument, dispatch: SavedDispatch): RealTime { + const at = eventDueAt(dispatch)!; + applyTripEvent(save, dispatch, dispatch.event!.defaultOptionId, false, at); + return at; +} + +/** Radio calls waiting for an answer now (decision 0.5.144), soonest window end first. */ +export function radioCalls(save: SaveDocument, now: RealTime): RadioCall[] { + return save.dispatches + .filter( + (d) => + d.status === "in-flight" && + d.event?.kind === "radio" && + !d.eventApplied && + d.event.fireAt <= now && + now < d.event.windowEndsAt, + ) + .map((d) => ({ + dispatchId: d.id, + shipId: d.shipId, + pilotId: d.pilotId, + eventId: d.event!.eventId, + fireAt: d.event!.fireAt, + windowEndsAt: d.event!.windowEndsAt, + options: d.event!.options.map((o) => ({ id: o.id, available: o.available })), + defaultOptionId: d.event!.defaultOptionId, + independent: d.independent, + })) + .sort((a, b) => a.windowEndsAt - b.windowEndsAt); +} + +/** The player answers a radio call within its window (decision 0.5.144). */ +export function answerRadioCall( + save: SaveDocument, + input: { dispatchId: string; optionId: string }, + now: RealTime, +): RadioCall[] { + const dispatch = save.dispatches.find((d) => d.id === input.dispatchId); + const event = dispatch?.event; + if (!dispatch || event?.kind !== "radio" || event.fireAt > now) { + throw fail("radio-call-not-found", "There's no radio call on that trip."); + } + if (dispatch.eventApplied || now >= event.windowEndsAt) { + throw fail("radio-call-closed", "Too late: the pilot has already made the call."); + } + const option = event.options.find((o) => o.id === input.optionId); + if (!option) throw fail("validation-error", `No option "${input.optionId}" on that call.`); + if (!option.available) { + throw fail("radio-option-unavailable", "That option isn't possible on this trip."); + } + applyTripEvent(save, dispatch, option.id, true, now); + return radioCalls(save, now); +} + +/** + * Repairs a ship's hull to 100% at an HQ (decision 0.5.148): charged now, in + * the shop for the repair time, which counts as rest for its pilot like an + * upgrade (decision 0.5.135). The hull is whole when the shop time ends. + */ +export function repairShip(save: SaveDocument, input: { shipId: string }, now: RealTime): number { + const ship = requireShip(save, input.shipId); + if (ship.status !== "idle" || ship.locationId === null) { + throw fail("ship-not-idle", "Only a ship on the ground can be repaired."); + } + if (isInShop(ship, now)) throw fail("ship-in-shop", "That ship is already in the shop."); + if (!hasBayAt(save, ship.locationId)) { + throw fail("no-bay-at-location", "Repairs are done at one of your HQs."); + } + if (ship.hullConditionPct >= 100) throw fail("hull-intact", "The hull is already at 100%."); + const model = content.shipModel(ship.modelId)!; + const cost = repairCost(model, ship.hullConditionPct, content.config); + spend(save, cost, "the repair"); + const until = now + repairShopMs(ship.hullConditionPct, content.config); + const pilot = save.pilots.find((p) => p.assignedShipId === ship.id); + const coversRest = until - now >= restEndsAt(now, content.config) - now; + Object.assign(ship, { + inShopUntil: until, + shopRepair: true, + shopOpen: true, + shopPilotId: pilot && coversRest ? pilot.id : null, + }); + return cost; +} + +/** A repair's shop time ends: the hull is whole, and the logbook says so (decision 0.5.148). */ +export function finishRepair(save: SaveDocument, ship: SavedShip, at: RealTime): void { + const model = content.shipModel(ship.modelId)!; + logEntry(save, `${ship.id}:repair:${at}`, { + at, + pilotId: shipPilotId(save, ship.id), + shipId: ship.id, + kind: "repair", + fromPct: ship.hullConditionPct, + cost: repairCost(model, ship.hullConditionPct, content.config), + }); + ship.hullConditionPct = 100; + ship.shopRepair = false; +} diff --git a/packages/engine/src/game/fleet.ts b/packages/engine/src/game/fleet.ts index 14d09f6..f86f009 100644 --- a/packages/engine/src/game/fleet.ts +++ b/packages/engine/src/game/fleet.ts @@ -16,6 +16,7 @@ import { import type { RandomSource } from "../core"; import { fail } from "./errors"; import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages"; +import { finishRepair } from "./events"; import { logEntry, shipPilotId } from "./logbook"; import { pilotName, shipName } from "./names"; import { @@ -78,6 +79,9 @@ export function buyShip( shopLevel: null, shopPilotId: null, shopOpen: false, + shopRepair: false, + hullConditionPct: 100, + quietTrips: content.config.events.shipCooldownTrips, fromDeal: false, createdAt: now, lastArrivalAt: now, @@ -181,7 +185,9 @@ export function unassignPilot(save: SaveDocument, pilotId: string, now: RealTime */ export function finishShop(save: SaveDocument, ship: SavedShip): void { const at = ship.inShopUntil; - if (ship.shopOpen && at !== null && ship.shopUpgradeId && ship.shopLevel !== null) { + if (ship.shopOpen && at !== null && ship.shopRepair) { + finishRepair(save, ship, at); + } else if (ship.shopOpen && at !== null && ship.shopUpgradeId && ship.shopLevel !== null) { logEntry(save, `${ship.id}:upgrade:${at}`, { at, pilotId: shipPilotId(save, ship.id), @@ -277,6 +283,7 @@ export function startUpgrade( shopLevel: check.level, shopPilotId: pilot && coversRest ? pilot.id : null, shopOpen: true, + shopRepair: false, }); return toApiShip(save, ship, now); } diff --git a/packages/engine/src/game/resolve.ts b/packages/engine/src/game/resolve.ts index 4fc2ea2..b2ca9a0 100644 --- a/packages/engine/src/game/resolve.ts +++ b/packages/engine/src/game/resolve.ts @@ -43,6 +43,7 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number { pilotDutyHours: pilot.dutyHours, pilotSkills: pilot.skills, now, + event: dispatch.eventApplied, fuel: ship && model ? { diff --git a/packages/engine/src/game/state.ts b/packages/engine/src/game/state.ts index 2fab159..139a976 100644 --- a/packages/engine/src/game/state.ts +++ b/packages/engine/src/game/state.ts @@ -24,6 +24,7 @@ import { type ShipState, } from "@deadhead/sim"; import { fail } from "./errors"; +import { radioCalls } from "./events"; type SavedShip = SaveDocument["ships"][number]; type SavedPilot = SaveDocument["pilots"][number]; @@ -105,6 +106,7 @@ export function shipState(ship: SavedShip): ShipState { upgrades: ship.upgrades, inShopUntil: ship.inShopUntil, tank: { fuelFu: ship.fuelFu, avgPrice: ship.fuelAvgPrice }, + hullConditionPct: ship.hullConditionPct, }; } @@ -187,6 +189,8 @@ export function toApiShip(save: SaveDocument, ship: SavedShip, now: RealTime): S fuelFu: ship.fuelFu, fuelAvgPrice: ship.fuelAvgPrice, refuelPolicy: ship.refuelPolicy, + hullConditionPct: ship.hullConditionPct, + repairing: ship.shopRepair && isInShop(ship, now), }; } @@ -292,9 +296,11 @@ export function playerState(save: SaveDocument, now: RealTime): PlayerState { scriptedId: d.record.contract?.scriptedId ?? null, restEndsAt: d.resolution?.pilotRestEndsAt ?? null, resolvedAt: d.resolvedAt ?? d.arrivalAt, + event: d.eventApplied, })), goals: goalStates(save), welcomeSeen: save.player.welcomeSeen, + radioCalls: radioCalls(save, now), fleetSleep: { afterHours: save.company.sleepAfterHours, sleepsAt: sleepsAt(save, now), diff --git a/packages/engine/test/ported/tutorial-board.test.ts b/packages/engine/test/ported/tutorial-board.test.ts index 11a035d..ad2adc8 100644 --- a/packages/engine/test/ported/tutorial-board.test.ts +++ b/packages/engine/test/ported/tutorial-board.test.ts @@ -49,6 +49,8 @@ async function accept(guest: Guest, contract: Contract, now: number) { resolution: null, resolvedAt: null, acknowledgedAt: null, + event: null, + eventApplied: null, }); save.acceptedContracts.push({ contractId: contract.id, diff --git a/packages/engine/test/start.test.ts b/packages/engine/test/start.test.ts index 0273823..976e2b2 100644 --- a/packages/engine/test/start.test.ts +++ b/packages/engine/test/start.test.ts @@ -78,6 +78,7 @@ describe("company creation on the device (Step 14.8)", () => { owned: false, spendingFloor: content.config.independentPilots.defaultSpendingFloor, }, + radioCalls: [], fleetSleep: { afterHours: 24, sleepsAt: TEST_NOW + 24 * 60 * 60_000, diff --git a/packages/engine/test/support.ts b/packages/engine/test/support.ts index b3f3276..5f85c5d 100644 --- a/packages/engine/test/support.ts +++ b/packages/engine/test/support.ts @@ -259,6 +259,8 @@ export function insertDispatch( resolution: null, resolvedAt: null, acknowledgedAt: null, + event: null, + eventApplied: null, }); Object.assign(ship, { status: "in-flight", locationId: null }); pilot.status = "in-flight"; @@ -311,6 +313,9 @@ export function makeOwner( shopLevel: null, shopPilotId: null, shopOpen: false, + shopRepair: false, + hullConditionPct: 100, + quietTrips: 2, // Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank. fromDeal: true, createdAt: TEST_NOW, @@ -376,6 +381,9 @@ export function addShip( shopLevel: null, shopPilotId: null, shopOpen: false, + shopRepair: false, + hullConditionPct: 100, + quietTrips: 2, fromDeal: false, createdAt: TEST_NOW, lastArrivalAt: TEST_NOW, diff --git a/packages/engine/test/trip-events.test.ts b/packages/engine/test/trip-events.test.ts new file mode 100644 index 0000000..653ce44 --- /dev/null +++ b/packages/engine/test/trip-events.test.ts @@ -0,0 +1,294 @@ +import { content } from "@deadhead/content"; +import type { SaveDocument } from "@deadhead/schema"; +import { MS_PER_HOUR, repairCost, type EventOutcome, type TripEvent } from "@deadhead/sim"; +import { describe, expect, it } from "vitest"; +import { + addPilot, + addShip, + edit, + insertDispatch, + newTestApp, + putBoard, + readSave, + setCredits, + signUpOwner, + testContract, + type TestApp, +} from "./support"; + +const MINUTE = 60_000; +const GAME_HOUR = MS_PER_HOUR / content.config.timeCompression; +const none: EventOutcome = { + delayMs: 0, + hullLoss: 0, + payLoss: 0, + fee: 0, + bonus: 0, + xpBonusPct: 0, + extraFuelFu: 0, +}; + +/** A debris field 40% into a trip: go around (+3 h, the default) or push through (−15% hull). */ +function debris(departureAt: number, arrivalAt: number): TripEvent { + const fireAt = departureAt + Math.round((arrivalAt - departureAt) * 0.4); + return { + eventId: "debris-field", + kind: "radio", + good: false, + fireAt, + windowEndsAt: Math.min(fireAt + 30 * MINUTE, arrivalAt), + options: [ + { id: "go-around", available: true, outcome: { ...none, delayMs: 3 * GAME_HOUR } }, + { id: "push-through", available: true, outcome: { ...none, hullLoss: 15 } }, + ], + defaultOptionId: "go-around", + }; +} + +/** A grateful client's tip, automatic, 30% into a trip. */ +function tip(departureAt: number, arrivalAt: number, bonus: number): TripEvent { + const fireAt = departureAt + Math.round((arrivalAt - departureAt) * 0.3); + return { + eventId: "grateful-client", + kind: "automatic", + good: true, + fireAt, + windowEndsAt: fireAt, + options: [{ id: "tip", available: true, outcome: { ...none, bonus } }], + defaultOptionId: "tip", + }; +} + +/** An owner whose Courier is on a long trip to Mars carrying `event`. */ +async function flying( + test: TestApp, + make: (departureAt: number, arrivalAt: number) => TripEvent, + options: { independent?: boolean } = {}, +) { + const owner = await signUpOwner(test.app); + const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id; + const record = await insertDispatch(test.app, { + shipId: owner.shipId, + pilotId: avatar, + departAt: test.clock.now, + contract: testContract({ destinationId: "mars", payout: 8_000 }), + }); + const event = make(record.departureAt, record.arrivalAt); + await edit(test.app, (save) => { + const d = save.dispatches.find((x) => x.id === record.id)!; + d.event = event; + d.independent = options.independent ?? false; + }); + return { owner, record, event, avatar }; +} + +const dispatchOf = (save: SaveDocument, id: string) => save.dispatches.find((d) => d.id === id)!; + +describe("events in play and catch-up (Step 26.5, decisions 0.5.143, 0.5.144)", () => { + it("lets the player answer a radio call in its window, and not before or after", async () => { + const test = newTestApp(); + const { owner, record, event } = await flying(test, debris); + const answer = (optionId: string) => + test.app.call("answerRadioCall", { dispatchId: record.id, optionId }); + + await expect(answer("push-through")).rejects.toMatchObject({ code: "radio-call-not-found" }); + test.clock.now = event.fireAt + MINUTE; + const state = await test.app.call("getPlayerState", {}); + expect(state.radioCalls).toEqual([ + expect.objectContaining({ + dispatchId: record.id, + eventId: "debris-field", + windowEndsAt: event.windowEndsAt, + defaultOptionId: "go-around", + }), + ]); + expect((await answer("push-through")).radioCalls).toEqual([]); + const save = await readSave(test.app); + const d = dispatchOf(save, record.id); + expect(d.eventApplied).toMatchObject({ + optionId: "push-through", + answered: true, + hullLoss: 15, + }); + expect(d.arrivalAt).toBe(record.arrivalAt); + expect(save.ships.find((s) => s.id === owner.shipId)!.hullConditionPct).toBe(85); + await expect(answer("go-around")).rejects.toMatchObject({ code: "radio-call-closed" }); + }); + + it("takes the default when the window closes unanswered: a 3-hour detour", async () => { + const test = newTestApp(); + const { record, event } = await flying(test, debris); + test.clock.now = event.windowEndsAt + MINUTE; + const state = await test.app.call("getPlayerState", {}); + expect(state.radioCalls).toEqual([]); + const save = await readSave(test.app); + const d = dispatchOf(save, record.id); + expect(d.eventApplied).toMatchObject({ optionId: "go-around", answered: false }); + expect(d.arrivalAt).toBe(record.arrivalAt + 3 * GAME_HOUR); + expect(save.logbook.find((e) => e.kind === "event")).toMatchObject({ + at: event.windowEndsAt, + eventId: "debris-field", + optionId: "go-around", + answered: false, + delayMs: 3 * GAME_HOUR, + }); + await expect( + test.app.call("answerRadioCall", { dispatchId: record.id, optionId: "push-through" }), + ).rejects.toMatchObject({ code: "radio-call-closed" }); + }); + + it("applies an automatic event when it fires, and pays its tip on delivery", async () => { + const test = newTestApp(); + const { record } = await flying(test, (from, to) => tip(from, to, 800)); + test.clock.now = record.arrivalAt + MINUTE; + const state = await test.app.call("getPlayerState", {}); + const summary = state.summaries.find((s) => s.dispatchId === record.id)!; + expect(summary.event).toMatchObject({ eventId: "grateful-client", bonus: 800 }); + // 8,000 + 800 for the avatar, less the loan share if any. + expect(summary.credits + summary.loanRepayment).toBe(8_800); + }); + + it("gives an Independent pilot's unanswered call its default, and lets the player answer one", async () => { + const test = newTestApp(); + const { record, event } = await flying(test, debris, { independent: true }); + test.clock.now = event.fireAt + MINUTE; + expect((await test.app.call("getPlayerState", {})).radioCalls[0]).toMatchObject({ + independent: true, + }); + test.clock.now = event.windowEndsAt; + await test.app.call("getPlayerState", {}); + expect(dispatchOf(await readSave(test.app), record.id).eventApplied).toMatchObject({ + optionId: "go-around", + answered: false, + }); + }); + + it("gives the same save caught up in one go or in pieces, defaulted calls included", async () => { + const once = newTestApp(); + const { record, event } = await flying(once, debris); + await addShip(once.app, { id: "s2", modelId: "courier", name: "Wren" }); + await addPilot(once.app, { id: "p2", name: "Ada", assignedShipId: "s2" }); + const second = await insertDispatch(once.app, { + shipId: "s2", + pilotId: "p2", + departAt: once.clock.now, + contract: testContract({ destinationId: "mars", payout: 5_000 }), + }); + await edit(once.app, (save) => { + dispatchOf(save, second.id).event = tip(second.departureAt, second.arrivalAt, 300); + }); + const { code } = await once.app.call("exportSave", {}); + const pieces = newTestApp(); + pieces.clock.now = once.clock.now; + await pieces.app.call("importSave", { code }); + + const end = Math.max(record.arrivalAt, second.arrivalAt) + 8 * 60 * MINUTE; + once.clock.now = end; + await once.app.call("getPlayerState", {}); + while (pieces.clock.now < end) { + pieces.clock.now = Math.min(end, pieces.clock.now + 7 * MINUTE); + await pieces.app.call("getPlayerState", {}); + } + const a = await readSave(once.app); + const b = await readSave(pieces.app); + expect(dispatchOf(a, record.id).eventApplied?.optionId).toBe("go-around"); + expect(dispatchOf(a, record.id).status).toBe("resolved"); + expect(a.logbook.filter((e) => e.kind === "event")).toHaveLength(2); + expect(event.windowEndsAt).toBeLessThan(record.arrivalAt); + expect({ ...b, lastTimeSeen: 0 }).toEqual({ ...a, lastTimeSeen: 0 }); + }); +}); + +describe("events rolled at dispatch (Step 26.5, decision 0.5.145)", () => { + it("makes the first event after the grace trips the derelict pod, and counts the pacing", async () => { + const test = newTestApp(); + await signUpOwner(test.app); + await setCredits(test.app, 100_000); + await addShip(test.app, { id: "c1", modelId: "clipper", name: "Heron" }); + await addPilot(test.app, { id: "p1", name: "Rosa", assignedShipId: "c1" }); + await edit(test.app, (save) => { + save.player.dealAccepted = true; + save.company.tripsSinceDeal = content.config.events.graceTripsAfterDeal; + }); + const job = testContract({ + destinationId: "mars", + expiresAt: test.clock.now + MS_PER_HOUR, + }); + await putBoard(test.app, [job], test.clock.now); + const { dispatch } = await test.app.call("createDispatch", { + kind: "contract", + contractId: job.id, + shipId: "c1", + pilotId: "p1", + }); + const save = await readSave(test.app); + const d = dispatchOf(save, dispatch.id); + expect(d.event?.eventId).toBe("derelict-pod"); + expect(d.event?.kind).toBe("radio"); + expect(save.company).toMatchObject({ firstEventDone: true, tripsSinceDeal: 4 }); + expect(save.ships.find((s) => s.id === "c1")!.quietTrips).toBe(0); + }); + + it("has no events in the grace trips, while counting them", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + await edit(test.app, (save) => { + save.player.dealAccepted = true; + }); + const avatar = (await readSave(test.app)).pilots.find((p) => p.isAvatar)!.id; + const { dispatch } = await test.app.call("createDispatch", { + kind: "reposition", + shipId: owner.shipId, + pilotId: avatar, + destinationId: "moon", + }); + const save = await readSave(test.app); + expect(dispatchOf(save, dispatch.id).event).toBeNull(); + expect(save.company.tripsSinceDeal).toBe(1); + expect(save.ships.find((s) => s.id === owner.shipId)!.quietTrips).toBe(3); + }); +}); + +describe("repairs (Step 26.5, decision 0.5.148)", () => { + it("repairs to 100% at an HQ: charged now, in the shop for the time, logged when done", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + await setCredits(test.app, 50_000); + await edit(test.app, (save) => { + save.ships.find((s) => s.id === owner.shipId)!.hullConditionPct = 60; + }); + const cost = repairCost(content.shipModel("courier")!, 60, content.config); + const started = test.clock.now; + const { ship } = await test.app.call("repairShip", { shipId: owner.shipId }); + expect(ship).toMatchObject({ inShopUntil: started + 20 * MINUTE, repairing: true }); + expect((await readSave(test.app)).company.credits).toBe(50_000 - cost); + + test.clock.now = started + 20 * MINUTE; + const state = await test.app.call("getPlayerState", {}); + expect(state.ships.find((s) => s.id === owner.shipId)).toMatchObject({ + hullConditionPct: 100, + repairing: false, + }); + expect((await readSave(test.app)).logbook.find((e) => e.kind === "repair")).toMatchObject({ + shipId: owner.shipId, + fromPct: 60, + cost, + }); + await expect(test.app.call("repairShip", { shipId: owner.shipId })).rejects.toMatchObject({ + code: "hull-intact", + }); + }); + + it("refuses a repair away from an HQ", async () => { + const test = newTestApp(); + const owner = await signUpOwner(test.app); + await edit(test.app, (save) => { + const ship = save.ships.find((s) => s.id === owner.shipId)!; + ship.hullConditionPct = 60; + ship.locationId = "moon"; + }); + await expect(test.app.call("repairShip", { shipId: owner.shipId })).rejects.toMatchObject({ + code: "no-bay-at-location", + }); + }); +}); diff --git a/packages/schema/src/entities.ts b/packages/schema/src/entities.ts index ad20210..e621839 100644 --- a/packages/schema/src/entities.ts +++ b/packages/schema/src/entities.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { appliedEventSchema, radioCallSchema } from "./events"; /** * Per-player data sent over the API. Static data (location details, ship @@ -71,6 +72,10 @@ export const shipSchema = z.object({ fuelAvgPrice: z.number().nonnegative(), /** Auto top-up on landing, or Manual (decision 0.5.97). */ refuelPolicy: z.enum(["auto", "manual"]), + /** Hull condition, 5–100% (decision 0.5.148). */ + hullConditionPct: z.number().min(0).max(100), + /** In the shop for a hull repair rather than an upgrade. */ + repairing: z.boolean(), }); export type Ship = z.infer; @@ -238,6 +243,8 @@ export const resolutionSummarySchema = z.object({ /** Set when the trip ended in mandatory rest: the pilot rests until then (CR-004). */ restEndsAt: timeSchema.nullable(), resolvedAt: timeSchema, + /** The trip's event and what it changed (CR-026); null for a quiet trip. */ + event: appliedEventSchema.nullable(), }); export type ResolutionSummary = z.infer; @@ -275,6 +282,8 @@ export const playerStateSchema = z.object({ goals: z.array(goalStateSchema), dispatchOffice: dispatchOfficeSchema, fleetSleep: fleetSleepSchema, + /** Radio calls waiting for an answer, soonest window end first (decision 0.5.144). */ + radioCalls: z.array(radioCallSchema), /** This company's welcome cards have been seen (decision 0.5.131). */ welcomeSeen: z.boolean(), }); diff --git a/packages/schema/src/errors.ts b/packages/schema/src/errors.ts index ac3c95e..f0fa896 100644 --- a/packages/schema/src/errors.ts +++ b/packages/schema/src/errors.ts @@ -44,6 +44,10 @@ export const SERVER_ERROR_CODES = [ "avatar-not-independent", "unknown-location", "unknown-cargo-class", + "radio-call-not-found", + "radio-call-closed", + "radio-option-unavailable", + "hull-intact", ] as const; export type ErrorCode = diff --git a/packages/schema/src/events.ts b/packages/schema/src/events.ts new file mode 100644 index 0000000..407155f --- /dev/null +++ b/packages/schema/src/events.ts @@ -0,0 +1,69 @@ +import type { AppliedEvent, TripEvent } from "@deadhead/sim"; +import { z } from "zod"; +// Local, not from ./entities: entities imports this file. +const idSchema = z.string().min(1); +const timeSchema = z.number().int().nonnegative(); + +const credits = z.number().int(); + +/** One option's pre-rolled outcome (decision 0.5.143). */ +const eventOutcomeSchema = z.object({ + delayMs: z.number().int(), + hullLoss: z.number().nonnegative(), + payLoss: credits.nonnegative(), + fee: credits.nonnegative(), + bonus: credits.nonnegative(), + xpBonusPct: z.number().nonnegative(), + extraFuelFu: z.number().nonnegative(), +}); + +/** A trip's event as rolled at dispatch (decisions 0.5.143–0.5.145). */ +export const savedTripEventSchema = z.object({ + eventId: idSchema, + kind: z.enum(["automatic", "radio"]), + good: z.boolean(), + fireAt: timeSchema, + windowEndsAt: timeSchema, + options: z.array(z.object({ id: idSchema, available: z.boolean(), outcome: eventOutcomeSchema })), + defaultOptionId: idSchema, +}); + +/** What the event changed once its option applied. */ +export const appliedEventSchema = z.object({ + eventId: idSchema, + optionId: idSchema, + answered: z.boolean(), + arrivalAt: timeSchema, + hullLoss: z.number().nonnegative(), + payLoss: credits.nonnegative(), + fee: credits.nonnegative(), + bonus: credits.nonnegative(), + priorityLost: credits.nonnegative(), + xpBonusPct: z.number().nonnegative(), + extraFuelFu: z.number().nonnegative(), +}); + +/** + * A radio call waiting for the player (decision 0.5.144): fired, not yet + * answered, window still open. The options' outcomes stay hidden: the player + * chooses on what each option might do (content), not on what it will. + */ +export const radioCallSchema = z.object({ + dispatchId: idSchema, + shipId: idSchema.nullable(), + pilotId: idSchema, + eventId: idSchema, + fireAt: timeSchema, + windowEndsAt: timeSchema, + options: z.array(z.object({ id: idSchema, available: z.boolean() })), + defaultOptionId: idSchema, + /** Flown by an Independent pilot's own choice (CR-021). */ + independent: z.boolean(), +}); +export type RadioCall = z.infer; + +// The saved shapes are the sim's own: a change on either side fails the build. +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const _sameEvent: Same, TripEvent> = true; +const _sameApplied: Same, AppliedEvent> = true; +void [_sameEvent, _sameApplied]; diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index b9d8323..e52f4bf 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -4,3 +4,4 @@ export * from "./requests"; export * from "./routes"; export * from "./save"; export * from "./logbook"; +export * from "./events"; diff --git a/packages/schema/src/logbook.ts b/packages/schema/src/logbook.ts index 3ec951c..cc1c60b 100644 --- a/packages/schema/src/logbook.ts +++ b/packages/schema/src/logbook.ts @@ -66,6 +66,32 @@ export const logEntrySchema = z.discriminatedUnion("kind", [ skillPoints: z.number().int().nonnegative(), }), z.object({ ...base, kind: z.literal("goal-ready"), goalId: idSchema }), + /** A trip's event once it applied (CR-026), with what it changed. */ + z.object({ + ...base, + kind: z.literal("event"), + dispatchId: idSchema, + eventId: idSchema, + optionId: idSchema, + good: z.boolean(), + /** The player chose; false for an automatic event or a default. */ + answered: z.boolean(), + delayMs: z.number().int(), + hullLoss: z.number().nonnegative(), + payLoss: credits.nonnegative(), + fee: credits.nonnegative(), + bonus: credits.nonnegative(), + priorityLost: credits.nonnegative(), + xpBonusPct: z.number().nonnegative(), + extraFuelFu: z.number().nonnegative(), + }), + /** A hull repaired to 100% at an HQ (decision 0.5.148). */ + z.object({ + ...base, + kind: z.literal("repair"), + fromPct: z.number().min(0).max(100), + cost: credits.nonnegative(), + }), ]); export type LogEntry = z.infer; export type LogEntryKind = LogEntry["kind"]; diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index 9f82d73..6012900 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { radioCallSchema } from "./events"; import { baySchema, companySchema, @@ -83,6 +84,12 @@ export const pilotResponseSchema = z.object({ pilot: pilotSchema }); export const pilotModeRequestSchema = z.object({ pilotId: idSchema, mode: pilotModeSchema }); export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pilotOrdersSchema }); /** CR-021: the credits Independent pilots never spend below (decision 0.5.127). */ +/** Answer a radio call on a trip (decision 0.5.144). */ +export const answerRadioCallRequestSchema = z.object({ dispatchId: idSchema, optionId: idSchema }); +export const radioCallsResponseSchema = z.object({ radioCalls: z.array(radioCallSchema) }); +/** Repair a ship's hull at an HQ (decision 0.5.148). */ +export const repairShipRequestSchema = z.object({ shipId: idSchema }); + /** Settings → Company: real hours without orders before the fleet goes quiet (CR-024). */ export const sleepThresholdRequestSchema = z.object({ hours: z.number().positive() }); export const spendingFloorRequestSchema = z.object({ credits: z.number().int().nonnegative() }); diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 8b5e4b5..39f8181 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -3,6 +3,9 @@ import { playerStateSchema, serverTimeSchema } from "./entities"; import { logbookResponseSchema } from "./logbook"; import { acknowledgeSummariesRequestSchema, + answerRadioCallRequestSchema, + radioCallsResponseSchema, + repairShipRequestSchema, acknowledgeSummariesResponseSchema, assignPilotRequestSchema, bayResponseSchema, @@ -242,6 +245,23 @@ export const routes = { manualDecision: false, }, /** CR-022: the welcome cards are closed for this company (decision 0.5.131). */ + answerRadioCall: { + method: "POST", + path: "/api/dispatches/:dispatchId/radio", + request: answerRadioCallRequestSchema, + response: radioCallsResponseSchema, + auth: true, + // Answering is the player's decision (decision 0.5.136). + manualDecision: true, + }, + repairShip: { + method: "POST", + path: "/api/ships/:shipId/repair", + request: repairShipRequestSchema, + response: shipResponseSchema, + auth: true, + manualDecision: true, + }, getLogbook: { method: "GET", path: "/api/logbook", diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index f15e45a..2e14730 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -8,6 +8,7 @@ import { type Resolution, } from "@deadhead/sim"; import { z } from "zod"; +import { appliedEventSchema, savedTripEventSchema } from "./events"; import { logEntrySchema } from "./logbook"; import { contractSchema, @@ -98,6 +99,12 @@ export const savedShipSchema = z.object({ * "upgrade finished" (decision 0.5.141) and settles the shop rest. */ shopOpen: z.boolean(), + /** The shop job is a hull repair, not an upgrade (decision 0.5.148). */ + shopRepair: z.boolean(), + /** Hull condition, 5–100% (decisions 0.5.147, 0.5.148). */ + hullConditionPct: z.number().min(0).max(100), + /** Trips since its last event: none until the cooldown has passed (decision 0.5.145). */ + quietTrips: z.number().int().nonnegative(), /** Handed over in the deal rather than bought (CR-013). */ fromDeal: z.boolean(), createdAt: timeSchema, @@ -173,10 +180,14 @@ export const savedDispatchSchema = z.object({ resolution: resolutionSchema.nullable(), resolvedAt: timeSchema.nullable(), acknowledgedAt: timeSchema.nullable(), + /** The trip's event, rolled at dispatch (decision 0.5.143); null for a quiet trip. */ + event: savedTripEventSchema.nullable(), + /** What the event changed once applied; null until then. */ + eventApplied: appliedEventSchema.nullable(), }); /** The save version this build writes. Bump it with a new migration. */ -export const CURRENT_SAVE_VERSION = 10; +export const CURRENT_SAVE_VERSION = 11; export const saveDocumentSchema = z.object({ saveVersion: z.literal(CURRENT_SAVE_VERSION), @@ -221,6 +232,10 @@ export const saveDocumentSchema = z.object({ lastManualDecisionAt: timeSchema.nullable(), /** Real hours without orders before the fleet goes quiet (Settings → Company). */ sleepAfterHours: z.number().positive(), + /** The company has had its first event, the derelict pod (decision 0.5.145). */ + firstEventDone: z.boolean(), + /** Trips sent since the deal: the first few are quiet (decision 0.5.145). */ + tripsSinceDeal: z.number().int().nonnegative(), }), ships: z.array(savedShipSchema), pilots: z.array(savedPilotSchema), @@ -480,6 +495,31 @@ function logbook(save: Doc): Doc { }; } +/** + * Version 10 → 11 (CR-026 §6): hulls at 100%, every ship past its event + * cooldown, no event on a flight already in the air, and the company yet to + * have its first event (past the grace trips if it has made the deal). + */ +function tripEvents(save: Doc): Doc { + const { events } = content.config; + const player = save.player as Doc; + return { + ...save, + company: { + ...(save.company as Doc), + firstEventDone: false, + tripsSinceDeal: player.dealAccepted ? events.graceTripsAfterDeal : 0, + }, + ships: records(save.ships).map((ship) => ({ + ...ship, + shopRepair: false, + hullConditionPct: 100, + quietTrips: events.shipCooldownTrips, + })), + dispatches: records(save.dispatches).map((d) => ({ ...d, event: null, eventApplied: null })), + }; +} + export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, @@ -490,6 +530,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 7, migrate: shopPilot }, { from: 8, migrate: fleetSleep }, { from: 9, migrate: logbook }, + { from: 10, migrate: tripEvents }, ]; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index d0b574d..8c053a9 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -14,6 +14,8 @@ describe("route catalog", () => { "getContractBoard", "getTutorialBoard", "getLogbook", + "answerRadioCall", + "repairShip", "createDispatch", "buyShip", "leaseBay", @@ -98,6 +100,7 @@ describe("manual decisions in the catalog (Step 24.2, decision 0.5.136)", () => expect(manual).toEqual( [ "acceptDeal", + "answerRadioCall", "backToWork", "assignPilot", "buyDispatchOffice", @@ -109,6 +112,7 @@ describe("manual decisions in the catalog (Step 24.2, decision 0.5.136)", () => "orderEmergencyFuel", "refuelShip", "renameCompany", + "repairShip", "repayLoan", "restPilot", "setPilotMode", diff --git a/packages/schema/test/save-fixture.ts b/packages/schema/test/save-fixture.ts index 00af456..8590d9e 100644 --- a/packages/schema/test/save-fixture.ts +++ b/packages/schema/test/save-fixture.ts @@ -45,7 +45,7 @@ export function fullSave(): SaveDocument { seed: 42, }); return { - saveVersion: 10, + saveVersion: 11, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -71,6 +71,9 @@ export function fullSave(): SaveDocument { // As the 8 → 9 migration leaves it: the engine sets it on first catch-up. lastManualDecisionAt: null, sleepAfterHours: 24, + // As the 10 → 11 migration leaves it: past the grace trips (the deal is made). + firstEventDone: false, + tripsSinceDeal: 3, }, ships: [ { @@ -85,6 +88,9 @@ export function fullSave(): SaveDocument { shopLevel: null, shopPilotId: null, shopOpen: false, + shopRepair: false, + hullConditionPct: 100, + quietTrips: 2, fromDeal: true, createdAt: T, lastArrivalAt: T + 200_000, @@ -105,6 +111,9 @@ export function fullSave(): SaveDocument { shopPilotId: null, // In the shop: catch-up logs the end (CR-025). shopOpen: true, + shopRepair: false, + hullConditionPct: 100, + quietTrips: 2, fromDeal: true, createdAt: T, lastArrivalAt: T, @@ -195,6 +204,8 @@ export function fullSave(): SaveDocument { resolution: null, resolvedAt: null, acknowledgedAt: null, + event: null, + eventApplied: null, }, { id: "d0", @@ -224,6 +235,8 @@ export function fullSave(): SaveDocument { }, resolvedAt: T + 200_000, acknowledgedAt: null, + event: null, + eventApplied: null, }, ], acceptedContracts: [ diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index ea2e8db..2d5589c 100644 --- a/packages/schema/test/save.test.ts +++ b/packages/schema/test/save.test.ts @@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture"; describe("save document (Step 14.2)", () => { it("validates a fully populated save", () => { expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); - expect(CURRENT_SAVE_VERSION).toBe(10); + expect(CURRENT_SAVE_VERSION).toBe(11); }); it("rejects an invalid save with a readable error naming the field", () => { @@ -84,7 +84,7 @@ describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => { if (!result.ok) throw new Error(result.error); const { save } = result; const before = fullSave(); - expect(save.saveVersion).toBe(10); + expect(save.saveVersion).toBe(11); // The Courier has Extended Fuel Tanks 1 (40 fu × 4.8) and is in flight: the base // price, 5 cr/fu since CR-018 (decision 0.5.110). expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" }); @@ -116,7 +116,7 @@ describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => { const result = migrateSave(old); if (!result.ok) throw new Error(result.error); const { save } = result; - expect(save.saveVersion).toBe(10); + expect(save.saveVersion).toBe(11); expect(save.ships.map((s) => s.refuelPolicy)).toEqual(save.ships.map(() => "manual")); expect(save.ships.map((s) => [s.fuelFu, s.fuelAvgPrice])).toEqual( before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]), @@ -142,7 +142,7 @@ describe("save migration 3 → 4: completable boards (CR-019 §3)", () => { 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(10); + expect(result.save.saveVersion).toBe(11); expect(result.save.contractBoards).toEqual([]); expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] }); }); @@ -164,7 +164,7 @@ describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => { } const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(10); + expect(result.save.saveVersion).toBe(11); expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 }); for (const pilot of result.save.pilots) { expect(pilot.mode).toBe("manual"); @@ -191,7 +191,7 @@ describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => { delete old.player.welcomeSeen; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(10); + expect(result.save.saveVersion).toBe(11); expect(result.save.player.welcomeSeen).toBe(true); expect(result.save).toEqual(before); }); @@ -207,7 +207,7 @@ describe("save migration 7 → 8: shop time counts as rest (CR-023)", () => { for (const ship of old.ships) delete ship.shopPilotId; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(10); + expect(result.save.saveVersion).toBe(11); // Including the ship still in the shop: its upgrade finishes without the rest. expect(result.save.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null)); expect(result.save).toEqual(before); @@ -225,7 +225,7 @@ describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => { delete old.company.sleepAfterHours; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(10); + expect(result.save.saveVersion).toBe(11); expect(result.save.company.lastManualDecisionAt).toBeNull(); expect(result.save.company.sleepAfterHours).toBe(24); expect(result.save).toEqual(before); @@ -245,7 +245,7 @@ describe("save migration 9 → 10: the logbook (CR-025 §4)", () => { for (const ship of old.ships) delete ship.shopOpen; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(10); + expect(result.save.saveVersion).toBe(11); // The resolved d0: its flight (payout = credits + pilot pay + loan) and its top-up. expect(result.save.logbook.map((e) => e.id)).toEqual(["d0:flight", "d0:top-up"]); expect(result.save.ships.map((s) => s.shopOpen)).toEqual([false, true]); @@ -265,3 +265,31 @@ describe("save migration 9 → 10: the logbook (CR-025 §4)", () => { expect(result.save.player.awaySince).toBe(1_234); }); }); + +describe("save migration 10 → 11: events and hulls (CR-026 §6)", () => { + it("gives every hull 100%, no event to flights in the air, and no first event yet", () => { + const before = fullSave(); + const old = JSON.parse(JSON.stringify(before)) as Record & { + company: Record; + ships: Record[]; + dispatches: Record[]; + }; + old.saveVersion = 10; + for (const key of ["firstEventDone", "tripsSinceDeal"]) delete old.company[key]; + for (const ship of old.ships) { + for (const key of ["shopRepair", "hullConditionPct", "quietTrips"]) delete ship[key]; + } + for (const d of old.dispatches) { + delete d.event; + delete d.eventApplied; + } + const result = migrateSave(old); + if (!result.ok) throw new Error(result.error); + expect(result.save.saveVersion).toBe(11); + expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]); + expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]); + // The deal is made, so the grace trips are behind it. + expect(result.save.company).toMatchObject({ firstEventDone: false, tripsSinceDeal: 3 }); + expect(result.save).toEqual(before); + }); +}); diff --git a/packages/schema/test/schemas.test.ts b/packages/schema/test/schemas.test.ts index d4b640a..a6820cd 100644 --- a/packages/schema/test/schemas.test.ts +++ b/packages/schema/test/schemas.test.ts @@ -72,6 +72,8 @@ const ship = { fuelFu: 32.3, fuelAvgPrice: 15.2, refuelPolicy: "auto", + hullConditionPct: 100, + repairing: false, }; const pilot = { id: "p1", @@ -157,6 +159,7 @@ const summary = { pilotXp: 10, discoveredLocationId: null, scriptedId: "unmarked-crates", + event: null, restEndsAt: null, resolvedAt: 3_900_000, }; @@ -180,6 +183,7 @@ const playerState = { goals: [goal], dispatchOffice: { owned: true, spendingFloor: 500 }, fleetSleep: { afterHours: 24, sleepsAt: 90_000_000, asleep: false }, + radioCalls: [], welcomeSeen: true, }; diff --git a/packages/sim/src/away.ts b/packages/sim/src/away.ts index e940102..dd3920d 100644 --- a/packages/sim/src/away.ts +++ b/packages/sim/src/away.ts @@ -43,6 +43,23 @@ export type LogbookEntry = EntryBase & | { kind: "discovery"; locationId: string } | { kind: "level-up"; level: number; skillPoints: number } | { kind: "goal-ready"; goalId: string } + | { + kind: "event"; + dispatchId: string; + eventId: string; + optionId: string; + good: boolean; + answered: boolean; + delayMs: number; + hullLoss: number; + payLoss: number; + fee: number; + bonus: number; + priorityLost: number; + xpBonusPct: number; + extraFuelFu: number; + } + | { kind: "repair"; fromPct: number; cost: number } ); type Flight = Extract;