Keep several saves, with migration, atomic writes and import previews (Closes #37)
Checks / web (push) Successful in 2m33s
Private ROM checks / private-rom (push) Successful in 20m32s

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:
kawaandClaude Opus 5.5 committed 2026-10-16 11:27:41 +02:00
1 parent 7c12c81a27
commit 7d03f5c5ca
18 files changed
+2079 -56

No files matched your search

+362 -35
View File
@@ -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({