Step 28.1: engine: seen tracking
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-18 14:17:40 -04:00
parent d17332f3ac
commit 2826223f74
20 changed files with 170 additions and 22 deletions

View File

@ -2046,3 +2046,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Received as "CR-026 … Phase 26"; stored as CR-028, Phase 28**, decisions CR-026.D1–D4 = plan §0.5.160–0.5.163, with every reference mapped in the CR's numbering note. - **Received as "CR-026 … Phase 26"; stored as CR-028, Phase 28**, decisions CR-026.D1–D4 = plan §0.5.160–0.5.163, with every reference mapped in the CR's numbering note.
- **"The HQ banner" is the Bridge's "Fleet asleep" panel** (CR-024), the panel that already shows while the fleet sleeps. - **"The HQ banner" is the Bridge's "Fleet asleep" panel** (CR-024), the panel that already shows while the fleet sleeps.
## Step 28.1
- **`seen` is on the entry itself**, not a list of seen ids: it's pruned with the entry after 7 days, and catch-up writes entries with the same ids however it's split, so a replayed entry can't come back unseen.
- **Marking seen takes ids** (what was toasted or shown), ignores unknown ones, and returns how many were newly seen; it's not a manual decision, so a toast doesn't wake the fleet.
- **The away-period fields (`awaySince`, `lastAbsence`) stay for now**: Step 28.4 decides what the pop-up uses.

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: 12"); expect(text).toContain("Save version: 13");
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

