From c473580c5656440e9294e63de471e452af5243ac Mon Sep 17 00:00:00 2001 From: fireshaper Date: Fri, 9 Oct 2026 19:14:21 -0400 Subject: [PATCH] Step 14.2: save document schema and migrations Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 7 + memory-bank/architecture.md | 4 + memory-bank/progress.md | 2 +- packages/schema/src/index.ts | 1 + packages/schema/src/save.ts | 216 +++++++++++++++++++++++++++ packages/schema/test/save-fixture.ts | 175 ++++++++++++++++++++++ packages/schema/test/save.test.ts | 58 +++++++ 7 files changed, 462 insertions(+), 1 deletion(-) create mode 100644 packages/schema/src/save.ts create mode 100644 packages/schema/test/save-fixture.ts create mode 100644 packages/schema/test/save.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 57cd3b0..4a3263e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1345,3 +1345,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md` - **The device clock is a small class with injected device time**, so tests set the device clock forward and back. It remembers the latest time it handed out; the save stores that (`lastTimeSeen`) and `restore` applies it on load, so restarting the app with the device clock set back still can't rewind the game. - **The developer offset only moves forward** (`advance`), like the old developer route; there is no "set offset" any more, because moving back would be clamped anyway. + +## Step 14.2 + +- **The save mirrors the database, one company per document**: the same fields as the tables, minus player ids (there's one player per save) and minus the backup-code and login fields, which CR-014 removes. Statuses use the API's words (`in-flight`), not the database enum's (`in_flight`). +- **Version 1 needs no migration**: it is the first local format and the CR allows resetting dev data. The CR's "identity migration for version 1" is covered by the framework itself (a version-1 save passes through unchanged and validates); the chaining is tested with made-up versions 1 → 2 → 3. +- **The stored contract, dispatch record and resolution are checked against the sim's types at compile time**, so a sim change that the save doesn't follow fails the build instead of a player's load. +- **`migrateSave` never throws**: unknown shapes, missing or future versions and invalid fields all come back as a readable message (Zod's pretty error for field problems), for the storage fallback (14.3) and import (14.10). diff --git a/memory-bank/architecture.md b/memory-bank/architecture.md index 7ed0ec0..f52ef8a 100644 --- a/memory-bank/architecture.md +++ b/memory-bank/architecture.md @@ -76,6 +76,10 @@ The `routes` catalog is the single source for both server and client. Each entry | `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes | | `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ company }` | yes | +## Save document (`packages/schema/src/save.ts`, CR-014) + +One document per company on the device, validated on every load and save: `saveVersion` (currently 1), `saveId`, `lastTimeSeen` (the clock never goes below it), `player` (world seed, tutorial state, deal accepted), `company` (name, credits, loan), `ships` (with `upgrades` as a level map and shop fields), `pilots`, `bays`, `discoveredLocations`, `contractBoards` (stored boards), `dispatches` (sim record, status, resolution, acknowledged), `acceptedContracts`, `claimedGoals`. It mirrors the database tables below 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. + ## Database (`apps/server/src/db`) Postgres via Drizzle (`pg` driver). Migrations are SQL files in `apps/server/drizzle/`, generated with `pnpm --filter @deadhead/server db:generate` and applied with `db:migrate` (uses `DATABASE_URL`; rerunning is a no-op). Contracts are JSONB snapshots (`contract_boards.contracts`, `dispatches.record`); `0005_cr007_goods` backfills pre-CR-007 contracts with a default good of their class, the origin's first shipper and no Priority. Every game table hangs off `players.id`; times are bigint Unix ms. diff --git a/memory-bank/progress.md b/memory-bank/progress.md index de6d8b6..284ebd6 100644 --- a/memory-bank/progress.md +++ b/memory-bank/progress.md @@ -342,7 +342,7 @@ Spec: `memory-bank/change-requests/CR-014-local-first.md` (received as "CR-011 - **Follow-ups to preserve:** Earth launch cost 2.1; base pay +50 %; developer tools for pilots, ships and discovery (become developer-only engine commands); deadhead.belowland.com as an allowed Vite host. - **Not started:** the Fixed Map and Fuel Tanks request (recorded after this phase as CR-015). - [x] **Step 14.1 — Engine package and clock.** New `packages/engine` (depends on content, schema, sim; picked up as a Vitest project). `Clock` interface (`now()`) and `DeviceClock`: device time (injected, `Date.now` by default) plus a developer offset, clamped to the last time it handed out; `lastTimeSeen` for the save, `restore(lastTimeSeen)` after loading one, `advance(ms)` for the developer tools (forward only). Tests (`packages/engine/test/clock.test.ts`): offset applied; device clock set back returns the last time seen; a forward move shows at once; a save's last time seen wins over a device that is behind; the offset can't go backward. -- [ ] Step 14.2 — Save document schema and migrations +- [x] **Step 14.2 — Save document schema and migrations.** `packages/schema/src/save.ts`: the save document (every database table for one company, without player ids, plus `saveVersion`, `saveId`, `lastTimeSeen`), with compile-time checks that its contract, dispatch record and resolution match the sim types. Migration framework: `SAVE_MIGRATIONS` (ordered version-to-version steps; none yet, version 1 is the first format), `applyMigrations` (steps a save up one version at a time), `migrateSave` (migrate, then validate; never throws, gives a readable error). Tests (`packages/schema/test/save.test.ts`, fixture `save-fixture.ts` reused later): a fully populated save validates; a wrong field is rejected naming `company.credits`; non-saves and saves from a newer build are refused; a version-1 save round-trips through JSON and migration unchanged; migrations chain in order and a missing step is reported. - [ ] Step 14.3 — Storage layer - [ ] Step 14.4 — Mutation queue and tab lock - [ ] Step 14.5 — Port the game logic diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index ade9769..f9c741f 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -2,3 +2,4 @@ export * from "./entities"; export * from "./errors"; export * from "./requests"; export * from "./routes"; +export * from "./save"; diff --git a/packages/schema/src/save.ts b/packages/schema/src/save.ts new file mode 100644 index 0000000..189eba0 --- /dev/null +++ b/packages/schema/src/save.ts @@ -0,0 +1,216 @@ +import type { Contract as SimContract, DispatchRecord, Resolution } from "@deadhead/sim"; +import { z } from "zod"; +import { contractSchema, idSchema, timeSchema } from "./entities"; + +/** + * The save document (CR-014): everything the game database held, for the one + * company on this device. Validated on every load and every save; each + * `saveVersion` change ships with a migration (see `migrateSave`). + */ + +const credits = z.number().int(); +const activity = z.enum(["idle", "in-flight"]); + +/** The sim's dispatch record, stored as it was created. */ +export const dispatchRecordSchema = z.object({ + id: idSchema, + shipId: idSchema, + pilotId: idSchema, + contract: contractSchema.nullable(), + originId: idSchema, + destinationId: idSchema, + tutorial: z.boolean(), + departureAt: timeSchema, + arrivalAt: timeSchema, + distanceMillionKm: z.number().nonnegative(), + fuelCost: credits.nonnegative(), + expectedPayout: credits, + seed: z.number().int().nonnegative(), +}); + +/** What resolving a dispatch did, kept for the "while you were away" summary. */ +export const resolutionSchema = z.object({ + credits, + pilotPay: credits.nonnegative(), + loanRepayment: credits.nonnegative(), + pilotXp: z.number().int().nonnegative(), + shipLocationId: idSchema, + discoversDestination: z.boolean(), + pilotDutyHours: z.number().nonnegative(), + pilotRestEndsAt: timeSchema.nullable(), +}); + +export const savedShipSchema = z.object({ + id: idSchema, + modelId: idSchema, + name: z.string().min(1), + status: activity, + /** Null while in flight. */ + locationId: idSchema.nullable(), + /** Installed upgrade levels (CR-009); a missing upgrade is level 0. */ + upgrades: z.record(idSchema, z.number().int().positive()), + /** In the upgrade shop until this time (decision 0.5.66); null or past: not. */ + inShopUntil: timeSchema.nullable(), + /** The latest shop job, kept until the player has seen it. */ + shopUpgradeId: idSchema.nullable(), + shopLevel: z.number().int().positive().nullable(), + /** Handed over in the deal rather than bought (CR-013). */ + fromDeal: z.boolean(), + createdAt: timeSchema, +}); + +export const savedPilotSchema = z.object({ + id: idSchema, + name: z.string().min(1), + isAvatar: z.boolean(), + isVeteran: z.boolean(), + xp: z.number().int().nonnegative(), + skills: z.record(idSchema, z.number().int().nonnegative()), + skillPointsSpent: z.number().int().nonnegative(), + assignedShipId: idSchema.nullable(), + /** Only unassigned pilots store a location (decision 0.5.27). */ + locationId: idSchema.nullable(), + status: activity, + dutyHours: z.number().nonnegative(), + restEndsAt: timeSchema.nullable(), + createdAt: timeSchema, +}); + +export const savedBoardSchema = z.object({ + id: idSchema, + kind: z.enum(["location", "tutorial"]), + locationId: idSchema, + periodIndex: z.number().int(), + /** Tutorial boards: runs completed when generated; 0 for location boards. */ + tutorialRun: z.number().int().nonnegative(), + contracts: z.array(contractSchema), + createdAt: timeSchema, +}); + +export const savedDispatchSchema = z.object({ + id: idSchema, + /** Null for a tutorial run (the loaner is not an owned ship). */ + shipId: idSchema.nullable(), + pilotId: idSchema, + tutorial: z.boolean(), + record: dispatchRecordSchema, + arrivalAt: timeSchema, + status: z.enum(["in-flight", "resolved"]), + resolution: resolutionSchema.nullable(), + resolvedAt: timeSchema.nullable(), + acknowledgedAt: timeSchema.nullable(), +}); + +/** The save version this build writes. Bump it with a new migration. */ +export const CURRENT_SAVE_VERSION = 1; + +export const saveDocumentSchema = z.object({ + saveVersion: z.literal(CURRENT_SAVE_VERSION), + /** Identifies this company's save (debug info, notifications). */ + saveId: idSchema, + /** The latest time the game has seen (decision 0.5.88): the clock never goes below it. */ + lastTimeSeen: timeSchema, + player: z.object({ + /** 32-bit unsigned seed for boards, dispatches and names (decision 0.5.10). */ + worldSeed: z.number().int().nonnegative().max(0xffff_ffff), + tutorialActive: z.boolean(), + loanerLocationId: idSchema.nullable(), + tutorialRunsCompleted: z.number().int().nonnegative(), + dealAccepted: z.boolean(), + createdAt: timeSchema, + }), + company: z.object({ + id: idSchema, + name: z.string().min(1), + credits, + loanBalance: credits.nonnegative(), + }), + ships: z.array(savedShipSchema), + pilots: z.array(savedPilotSchema), + bays: z.array(z.object({ id: idSchema, locationId: idSchema, createdAt: timeSchema })), + discoveredLocations: z.array(z.object({ locationId: idSchema, discoveredAt: timeSchema })), + contractBoards: z.array(savedBoardSchema), + dispatches: z.array(savedDispatchSchema), + acceptedContracts: z.array( + z.object({ + contractId: idSchema, + periodIndex: z.number().int(), + dispatchId: idSchema, + acceptedAt: timeSchema, + }), + ), + claimedGoals: z.array(z.object({ goalId: idSchema, claimedAt: timeSchema })), +}); +export type SaveDocument = z.infer; + +// The saved shapes are the sim's own: a change on either side fails the build. +type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +const _sameContract: Same, SimContract> = true; +const _sameRecord: Same, DispatchRecord> = true; +const _sameResolution: Same, Resolution> = true; +void [_sameContract, _sameRecord, _sameResolution]; + +/** One step: a save of version `from` to version `from + 1`. */ +export interface SaveMigration { + from: number; + migrate(save: Record): Record; +} + +/** + * Every migration, in order. Version 1 is the first local save format, so + * there is nothing before it yet; a format change adds `{ from: 1, … }` here + * and bumps `CURRENT_SAVE_VERSION`. + */ +export const SAVE_MIGRATIONS: readonly SaveMigration[] = []; + +export type SaveParse = { ok: true; save: SaveDocument } | { ok: false; error: string }; +type Stepped = { ok: true; save: Record } | { ok: false; error: string }; + +/** + * Steps a save from its version up to `current`, one migration at a time. + * Doesn't validate the result; `migrateSave` does. + */ +export function applyMigrations( + raw: unknown, + migrations: readonly SaveMigration[], + current: number, +): Stepped { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return { ok: false, error: "The save isn't a save document." }; + } + let save = raw as Record; + let version = save.saveVersion; + if (typeof version !== "number" || !Number.isInteger(version) || version < 1) { + return { ok: false, error: "The save has no valid saveVersion." }; + } + if (version > current) { + return { + ok: false, + error: `The save is from a newer version of the game (save version ${version}, this build reads up to ${current}).`, + }; + } + while (version < current) { + const step = migrations.find((m) => m.from === version); + if (!step) return { ok: false, error: `No migration from save version ${version}.` }; + save = { ...step.migrate(save), saveVersion: version + 1 }; + version += 1; + } + return { ok: true, save }; +} + +/** + * Migrates a save of any known version to the current one and validates it. + * Never throws: an unknown or broken save gives a readable error. + */ +export function migrateSave(raw: unknown): SaveParse { + const stepped = applyMigrations(raw, SAVE_MIGRATIONS, CURRENT_SAVE_VERSION); + if (!stepped.ok) return stepped; + const parsed = saveDocumentSchema.safeParse(stepped.save); + return parsed.success + ? { ok: true, save: parsed.data } + : { + ok: false, + error: `The save is damaged: +${z.prettifyError(parsed.error)}`, + }; +} diff --git a/packages/schema/test/save-fixture.ts b/packages/schema/test/save-fixture.ts new file mode 100644 index 0000000..889c5e0 --- /dev/null +++ b/packages/schema/test/save-fixture.ts @@ -0,0 +1,175 @@ +import type { Contract, SaveDocument } from "../src/index"; + +const T = Date.parse("2100-01-01T00:00:00Z"); + +/** A contract as the sim builds it. */ +export const sampleContract: Contract = { + id: "earth:1:0", + originId: "earth", + destinationId: "moon", + cargoTypeId: "standard", + goodId: "consumer-goods", + shipper: "Pacific Orbital Logistics", + priority: false, + deliveryWindowGameHours: null, + tier: 0, + massTonnes: 12, + payout: 1500, + requiredSkill: null, + requiredLongHaulLevel: 0, + offeredAt: T, + expiresAt: T + 600_000, + firstContact: false, +}; + +/** + * A save with every collection and every optional field filled: a mid-game + * company after the deal, with an upgraded ship in the shop, a resting pilot, + * one flight under way and one resolved and unseen. + */ +export function fullSave(): SaveDocument { + const record = (id: string, arrivalAt: number) => ({ + id, + shipId: "s1", + pilotId: "avatar", + contract: sampleContract, + originId: "earth", + destinationId: "moon", + tutorial: false, + departureAt: T, + arrivalAt, + distanceMillionKm: 0.38, + fuelCost: 160, + expectedPayout: 1500, + seed: 42, + }); + return { + saveVersion: 1, + saveId: "save-1", + lastTimeSeen: T + 3_600_000, + player: { + worldSeed: 0xdeadbeef, + tutorialActive: false, + loanerLocationId: null, + tutorialRunsCompleted: 4, + dealAccepted: true, + createdAt: T, + }, + company: { id: "co1", name: "Deadhead Freight", credits: 12_345, loanBalance: 60_000 }, + ships: [ + { + id: "s1", + modelId: "courier", + name: "Dusty Mule", + status: "in-flight", + locationId: null, + upgrades: { engines: 2, "fuel-tanks": 1 }, + inShopUntil: null, + shopUpgradeId: null, + shopLevel: null, + fromDeal: true, + createdAt: T, + }, + { + id: "s2", + modelId: "clipper", + name: "Swift Heron", + status: "idle", + locationId: "earth", + upgrades: {}, + inShopUntil: T + 7_200_000, + shopUpgradeId: "cargo-bay", + shopLevel: 1, + fromDeal: true, + createdAt: T, + }, + ], + pilots: [ + { + id: "avatar", + name: "Captain", + isAvatar: true, + isVeteran: false, + xp: 120, + skills: { "long-haul": 1 }, + skillPointsSpent: 1, + assignedShipId: "s1", + locationId: null, + status: "in-flight", + dutyHours: 4.5, + restEndsAt: null, + createdAt: T, + }, + { + id: "veteran", + name: "Rosa Kell", + isAvatar: false, + isVeteran: true, + xp: 250, + skills: { "expensive-cargo": 1 }, + skillPointsSpent: 1, + assignedShipId: "s2", + locationId: null, + status: "idle", + dutyHours: 0, + restEndsAt: T + 3_000_000, + createdAt: T, + }, + ], + bays: [{ id: "b1", locationId: "earth", createdAt: T }], + discoveredLocations: [ + { locationId: "earth", discoveredAt: T }, + { locationId: "moon", discoveredAt: T }, + ], + contractBoards: [ + { + id: "board-1", + kind: "location", + locationId: "earth", + periodIndex: 1, + tutorialRun: 0, + contracts: [sampleContract], + createdAt: T, + }, + ], + dispatches: [ + { + id: "d1", + shipId: "s1", + pilotId: "avatar", + tutorial: false, + record: record("d1", T + 9_000_000), + arrivalAt: T + 9_000_000, + status: "in-flight", + resolution: null, + resolvedAt: null, + acknowledgedAt: null, + }, + { + id: "d0", + shipId: "s1", + pilotId: "avatar", + tutorial: false, + record: record("d0", T + 200_000), + arrivalAt: T + 200_000, + status: "resolved", + resolution: { + credits: 1000, + pilotPay: 0, + loanRepayment: 340, + pilotXp: 10, + shipLocationId: "moon", + discoversDestination: false, + pilotDutyHours: 2.4, + pilotRestEndsAt: null, + }, + resolvedAt: T + 200_000, + acknowledgedAt: null, + }, + ], + acceptedContracts: [ + { contractId: sampleContract.id, periodIndex: 1, dispatchId: "d1", acceptedAt: T }, + ], + claimedGoals: [{ goalId: "first-own-delivery", claimedAt: T + 300_000 }], + }; +} diff --git a/packages/schema/test/save.test.ts b/packages/schema/test/save.test.ts new file mode 100644 index 0000000..2278793 --- /dev/null +++ b/packages/schema/test/save.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { + applyMigrations, + CURRENT_SAVE_VERSION, + migrateSave, + saveDocumentSchema, + type SaveMigration, +} from "../src/index"; +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(1); + }); + + it("rejects an invalid save with a readable error naming the field", () => { + const broken = fullSave() as unknown as { company: { credits: unknown } }; + broken.company.credits = "lots"; + const result = migrateSave(broken); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("The save is damaged"); + expect(result.error).toContain("company.credits"); + } + }); + + it("rejects things that aren't saves, and saves from a newer build", () => { + expect(migrateSave("hello")).toEqual({ ok: false, error: "The save isn't a save document." }); + expect(migrateSave({ company: {} })).toEqual({ + ok: false, + error: "The save has no valid saveVersion.", + }); + const future = migrateSave({ ...fullSave(), saveVersion: CURRENT_SAVE_VERSION + 1 }); + expect(future.ok).toBe(false); + if (!future.ok) expect(future.error).toContain("newer version"); + }); + + it("migrates a version-1 save to a valid current save", () => { + const result = migrateSave(JSON.parse(JSON.stringify(fullSave()))); + expect(result).toEqual({ ok: true, save: fullSave() }); + }); + + it("chains migrations in order, one version at a time", () => { + const seen: number[] = []; + const migrations: SaveMigration[] = [ + { from: 2, migrate: (s) => (seen.push(2), { ...s, extra: [...(s.extra as number[]), 2] }) }, + { from: 1, migrate: (s) => (seen.push(1), { ...s, extra: [1] }) }, + ]; + const result = applyMigrations({ saveVersion: 1 }, migrations, 3); + expect(result).toEqual({ ok: true, save: { saveVersion: 3, extra: [1, 2] } }); + expect(seen).toEqual([1, 2]); + expect(applyMigrations({ saveVersion: 1 }, [], 2)).toEqual({ + ok: false, + error: "No migration from save version 1.", + }); + }); +});