Compare commits

...

2 Commits

Author SHA1 Message Date
fireshaper fdbc46e165 Step 25.1: engine: logbook entries and retention
CI / check (push) Has been cancelled Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:17:55 -04:00
fireshaper 7dfffc9b14 Record CR-025: short away summary (Phase 25)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:07:51 -04:00
23 changed files with 871 additions and 22 deletions

View File

@ -1850,3 +1850,18 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **The week-away check runs in the balance simulator**, beside the 8-hour scenario: the scenario's start is the player's last decision, and the fleet goes quiet the default 24 hours later. "A 24-hour absence plus the trips in flight at the sleep time" is measured as the 24-hour run's earnings plus the gross payouts of the week run's trips flying at that moment, which bounds anything that lands later. - **The week-away check runs in the balance simulator**, beside the 8-hour scenario: the scenario's start is the player's last decision, and the fleet goes quiet the default 24 hours later. "A 24-hour absence plus the trips in flight at the sleep time" is measured as the 24-hour run's earnings plus the gross payouts of the week run's trips flying at that moment, which bounds anything that lands later.
- **CR-011's "Settings → Company" is the Settings screen's step (10H.2)**, where the Company section was laid out. - **CR-011's "Settings → Company" is the Settings screen's step (10H.2)**, where the Company section was laid out.
## CR-025 recorded (Short "While You Were Away" Summary)
- **Numbering:** received as "CR-023 — Short 'While You Were Away' Summary" for "Phase 23"; stored as **CR-025**, built as **Phase 25** (its 23.n = 25.n), since CR-023 (Shop Time Counts as Rest) and CR-024 (Fleet Sleep Mode) exist. Its decisions CR-023.D1–D3 are **0.5.140–0.5.142**. Its references: CR-011 → CR-014, CR-014 (leg fuel cost) → CR-015, CR-019 → CR-021, CR-022 → CR-024; CR-001 and plan 9.2 are unchanged.
- **"HQ" is the Bridge**, as in CR-021 and CR-024.
## Step 25.1
- **One "flight" kind for deliveries and deadheads**, with a `deadhead` flag, as the delivery summaries had: both are a dispatch landing, with the same route, fuel and XP fields.
- **Ids come from what an entry records** (`<dispatch>:flight`, `<ship>:upgrade:<time>`, `goal:<id>`), not from the random source, so catch-up in one go and in pieces writes identical entries. A repeat in the same moment gets `#2`.
- **Every shop end is now a catch-up event** (`ship.shopOpen`), not only ones with a pilot waiting it out: "upgrade finished" is logged at the time the shop time ends, in order with arrivals.
- **The absence is the time since the summary was last closed** (`player.awaySince`). The engine can't tell when the app was closed, but the summary opens on every landing, so its last "Done" is the last time the player looked. Closing it records the absence it covered (`lastAbsence`) for the Logbook's "This absence".
- **The pop-up still opens on unseen deliveries and finished upgrades**, as before (`summaries`, `acknowledgeSummaries`): the logbook adds detail and a window, it doesn't change when the summary appears.
- **Goals are logged once** (`player.readyGoalsLogged`), so pruning old entries doesn't log them again. After the update, goals that were already ready are logged at the first catch-up.
- **Migration:** resolved dispatches still in the save (7 days of them) become flight entries with their top-ups, rests and discoveries. Level-ups and goals can't be rebuilt and are dropped; finished upgrades the player hadn't seen are logged by catch-up at their shop end.

View File

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

View File

@ -83,7 +83,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 9; 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), `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`. 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 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.
Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned. Commands (`packages/engine/src/game/`): `GameEngine.call(name, input)` runs the route catalog's endpoints on the save, with the same schemas and error codes (`CommandError`); `getPlayerState` with no save answers `unauthorized`. Every command resolves due arrivals first and prunes on save; saves are skipped when nothing changed. `stats` keeps lifetime delivery counts for goals, so seen trips older than `savedDispatchRetentionRealHours` can be pruned.

View File

@ -0,0 +1,114 @@
# Change Request CR-025 — Short "While You Were Away" Summary
> **Numbering note (added when recorded):** this request arrived titled "CR-023 — Short 'While You Were Away' Summary" for "Phase 23". CR-023 (Shop Time Counts as Rest) and CR-024 (Fleet Sleep Mode), and Phases 23 and 24, already existed, so it is stored as **CR-025** and done as **Phase 25**: every "23.n" below means **25.n**. Its references map to this repo as: "Phase 13 and CR-001 through CR-022" = Phase 13 and **CR-001 to CR-024**; "CR-001 faster start" (scripted contract flavor messages) is the same number; "CR-011 local-first" = **CR-014** (the engine, the save, retention on save); "CR-014 leg fuel cost" = **CR-015** (fuel tanks: a leg's fuel at the tank's average price, decision 0.5.98); "CR-019 independent pilots" = **CR-021** (the per-pilot logbook, the NPC spending floor); "CR-022 fleet sleep" = **CR-024**. Plan 9.2 is the same step. Its new decisions CR-023.D1–D3 are recorded as **0.5.140 Highlights pop-up**, **0.5.141 Logbook screen** and **0.5.142 Net earnings**.
>
> **On recording:** "HQ" is the Bridge (the dashboard), as in CR-021 and CR-024.
**Project:** Deadhead: Space Freight Tycoon
**Applies to:** a build with Phase 13 and CR-001 through CR-022 complete
**Do this:** as **Phase 23**
**Related docs:** `change-request-faster-start.md` (CR-001), `change-request-local-first.md` (CR-011), `change-request-independent-pilots.md` (CR-019), `change-request-fleet-sleep.md` (CR-022), `DECISIONS.md`
This change request is written for the local-first engine (CR-011).
---
## 1. Why
The "while you were away" pop-up lists every delivery, and CR-019 added a full per-pilot logbook to it. With a handful of Independent pilots running for a day, it becomes a long scroll. The pop-up should show **highlights only**: company totals, anything that needs the player, and one line per pilot. Full detail moves to a separate **Logbook** screen.
## 2. The pop-up
Fits on one phone screen without scrolling, however many pilots there are.
### Layout, top to bottom
1. **Header:** "While you were away · 9 h 40 m"
2. **Sleep banner** (CR-022), if the fleet went quiet, with **Back to work**.
3. **Company totals**, one row of figures: net earned, deliveries, discoveries (if any), loan repaid (if any).
4. **Highlights**, at most 3, in this priority order:
1. New location discovered
2. Pilot leveled up ("Rosa reached level 4 · 1 skill point")
3. Goal ready to claim
4. Scripted contract flavor message (CR-001)
5. Best single delivery ("Biggest job: Helium-3 Canisters to Earth, 8,420 cr, Rosa")
6. Problem needing attention: a ship idle with no reachable jobs, fuel too low to fly, emergency fuel available, or credits below the NPC spending floor (CR-019)
If there are more than 3, show the top 3 and "+2 more in the Logbook".
5. **Pilots**, one line each: name, deliveries, net profit, and where they are now ("Rosa · 6 jobs · +12,300 cr · Resting at Ceres"). A level-up adds a small badge.
- Only pilots with activity during the absence are listed; others are summed in one line ("3 pilots had nothing to do").
- Sorted by net profit, highest first.
- At most **5 pilot lines**; the rest collapse into "+7 more pilots: +18,900 cr".
6. **Buttons:** **View logbook** and **Done**.
### Short absences
If the absence produced **3 events or fewer** in total (deliveries, discoveries, level-ups), show those events directly instead of the totals and pilot sections, so a quick check-in still reads naturally ("Courier delivered Mail & Parcels to the Moon, +410 cr").
### What counts as "net"
Payout minus pilot share, fuel used (CR-014 leg fuel cost), and loan repayment, matching the delivery summaries. Fuel bought counts when it's spent, not when it's bought.
## 3. The Logbook screen
- A new screen, reached from **View logbook** and from HQ.
- Holds the full detail CR-019 put in the pop-up: per pilot, every job (good, route, payout, pilot share, fuel), deadheads, refuels, rests, upgrades finished, XP gained.
- Filter by pilot and by time range ("This absence", "Last 24 h", "Last 7 days").
- Keeps entries for 7 days, pruned on save (CR-011 retention), so the save stays small.
## 4. Rules for this change
- All rules in `implementation-plan.md` §0.1 apply: steps in order, one commit per step, each step's test must pass.
- Building the summary (totals, highlights, pilot lines, collapse) is a pure function of the logbook entries for the absence, so it can be tested without UI.
- Highlight wording lives in content.
- Save migration: existing summary data is converted to logbook entries where possible; otherwise it's dropped.
- Update `architecture.md` and `progress.md` as you go. Stop for the user's review at the end of Phase 23.
## 5. Decisions
Record these in `DECISIONS.md` and `implementation-plan.md` §0.5, using the next free numbers (labeled CR-023.D1–D3 here; note the mapping).
- **CR-023.D1 Highlights pop-up.** Per §2: totals, at most 3 highlights, at most 5 pilot lines, one screen, with a direct list for absences of 3 events or fewer.
- **CR-023.D2 Logbook screen.** Full detail per §3, kept for 7 days.
- **CR-023.D3 Net earnings.** Payout minus pilot share, fuel used, and loan repayment.
## 6. Steps — Phase 23
### Step 23.1 — Engine: logbook entries and retention
- Store every relevant event (delivery, deadhead, refuel, rest, upgrade finished, discovery, level-up, goal ready) as a logbook entry with pilot, ship, time, and amounts. Prune entries older than 7 days on save.
- **Test:** Each event type creates one entry with the right fields. Entries older than 7 days are pruned; newer ones remain. Catch-up and live play produce the same entries.
### Step 23.2 — Sim: summary builder
- A pure function that turns the entries for an absence into: duration, totals, up to 3 highlights in priority order with an overflow count, up to 5 pilot lines sorted by net profit with an overflow line, the idle-pilots line, and the "short absence" direct list when there are 3 events or fewer.
- **Test:** With 50 pilots' worth of entries, the result has exactly 5 pilot lines and an overflow line whose totals equal the rest. Highlights follow the priority order and cap at 3 with the right overflow count. Totals equal the sum of entries. With 2 events, the result is the direct list. Net matches the delivery-summary calculation.
### Step 23.3 — Client: highlights pop-up
- Replace the current pop-up with the §2 layout. Keep the CR-022 sleep banner and button.
- **Test:** Component tests: with 50 active pilots, the pop-up fits in a 375×667 viewport without scrolling; each section renders from a seeded summary; "+N more" lines appear only when needed; a short absence shows the direct list; Done dismisses it; View logbook opens the Logbook filtered to this absence.
### Step 23.4 — Client: Logbook screen
- Add the Logbook screen per §3, with pilot and time-range filters, reachable from the pop-up and from HQ.
- **Test:** Component tests: entries render per pilot with the CR-019 detail; filters narrow the list correctly; the "This absence" filter matches the pop-up's totals.
### Step 23.5 — Regression
- Run the full suite.
- **Test:** CI is green. Manual check: with several Independent pilots, leave the game for a few hours, reopen on a phone, and confirm the pop-up fits on one screen and the Logbook holds the full detail.
## 7. Amendments
| Where | Change |
| -------- | --------------------------------------------------------------------------------------- |
| Plan 0.5 | Add CR-023.D1–D3. |
| Plan 9.2 | "While you were away" becomes the highlights pop-up (23.3). |
| CR-019 | The per-pilot logbook moves from the pop-up to the Logbook screen (23.4). |
| CR-022 | Sleep banner stays at the top of the pop-up. |
## 8. Out of scope
Charts or graphs of earnings, exporting the logbook, per-pilot notification digests, and anything else in `implementation-plan.md` §0.3.

