Local save/load via localStorage: checkpoint autosave, New Game/Continue screen, versioned corruption-safe saves
This is a clean, well-scoped implementation: a versioned envelope with a `migrate` hook and field-by-field validation, storage wrappers that never throw and back up bad data to the corrupt key, checkpoint hooks wired into the existing `Game` mutation helpers, and a minimal start screen with a confirm dialog. The tests map closely to every acceptance criterion, and the Results-checkpoint choice (persisting `lastResult`) is documented. The main reservation is that no CI ran and nothing was tried in a real browser, so the build and typecheck claims are unverified. There is also a minor risk that input reaching the game behind the start screen could autosave over an existing run.
Acceptance criteria · 9 of 10 met
- YESRound-trip serialize → deserialize of a non-trivial run (multiple rooms, monsters, traps, gold, raid > 1, an unlock) yields deeply equal state`tests/save.test.ts` 'round-trips a non-trivial run' builds 2 rooms on top of the starter rooms, 3 goblins, 2 traps, runs 2 raids, pushes an unlock, and asserts `toEqual` after decode and after `restore`.
- YESStored envelope has numeric version equal to SAVE_VERSION and an ISO savedAt string'stores an envelope with numeric version and ISO savedAt' checks typeof, equality with SAVE_VERSION and that `toISOString` round-trips.
- YESMalformed JSON, missing version, unknown version and missing required fields each return a handled error without throwing and write raw data to the corrupt keyThe parameterised cases in `tests/save.test.ts` cover all four (plus wrong-typed fields and a saved Raid phase), asserting not.toThrow, the status, and `CORRUPT_SAVE_KEY === raw`.
- YESA mocked localStorage.setItem that throws does not make save throw'does not throw when setItem throws' stubs a QuotaExceededError and asserts no throw, a false return and a console.warn.
- YESUnavailable localStorage (getter throws or undefined) does not make load or save throwSeparate tests cover a throwing getter, `undefined`, and a throwing getItem, each returning 'unavailable' or false.
- YESAutosave runs on Preparation commits (build, hire, place, sell), on entering Results and on leaving Results`Game.checkpoint()` is called from `dungeonChanged()`, `finishRaid()` and `continueToPreparation()`, and `tests/checkpoints.test.ts` verifies build/hire/place/remove plus both Results transitions; 'sell' maps to `removeRoom` because no sell action exists.
- YESNo save call in the raid update loop or any per-frame pathRaid ticks do not checkpoint, a test asserts zero saves while phase is Raid, and `snapshot()` throws during a Raid; only the one-time `finishRaid` transition saves.
- YESStart screen shows Continue only with a valid save; Continue restores the saved phase; a mid-raid refresh restores pre-raid Preparation`StartScreen.showMenu` branches on `load.status`, and the start-screen tests cover the no-save, Continue-into-Results and corrupt cases, while the checkpoints test covers the pre-raid Preparation restore.
- YESNew Game with an existing save requires confirmation, and Cancel leaves the stored save byte-identical`showConfirm` shows the 'will be overwritten' text, and the test compares `localStorage.getItem(SAVE_KEY)` before and after Cancel.
- UNCLEARExisting tests, lint/typecheck and vite build pass; no backend, network or account codeNo network or backend code is added, but no CI ran, so passing tests, typecheck and build rest only on the builder's claim.
- No CI ran on this commit, so the claimed passing tests, typecheck and `vite build` are unverified. The builder also says the game was never opened in a real browser.
- In `main.ts` the live `Game` already exists and `onCheckpoint(saveRun)` is registered while the start screen is still open. Any build action that reaches the game behind the overlay (e.g. a HUD keyboard shortcut, if one exists) would checkpoint a fresh run over the existing save before the player picks Continue or New Game. The overlay blocks pointer clicks, but nothing stops keyboard input or disables the HUD.
- 'Sell' is mapped to `removeRoom` and 'progression/unlocks' is a new `unlocks: string[]` field that nothing writes to. Both are reasonable, documented gap-fills, but the round-trip test's unlock entry is pushed by hand rather than produced by gameplay.
- The new devDependency `happy-dom` pulls in `ws`, `@types/node` and other transitive packages. These are dev-only and acceptable, but they are an addition backers may want to know about.
- The tests' `afterEach` calls `Reflect.deleteProperty(globalThis, 'localStorage')`. On Node versions that ship a built-in `localStorage`, this global mutation could interact oddly with other test files.
CI details
No CI checks ran on this commit.
Accepted by the backers and merged by the maintainer.
Ballots · 1
Automated review cost $0.16, counted as builder cost.
Motivation
A page refresh currently loses the run. Dungeon Company is meant to grow over many raid cycles, so it needs reliable local persistence and a New Game / Continue flow.
Scope
- Save module in
src/save/that serializes and deserializes all state needed to rebuild a run:- dungeon layout (rooms)
- gold and economy
- hired monsters
- placed traps
- progression and unlocks
- raid number
- current phase (only
PreparationorResultsis ever saved) - any other state required to reconstruct the run
- Serialization must use plain JSON-safe data, not Phaser objects. If run state is currently spread across scenes, the agent may introduce a small serializable run-state accessor. A broader refactor is not in scope.
- Storage:
localStorageunder the single keydungeon-company:save. Assumption: saves are small, so IndexedDB is not needed. - Envelope:
- Format:
{ version: number, savedAt: ISO-8601 string, state: {...} }. - Export a
SAVE_VERSIONconstant. - Provide a
migrate(data)hook that currently only accepts the current version and rejects anything else as incompatible.
- Format:
- Autosave checkpoints (synchronous, never per-frame):
- after each committed Preparation change: build, hire, place, sell
- on entering Results
- on leaving Results into the next Preparation
- No save during an active raid. A refresh mid-raid restores the most recent Preparation checkpoint, which is the state just before that raid started.
- Hero positions, timers, transient combat state and RNG state are not persisted.
- Assumption: if the Results screen cannot be rebuilt from the saved state (e.g. it needs a transient raid summary), persist the minimal summary it needs. If that is impractical, a Results checkpoint may restore into the following Preparation phase instead. Document whichever choice is made in the PR.
- Start flow:
- On load, if a valid save exists, show Continue and New Game. Otherwise show only New Game.
- If no start screen exists, create a minimal one styled consistently with the current UI.
- Do not redesign menus or the title screen.
- Continue restores the run into the saved phase.
- New Game confirmation:
- When a save exists, New Game shows a confirm dialog stating the current run will be overwritten.
- Confirm starts a fresh run, and the old save is overwritten at the first checkpoint or immediately.
- Cancel leaves the save untouched and returns to the start screen.
- Corrupt or incompatible data:
- Covers JSON parse failure, missing or unknown
version, and failed schema validation (missing or wrong-typed required fields). - Load returns a handled error result and never throws.
- The raw string is copied to
dungeon-company:save:corruptbefore anything overwrites it. - The start screen shows a short message with a Start New Game option. Continue is not shown.
- Covers JSON parse failure, missing or unknown
- Storage errors:
- If
localStorageis unavailable or a write throws (e.g. quota exceeded), log aconsole.warn. - Save and load return gracefully, and the game remains playable without persistence.
- If
- RNG: no seeded RNG or deterministic replay is required. Future RNG persistence would arrive via a schema version bump.
Acceptance criteria
- Unit test: round-trip serialize → deserialize of a non-trivial run yields deeply equal state. The run includes multiple rooms, multiple monsters, multiple traps, gold, raid > 1 and at least one progression or unlock entry.
- Unit test: the stored envelope has a numeric
versionequal toSAVE_VERSIONand an ISOsavedAtstring. - Unit tests, each returning a handled error result without throwing and writing the raw data to
dungeon-company:save:corrupt:- malformed JSON
- missing version
- unknown version
- missing required fields
- Unit test: a mocked
localStorage.setItemthat throws does not cause the save call to throw. - Unit test: unavailable
localStorage(getter throws or is undefined) does not cause load or save to throw. - Autosave runs on Preparation commits (build, hire, place, sell), on entering Results and on leaving Results. This is verified by a test or by explicit calls in the commit and phase-transition code.
- There is no save call in the raid update loop or any per-frame path.
- The start screen shows Continue only when a valid save exists. Continue restores the run into the saved phase, and a mid-raid refresh restores the pre-raid Preparation state.
- New Game with an existing save requires confirmation, and Cancel leaves the stored save byte-identical.
- Existing tests, lint/typecheck (if configured) and
vite buildpass. No backend, network or account code is added.
Out of scope
- cloud, account or cross-device sync
- multiple save slots
- import/export of save files
- multiplayer state
- resuming mid-raid simulation state
- seeded or deterministic RNG
- real migrations beyond the version hook
- a title or menu redesign
Model claude-opus-5-5 · ceiling $5 · started 1 hour ago · finished 1 hour ago
- **`src/ui/StartScreen.ts`** + **`src/style.css`**: a small overlay that reuses the existing Results card styles. - Continue appears only when the save is valid. - New Game, when a save exists, asks for confirmation and says the current run will be overwritten. Cancel goes back without touching storage. - A corrupt or incompatible save shows a short message and only a **Start New Game** button. - **`src/main.ts`**: connects it all. Saves happen at checkpoints; a confirmed New Game saves immediately. On load, the restore check runs against a throwaway `Game`, so the live game is never touched by a bad save. - **`src/scenes/DungeonScene.ts`**: re-centres the camera when `runVersion` changes. - **README**: new "Saving" section and project-layout rows. ### Choices made where the spec left room - **Results checkpoint:** the Results screen only needs `lastResult`, which is already plain data, so it is saved. Continue reopens the actual Results screen rather than skipping to the next Preparation. - **Mid-raid refresh:** nothing is saved during a raid, so the last checkpoint is the Preparation state just before it started. A test covers this. - **"Sell":** the game has no sell action yet. `removeRoom` is the nearest equivalent and it is a checkpoint. - **Progression/unlocks:** the game has no unlock system yet. I added a `Game.unlocks: string[]` field that is saved and restored but that nothing writes to yet. It exists to satisfy the "at least one progression entry" round-trip test and give a future feature somewhere to store unlocks. - **New devDependency:** `happy-dom`, used only by `tests/start-screen.test.ts` (it opts in with `// @vitest-environment happy-dom`). Other tests still run in Node. ### Tests - **`tests/save.test.ts`**: - Round-trip of a non-trivial run: more than 3 rooms, several monsters and traps, raid > 1, gold, one unlock. - Round-trip of a Results checkpoint. - The envelope has a numeric `version` equal to `SAVE_VERSION` and an ISO `savedAt`. - `migrate` behaviour. - Malformed JSON, missing version, unknown version, missing fields, wrong-typed fields and a saved Raid phase each return a handled result, copy the raw data to the corrupt key and leave the main key unchanged. - A throwing `setItem`, a throwing `localStorage` getter, an undefined `localStorage` and a throwing `getItem` all fail without throwing. - **`tests/checkpoints.test.ts`**: - Checkpoints fire on build, hire, place and remove, and not for rejected actions. - None fire during raid ticks; they fire once on entering Results and once on leaving it. - The last checkpoint before a raid is the pre-raid state. - `restore` leaves the run untouched on unknown content. - **`tests/start-screen.test.ts`**: - Only New Game appears when there is no save. - Continue restores into the saved Results phase. - New Game asks for confirmation, and Cancel leaves the stored save byte-identical. - Confirming overwrites the save. - A corrupt save shows the message and only Start New Game. ### Worth checking - `finishRaid()` is called from `Game.update()` when a raid ends, so the "entering Results" save technically starts inside the frame loop. It runs once per raid, at the phase change, and never while the raid is still running (a test checks this). If you'd rather keep saving out of `update()` entirely, the alternative is to save from the HUD or scene when the phase changes. - No backend, network or account code was added. 2026-10-09T10:13:06Z [claude] I've added local saving: the game now saves to `localStorage` automatically, and a start screen on page load offers **Continue** and **New Game**. All 68 tests pass (41 existing, 27 new), and the typecheck and `vite build` succeed. I haven't opened the game in a real browser; the start screen is only tested against a simulated DOM. The work is pushed to `nabled/feature-27-job-29`. **When it saves:** - **Building:** after every build, remove, hire or trap placement. - **Results:** when Results opens and when you go from Results to the next Preparation. - **Never during a raid.** Refreshing mid-raid brings back the state just before that raid started. **Start screen:** - **Continue** appears only when there's a valid save, and it reopens the run in the phase it was saved in, including the Results screen. - **New Game** asks for confirmation if a save exists; Cancel leaves the stored save exactly as it was. - A damaged or incompatible save is copied to `dungeon-company:save:corrupt`, and the screen shows a short message with only **Start New Game**. If `localStorage` is unavailable or a save fails, the game logs a warning and keeps running without saving. **Choices where the game didn't match the spec:** - **No "sell" action exists yet,** so removing a room counts as the sell checkpoint. - **There's no unlock system yet,** so I added an `unlocks` list that is saved and restored but that nothing fills in. It satisfies the "at least one progression entry" round-trip test and gives a future feature somewhere to store unlocks. - **New test dependency:** `happy-dom`, used only by the start-screen test. **Worth a look:** the save on entering Results starts from inside the game's per-frame update, because that's where the raid ends. It runs once per raid and never while the raid is still running (a test checks this). If you'd rather keep all saving out of the frame loop, it could be moved to the HUD's phase-change handling instead. The main new code is in `src/save/` (the save format and validation in `schema.ts`, `localStorage` access in `storage.ts`) and `src/ui/StartScreen.ts`, with small changes to `src/core/game.ts` and `src/main.ts`. 2026-10-09T10:13:08Z Changed files: modified README.md +14 -2 modified package-lock.json +112 -0 modified package.json +1 -0 modified src/core/game.ts +82 -1 modified src/core/types.ts +21 -0 modified src/main.ts +16 -1 added src/save/index.ts +2 -0 added src/save/schema.ts +126 -0 added src/save/storage.ts +84 -0 modified src/scenes/DungeonScene.ts +3 -0 modified src/style.css +35 -0 added src/ui/StartScreen.ts +111 -0 added tests/checkpoints.test.ts +78 -0 added tests/save.test.ts +238 -0 added tests/start-screen.test.ts +102 -0 added tests/storage-helpers.ts +28 -0 2026-10-09T10:13:10Z Opened pull request https://github.com/nabledhq/dungeon-company/pull/3 2026-10-09T10:13:10Z Finished: success=true turns=21 tokens(in/out)=1045785/26506 list cost=$1.07
Show patch
diff --git a/README.md b/README.md
index 96a3da8..f3d6b4a 100644
--- a/README.md
+++ b/README.md
@@ -48,8 +48,18 @@ The game loops through three phases: **Preparation → Raid → Results → Prep
Camera: drag with the mouse or use the arrow keys/WASD to pan, and the mouse wheel to zoom.
The sidebar categories are placeholders for now. They list the registered content, and
-placing it will be added later. State is in memory only, so reloading the page starts a
-new game.
+placing it will be added later.
+
+### Saving
+
+The run is saved automatically to `localStorage` (key `dungeon-company:save`) at
+checkpoints: after every build action (build, remove, hire, place), on entering Results
+and on returning to Preparation. Nothing is saved during a raid, so reloading mid-raid
+returns you to the Preparation state just before that raid. On page load a start screen
+offers **Continue** (only when a valid save exists) and **New Game**, which asks for
+confirmation before overwriting an existing run. Unreadable or incompatible saves are
+copied to `dungeon-company:save:corrupt` and the start screen offers a new game instead.
+If `localStorage` is unavailable the game still runs, just without persistence.
## Project layout
@@ -60,6 +70,8 @@ new game.
| `src/content/placeholders.ts` | Placeholder rooms, monsters, traps, hero party generator and reward hook |
| `src/scenes/DungeonScene.ts` | Phaser scene: renders the dungeon and party, camera pan/zoom, and drives the raid clock |
| `src/ui/Hud.ts`, `src/style.css` | DOM HUD overlaid on the canvas: top bar, sidebar and Results screen |
+| `src/ui/StartScreen.ts` | New Game / Continue overlay shown on page load |
+| `src/save/` | Versioned save envelope, validation and `migrate()` hook (`schema.ts`), `localStorage` access (`storage.ts`) |
| `tests/` | Vitest unit tests for the core logic |
Every state change goes through `Game` (`src/core/game.ts`). Illegal phase transitions
diff --git a/package-lock.json b/package-lock.json
index 4ecebfd..7fc7219 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"phaser": "^3.90.0"
},
"devDependencies": {
+ "happy-dom": "^20.14.6",
"typescript": "^5.9.3",
"vite": "^8.3.4",
"vitest": "^5.0.3"
@@ -345,6 +346,33 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "26.6.4",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
+ "integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~8.9.0"
+ }
+ },
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vitest/mocker": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz",
@@ -393,6 +421,19 @@
"node": ">=12"
}
},
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/chai": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
@@ -413,6 +454,19 @@
"node": ">=8"
}
},
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@@ -479,6 +533,25 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/happy-dom": {
+ "version": "20.14.6",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.6.tgz",
+ "integrity": "sha512-5DDsf4QmEIlRL6NkCv/fxSjYHHkZceL/IaYQdJc2h3G7DRTEai++VTjbfL0LB9geW2VrA3+vrjF3Efx+OTtX0Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -943,6 +1016,13 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
+ "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
@@ -1104,6 +1184,16 @@
}
}
},
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/why-is-node-running": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
@@ -1116,6 +1206,28 @@
"engines": {
"node": ">=20.11"
}
+ },
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/package.json b/package.json
index ac6c963..eaa2acc 100644
--- a/package.json
+++ b/package.json
@@ -17,6 +17,7 @@
"phaser": "^3.90.0"
},
"devDependencies": {
+ "happy-dom": "^20.14.6",
"typescript": "^5.9.3",
"vite": "^8.3.4",
"vitest": "^5.0.3"
diff --git a/src/core/game.ts b/src/core/game.ts
index 2b32d12..5af02fb 100644
--- a/src/core/game.ts
+++ b/src/core/game.ts
@@ -4,7 +4,7 @@ import { raidGoldDelta } from './economy';
import { BuildLockedError, GameError, IllegalTransitionError } from './errors';
import { RaidSimulation } from './raid';
import type { Registries } from './registry';
-import type { Dungeon, Phase, RaidResult, Room } from './types';
+import type { Dungeon, Phase, RaidResult, Room, RunState } from './types';
/** The only legal transitions: Preparation → Raid → Results → Preparation. */
const NEXT_PHASE: Record<Phase, Phase> = {
@@ -22,6 +22,9 @@ export interface GameOptions {
export type GameListener = (game: Game) => void;
+/** Called at save checkpoints with the state to persist. Never called during a raid. */
+export type CheckpointListener = (state: RunState) => void;
+
/**
* Owns all game state and enforces the phase machine and the build lock.
* Every mutation goes through a method here; invalid calls throw a GameError
@@ -42,9 +45,14 @@ export class Game {
lastResult: RaidResult | null = null;
paused = false;
speed = 1;
+ /** Ids of unlocked progression entries. Persisted with the run; nothing grants unlocks yet. */
+ unlocks: string[] = [];
+ /** Incremented whenever the whole run is replaced (new game or restore). */
+ runVersion = 0;
private readonly random: () => number;
private readonly listeners = new Set<GameListener>();
+ private readonly checkpointListeners = new Set<CheckpointListener>();
private nextRoomNumber = 1;
constructor(options: GameOptions) {
@@ -65,10 +73,73 @@ export class Game {
this.paused = false;
this.speed = this.config.raid.speedOptions[0] ?? 1;
this.nextRoomNumber = 1;
+ this.unlocks = [];
this.dungeonVersion++;
+ this.runVersion++;
this.emit();
}
+ /** JSON-safe copy of the run. Only available outside a raid. */
+ snapshot(): RunState {
+ if (this.phase === 'Raid') {
+ throw new GameError('INVALID_ACTION', 'The run cannot be saved during a raid');
+ }
+ return structuredClone({
+ phase: this.phase,
+ gold: this.gold,
+ cycle: this.cycle,
+ dungeon: this.dungeon,
+ nextRoomNumber: this.nextRoomNumber,
+ lastResult: this.lastResult,
+ unlocks: this.unlocks,
+ });
+ }
+
+ /**
+ * Replaces the run with a saved snapshot. Every content id is checked against the
+ * registries first; on error a GameError is thrown and the current run is untouched.
+ */
+ restore(state: RunState): void {
+ const dungeon = structuredClone(state.dungeon);
+ for (const room of dungeon.rooms) {
+ this.registries.rooms.get(room.typeId);
+ room.monsterTypeIds.forEach((id) => this.registries.monsters.get(id));
+ room.trapTypeIds.forEach((id) => this.registries.traps.get(id));
+ }
+ for (const [a, b] of dungeon.connections) {
+ getRoom(dungeon, a);
+ getRoom(dungeon, b);
+ }
+ if (!findPath(dungeon)) {
+ throw new GameError('INVALID_ACTION', 'Saved dungeon has no path from entrance to core');
+ }
+ if (state.phase === 'Results' && !state.lastResult) {
+ throw new GameError('INVALID_ACTION', 'Saved Results phase has no raid result');
+ }
+ this.dungeon = dungeon;
+ this.phase = state.phase;
+ this.gold = state.gold;
+ this.cycle = state.cycle;
+ this.nextRoomNumber = state.nextRoomNumber;
+ this.lastResult = state.lastResult ? { ...state.lastResult } : null;
+ this.unlocks = [...state.unlocks];
+ this.raid = null;
+ this.paused = false;
+ this.speed = this.config.raid.speedOptions[0] ?? 1;
+ this.dungeonVersion++;
+ this.runVersion++;
+ this.emit();
+ }
+
+ /**
+ * Subscribes to save checkpoints: after each committed build action, on entering
+ * Results and on returning to Preparation. Raid ticks never trigger a checkpoint.
+ */
+ onCheckpoint(listener: CheckpointListener): () => void {
+ this.checkpointListeners.add(listener);
+ return () => this.checkpointListeners.delete(listener);
+ }
+
onChange(listener: GameListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
@@ -150,6 +221,7 @@ export class Game {
this.paused = false;
this.phase = 'Results';
this.emit();
+ this.checkpoint();
}
/** Results → Preparation. */
@@ -158,6 +230,7 @@ export class Game {
this.raid = null;
this.phase = 'Preparation';
this.emit();
+ this.checkpoint();
}
// ---- Raid controls -------------------------------------------------------
@@ -266,9 +339,17 @@ export class Game {
this.gold -= cost;
}
+ /** Every build action ends here, so each committed Preparation change is a checkpoint. */
private dungeonChanged(): void {
this.dungeonVersion++;
this.emit();
+ this.checkpoint();
+ }
+
+ private checkpoint(): void {
+ if (this.checkpointListeners.size === 0) return;
+ const state = this.snapshot();
+ for (const listener of this.checkpointListeners) listener(state);
}
private emit(): void {
diff --git a/src/core/types.ts b/src/core/types.ts
index 2bf99ac..3f08233 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -47,3 +47,24 @@ export interface RaidResult {
partyStrengthEnd: number;
roomsVisited: number;
}
+
+/** Phases a run can be saved in. Raids are never saved. */
+export type SavablePhase = Exclude<Phase, 'Raid'>;
+
+/**
+ * Plain JSON-safe snapshot of everything needed to rebuild a run between raids.
+ * Transient raid state (hero positions, timers, RNG) is deliberately not included.
+ */
+export interface RunState {
+ phase: SavablePhase;
+ gold: number;
+ /** Number of raids started so far (see Game.cycle). */
+ cycle: number;
+ dungeon: Dungeon;
+ /** Counter used to allocate the next built room id. */
+ nextRoomNumber: number;
+ /** Summary shown on the Results screen; null in Preparation before the first raid. */
+ lastResult: RaidResult | null;
+ /** Ids of unlocked progression entries. Nothing grants unlocks yet. */
+ unlocks: string[];
+}
diff --git a/src/main.ts b/src/main.ts
index a8de03e..62342e7 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -3,15 +3,30 @@ import { DEFAULT_CONFIG } from './config';
import { registerPlaceholderContent } from './content/placeholders';
import { Game } from './core/game';
import { createRegistries } from './core/registry';
+import { loadRun, saveRun } from './save';
import { DungeonScene } from './scenes/DungeonScene';
import { Hud } from './ui/Hud';
+import { StartScreen } from './ui/StartScreen';
import './style.css';
const registries = createRegistries();
registerPlaceholderContent(registries);
const game = new Game({ registries, config: DEFAULT_CONFIG });
-const hud = new Hud(document.getElementById('hud')!, game);
+const hudRoot = document.getElementById('hud')!;
+const hud = new Hud(hudRoot, game);
+
+// Reject saves that reference content which is no longer registered, without touching the live game.
+const load = loadRun((state) => new Game({ registries, config: DEFAULT_CONFIG }).restore(state));
+new StartScreen(hudRoot, load, {
+ onContinue: (state) => game.restore(state),
+ onNewGame: () => {
+ game.newGame();
+ saveRun(game.snapshot());
+ },
+});
+// Autosave at checkpoints (build actions, entering Results, leaving Results). Never during a raid.
+game.onCheckpoint(saveRun);
new Phaser.Game({
type: Phaser.AUTO,
diff --git a/src/save/index.ts b/src/save/index.ts
new file mode 100644
index 0000000..1f5083c
--- /dev/null
+++ b/src/save/index.ts
@@ -0,0 +1,2 @@
+export * from './schema';
+export * from './storage';
diff --git a/src/save/schema.ts b/src/save/schema.ts
new file mode 100644
index 0000000..a78b550
--- /dev/null
+++ b/src/save/schema.ts
@@ -0,0 +1,126 @@
+import type { Dungeon, RaidResult, Room, RunState } from '../core/types';
+
+/** Bump when the shape of RunState changes, and teach migrate() the old version. */
+export const SAVE_VERSION = 1;
+
+export interface SaveEnvelope {
+ version: number;
+ /** ISO-8601 timestamp of the save. */
+ savedAt: string;
+ state: RunState;
+}
+
+/** Why stored data could not be turned into a run. */
+export type SaveErrorKind = 'corrupt' | 'incompatible';
+
+export type DecodeResult =
+ | { ok: true; envelope: SaveEnvelope }
+ | { ok: false; kind: SaveErrorKind; message: string };
+
+type Obj = Record<string, unknown>;
+
+const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v);
+const isInt = (v: unknown): v is number => Number.isInteger(v);
+const isNum = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
+const isStr = (v: unknown): v is string => typeof v === 'string';
+const isStrArray = (v: unknown): v is string[] => Array.isArray(v) && v.every(isStr);
+
+export function serializeRun(state: RunState, now = new Date()): string {
+ const envelope: SaveEnvelope = { version: SAVE_VERSION, savedAt: now.toISOString(), state };
+ return JSON.stringify(envelope);
+}
+
+/** Parses, migrates and validates a stored string. Never throws. */
+export function deserializeRun(raw: string): DecodeResult {
+ let data: unknown;
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ return { ok: false, kind: 'corrupt', message: 'Save data is not valid JSON' };
+ }
+ const migrated = migrate(data);
+ if (!migrated.ok) return migrated;
+ const envelope = migrated.data;
+ if (!isStr(envelope.savedAt) || Number.isNaN(Date.parse(envelope.savedAt))) {
+ return { ok: false, kind: 'corrupt', message: 'Save data has no valid savedAt timestamp' };
+ }
+ const problem = validateRunState(envelope.state);
+ if (problem) return { ok: false, kind: 'corrupt', message: `Save data is invalid: ${problem}` };
+ return { ok: true, envelope: envelope as unknown as SaveEnvelope };
+}
+
+/**
+ * Upgrades parsed save data to the current version. Only SAVE_VERSION is accepted
+ * for now; any other version is rejected as incompatible.
+ */
+export function migrate(
+ data: unknown,
+): { ok: true; data: Obj } | { ok: false; kind: SaveErrorKind; message: string } {
+ if (!isObj(data) || !('version' in data)) {
+ return { ok: false, kind: 'corrupt', message: 'Save data has no version' };
+ }
+ if (data.version !== SAVE_VERSION) {
+ return {
+ ok: false,
+ kind: 'incompatible',
+ message: `Save version ${String(data.version)} is not supported (expected ${SAVE_VERSION})`,
+ };
+ }
+ return { ok: true, data };
+}
+
+/** Returns a description of the first problem found, or null when the state is well-formed. */
+export function validateRunState(v: unknown): string | null {
+ if (!isObj(v)) return 'state is missing';
+ if (v.phase !== 'Preparation' && v.phase !== 'Results') return 'phase must be Preparation or Results';
+ if (!isNum(v.gold) || v.gold < 0) return 'gold must be a non-negative number';
+ if (!isInt(v.cycle) || v.cycle < 0) return 'cycle must be a non-negative integer';
+ if (!isInt(v.nextRoomNumber) || v.nextRoomNumber < 1) return 'nextRoomNumber must be a positive integer';
+ if (!isStrArray(v.unlocks)) return 'unlocks must be a list of ids';
+ const dungeonProblem = validateDungeon(v.dungeon);
+ if (dungeonProblem) return dungeonProblem;
+ if (v.lastResult !== null && !isRaidResult(v.lastResult)) return 'lastResult is malformed';
+ if (v.phase === 'Results' && v.lastResult === null) return 'Results phase needs lastResult';
+ return null;
+}
+
+function validateDungeon(v: unknown): string | null {
+ if (!isObj(v)) return 'dungeon is missing';
+ const d = v as Partial<Record<keyof Dungeon, unknown>>;
+ if (!Array.isArray(d.rooms) || d.rooms.length === 0) return 'dungeon.rooms must be a non-empty list';
+ if (!d.rooms.every(isRoom)) return 'dungeon.rooms contains a malformed room';
+ if (
+ !Array.isArray(d.connections) ||
+ !d.connections.every((c) => Array.isArray(c) && c.length === 2 && c.every(isStr))
+ ) {
+ return 'dungeon.connections must be a list of id pairs';
+ }
+ if (!isStr(d.entranceId) || !isStr(d.coreId)) return 'dungeon entrance and core ids are required';
+ return null;
+}
+
+function isRoom(v: unknown): v is Room {
+ return (
+ isObj(v) &&
+ isStr(v.id) &&
+ isStr(v.typeId) &&
+ isInt(v.col) &&
+ isInt(v.row) &&
+ isStrArray(v.monsterTypeIds) &&
+ isStrArray(v.trapTypeIds)
+ );
+}
+
+function isRaidResult(v: unknown): v is RaidResult {
+ return (
+ isObj(v) &&
+ isInt(v.cycle) &&
+ (v.outcome === 'success' || v.outcome === 'failure') &&
+ isNum(v.goldDelta) &&
+ isNum(v.goldBefore) &&
+ isNum(v.goldAfter) &&
+ isNum(v.partyStrengthStart) &&
+ isNum(v.partyStrengthEnd) &&
+ isInt(v.roomsVisited)
+ );
+}
diff --git a/src/save/storage.ts b/src/save/storage.ts
new file mode 100644
index 0000000..fc51665
--- /dev/null
+++ b/src/save/storage.ts
@@ -0,0 +1,84 @@
+import type { RunState } from '../core/types';
+import { deserializeRun, serializeRun, type SaveEnvelope, type SaveErrorKind } from './schema';
+
+export const SAVE_KEY = 'dungeon-company:save';
+/** Unreadable save data is copied here before anything can overwrite it. */
+export const CORRUPT_SAVE_KEY = 'dungeon-company:save:corrupt';
+
+export type LoadResult =
+ | { status: 'ok'; envelope: SaveEnvelope }
+ /** No save stored. */
+ | { status: 'empty' }
+ /** localStorage cannot be used; the game runs without persistence. */
+ | { status: 'unavailable' }
+ | { status: SaveErrorKind; message: string };
+
+/** localStorage, or null when it is missing or access throws (e.g. blocked by privacy settings). */
+function getStorage(): Storage | null {
+ try {
+ const storage = globalThis.localStorage;
+ return storage ?? null;
+ } catch {
+ return null;
+ }
+}
+
+/** Writes a save checkpoint. Returns false (after a console.warn) if it could not be stored. */
+export function saveRun(state: RunState): boolean {
+ const storage = getStorage();
+ if (!storage) {
+ console.warn('Dungeon Company: localStorage is unavailable, the run will not be saved');
+ return false;
+ }
+ try {
+ storage.setItem(SAVE_KEY, serializeRun(state));
+ return true;
+ } catch (err) {
+ console.warn('Dungeon Company: could not save the run', err);
+ return false;
+ }
+}
+
+/**
+ * Reads the stored run. Never throws. Corrupt or incompatible data is copied to
+ * CORRUPT_SAVE_KEY and reported in the result; the original key is left as it was.
+ * `check` may throw to reject a well-formed state that cannot be restored (for
+ * example one referencing content that is no longer registered).
+ */
+export function loadRun(check?: (state: RunState) => void): LoadResult {
+ const storage = getStorage();
+ if (!storage) {
+ console.warn('Dungeon Company: localStorage is unavailable, saves are disabled');
+ return { status: 'unavailable' };
+ }
+ let raw: string | null;
+ try {
+ raw = storage.getItem(SAVE_KEY);
+ } catch (err) {
+ console.warn('Dungeon Company: could not read the save', err);
+ return { status: 'unavailable' };
+ }
+ if (raw === null) return { status: 'empty' };
+ const decoded = deserializeRun(raw);
+ if (!decoded.ok) {
+ preserveCorrupt(raw);
+ return { status: decoded.kind, message: decoded.message };
+ }
+ try {
+ check?.(decoded.envelope.state);
+ } catch (err) {
+ preserveCorrupt(raw);
+ const message = err instanceof Error ? err.message : String(err);
+ return { status: 'incompatible', message: `Save cannot be restored: ${message}` };
+ }
+ return { status: 'ok', envelope: decoded.envelope };
+}
+
+/** Copies unusable save data aside so it survives the next save. Never throws. */
+export function preserveCorrupt(raw: string): void {
+ try {
+ getStorage()?.setItem(CORRUPT_SAVE_KEY, raw);
+ } catch (err) {
+ console.warn('Dungeon Company: could not back up the unreadable save', err);
+ }
+}
diff --git a/src/scenes/DungeonScene.ts b/src/scenes/DungeonScene.ts
index df7c34c..9d428c0 100644
--- a/src/scenes/DungeonScene.ts
+++ b/src/scenes/DungeonScene.ts
@@ -37,6 +37,7 @@ export class DungeonScene extends Phaser.Scene {
private readonly getInsets: () => ViewportInsets;
private renderedVersion = -1;
+ private centeredRun = -1;
private corridors!: Phaser.GameObjects.Graphics;
private roomLayer!: Phaser.GameObjects.Container;
private partyLayer!: Phaser.GameObjects.Container;
@@ -67,6 +68,7 @@ export class DungeonScene extends Phaser.Scene {
update(_time: number, delta: number): void {
this.game_.update(Math.min(delta, MAX_FRAME_MS));
if (this.renderedVersion !== this.game_.dungeonVersion) this.redrawDungeon();
+ if (this.centeredRun !== this.game_.runVersion) this.centerCamera();
this.updateParty();
this.updateKeyboardPan(delta);
}
@@ -127,6 +129,7 @@ export class DungeonScene extends Phaser.Scene {
/** Centre the dungeon in the part of the canvas not covered by the HUD. */
private centerCamera(): void {
+ this.centeredRun = this.game_.runVersion;
const rooms = this.game_.dungeon.rooms;
if (rooms.length === 0) return;
const xs = rooms.map((r) => r.col * CELL);
diff --git a/src/style.css b/src/style.css
index 9a29b31..3059ece 100644
--- a/src/style.css
+++ b/src/style.css
@@ -455,3 +455,38 @@ button {
font-size: 18px;
}
}
+
+/* ---- Start screen ------------------------------------------------------- */
+
+.start-screen {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.6);
+}
+
+.start-title {
+ color: var(--gold);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ text-shadow: 0 2px 0 #000;
+}
+
+.start-error {
+ margin: 12px 0 20px;
+ color: #ff6b5e;
+}
+
+.start-buttons {
+ display: flex;
+ justify-content: center;
+ gap: 12px;
+}
+
+.start-secondary {
+ height: 52px;
+ padding: 0 22px;
+ font-size: 18px;
+}
diff --git a/src/ui/StartScreen.ts b/src/ui/StartScreen.ts
new file mode 100644
index 0000000..debac50
--- /dev/null
+++ b/src/ui/StartScreen.ts
@@ -0,0 +1,111 @@
+import type { RunState } from '../core/types';
+import type { LoadResult } from '../save/storage';
+
+export interface StartScreenActions {
+ /** Restore the saved run. */
+ onContinue(state: RunState): void;
+ /** Start a fresh run (overwriting any save). */
+ onNewGame(): void;
+}
+
+function el<K extends keyof HTMLElementTagNameMap>(
+ tag: K,
+ className?: string,
+ text?: string,
+): HTMLElementTagNameMap[K] {
+ const node = document.createElement(tag);
+ if (className) node.className = className;
+ if (text !== undefined) node.textContent = text;
+ return node;
+}
+
+/**
+ * Minimal New Game / Continue overlay shown on page load. Continue only appears
+ * when a valid save exists; New Game asks for confirmation before replacing it.
+ */
+export class StartScreen {
+ private readonly overlay = el('div', 'start-screen');
+ private readonly card = el('div', 'hud-results-card start-card');
+
+ constructor(
+ root: HTMLElement,
+ private readonly load: LoadResult,
+ private readonly actions: StartScreenActions,
+ ) {
+ this.overlay.setAttribute('role', 'dialog');
+ this.overlay.setAttribute('aria-modal', 'true');
+ this.overlay.append(this.card);
+ root.append(this.overlay);
+ this.showMenu();
+ }
+
+ private showMenu(): void {
+ const { load } = this;
+ const title = el('h2', 'start-title', 'Dungeon Company');
+ const nodes: HTMLElement[] = [title];
+ const buttons = el('div', 'start-buttons');
+
+ if (load.status === 'ok') {
+ const { state, savedAt } = load.envelope;
+ nodes.push(el('p', 'hud-results-detail', `Saved run: ${describeRun(state)} • ${formatDate(savedAt)}`));
+ const cont = el('button', 'hud-btn hud-start', 'Continue');
+ cont.addEventListener('click', () => this.finish(() => this.actions.onContinue(state)));
+ const fresh = el('button', 'hud-btn start-secondary', 'New Game');
+ fresh.addEventListener('click', () => this.showConfirm(state));
+ buttons.append(cont, fresh);
+ } else if (load.status === 'corrupt' || load.status === 'incompatible') {
+ nodes.push(
+ el(
+ 'p',
+ 'start-error',
+ load.status === 'corrupt'
+ ? 'Your saved game is damaged and cannot be loaded.'
+ : 'Your saved game is from an incompatible version and cannot be loaded.',
+ ),
+ );
+ buttons.append(this.newGameButton('Start New Game'));
+ } else {
+ if (load.status === 'unavailable') {
+ nodes.push(el('p', 'hud-results-detail', 'Saving is unavailable in this browser. Progress will not be kept.'));
+ }
+ buttons.append(this.newGameButton('New Game'));
+ }
+ nodes.push(buttons);
+ this.card.replaceChildren(...nodes);
+ buttons.querySelector('button')?.focus();
+ }
+
+ private showConfirm(state: RunState): void {
+ const cancel = el('button', 'hud-btn start-secondary', 'Cancel');
+ cancel.addEventListener('click', () => this.showMenu());
+ const buttons = el('div', 'start-buttons');
+ buttons.append(cancel, this.newGameButton('Start New Game'));
+ this.card.replaceChildren(
+ el('h2', 'start-title', 'Start a new game?'),
+ el('p', 'hud-results-detail', `Your current run (${describeRun(state)}) will be overwritten.`),
+ buttons,
+ );
+ cancel.focus();
+ }
+
+ private newGameButton(label: string): HTMLButtonElement {
+ const button = el('button', 'hud-btn hud-start', label);
+ button.addEventListener('click', () => this.finish(() => this.actions.onNewGame()));
+ return button;
+ }
+
+ private finish(action: () => void): void {
+ this.overlay.remove();
+ action();
+ }
+}
+
+function describeRun(state: RunState): string {
+ const raid = state.phase === 'Preparation' ? state.cycle + 1 : state.cycle;
+ const where = state.phase === 'Preparation' ? 'preparing' : 'results';
+ return `Raid ${raid} (${where}), ${new Intl.NumberFormat('en-US').format(state.gold)} gold`;
+}
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleString();
+}
diff --git a/tests/checkpoints.test.ts b/tests/checkpoints.test.ts
new file mode 100644
index 0000000..5cffd86
--- /dev/null
+++ b/tests/checkpoints.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'vitest';
+import type { RunState } from '../src/core/types';
+import { makeGame, runRaidToEnd } from './helpers';
+
+function recordCheckpoints() {
+ const game = makeGame((c) => {
+ c.economy.starterGold = 5000;
+ });
+ const saved: RunState[] = [];
+ game.onCheckpoint((state) => saved.push(state));
+ return { game, saved };
+}
+
+describe('autosave checkpoints', () => {
+ it('fire after each committed Preparation change', () => {
+ const { game, saved } = recordCheckpoints();
+ const room = game.buildRoom('lair', 1, 0, 'corridor');
+ expect(saved).toHaveLength(1);
+ expect(saved[0].dungeon.rooms.some((r) => r.id === room.id)).toBe(true);
+ game.hireMonster(room.id, 'goblin');
+ expect(saved).toHaveLength(2);
+ game.placeTrap(room.id, 'spikes');
+ expect(saved).toHaveLength(3);
+ game.removeRoom(room.id);
+ expect(saved).toHaveLength(4);
+ expect(saved.at(-1)).toEqual(game.snapshot());
+ });
+
+ it('do not fire for rejected actions', () => {
+ const { game, saved } = recordCheckpoints();
+ expect(() => game.buildRoom('lair', 1, 1, 'corridor')).toThrow();
+ expect(saved).toHaveLength(0);
+ });
+
+ it('never fire during a raid, then fire on entering and leaving Results', () => {
+ const { game, saved } = recordCheckpoints();
+ game.startRaid();
+ for (let i = 0; i < 100_000 && game.phase === 'Raid'; i++) {
+ game.update(16);
+ if (game.phase === 'Raid') expect(saved).toHaveLength(0);
+ }
+ expect(game.phase).toBe('Results');
+ expect(saved).toHaveLength(1);
+ expect(saved[0].phase).toBe('Results');
+ expect(saved[0].lastResult).toEqual(game.lastResult);
+
+ game.continueToPreparation();
+ expect(saved).toHaveLength(2);
+ expect(saved[1].phase).toBe('Preparation');
+ expect(saved[1].cycle).toBe(1);
+ });
+
+ it('the latest checkpoint before a raid is the pre-raid Preparation state', () => {
+ const { game, saved } = recordCheckpoints();
+ game.hireMonster('lair', 'goblin');
+ const beforeRaid = game.snapshot();
+ game.startRaid();
+ game.update(500);
+ // A refresh now would restore the last checkpoint: the state just before the raid.
+ expect(saved.at(-1)).toEqual(beforeRaid);
+ const restored = makeGame();
+ restored.restore(saved.at(-1)!);
+ expect(restored.phase).toBe('Preparation');
+ expect(restored.cycle).toBe(0);
+ expect(restored.raid).toBeNull();
+ });
+
+ it('restore leaves the run untouched when the state references unknown content', () => {
+ const game = makeGame();
+ runRaidToEnd((game.startRaid(), game));
+ game.continueToPreparation();
+ const before = game.snapshot();
+ const bad = structuredClone(before);
+ bad.dungeon.rooms[0].monsterTypeIds.push('dragon-that-does-not-exist');
+ expect(() => game.restore(bad)).toThrow();
+ expect(game.snapshot()).toEqual(before);
+ });
+});
diff --git a/tests/save.test.ts b/tests/save.test.ts
new file mode 100644
index 0000000..8bf959e
--- /dev/null
+++ b/tests/save.test.ts
@@ -0,0 +1,238 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { RunState } from '../src/core/types';
+import {
+ CORRUPT_SAVE_KEY,
+ SAVE_KEY,
+ SAVE_VERSION,
+ deserializeRun,
+ loadRun,
+ migrate,
+ saveRun,
+ serializeRun,
+} from '../src/save';
+import { makeGame, runRaidToEnd } from './helpers';
+import { MemoryStorage } from './storage-helpers';
+
+/** A run with several rooms, monsters and traps, gold, raid > 1 and an unlock entry. */
+function buildRun() {
+ const game = makeGame((c) => {
+ c.economy.starterGold = 5000;
+ });
+ const lair = game.buildRoom('lair', 1, 0, 'corridor');
+ const corridor = game.buildRoom('trap-corridor', 1, 2, 'corridor');
+ game.hireMonster(lair.id, 'goblin');
+ game.hireMonster(lair.id, 'goblin');
+ game.hireMonster(corridor.id, 'goblin');
+ game.placeTrap(corridor.id, 'spikes');
+ game.placeTrap(lair.id, 'spikes');
+ for (let i = 0; i < 2; i++) {
+ game.startRaid();
+ runRaidToEnd(game);
+ game.continueToPreparation();
+ }
+ game.unlocks.push('room:lair');
+ return game;
+}
+
+function stubStorage(): MemoryStorage {
+ const storage = new MemoryStorage();
+ vi.stubGlobal('localStorage', storage);
+ return storage;
+}
+
+beforeEach(() => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(globalThis, 'localStorage');
+});
+
+describe('serialization', () => {
+ it('round-trips a non-trivial run to a deeply equal state', () => {
+ const game = buildRun();
+ const state = game.snapshot();
+ expect(state.dungeon.rooms.length).toBeGreaterThan(3);
+ expect(state.dungeon.rooms.flatMap((r) => r.monsterTypeIds).length).toBeGreaterThan(1);
+ expect(state.dungeon.rooms.flatMap((r) => r.trapTypeIds).length).toBeGreaterThan(1);
+ expect(state.cycle).toBeGreaterThan(1);
+ expect(state.unlocks.length).toBeGreaterThan(0);
+
+ const decoded = deserializeRun(serializeRun(state));
+ expect(decoded.ok).toBe(true);
+ if (!decoded.ok) return;
+ expect(decoded.envelope.state).toEqual(state);
+
+ const restored = makeGame();
+ restored.restore(decoded.envelope.state);
+ expect(restored.snapshot()).toEqual(state);
+ const lair = state.dungeon.rooms.find((r) => r.col === 1 && r.row === 0)!;
+ expect(restored.gold).toBe(game.gold);
+ expect(restored.cycle).toBe(game.cycle);
+ // The room id counter is restored, so new rooms do not collide with saved ones.
+ const next = restored.buildRoom('lair', 0, 0, lair.id);
+ expect(state.dungeon.rooms.some((r) => r.id === next.id)).toBe(false);
+ });
+
+ it('round-trips a Results checkpoint including the raid summary', () => {
+ const game = makeGame();
+ game.startRaid();
+ runRaidToEnd(game);
+ const state = game.snapshot();
+ expect(state.phase).toBe('Results');
+ const decoded = deserializeRun(serializeRun(state));
+ if (!decoded.ok) throw new Error(decoded.message);
+ const restored = makeGame();
+ restored.restore(decoded.envelope.state);
+ expect(restored.phase).toBe('Results');
+ expect(restored.lastResult).toEqual(game.lastResult);
+ restored.continueToPreparation();
+ expect(restored.phase).toBe('Preparation');
+ });
+
+ it('stores an envelope with numeric version and ISO savedAt', () => {
+ const storage = stubStorage();
+ expect(saveRun(buildRun().snapshot())).toBe(true);
+ const envelope = JSON.parse(storage.getItem(SAVE_KEY)!);
+ expect(typeof envelope.version).toBe('number');
+ expect(envelope.version).toBe(SAVE_VERSION);
+ expect(typeof envelope.savedAt).toBe('string');
+ expect(new Date(envelope.savedAt).toISOString()).toBe(envelope.savedAt);
+ expect(Object.keys(envelope).sort()).toEqual(['savedAt', 'state', 'version']);
+ });
+
+ it('snapshot is refused during a raid', () => {
+ const game = makeGame();
+ game.startRaid();
+ expect(() => game.snapshot()).toThrow();
+ });
+});
+
+describe('migrate', () => {
+ it('accepts only the current version', () => {
+ expect(migrate({ version: SAVE_VERSION }).ok).toBe(true);
+ expect(migrate({ version: SAVE_VERSION + 1 })).toMatchObject({ ok: false, kind: 'incompatible' });
+ expect(migrate({ version: '1' })).toMatchObject({ ok: false, kind: 'incompatible' });
+ expect(migrate({})).toMatchObject({ ok: false, kind: 'corrupt' });
+ });
+});
+
+describe('loading', () => {
+ it('returns empty when nothing is stored and ok for a valid save', () => {
+ stubStorage();
+ expect(loadRun()).toEqual({ status: 'empty' });
+ const state = buildRun().snapshot();
+ saveRun(state);
+ const result = loadRun();
+ expect(result.status).toBe('ok');
+ if (result.status === 'ok') expect(result.envelope.state).toEqual(state);
+ });
+
+ const valid = (): Record<string, unknown> =>
+ JSON.parse(serializeRun(buildRun().snapshot())) as Record<string, unknown>;
+
+ const cases: Array<[string, () => string, string]> = [
+ ['malformed JSON', () => '{"version": 1, "state": {', 'corrupt'],
+ [
+ 'missing version',
+ () => {
+ const data = valid();
+ delete data.version;
+ return JSON.stringify(data);
+ },
+ 'corrupt',
+ ],
+ ['unknown version', () => JSON.stringify({ ...valid(), version: 999 }), 'incompatible'],
+ [
+ 'missing required fields',
+ () => {
+ const data = valid();
+ const state = data.state as Partial<RunState>;
+ delete state.gold;
+ return JSON.stringify(data);
+ },
+ 'corrupt',
+ ],
+ [
+ 'wrong-typed required fields',
+ () => {
+ const data = valid();
+ (data.state as Record<string, unknown>).dungeon = { rooms: 'nope' };
+ return JSON.stringify(data);
+ },
+ 'corrupt',
+ ],
+ ['a saved Raid phase', () => {
+ const data = valid();
+ (data.state as Record<string, unknown>).phase = 'Raid';
+ return JSON.stringify(data);
+ }, 'corrupt'],
+ ];
+
+ for (const [name, makeRaw, status] of cases) {
+ it(`handles ${name} without throwing and backs up the raw data`, () => {
+ const storage = stubStorage();
+ const raw = makeRaw();
+ storage.setItem(SAVE_KEY, raw);
+ let result: ReturnType<typeof loadRun> | undefined;
+ expect(() => (result = loadRun())).not.toThrow();
+ expect(result?.status).toBe(status);
+ expect(storage.getItem(CORRUPT_SAVE_KEY)).toBe(raw);
+ expect(storage.getItem(SAVE_KEY)).toBe(raw);
+ });
+ }
+
+ it('treats a save rejected by the restore check as incompatible', () => {
+ const storage = stubStorage();
+ const data = valid();
+ (data.state as RunState).dungeon.rooms[0].typeId = 'removed-room-type';
+ const raw = JSON.stringify(data);
+ storage.setItem(SAVE_KEY, raw);
+ const result = loadRun((state) => makeGame().restore(state));
+ expect(result.status).toBe('incompatible');
+ expect(storage.getItem(CORRUPT_SAVE_KEY)).toBe(raw);
+ });
+});
+
+describe('storage errors', () => {
+ it('does not throw when setItem throws (e.g. quota exceeded)', () => {
+ const storage = stubStorage();
+ vi.spyOn(storage, 'setItem').mockImplementation(() => {
+ throw new DOMException('Quota exceeded', 'QuotaExceededError');
+ });
+ let ok: boolean | undefined;
+ expect(() => (ok = saveRun(makeGame().snapshot()))).not.toThrow();
+ expect(ok).toBe(false);
+ expect(console.warn).toHaveBeenCalled();
+ });
+
+ it('does not throw when the localStorage getter throws', () => {
+ Object.defineProperty(globalThis, 'localStorage', {
+ configurable: true,
+ get() {
+ throw new DOMException('Access denied', 'SecurityError');
+ },
+ });
+ expect(() => saveRun(makeGame().snapshot())).not.toThrow();
+ expect(saveRun(makeGame().snapshot())).toBe(false);
+ expect(() => loadRun()).not.toThrow();
+ expect(loadRun()).toEqual({ status: 'unavailable' });
+ expect(console.warn).toHaveBeenCalled();
+ });
+
+ it('does not throw when localStorage is undefined', () => {
+ vi.stubGlobal('localStorage', undefined);
+ expect(() => saveRun(makeGame().snapshot())).not.toThrow();
+ expect(loadRun()).toEqual({ status: 'unavailable' });
+ });
+
+ it('does not throw when getItem throws', () => {
+ const storage = stubStorage();
+ vi.spyOn(storage, 'getItem').mockImplementation(() => {
+ throw new Error('boom');
+ });
+ expect(loadRun()).toEqual({ status: 'unavailable' });
+ });
+});
diff --git a/tests/start-screen.test.ts b/tests/start-screen.test.ts
new file mode 100644
index 0000000..743131e
--- /dev/null
+++ b/tests/start-screen.test.ts
@@ -0,0 +1,102 @@
+// @vitest-environment happy-dom
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SAVE_KEY, loadRun, saveRun, type LoadResult } from '../src/save';
+import { StartScreen } from '../src/ui/StartScreen';
+import { makeGame } from './helpers';
+
+function buttons(root: HTMLElement): string[] {
+ return [...root.querySelectorAll('button')].map((b) => b.textContent ?? '');
+}
+
+function click(root: HTMLElement, label: string): void {
+ const button = [...root.querySelectorAll('button')].find((b) => b.textContent === label);
+ if (!button) throw new Error(`No "${label}" button (have: ${buttons(root).join(', ')})`);
+ button.click();
+}
+
+/** Wires the screen the same way main.ts does. */
+function mount(load: LoadResult) {
+ const root = document.createElement('div');
+ document.body.append(root);
+ const game = makeGame();
+ const screen = new StartScreen(root, load, {
+ onContinue: (state) => game.restore(state),
+ onNewGame: () => {
+ game.newGame();
+ saveRun(game.snapshot());
+ },
+ });
+ return { root, game, screen };
+}
+
+function savedRun() {
+ const game = makeGame();
+ game.hireMonster('lair', 'goblin');
+ game.startRaid();
+ for (let i = 0; i < 100_000 && game.phase === 'Raid'; i++) game.update(100);
+ saveRun(game.snapshot());
+ return game;
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ document.body.replaceChildren();
+});
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('start screen', () => {
+ it('shows only New Game when there is no save', () => {
+ const { root } = mount(loadRun());
+ expect(buttons(root)).toEqual(['New Game']);
+ click(root, 'New Game');
+ expect(root.querySelector('.start-screen')).toBeNull();
+ expect(localStorage.getItem(SAVE_KEY)).not.toBeNull();
+ });
+
+ it('Continue restores the run into the saved phase', () => {
+ const original = savedRun();
+ const { root, game } = mount(loadRun());
+ expect(buttons(root)).toEqual(['Continue', 'New Game']);
+ click(root, 'Continue');
+ expect(root.querySelector('.start-screen')).toBeNull();
+ expect(game.phase).toBe('Results');
+ expect(game.snapshot()).toEqual(original.snapshot());
+ });
+
+ it('New Game asks for confirmation and Cancel leaves the save byte-identical', () => {
+ savedRun();
+ const before = localStorage.getItem(SAVE_KEY);
+ const { root, game } = mount(loadRun());
+ click(root, 'New Game');
+ expect(root.textContent).toContain('will be overwritten');
+ expect(buttons(root)).toEqual(['Cancel', 'Start New Game']);
+ click(root, 'Cancel');
+ expect(localStorage.getItem(SAVE_KEY)).toBe(before);
+ expect(buttons(root)).toEqual(['Continue', 'New Game']);
+ expect(game.cycle).toBe(0);
+ });
+
+ it('confirming New Game starts a fresh run and overwrites the save', () => {
+ savedRun();
+ const before = localStorage.getItem(SAVE_KEY);
+ const { root, game } = mount(loadRun());
+ click(root, 'New Game');
+ click(root, 'Start New Game');
+ expect(root.querySelector('.start-screen')).toBeNull();
+ expect(game.phase).toBe('Preparation');
+ expect(game.cycle).toBe(0);
+ expect(localStorage.getItem(SAVE_KEY)).not.toBe(before);
+ const reloaded = loadRun();
+ expect(reloaded.status === 'ok' && reloaded.envelope.state.cycle).toBe(0);
+ });
+
+ it('shows a message and Start New Game (no Continue) for a corrupt save', () => {
+ localStorage.setItem(SAVE_KEY, 'not json');
+ const { root } = mount(loadRun());
+ expect(root.querySelector('.start-error')?.textContent).toMatch(/damaged/);
+ expect(buttons(root)).toEqual(['Start New Game']);
+ click(root, 'Start New Game');
+ expect(localStorage.getItem('dungeon-company:save:corrupt')).toBe('not json');
+ });
+});
diff --git a/tests/storage-helpers.ts b/tests/storage-helpers.ts
new file mode 100644
index 0000000..8ab926f
--- /dev/null
+++ b/tests/storage-helpers.ts
@@ -0,0 +1,28 @@
+/** In-memory Storage stand-in for the Node test environment. */
+export class MemoryStorage implements Storage {
+ private readonly data = new Map<string, string>();
+
+ get length(): number {
+ return this.data.size;
+ }
+
+ clear(): void {
+ this.data.clear();
+ }
+
+ getItem(key: string): string | null {
+ return this.data.get(key) ?? null;
+ }
+
+ key(index: number): string | null {
+ return [...this.data.keys()][index] ?? null;
+ }
+
+ removeItem(key: string): void {
+ this.data.delete(key);
+ }
+
+ setItem(key: string, value: string): void {
+ this.data.set(key, String(value));
+ }
+}
No comments yet.