Keep several saves, with migration, atomic writes and import previews (Closes #37)
Savegames now lists independent save slots made with the loaded ROM: new game, continue, rename, duplicate, export and delete. The native database moves to IndexedDB version 2; the singleton save becomes "Save 1" inside the upgrade transaction, so it migrates exactly once and the old store is left untouched. Each write adds a generation and drops older ones in one transaction, keeping two records with CRC-32 checksums: a damaged newest record falls back to the previous one. Slots and records from a newer player are kept and never written. Imports are checked in a scratch world before anything changes and previewed with the trainer, progress, saved time, game data version and the slots of the same trainer; they go into a new save or replace one, whose previous record stays recoverable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c12c81a27
commit
7d03f5c5ca
18 files changed
+2076
-53
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
|
||||
|
||||
**Current milestone: all of Johto, from the opening to Blackthorn City, the Rising Badge and the Battle Tower.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Save, export and import work now. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||
**Current milestone: all of Johto, from the opening to Blackthorn City, the Rising Badge and the Battle Tower.** Play in a minimal Crystal-style interface with Game, Savegames, Map, Pokédex, Link and Friends tabs. The native canvas fills the play area, and the live local map follows your position. The Map tab also shows the Johto map with your location, the towns you can fly to and the areas open so far. Savegames keeps several independent playthroughs: start a new game, continue, rename, duplicate, export or delete any save, and preview a backup before importing it. The Pokédex tab shows the Pokémon you have seen and caught. Link and Friends show their planned scope without placeholder data.
|
||||
|
||||
Walk around your home, the town and its buildings at the original walking speed. Mom gives you the Pokégear and asks the day of the week. The teacher stops you from leaving town. Your rival shoves you away from the lab window. Professor Elm introduces his errand, and you choose Chikorita, Cyndaquil or Totodile. The Pokémon's picture and stats come from your ROM, and you can nickname it. Elm's aide then gives you a Potion. These scenes run the game's own event scripts and movement data, with native choices and naming.
|
||||
|
||||
@@ -47,9 +47,9 @@ npm run dev
|
||||
|
||||
Open the printed local URL and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume. A new game starts with Professor Oak's introduction, where you choose your character, set the clock and pick a name.
|
||||
|
||||
Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys, the text size and the sound volume for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data.
|
||||
Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. **Settings** changes these keys, the text size and the sound volume for this browser. In choices, **Up / Down** change the answer, **X** confirms and **Z** answers No. Games save every 10 seconds and when paused, each into its own save. Switching apps pauses play. Download a backup before clearing browser data.
|
||||
|
||||
Native saves are separate from emulator snapshots and cartridge `.sav` files. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps.
|
||||
Native saves are separate from emulator snapshots and cartridge `.sav` files. Each save keeps its newest and previous write; if the newest was interrupted or damaged, the previous one loads and nothing is overwritten. The single save of earlier versions becomes "Save 1" the first time this version opens it. Saves and backups from a newer version are kept as they are and refused, never rewritten. Saving during a scene keeps the progress from before it, so the scene replays after loading. The time you give Oak is kept as an offset from the device clock (the steppers start at the device's time), and the weekday you give Mom as an offset from that clock's weekday. The tile counter measures in-game movement, not real-world steps.
|
||||
|
||||
## Reference emulator
|
||||
|
||||
@@ -87,7 +87,7 @@ CRYSTAL_ROM_PATH=/nonexistent/crystal-ci.gbc npm run test:e2e -- --workers=2
|
||||
|
||||
`git config core.hooksPath .githooks` keeps the forge's project board in step with commits: `Closes #N` in a message moves card N to Fait and closes it, any other `#N` moves it to En cours, and a commit on `main` without a reference adds a closed card to Fait. `[no-card]` skips a commit. See [project-board.mjs](scripts/project-board.mjs).
|
||||
|
||||
The [full port plan](docs/PLAN.md) covers remaining game systems, save slots, the world map, Pokédex, internet Link, friends and optional mobile step support for egg hatching. See the [architecture](docs/ARCHITECTURE.md) and [routine map and verification](docs/NATIVE_PORT.md) for module boundaries and reference checks.
|
||||
The [full port plan](docs/PLAN.md) covers remaining game systems, the world map, Pokédex, internet Link, friends and optional mobile step support for egg hatching. See the [architecture](docs/ARCHITECTURE.md) and [routine map and verification](docs/NATIVE_PORT.md) for module boundaries and reference checks.
|
||||
|
||||
References: PRET [documentation](https://pret.github.io/pokecrystal/), [wiki](https://github.com/pret/pokecrystal/wiki) and [source](https://github.com/pret/pokecrystal). The development reference uses [binjgb](https://github.com/binji/binjgb) under its [MIT license](reference/public/vendor/binjgb/LICENSE); see [provenance](reference/public/vendor/binjgb/PROVENANCE.md).
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ flowchart LR
|
||||
| `src/game/runtime.ts` | 30 Hz tick scheduling from real time, input routing and presentation updates |
|
||||
| `src/input` | Abstract buttons, multiple sources and browser bindings |
|
||||
| `src/app` | Runtime-independent load/play/pause/backup lifecycle |
|
||||
| `src/storage` | Versioned envelope, IndexedDB transactions and ordered writes |
|
||||
| `src/storage` | Versioned envelope, save slots with atomic generations and checksums, the singleton migration |
|
||||
| `src/ui`, `src/main.ts` | Accessible game tabs, UI composition and DOM dialogue, no ROM offsets |
|
||||
| `src/platform` | Browser locks, the installable app (service worker, install, storage) and future native adapters |
|
||||
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
|
||||
@@ -39,7 +39,7 @@ flowchart LR
|
||||
|
||||
Saves made during an unfinished walk or jump retain its source tile and completed step count, so loading cannot skip a landing event or map transition. Directional mats wait for exit input; doors, stairs and caves force an animated downward step after arrival.
|
||||
|
||||
Native and reference sessions use different IndexedDB databases and Web Locks. Imports reject incompatible schemas and invalid positions before changing the world. A failed import restores the prior state. Original cartridge saves and emulator snapshots cannot be used as native saves.
|
||||
Native and reference sessions use different IndexedDB databases and Web Locks. The native player keeps save slots (`storage/slots.ts`, `app/slot-session.ts`): each slot keeps its newest two records with checksums, written in one transaction, and loading falls back to the previous record when the newest is damaged. The singleton save of database version 1 becomes the first slot inside the version-2 upgrade. Imports are previewed in a scratch world and reject incompatible schemas and invalid positions before changing the world or any slot. A failed import, switch or new game restores the prior state. Original cartridge saves and emulator snapshots cannot be used as native saves.
|
||||
|
||||
`GameWorld` advances in overworld ticks of two video frames, as the original loop does. The runtime converts elapsed time to ticks. Tile events follow `CheckTileEvent`: a completed step checks connections, then warps, then coordinate events for the current scene. Idle ticks run the current scene script. Map entry runs the NEWMAP, TILES and OBJECTS callbacks on a copy first, so an unsupported callback refuses the step instead of entering a half-initialized map. Objects spawn from event flags at map load, and later flag changes leave them in place until `appear`/`disappear` or the next load.
|
||||
|
||||
|
||||
@@ -113,6 +113,8 @@ Native production is `dist/`; the optional reference output is `dist-reference/`
|
||||
|
||||
## Save schema
|
||||
|
||||
Saves live in slots (see [PLAN.md](PLAN.md), save slots): the slot model is version 1, each record keeps the version-1 envelope below, and the game state inside has its own schema.
|
||||
|
||||
Native saves are version 3. Version 2 added scene values by owning map, phone contacts, the weekday offset and DST flag, and positions of objects that scripts moved on the current map. Version 3 adds the player ID, party, bag, Pokédex seen/caught sets and money, and later Mom's savings (`momsMoney`, migrated by absence to 0). Early version-3 backups without money receive the original starting balance; explicitly invalid balances are rejected. Restores recompute each Pokémon's stats from its DVs and reject any mismatch, and check every item against its ROM pocket. Older saves migrate with new-game defaults; a missing player ID is drawn as a new game draws it. Objects are restored from the save on continue, and only the TILES callback runs, as in `MapSetupScript_Continue`.
|
||||
|
||||
Later fields keep, by absence-migrated defaults: `memory`, the saved WRAM bytes that scripts read and write directly, by PRET label (`SAVED_WRAM`); and `unownDex` with `firstUnownSeen` (`wUnownDex`, `wFirstUnownSeen`).
|
||||
|
||||
+10
-1
@@ -483,6 +483,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- Buena's show at the Radio Tower: `AskRememberPassword`, `BuenasPassword` (the category's three words, B disabled) and `BuenaPrize` (prizes and points beside the Blue Card balance, confirm, pay, receive). Tests: `tests/phone.test.ts` (rematches, a random call, no call without a caller, the daily reset, swarms, each station's lines and music, the radio card, a house radio, Buena's password and prizes) and `tests/radio-browser.spec.ts` (the radio card in the browser).
|
||||
- Not ported: the radio card's tuning dial graphics (the frequency is shown as text), holding up or down to turn the knob continuously, the Poké Flute channel waking Snorlax (Kanto is closed), and calls that use the mobile adapter.
|
||||
|
||||
## Implemented: save slots (board #37)
|
||||
|
||||
- `storage/slots.ts`: the native database (`crystal-native-port`) moves to IndexedDB version 2 with `slots` (durable ID, ROM hash, name, created/updated time, newest generation, and a display summary), `records` (keyed by slot and generation) and `meta` (the slot last played per ROM, the migration record). The version-1 `saves` store is copied into "Save 1" inside the upgrade transaction itself, so it happens exactly once (an aborted upgrade retries; a committed one never runs again); the record is copied byte for byte and the old store is left untouched.
|
||||
- Writes are atomic: one transaction adds generation n+1, drops generations below n and updates the slot. Each record carries a CRC-32 of its fields and state. Loading takes the newest intact record that the game accepts, falling back to the previous one (reported to the player); the next write supersedes the damaged one.
|
||||
- Versioning: slots carry `version: 1`, records the existing envelope (`version: 1`, `coreId`) and the game state its schema (1-3, migrated by `migrateSave`). A slot whose model or newest record is newer is listed, exportable and deletable, but never opened, copied or written.
|
||||
- `app/slot-session.ts` (`SlotSession`, native only; the reference keeps `GameSession`): load (last played slot, otherwise the newest readable one, otherwise a new game in a new slot), save into the slot captured with the snapshot (an autosave queued before a switch lands in its own slot), switch, new game, rename (1-24 characters), duplicate, delete (not the running slot), export, and import. Switching, new games and imports save the running game first and restore it if the target cannot be opened. `NativeRuntime.reset()` rebuilds the world from the loaded ROM so nothing carries over between slots.
|
||||
- Import preview (`previewImport`) checks the envelope, cartridge, player, base64 and the game state in a scratch world (`NativeRuntime.inspectState`) before anything changes, and shows the trainer and ID, place, play time, badges, caught count, saved time, the game data version (and its upgrade) and the slots of the same trainer, marking one with identical progress. The player adds the backup as a new save or replaces the running save or a conflicting one; a replaced slot keeps its previous record.
|
||||
- Tests: `tests/save-slots.test.ts` (migration once, a newer singleton kept, slot lifecycle, autosave isolation, recovery from a damaged or missing newest record, an interrupted write, rollback when a slot cannot be opened, rejected imports, conflicts and both import targets), `tests/native.test.ts` (`describe` on saves of schema 1, 2 and 3) and `tests/save-slots-browser.spec.ts` (the migration and every Savegames action in Chromium and WebKit).
|
||||
|
||||
## Native battles: current state
|
||||
|
||||
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Every ball can be thrown; only the contest's Park Ball handling remains.
|
||||
@@ -606,7 +615,7 @@ Acceptance: main-game/postgame playthroughs, representative optional content, ba
|
||||
- [x] Pokédex screen: New mode list and entries
|
||||
- [x] Scalable text and configurable keyboard controls
|
||||
- [ ] Fuller accessibility: announcements for every native screen and touch control remapping
|
||||
- [ ] Save slots with migration, atomic writes and import previews
|
||||
- [x] Save slots with migration, atomic writes and import previews
|
||||
- [x] Installable web app with offline app shell, safe updates and quota handling
|
||||
- [ ] Acceptance on physical iPhone and Android devices
|
||||
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import type { GameRuntime } from "./runtime";
|
||||
import type { SessionState } from "./session";
|
||||
import type { ValidatedRom } from "../rom/validate";
|
||||
import { encodeSave, type SaveRecord } from "../storage/saves";
|
||||
import {
|
||||
SLOT_VERSION,
|
||||
type SaveSlot,
|
||||
type SlotRepository,
|
||||
type SlotSummary,
|
||||
} from "../storage/slots";
|
||||
|
||||
/** What the slot session needs from the game beyond the lifecycle. */
|
||||
export interface SlotRuntime extends GameRuntime {
|
||||
newGame(): void;
|
||||
reset(): void;
|
||||
summary(): SlotSummary;
|
||||
inspectState(state: Uint8Array): { summary: SlotSummary; schema: number };
|
||||
}
|
||||
|
||||
export interface SlotEntry {
|
||||
slot: SaveSlot;
|
||||
/** This player can open and write it. Others came from a newer player
|
||||
* and are only kept, exported or deleted. */
|
||||
compatible: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** An import checked against the ROM, the player and the existing slots,
|
||||
* before anything changes. */
|
||||
export type ImportPreview =
|
||||
| {
|
||||
ok: true;
|
||||
record: SaveRecord;
|
||||
summary: SlotSummary;
|
||||
/** The native schema the backup was saved with; older ones upgrade. */
|
||||
schema: number;
|
||||
/** Slots of the same trainer (name and ID), newest first;
|
||||
* `identical` when the slot's newest record holds the same game. */
|
||||
conflicts: { slot: SaveSlot; identical: boolean }[];
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
reason: string;
|
||||
/** Whatever identity the file still shows. */
|
||||
identity?: { version?: unknown; coreId?: unknown; savedAt?: unknown };
|
||||
};
|
||||
|
||||
export interface LoadResult {
|
||||
restored: boolean;
|
||||
/** The newest record was damaged; an earlier one was loaded. */
|
||||
recovered: boolean;
|
||||
/** Slots that could not be opened and were kept as they are. */
|
||||
skipped: SaveSlot[];
|
||||
}
|
||||
|
||||
const MAX_BACKUP = 2_000_000;
|
||||
const NAME_PATTERN = /^[^\p{Cc}]{1,24}$/u;
|
||||
|
||||
/** The native game's saves: several independent slots, each written
|
||||
* atomically with the previous record kept for recovery. */
|
||||
export class SlotSession {
|
||||
state: SessionState = "empty";
|
||||
active?: SaveSlot;
|
||||
private romHash?: string;
|
||||
// Writes run in capture order, each into the slot it was captured from:
|
||||
// switching slots never lets an earlier autosave land in the new one.
|
||||
private writes: Promise<unknown> = Promise.resolve();
|
||||
constructor(
|
||||
readonly runtime: SlotRuntime,
|
||||
private store: SlotRepository,
|
||||
) {}
|
||||
|
||||
async load(
|
||||
rom: ValidatedRom,
|
||||
canvas: HTMLCanvasElement,
|
||||
): Promise<LoadResult> {
|
||||
this.state = "loading";
|
||||
this.romHash = undefined;
|
||||
this.active = undefined;
|
||||
try {
|
||||
await this.runtime.load(rom.bytes, canvas);
|
||||
this.romHash = rom.sha1;
|
||||
const slots = await this.store.list(rom.sha1);
|
||||
const preferred = await this.store.active(rom.sha1);
|
||||
slots.sort(
|
||||
(a, b) => Number(b.id === preferred) - Number(a.id === preferred),
|
||||
);
|
||||
const skipped: SaveSlot[] = [];
|
||||
for (const slot of slots) {
|
||||
try {
|
||||
const { recovered } = await this.open(slot);
|
||||
this.state = "paused";
|
||||
return { restored: true, recovered, skipped };
|
||||
} catch {
|
||||
skipped.push(slot);
|
||||
}
|
||||
}
|
||||
// A slot that failed to open may have left part of its game behind;
|
||||
// otherwise the world is as the ROM's load left it.
|
||||
if (skipped.length) this.runtime.reset();
|
||||
this.runtime.newGame();
|
||||
this.active = await this.store.create(
|
||||
rom.sha1,
|
||||
this.freeName(slots),
|
||||
this.snapshot(true),
|
||||
this.runtime.summary(),
|
||||
);
|
||||
await this.store.setActive(rom.sha1, this.active.id);
|
||||
this.state = "paused";
|
||||
return { restored: false, recovered: false, skipped };
|
||||
} catch (error) {
|
||||
this.runtime.dispose();
|
||||
this.romHash = undefined;
|
||||
this.state = "error";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
play() {
|
||||
if (this.state !== "paused") return;
|
||||
this.runtime.play();
|
||||
this.state = "playing";
|
||||
}
|
||||
pause() {
|
||||
if (this.state !== "playing") return;
|
||||
this.runtime.pause();
|
||||
this.state = "paused";
|
||||
}
|
||||
private get ready() {
|
||||
return Boolean(this.romHash) && ["paused", "playing"].includes(this.state);
|
||||
}
|
||||
snapshot(force = false): SaveRecord {
|
||||
if (!this.romHash || (!force && !this.ready))
|
||||
throw new Error("Load a ROM before saving.");
|
||||
return {
|
||||
version: 1,
|
||||
romHash: this.romHash,
|
||||
coreId: this.runtime.id,
|
||||
savedAt: new Date().toISOString(),
|
||||
state: this.runtime.captureState(),
|
||||
};
|
||||
}
|
||||
/** Saves the running game into its slot. */
|
||||
save(): Promise<string> {
|
||||
const slot = this.active;
|
||||
if (!slot) return Promise.reject(new Error("Load a ROM before saving."));
|
||||
const record = this.snapshot();
|
||||
const summary = this.runtime.summary();
|
||||
const operation = this.writes
|
||||
.catch(() => {})
|
||||
.then(() => this.store.write(slot.id, record, summary))
|
||||
.then((written) => {
|
||||
if (this.active?.id === written.id) this.active = written;
|
||||
return record.savedAt;
|
||||
});
|
||||
this.writes = operation;
|
||||
return operation;
|
||||
}
|
||||
|
||||
/** Every slot made with this ROM. */
|
||||
async slots(): Promise<SlotEntry[]> {
|
||||
if (!this.romHash) return [];
|
||||
const slots = await this.store.list(this.romHash);
|
||||
return Promise.all(
|
||||
slots.map(async (slot) => {
|
||||
const compatible = await this.compatible(slot);
|
||||
return {
|
||||
slot: compatible ? await this.withSummary(slot) : slot,
|
||||
compatible,
|
||||
active: slot.id === this.active?.id,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Continues another slot. The running game is saved first; if the slot
|
||||
* cannot be opened, the running game is restored as it was. */
|
||||
async switchTo(id: string) {
|
||||
const target = await this.slot(id);
|
||||
if (target.id === this.active?.id) return { recovered: false };
|
||||
await this.leave();
|
||||
const previous = this.runtime.captureState();
|
||||
try {
|
||||
return await this.open(target);
|
||||
} catch (error) {
|
||||
this.rollback(previous);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts a new game in a new slot. */
|
||||
async newGame(name?: string) {
|
||||
if (!this.romHash) throw new Error("Load your ROM first.");
|
||||
await this.leave();
|
||||
const previous = this.runtime.captureState();
|
||||
try {
|
||||
this.runtime.reset();
|
||||
this.runtime.newGame();
|
||||
const slots = await this.store.list(this.romHash);
|
||||
const slot = await this.store.create(
|
||||
this.romHash,
|
||||
this.checkName(name ?? this.freeName(slots)),
|
||||
this.snapshot(),
|
||||
this.runtime.summary(),
|
||||
);
|
||||
await this.select(slot);
|
||||
return slot;
|
||||
} catch (error) {
|
||||
this.rollback(previous);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async rename(id: string, name: string) {
|
||||
const slot = await this.store.rename(id, this.checkName(name));
|
||||
if (this.active?.id === id)
|
||||
this.active = { ...this.active, name: slot.name };
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** Copies a slot's newest readable record into a new slot. */
|
||||
async duplicate(id: string) {
|
||||
const source = await this.slot(id);
|
||||
if (!(await this.compatible(source)))
|
||||
throw new Error(
|
||||
"Saves from a newer version of Crystal cannot be copied.",
|
||||
);
|
||||
if (source.id === this.active?.id) await this.save();
|
||||
const { record, summary } = await this.latest(source);
|
||||
const slots = await this.store.list(source.romHash);
|
||||
return this.store.create(
|
||||
source.romHash,
|
||||
this.copyName(source.name, slots),
|
||||
{ ...record, savedAt: new Date().toISOString() },
|
||||
summary,
|
||||
);
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
if (id === this.active?.id)
|
||||
throw new Error("Load another save before deleting this one.");
|
||||
await this.store.delete(id);
|
||||
}
|
||||
|
||||
/** A backup file of the slot: the running game for the active slot,
|
||||
* otherwise its newest readable record, unchanged. */
|
||||
async exportSlot(id: string) {
|
||||
const slot = await this.slot(id);
|
||||
const record =
|
||||
slot.id === this.active?.id
|
||||
? this.snapshot()
|
||||
: (await this.store.records(id)).find((entry) => entry.intact)?.record;
|
||||
if (!record)
|
||||
throw new Error(`“${slot.name}” has no readable record to export.`);
|
||||
// A newer player's record is exported as it is stored.
|
||||
const text =
|
||||
record.state instanceof Uint8Array
|
||||
? encodeSave(record)
|
||||
: JSON.stringify(record);
|
||||
return { slot, text };
|
||||
}
|
||||
|
||||
/** Checks a backup without changing the running game or any slot. */
|
||||
async previewImport(text: string): Promise<ImportPreview> {
|
||||
if (!this.romHash)
|
||||
throw new Error("Load your ROM before importing a backup.");
|
||||
if (text.length > MAX_BACKUP)
|
||||
return { ok: false, reason: "This backup is too large." };
|
||||
let value: Record<string, unknown>;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
if (!value || typeof value !== "object") throw new Error();
|
||||
} catch {
|
||||
return { ok: false, reason: "This file is not a Crystal backup." };
|
||||
}
|
||||
const identity = {
|
||||
version: value.version,
|
||||
coreId: value.coreId,
|
||||
savedAt: value.savedAt,
|
||||
};
|
||||
if (typeof value.version === "number" && value.version > 1)
|
||||
return {
|
||||
ok: false,
|
||||
reason: `This backup uses save format ${value.version}, made by a newer version of Crystal. Update Crystal to import it.`,
|
||||
identity,
|
||||
};
|
||||
if (value.version !== 1)
|
||||
return {
|
||||
ok: false,
|
||||
reason: "This file is not a Crystal backup.",
|
||||
identity,
|
||||
};
|
||||
if (value.romHash !== this.romHash)
|
||||
return {
|
||||
ok: false,
|
||||
reason: "This backup was made with a different cartridge.",
|
||||
identity,
|
||||
};
|
||||
if (value.coreId !== this.runtime.id)
|
||||
return {
|
||||
ok: false,
|
||||
reason: `This backup belongs to another player (${String(value.coreId)}). Only native backups can be imported here.`,
|
||||
identity,
|
||||
};
|
||||
if (
|
||||
typeof value.savedAt !== "string" ||
|
||||
!Number.isFinite(Date.parse(value.savedAt)) ||
|
||||
typeof value.state !== "string" ||
|
||||
!/^[A-Za-z0-9+/]+={0,2}$/.test(value.state) ||
|
||||
value.state.length % 4 !== 0
|
||||
)
|
||||
return { ok: false, reason: "The backup is damaged.", identity };
|
||||
const record: SaveRecord = {
|
||||
version: 1,
|
||||
romHash: this.romHash,
|
||||
coreId: this.runtime.id,
|
||||
savedAt: value.savedAt,
|
||||
state: Uint8Array.from(atob(value.state), (char) => char.charCodeAt(0)),
|
||||
};
|
||||
let inspected: ReturnType<SlotRuntime["inspectState"]>;
|
||||
try {
|
||||
inspected = this.runtime.inspectState(record.state);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `The backup's game could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
||||
identity,
|
||||
};
|
||||
}
|
||||
const conflicts = [];
|
||||
for (const listed of await this.store.list(this.romHash)) {
|
||||
const slot = await this.withSummary(listed);
|
||||
if (
|
||||
slot.summary?.player !== inspected.summary.player ||
|
||||
slot.summary.playerId !== inspected.summary.playerId
|
||||
)
|
||||
continue;
|
||||
const newest = (await this.store.records(slot.id))[0]?.record.state;
|
||||
conflicts.push({
|
||||
slot,
|
||||
identical:
|
||||
newest instanceof Uint8Array &&
|
||||
newest.length === record.state.length &&
|
||||
newest.every((byte, index) => byte === record.state[index]),
|
||||
});
|
||||
}
|
||||
return { ok: true, record, ...inspected, conflicts };
|
||||
}
|
||||
|
||||
/** Imports a previewed backup into a new slot, or over a slot (whose
|
||||
* previous record stays recoverable), and continues it. On failure the
|
||||
* running game and every slot stay as they were. */
|
||||
async importBackup(
|
||||
preview: Extract<ImportPreview, { ok: true }>,
|
||||
target: "new" | string,
|
||||
) {
|
||||
if (!this.romHash)
|
||||
throw new Error("Load your ROM before importing a backup.");
|
||||
const replaced = target === "new" ? undefined : await this.slot(target);
|
||||
if (replaced && !(await this.compatible(replaced)))
|
||||
throw new Error(
|
||||
"Saves from a newer version of Crystal cannot be replaced.",
|
||||
);
|
||||
await this.leave();
|
||||
const previous = this.runtime.captureState();
|
||||
try {
|
||||
this.runtime.reset();
|
||||
this.runtime.restoreState(preview.record.state);
|
||||
const record = this.snapshot();
|
||||
const summary = this.runtime.summary();
|
||||
const slot = replaced
|
||||
? await this.store.write(replaced.id, record, summary)
|
||||
: await this.store.create(
|
||||
this.romHash,
|
||||
this.freeName(await this.store.list(this.romHash), summary.player),
|
||||
record,
|
||||
summary,
|
||||
);
|
||||
await this.select(slot);
|
||||
return slot;
|
||||
} catch (error) {
|
||||
this.rollback(previous);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Pauses and writes the running game before it is replaced. */
|
||||
private async leave() {
|
||||
this.pause();
|
||||
if (this.active) await this.save();
|
||||
}
|
||||
private rollback(previous: Uint8Array) {
|
||||
this.runtime.reset();
|
||||
this.runtime.restoreState(previous, false);
|
||||
}
|
||||
private async select(slot: SaveSlot) {
|
||||
this.active = slot;
|
||||
await this.store.setActive(slot.romHash, slot.id);
|
||||
}
|
||||
private async slot(id: string) {
|
||||
const slot = await this.store.get(id);
|
||||
if (!slot || slot.romHash !== this.romHash)
|
||||
throw new Error("This save no longer exists.");
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** Opens a compatible slot from its newest intact record, or the one
|
||||
* before it when the newest was damaged or not written. */
|
||||
private async open(slot: SaveSlot) {
|
||||
if (!(await this.compatible(slot)))
|
||||
throw new Error(
|
||||
`“${slot.name}” was made by a newer version of Crystal. It has been kept.`,
|
||||
);
|
||||
for (const entry of await this.store.records(slot.id)) {
|
||||
if (!entry.intact || !this.readable(entry.record)) continue;
|
||||
try {
|
||||
this.runtime.reset();
|
||||
this.runtime.restoreState(entry.record.state);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
await this.select(slot);
|
||||
return { recovered: entry.generation !== slot.head };
|
||||
}
|
||||
throw new Error(`“${slot.name}” could not be read. It has been kept.`);
|
||||
}
|
||||
private async latest(slot: SaveSlot) {
|
||||
for (const entry of await this.store.records(slot.id)) {
|
||||
if (!entry.intact || !this.readable(entry.record)) continue;
|
||||
try {
|
||||
return {
|
||||
record: entry.record,
|
||||
summary: this.runtime.inspectState(entry.record.state).summary,
|
||||
};
|
||||
} catch {
|
||||
// Try the previous record.
|
||||
}
|
||||
}
|
||||
throw new Error(`“${slot.name}” could not be read.`);
|
||||
}
|
||||
/** Slots migrated from the singleton save have no summary until their
|
||||
* first write; it is read from their record instead. */
|
||||
private async withSummary(slot: SaveSlot) {
|
||||
if (slot.summary) return slot;
|
||||
try {
|
||||
return { ...slot, summary: (await this.latest(slot)).summary };
|
||||
} catch {
|
||||
return slot;
|
||||
}
|
||||
}
|
||||
private readable(record: SaveRecord) {
|
||||
return (
|
||||
record.version === 1 &&
|
||||
record.coreId === this.runtime.id &&
|
||||
record.state instanceof Uint8Array
|
||||
);
|
||||
}
|
||||
/** A slot is newer when its model or its newest record's envelope is.
|
||||
* Only a damaged newest record lets an older one be used: a newer one
|
||||
* must never be replaced by a write from this player. */
|
||||
private async compatible(slot: SaveSlot) {
|
||||
if (slot.version > SLOT_VERSION) return false;
|
||||
const records = await this.store.records(slot.id);
|
||||
const newest = records.find((entry) => entry.intact);
|
||||
return !newest || this.readable(newest.record);
|
||||
}
|
||||
|
||||
private checkName(name: string) {
|
||||
const trimmed = name.trim();
|
||||
if (!NAME_PATTERN.test(trimmed))
|
||||
throw new Error("Save names have 1 to 24 characters.");
|
||||
return trimmed;
|
||||
}
|
||||
private freeName(slots: SaveSlot[], base?: string) {
|
||||
const names = new Set(slots.map((slot) => slot.name));
|
||||
if (base && !names.has(base)) return base;
|
||||
for (let n = slots.length + 1; ; n++) {
|
||||
const name = base ? `${base} ${n}` : `Save ${n}`;
|
||||
if (!names.has(name)) return name;
|
||||
}
|
||||
}
|
||||
private copyName(name: string, slots: SaveSlot[]) {
|
||||
const names = new Set(slots.map((slot) => slot.name));
|
||||
const base = `${name.slice(0, 18)} copy`;
|
||||
if (!names.has(base)) return base;
|
||||
for (let n = 2; ; n++)
|
||||
if (!names.has(`${base} ${n}`)) return `${base} ${n}`;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,36 @@ export class NativeRuntime implements GameRuntime {
|
||||
this.world?.beginIntro();
|
||||
this.publish();
|
||||
}
|
||||
/** A fresh world from the loaded ROM, as before a save is restored or a
|
||||
* new game starts, so nothing of the previous game carries over. */
|
||||
reset() {
|
||||
if (!this.world) throw new Error("Load your ROM first.");
|
||||
this.pause();
|
||||
this.world = this.world.fresh();
|
||||
this.publish();
|
||||
}
|
||||
/** The Savegames entry for the running game's committed state. */
|
||||
summary() {
|
||||
if (!this.world) throw new Error("Load your ROM first.");
|
||||
return this.world.describe();
|
||||
}
|
||||
/** Reads a state into a separate world, leaving the running game alone:
|
||||
* throws when the game cannot load it. Returns its Savegames entry and
|
||||
* the native schema version it was saved with. */
|
||||
inspectState(bytes: Uint8Array) {
|
||||
if (!this.world || bytes.length > 64_000)
|
||||
throw new Error("Invalid native save size.");
|
||||
const value: unknown = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||
);
|
||||
const world = this.world.fresh();
|
||||
world.restore(value);
|
||||
const schema = (value as { version?: unknown }).version;
|
||||
return {
|
||||
summary: world.describe(),
|
||||
schema: typeof schema === "number" ? schema : 0,
|
||||
};
|
||||
}
|
||||
play() {
|
||||
if (!this.world || this.raf) return;
|
||||
// Starting play is a user gesture in the app; browsers allow sound then.
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
type DayTimerName,
|
||||
pageCues,
|
||||
type SoundCue,
|
||||
landmarkName,
|
||||
} from "../scripts/interpreter";
|
||||
import { initialMagikarpRecord, type MagikarpRecord } from "../scripts/lake-of-rage";
|
||||
import {
|
||||
@@ -126,6 +127,7 @@ import {
|
||||
type BattleSound,
|
||||
} from "../battle/battle";
|
||||
import { C } from "../battle/constants";
|
||||
import type { SlotSummary } from "../../storage/slots";
|
||||
import {
|
||||
DIRECTION_ORDER,
|
||||
VECTORS,
|
||||
@@ -845,6 +847,11 @@ export class GameWorld {
|
||||
this.initCallReceiveDelay();
|
||||
}
|
||||
|
||||
/** A new world on the same ROM, clock and random source. */
|
||||
fresh() {
|
||||
return new GameWorld(this.assets, this.now, this.random);
|
||||
}
|
||||
|
||||
get x() {
|
||||
return this.player.x;
|
||||
}
|
||||
@@ -4886,6 +4893,28 @@ export class GameWorld {
|
||||
|
||||
// ---------------------------------------------------------------- saves
|
||||
|
||||
/** The Savegames list entry for a save: the trainer, the landmark (the
|
||||
* backup map's in LANDMARK_SPECIAL rooms), play time, Johto badges and
|
||||
* caught count. */
|
||||
describe(save: NativeSave = this.save()): SlotSummary {
|
||||
let landmark = this.assets.map(save.map).landmark;
|
||||
if (landmark === OW.LANDMARK_SPECIAL && save.backupWarp)
|
||||
landmark = this.assets.map(save.backupWarp.map).landmark;
|
||||
let badges = 0;
|
||||
for (let bit = 0; bit < OW.NUM_JOHTO_BADGES; bit++)
|
||||
if (save.flags.includes(OW.ENGINE_ZEPHYRBADGE + bit)) badges++;
|
||||
return {
|
||||
player: save.name,
|
||||
playerId: save.playerId,
|
||||
place: landmarkName(this.assets.reader, landmark, this.host(PLAYER).text()),
|
||||
hours: save.gameTime.hours,
|
||||
minutes: save.gameTime.minutes,
|
||||
badges,
|
||||
caught: save.pokedex.caught.length,
|
||||
newGame: save.intro,
|
||||
};
|
||||
}
|
||||
|
||||
save(): NativeSave {
|
||||
// Incomplete interactions are replayed after reload; no partial quest writes.
|
||||
// The play time keeps counting through scenes, like GameTimer.
|
||||
|
||||
+359
-32
@@ -42,11 +42,11 @@ import {
|
||||
mailMessage,
|
||||
validMailMessage,
|
||||
} from "./game/party/mail";
|
||||
import { GameSession } from "./app/session";
|
||||
import { SlotSession, type ImportPreview, type SlotEntry } from "./app/slot-session";
|
||||
import { InputController } from "./input/controller";
|
||||
import { bindControls } from "./input/bindings";
|
||||
import { validateRom } from "./rom/validate";
|
||||
import { BrowserSaveRepository, encodeSave } from "./storage/saves";
|
||||
import { SlotRepository, type SlotSummary } from "./storage/slots";
|
||||
import { acquireSessionLock } from "./platform/session-lock";
|
||||
import {
|
||||
InstallableApp,
|
||||
@@ -1704,10 +1704,7 @@ const runtime = new NativeRuntime(
|
||||
},
|
||||
handleBattleInput,
|
||||
);
|
||||
const session = new GameSession(
|
||||
runtime,
|
||||
new BrowserSaveRepository("crystal-native-port"),
|
||||
);
|
||||
const session = new SlotSession(runtime, new SlotRepository("crystal-native-port"));
|
||||
const input = new InputController((button, pressed) =>
|
||||
runtime.setButton(button, pressed),
|
||||
);
|
||||
@@ -1733,7 +1730,10 @@ const tabs = bindTabs(
|
||||
(id) => {
|
||||
if (id === "pokedex") showPokedexTab();
|
||||
if (id === "map") showAtlas();
|
||||
if (id === "saves") showAppSettings();
|
||||
if (id === "saves") {
|
||||
showAppSettings();
|
||||
void showSlots();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1751,9 +1751,15 @@ function message(text: string) {
|
||||
function update() {
|
||||
const playing = session.state === "playing";
|
||||
root.classList.toggle("has-game", ready());
|
||||
for (const id of ["play", "save", "backup", "import-save", "screen-resume"]) {
|
||||
for (const id of ["play", "save", "new-game", "backup", "import-save", "screen-resume"]) {
|
||||
get<HTMLButtonElement>(id).disabled = busy || !ready();
|
||||
}
|
||||
get("slot-list")
|
||||
.querySelectorAll<HTMLButtonElement>("button")
|
||||
.forEach((button) => {
|
||||
button.disabled =
|
||||
busy || !ready() || button.dataset.locked === "true";
|
||||
});
|
||||
root
|
||||
.querySelectorAll<HTMLButtonElement>("[data-game-button]")
|
||||
.forEach((button) => {
|
||||
@@ -1791,6 +1797,7 @@ async function save() {
|
||||
.then((time) => {
|
||||
saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
saveStatus.classList.remove("error");
|
||||
if (tabs.active() === "saves") void showSlots();
|
||||
})
|
||||
.catch((error) => {
|
||||
saveStatus.textContent = quotaExceeded(error)
|
||||
@@ -1842,13 +1849,23 @@ get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
|
||||
await saving;
|
||||
await save();
|
||||
}
|
||||
const restored = await session.load(rom, canvas);
|
||||
const { restored, recovered, skipped } = await session.load(rom, canvas);
|
||||
tabs.select("game");
|
||||
message(
|
||||
[
|
||||
restored
|
||||
? "Your saved session is ready. Press Play to continue."
|
||||
: "Cartridge verified. Press Play to explore your room and New Bark Town.",
|
||||
recovered &&
|
||||
`The last write of “${session.active?.name}” was damaged; its previous save was loaded.`,
|
||||
...skipped.map(
|
||||
(slot) => `“${slot.name}” could not be opened here and was kept unchanged.`,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
);
|
||||
void showSlots();
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
@@ -1884,17 +1901,235 @@ function download(contents: BlobPart, filename: string, type: string) {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
get("backup").addEventListener("click", () => {
|
||||
if (session.active) void exportSlot(session.active.id);
|
||||
});
|
||||
async function exportSlot(id: string) {
|
||||
try {
|
||||
const { slot, text } = await session.exportSlot(id);
|
||||
const name = slot.name.replace(/[^A-Za-z0-9-]+/g, "-").replace(/^-|-$/g, "");
|
||||
download(
|
||||
encodeSave(session.snapshot()),
|
||||
`crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
|
||||
text,
|
||||
`crystal-${name ? `${name}-` : ""}${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
|
||||
"application/json",
|
||||
);
|
||||
message("Session backup exported. Keep it somewhere safe.");
|
||||
message(`“${slot.name}” exported. Keep the backup somewhere safe.`);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Savegames: the slots made with this ROM, newest first. */
|
||||
let slotsShown = 0;
|
||||
async function showSlots() {
|
||||
const list = get("slot-list");
|
||||
const request = ++slotsShown;
|
||||
let entries: SlotEntry[] = [];
|
||||
try {
|
||||
entries = ready() ? await session.slots() : [];
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
}
|
||||
// A later refresh may have finished first.
|
||||
if (request !== slotsShown) return;
|
||||
// Autosaves refresh the list; keep the keyboard focus where it was.
|
||||
const focused = list.contains(document.activeElement)
|
||||
? (document.activeElement as HTMLElement)
|
||||
: undefined;
|
||||
const focusId = focused?.closest<HTMLElement>("[data-slot-id]")?.dataset.slotId;
|
||||
const focusAction = focused?.dataset.slotAction;
|
||||
get("slots-empty").hidden = entries.length > 0;
|
||||
list.hidden = entries.length === 0;
|
||||
list.replaceChildren(...entries.map(slotItem));
|
||||
update();
|
||||
if (focusId && focusAction)
|
||||
[...list.querySelectorAll<HTMLButtonElement>("button[data-slot-action]")]
|
||||
.find(
|
||||
(button) =>
|
||||
button.dataset.slotAction === focusAction &&
|
||||
button.closest<HTMLElement>("[data-slot-id]")?.dataset.slotId === focusId,
|
||||
)
|
||||
?.focus();
|
||||
}
|
||||
function element<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className: string,
|
||||
text = "",
|
||||
) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
function summaryLines(summary?: SlotSummary) {
|
||||
if (!summary) return ["No details available"];
|
||||
if (summary.newGame) return ["New game", "The introduction is not finished"];
|
||||
const badges = `${summary.badges} badge${summary.badges === 1 ? "" : "s"}`;
|
||||
return [
|
||||
`${summary.player} · ${summary.place}`,
|
||||
`${summary.hours}:${String(summary.minutes).padStart(2, "0")} played · ${badges} · ${summary.caught} caught`,
|
||||
];
|
||||
}
|
||||
const savedTime = (iso: string) =>
|
||||
new Date(iso).toLocaleString([], {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
function slotItem({ slot, compatible, active }: SlotEntry) {
|
||||
const item = element("li", "slot");
|
||||
item.dataset.slotId = slot.id;
|
||||
if (active) item.setAttribute("aria-current", "true");
|
||||
const heading = element("h3", "slot-title");
|
||||
heading.append(element("span", "slot-name", slot.name));
|
||||
if (active) heading.append(element("span", "slot-tag", "Playing"));
|
||||
if (!compatible) heading.append(element("span", "slot-tag warning", "Newer version"));
|
||||
item.append(heading);
|
||||
const lines = compatible
|
||||
? summaryLines(slot.summary)
|
||||
: ["Made by a newer version of Crystal. It is kept unchanged."];
|
||||
for (const line of lines) item.append(element("p", "slot-detail", line));
|
||||
item.append(element("p", "slot-saved", `Saved ${savedTime(slot.updatedAt)}`));
|
||||
const actions = element("div", "slot-actions");
|
||||
const action = (name: string, label: string, locked = false) => {
|
||||
const button = element("button", "", label);
|
||||
button.dataset.slotAction = name;
|
||||
button.setAttribute("aria-label", `${label} ${slot.name}`);
|
||||
if (locked) button.dataset.locked = "true";
|
||||
actions.append(button);
|
||||
};
|
||||
if (compatible) {
|
||||
if (!active) action("load", "Continue");
|
||||
action("rename", "Rename");
|
||||
action("duplicate", "Duplicate");
|
||||
}
|
||||
action("export", "Export");
|
||||
action("delete", "Delete", active);
|
||||
if (active)
|
||||
actions.append(
|
||||
element("p", "slot-hint", "Continue another save to delete this one."),
|
||||
);
|
||||
item.append(actions);
|
||||
return item;
|
||||
}
|
||||
|
||||
get("slot-list").addEventListener("click", (event) => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>(
|
||||
"button[data-slot-action]",
|
||||
);
|
||||
const id = button?.closest<HTMLElement>("[data-slot-id]")?.dataset.slotId;
|
||||
if (!button || !id || busy || button.disabled) return;
|
||||
const name =
|
||||
button.closest("li")?.querySelector(".slot-name")?.textContent ?? "";
|
||||
void slotAction(button.dataset.slotAction!, id, name);
|
||||
});
|
||||
/** Runs one Savegames action with the session paused and saved. */
|
||||
async function withSlots(work: () => Promise<string | undefined>) {
|
||||
busy = true;
|
||||
pause();
|
||||
update();
|
||||
try {
|
||||
await saving;
|
||||
const text = await work();
|
||||
if (text) message(text);
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
await showSlots();
|
||||
}
|
||||
}
|
||||
async function slotAction(action: string, id: string, name: string) {
|
||||
switch (action) {
|
||||
case "load":
|
||||
return withSlots(async () => {
|
||||
const { recovered } = await session.switchTo(id);
|
||||
return recovered
|
||||
? `“${name}” is ready. Its last write was damaged; the previous save was loaded. Press Play to continue.`
|
||||
: `“${name}” is ready. Press Play to continue.`;
|
||||
});
|
||||
case "rename": {
|
||||
const newName = await askSlot({
|
||||
title: `Rename “${name}”`,
|
||||
text: "Names have 1 to 24 characters.",
|
||||
confirm: "Rename",
|
||||
value: name,
|
||||
});
|
||||
if (newName === undefined) return;
|
||||
return withSlots(async () => {
|
||||
const slot = await session.rename(id, newName);
|
||||
return `Renamed to “${slot.name}”.`;
|
||||
});
|
||||
}
|
||||
case "duplicate":
|
||||
return withSlots(async () => {
|
||||
const slot = await session.duplicate(id);
|
||||
return `“${name}” copied to “${slot.name}”.`;
|
||||
});
|
||||
case "export":
|
||||
return exportSlot(id);
|
||||
case "delete": {
|
||||
const confirmed = await askSlot({
|
||||
title: `Delete “${name}”?`,
|
||||
text: "Its progress is removed from this device. Export it first to keep a backup.",
|
||||
confirm: "Delete",
|
||||
});
|
||||
if (confirmed === undefined) return;
|
||||
return withSlots(async () => {
|
||||
await session.delete(id);
|
||||
return `“${name}” deleted.`;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
get("new-game").addEventListener("click", () => {
|
||||
void withSlots(async () => {
|
||||
const slot = await session.newGame();
|
||||
tabs.select("game");
|
||||
return `“${slot.name}” created. Press Play to start your new game.`;
|
||||
});
|
||||
});
|
||||
|
||||
/** The rename and delete dialog. Resolves with the name entered (or "" for
|
||||
* a confirmation), or undefined when cancelled. */
|
||||
function askSlot(options: {
|
||||
title: string;
|
||||
text: string;
|
||||
confirm: string;
|
||||
value?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const dialog = get<HTMLDialogElement>("slot-dialog");
|
||||
const field = get<HTMLInputElement>("slot-name");
|
||||
const error = get("slot-dialog-error");
|
||||
get("slot-dialog-title").textContent = options.title;
|
||||
get("slot-dialog-text").textContent = options.text;
|
||||
get("slot-dialog-confirm").textContent = options.confirm;
|
||||
get("slot-name-label").hidden = options.value === undefined;
|
||||
field.required = options.value !== undefined;
|
||||
field.value = options.value ?? "";
|
||||
error.textContent = "";
|
||||
dialog.showModal();
|
||||
if (options.value !== undefined) field.select();
|
||||
return new Promise((resolve) => {
|
||||
const submit = (event: SubmitEvent) => {
|
||||
const confirmed =
|
||||
(event.submitter as HTMLButtonElement | null)?.value === "confirm";
|
||||
if (confirmed && options.value !== undefined && !field.value.trim()) {
|
||||
event.preventDefault();
|
||||
error.textContent = "Enter a name.";
|
||||
return;
|
||||
}
|
||||
dialog.removeEventListener("close", closed);
|
||||
dialog.querySelector("form")!.removeEventListener("submit", submit);
|
||||
resolve(confirmed ? field.value : undefined);
|
||||
};
|
||||
const closed = () => {
|
||||
dialog.querySelector("form")!.removeEventListener("submit", submit);
|
||||
resolve(undefined);
|
||||
};
|
||||
dialog.querySelector("form")!.addEventListener("submit", submit);
|
||||
dialog.addEventListener("close", closed, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
get("import-save").addEventListener("click", () => {
|
||||
pause();
|
||||
get<HTMLInputElement>("save-file").click();
|
||||
@@ -1903,32 +2138,124 @@ get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
|
||||
const field = event.target as HTMLInputElement;
|
||||
const file = field.files?.[0];
|
||||
field.value = "";
|
||||
if (!file || busy) return;
|
||||
if (file.size > 2_000_000) {
|
||||
showError(new Error("This backup is too large."));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
"Replace your current session with this backup? Export your current session first if you want to keep it.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
busy = true;
|
||||
update();
|
||||
if (!file || busy || !ready()) return;
|
||||
let preview: ImportPreview;
|
||||
try {
|
||||
await saving;
|
||||
await session.importSave(await file.text());
|
||||
message("Backup restored. Press Play to continue.");
|
||||
saveStatus.textContent = "Imported session saved on this device.";
|
||||
preview =
|
||||
file.size > 2_000_000
|
||||
? { ok: false, reason: "This backup is too large." }
|
||||
: await session.previewImport(await file.text());
|
||||
} catch (error) {
|
||||
showError(error);
|
||||
} finally {
|
||||
busy = false;
|
||||
update();
|
||||
return;
|
||||
}
|
||||
const target = await previewImport(file.name, preview);
|
||||
if (!target || !preview.ok) return;
|
||||
const ok = preview;
|
||||
await withSlots(async () => {
|
||||
const slot = await session.importBackup(ok, target);
|
||||
saveStatus.textContent = `Imported into “${slot.name}” on this device.`;
|
||||
saveStatus.classList.remove("error");
|
||||
return "Backup restored. Press Play to continue.";
|
||||
});
|
||||
});
|
||||
|
||||
/** Shows what a backup holds and what importing it would replace.
|
||||
* Resolves with "new", a slot ID to replace, or undefined. */
|
||||
function previewImport(
|
||||
file: string,
|
||||
preview: ImportPreview,
|
||||
): Promise<string | undefined> {
|
||||
const dialog = get<HTMLDialogElement>("import-dialog");
|
||||
const details = get("import-details");
|
||||
const conflicts = get("import-conflicts");
|
||||
const actions = get("import-actions");
|
||||
const row = (term: string, value: string) => {
|
||||
details.append(element("dt", "", term), element("dd", "", value));
|
||||
};
|
||||
details.replaceChildren();
|
||||
conflicts.replaceChildren();
|
||||
actions.replaceChildren();
|
||||
const button = (value: string, label: string, primary = false, id = "") => {
|
||||
const node = element("button", primary ? "menu-button" : "secondary-button", label);
|
||||
node.value = value;
|
||||
if (id) node.id = id;
|
||||
actions.append(node);
|
||||
};
|
||||
if (!preview.ok) {
|
||||
get("import-title").textContent = "This backup cannot be imported";
|
||||
get("import-text").textContent = `${preview.reason} Your saves are unchanged.`;
|
||||
row("File", file);
|
||||
if (typeof preview.identity?.savedAt === "string")
|
||||
row("Saved", savedTime(preview.identity.savedAt));
|
||||
button("cancel", "Close", true);
|
||||
} else {
|
||||
const { summary } = preview;
|
||||
get("import-title").textContent = summary.newGame
|
||||
? "A new game"
|
||||
: `${summary.player}'s game`;
|
||||
get("import-text").textContent =
|
||||
"Check the backup before importing it. Importing continues it in place of the running game, which is saved first.";
|
||||
row("File", file);
|
||||
row(
|
||||
"Trainer",
|
||||
summary.newGame
|
||||
? "Not named yet"
|
||||
: `${summary.player} · ID No. ${String(summary.playerId).padStart(5, "0")}`,
|
||||
);
|
||||
row("Place", summary.newGame ? "Introduction" : summary.place);
|
||||
for (const line of summaryLines(summary).slice(1)) row("Progress", line);
|
||||
row("Saved", savedTime(preview.record.savedAt));
|
||||
row(
|
||||
"Format",
|
||||
preview.schema < 3
|
||||
? `Native save, game data version ${preview.schema}: upgraded to version 3 on import`
|
||||
: `Native save, game data version ${preview.schema}`,
|
||||
);
|
||||
if (preview.conflicts.length) {
|
||||
conflicts.append(element("h3", "", "Saves of the same trainer"));
|
||||
const list = element("ul", "");
|
||||
for (const { slot, identical } of preview.conflicts)
|
||||
list.append(
|
||||
element(
|
||||
"li",
|
||||
"",
|
||||
`“${slot.name}”, saved ${savedTime(slot.updatedAt)}${identical ? ": the same progress as this backup" : slot.updatedAt > preview.record.savedAt ? ": saved after this backup" : ": saved before this backup"}`,
|
||||
),
|
||||
);
|
||||
conflicts.append(list);
|
||||
}
|
||||
button("new", "Add as a new save", true, "import-new");
|
||||
const active = session.active;
|
||||
const replaceable = [
|
||||
...(active ? [active] : []),
|
||||
...preview.conflicts
|
||||
.map((conflict) => conflict.slot)
|
||||
.filter((slot) => slot.id !== active?.id),
|
||||
];
|
||||
for (const slot of replaceable)
|
||||
button(
|
||||
slot.id,
|
||||
`Replace “${slot.name}”`,
|
||||
false,
|
||||
slot.id === active?.id ? "import-current" : "",
|
||||
);
|
||||
button("cancel", "Cancel");
|
||||
}
|
||||
dialog.returnValue = "";
|
||||
dialog.showModal();
|
||||
return new Promise((resolve) => {
|
||||
dialog.addEventListener(
|
||||
"close",
|
||||
() => {
|
||||
const value = dialog.returnValue;
|
||||
resolve(value && value !== "cancel" ? value : undefined);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** The installable app: updates wait for the player, and the Savegames
|
||||
* tab offers installation and persistent storage. */
|
||||
const app = new InstallableApp({
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import type { SaveRecord } from "./saves";
|
||||
|
||||
/** The slot model this player writes. Slots of a higher version come from
|
||||
* a newer player: they are listed and kept, never rewritten. */
|
||||
export const SLOT_VERSION = 1;
|
||||
/** Records kept per slot: the newest and the one before it, which a
|
||||
* damaged newest record falls back to. */
|
||||
const KEPT_GENERATIONS = 2;
|
||||
|
||||
/** What a slot's list entry shows, taken from its newest record. */
|
||||
export interface SlotSummary {
|
||||
player: string;
|
||||
/** wPlayerID: with the name, it tells playthroughs apart. */
|
||||
playerId: number;
|
||||
/** The landmark name, as the Pokégear and Town Map show it. */
|
||||
place: string;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
badges: number;
|
||||
caught: number;
|
||||
/** The introduction has not finished. */
|
||||
newGame: boolean;
|
||||
}
|
||||
|
||||
export interface SaveSlot {
|
||||
version: number;
|
||||
/** Durable ID, kept across renames, exports and recoveries. */
|
||||
id: string;
|
||||
romHash: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
/** The newest record's generation. */
|
||||
head: number;
|
||||
summary?: SlotSummary;
|
||||
}
|
||||
|
||||
/** One write of a slot. The checksum covers the whole record, so a torn or
|
||||
* damaged write is recognized and the previous generation is used. */
|
||||
interface StoredRecord {
|
||||
slot: string;
|
||||
generation: number;
|
||||
checksum: number;
|
||||
record: SaveRecord;
|
||||
}
|
||||
|
||||
export interface SlotRecord {
|
||||
generation: number;
|
||||
record: SaveRecord;
|
||||
/** The checksum matches: the record is as it was written. */
|
||||
intact: boolean;
|
||||
}
|
||||
|
||||
/** Native saves in IndexedDB. Version 1 of the database held one record per
|
||||
* ROM in `saves`; version 2 keeps that store untouched and adds slots. */
|
||||
export class SlotRepository {
|
||||
private database?: Promise<IDBDatabase>;
|
||||
constructor(private databaseName = "crystal-native-port") {}
|
||||
|
||||
private open() {
|
||||
return (this.database ??= new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(this.databaseName, 2);
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = request.result;
|
||||
db.createObjectStore("slots", { keyPath: "id" }).createIndex(
|
||||
"romHash",
|
||||
"romHash",
|
||||
);
|
||||
db.createObjectStore("records", { keyPath: ["slot", "generation"] });
|
||||
const meta = db.createObjectStore("meta");
|
||||
// The upgrade commits or aborts as a whole, and runs once per
|
||||
// database version: the singleton save moves into a slot exactly once.
|
||||
if (event.oldVersion === 1)
|
||||
migrateSingleton(request.transaction!.objectStore("saves"), meta);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
// A newer tab upgrading the database closes this connection.
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
this.database = undefined;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => {
|
||||
this.database = undefined;
|
||||
reject(request.error);
|
||||
};
|
||||
request.onblocked = () =>
|
||||
reject(
|
||||
new Error("Close other Crystal tabs, then reload to enable saves."),
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
private async run<T>(
|
||||
stores: string[],
|
||||
mode: IDBTransactionMode,
|
||||
body: (transaction: IDBTransaction) => Promise<T> | T,
|
||||
): Promise<T> {
|
||||
const db = await this.open();
|
||||
const transaction = db.transaction(stores, mode, { durability: "strict" });
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("Saving was interrupted."));
|
||||
});
|
||||
let result: T;
|
||||
try {
|
||||
result = await body(transaction);
|
||||
} catch (error) {
|
||||
try {
|
||||
transaction.abort();
|
||||
} catch {
|
||||
// Already finished.
|
||||
}
|
||||
done.catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
await done;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The slots made with this ROM, most recently saved first. */
|
||||
async list(romHash: string): Promise<SaveSlot[]> {
|
||||
const slots = await this.run(["slots"], "readonly", (t) =>
|
||||
request<SaveSlot[]>(
|
||||
t.objectStore("slots").index("romHash").getAll(romHash),
|
||||
),
|
||||
);
|
||||
return slots.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
}
|
||||
|
||||
async get(id: string): Promise<SaveSlot | undefined> {
|
||||
return this.run(["slots"], "readonly", (t) =>
|
||||
request<SaveSlot | undefined>(t.objectStore("slots").get(id)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The slot's records, newest first. */
|
||||
async records(id: string): Promise<SlotRecord[]> {
|
||||
const stored = await this.run(["records"], "readonly", (t) =>
|
||||
request<StoredRecord[]>(t.objectStore("records").getAll(range(id))),
|
||||
);
|
||||
return stored
|
||||
.sort((a, b) => b.generation - a.generation)
|
||||
.map(({ generation, record, checksum }) => ({
|
||||
generation,
|
||||
record,
|
||||
intact: checksum === recordChecksum(record),
|
||||
}));
|
||||
}
|
||||
|
||||
async create(
|
||||
romHash: string,
|
||||
name: string,
|
||||
record: SaveRecord,
|
||||
summary?: SlotSummary,
|
||||
): Promise<SaveSlot> {
|
||||
const slot: SaveSlot = {
|
||||
version: SLOT_VERSION,
|
||||
id: crypto.randomUUID(),
|
||||
romHash,
|
||||
name,
|
||||
createdAt: record.savedAt,
|
||||
updatedAt: record.savedAt,
|
||||
head: 1,
|
||||
...(summary && { summary }),
|
||||
};
|
||||
await this.run(["slots", "records"], "readwrite", (t) => {
|
||||
t.objectStore("slots").add(slot);
|
||||
t.objectStore("records").add(stored(slot.id, 1, record));
|
||||
});
|
||||
return slot;
|
||||
}
|
||||
|
||||
/** Adds a record to the slot and drops the oldest, in one transaction:
|
||||
* an interrupted write leaves the slot as it was. */
|
||||
async write(
|
||||
id: string,
|
||||
record: SaveRecord,
|
||||
summary?: SlotSummary,
|
||||
): Promise<SaveSlot> {
|
||||
return this.run(["slots", "records"], "readwrite", async (t) => {
|
||||
const slots = t.objectStore("slots");
|
||||
const current = await request<SaveSlot | undefined>(slots.get(id));
|
||||
if (!current) throw new Error("This save no longer exists.");
|
||||
if (current.version > SLOT_VERSION)
|
||||
throw new Error("This save was made by a newer version of Crystal.");
|
||||
const head = current.head + 1;
|
||||
const slot: SaveSlot = {
|
||||
...current,
|
||||
head,
|
||||
updatedAt: record.savedAt,
|
||||
...(summary && { summary }),
|
||||
};
|
||||
const records = t.objectStore("records");
|
||||
records.put(stored(id, head, record));
|
||||
if (head > KEPT_GENERATIONS)
|
||||
records.delete(range(id, head - KEPT_GENERATIONS));
|
||||
slots.put(slot);
|
||||
return slot;
|
||||
});
|
||||
}
|
||||
|
||||
async rename(id: string, name: string): Promise<SaveSlot> {
|
||||
return this.run(["slots"], "readwrite", async (t) => {
|
||||
const slots = t.objectStore("slots");
|
||||
const current = await request<SaveSlot | undefined>(slots.get(id));
|
||||
if (!current) throw new Error("This save no longer exists.");
|
||||
if (current.version > SLOT_VERSION)
|
||||
throw new Error("This save was made by a newer version of Crystal.");
|
||||
const slot = { ...current, name };
|
||||
slots.put(slot);
|
||||
return slot;
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
await this.run(["slots", "records", "meta"], "readwrite", async (t) => {
|
||||
const slot = await request<SaveSlot | undefined>(
|
||||
t.objectStore("slots").get(id),
|
||||
);
|
||||
t.objectStore("slots").delete(id);
|
||||
t.objectStore("records").delete(range(id));
|
||||
if (!slot) return;
|
||||
const meta = t.objectStore("meta");
|
||||
if ((await request(meta.get(activeKey(slot.romHash)))) === id)
|
||||
meta.delete(activeKey(slot.romHash));
|
||||
});
|
||||
}
|
||||
|
||||
/** The slot last played with this ROM. */
|
||||
async active(romHash: string): Promise<string | undefined> {
|
||||
return this.run(["meta"], "readonly", (t) =>
|
||||
request<string | undefined>(
|
||||
t.objectStore("meta").get(activeKey(romHash)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async setActive(romHash: string, id: string) {
|
||||
await this.run(["meta"], "readwrite", (t) => {
|
||||
t.objectStore("meta").put(id, activeKey(romHash));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies every record of the version-1 `saves` store into its own slot,
|
||||
* unchanged: a record from another or a newer player keeps its bytes and is
|
||||
* listed as such. The `saves` store itself is left as it was. */
|
||||
function migrateSingleton(saves: IDBObjectStore, meta: IDBObjectStore) {
|
||||
const slots = meta.transaction.objectStore("slots");
|
||||
const records = meta.transaction.objectStore("records");
|
||||
const migrated: string[] = [];
|
||||
const cursor = saves.openCursor();
|
||||
cursor.onsuccess = () => {
|
||||
const entry = cursor.result;
|
||||
if (!entry) {
|
||||
meta.put(
|
||||
{ from: 1, slots: migrated, at: new Date().toISOString() },
|
||||
"migration",
|
||||
);
|
||||
return;
|
||||
}
|
||||
const record = entry.value as SaveRecord;
|
||||
const romHash = String(entry.key);
|
||||
const savedAt =
|
||||
typeof record.savedAt === "string" &&
|
||||
Number.isFinite(Date.parse(record.savedAt))
|
||||
? record.savedAt
|
||||
: new Date().toISOString();
|
||||
const id = crypto.randomUUID();
|
||||
slots.add({
|
||||
version: SLOT_VERSION,
|
||||
id,
|
||||
romHash,
|
||||
name: "Save 1",
|
||||
createdAt: savedAt,
|
||||
updatedAt: savedAt,
|
||||
head: 1,
|
||||
} satisfies SaveSlot);
|
||||
records.add(stored(id, 1, record));
|
||||
meta.put(id, activeKey(romHash));
|
||||
migrated.push(id);
|
||||
entry.continue();
|
||||
};
|
||||
}
|
||||
|
||||
const activeKey = (romHash: string) => `active:${romHash}`;
|
||||
|
||||
/** A slot's records, or those up to `last`. */
|
||||
function range(slot: string, last = Number.MAX_SAFE_INTEGER) {
|
||||
return IDBKeyRange.bound([slot, 0], [slot, last]);
|
||||
}
|
||||
|
||||
function stored(slot: string, generation: number, record: SaveRecord) {
|
||||
return {
|
||||
slot,
|
||||
generation,
|
||||
checksum: recordChecksum(record),
|
||||
record,
|
||||
} satisfies StoredRecord;
|
||||
}
|
||||
|
||||
function request<T>(request: IDBRequest): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result as T);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** CRC-32 of the record's fields and state bytes. It runs synchronously, so
|
||||
* the migration can compute it inside the upgrade transaction. */
|
||||
export function recordChecksum(record: SaveRecord): number {
|
||||
const { state, ...fields } = record as SaveRecord & Record<string, unknown>;
|
||||
const header = new TextEncoder().encode(
|
||||
JSON.stringify(fields, Object.keys(fields).sort()),
|
||||
);
|
||||
let crc = crc32(0xffffffff, header);
|
||||
if (state instanceof Uint8Array) crc = crc32(crc, state);
|
||||
else crc = crc32(crc, new TextEncoder().encode(JSON.stringify(state)));
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
let table: Uint32Array | undefined;
|
||||
function crc32(crc: number, bytes: Uint8Array) {
|
||||
table ??= Uint32Array.from({ length: 256 }, (_, n) => {
|
||||
for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1;
|
||||
return n >>> 0;
|
||||
});
|
||||
for (const byte of bytes) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return crc >>> 0;
|
||||
}
|
||||
+8
-4
@@ -60,9 +60,11 @@ export function renderShell(root: HTMLElement) {
|
||||
</div>
|
||||
</section>
|
||||
<section id="panel-saves" class="tab-panel menu-panel" role="tabpanel" aria-labelledby="tab-saves" data-panel="saves" hidden>
|
||||
<header><p class="menu-title">SAVEGAMES</p><h2>Your local session</h2></header>
|
||||
<div class="save-actions"><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button><button id="import-save" disabled>Import native backup</button><input id="save-file" class="file-input" type="file" accept=".json"></div>
|
||||
<div class="save-copy"><p>Native backups include your character, position and completed interactions. They are separate from cartridge .sav files.</p><p>Export before clearing browser data.</p></div>
|
||||
<header><p class="menu-title">SAVEGAMES</p><h2>Your saves</h2></header>
|
||||
<div class="save-actions"><button id="save" disabled>Save now</button><button id="new-game" disabled>New game</button><button id="backup" disabled>Export current save</button><button id="import-save" disabled>Import backup</button><input id="save-file" class="file-input" type="file" accept=".json"></div>
|
||||
<p id="slots-empty" class="slots-empty">Choose your ROM to see the saves made with it.</p>
|
||||
<ul id="slot-list" class="slot-list" aria-label="Saves on this device" hidden></ul>
|
||||
<div class="save-copy"><p>Each save keeps its own playthrough and saves itself every 10 seconds and when paused. Backups include your character, position and completed interactions. They are separate from cartridge .sav files.</p><p>Export before clearing browser data.</p></div>
|
||||
<div class="replace-rom"><h3>Cartridge</h3><p>Select a ROM again after reloading, or choose another compatible copy.</p><label class="menu-button file-button" for="rom-file"><span aria-hidden="true">▶</span> Choose or replace ROM</label></div><div class="app-settings"><h3>App</h3><p id="install-status">Install Crystal to open it from your home screen and play the app offline. Select your ROM again each time you open it.</p><button id="install-app" class="menu-button" hidden>Install Crystal</button><h3>Storage</h3><p id="storage-status">Checking this browser's storage…</p><button id="persist-storage" class="menu-button" hidden>Keep saves on this device</button></div>
|
||||
</section>
|
||||
<section id="panel-map" class="tab-panel menu-panel map-panel" role="tabpanel" aria-labelledby="tab-map" data-panel="map" hidden>
|
||||
@@ -81,6 +83,8 @@ export function renderShell(root: HTMLElement) {
|
||||
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
|
||||
</div>
|
||||
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream">
|
||||
<dialog id="help-dialog" aria-labelledby="help-title"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2 id="help-title">Playing Crystal in your browser</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl id="help-keys"></dl><p>On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.</p><p>Sessions save every 10 seconds and when paused. Switching tabs or apps pauses play. The Map tab lists the areas open in this port; the others appear at the edges but cannot be entered yet.</p></dialog>
|
||||
<dialog id="help-dialog" aria-labelledby="help-title"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="menu-title">HOW TO PLAY</p><h2 id="help-title">Playing Crystal in your browser</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM.</p><dl id="help-keys"></dl><p>On a touch screen, use the on-screen pad and buttons. Settings changes the keys and the text size.</p><p>Games save every 10 seconds and when paused. Savegames keeps several playthroughs. Switching tabs or apps pauses play. The Map tab lists the areas open in this port; the others appear at the edges but cannot be entered yet.</p></dialog>
|
||||
<dialog id="slot-dialog" aria-labelledby="slot-dialog-title"><form method="dialog"><p class="menu-title">SAVEGAMES</p><h2 id="slot-dialog-title"></h2><p id="slot-dialog-text"></p><label id="slot-name-label" class="slot-name-field">Name <input id="slot-name" maxlength="24" autocomplete="off" spellcheck="false"></label><p id="slot-dialog-error" class="dialog-error" role="alert"></p><div class="dialog-actions"><button value="cancel" formnovalidate class="secondary-button">Cancel</button><button id="slot-dialog-confirm" value="confirm" class="menu-button"></button></div></form></dialog>
|
||||
<dialog id="import-dialog" aria-labelledby="import-title"><form method="dialog"><p class="menu-title">IMPORT BACKUP</p><h2 id="import-title"></h2><p id="import-text"></p><dl id="import-details" class="import-details"></dl><div id="import-conflicts" class="import-conflicts"></div><div id="import-actions" class="dialog-actions"></div></form></dialog>
|
||||
<dialog id="settings-dialog" aria-labelledby="settings-title"><button id="close-settings" class="dialog-close" aria-label="Close settings">×</button><p class="menu-title">SETTINGS</p><h2 id="settings-title">Display, sound and controls</h2><fieldset class="text-size"><legend>Text size</legend><label><input type="radio" name="text-scale" value="1"> Standard</label><label><input type="radio" name="text-scale" value="1.25"> Large</label><label><input type="radio" name="text-scale" value="1.5"> Largest</label></fieldset><fieldset class="text-size"><legend>Sound</legend><label><input type="radio" name="volume" value="0"> Off</label><label><input type="radio" name="volume" value="0.3"> Quiet</label><label><input type="radio" name="volume" value="0.6"> Medium</label><label><input type="radio" name="volume" value="1"> Full</label></fieldset><section class="key-settings" aria-labelledby="keys-title"><h3 id="keys-title">Keyboard controls</h3><p>Choose Change, then press the key to use. Tab, Escape and Space keep their usual roles.</p><table><thead><tr><th scope="col">Button</th><th scope="col">Key</th><th scope="col"><span class="visually-hidden">Change</span></th></tr></thead><tbody id="key-table"></tbody></table><button id="reset-keys" class="menu-button">Reset controls</button><p id="settings-status" class="settings-status" role="status" aria-live="polite"></p></section></dialog>`;
|
||||
}
|
||||
@@ -971,6 +971,83 @@ dialog h2 {
|
||||
max-width: 720px;
|
||||
color: #4e5157;
|
||||
}
|
||||
/* Savegames: one card per slot, the running one marked. */
|
||||
.slots-empty {
|
||||
color: #4e5157;
|
||||
}
|
||||
.slot-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
|
||||
gap: 12px;
|
||||
max-width: 960px;
|
||||
margin: 0 0 22px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.slot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px 14px;
|
||||
background: var(--cream);
|
||||
border: 3px double #747269;
|
||||
}
|
||||
.slot[aria-current="true"] {
|
||||
border: 3px double var(--blue);
|
||||
box-shadow: inset 4px 0 0 var(--blue);
|
||||
}
|
||||
.slot-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
margin: 0 0 4px;
|
||||
font-size: calc(14px * var(--text-scale));
|
||||
}
|
||||
.slot-tag {
|
||||
padding: 1px 6px;
|
||||
color: #fff;
|
||||
background: var(--blue);
|
||||
font-size: calc(10px * var(--text-scale));
|
||||
font-weight: 700;
|
||||
}
|
||||
.slot-tag.warning {
|
||||
background: #8a4b16;
|
||||
}
|
||||
.slot-detail,
|
||||
.slot-saved {
|
||||
margin: 0;
|
||||
}
|
||||
.slot-saved {
|
||||
color: #4e5157;
|
||||
font-size: calc(11px * var(--text-scale));
|
||||
}
|
||||
.slot-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.slot-actions button,
|
||||
.secondary-button {
|
||||
min-height: 36px;
|
||||
padding: 4px 10px;
|
||||
color: var(--ink);
|
||||
background: var(--cream-dark);
|
||||
border: 2px solid var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
.slot-actions button:disabled,
|
||||
.secondary-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.slot-hint {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
color: #4e5157;
|
||||
font-size: calc(10px * var(--text-scale));
|
||||
}
|
||||
.replace-rom {
|
||||
max-width: 720px;
|
||||
margin-top: 24px;
|
||||
@@ -1250,6 +1327,59 @@ dialog dd {
|
||||
min-height: 1.5em;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
/* Savegames dialogs: rename, delete and the import preview. */
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.dialog-actions .menu-button {
|
||||
min-height: 36px;
|
||||
}
|
||||
.slot-name-field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.slot-name-field input {
|
||||
min-height: 40px;
|
||||
padding: 6px 8px;
|
||||
font-weight: 400;
|
||||
background: #fff;
|
||||
border: 2px solid var(--ink);
|
||||
}
|
||||
.dialog-error:empty {
|
||||
display: none;
|
||||
}
|
||||
.dialog-error {
|
||||
margin: 6px 0 0;
|
||||
color: #a12b16;
|
||||
}
|
||||
#import-dialog {
|
||||
max-height: calc(100% - 24px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
#import-dialog .import-details {
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 4px 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.import-details dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
.import-details dd {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.import-conflicts h3 {
|
||||
margin: 16px 0 4px;
|
||||
font-size: calc(13px * var(--text-scale));
|
||||
}
|
||||
.import-conflicts ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.dialog-close {
|
||||
float: right;
|
||||
background: transparent;
|
||||
|
||||
@@ -158,8 +158,8 @@ test("pausing battle freezes messages and importing its backup restores committe
|
||||
const text = await page.locator("#battle-text").textContent();
|
||||
await page.clock.runFor(5000);
|
||||
expect(await page.locator("#battle-text").textContent()).toBe(text);
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.locator("#save-file").setInputFiles(path);
|
||||
await page.locator("#import-current").click();
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await expect(page.locator("#battle")).toBeHidden();
|
||||
await page.locator("#play").click();
|
||||
|
||||
@@ -8,9 +8,9 @@ import { encodeSave } from "../src/storage/saves";
|
||||
import { foundationFixture } from "./foundation-fixture";
|
||||
|
||||
// Backups for the manual checks on the project board's "A tester" column,
|
||||
// built from the local ROM and imported through Savegames → Import native
|
||||
// backup. They hold names read from the ROM, so they stay out of the
|
||||
// repository (*.crystal-save.json is ignored).
|
||||
// built from the local ROM and imported through Savegames → Import backup
|
||||
// (then "Add as a new save"). They hold names read from the ROM, so they
|
||||
// stay out of the repository (*.crystal-save.json is ignored).
|
||||
//
|
||||
// MAKE_TEST_SAVES=1 npx vitest run tests/human-test-saves.test.ts
|
||||
const romPath =
|
||||
|
||||
@@ -120,8 +120,8 @@ test("local maps follow a stairs transition and importing a previous position",
|
||||
`${world.map.name} · ${world.map.location}`,
|
||||
);
|
||||
await page.locator("#tab-saves").click();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.locator("#save-file").setInputFiles((await backup.path())!);
|
||||
await page.locator("#import-current").click();
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await page.locator("#tab-map").click();
|
||||
await expect(page.locator("#world-map")).toHaveAttribute("data-map", "24:7");
|
||||
@@ -239,8 +239,8 @@ test("choosing a starter shows its picture, asks for a nickname and saves the pa
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
await expect(page.locator("#status")).toContainText("verified");
|
||||
await page.locator("#tab-saves").click();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.locator("#save-file").setInputFiles(backup);
|
||||
await page.locator("#import-current").click();
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await page.locator("#play").click();
|
||||
await page.keyboard.press("KeyX");
|
||||
@@ -333,8 +333,8 @@ test("a home exit mat survives save/reload and waits for the exit direction", as
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
await expect(page.locator("#status")).toContainText("verified");
|
||||
await page.locator("#tab-saves").click();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.locator("#save-file").setInputFiles(backup);
|
||||
await page.locator("#import-current").click();
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await page.locator("#play").click();
|
||||
const screen = page.locator("#game-screen");
|
||||
@@ -409,8 +409,8 @@ test("walks from New Bark Town onto Route 29 after receiving a starter", async (
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
await expect(page.locator("#status")).toContainText("verified");
|
||||
await page.locator("#tab-saves").click();
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.locator("#save-file").setInputFiles(backup);
|
||||
await page.locator("#import-current").click();
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await page.locator("#play").click();
|
||||
const screen = page.locator("#game-screen");
|
||||
@@ -496,8 +496,8 @@ test("native player loads the actual ROM without loading an emulator and persist
|
||||
const exported = page.waitForEvent("download");
|
||||
await page.locator("#backup").click();
|
||||
const backup = await exported;
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.locator("#save-file").setInputFiles((await backup.path())!);
|
||||
await page.locator("#import-current").click();
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await page.reload();
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
|
||||
@@ -575,6 +575,54 @@ describe.skipIf(!existsSync(romPath))(
|
||||
expect(world.save().playerId).toBeLessThan(0x10000);
|
||||
expect(() => world.restore({ ...legacy, version: 4 })).toThrow("Invalid");
|
||||
});
|
||||
it("describes saves of every schema for the Savegames list", () => {
|
||||
const world = new GameWorld(load(), () => new Date("2026-09-22T12:00:00"));
|
||||
const flags = [27, 28, 29];
|
||||
const town = world.assets.map("24:4");
|
||||
const land = Array.from({ length: town.width * town.height }, (_, i) => ({
|
||||
x: i % town.width,
|
||||
y: Math.floor(i / town.width),
|
||||
})).find(
|
||||
({ x, y }) =>
|
||||
world.assets.permission(world.assets.collision(town, x, y)) === 0,
|
||||
)!;
|
||||
world.restore({
|
||||
...world.save(),
|
||||
name: "GOLD",
|
||||
map: "24:4",
|
||||
...land,
|
||||
flags,
|
||||
gameTime: { hours: 3, minutes: 7, seconds: 0, frames: 0, capped: false },
|
||||
pokedex: { seen: [152, 16], caught: [152] },
|
||||
});
|
||||
const current = world.save();
|
||||
expect(world.describe()).toEqual({
|
||||
player: "GOLD",
|
||||
playerId: current.playerId,
|
||||
place: "NEW BARK TOWN",
|
||||
hours: 3,
|
||||
minutes: 7,
|
||||
badges: 3,
|
||||
caught: 1,
|
||||
newGame: false,
|
||||
});
|
||||
// Indoors, the landmark is the map's own (the player's house).
|
||||
world.restore({ ...current, map: "24:6", x: 6, y: 6 });
|
||||
expect(world.describe().place).toBe("NEW BARK TOWN");
|
||||
const { scenes, phone, clock, objects, playerId, party, bag, pokedex, ...legacy } =
|
||||
current;
|
||||
void [scenes, phone, clock, objects, playerId, party, bag, pokedex];
|
||||
for (const version of [1, 2]) {
|
||||
const scratch = new GameWorld(load(), () => new Date("2026-09-22T12:00:00"));
|
||||
scratch.restore({ ...legacy, version, ...(version === 2 && { scenes: {}, phone: [], clock, objects: [] }) });
|
||||
expect(scratch.describe()).toMatchObject({
|
||||
player: "GOLD",
|
||||
place: "NEW BARK TOWN",
|
||||
badges: 3,
|
||||
caught: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
it("decodes species, moves, items and growth from the ROM", () => {
|
||||
const data = load().pokemon;
|
||||
expect([152, 155, 158].map((id) => data.speciesName(id))).toEqual([
|
||||
|
||||
@@ -99,8 +99,8 @@ test("Mr. Pokémon's ROM scene grants the Egg and Pokédex, heals and survives r
|
||||
expect(state.scenes).toMatchObject({ "26:10": 1, "26:3": 1, "24:5": 3 });
|
||||
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
|
||||
expect(state.specialPhoneCall).toBe(2); // SPECIALCALL_ROBBED
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
await page.locator("#save-file").setInputFiles(path);
|
||||
await page.locator("#import-current").click();
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await page.reload();
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolve } from "node:path";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
|
||||
const romPath = resolve(
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
||||
);
|
||||
|
||||
/** The version-1 database the singleton player left, with one save. */
|
||||
async function seedSingleton(page: Page, record: object) {
|
||||
await page.evaluate(
|
||||
(record) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.open("crystal-native-port", 1);
|
||||
request.onupgradeneeded = () =>
|
||||
request.result.createObjectStore("saves", { keyPath: "romHash" });
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
const transaction = db.transaction("saves", "readwrite");
|
||||
const { state, ...fields } = record as { state: number[] };
|
||||
transaction
|
||||
.objectStore("saves")
|
||||
.put({ ...fields, state: new Uint8Array(state) });
|
||||
transaction.oncomplete = () => {
|
||||
db.close();
|
||||
resolve();
|
||||
};
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
};
|
||||
}),
|
||||
record,
|
||||
);
|
||||
}
|
||||
|
||||
test("save slots: migration, new game, rename, continue, duplicate, delete and import preview", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(!existsSync(romPath), "Requires a local user-supplied ROM.");
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const bytes = readFileSync(romPath);
|
||||
const romHash = createHash("sha1").update(bytes).digest("hex");
|
||||
const world = new GameWorld(new CrystalAssets(new Uint8Array(bytes)));
|
||||
world.restore({ ...world.save(), name: "GOLD", map: "24:6", x: 6, y: 6 });
|
||||
const state = JSON.stringify(world.save());
|
||||
|
||||
await page.goto("/");
|
||||
await seedSingleton(page, {
|
||||
version: 1,
|
||||
romHash,
|
||||
coreId: "crystal-native-world-v1",
|
||||
savedAt: "2026-10-01T12:00:00.000Z",
|
||||
state: [...new TextEncoder().encode(state)],
|
||||
});
|
||||
await page.reload();
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
await expect(page.locator("#status")).toContainText("saved session is ready");
|
||||
const screen = page.locator("#game-screen");
|
||||
await expect(screen).toHaveAttribute("data-map", "24:6");
|
||||
|
||||
// The singleton save became the first slot.
|
||||
await page.locator("#tab-saves").click();
|
||||
const slots = page.locator("#slot-list > li");
|
||||
await expect(slots).toHaveCount(1);
|
||||
await expect(slots.first()).toHaveAttribute("aria-current", "true");
|
||||
await expect(slots.first()).toContainText("Save 1");
|
||||
await expect(slots.first()).toContainText("GOLD");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Delete Save 1" }),
|
||||
).toBeDisabled();
|
||||
|
||||
// A new game gets its own slot and starts with the introduction.
|
||||
await page.locator("#new-game").click();
|
||||
await expect(page.locator("#status")).toContainText("“Save 2” created");
|
||||
await expect(page.locator("#intro-screen")).toBeVisible();
|
||||
await page.locator("#tab-saves").click();
|
||||
await expect(slots).toHaveCount(2);
|
||||
await expect(
|
||||
page.locator('#slot-list > li[aria-current="true"]'),
|
||||
).toContainText("Save 2");
|
||||
|
||||
// Rename the first slot, then continue it.
|
||||
await page.getByRole("button", { name: "Rename Save 1" }).click();
|
||||
await page.locator("#slot-name").fill("Gold run");
|
||||
await page.locator("#slot-dialog-confirm").click();
|
||||
await expect(page.locator("#status")).toContainText("Renamed to “Gold run”");
|
||||
await page.getByRole("button", { name: "Continue Gold run" }).click();
|
||||
await expect(page.locator("#status")).toContainText("“Gold run” is ready");
|
||||
await expect(screen).toHaveAttribute("data-map", "24:6");
|
||||
await expect(page.locator("#intro-screen")).toBeHidden();
|
||||
|
||||
// Duplicate it, then delete the copy after confirming.
|
||||
await page.getByRole("button", { name: "Duplicate Gold run" }).click();
|
||||
await expect(page.locator("#status")).toContainText(
|
||||
"copied to “Gold run copy”",
|
||||
);
|
||||
await expect(slots).toHaveCount(3);
|
||||
await page.getByRole("button", { name: "Delete Gold run copy" }).click();
|
||||
await expect(page.locator("#slot-dialog")).toBeVisible();
|
||||
await page
|
||||
.locator("#slot-dialog")
|
||||
.getByRole("button", { name: "Cancel" })
|
||||
.click();
|
||||
await expect(slots).toHaveCount(3);
|
||||
await page.getByRole("button", { name: "Delete Gold run copy" }).click();
|
||||
await page.locator("#slot-dialog-confirm").click();
|
||||
await expect(page.locator("#status")).toContainText(
|
||||
"“Gold run copy” deleted",
|
||||
);
|
||||
await expect(slots).toHaveCount(2);
|
||||
|
||||
// The import preview shows the trainer and the slot it conflicts with,
|
||||
// and cancelling changes nothing.
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page.locator("#backup").click();
|
||||
const backup = (await (await downloaded).path())!;
|
||||
await page.locator("#save-file").setInputFiles(backup);
|
||||
const preview = page.locator("#import-dialog");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toContainText("GOLD · ID No.");
|
||||
await expect(preview).toContainText("game data version 3");
|
||||
await expect(preview).toContainText("“Gold run”");
|
||||
await expect(preview).toContainText("the same progress as this backup");
|
||||
await preview.getByRole("button", { name: "Cancel" }).click();
|
||||
await expect(slots).toHaveCount(2);
|
||||
|
||||
// A backup for another cartridge is refused without changing any slot.
|
||||
const foreign = testInfo.outputPath("foreign.crystal-save.json");
|
||||
writeFileSync(
|
||||
foreign,
|
||||
JSON.stringify({
|
||||
...JSON.parse(readFileSync(backup, "utf8")),
|
||||
romHash: "0".repeat(40),
|
||||
}),
|
||||
);
|
||||
await page.locator("#save-file").setInputFiles(foreign);
|
||||
await expect(preview).toContainText("different cartridge");
|
||||
await expect(page.locator("#import-new")).toHaveCount(0);
|
||||
await preview.getByRole("button", { name: "Close" }).click();
|
||||
await expect(slots).toHaveCount(2);
|
||||
|
||||
// Importing as a new save adds a slot and continues it.
|
||||
await page.locator("#save-file").setInputFiles(backup);
|
||||
await page.locator("#import-new").click();
|
||||
await expect(page.locator("#status")).toContainText("Backup restored");
|
||||
await expect(slots).toHaveCount(3);
|
||||
await expect(
|
||||
page.locator('#slot-list > li[aria-current="true"]'),
|
||||
).toContainText("GOLD");
|
||||
|
||||
// The last slot played opens after a reload.
|
||||
await page.getByRole("button", { name: "Continue Gold run" }).click();
|
||||
await expect(page.locator("#status")).toContainText("“Gold run” is ready");
|
||||
await page.reload();
|
||||
await page.locator("#rom-file").setInputFiles(romPath);
|
||||
await expect(page.locator("#status")).toContainText("saved session is ready");
|
||||
await page.locator("#tab-saves").click();
|
||||
await expect(
|
||||
page.locator('#slot-list > li[aria-current="true"]'),
|
||||
).toContainText("Gold run");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,454 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "fake-indexeddb/auto";
|
||||
import { SlotSession, type SlotRuntime } from "../src/app/slot-session";
|
||||
import { encodeSave, type SaveRecord } from "../src/storage/saves";
|
||||
import {
|
||||
SlotRepository,
|
||||
recordChecksum,
|
||||
type SlotSummary,
|
||||
} from "../src/storage/slots";
|
||||
|
||||
const ROM = { bytes: new Uint8Array([1]), sha1: "test-rom", name: "Test" };
|
||||
const CORE = "test-native";
|
||||
const canvas = {} as HTMLCanvasElement;
|
||||
let databases = 0;
|
||||
|
||||
/** A game whose state is JSON { player, id, steps }. */
|
||||
class FakeRuntime implements SlotRuntime {
|
||||
readonly id = CORE;
|
||||
game = { player: "", id: 0, steps: 0 };
|
||||
private games = 0;
|
||||
async load() {}
|
||||
newGame() {
|
||||
this.game = { player: "KRIS", id: ++this.games, steps: 0 };
|
||||
}
|
||||
reset() {
|
||||
this.game = { player: "", id: 0, steps: 0 };
|
||||
}
|
||||
play() {}
|
||||
pause() {}
|
||||
setButton() {}
|
||||
dispose() {}
|
||||
captureState() {
|
||||
return new TextEncoder().encode(JSON.stringify(this.game));
|
||||
}
|
||||
restoreState(state: Uint8Array) {
|
||||
this.game = this.parse(state);
|
||||
}
|
||||
summary(): SlotSummary {
|
||||
return summarize(this.game);
|
||||
}
|
||||
inspectState(state: Uint8Array) {
|
||||
return { summary: summarize(this.parse(state)), schema: 3 };
|
||||
}
|
||||
private parse(state: Uint8Array) {
|
||||
const game = JSON.parse(new TextDecoder().decode(state));
|
||||
if (typeof game.player !== "string" || !Number.isInteger(game.steps))
|
||||
throw new Error("Bad state");
|
||||
return game as FakeRuntime["game"];
|
||||
}
|
||||
}
|
||||
function summarize(game: FakeRuntime["game"]): SlotSummary {
|
||||
return {
|
||||
player: game.player,
|
||||
playerId: game.id,
|
||||
place: "NEW BARK TOWN",
|
||||
hours: 0,
|
||||
minutes: game.steps,
|
||||
badges: 0,
|
||||
caught: 0,
|
||||
newGame: false,
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const name = `slots-${++databases}`;
|
||||
const store = new SlotRepository(name);
|
||||
const runtime = new FakeRuntime();
|
||||
return { name, store, runtime, session: new SlotSession(runtime, store) };
|
||||
}
|
||||
const state = (game: object) => new TextEncoder().encode(JSON.stringify(game));
|
||||
const record = (game: object, extra: Partial<SaveRecord> = {}): SaveRecord => ({
|
||||
version: 1,
|
||||
romHash: ROM.sha1,
|
||||
coreId: CORE,
|
||||
savedAt: "2026-10-01T12:00:00.000Z",
|
||||
state: state(game),
|
||||
...extra,
|
||||
});
|
||||
|
||||
/** A version-1 database as the singleton player left it. */
|
||||
async function legacyDatabase(name: string, saved: SaveRecord) {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(name, 1);
|
||||
request.onupgradeneeded = () =>
|
||||
request.result.createObjectStore("saves", { keyPath: "romHash" });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction("saves", "readwrite");
|
||||
transaction.objectStore("saves").put(saved);
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
async function legacyRecords(name: string) {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
const records = await new Promise<SaveRecord[]>((resolve, reject) => {
|
||||
const request = db.transaction("saves").objectStore("saves").getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
db.close();
|
||||
return records;
|
||||
}
|
||||
/** Overwrites a stored record behind the repository's back. */
|
||||
async function tamper(
|
||||
name: string,
|
||||
slot: string,
|
||||
generation: number,
|
||||
change: (stored: { record: SaveRecord; checksum: number }) => void,
|
||||
) {
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
const request = indexedDB.open(name);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const transaction = db.transaction("records", "readwrite");
|
||||
const records = transaction.objectStore("records");
|
||||
const request = records.get([slot, generation]);
|
||||
request.onsuccess = () => {
|
||||
change(request.result);
|
||||
records.put(request.result);
|
||||
};
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
});
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe("save slot migration", () => {
|
||||
it("moves the singleton save into a default slot exactly once", async () => {
|
||||
const { name, store, runtime, session } = setup();
|
||||
const saved = record({ player: "GOLD", id: 7, steps: 12 });
|
||||
await legacyDatabase(name, saved);
|
||||
expect(await session.load(ROM, canvas)).toMatchObject({ restored: true });
|
||||
expect(runtime.game).toEqual({ player: "GOLD", id: 7, steps: 12 });
|
||||
const [slot] = await store.list(ROM.sha1);
|
||||
expect(slot).toMatchObject({ name: "Save 1", head: 1, version: 1 });
|
||||
expect(session.active?.id).toBe(slot.id);
|
||||
expect((await store.records(slot.id))[0]).toEqual({
|
||||
generation: 1,
|
||||
record: saved,
|
||||
intact: true,
|
||||
});
|
||||
// The list reads the summary from the record until the first write.
|
||||
expect((await session.slots())[0].slot.summary?.player).toBe("GOLD");
|
||||
// The version-1 store is kept as it was.
|
||||
expect(await legacyRecords(name)).toEqual([saved]);
|
||||
// Deleting the slot does not bring the singleton back on reopening.
|
||||
await session.newGame();
|
||||
await session.delete(slot.id);
|
||||
const reopened = new SlotRepository(name);
|
||||
expect((await reopened.list(ROM.sha1)).map((s) => s.name)).toEqual([
|
||||
"Save 2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a save from a newer player without rewriting it", async () => {
|
||||
const { name, store, session } = setup();
|
||||
const future = {
|
||||
...record({ player: "SILVER", id: 3, steps: 1 }),
|
||||
version: 2,
|
||||
extra: "kept",
|
||||
} as unknown as SaveRecord;
|
||||
await legacyDatabase(name, future);
|
||||
const result = await session.load(ROM, canvas);
|
||||
expect(result.restored).toBe(false);
|
||||
expect(result.skipped.map((slot) => slot.name)).toEqual(["Save 1"]);
|
||||
const entries = await session.slots();
|
||||
const kept = entries.find((entry) => !entry.compatible)!;
|
||||
expect(kept.slot.name).toBe("Save 1");
|
||||
expect(entries.find((entry) => entry.active)?.slot.name).toBe("Save 2");
|
||||
await session.save();
|
||||
await expect(session.switchTo(kept.slot.id)).rejects.toThrow(
|
||||
"newer version",
|
||||
);
|
||||
await expect(session.duplicate(kept.slot.id)).rejects.toThrow(
|
||||
"newer version",
|
||||
);
|
||||
expect((await store.records(kept.slot.id))[0].record).toEqual(future);
|
||||
expect(session.active?.name).toBe("Save 2");
|
||||
});
|
||||
|
||||
it("starts a new game in a first slot on an empty database", async () => {
|
||||
const { store, runtime, session } = setup();
|
||||
expect(await session.load(ROM, canvas)).toEqual({
|
||||
restored: false,
|
||||
recovered: false,
|
||||
skipped: [],
|
||||
});
|
||||
expect(runtime.game.player).toBe("KRIS");
|
||||
expect((await store.list(ROM.sha1)).map((slot) => slot.name)).toEqual([
|
||||
"Save 1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save slots", () => {
|
||||
it("creates, loads, renames, duplicates and deletes independent slots", async () => {
|
||||
const { store, runtime, session } = setup();
|
||||
await session.load(ROM, canvas);
|
||||
const first = session.active!;
|
||||
runtime.game.steps = 5;
|
||||
await session.save();
|
||||
|
||||
const second = await session.newGame("Nuzlocke");
|
||||
expect(runtime.game.steps).toBe(0);
|
||||
runtime.game.steps = 9;
|
||||
await session.save();
|
||||
|
||||
await session.switchTo(first.id);
|
||||
expect(runtime.game.steps).toBe(5);
|
||||
expect(session.active?.id).toBe(first.id);
|
||||
expect(await store.active(ROM.sha1)).toBe(first.id);
|
||||
|
||||
await session.rename(first.id, " Main run ");
|
||||
expect(session.active?.name).toBe("Main run");
|
||||
await expect(session.rename(first.id, "")).rejects.toThrow("1 to 24");
|
||||
await expect(session.rename(first.id, "x".repeat(25))).rejects.toThrow(
|
||||
"1 to 24",
|
||||
);
|
||||
|
||||
runtime.game.steps = 6;
|
||||
const copy = await session.duplicate(first.id);
|
||||
expect(copy.name).toBe("Main run copy");
|
||||
expect(copy.summary?.minutes).toBe(6);
|
||||
// The copy is independent of its source.
|
||||
runtime.game.steps = 7;
|
||||
await session.save();
|
||||
await session.switchTo(copy.id);
|
||||
expect(runtime.game.steps).toBe(6);
|
||||
|
||||
await expect(session.delete(copy.id)).rejects.toThrow("Load another save");
|
||||
await session.switchTo(second.id);
|
||||
expect(runtime.game.steps).toBe(9);
|
||||
await session.delete(copy.id);
|
||||
expect(
|
||||
(await session.slots()).map((entry) => entry.slot.name).sort(),
|
||||
).toEqual(["Main run", "Nuzlocke"]);
|
||||
expect(await store.records(copy.id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes each autosave into the slot it was captured from", async () => {
|
||||
const { store, runtime, session } = setup();
|
||||
await session.load(ROM, canvas);
|
||||
const first = session.active!;
|
||||
// Hold every write until released, as a slow device would.
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => (release = resolve));
|
||||
const write = store.write.bind(store);
|
||||
store.write = async (...args) => {
|
||||
await held;
|
||||
return write(...args);
|
||||
};
|
||||
runtime.game.steps = 3;
|
||||
const autosave = session.save();
|
||||
const second = session.newGame();
|
||||
release();
|
||||
await autosave;
|
||||
const slot = await second;
|
||||
store.write = write;
|
||||
expect(runtime.game.steps).toBe(0);
|
||||
const [newest] = await store.records(first.id);
|
||||
expect(
|
||||
JSON.parse(new TextDecoder().decode(newest.record.state)).steps,
|
||||
).toBe(3);
|
||||
expect((await store.records(slot.id)).length).toBe(1);
|
||||
expect((await store.get(slot.id))?.summary?.minutes).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps two records and recovers from a damaged or missing newest one", async () => {
|
||||
const { name, store, runtime, session } = setup();
|
||||
await session.load(ROM, canvas);
|
||||
const slot = session.active!;
|
||||
for (const steps of [1, 2, 3]) {
|
||||
runtime.game.steps = steps;
|
||||
await session.save();
|
||||
}
|
||||
expect((await store.records(slot.id)).map((r) => r.generation)).toEqual([
|
||||
4, 3,
|
||||
]);
|
||||
// A torn write: the newest record no longer matches its checksum.
|
||||
await tamper(name, slot.id, 4, (stored) => {
|
||||
stored.record.state = state({ player: "KRIS", id: 1, steps: 99 });
|
||||
});
|
||||
const reloaded = new SlotSession(
|
||||
new FakeRuntime(),
|
||||
new SlotRepository(name),
|
||||
);
|
||||
expect(await reloaded.load(ROM, canvas)).toMatchObject({
|
||||
restored: true,
|
||||
recovered: true,
|
||||
});
|
||||
expect((reloaded.runtime as FakeRuntime).game.steps).toBe(2);
|
||||
// The next write supersedes the damaged record.
|
||||
await reloaded.save();
|
||||
expect(
|
||||
(await store.records(slot.id)).map((r) => [r.generation, r.intact]),
|
||||
).toEqual([
|
||||
[5, true],
|
||||
[4, false],
|
||||
]);
|
||||
|
||||
// A newest record the game rejects also falls back.
|
||||
await tamper(name, slot.id, 5, (stored) => {
|
||||
stored.record.state = state({ player: 5 });
|
||||
stored.checksum = recordChecksum(stored.record);
|
||||
});
|
||||
const again = new SlotSession(new FakeRuntime(), new SlotRepository(name));
|
||||
await expect(again.load(ROM, canvas)).resolves.toMatchObject({
|
||||
restored: false,
|
||||
skipped: [expect.objectContaining({ id: slot.id })],
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves the slot unchanged when a write is interrupted", async () => {
|
||||
const { store, runtime, session } = setup();
|
||||
await session.load(ROM, canvas);
|
||||
const slot = session.active!;
|
||||
runtime.game.steps = 1;
|
||||
await session.save();
|
||||
const before = await store.records(slot.id);
|
||||
// A value IndexedDB cannot store aborts the transaction midway.
|
||||
const summary = {
|
||||
...runtime.summary(),
|
||||
place: (() => {}) as unknown as string,
|
||||
};
|
||||
await expect(
|
||||
store.write(
|
||||
slot.id,
|
||||
record({ player: "KRIS", id: 1, steps: 8 }),
|
||||
summary,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(await store.records(slot.id)).toEqual(before);
|
||||
expect((await store.get(slot.id))?.head).toBe(2);
|
||||
});
|
||||
|
||||
it("restores the running game when a slot cannot be opened", async () => {
|
||||
const { name, store, runtime, session } = setup();
|
||||
await session.load(ROM, canvas);
|
||||
const first = session.active!;
|
||||
const broken = await store.create(
|
||||
ROM.sha1,
|
||||
"Broken",
|
||||
record({ player: 1 }),
|
||||
);
|
||||
runtime.game.steps = 4;
|
||||
await expect(session.switchTo(broken.id)).rejects.toThrow(
|
||||
"could not be read",
|
||||
);
|
||||
expect(runtime.game.steps).toBe(4);
|
||||
expect(session.active?.id).toBe(first.id);
|
||||
expect(await new SlotRepository(name).active(ROM.sha1)).toBe(first.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("save slot import", () => {
|
||||
async function loaded() {
|
||||
const context = setup();
|
||||
await context.session.load(ROM, canvas);
|
||||
context.runtime.game.steps = 2;
|
||||
await context.session.save();
|
||||
return context;
|
||||
}
|
||||
|
||||
it("rejects incompatible and damaged backups without changing a slot", async () => {
|
||||
const { store, runtime, session } = await loaded();
|
||||
const slot = session.active!;
|
||||
const before = await store.records(slot.id);
|
||||
const good = record({ player: "KRIS", id: 5, steps: 1 });
|
||||
const cases: [string, string][] = [
|
||||
["not json", "not a Crystal backup"],
|
||||
[encodeSave({ ...good, romHash: "other" }), "different cartridge"],
|
||||
[encodeSave({ ...good, coreId: "reference" }), "another player"],
|
||||
[
|
||||
JSON.stringify({ ...JSON.parse(encodeSave(good)), version: 2 }),
|
||||
"save format 2",
|
||||
],
|
||||
[
|
||||
JSON.stringify({ ...JSON.parse(encodeSave(good)), state: "???=" }),
|
||||
"damaged",
|
||||
],
|
||||
[encodeSave(record({ player: 1 })), "could not be read"],
|
||||
["x".repeat(2_000_001), "too large"],
|
||||
];
|
||||
for (const [text, reason] of cases) {
|
||||
const preview = await session.previewImport(text);
|
||||
expect(preview.ok).toBe(false);
|
||||
expect(!preview.ok && preview.reason).toContain(reason);
|
||||
}
|
||||
expect(await store.records(slot.id)).toEqual(before);
|
||||
expect(runtime.game.steps).toBe(2);
|
||||
});
|
||||
|
||||
it("previews conflicts and imports as a new slot or over one", async () => {
|
||||
const { store, runtime, session } = await loaded();
|
||||
const first = session.active!;
|
||||
const { text } = await session.exportSlot(first.id);
|
||||
const preview = await session.previewImport(text);
|
||||
if (!preview.ok) throw new Error(preview.reason);
|
||||
expect(preview.summary).toMatchObject({ player: "KRIS", minutes: 2 });
|
||||
expect(preview.schema).toBe(3);
|
||||
// The export is the running game, which was just saved.
|
||||
expect(preview.conflicts).toEqual([
|
||||
{ slot: expect.objectContaining({ id: first.id }), identical: true },
|
||||
]);
|
||||
|
||||
runtime.game.steps = 8;
|
||||
const added = await session.importBackup(preview, "new");
|
||||
expect(added.name).toBe("KRIS");
|
||||
expect(session.active?.id).toBe(added.id);
|
||||
expect(runtime.game.steps).toBe(2);
|
||||
// The slot left behind kept the progress made before importing.
|
||||
const [left] = await store.records(first.id);
|
||||
expect(JSON.parse(new TextDecoder().decode(left.record.state)).steps).toBe(
|
||||
8,
|
||||
);
|
||||
|
||||
const replaced = await session.importBackup(preview, first.id);
|
||||
expect(replaced.id).toBe(first.id);
|
||||
expect(session.active?.id).toBe(first.id);
|
||||
// The replaced record stays as the previous generation.
|
||||
const records = await store.records(first.id);
|
||||
expect(
|
||||
records.map(
|
||||
(r) => JSON.parse(new TextDecoder().decode(r.record.state)).steps,
|
||||
),
|
||||
).toEqual([2, 8]);
|
||||
});
|
||||
|
||||
it("tells a conflicting slot that moved on from an identical one", async () => {
|
||||
const { session, store, runtime } = await loaded();
|
||||
const first = session.active!;
|
||||
const { text } = await session.exportSlot(first.id);
|
||||
runtime.game.steps = 3;
|
||||
await session.save();
|
||||
const other = await session.newGame();
|
||||
expect((await store.list(ROM.sha1)).length).toBe(2);
|
||||
const preview = await session.previewImport(text);
|
||||
// The second game is another trainer; the first moved on since.
|
||||
expect(other.summary?.playerId).not.toBe(first.summary?.playerId);
|
||||
expect(preview.ok && preview.conflicts).toEqual([
|
||||
{ slot: expect.objectContaining({ id: first.id }), identical: false },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user