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
+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({
|
||||
|
||||
Reference in new issue
Block a user