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}`;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+362
-35
@@ -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.",
|
||||
[
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user