Checks / web (push) Canceled after 0s
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>
92 lines
2.7 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|