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>
This commit is contained in:
fireshaper 2026-10-16 22:17:55 -04:00
parent 7dfffc9b14
commit fdbc46e165
21 changed files with 734 additions and 23 deletions

View File

@ -1855,3 +1855,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **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. - **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. - **"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

@ -535,7 +535,7 @@ Spec: `memory-bank/change-requests/CR-024-fleet-sleep.md` (received as "CR-022
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. 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.
- [ ] Step 25.1 — Engine: logbook entries and retention - [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.2 — Sim: summary builder
- [ ] Step 25.3 — Client: highlights pop-up - [ ] Step 25.3 — Client: highlights pop-up
- [ ] Step 25.4 — Client: Logbook screen - [ ] Step 25.4 — Client: Logbook screen

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