diff --git a/src/save/checksum.ts b/src/save/checksum.ts new file mode 100644 index 0000000..0df6c24 --- /dev/null +++ b/src/save/checksum.ts @@ -0,0 +1,38 @@ +import CRC32 from 'crc-32'; + +/** + * 8-char lowercase hex CRC-32 of the input string. + * crc-32 returns a signed 32-bit integer; we mask to unsigned and pad. + * Used by envelope.wrap/unwrap to detect save corruption (lossy storage, + * partial writes, browser-eviction truncation). + */ +export function crc32hex(input: string): string { + const signed = CRC32.str(input); + const unsigned = signed >>> 0; // coerce to uint32 + return unsigned.toString(16).padStart(8, '0'); +} + +/** + * Deterministic JSON serialization with recursively-sorted object keys. + * Required because checksum stability depends on stable key order across + * V8 / SpiderMonkey / JavaScriptCore runs and across migration round-trips + * (per .planning/phases/01-foundations-and-doctrine/01-RESEARCH.md Pitfall 3). + * + * Arrays are NOT sorted — their order is meaningful (a garden tile list, + * a timeline of harvested fragments). Only plain object keys are reordered. + * + * Hand-rolled rather than pulling in `json-stable-stringify` per RESEARCH + * Open Question #1: ~10 LoC saves a dependency. + */ +export function canonicalJSON(value: unknown): string { + return JSON.stringify(value, (_key, val) => { + if (val && typeof val === 'object' && !Array.isArray(val)) { + return Object.fromEntries( + Object.entries(val as Record).sort(([a], [b]) => + a.localeCompare(b), + ), + ); + } + return val; + }); +} diff --git a/src/save/envelope.ts b/src/save/envelope.ts new file mode 100644 index 0000000..f990f12 --- /dev/null +++ b/src/save/envelope.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { crc32hex, canonicalJSON } from './checksum'; + +/** + * The save envelope shape, locked by CLAUDE.md "Code Style": + * `{schemaVersion, payload, checksum}` + * + * `schemaVersion` is `nonnegative` (NOT `positive`) because CONTEXT D-05 + * declares the synthetic v0 era — see migrations.ts. RESEARCH Pattern 1's + * example uses `positive` but that conflicts with D-05's requirement. + */ +export const SaveEnvelopeSchema = z.object({ + schemaVersion: z.number().int().nonnegative(), + payload: z.unknown(), + checksum: z.string().regex(/^[0-9a-f]{8}$/), +}); + +export type SaveEnvelope = { + schemaVersion: number; + payload: T; + checksum: string; +}; + +/** + * Thrown by `unwrap` when the envelope's stored checksum disagrees with + * the recomputed checksum of the payload. Phase 2's settings UI surfaces + * this with the recovery option (load from `save_snapshots` per CORE-08). + * + * NOT a cryptographic guarantee — see threat-model T-01-03 in the plan. + * A player editing their own save is acceptable in single-player; this + * detects lossy-storage corruption, not adversarial editing. + */ +export class SaveCorruptError extends Error { + override readonly name = 'SaveCorruptError'; + constructor( + public readonly expected: string, + public readonly actual: string, + ) { + super(`Save checksum mismatch: expected ${expected}, got ${actual}`); + } +} + +/** + * Wrap a payload in an envelope at the given schema version. Computes the + * checksum over the canonical-JSON serialization of the payload so that + * key order does not affect the checksum (per RESEARCH Pitfall 3). + */ +export function wrap(payload: T, schemaVersion: number): SaveEnvelope { + return { + schemaVersion, + payload, + checksum: crc32hex(canonicalJSON(payload)), + }; +} + +/** + * Unwrap an envelope, verifying the checksum. Throws `SaveCorruptError` + * when the payload's recomputed checksum does not match the envelope's + * stored checksum. + * + * The `expected` field on the error is the value the envelope ARRIVED with + * (what the storage layer expected to be authoritative); `actual` is the + * value computed from the payload as decoded. Phase 2's recovery UI shows + * this delta so the user can choose between rolling back to a snapshot + * or accepting the (presumably-tampered) payload as-is. + */ +export function unwrap(env: SaveEnvelope): T { + const computed = crc32hex(canonicalJSON(env.payload)); + if (computed !== env.checksum) { + throw new SaveCorruptError(env.checksum, computed); + } + return env.payload as T; +} diff --git a/src/save/migrations.ts b/src/save/migrations.ts new file mode 100644 index 0000000..983ebdf --- /dev/null +++ b/src/save/migrations.ts @@ -0,0 +1,100 @@ +/** + * Forward-only save migration registry. + * + * Each entry `migrations[N]` is the function that migrates payload from + * schema version N-1 to schema version N. Phase 1 ships migrations[1] + * (the synthetic v0 → v1 demo per CONTEXT D-05); Phase 4 will land + * migrations[2] when prestige / Roothold state lands. + * + * The v1 shape (from CONTEXT D-04) is intentionally minimal: only what + * Phase 2's first feature commit will write. Authoring it now lets us + * prove the migration chain end-to-end without speculating about future + * Season 5+ structures. + */ + +type Migration = (payload: unknown) => unknown; + +export const CURRENT_SCHEMA_VERSION = 1; + +interface V0Payload { + garden?: unknown[]; +} + +/** + * The minimal v1 save shape per CONTEXT D-04: garden tiles, plant growth + * data placeholder, harvested fragment IDs, last tick timestamp, settings. + * Phase 2 fleshes the contents; Phase 1 just locks the field set. + */ +export interface V1Payload { + garden: { tiles: unknown[] }; + plants: unknown[]; + harvestedFragmentIds: string[]; + lastTickAt: number; + settings: { + musicVolume: number; + ambientVolume: number; + sfxVolume: number; + }; +} + +/** + * Forward-only migration chain. Keys are TARGET versions; the function + * at key N migrates FROM N-1 TO N. + * + * - `migrations[1]` = v0 → v1 (synthetic demo per CONTEXT D-05). + * - `migrations[2]` = v1 → v2 will be added in Phase 4 when Roothold / + * prestige state lands. + */ +export const migrations: Record = { + 1: (s: unknown): V1Payload => { + const v0 = (s ?? {}) as V0Payload; + return { + garden: { tiles: v0.garden ?? [] }, + plants: [], + harvestedFragmentIds: [], + lastTickAt: Date.now(), + settings: { + musicVolume: 0.7, + ambientVolume: 0.5, + sfxVolume: 0.8, + }, + }; + }, +}; + +/** + * Migrate `payload` from `fromVersion` up to `CURRENT_SCHEMA_VERSION`, + * applying each registered migration in order. Returns both the migrated + * payload and the schema version it now matches. + * + * Throws when: + * - `fromVersion` is negative (invalid input) + * - `fromVersion` is greater than `CURRENT_SCHEMA_VERSION` (future save + * from a newer build of the game — refuse to silently downgrade) + * - any required migration function is missing + */ +export function migrate( + payload: unknown, + fromVersion: number, +): { payload: unknown; toVersion: number } { + if (fromVersion < 0) { + throw new Error(`Cannot migrate from negative version ${fromVersion}`); + } + if (fromVersion > CURRENT_SCHEMA_VERSION) { + throw new Error( + `Cannot migrate from future version ${fromVersion} (current: ${CURRENT_SCHEMA_VERSION})`, + ); + } + let current = payload; + let v = fromVersion; + while (v < CURRENT_SCHEMA_VERSION) { + const next = v + 1; + const fn = migrations[next]; + if (!fn) { + throw new Error(`No migration registered for v${v} → v${next}`); + } + current = fn(current); + v = next; + } + return { payload: current, toVersion: v }; +}