Step 28.1: engine: seen tracking
CI / check (push) Has been cancelled
Details
CI / check (push) Has been cancelled
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d17332f3ac
commit
2826223f74
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const flight = (pilotId: string, payout: number, over: Partial<LogEntry> = {}):
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export function samplePlayerState(overrides: Partial<PlayerState> = {}): 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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ const event = (over: Partial<Extract<LogEntry, { kind: "event" }>>): 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,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const job = (pilotId: string, hoursAgo: number, over: Partial<LogEntry> = {}): 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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -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 {
|
||||
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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ describe("route catalog", () => {
|
|||
"getContractBoard",
|
||||
"getTutorialBoard",
|
||||
"getLogbook",
|
||||
"markLogbookSeen",
|
||||
"answerRadioCall",
|
||||
"repairShip",
|
||||
"bookService",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 &
|
||||
|
|
|
|||
Loading…
Reference in New Issue