diff --git a/DECISIONS.md b/DECISIONS.md index c87d9d2..330f0d1 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -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. - **"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. diff --git a/apps/web/test/about.test.tsx b/apps/web/test/about.test.tsx index 5006f4f..4a94933 100644 --- a/apps/web/test/about.test.tsx +++ b/apps/web/test/about.test.tsx @@ -23,7 +23,7 @@ describe("about and debug info (Step 10K.10)", () => { expect(text).toContain("Build: test"); // CR-014: the save, its format version and where it is stored; no player id. expect(text).toContain("Save: player-1"); - expect(text).toContain("Save version: 12"); + expect(text).toContain("Save version: 13"); expect(text).toContain("Storage: unknown"); expect(text).not.toContain("Player:"); expect(text).toMatch(/Game time: \d{4}-\d{2}-\d{2}T/); diff --git a/apps/web/test/away-summary.test.tsx b/apps/web/test/away-summary.test.tsx index ffae1e5..0d7a80e 100644 --- a/apps/web/test/away-summary.test.tsx +++ b/apps/web/test/away-summary.test.tsx @@ -66,6 +66,7 @@ const flight = (pilotId: string, payout: number, over: Partial = {}): at: FROM + 60_000 + n, pilotId, shipId: `s${pilotId.slice(1)}`, + seen: false, kind: "flight", dispatchId: `d${n}`, deadhead: false, @@ -162,6 +163,7 @@ describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => { at: FROM + 5, pilotId: "p3", shipId: "s3", + seen: false, kind: "discovery", locationId: "mars", }, @@ -170,6 +172,7 @@ describe("the highlights pop-up (Step 25.3, decision 0.5.140)", () => { at: FROM + 6, pilotId: "p0", shipId: "s0", + seen: false, kind: "level-up", level: 4, skillPoints: 1, diff --git a/apps/web/test/fixtures.ts b/apps/web/test/fixtures.ts index 622926f..fac28ae 100644 --- a/apps/web/test/fixtures.ts +++ b/apps/web/test/fixtures.ts @@ -129,7 +129,7 @@ export function samplePlayerState(overrides: Partial = {}): PlayerS export function logbookFrom(state: PlayerState): LogbookResponse { const entries: LogEntry[] = []; 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({ id: `${s.dispatchId}:flight`, ...who, @@ -169,6 +169,7 @@ export function logbookFrom(state: PlayerState): LogbookResponse { at: ship.shop.until, pilotId: ship.pilotId, shipId: ship.id, + seen: false, kind: "upgrade", upgradeId: ship.shop.upgradeId, level: ship.shop.level, @@ -181,6 +182,7 @@ export function logbookFrom(state: PlayerState): LogbookResponse { at: Date.now(), pilotId: null, shipId: null, + seen: false, kind: "goal-ready", goalId: goal.id, }); diff --git a/apps/web/test/hull-events.test.tsx b/apps/web/test/hull-events.test.tsx index 7151ab6..21745be 100644 --- a/apps/web/test/hull-events.test.tsx +++ b/apps/web/test/hull-events.test.tsx @@ -51,6 +51,7 @@ const event = (over: Partial>): LogEntry => at: NOW - 60_000, pilotId: "p1", shipId: "s1", + seen: false, kind: "event", dispatchId: "d1", 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"); const book = pilotLogbook(s, [ 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; 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, pilotId: "p1", shipId: "s1", + seen: false, kind: "flight", dispatchId: `d${i}`, 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, pilotId: null, shipId: null, + seen: false, kind: "goal-ready", 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, pilotId: "p1", shipId: "s1", + seen: false, kind: "level-up", level: 2, skillPoints: 1, diff --git a/apps/web/test/logbook-screen.test.tsx b/apps/web/test/logbook-screen.test.tsx index f7ce87f..090478d 100644 --- a/apps/web/test/logbook-screen.test.tsx +++ b/apps/web/test/logbook-screen.test.tsx @@ -41,6 +41,7 @@ const job = (pilotId: string, hoursAgo: number, over: Partial = {}): L at: at(hoursAgo), pilotId, shipId: `s-${pilotId}`, + seen: false, kind: "flight", dispatchId: `d${n}`, deadhead: false, @@ -72,12 +73,21 @@ const entries: LogEntry[] = [ xp: 0, 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", at: at(3), pilotId: "ada", shipId: "s-ada", + seen: false, kind: "refuel", locationId: "earth", boughtFu: 12, @@ -89,16 +99,26 @@ const entries: LogEntry[] = [ at: at(4), pilotId: "bo", shipId: "s-bo", + seen: false, kind: "upgrade", upgradeId: "engines", 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", at: at(4), pilotId: "bo", shipId: "s-bo", + seen: false, kind: "level-up", level: 3, skillPoints: 1, @@ -113,6 +133,7 @@ const entries: LogEntry[] = [ at: at(1), pilotId: null, shipId: null, + seen: false, kind: "goal-ready", goalId: "first-own-delivery", }, diff --git a/apps/web/test/service.test.tsx b/apps/web/test/service.test.tsx index f7b2607..08e7dee 100644 --- a/apps/web/test/service.test.tsx +++ b/apps/web/test/service.test.tsx @@ -232,6 +232,7 @@ describe("service in the Logbook and the away summary (Step 27.6)", () => { at: 1_000, pilotId: "p1", shipId: "s1", + seen: false, kind: "service", phase, hqId: "earth", diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 0e24b48..9bfcae1 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -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 | | `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 | +| `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) | | `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) | @@ -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) -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. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index 5379aea..fdda0ae 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -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. -- [ ] 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.3 — Client: toasts - [ ] Step 28.4 — Client: pop-up only after being away diff --git a/packages/engine/src/game/commands.ts b/packages/engine/src/game/commands.ts index 3e3dd68..5a9c596 100644 --- a/packages/engine/src/game/commands.ts +++ b/packages/engine/src/game/commands.ts @@ -31,6 +31,7 @@ import { } from "./state"; import { buyDispatchOffice, setPilotMode, setPilotOrders, setSpendingFloor } from "./independent"; 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). */ export function commandTable(): CommandTable { @@ -99,6 +100,8 @@ export function commandTable(): CommandTable { repairShip(save, input, 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). getLogbook: ({ save, now }) => ({ entries: save.logbook, diff --git a/packages/engine/src/game/logbook.ts b/packages/engine/src/game/logbook.ts index 7c7b130..6ed0482 100644 --- a/packages/engine/src/game/logbook.ts +++ b/packages/engine/src/game/logbook.ts @@ -5,7 +5,11 @@ import { goalStates } from "./state"; const DAY_MS = 24 * 60 * 60 * 1000; -type NewEntry = LogEntry extends infer E ? (E extends LogEntry ? Omit : never) : never; +type NewEntry = LogEntry extends infer E + ? E extends LogEntry + ? Omit + : never + : never; /** * 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 : 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); + // 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. */ diff --git a/packages/engine/test/logbook.test.ts b/packages/engine/test/logbook.test.ts index 1c9ac02..e35d045 100644 --- a/packages/engine/test/logbook.test.ts +++ b/packages/engine/test/logbook.test.ts @@ -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([]); }); }); + +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); + }); +}); diff --git a/packages/schema/src/logbook.ts b/packages/schema/src/logbook.ts index 4e2f011..598eea5 100644 --- a/packages/schema/src/logbook.ts +++ b/packages/schema/src/logbook.ts @@ -15,6 +15,8 @@ const base = { /** Whose entry it is: the flight's pilot, the ship's pilot, or none. */ pilotId: 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", [ diff --git a/packages/schema/src/requests.ts b/packages/schema/src/requests.ts index 3fb2a30..ca02fc1 100644 --- a/packages/schema/src/requests.ts +++ b/packages/schema/src/requests.ts @@ -87,6 +87,14 @@ export const pilotOrdersRequestSchema = z.object({ pilotId: idSchema, orders: pi /** Answer a radio call on a trip (decision 0.5.144). */ export const answerRadioCallRequestSchema = z.object({ dispatchId: idSchema, optionId: idSchema }); export const radioCallsResponseSchema = z.object({ radioCalls: z.array(radioCallSchema) }); +/** 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. */ export const bookServiceRequestSchema = z.object({ shipId: idSchema, diff --git a/packages/schema/src/routes.ts b/packages/schema/src/routes.ts index 325bbe8..43217a5 100644 --- a/packages/schema/src/routes.ts +++ b/packages/schema/src/routes.ts @@ -6,6 +6,8 @@ import { answerRadioCallRequestSchema, autoRepairRequestSchema, bookServiceRequestSchema, + markLogbookSeenRequestSchema, + markLogbookSeenResponseSchema, radioCallsResponseSchema, repairShipRequestSchema, acknowledgeSummariesResponseSchema, @@ -264,6 +266,15 @@ export const routes = { auth: 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: { method: "POST", path: "/api/ships/:shipId/service", diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts index 142ac39..a4b5d8f 100644 --- a/packages/schema/src/save.ts +++ b/packages/schema/src/save.ts @@ -207,7 +207,7 @@ export const savedDispatchSchema = z.object({ }); /** 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({ 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[] = [ { from: 1, migrate: addFuelTanks }, { from: 2, migrate: manualRefueling }, @@ -565,6 +570,7 @@ export const SAVE_MIGRATIONS: readonly SaveMigration[] = [ { from: 9, migrate: logbook }, { from: 10, migrate: tripEvents }, { from: 11, migrate: serviceBookings }, + { from: 12, migrate: seenEntries }, ]; export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; diff --git a/packages/schema/test/routes.test.ts b/packages/schema/test/routes.test.ts index df7d2d4..744d590 100644 --- a/packages/schema/test/routes.test.ts +++ b/packages/schema/test/routes.test.ts @@ -14,6 +14,7 @@ describe("route catalog", () => { "getContractBoard", "getTutorialBoard", "getLogbook", + "markLogbookSeen", "answerRadioCall", "repairShip", "bookService", diff --git a/packages/schema/test/save-fixture.ts b/packages/schema/test/save-fixture.ts index 920976d..1dbe5f3 100644 --- a/packages/schema/test/save-fixture.ts +++ b/packages/schema/test/save-fixture.ts @@ -45,7 +45,7 @@ export function fullSave(): SaveDocument { seed: 42, }); return { - saveVersion: 12, + saveVersion: 13, saveId: "save-1", lastTimeSeen: T + 3_600_000, player: { @@ -271,6 +271,7 @@ export function fullSave(): SaveDocument { loanRepayment: 340, xp: 10, scriptedId: null, + seen: true, }, { id: "d0:top-up", @@ -282,6 +283,7 @@ export function fullSave(): SaveDocument { boughtFu: 7.6, cost: 84, source: "top-up", + seen: true, }, ], }; diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts index c14b5ae..4005a2b 100644 --- a/packages/schema/test/save.test.ts +++ b/packages/schema/test/save.test.ts @@ -11,7 +11,7 @@ import { fullSave } from "./save-fixture"; describe("save document (Step 14.2)", () => { it("validates a fully populated save", () => { expect(saveDocumentSchema.safeParse(fullSave()).success).toBe(true); - expect(CURRENT_SAVE_VERSION).toBe(12); + expect(CURRENT_SAVE_VERSION).toBe(13); }); it("rejects an invalid save with a readable error naming the field", () => { @@ -84,7 +84,7 @@ describe("save migration 1 → 2: fuel tanks (CR-015 §5)", () => { if (!result.ok) throw new Error(result.error); const { save } = result; const before = fullSave(); - expect(save.saveVersion).toBe(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 // price, 5 cr/fu since CR-018 (decision 0.5.110). expect(save.ships[0]).toMatchObject({ fuelFu: 192, fuelAvgPrice: 5, refuelPolicy: "manual" }); @@ -116,7 +116,7 @@ describe("save migration 2 → 3: Manual refueling (CR-018 §3)", () => { const result = migrateSave(old); if (!result.ok) throw new Error(result.error); const { save } = result; - expect(save.saveVersion).toBe(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.fuelFu, s.fuelAvgPrice])).toEqual( before.ships.map((s) => [s.fuelFu, s.fuelAvgPrice]), @@ -142,7 +142,7 @@ describe("save migration 3 → 4: completable boards (CR-019 §3)", () => { for (const board of old.contractBoards) delete board.fleet; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(12); + expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION); expect(result.save.contractBoards).toEqual([]); expect({ ...result.save, contractBoards: [] }).toEqual({ ...before, contractBoards: [] }); }); @@ -164,7 +164,7 @@ describe("save migration 4 → 5: independent pilots (CR-021 §3)", () => { } const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(12); + expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION); expect(result.save.company).toMatchObject({ dispatchOffice: false, npcSpendingFloor: 500 }); for (const pilot of result.save.pilots) { expect(pilot.mode).toBe("manual"); @@ -191,7 +191,7 @@ describe("save migration 6 → 7: welcome cards (CR-022 §3)", () => { delete old.player.welcomeSeen; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(12); + expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION); expect(result.save.player.welcomeSeen).toBe(true); expect(result.save).toEqual(before); }); @@ -207,7 +207,7 @@ describe("save migration 7 → 8: shop time counts as rest (CR-023)", () => { for (const ship of old.ships) delete ship.shopPilotId; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(12); + expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION); // Including the ship still in the shop: its upgrade finishes without the rest. expect(result.save.ships.map((s) => s.shopPilotId)).toEqual(before.ships.map(() => null)); expect(result.save).toEqual(before); @@ -225,7 +225,7 @@ describe("save migration 8 → 9: fleet sleep (CR-024 §3)", () => { delete old.company.sleepAfterHours; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(12); + expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION); expect(result.save.company.lastManualDecisionAt).toBeNull(); expect(result.save.company.sleepAfterHours).toBe(24); expect(result.save).toEqual(before); @@ -245,7 +245,7 @@ describe("save migration 9 → 10: the logbook (CR-025 §4)", () => { for (const ship of old.ships) delete ship.shopOpen; const result = migrateSave(old); if (!result.ok) throw new Error(result.error); - expect(result.save.saveVersion).toBe(12); + expect(result.save.saveVersion).toBe(CURRENT_SAVE_VERSION); // 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]); @@ -285,7 +285,7 @@ describe("save migration 10 → 11: events and hulls (CR-026 §6)", () => { } const result = migrateSave(old); 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.dispatches.map((d) => d.event)).toEqual([null, null]); // 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); 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([ [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); }); }); + +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 & { + logbook: Record[]; + }; + 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); + }); +}); diff --git a/packages/sim/src/away.ts b/packages/sim/src/away.ts index d42916d..e3b7133 100644 --- a/packages/sim/src/away.ts +++ b/packages/sim/src/away.ts @@ -9,6 +9,8 @@ interface EntryBase { at: RealTime; pilotId: 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 &