View File

@ -195,6 +195,9 @@ These answer the pre-build review. Log each one in `DECISIONS.md` as it is imple
137. **Sleep** (CR-024). From the last manual decision plus the threshold (default 24 real hours; 12 h, 24 h, 48 h or 3 days in Settings → Company), Independent pilots start no new jobs or deadheads; trips in flight and rest finish normally, and everything else already scheduled is unaffected. 137. **Sleep** (CR-024). From the last manual decision plus the threshold (default 24 real hours; 12 h, 24 h, 48 h or 3 days in Settings → Company), Independent pilots start no new jobs or deadheads; trips in flight and rest finish normally, and everything else already scheduled is unaffected.
138. **Waking** (CR-024). Any manual decision, including "Back to work", wakes the fleet: Independent pilots act again from that moment. 138. **Waking** (CR-024). Any manual decision, including "Back to work", wakes the fleet: Independent pilots act again from that moment.
139. **Sleep notification** (CR-024). One local notification at the sleep time ("Your pilots are waiting for orders."), rescheduled by every manual decision, with the Notifications toggle. 139. **Sleep notification** (CR-024). One local notification at the sleep time ("Your pilots are waiting for orders."), rescheduled by every manual decision, with the Notifications toggle.
140. **Highlights pop-up** (CR-025). "While you were away" fits one phone screen: its duration, the sleep banner, company totals (net, deliveries, discoveries, loan repaid), at most 3 highlights in a fixed priority order (discovery, level-up, goal ready, scripted flavor, biggest job, a problem needing attention) with "+N more in the Logbook", and at most 5 pilot lines by net profit with the rest collapsed and idle pilots summed. An absence of 3 events or fewer lists them directly.
141. **Logbook screen** (CR-025). Every delivery, deadhead, refuel, rest, finished upgrade, discovery, level-up and goal ready is kept as a logbook entry for 7 days (pruned on save), shown per pilot on a Logbook screen with pilot and time-range filters (this absence, last 24 h, last 7 days), reached from the pop-up and the Bridge.
142. **Net earnings** (CR-025). A flight's net is its payout minus the pilot's share, the fuel it used (at the tank's average price) and the loan repayment; fuel bought counts when it's used.
### 0.6 Translating change requests written for the server (CR-014 §7) ### 0.6 Translating change requests written for the server (CR-014 §7)
@ -901,6 +904,12 @@ Steps 24.1–24.7 are specified in `memory-bank/change-requests/CR-024-fleet-sle
--- ---
## Phase 25 — Short "While You Were Away" Summary (CR-025)
Steps 25.1–25.5 are specified in `memory-bank/change-requests/CR-025-short-away-summary.md` §6 (written there as 23.1–23.5). Stop for review at the end.
---
## Definition of Done (Base Game) ## Definition of Done (Base Game)
- All step tests pass in CI. - All step tests pass in CI.

View File

@ -530,3 +530,13 @@ Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022
- [x] **Step 24.7 — Plan amendments and regression.** CR-024 §6 applied: plan 0.5 (0.5.136–0.5.139, recorded with the CR) and Step 12.1; CR-014 Steps 10K.5 (the catalog flag) and 10K.9 (the sleep notification); CR-021 Steps 19.4 (no new work while asleep) and 19.7 (the banner); CR-011 Step 10H.2 (the threshold setting). Balance: the away scenario sleeps 24 hours into the window and reports `payoutInFlightAtSleep`; the new check (`packages/sim/test/balance.test.ts`, every seed) has a week away earn no more than a day away plus those payouts (seed 12345: day 8,352,903 cr, week 8,519,338 cr, in flight 217,951 cr). The 8-hour scenario stays within 40–80% (58% for seed 12345). Full suite: typecheck, lint, 1469 unit tests and 10 e2e tests pass. - [x] **Step 24.7 — Plan amendments and regression.** CR-024 §6 applied: plan 0.5 (0.5.136–0.5.139, recorded with the CR) and Step 12.1; CR-014 Steps 10K.5 (the catalog flag) and 10K.9 (the sleep notification); CR-021 Steps 19.4 (no new work while asleep) and 19.7 (the banner); CR-011 Step 10H.2 (the threshold setting). Balance: the away scenario sleeps 24 hours into the window and reports `payoutInFlightAtSleep`; the new check (`packages/sim/test/balance.test.ts`, every seed) has a week away earn no more than a day away plus those payouts (seed 12345: day 8,352,903 cr, week 8,519,338 cr, in flight 217,951 cr). The 8-hour scenario stays within 40–80% (58% for seed 12345). Full suite: typecheck, lint, 1469 unit tests and 10 e2e tests pass.
**Phase 24 is complete; stopped for review.** Manual check for the user: set Settings → Company → "Fleet goes quiet after" to 12 h with an Independent pilot, leave the game, reopen after 13+ hours, and check the banner on "While you were away", the logbook (trips up to the sleep time and those that landed after), "Waiting for orders" on the pilot, and that Back to work starts the pilot again. **Phase 24 is complete; stopped for review.** Manual check for the user: set Settings → Company → "Fleet goes quiet after" to 12 h with an Independent pilot, leave the game, reopen after 13+ hours, and check the banner on "While you were away", the logbook (trips up to the sleep time and those that landed after), "Waiting for orders" on the pilot, and that Back to work starts the pilot again.
## Phase 25 — Short "While You Were Away" Summary (CR-025)
Spec: `memory-bank/change-requests/CR-025-short-away-summary.md` (received as "CR-023 … Phase 23"). New decisions recorded as plan §0.5.140–0.5.142.
- [x] **Step 25.1 — Engine: logbook entries and retention.** Schema `logEntrySchema` (`packages/schema/src/logbook.ts`) and save version 10: `logbook`, player `awaySince` (when the summary was last closed), `lastAbsence` (the absence it covered) and `readyGoalsLogged`, ship `shopOpen`. The engine logs each arrival (the flight, its Auto top-up, a forced rest, a discovery, a level-up with skill points), fuel the player, an Independent pilot or emergency fuel buys, a rest the player orders, every upgrade at its shop end (now a catch-up event for any open shop), and each goal the first time it's ready. `pruneSave` drops entries over 7 days old (`logbookRetentionRealDays`). Closing the summary sets `lastAbsence` and moves `awaySince` to now. The 9 → 10 migration turns resolved dispatches into flight, top-up, rest and discovery entries (level-ups can't be rebuilt), leaves an upgrade still in the shop open for catch-up, and starts the absence at the last dismissed summary. Tests: `packages/engine/test/logbook.test.ts` (each kind's fields; pruning at 7 days; catch-up in one go equals 2-minute steps over 6 hours with Independent pilots, pilot fuel and an upgrade; closing the summary); `packages/schema/test/save.test.ts` (9 → 10).
- [ ] Step 25.2 — Sim: summary builder
- [ ] Step 25.3 — Client: highlights pop-up
- [ ] Step 25.4 — Client: Logbook screen
- [ ] Step 25.5 — Regression

View File

@ -35,6 +35,7 @@ export const gameConfig: GameConfig = {
// Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips. // Housekeeping (Step 7.3, CR-014): four hours of boards; a week of seen trips.
boardRetentionPeriods: 24, boardRetentionPeriods: 24,
savedDispatchRetentionRealHours: 168, savedDispatchRetentionRealHours: 168,
logbookRetentionRealDays: 7,
// Fuel (CR-015): credits per fu before each location's price multiple, and // Fuel (CR-015): credits per fu before each location's price multiple, and
// the emergency fuel markup (decisions 0.5.96, 0.5.99). Halved by CR-018 // the emergency fuel markup (decisions 0.5.96, 0.5.99). Halved by CR-018
// (decision 0.5.110): the balance script prints each ship and route's fuel share. // (decision 0.5.110): the balance script prints each ship and route's fuel share.

View File

@ -248,6 +248,8 @@ export const gameConfigSchema = z
boardRetentionPeriods: positiveInt, boardRetentionPeriods: positiveInt,
/** Resolved, seen dispatches older than this are pruned from the save (CR-014). */ /** Resolved, seen dispatches older than this are pruned from the save (CR-014). */
savedDispatchRetentionRealHours: z.number().positive(), savedDispatchRetentionRealHours: z.number().positive(),
/** Real days logbook entries are kept (decision 0.5.141). */
logbookRetentionRealDays: z.number().positive(),
/** /**
* Pay and fuel use `reference × (distance / reference)^exponent` (decision * Pay and fuel use `reference × (distance / reference)^exponent` (decision
* 0.5.84), so long legs aren't worth hundreds of short ones. * 0.5.84), so long legs aren't worth hundreds of short ones.

View File

@ -122,6 +122,7 @@ const validGameConfig = {
loanRepaymentShare: 0.3, loanRepaymentShare: 0.3,
boardRetentionPeriods: 24, boardRetentionPeriods: 24,
savedDispatchRetentionRealHours: 168, savedDispatchRetentionRealHours: 168,
logbookRetentionRealDays: 7,
baseFuelPricePerFu: 10, baseFuelPricePerFu: 10,
emergencyFuelMultiplier: 2.5, emergencyFuelMultiplier: 2.5,
economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 }, economicDistance: { referenceMillionKm: 0.384, exponent: 0.6 },

View File

@ -13,6 +13,7 @@ import type { RandomSource } from "../core";
import { locationBoard } from "./boards"; import { locationBoard } from "./boards";
import { dispatchContract, dispatchReposition } from "./dispatch"; import { dispatchContract, dispatchReposition } from "./dispatch";
import { finishShop, refuelShip } from "./fleet"; import { finishShop, refuelShip } from "./fleet";
import { logReadyGoals } from "./logbook";
import { scheduleDecision } from "./independent"; import { scheduleDecision } from "./independent";
import { resolveArrivals } from "./resolve"; import { resolveArrivals } from "./resolve";
import { discoveredIds, pilotState, shipState, sleepsAt } from "./state"; import { discoveredIds, pilotState, shipState, sleepsAt } from "./state";
@ -77,12 +78,12 @@ function nextDecision(save: SaveDocument, limit: RealTime): SavedPilot | null {
return best; return best;
} }
/** The earliest shop end by `limit` with a pilot waiting it out (decision 0.5.135). */ /** The earliest open shop end by `limit` (decisions 0.5.135, 0.5.141). */
function nextShopEnd(save: SaveDocument, limit: RealTime): SaveDocument["ships"][number] | null { function nextShopEnd(save: SaveDocument, limit: RealTime): SaveDocument["ships"][number] | null {
let best: SaveDocument["ships"][number] | null = null; let best: SaveDocument["ships"][number] | null = null;
for (const ship of save.ships) { for (const ship of save.ships) {
const t = ship.inShopUntil; const t = ship.inShopUntil;
if (ship.shopPilotId === null || t === null || t > limit) continue; if (!ship.shopOpen || t === null || t > limit) continue;
if (!best || t < best.inShopUntil! || (t === best.inShopUntil && ship.id < best.id)) if (!best || t < best.inShopUntil! || (t === best.inShopUntil && ship.id < best.id))
best = ship; best = ship;
} }
@ -148,7 +149,9 @@ function decide(save: SaveDocument, pilot: SavedPilot, t: RealTime, viewed: stri
// The same commands, and so the same checks, as the player's (CR-021 §3). The // The same commands, and so the same checks, as the player's (CR-021 §3). The
// sim chose with the same rules, so a refusal is a bug; the pilot then waits. // sim chose with the same rules, so a refusal is a bug; the pilot then waits.
try { try {
if (action.buyFu > 0) refuelShip(save, { shipId: ship.id, amountFu: action.buyFu }, t); if (action.buyFu > 0) {
refuelShip(save, { shipId: ship.id, amountFu: action.buyFu }, t, "pilot");
}
if (action.kind === "take") { if (action.kind === "take") {
dispatchContract( dispatchContract(
save, save,
@ -205,6 +208,7 @@ export function catchUp(
(decisionAt === null || shopAt <= decisionAt) (decisionAt === null || shopAt <= decisionAt)
) { ) {
finishShop(save, shop!); finishShop(save, shop!);
logReadyGoals(save, shopAt);
reached = shopAt; reached = shopAt;
continue; continue;
} }
@ -223,6 +227,7 @@ export function catchUp(
(d) => d.status === "in-flight" && d.arrivalAt <= arrival, (d) => d.status === "in-flight" && d.arrivalAt <= arrival,
); );
resolved += resolveArrivals(save, arrival); resolved += resolveArrivals(save, arrival);
logReadyGoals(save, arrival);
for (const d of landed) { for (const d of landed) {
const flyer = save.pilots.find((p) => p.id === d.pilotId); const flyer = save.pilots.find((p) => p.id === d.pilotId);
if (flyer?.mode === "independent") { if (flyer?.mode === "independent") {

View File

@ -132,7 +132,7 @@ export function commandTable(): CommandTable {
// Done now: the "out of the shop" note still shows, as for a finished job. // Done now: the "out of the shop" note still shows, as for a finished job.
if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now; if (ship.inShopUntil !== null && ship.inShopUntil > now) ship.inShopUntil = now;
// Its pilot comes out rested at once (decision 0.5.135). // Its pilot comes out rested at once (decision 0.5.135).
if (ship.shopPilotId !== null) finishShop(save, ship); if (ship.shopOpen) finishShop(save, ship);
break; break;
case "move": case "move":
if (ship.status !== "idle") if (ship.status !== "idle")

View File

@ -35,6 +35,9 @@ export function startingSave(
dealAccepted: false, dealAccepted: false,
createdAt: now, createdAt: now,
welcomeSeen: false, welcomeSeen: false,
awaySince: now,
lastAbsence: null,
readyGoalsLogged: [],
}, },
company: { company: {
id: random.id(), id: random.id(),
@ -78,6 +81,7 @@ export function startingSave(
acceptedContracts: [], acceptedContracts: [],
claimedGoals: [], claimedGoals: [],
stats: { contractDeliveries: 0, priorityDeliveries: 0 }, stats: { contractDeliveries: 0, priorityDeliveries: 0 },
logbook: [],
}; };
} }
@ -130,6 +134,7 @@ export function acceptDeal(save: SaveDocument, random: RandomSource, now: RealTi
shopUpgradeId: null, shopUpgradeId: null,
shopLevel: null, shopLevel: null,
shopPilotId: null, shopPilotId: null,
shopOpen: false,
fromDeal: true, fromDeal: true,
createdAt: now, createdAt: now,
lastArrivalAt: now, lastArrivalAt: now,

View File

@ -16,6 +16,7 @@ import { commandTable } from "./commands";
import { startingSave } from "./company"; import { startingSave } from "./company";
import { CommandError, fail } from "./errors"; import { CommandError, fail } from "./errors";
import { catchUp, decideUntil } from "./catchup"; import { catchUp, decideUntil } from "./catchup";
import { logReadyGoals } from "./logbook";
import { pruneSave } from "./resolve"; import { pruneSave } from "./resolve";
import { playerState } from "./state"; import { playerState } from "./state";
@ -257,6 +258,8 @@ export class GameEngine {
const result = handler({ save, now, random }, input); const result = handler({ save, now, random }, input);
// The catalog says which commands are the player's decisions (decision 0.5.136). // The catalog says which commands are the player's decisions (decision 0.5.136).
if (routes[name].manualDecision) save.company.lastManualDecisionAt = now; if (routes[name].manualDecision) save.company.lastManualDecisionAt = now;
// A purchase or delivery may have made a goal ready (decision 0.5.141).
logReadyGoals(save, now);
pruneSave(save, now); pruneSave(save, now);
return { save, result, unchanged: JSON.stringify(save) === before }; return { save, result, unchanged: JSON.stringify(save) === before };
}); });

View File

@ -16,6 +16,7 @@ import {
import type { RandomSource } from "../core"; import type { RandomSource } from "../core";
import { fail } from "./errors"; import { fail } from "./errors";
import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages"; import { UPGRADE_MESSAGES, VALIDATION_MESSAGES } from "./messages";
import { logEntry, shipPilotId } from "./logbook";
import { pilotName, shipName } from "./names"; import { pilotName, shipName } from "./names";
import { import {
deliveredTank, deliveredTank,
@ -76,6 +77,7 @@ export function buyShip(
shopUpgradeId: null, shopUpgradeId: null,
shopLevel: null, shopLevel: null,
shopPilotId: null, shopPilotId: null,
shopOpen: false,
fromDeal: false, fromDeal: false,
createdAt: now, createdAt: now,
lastArrivalAt: now, lastArrivalAt: now,
@ -171,13 +173,25 @@ export function unassignPilot(save: SaveDocument, pilotId: string, now: RealTime
} }
/** /**
* The shop time ends (decision 0.5.135): the pilot who stayed with the ship * The shop time ends: "upgrade finished" goes in the logbook (decision
* the whole time comes out rested. Duty goes to zero, and a rest that would * 0.5.141), and the pilot who stayed with the ship the whole time comes out
* end later ends now. An Independent pilot who was waiting for that rest * rested (decision 0.5.135). Duty goes to zero, and a rest that would end
* decides after the usual turnaround. * later ends now. An Independent pilot who was waiting for that rest decides
* after the usual turnaround.
*/ */
export function finishShop(save: SaveDocument, ship: SavedShip): void { export function finishShop(save: SaveDocument, ship: SavedShip): void {
const at = ship.inShopUntil; const at = ship.inShopUntil;
if (ship.shopOpen && at !== null && ship.shopUpgradeId && ship.shopLevel !== null) {
logEntry(save, `${ship.id}:upgrade:${at}`, {
at,
pilotId: shipPilotId(save, ship.id),
shipId: ship.id,
kind: "upgrade",
upgradeId: ship.shopUpgradeId,
level: ship.shopLevel,
});
}
ship.shopOpen = false;
const pilot = save.pilots.find((p) => p.id === ship.shopPilotId); const pilot = save.pilots.find((p) => p.id === ship.shopPilotId);
ship.shopPilotId = null; ship.shopPilotId = null;
if (!pilot || at === null || pilot.assignedShipId !== ship.id) return; if (!pilot || at === null || pilot.assignedShipId !== ship.id) return;
@ -197,6 +211,13 @@ export function restPilot(save: SaveDocument, pilotId: string, now: RealTime): P
if (isResting(pilot.restEndsAt, now)) if (isResting(pilot.restEndsAt, now))
throw fail("pilot-resting", "That pilot is already resting."); throw fail("pilot-resting", "That pilot is already resting.");
Object.assign(pilot, { dutyHours: 0, restEndsAt: restEndsAt(now, content.config) }); Object.assign(pilot, { dutyHours: 0, restEndsAt: restEndsAt(now, content.config) });
logEntry(save, `${pilot.id}:rest:${now}`, {
at: now,
pilotId: pilot.id,
shipId: pilot.assignedShipId,
kind: "rest",
until: pilot.restEndsAt!,
});
return toApiPilot(save, pilot, now); return toApiPilot(save, pilot, now);
} }
@ -255,6 +276,7 @@ export function startUpgrade(
shopUpgradeId: upgrade.id, shopUpgradeId: upgrade.id,
shopLevel: check.level, shopLevel: check.level,
shopPilotId: pilot && coversRest ? pilot.id : null, shopPilotId: pilot && coversRest ? pilot.id : null,
shopOpen: true,
}); });
return toApiShip(save, ship, now); return toApiShip(save, ship, now);
} }
@ -289,6 +311,11 @@ export function acknowledge(
count++; count++;
} }
} }
// Closing "While you were away" ends that absence (decision 0.5.140).
if (count > 0) {
save.player.lastAbsence = { from: save.player.awaySince, to: now };
save.player.awaySince = now;
}
return count; return count;
} }
@ -316,6 +343,8 @@ export function refuelShip(
save: SaveDocument, save: SaveDocument,
input: { shipId: string; amountFu?: number }, input: { shipId: string; amountFu?: number },
now: RealTime, now: RealTime,
/** Who bought it, for the logbook: the player, or an Independent pilot. */
source: "bought" | "pilot" = "bought",
): Ship { ): Ship {
const ship = requireShip(save, input.shipId); const ship = requireShip(save, input.shipId);
const location = idleAt(ship); const location = idleAt(ship);
@ -330,11 +359,36 @@ export function refuelShip(
fuelPriceAt(location, content.config), fuelPriceAt(location, content.config),
); );
spend(save, bought.cost, "fuel"); spend(save, bought.cost, "fuel");
logRefuel(save, ship, location.id, ship.fuelFu, bought, source, now);
ship.fuelFu = bought.tank.fuelFu; ship.fuelFu = bought.tank.fuelFu;
ship.fuelAvgPrice = bought.tank.avgPrice; ship.fuelAvgPrice = bought.tank.avgPrice;
return toApiShip(save, ship, now); return toApiShip(save, ship, now);
} }
/** A fuel purchase in the logbook (decision 0.5.141). */
function logRefuel(
save: SaveDocument,
ship: SavedShip,
locationId: string,
before: number,
bought: { cost: number; tank: { fuelFu: number } },
source: "bought" | "pilot" | "emergency",
now: RealTime,
): void {
const boughtFu = bought.tank.fuelFu - before;
if (boughtFu <= 0) return;
logEntry(save, `${ship.id}:refuel:${now}`, {
at: now,
pilotId: shipPilotId(save, ship.id),
shipId: ship.id,
kind: "refuel",
locationId,
boughtFu,
cost: bought.cost,
source,
});
}
/** Auto top-up on landing, or Manual (decision 0.5.97). */ /** Auto top-up on landing, or Manual (decision 0.5.97). */
export function setRefuelPolicy( export function setRefuelPolicy(
save: SaveDocument, save: SaveDocument,
@ -373,6 +427,7 @@ export function orderEmergencyFuel(
offer.amountFu, offer.amountFu,
offer.price, offer.price,
); );
logRefuel(save, ship, ship.locationId!, ship.fuelFu, bought, "emergency", now);
ship.fuelFu = bought.tank.fuelFu; ship.fuelFu = bought.tank.fuelFu;
ship.fuelAvgPrice = bought.tank.avgPrice; ship.fuelAvgPrice = bought.tank.avgPrice;
save.company.loanBalance += offer.cost; save.company.loanBalance += offer.cost;

View File

@ -0,0 +1,46 @@
import { content } from "@deadhead/content";
import type { LogEntry, SaveDocument } from "@deadhead/schema";
import type { RealTime } from "@deadhead/sim";
import { goalStates } from "./state";
const DAY_MS = 24 * 60 * 60 * 1000;
type NewEntry = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, "id"> : never) : never;
/**
* Adds an entry to the logbook (decision 0.5.141). Its id is `base`, made from
* what it records, so replaying the same time writes the same entries; a
* repeat in the same moment gets `#2`, `#3`, ….
*/
export function logEntry(save: SaveDocument, base: string, entry: NewEntry): void {
let id = base;
for (let n = 2; save.logbook.some((e) => e.id === id); n++) id = `${base}#${n}`;
save.logbook.push({ id, ...entry } as LogEntry);
}
/** The pilot assigned to a ship, if any: whose entry a ship's event is. */
export function shipPilotId(save: SaveDocument, shipId: string): string | null {
return save.pilots.find((p) => p.assignedShipId === shipId)?.id ?? null;
}
/** Logs each goal the first time it's ready to claim (decision 0.5.141). */
export function logReadyGoals(save: SaveDocument, at: RealTime): void {
const logged = new Set(save.player.readyGoalsLogged);
for (const goal of goalStates(save)) {
if (goal.status !== "ready" || logged.has(goal.id)) continue;
save.player.readyGoalsLogged.push(goal.id);
logEntry(save, `goal:${goal.id}`, {
at,
pilotId: null,
shipId: null,
kind: "goal-ready",
goalId: goal.id,
});
}
}
/** Drops entries older than the retention (7 days), on every save (CR-014 retention). */
export function pruneLogbook(save: SaveDocument, now: RealTime): void {
const cutoff = now - content.config.logbookRetentionRealDays * DAY_MS;
save.logbook = save.logbook.filter((e) => e.at >= cutoff);
}

View File

@ -1,6 +1,15 @@
import { content } from "@deadhead/content"; import { content } from "@deadhead/content";
import type { SaveDocument } from "@deadhead/schema"; import type { SaveDocument } from "@deadhead/schema";
import { boardPeriodIndex, effectiveStats, resolveDispatch, type RealTime } from "@deadhead/sim"; import {
boardPeriodIndex,
effectiveStats,
pilotLevel,
resolveDispatch,
skillPointsAvailable,
type RealTime,
type Resolution,
} from "@deadhead/sim";
import { logEntry, pruneLogbook } from "./logbook";
import { discoveredIds } from "./state"; import { discoveredIds } from "./state";
/** /**
@ -60,6 +69,7 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
ship.fuelAvgPrice = r.topUp.tank.avgPrice; ship.fuelAvgPrice = r.topUp.tank.avgPrice;
} }
const levelBefore = pilotLevel(pilot.xp, content.config);
pilot.xp += r.pilotXp; pilot.xp += r.pilotXp;
pilot.status = "idle"; pilot.status = "idle";
// Duty and mandatory rest from the arrival time (decisions 0.5.42–0.5.45). // Duty and mandatory rest from the arrival time (decisions 0.5.42–0.5.45).
@ -80,17 +90,91 @@ export function resolveArrivals(save: SaveDocument, now: RealTime): number {
save.stats.contractDeliveries++; save.stats.contractDeliveries++;
if (dispatch.record.contract.priority) save.stats.priorityDeliveries++; if (dispatch.record.contract.priority) save.stats.priorityDeliveries++;
} }
if (r.discoversDestination && !discovered.has(dispatch.record.destinationId)) { const discovers = r.discoversDestination && !discovered.has(dispatch.record.destinationId);
if (discovers) {
save.discoveredLocations.push({ save.discoveredLocations.push({
locationId: dispatch.record.destinationId, locationId: dispatch.record.destinationId,
discoveredAt: now, discoveredAt: now,
}); });
discovered.add(dispatch.record.destinationId); discovered.add(dispatch.record.destinationId);
} }
logArrival(save, dispatch, r, now, discovers, levelBefore, pilot);
} }
return resolved; return resolved;
} }
/** One arrival in the logbook (decision 0.5.141): the flight and what came with it. */
function logArrival(
save: SaveDocument,
dispatch: SaveDocument["dispatches"][number],
r: Resolution,
now: RealTime,
discovers: boolean,
levelBefore: number,
pilot: SaveDocument["pilots"][number],
): void {
const { record } = dispatch;
const contract = record.contract;
const who = { pilotId: pilot.id, shipId: dispatch.shipId };
logEntry(save, `${dispatch.id}:flight`, {
at: now,
...who,
kind: "flight",
dispatchId: dispatch.id,
deadhead: contract === null,
tutorial: dispatch.tutorial,
independent: dispatch.independent,
originId: record.originId,
destinationId: record.destinationId,
goodId: contract?.goodId ?? null,
massTonnes: contract?.massTonnes ?? null,
shipper: contract?.shipper ?? null,
payout: Math.max(0, r.credits + r.pilotPay + r.loanRepayment),
pilotPay: r.pilotPay,
fuelCost: record.fuelCost,
loanRepayment: r.loanRepayment,
xp: r.pilotXp,
scriptedId: contract?.scriptedId ?? null,
});
if (r.topUp && r.topUp.boughtFu > 0) {
logEntry(save, `${dispatch.id}:top-up`, {
at: now,
...who,
kind: "refuel",
locationId: r.topUp.locationId,
boughtFu: r.topUp.boughtFu,
cost: r.topUp.cost,
source: "top-up",
});
}
if (r.pilotRestEndsAt !== null) {
logEntry(save, `${dispatch.id}:rest`, {
at: now,
...who,
kind: "rest",
until: r.pilotRestEndsAt,
});
}
if (discovers) {
logEntry(save, `${dispatch.id}:discovery`, {
at: now,
...who,
kind: "discovery",
locationId: record.destinationId,
});
}
const level = pilotLevel(pilot.xp, content.config);
if (level > levelBefore) {
logEntry(save, `${dispatch.id}:level`, {
at: now,
...who,
kind: "level-up",
level,
skillPoints: skillPointsAvailable(level, pilot.skillPointsSpent, content.config),
});
}
}
/** /**
* Keeps the save small (CR-014 §2, Step 7.3): drops boards and accepted * Keeps the save small (CR-014 §2, Step 7.3): drops boards and accepted
* contracts older than the board retention, and resolved dispatches the player * contracts older than the board retention, and resolved dispatches the player
@ -102,6 +186,7 @@ export function pruneSave(save: SaveDocument, now: RealTime): void {
save.contractBoards = save.contractBoards.filter((b) => b.periodIndex >= cutoffPeriod); save.contractBoards = save.contractBoards.filter((b) => b.periodIndex >= cutoffPeriod);
save.acceptedContracts = save.acceptedContracts.filter((a) => a.periodIndex >= cutoffPeriod); save.acceptedContracts = save.acceptedContracts.filter((a) => a.periodIndex >= cutoffPeriod);
const cutoff = now - content.config.savedDispatchRetentionRealHours * 3_600_000; const cutoff = now - content.config.savedDispatchRetentionRealHours * 3_600_000;
pruneLogbook(save, now);
save.dispatches = save.dispatches.filter( save.dispatches = save.dispatches.filter(
(d) => (d) =>
d.status === "in-flight" || d.status === "in-flight" ||

View File

@ -0,0 +1,245 @@
import { content } from "@deadhead/content";
import type { LogEntry, SaveDocument } from "@deadhead/schema";
import { pilotLevel, skillPointsAvailable } from "@deadhead/sim";
import { describe, expect, it } from "vitest";
import {
addPilot,
addShip,
edit,
newTestApp,
putBoard,
readSave,
setCredits,
signUpOwner,
testContract,
type TestApp,
} from "./support";
const HOUR = 60 * 60_000;
const DAY = 24 * HOUR;
const of = <K extends LogEntry["kind"]>(save: SaveDocument, kind: K) =>
save.logbook.filter((e): e is Extract<LogEntry, { kind: K }> => e.kind === kind);
/** An owner with the avatar's Courier at Earth; returns ids. */
async function owner(test: TestApp) {
const o = await signUpOwner(test.app);
await setCredits(test.app, 100_000);
return o;
}
/** Flies the avatar on a board contract and lets it land. */
async function deliver(test: TestApp, avatarId: string, shipId: string, over = {}) {
const contract = testContract({
offeredAt: test.clock.now,
expiresAt: test.clock.now + HOUR,
...over,
});
await putBoard(test.app, [contract], test.clock.now);
const { dispatch } = await test.app.call("createDispatch", {
kind: "contract",
contractId: contract.id,
shipId,
pilotId: avatarId,
});
test.clock.now = dispatch.arrivalAt + 1_000;
await test.app.call("getPlayerState", {});
return { contract, dispatch, save: await readSave(test.app) };
}
describe("logbook entries (Step 25.1, decision 0.5.141)", () => {
it("logs a delivery with its pilot, ship, route, payout, pilot pay, fuel, loan and XP", async () => {
const test = newTestApp();
const o = await owner(test);
const { contract, dispatch, save } = await deliver(test, o.avatarId, o.shipId);
const [flight] = of(save, "flight");
expect(flight).toMatchObject({
id: `${dispatch.id}:flight`,
at: dispatch.arrivalAt,
pilotId: o.avatarId,
shipId: o.shipId,
dispatchId: dispatch.id,
deadhead: false,
independent: false,
originId: "earth",
destinationId: "moon",
goodId: contract.goodId,
massTonnes: contract.massTonnes,
shipper: contract.shipper,
payout: contract.payout,
pilotPay: 0,
fuelCost: dispatch.fuelCost,
loanRepayment: 0,
});
expect(flight!.xp).toBeGreaterThan(0);
expect(of(save, "flight")).toHaveLength(1);
});
it("logs a deadhead, the Auto top-up on landing, and fuel the player buys", async () => {
const test = newTestApp();
const o = await owner(test);
await test.app.call("setRefuelPolicy", { shipId: o.shipId, policy: "auto" });
const { dispatch } = await test.app.call("createDispatch", {
kind: "reposition",
shipId: o.shipId,
pilotId: o.avatarId,
destinationId: "moon",
});
test.clock.now = dispatch.arrivalAt + 1_000;
await test.app.call("getPlayerState", {});
let save = await readSave(test.app);
expect(of(save, "flight")).toEqual([
expect.objectContaining({
deadhead: true,
payout: 0,
goodId: null,
fuelCost: dispatch.fuelCost,
}),
]);
expect(of(save, "refuel")).toEqual([
expect.objectContaining({
id: `${dispatch.id}:top-up`,
source: "top-up",
locationId: "moon",
}),
]);
await test.app.call("setRefuelPolicy", { shipId: o.shipId, policy: "manual" });
await edit(test.app, (s) => {
s.ships.find((x) => x.id === o.shipId)!.fuelFu -= 5;
});
await test.app.call("refuelShip", { shipId: o.shipId, amountFu: 5 });
save = await readSave(test.app);
const bought = of(save, "refuel").filter((e) => e.source === "bought");
expect(bought).toEqual([
expect.objectContaining({ shipId: o.shipId, pilotId: o.avatarId, locationId: "moon" }),
]);
expect(bought[0]!.boughtFu).toBeCloseTo(5);
expect(bought[0]!.cost).toBeGreaterThan(0);
});
it("logs a rest the player orders and one forced by the duty limit", async () => {
const test = newTestApp();
const o = await owner(test);
await test.app.call("restPilot", { pilotId: o.avatarId });
let save = await readSave(test.app);
const ordered = save.pilots.find((p) => p.id === o.avatarId)!.restEndsAt!;
expect(of(save, "rest")).toEqual([
expect.objectContaining({ pilotId: o.avatarId, at: test.clock.now, until: ordered }),
]);
test.clock.now = ordered + 1_000;
await edit(test.app, (s) => {
s.pilots.find((p) => p.id === o.avatarId)!.dutyHours = content.config.dutyLimitGameHours;
});
const { dispatch, save: after } = await deliver(test, o.avatarId, o.shipId);
save = after;
expect(of(save, "rest").at(-1)).toMatchObject({
id: `${dispatch.id}:rest`,
pilotId: o.avatarId,
});
});
it("logs an upgrade when its shop time ends", async () => {
const test = newTestApp();
const o = await owner(test);
const { ship } = await test.app.call("startUpgrade", {
shipId: o.shipId,
upgradeId: "engines",
});
expect(of(await readSave(test.app), "upgrade")).toEqual([]);
test.clock.now = ship.inShopUntil! + 1_000;
await test.app.call("getPlayerState", {});
expect(of(await readSave(test.app), "upgrade")).toEqual([
expect.objectContaining({
at: ship.inShopUntil,
shipId: o.shipId,
pilotId: o.avatarId,
upgradeId: "engines",
level: 1,
}),
]);
});
it("logs a discovery, a level-up with the skill points, and a goal ready to claim", async () => {
const test = newTestApp();
const o = await owner(test);
let toLevel = 0;
while (pilotLevel(toLevel, content.config) < 1) toLevel++;
await edit(test.app, (s) => {
s.discoveredLocations = s.discoveredLocations.filter((d) => d.locationId !== "moon");
s.pilots.find((p) => p.id === o.avatarId)!.xp = toLevel - 1;
});
const { dispatch, save } = await deliver(test, o.avatarId, o.shipId, { firstContact: true });
expect(of(save, "discovery")).toEqual([
expect.objectContaining({ id: `${dispatch.id}:discovery`, locationId: "moon" }),
]);
expect(of(save, "level-up")).toEqual([
expect.objectContaining({
pilotId: o.avatarId,
level: 1,
skillPoints: skillPointsAvailable(1, 0, content.config),
}),
]);
const goals = of(save, "goal-ready");
expect(goals.length).toBeGreaterThan(0);
// Each goal once, however often the state is read.
await test.app.call("getPlayerState", {});
expect(of(await readSave(test.app), "goal-ready")).toEqual(goals);
});
it("prunes entries older than 7 days on save", async () => {
const test = newTestApp();
const o = await owner(test);
await deliver(test, o.avatarId, o.shipId);
const old = of(await readSave(test.app), "flight")[0]!;
test.clock.now = old.at + 7 * DAY - HOUR;
await test.app.call("restPilot", { pilotId: o.avatarId });
expect((await readSave(test.app)).logbook.map((e) => e.id)).toContain(old.id);
test.clock.now = old.at + 7 * DAY + HOUR;
await test.app.call("getPlayerState", {});
const ids = (await readSave(test.app)).logbook.map((e) => e.id);
expect(ids).not.toContain(old.id);
expect(ids.length).toBeGreaterThan(0);
});
it("writes the same entries in catch-up as in live play", async () => {
const once = newTestApp();
await owner(once);
await setCredits(once.app, content.config.independentPilots.dispatchOfficePrice + 50_000);
await once.app.call("buyDispatchOffice", {});
for (const id of ["pa", "pb"]) {
await addShip(once.app, { id: `ship-${id}`, modelId: "courier", name: `Courier ${id}` });
await addPilot(once.app, { id, name: `Pilot ${id}`, assignedShipId: `ship-${id}` });
await once.app.call("setPilotMode", { pilotId: id, mode: "independent" });
}
await once.app.call("startUpgrade", { shipId: "ship-pa", upgradeId: "engines" });
const { code } = await once.app.call("exportSave", {});
const live = newTestApp();
live.clock.now = once.clock.now;
await live.app.call("importSave", { code });
const end = once.clock.now + 6 * HOUR;
once.clock.now = end;
await once.app.call("getPlayerState", {});
while (live.clock.now < end) {
live.clock.now = Math.min(end, live.clock.now + 2 * 60_000);
await live.app.call("getPlayerState", {});
}
const a = (await readSave(once.app)).logbook;
expect(a.filter((e) => e.kind === "flight").length).toBeGreaterThan(10);
expect(a.some((e) => e.kind === "refuel" && e.source === "pilot")).toBe(true);
expect(a.some((e) => e.kind === "upgrade")).toBe(true);
expect((await readSave(live.app)).logbook).toEqual(a);
}, 60_000);
it("starts the next absence when the summary is closed", async () => {
const test = newTestApp();
const o = await owner(test);
const start = (await readSave(test.app)).player.awaySince;
const { dispatch } = await deliver(test, o.avatarId, o.shipId);
await test.app.call("acknowledgeSummaries", { dispatchIds: [dispatch.id] });
const { player } = await readSave(test.app);
expect(player.lastAbsence).toEqual({ from: start, to: test.clock.now });
expect(player.awaySince).toBe(test.clock.now);
});
});

View File

@ -310,6 +310,7 @@ export function makeOwner(
shopUpgradeId: null, shopUpgradeId: null,
shopLevel: null, shopLevel: null,
shopPilotId: null, shopPilotId: null,
shopOpen: false,
// Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank. // Stands in for the deal's Courier (not a Market purchase, CR-013), with a full tank.
fromDeal: true, fromDeal: true,
createdAt: TEST_NOW, createdAt: TEST_NOW,
@ -374,6 +375,7 @@ export function addShip(
shopUpgradeId: null, shopUpgradeId: null,
shopLevel: null, shopLevel: null,
shopPilotId: null, shopPilotId: null,
shopOpen: false,
fromDeal: false, fromDeal: false,
createdAt: TEST_NOW, createdAt: TEST_NOW,
lastArrivalAt: TEST_NOW, lastArrivalAt: TEST_NOW,

View File

@ -3,3 +3,4 @@ export * from "./errors";
export * from "./requests"; export * from "./requests";
export * from "./routes"; export * from "./routes";
export * from "./save"; export * from "./save";
export * from "./logbook";

View File

@ -0,0 +1,70 @@
import { z } from "zod";
import { idSchema, timeSchema } from "./entities";
const credits = z.number().int();
/**
* One logbook entry (decision 0.5.141): something that happened to the
* company, kept for 7 days. Ids come from what they record (a dispatch, a
* ship and a time), so catch-up and live play write the same entries.
*/
const base = {
id: z.string().min(1),
at: timeSchema,
/** Whose entry it is: the flight's pilot, the ship's pilot, or none. */
pilotId: idSchema.nullable(),
shipId: idSchema.nullable(),
};
export const logEntrySchema = z.discriminatedUnion("kind", [
/** A delivery, or a deadhead (no cargo, no pay). */
z.object({
...base,
kind: z.literal("flight"),
dispatchId: idSchema,
deadhead: z.boolean(),
tutorial: z.boolean(),
independent: z.boolean(),
originId: idSchema,
destinationId: idSchema,
goodId: idSchema.nullable(),
massTonnes: z.number().positive().nullable(),
shipper: z.string().min(1).nullable(),
/** What the shipper paid, before pilot pay and the loan. */
payout: credits.nonnegative(),
pilotPay: credits.nonnegative(),
/** The fuel the leg used, at the tank's average price (decision 0.5.98). */
fuelCost: credits.nonnegative(),
loanRepayment: credits.nonnegative(),
xp: z.number().int().nonnegative(),
/** A scripted tutorial contract, for its flavor message (CR-001). */
scriptedId: idSchema.nullable(),
}),
z.object({
...base,
kind: z.literal("refuel"),
locationId: idSchema,
boughtFu: z.number().positive(),
cost: credits.nonnegative(),
/** Auto top-up on landing, the player, an Independent pilot, or emergency fuel (on the loan). */
source: z.enum(["top-up", "bought", "pilot", "emergency"]),
}),
z.object({ ...base, kind: z.literal("rest"), until: timeSchema }),
z.object({
...base,
kind: z.literal("upgrade"),
upgradeId: idSchema,
level: z.number().int().positive(),
}),
z.object({ ...base, kind: z.literal("discovery"), locationId: idSchema }),
z.object({
...base,
kind: z.literal("level-up"),
level: z.number().int().positive(),
/** Skill points the pilot has to spend after it. */
skillPoints: z.number().int().nonnegative(),
}),
z.object({ ...base, kind: z.literal("goal-ready"), goalId: idSchema }),
]);
export type LogEntry = z.infer<typeof logEntrySchema>;
export type LogEntryKind = LogEntry["kind"];

View File

@ -8,6 +8,7 @@ import {
type Resolution, type Resolution,
} from "@deadhead/sim"; } from "@deadhead/sim";
import { z } from "zod"; import { z } from "zod";
import { logEntrySchema } from "./logbook";
import { import {
contractSchema, contractSchema,
idSchema, idSchema,
@ -92,6 +93,11 @@ export const savedShipSchema = z.object({
* when the shop time ends. * when the shop time ends.
*/ */
shopPilotId: idSchema.nullable(), shopPilotId: idSchema.nullable(),
/**
* An upgrade's shop time hasn't been through catch-up yet: its end logs
* "upgrade finished" (decision 0.5.141) and settles the shop rest.
*/
shopOpen: z.boolean(),
/** Handed over in the deal rather than bought (CR-013). */ /** Handed over in the deal rather than bought (CR-013). */
fromDeal: z.boolean(), fromDeal: z.boolean(),
createdAt: timeSchema, createdAt: timeSchema,
@ -170,7 +176,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 = 9; export const CURRENT_SAVE_VERSION = 10;
export const saveDocumentSchema = z.object({ export const saveDocumentSchema = z.object({
saveVersion: z.literal(CURRENT_SAVE_VERSION), saveVersion: z.literal(CURRENT_SAVE_VERSION),
@ -188,6 +194,15 @@ export const saveDocumentSchema = z.object({
createdAt: timeSchema, createdAt: timeSchema,
/** This company's welcome cards have been seen (decision 0.5.131). */ /** This company's welcome cards have been seen (decision 0.5.131). */
welcomeSeen: z.boolean(), welcomeSeen: z.boolean(),
/**
* When "While you were away" was last closed: the current absence runs
* from here (decision 0.5.140).
*/
awaySince: timeSchema,
/** The absence the last closed pop-up covered, for the Logbook's "This absence". */
lastAbsence: z.object({ from: timeSchema, to: timeSchema }).nullable(),
/** Goals already logged as ready to claim, so each is logged once. */
readyGoalsLogged: z.array(idSchema),
}), }),
company: z.object({ company: z.object({
id: idSchema, id: idSchema,
@ -231,6 +246,8 @@ export const saveDocumentSchema = z.object({
contractDeliveries: z.number().int().nonnegative(), contractDeliveries: z.number().int().nonnegative(),
priorityDeliveries: z.number().int().nonnegative(), priorityDeliveries: z.number().int().nonnegative(),
}), }),
/** The last 7 days of what happened (decision 0.5.141), oldest first. */
logbook: z.array(logEntrySchema),
}); });
export type SaveDocument = z.infer<typeof saveDocumentSchema>; export type SaveDocument = z.infer<typeof saveDocumentSchema>;
@ -378,6 +395,91 @@ function fleetSleep(save: Doc): Doc {
}; };
} }
/**
* Version 9 → 10 (CR-025 §4): the logbook. Resolved dispatches still in the
* save become flight entries, with their top-ups, rests and discoveries;
* level-ups can't be rebuilt and are dropped. An upgrade still showing in the
* shop is left open, so catch-up logs its end. The absence runs from the last
* time a summary was dismissed.
*/
function logbook(save: Doc): Doc {
const entries: Doc[] = [];
let seen = 0;
for (const d of records(save.dispatches)) {
const r = d.resolution as Doc | null;
if (d.status !== "resolved" || !r) continue;
const record = d.record as Doc;
const contract = record.contract as Doc | null;
const at = (d.resolvedAt as number | null) ?? (d.arrivalAt as number);
if (typeof d.acknowledgedAt === "number") seen = Math.max(seen, d.acknowledgedAt);
const who = { pilotId: d.pilotId, shipId: d.shipId ?? null };
const pilotPay = r.pilotPay as number;
const loanRepayment = r.loanRepayment as number;
entries.push({
id: `${d.id}:flight`,
at,
...who,
kind: "flight",
dispatchId: d.id,
deadhead: contract === null,
tutorial: d.tutorial,
independent: d.independent,
originId: record.originId,
destinationId: record.destinationId,
goodId: contract?.goodId ?? null,
massTonnes: contract?.massTonnes ?? null,
shipper: contract?.shipper ?? null,
payout: Math.max(0, (r.credits as number) + pilotPay + loanRepayment),
pilotPay,
fuelCost: record.fuelCost,
loanRepayment,
xp: r.pilotXp,
scriptedId: contract?.scriptedId ?? null,
});
const topUp = r.topUp as Doc | null;
if (topUp && (topUp.boughtFu as number) > 0) {
entries.push({
id: `${d.id}:top-up`,
at,
...who,
kind: "refuel",
locationId: topUp.locationId,
boughtFu: topUp.boughtFu,
cost: topUp.cost,
source: "top-up",
});
}
if (typeof r.pilotRestEndsAt === "number") {
entries.push({ id: `${d.id}:rest`, at, ...who, kind: "rest", until: r.pilotRestEndsAt });
}
if (r.discoversDestination) {
entries.push({
id: `${d.id}:discovery`,
at,
...who,
kind: "discovery",
locationId: record.destinationId,
});
}
}
entries.sort((a, b) => (a.at as number) - (b.at as number));
const player = save.player as Doc;
return {
...save,
player: {
...player,
awaySince: seen > 0 ? seen : player.createdAt,
lastAbsence: null,
readyGoalsLogged: [],
},
ships: records(save.ships).map((ship) => ({
...ship,
shopOpen: ship.inShopUntil !== null && ship.shopUpgradeId !== null,
})),
logbook: entries,
};
}
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 },
@ -387,6 +489,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
{ from: 6, migrate: welcomeSeen }, { from: 6, migrate: welcomeSeen },
{ from: 7, migrate: shopPilot }, { from: 7, migrate: shopPilot },
{ from: 8, migrate: fleetSleep }, { from: 8, migrate: fleetSleep },
{ from: 9, migrate: logbook },
]; ];
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };

