Files
Crystal/src/app/session.ts
T
Sébastien QUEROLandClaude Opus 5.5 b13fd20be4
Checks / web (push) Canceled after 0s
Port the introduction's naming and clock screens
A new game now runs NewGame's InitGender and OakSpeech natively before
the bedroom: Boy or Girl, InitClock's hour and minute steppers with
their YES/NO confirmations, Oak's speech with Wooper, and NamePlayer's
preset names or naming field (a blank name takes CHRIS or KRIS). Texts,
menus and time strings come from a new INTRO profile table verified by
check:reference; Oak's and the player's pictures decode from
TrainerPicPointers, ChrisPic/KrisPic and TrainerPalettes.

The chosen time is kept as a minute offset from the device clock, which
time of day, the weekday, NPC hours and the daily reset now read. The
intro runs only when nothing is saved; a save made during it starts it
again. Saves gain intro and clock.minuteOffset, migrated by absence. The
shell's name and character fields are gone. Intentional change: the
steppers start at the device time rather than 10:00.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:53:52 +02:00

92 lines
2.7 KiB
TypeScript

import type { GameRuntime } from "./runtime";
import type { ValidatedRom } from "../rom/validate";
import {
decodeSave,
type SaveRecord,
type SaveRepository,
} from "../storage/saves";
export type SessionState = "empty" | "loading" | "paused" | "playing" | "error";
export class GameSession {
state: SessionState = "empty";
private rom?: ValidatedRom;
// Queue snapshots in capture order; a slow write cannot overwrite newer progress.
private writes: Promise<void> = Promise.resolve();
constructor(
readonly runtime: GameRuntime,
private saves: SaveRepository,
) {}
async load(rom: ValidatedRom, canvas: HTMLCanvasElement) {
this.state = "loading";
this.rom = undefined;
try {
const saved = await this.saves.read(rom.sha1);
if (saved && (saved.version !== 1 || saved.coreId !== this.runtime.id)) {
throw new Error(
"The stored session needs migration before this player can open it. Your save has been kept.",
);
}
await this.runtime.load(rom.bytes, canvas);
if (saved) this.runtime.restoreState(saved.state);
else this.runtime.newGame?.();
// Never retain ROM bytes in storage or the session after loading.
this.rom = { ...rom, bytes: new Uint8Array() };
this.state = "paused";
return Boolean(saved);
} catch (error) {
this.runtime.dispose();
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";
}
snapshot(): SaveRecord {
if (!this.rom || !["paused", "playing"].includes(this.state))
throw new Error("Load a ROM before saving.");
return {
version: 1,
romHash: this.rom.sha1,
coreId: this.runtime.id,
savedAt: new Date().toISOString(),
state: this.runtime.captureState(),
};
}
save() {
const record = this.snapshot();
const operation = this.writes
.catch(() => {})
.then(() => this.saves.write(record));
this.writes = operation;
return operation.then(() => record.savedAt);
}
async importSave(text: string) {
if (!this.rom) throw new Error("Load your ROM before importing a backup.");
const record = decodeSave(text, this.rom.sha1, this.runtime.id);
this.pause();
const previous = this.runtime.captureState();
try {
this.runtime.restoreState(record.state);
} catch (error) {
this.runtime.restoreState(previous);
throw error;
}
try {
await this.save();
} catch (error) {
this.runtime.restoreState(previous);
throw error;
}
}
}