Step 14.2: save document schema and migrations
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-09 19:14:21 -04:00
parent 6cdffa2a43
commit c473580c56
7 changed files with 462 additions and 1 deletions

View File

@ -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 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. - **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).

View File

@ -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 | | `repayLoan` | `POST /api/loan/repay` | `amount` | `{ company }` | yes |
| `claimGoal` | `POST /api/goals/:goalId/claim` | `goalId` | `{ 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`) ## 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. 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.

View File

@ -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. - **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). - **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. - [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.3 — Storage layer
- [ ] Step 14.4 — Mutation queue and tab lock - [ ] Step 14.4 — Mutation queue and tab lock
- [ ] Step 14.5 — Port the game logic - [ ] Step 14.5 — Port the game logic

View File

@ -2,3 +2,4 @@ export * from "./entities";
export * from "./errors"; export * from "./errors";
export * from "./requests"; export * from "./requests";
export * from "./routes"; export * from "./routes";
export * from "./save";

216
packages/schema/src/save.ts Normal file
View File

@ -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<typeof saveDocumentSchema>;
// The saved shapes are the sim's own: a change on either side fails the build.
type Same<A, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
const _sameContract: Same<z.infer<typeof contractSchema>, SimContract> = true;
const _sameRecord: Same<z.infer<typeof dispatchRecordSchema>, DispatchRecord> = true;
const _sameResolution: Same<z.infer<typeof resolutionSchema>, 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<string, unknown>): Record<string, unknown>;
}
/**
* 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<string, unknown> } | { 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<string, unknown>;
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)}`,
};
}

View File

@ -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 }],
};
}

View File

@ -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.",
});
});
});