View File

@ -45,7 +45,7 @@ export function fullSave(): SaveDocument {
seed: 42, seed: 42,
}); });
return { return {
saveVersion: 9, saveVersion: 10,
saveId: "save-1", saveId: "save-1",
lastTimeSeen: T + 3_600_000, lastTimeSeen: T + 3_600_000,
player: { player: {
@ -56,6 +56,10 @@ export function fullSave(): SaveDocument {
dealAccepted: true, dealAccepted: true,
createdAt: T, createdAt: T,
welcomeSeen: true, welcomeSeen: true,
// As the 9 → 10 migration leaves it: no summary dismissed yet.
awaySince: T,
lastAbsence: null,
readyGoalsLogged: [],
}, },
company: { company: {
id: "co1", id: "co1",
@ -80,6 +84,7 @@ export function fullSave(): SaveDocument {
shopUpgradeId: null, shopUpgradeId: null,
shopLevel: null, shopLevel: null,
shopPilotId: null, shopPilotId: null,
shopOpen: false,
fromDeal: true, fromDeal: true,
createdAt: T, createdAt: T,
lastArrivalAt: T + 200_000, lastArrivalAt: T + 200_000,
@ -98,6 +103,8 @@ export function fullSave(): SaveDocument {
shopUpgradeId: "cargo-bay", shopUpgradeId: "cargo-bay",
shopLevel: 1, shopLevel: 1,
shopPilotId: null, shopPilotId: null,
// In the shop: catch-up logs the end (CR-025).
shopOpen: true,
fromDeal: true, fromDeal: true,
createdAt: T, createdAt: T,
lastArrivalAt: T, lastArrivalAt: T,
@ -224,5 +231,41 @@ export function fullSave(): SaveDocument {
], ],
claimedGoals: [{ goalId: "first-own-delivery", claimedAt: T + 300_000 }], claimedGoals: [{ goalId: "first-own-delivery", claimedAt: T + 300_000 }],
stats: { contractDeliveries: 1, priorityDeliveries: 0 }, stats: { contractDeliveries: 1, priorityDeliveries: 0 },
// d0 as the 9 → 10 migration turns it into entries: the flight and its top-up.
logbook: [
{
id: "d0:flight",
at: T + 200_000,
pilotId: "avatar",
shipId: "s1",
kind: "flight",
dispatchId: "d0",
deadhead: false,
tutorial: false,
independent: false,
originId: "earth",
destinationId: "moon",
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
payout: 1340,
pilotPay: 0,
fuelCost: 160,
loanRepayment: 340,
xp: 10,
scriptedId: null,
},
{
id: "d0:top-up",
at: T + 200_000,
pilotId: "avatar",
shipId: "s1",
kind: "refuel",
locationId: "moon",
boughtFu: 7.6,
cost: 84,
source: "top-up",
},
],
}; };
} }

View File

@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture";
describe("save document (Step 14.2)", () => { describe("save document (Step 14.2)", () => {
it("validates a fully populated save", () => { it("validates a fully populated save", () => {
expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true);
expect(CURRENT_SAVE_VERSION).toBe(9); expect(CURRENT_SAVE_VERSION).toBe(10);
}); });
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(9); expect(save.saveVersion).toBe(10);
// 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(9); expect(save.saveVersion).toBe(10);
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(9); expect(result.save.saveVersion).toBe(10);
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-021 §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(9); expect(result.save.saveVersion).toBe(10);
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");
@ -191,7 +191,7 @@ describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => {
delete old.player.welcomeSeen; delete old.player.welcomeSeen;
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(9); expect(result.save.saveVersion).toBe(10);
expect(result.save.player.welcomeSeen).toBe(true); expect(result.save.player.welcomeSeen).toBe(true);
expect(result.save).toEqual(before); 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; for (const ship of old.ships) delete ship.shopPilotId;
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(9); expect(result.save.saveVersion).toBe(10);
// Including the ship still in the shop: its upgrade finishes without the rest. // 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.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null));
expect(result.save).toEqual(before); expect(result.save).toEqual(before);
@ -225,9 +225,43 @@ describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => {
delete old.company.sleepAfterHours; delete old.company.sleepAfterHours;
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(9); expect(result.save.saveVersion).toBe(10);
expect(result.save.company.lastManualDecisionAt).toBeNull(); expect(result.save.company.lastManualDecisionAt).toBeNull();
expect(result.save.company.sleepAfterHours).toBe(24); expect(result.save.company.sleepAfterHours).toBe(24);
expect(result.save).toEqual(before); expect(result.save).toEqual(before);
}); });
}); });
describe("save migration 9 → 10: the logbook (CR-025 §4)", () => {
it("turns resolved dispatches into entries, leaves the shop open for catch-up, and changes nothing else", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
player: Record<string, unknown>;
ships: Record<string, unknown>[];
};
old.saveVersion = 9;
delete old.logbook;
for (const key of ["awaySince", "lastAbsence", "readyGoalsLogged"]) delete old.player[key];
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);
// 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]);
expect(result.save).toEqual(before);
});
it("starts the absence at the last summary the player dismissed", () => {
const old = JSON.parse(JSON.stringify(fullSave())) as Record<string, unknown> & {
player: Record<string, unknown>;
dispatches: Record<string, unknown>[];
};
old.saveVersion = 9;
delete old.logbook;
old.dispatches[1]!.acknowledgedAt = 1_234;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.player.awaySince).toBe(1_234);
});
});