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
+2079
-56
No files matched your search
@@ -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}`;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user