@ -66,6 +66,7 @@ const flight = (pilotId: string, payout: number, over: Partial<LogEntry> = {}):
at: FROM + 60_000 + n, at: FROM + 60_000 + n,
pilotId, pilotId,
shipId: `s${pilotId.slice(1)}`, shipId: `s${pilotId.slice(1)}`,
seen: false,
kind: "flight", kind: "flight",
dispatchId: `d${n}`, dispatchId: `d${n}`,
deadhead: false, deadhead: false,
@ -162,6 +163,7 @@ describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => {
at: FROM + 5, at: FROM + 5,
pilotId: "p3", pilotId: "p3",
shipId: "s3", shipId: "s3",
seen: false,
kind: "discovery", kind: "discovery",
locationId: "mars", locationId: "mars",
}, },
@ -170,6 +172,7 @@ describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => {
at: FROM + 6, at: FROM + 6,
pilotId: "p0", pilotId: "p0",
shipId: "s0", shipId: "s0",
seen: false,
kind: "level-up", kind: "level-up",
level: 4, level: 4,
skillPoints: 1, skillPoints: 1,

View File

@ -129,7 +129,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): PlayerS
export function logbookFrom(state: PlayerState): LogbookResponse { export function logbookFrom(state: PlayerState): LogbookResponse {
const entries: LogEntry[] = []; const entries: LogEntry[] = [];
for (const s of state.summaries) { for (const s of state.summaries) {
const who = { at: s.resolvedAt, pilotId: s.pilotId, shipId: s.shipId }; const who = { at: s.resolvedAt, pilotId: s.pilotId, shipId: s.shipId, seen: false };
entries.push({ entries.push({
id: `${s.dispatchId}:flight`, id: `${s.dispatchId}:flight`,
...who, ...who,
@ -169,6 +169,7 @@ export function logbookFrom(state: PlayerState): LogbookResponse {
at: ship.shop.until, at: ship.shop.until,
pilotId: ship.pilotId, pilotId: ship.pilotId,
shipId: ship.id, shipId: ship.id,
seen: false,
kind: "upgrade", kind: "upgrade",
upgradeId: ship.shop.upgradeId, upgradeId: ship.shop.upgradeId,
level: ship.shop.level, level: ship.shop.level,
@ -181,6 +182,7 @@ export function logbookFrom(state: PlayerState): LogbookResponse {
at: Date.now(), at: Date.now(),
pilotId: null, pilotId: null,
shipId: null, shipId: null,
seen: false,
kind: "goal-ready", kind: "goal-ready",
goalId: goal.id, goalId: goal.id,
}); });

View File

@ -51,6 +51,7 @@ const event = (over: Partial<Extract<LogEntry, { kind: "event" }>>): LogEntry =>
at: NOW - 60_000, at: NOW - 60_000,
pilotId: "p1", pilotId: "p1",
shipId: "s1", shipId: "s1",
seen: false,
kind: "event", kind: "event",
dispatchId: "d1", dispatchId: "d1",
eventId: "debris-field", eventId: "debris-field",
@ -182,7 +183,16 @@ describe("hull condition and event results on screen (Step 26.7, decisions 0.5.1
).toBe("Solar tailwind — Rosa: 1.5 h sooner"); ).toBe("Solar tailwind — Rosa: 1.5 h sooner");
const book = pilotLogbook(s, [ const book = pilotLogbook(s, [
event({ eventId: "grateful-client", optionId: "tip", good: true, delayMs: 0, bonus: 800 }), event({ eventId: "grateful-client", optionId: "tip", good: true, delayMs: 0, bonus: 800 }),
{ id: "r1", at: NOW, pilotId: "p1", shipId: "s1", kind: "repair", fromPct: 60, cost: 2_000 }, {
id: "r1",
at: NOW,
pilotId: "p1",
shipId: "s1",
seen: false,
kind: "repair",
fromPct: 60,
cost: 2_000,
},
]); ]);
const lines = book.pilots[0]!.entries; const lines = book.pilots[0]!.entries;
expect(lines.map((e) => e.kind)).toEqual(["repair", "event"]); expect(lines.map((e) => e.kind)).toEqual(["repair", "event"]);
@ -195,6 +205,7 @@ describe("hull condition and event results on screen (Step 26.7, decisions 0.5.1
at: NOW - 30_000 + i, at: NOW - 30_000 + i,
pilotId: "p1", pilotId: "p1",
shipId: "s1", shipId: "s1",
seen: false,
kind: "flight", kind: "flight",
dispatchId: `d${i}`, dispatchId: `d${i}`,
deadhead: false, deadhead: false,
@ -222,6 +233,7 @@ describe("hull condition and event results on screen (Step 26.7, decisions 0.5.1
at: NOW - 50_000, at: NOW - 50_000,
pilotId: null, pilotId: null,
shipId: null, shipId: null,
seen: false,
kind: "goal-ready", kind: "goal-ready",
goalId: "first-own-delivery", goalId: "first-own-delivery",
}, },
@ -232,6 +244,7 @@ describe("hull condition and event results on screen (Step 26.7, decisions 0.5.1
at: NOW - 40_000, at: NOW - 40_000,
pilotId: "p1", pilotId: "p1",
shipId: "s1", shipId: "s1",
seen: false,
kind: "level-up", kind: "level-up",
level: 2, level: 2,
skillPoints: 1, skillPoints: 1,

View File

@ -41,6 +41,7 @@ const job = (pilotId: string, hoursAgo: number, over: Partial<LogEntry> = {}): L
at: at(hoursAgo), at: at(hoursAgo),
pilotId, pilotId,
shipId: `s-${pilotId}`, shipId: `s-${pilotId}`,
seen: false,
kind: "flight", kind: "flight",
dispatchId: `d${n}`, dispatchId: `d${n}`,
deadhead: false, deadhead: false,
@ -72,12 +73,21 @@ const entries: LogEntry[] = [
xp: 0, xp: 0,
fuelCost: 40, fuelCost: 40,
}), }),
{ id: "r1", at: at(2), pilotId: "ada", shipId: "s-ada", kind: "rest", until: restUntil }, {
id: "r1",
at: at(2),
pilotId: "ada",
shipId: "s-ada",
seen: false,
kind: "rest",
until: restUntil,
},
{ {
id: "f1", id: "f1",
at: at(3), at: at(3),
pilotId: "ada", pilotId: "ada",
shipId: "s-ada", shipId: "s-ada",
seen: false,
kind: "refuel", kind: "refuel",
locationId: "earth", locationId: "earth",
boughtFu: 12, boughtFu: 12,
@ -89,16 +99,26 @@ const entries: LogEntry[] = [
at: at(4), at: at(4),
pilotId: "bo", pilotId: "bo",
shipId: "s-bo", shipId: "s-bo",
seen: false,
kind: "upgrade", kind: "upgrade",
upgradeId: "engines", upgradeId: "engines",
level: 2, level: 2,
}, },
{ id: "x1", at: at(4), pilotId: "bo", shipId: "s-bo", kind: "discovery", locationId: "mars" }, {
id: "x1",
at: at(4),
pilotId: "bo",
shipId: "s-bo",
seen: false,
kind: "discovery",
locationId: "mars",
},
{ {
id: "l1", id: "l1",
at: at(4), at: at(4),
pilotId: "bo", pilotId: "bo",
shipId: "s-bo", shipId: "s-bo",
seen: false,
kind: "level-up", kind: "level-up",
level: 3, level: 3,
skillPoints: 1, skillPoints: 1,
@ -113,6 +133,7 @@ const entries: LogEntry[] = [
at: at(1), at: at(1),
pilotId: null, pilotId: null,
shipId: null, shipId: null,
seen: false,
kind: "goal-ready", kind: "goal-ready",
goalId: "first-own-delivery", goalId: "first-own-delivery",
}, },

View File

@ -232,6 +232,7 @@ describe("service in the Logbook and the away summary (Step 27.6)", () => {
at: 1_000, at: 1_000,
pilotId: "p1", pilotId: "p1",
shipId: "s1", shipId: "s1",
seen: false,
kind: "service", kind: "service",
phase, phase,
hqId: "earth", hqId: "earth",

View File

@ -66,6 +66,7 @@ The `routes` catalog is the list of engine commands (CR-014). Each entry has `re
| `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes | | `acceptDeal` | `POST /api/deal/accept` | — | `PlayerState` | yes |
| `setSleepThreshold` | `POST /api/fleet/sleep-after` | `hours` (one of `fleetSleep.optionsHours`, else `validation-error`) | `PlayerState` | yes; a manual decision (CR-024) | | `setSleepThreshold` | `POST /api/fleet/sleep-after` | `hours` (one of `fleetSleep.optionsHours`, else `validation-error`) | `PlayerState` | yes; a manual decision (CR-024) |
| `getLogbook` | `GET /api/logbook` | — | `{ entries, absence: { from, to }, lastAbsence, problems }` (CR-025: the last 7 days, the absence since the summary was last closed, the one before, and what needs the player now) | yes | | `getLogbook` | `GET /api/logbook` | — | `{ entries, absence: { from, to }, lastAbsence, problems }` (CR-025: the last 7 days, the absence since the summary was last closed, the one before, and what needs the player now) | yes |
| `markLogbookSeen` | `POST /api/logbook/seen` | `entryIds` | `{ marked }` (entries newly seen) | yes; not a manual decision; unknown ids ignored (CR-028) |
| `answerRadioCall` | `POST /api/dispatches/:dispatchId/radio` | `dispatchId`, `optionId` | `{ radioCalls }` (still waiting) | yes; a manual decision; `radio-call-not-found` before it fires, `radio-call-closed` after its window or once applied, `radio-option-unavailable` (CR-026) | | `answerRadioCall` | `POST /api/dispatches/:dispatchId/radio` | `dispatchId`, `optionId` | `{ radioCalls }` (still waiting) | yes; a manual decision; `radio-call-not-found` before it fires, `radio-call-closed` after its window or once applied, `radio-option-unavailable` (CR-026) |
| `repairShip` | `POST /api/ships/:shipId/repair` | `shipId` | `{ ship }` (in the shop) | yes; a manual decision; at an HQ, below 100% (`hull-intact`), charged now (CR-026) | | `repairShip` | `POST /api/ships/:shipId/repair` | `shipId` | `{ ship }` (in the shop) | yes; a manual decision; at an HQ, below 100% (`hull-intact`), charged now (CR-026) |
| `bookService` | `POST /api/ships/:shipId/service` | `shipId`, `repair`, `upgradeId` (or null), `hqId` (optional: the nearest reachable HQ) | `{ ship }` (with its `booking`) | yes; a manual decision; replaces an unstarted booking; starts at once if idle at the HQ; `nothing-to-service`, `hull-intact`, `upgrade-maxed`, `unknown-hq`, `no-hq-reachable`, `service-started` (CR-027) | | `bookService` | `POST /api/ships/:shipId/service` | `shipId`, `repair`, `upgradeId` (or null), `hqId` (optional: the nearest reachable HQ) | `{ ship }` (with its `booking`) | yes; a manual decision; replaces an unstarted booking; starts at once if idle at the HQ; `nothing-to-service`, `hull-intact`, `upgrade-maxed`, `unknown-hq`, `no-hq-reachable`, `service-started` (CR-027) |
@ -89,7 +90,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 12; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023; 8 → 9 added company `lastManualDecisionAt` (null until the engine's first catch-up sets it) and `sleepAfterHours`, CR-024; 9 → 10 added the `logbook`, player `awaySince`, `lastAbsence` and `readyGoalsLogged`, and ship `shopOpen`, turning resolved dispatches into entries, CR-025; 10 → 11 added ship `hullConditionPct` (100), `quietTrips` and `shopRepair`, dispatch `event` and `eventApplied` (null), company `firstEventDone` and `tripsSinceDeal`, CR-026; 11 → 12 added ship `booking` (null) and `autoRepair` (on, 40%), CR-027), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan, the Dispatch Office and spending floor, and since CR-024 the last manual decision and the sleep threshold), `ships` (with `upgrades` as a level map, shop fields including `shopPilotId` (the pilot waiting out the shop as rest, CR-023), and `fuelFu`, `fuelAvgPrice`, `refuelPolicy` since CR-015), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`, `logbook` (CR-025: entries of kind `flight` (deliveries and deadheads, with payout, pilot pay, fuel used, loan repayment and XP), `refuel` (top-up, bought, pilot or emergency), `rest`, `upgrade`, `discovery`, `level-up` and `goal-ready`, each with time, pilot and ship; ids made from what they record; kept `logbookRetentionRealDays` (7) and pruned on save). Written by `packages/engine/src/game/logbook.ts` helpers from arrivals, refuelling, rest, shop ends (a catch-up event for every `shopOpen` ship), and goals becoming ready (after every catch-up event and command). It mirrors the old server's database tables without player ids; the stored contract, dispatch record and resolution are type-checked against the sim types. `migrateSave(raw)` steps an older save through `SAVE_MIGRATIONS` (`applyMigrations`) and validates it, returning a readable error instead of throwing. One document per company on the device, validated on every load and save: `saveVersion` (currently 13; 1 → 2 added fuel tanks, CR-015; 2 → 3 put every ship on Manual refueling, CR-018; 3 → 4 dropped stored boards, CR-019; 4 → 5 added pilot `mode` and `orders` and the company's `dispatchOffice` and `npcSpendingFloor`, CR-021; 5 → 6 added pilot `nextDecisionAt` and `emptyPeriods` and dispatch `independent`, CR-021; 6 → 7 added `player.welcomeSeen`, true for existing companies, CR-022; 7 → 8 added ship `shopPilotId`, null for existing ships, CR-023; 8 → 9 added company `lastManualDecisionAt` (null until the engine's first catch-up sets it) and `sleepAfterHours`, CR-024; 9 → 10 added the `logbook`, player `awaySince`, `lastAbsence` and `readyGoalsLogged`, and ship `shopOpen`, turning resolved dispatches into entries, CR-025; 10 → 11 added ship `hullConditionPct` (100), `quietTrips` and `shopRepair`, dispatch `event` and `eventApplied` (null), company `firstEventDone` and `tripsSinceDeal`, CR-026; 11 → 12 added ship `booking` (null) and `autoRepair` (on, 40%), CR-027; 12 → 13 added logbook entries' `seen`, true for existing entries, CR-028), `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

@ -581,7 +581,7 @@ Spec: `memory-bank/change-requests/CR-027-service-bookings.md` (received as "CR-
Spec: `memory-bank/change-requests/CR-028-live-toasts.md` (received as "CR-026 … Phase 26"). New decisions recorded as plan §0.5.160–0.5.163. Spec: `memory-bank/change-requests/CR-028-live-toasts.md` (received as "CR-026 … Phase 26"). New decisions recorded as plan §0.5.160–0.5.163.
- [ ] Step 28.1 — Engine: seen tracking - [x] **Step 28.1 — Engine: seen tracking.** Every logbook entry has a saved `seen` flag (schema, and the sim's `LogbookEntry`); `logEntry` writes new entries unseen. Command `markLogbookSeen` (`POST /api/logbook/seen`, `{ entryIds }` → `{ marked }`), not a manual decision, so it doesn't wake the fleet. Save version 13: the 12 → 13 migration marks every existing entry seen. Tests: `packages/engine/test/logbook.test.ts` (new entries unseen; marking one, unknown ids ignored, marking again changes nothing, the fleet not woken, kept through export and import); `packages/schema/test/save.test.ts` (12 → 13); the route catalog.
- [ ] Step 28.2 — Client: presence tracking - [ ] Step 28.2 — Client: presence tracking
- [ ] Step 28.3 — Client: toasts - [ ] Step 28.3 — Client: toasts
- [ ] Step 28.4 — Client: pop-up only after being away - [ ] Step 28.4 — Client: pop-up only after being away

View File

@ -31,6 +31,7 @@ import {
} from "./state"; } from "./state";
import { buyDispatchOffice, setPilotMode, setPilotOrders, setSpendingFloor } from "./independent"; import { buyDispatchOffice, setPilotMode, setPilotOrders, setSpendingFloor } from "./independent";
import { autoRepairAfterDamage, bookService, cancelBooking, setAutoRepair } from "./service"; import { autoRepairAfterDamage, bookService, cancelBooking, setAutoRepair } from "./service";
import { markSeen } from "./logbook";
/** Every command that works on an existing company's save (the server's endpoints). */ /** Every command that works on an existing company's save (the server's endpoints). */
export function commandTable(): CommandTable { export function commandTable(): CommandTable {
@ -99,6 +100,8 @@ export function commandTable(): CommandTable {
repairShip(save, input, now); repairShip(save, input, now);
return { ship: toApiShip(save, requireShip(save, input.shipId), now) }; return { ship: toApiShip(save, requireShip(save, input.shipId), now) };
}, },
// Toasted or shown in the pop-up (decision 0.5.163).
markLogbookSeen: ({ save }, input) => ({ marked: markSeen(save, input.entryIds) }),
// The logbook and the absence (decisions 0.5.140, 0.5.141). // The logbook and the absence (decisions 0.5.140, 0.5.141).
getLogbook: ({ save, now }) => ({ getLogbook: ({ save, now }) => ({
entries: save.logbook, entries: save.logbook,

View File

@ -5,7 +5,11 @@ import { goalStates } from "./state";
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
type NewEntry = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, "id"> : never) : never; type NewEntry = LogEntry extends infer E
? E extends LogEntry
? Omit<E, "id" | "seen">
: never
: never;
/** /**
* Adds an entry to the logbook (decision 0.5.141). Its id is `base`, made from * Adds an entry to the logbook (decision 0.5.141). Its id is `base`, made from
@ -15,7 +19,21 @@ type NewEntry = LogEntry extends infer E ? (E extends LogEntry ? Omit<E, "id"> :
export function logEntry(save: SaveDocument, base: string, entry: NewEntry): void { export function logEntry(save: SaveDocument, base: string, entry: NewEntry): void {
let id = base; let id = base;
for (let n = 2; save.logbook.some((e) => e.id === id); n++) id = `${base}#${n}`; for (let n = 2; save.logbook.some((e) => e.id === id); n++) id = `${base}#${n}`;
save.logbook.push({ id, ...entry } as LogEntry); // Unseen until the player is shown it (decision 0.5.163).
save.logbook.push({ id, ...entry, seen: false } as LogEntry);
}
/** Marks entries seen (decision 0.5.163); returns how many weren't before. */
export function markSeen(save: SaveDocument, entryIds: readonly string[]): number {
const ids = new Set(entryIds);
let marked = 0;
for (const entry of save.logbook) {
if (ids.has(entry.id) && !entry.seen) {
entry.seen = true;
marked++;
}
}
return marked;
} }
/** The pilot assigned to a ship, if any: whose entry a ship's event is. */ /** The pilot assigned to a ship, if any: whose entry a ship's event is. */

View File

@ -322,3 +322,35 @@ describe("getLogbook and what needs the player (Step 25.3, decision 0.5.140)", (
expect((await test.app.call("getLogbook", {})).problems).toEqual([]); expect((await test.app.call("getLogbook", {})).problems).toEqual([]);
}); });
}); });
describe("seen entries (Step 28.1, decision 0.5.163)", () => {
it("starts new entries unseen, marks them seen, and keeps that through a reload", async () => {
const test = newTestApp();
const o = await owner(test);
const { dispatch, save } = await deliver(test, o.avatarId, o.shipId);
const flightId = `${dispatch.id}:flight`;
expect(save.logbook.length).toBeGreaterThan(0);
expect(save.logbook.every((e) => !e.seen)).toBe(true);
const decidedAt = save.company.lastManualDecisionAt;
test.clock.now += 60_000;
expect(
await test.app.call("markLogbookSeen", { entryIds: [flightId, "no-such-entry"] }),
).toEqual({ marked: 1 });
// Again: nothing new to mark.
expect(await test.app.call("markLogbookSeen", { entryIds: [flightId] })).toEqual({ marked: 0 });
const after = await readSave(test.app);
expect(after.logbook.find((e) => e.id === flightId)!.seen).toBe(true);
expect(after.logbook.filter((e) => e.id !== flightId).every((e) => !e.seen)).toBe(true);
// Seeing a toast isn't a decision: it doesn't wake the fleet (CR-024).
expect(after.company.lastManualDecisionAt).toBe(decidedAt);
// A reload (the save exported and opened again) keeps it.
const { code } = await test.app.call("exportSave", {});
const reloaded = newTestApp();
reloaded.clock.now = test.clock.now;
await reloaded.app.call("importSave", { code });
const book = await reloaded.app.call("getLogbook", {});
expect(book.entries.find((e) => e.id === flightId)!.seen).toBe(true);
});
});

View File

@ -15,6 +15,8 @@ const base = {
/** Whose entry it is: the flight's pilot, the ship's pilot, or none. */ /** Whose entry it is: the flight's pilot, the ship's pilot, or none. */
pilotId: idSchema.nullable(), pilotId: idSchema.nullable(),
shipId: idSchema.nullable(), shipId: idSchema.nullable(),
/** Shown to the player, as a toast or in the pop-up (decision 0.5.163). */
seen: z.boolean(),
}; };
export const logEntrySchema = z.discriminatedUnion("kind", [ export const logEntrySchema = z.discriminatedUnion("kind", [

View File

@ -87,6 +87,14 @@ export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pi
/** Answer a radio call on a trip (decision 0.5.144). */ /** Answer a radio call on a trip (decision 0.5.144). */
export const answerRadioCallRequestSchema = z.object({ dispatchId: idSchema, optionId: idSchema }); export const answerRadioCallRequestSchema = z.object({ dispatchId: idSchema, optionId: idSchema });
export const radioCallsResponseSchema = z.object({ radioCalls: z.array(radioCallSchema) }); export const radioCallsResponseSchema = z.object({ radioCalls: z.array(radioCallSchema) });
/** Marks logbook entries seen (decision 0.5.163): toasted, or shown in the pop-up. */
export const markLogbookSeenRequestSchema = z.object({
entryIds: z.array(z.string().min(1)).max(1000),
});
export const markLogbookSeenResponseSchema = z.object({
/** Entries newly marked seen. */
marked: z.number().int().nonnegative(),
});
/** Book (or change) service for a ship (decision 0.5.153); no HQ means the nearest reachable. */ /** Book (or change) service for a ship (decision 0.5.153); no HQ means the nearest reachable. */
export const bookServiceRequestSchema = z.object({ export const bookServiceRequestSchema = z.object({
shipId: idSchema, shipId: idSchema,

View File

@ -6,6 +6,8 @@ import {
answerRadioCallRequestSchema, answerRadioCallRequestSchema,
autoRepairRequestSchema, autoRepairRequestSchema,
bookServiceRequestSchema, bookServiceRequestSchema,
markLogbookSeenRequestSchema,
markLogbookSeenResponseSchema,
radioCallsResponseSchema, radioCallsResponseSchema,
repairShipRequestSchema, repairShipRequestSchema,
acknowledgeSummariesResponseSchema, acknowledgeSummariesResponseSchema,
@ -264,6 +266,15 @@ export const routes = {
auth: true, auth: true,
manualDecision: true, manualDecision: true,
}, },
markLogbookSeen: {
method: "POST",
path: "/api/logbook/seen",
request: markLogbookSeenRequestSchema,
response: markLogbookSeenResponseSchema,
auth: true,
// Seeing a toast or the pop-up isn't a decision (CR-028 Step 28.1).
manualDecision: false,
},
bookService: { bookService: {
method: "POST", method: "POST",
path: "/api/ships/:shipId/service", path: "/api/ships/:shipId/service",

View File

@ -207,7 +207,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 = 12; export const CURRENT_SAVE_VERSION = 13;
export const saveDocumentSchema = z.object({ export const saveDocumentSchema = z.object({
saveVersion: z.literal(CURRENT_SAVE_VERSION), saveVersion: z.literal(CURRENT_SAVE_VERSION),
@ -553,6 +553,11 @@ function serviceBookings(save: Doc): Doc {
}; };
} }
/** Version 12 → 13 (CR-028 Step 28.1): every logbook entry already there counts as seen. */
function seenEntries(save: Doc): Doc {
return { ...save, logbook: records(save.logbook).map((entry) => ({ ...entry, seen: true })) };
}
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 },
@ -565,6 +570,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [
{ from: 9, migrate: logbook }, { from: 9, migrate: logbook },
{ from: 10, migrate: tripEvents }, { from: 10, migrate: tripEvents },
{ from: 11, migrate: serviceBookings }, { from: 11, migrate: serviceBookings },
{ from: 12, migrate: seenEntries },
]; ];
export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string };

View File

@ -14,6 +14,7 @@ describe("route catalog", () => {
"getContractBoard", "getContractBoard",
"getTutorialBoard", "getTutorialBoard",
"getLogbook", "getLogbook",
"markLogbookSeen",
"answerRadioCall", "answerRadioCall",
"repairShip", "repairShip",
"bookService", "bookService",

View File

@ -45,7 +45,7 @@ export function fullSave(): SaveDocument {
seed: 42, seed: 42,
}); });
return { return {
saveVersion: 12, saveVersion: 13,
saveId: "save-1", saveId: "save-1",
lastTimeSeen: T + 3_600_000, lastTimeSeen: T + 3_600_000,
player: { player: {
@ -271,6 +271,7 @@ export function fullSave(): SaveDocument {
loanRepayment: 340, loanRepayment: 340,
xp: 10, xp: 10,
scriptedId: null, scriptedId: null,
seen: true,
}, },
{ {
id: "d0:top-up", id: "d0:top-up",
@ -282,6 +283,7 @@ export function fullSave(): SaveDocument {
boughtFu: 7.6, boughtFu: 7.6,
cost: 84, cost: 84,
source: "top-up", source: "top-up",
seen: true,
}, },
], ],
}; };

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(12); expect(CURRENT_SAVE_VERSION).toBe(13);
}); });
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(12); expect(save.saveVersion).toBe(CURRENT_SAVE_VERSION);
// 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(12); expect(save.saveVersion).toBe(CURRENT_SAVE_VERSION);
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(12); expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
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(12); expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
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(12); expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
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(12); expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
// 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,7 +225,7 @@ 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(12); expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
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);
@ -245,7 +245,7 @@ describe("save migration 9 → 10: the logbook (CR-025 §4)", () => {
for (const ship of old.ships) delete ship.shopOpen; for (const ship of old.ships) delete ship.shopOpen;
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(12); expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
// The resolved d0: its flight (payout = credits + pilot pay + loan) and its top-up. // 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.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.ships.map((s) => s.shopOpen)).toEqual([false, true]);
@ -285,7 +285,7 @@ describe("save migration 10 → 11: events and hulls (CR-026 §6)", () => {
} }
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(12); expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]); expect(result.save.ships.map((s) => s.hullConditionPct)).toEqual([100, 100]);
expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]); expect(result.save.dispatches.map((d) => d.event)).toEqual([null, null]);
// The deal is made, so the grace trips are behind it. // The deal is made, so the grace trips are behind it.
@ -307,7 +307,7 @@ describe("save migration 11 → 12: service bookings (CR-027 §5)", () => {
} }
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(12); expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION);
expect(result.save.ships.map((s) => [s.booking, s.autoRepair])).toEqual([ expect(result.save.ships.map((s) => [s.booking, s.autoRepair])).toEqual([
[null, { on: true, thresholdPct: 40 }], [null, { on: true, thresholdPct: 40 }],
[null, { on: true, thresholdPct: 40 }], [null, { on: true, thresholdPct: 40 }],
@ -315,3 +315,19 @@ describe("save migration 11 → 12: service bookings (CR-027 §5)", () => {
expect(result.save).toEqual(before); expect(result.save).toEqual(before);
}); });
}); });
describe("save migration 12 → 13: seen entries (CR-028 Step 28.1)", () => {
it("marks every logbook entry already there as seen, and changes nothing else", () => {
const before = fullSave();
const old = JSON.parse(JSON.stringify(before)) as Record<string, unknown> & {
logbook: Record<string, unknown>[];
};
old.saveVersion = 12;
for (const entry of old.logbook) delete entry.seen;
const result = migrateSave(old);
if (!result.ok) throw new Error(result.error);
expect(result.save.saveVersion).toBe(13);
expect(result.save.logbook.map((e) => e.seen)).toEqual([true, true]);
expect(result.save).toEqual(before);
});
});

View File

@ -9,6 +9,8 @@ interface EntryBase {
at: RealTime; at: RealTime;
pilotId: string | null; pilotId: string | null;
shipId: string | null; shipId: string | null;
/** Shown to the player, as a toast or in the pop-up (decision 0.5.163); new entries start unseen. */
seen: boolean;
} }
export type LogbookEntry = EntryBase & export type LogbookEntry = EntryBase &