Port the introduction's naming and clock screens
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>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 09:53:52 +02:00
1 parent 5a0fe1e313
commit b13fd20be4
28 files changed
+975 -101

No files matched your search

+3 -1
View File
@@ -13,7 +13,9 @@ async function start(page: Page, scenario: BattleScenario = "win") {
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle,
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { battleFixtureWorld } = await import('/tests/battle-fixture.ts');
const world = battleFixtureWorld(assets, ${JSON.stringify(scenario)});`) });
});
-2
View File
@@ -45,8 +45,6 @@ test("ROM gate, help and responsive layouts", async ({ page }) => {
expect(button.width).toBeGreaterThanOrEqual(44);
expect(button.height).toBeGreaterThanOrEqual(44);
}
await page.locator("#player-name").scrollIntoViewIfNeeded();
await expect(page.locator("#player-name")).toBeInViewport({ ratio: 0.9 });
await page.locator("#rom-label").scrollIntoViewIfNeeded();
await expect(page.locator("#rom-label")).toBeInViewport({ ratio: 0.9 });
}
+3 -1
View File
@@ -15,9 +15,11 @@ async function start(page: Page, room: boolean | "poster" | "center" = false) {
source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({
response,
body: source.replace(
body: source.replace("this.world?.beginIntro();", "").replace(
needle,
`const { foundationFixture } = await import('/tests/foundation-fixture.ts'); const world = foundationFixture(assets, ${JSON.stringify(room)});`,
),
+131
View File
@@ -0,0 +1,131 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test.beforeEach(() =>
test.skip(!existsSync(romPath), "Requires the local user ROM."),
);
async function start(page: Page) {
await page.clock.install({ time: new Date("2026-09-25T14:37:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-25T14:37:05"));
await page.locator("#play").click();
}
/** Press A until the dialogue shows `text`. */
async function advanceTo(page: Page, text: string) {
const dialogue = page.locator("#dialogue-text");
for (let guard = 0; guard < 40; guard++) {
if ((await dialogue.textContent())?.includes(text)) return;
await page.keyboard.press("KeyX");
}
await expect(dialogue).toContainText(text);
}
test("a new game asks for gender, the clock and a name before the bedroom", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await start(page);
const screen = page.locator("#game-screen"),
choice = page.locator("#dialogue-choice"),
text = page.locator("#dialogue-text"),
picture = page.locator("#pokepic-canvas");
await expect(screen).toHaveAttribute("data-intro", "true");
await expect(page.locator("#intro-screen")).toBeVisible();
await expect(text).toHaveText("Are you a boy?\nOr are you a girl?");
// Down, then A chooses "Girl"; B is ignored.
await page.keyboard.press("KeyZ");
await page.keyboard.press("ArrowDown");
await expect(choice.getByRole("option", { name: "Girl" })).toHaveAttribute(
"aria-selected",
"true",
);
await page.keyboard.press("KeyX");
await advanceTo(page, "What time is it?");
// The hour starts at the device time; Up three times is 5 PM.
const value = choice.locator("output");
await expect(value).toHaveText("DAY 2 o'clock");
for (let i = 0; i < 3; i++) await page.keyboard.press("ArrowUp");
await expect(value).toHaveText("DAY 5 o'clock");
await page.keyboard.press("KeyX");
await expect(text).toHaveText("What?\nDAY 5 o'clock?");
await choice.getByRole("button", { name: "YES" }).click();
await expect(text).toHaveText("How many minutes?");
await expect(value).toHaveText("37 min.");
await choice.getByRole("button", { name: "Earlier" }).click();
await expect(value).toHaveText("36 min.");
await choice.getByRole("button", { name: "OK" }).click();
await expect(text).toHaveText("Whoa! 36 min.?");
await page.keyboard.press("KeyX");
await expect(text).toHaveText("DAY 5:36!\nYikes! I over-\nslept!");
await page.keyboard.press("KeyX");
await expect(picture).toHaveAttribute("aria-label", "Professor Oak");
await advanceTo(page, "This world is in-");
await expect(picture).toHaveAttribute("aria-label", "WOOPER");
await advanceTo(page, "Now, what did you");
await expect(picture).toHaveAttribute("aria-label", "Kris");
await page.keyboard.press("KeyX");
await expect(choice.getByRole("option")).toHaveText([
"NEW NAME",
"KRIS",
"AMANDA",
"JUANA",
"JODI",
]);
await page.keyboard.press("KeyX");
await expect(text).toHaveText("YOUR NAME?");
await choice.locator("input").fill("LYRA");
await choice.locator("input").press("Enter");
await expect(text).toHaveText("LYRA, are you\nready?");
await advanceTo(page, "I'll be seeing you");
await page.keyboard.press("KeyX");
await expect(screen).toHaveAttribute("data-intro", "false");
await expect(page.locator("#intro-screen")).toBeHidden();
await expect(page.locator("#pokepic")).toBeHidden();
await expect(screen).toHaveAttribute("data-map", "24:7");
await page.locator("#tab-saves").click();
const download = page.waitForEvent("download");
await page.locator("#backup").click();
const record = JSON.parse(
readFileSync((await (await download).path())!, "utf8"),
);
const save = JSON.parse(Buffer.from(record.state, "base64").toString("utf8"));
expect(save).toMatchObject({
intro: false,
name: "LYRA",
avatar: "kris",
clock: { minuteOffset: 17 * 60 + 36 - (14 * 60 + 37) },
});
expect(errors).toEqual([]);
});
test("a session saved during the introduction starts it again", async ({
page,
}) => {
await start(page);
await page.keyboard.press("KeyX");
await advanceTo(page, "What time is it?");
// Leaving the game tab pauses and saves.
await page.locator("#tab-saves").click();
await expect(page.locator("#save-status")).toContainText("Session saved");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
await page.locator("#play").click();
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-intro",
"true",
);
await expect(page.locator("#dialogue-text")).toHaveText(
"Are you a boy?\nOr are you a girl?",
);
});
+33
View File
@@ -0,0 +1,33 @@
import { expect, type Page } from "@playwright/test";
/** Play a new game's introduction through the real controls: the gender
* menu, the clock (kept at the device time) and a name. An empty name
* picks the gender's first preset (CHRIS or KRIS). */
export async function completeIntro(
page: Page,
{ girl = false, name = "" } = {},
) {
const screen = page.locator("#game-screen"),
choice = page.locator("#dialogue-choice");
await expect(screen).toHaveAttribute("data-intro", "true");
await choice.getByRole("option", { name: girl ? "Girl" : "Boy" }).click();
for (
let guard = 0;
guard < 80 && (await screen.getAttribute("data-intro")) === "true";
guard++
) {
if (!(await choice.isVisible())) {
await page.locator("#dialogue-next").click();
continue;
}
const menu = choice.getByRole("option");
if (await choice.locator("input").count()) {
await choice.locator("input").fill(name);
await choice.getByRole("button", { name: "OK" }).click();
} else if (await menu.count()) await menu.nth(name ? 0 : 1).click();
else if (await choice.getByRole("button", { name: "YES" }).count())
await choice.getByRole("button", { name: "YES" }).click();
else await choice.getByRole("button", { name: "OK" }).click();
}
await expect(screen).toHaveAttribute("data-intro", "false");
}
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
/** InitGender's menu, then NamePlayer's first item. */
const BOY = 0,
GIRL = 1,
NEW_NAME = 0,
YES = 1,
NO = 0;
describe.skipIf(!existsSync(romPath))("the new game introduction", () => {
const bytes = existsSync(romPath)
? new Uint8Array(readFileSync(romPath))
: new Uint8Array();
function intro(time = "2026-09-25T14:37:00") {
let now = new Date(time);
const world = new GameWorld(new CrystalAssets(bytes), () => now);
world.beginIntro();
return { world, setNow: (value: string) => (now = new Date(value)) };
}
it("asks for gender, the clock and a name, then starts in the bedroom", () => {
const { world } = intro();
expect(world.intro).toBe(true);
expect(world.dialogue?.choice).toEqual({
kind: "menu",
items: ["Boy", "Girl"],
selected: 0,
});
// The steppers start at the device time; 5:05 PM is chosen instead.
const shown = settle(world, [GIRL, 17, YES, 5, YES, NEW_NAME]);
expect(shown).toContain("What?\nDAY 5 o'clock?");
expect(shown).toContain("Whoa! 5 min.?");
expect(shown).toContain("DAY 5:05!\nYikes! I over-\nslept!");
expect(shown).toContain("My name is OAK.");
expect(shown.at(-1)).toBe("YOUR NAME?");
expect(world.portrait).toEqual({ player: true });
expect(world.dialogue?.choice).toEqual({
kind: "name",
current: "KRIS",
max: 7,
});
const rest = settle(world, ["KAWA"]);
expect(rest[1]).toBe("KAWA, are you\nready?");
expect(world.intro).toBe(false);
expect(world.busy).toBe(false);
expect(world.portrait).toBeUndefined();
expect(world.name).toBe("KAWA");
expect(world.avatar).toBe("kris");
expect(world.state.clock.minuteOffset).toBe(17 * 60 + 5 - (14 * 60 + 37));
expect(world.clock()).toMatchObject({ hours: 17, minutes: 5 });
expect(world.clockTimeOfDay).toBe(1);
expect(world.mapId).toBe("24:7");
expect(world.save()).toMatchObject({ intro: false, name: "KAWA" });
});
it("offers the gender's preset names and a blank name's default", () => {
const { world } = intro();
const shown = settle(world, [BOY, 14, YES, 37, YES, 2]);
expect(shown).toContain("Zzz… Hm? Wha…?\nYou woke me up!");
expect(world.name).toBe("MAT");
expect(world.avatar).toBe("chris");
expect(world.state.clock.minuteOffset).toBe(0);
const blank = intro().world;
settle(blank, [GIRL, 14, YES, 37, YES, NEW_NAME, ""]);
expect(blank.name).toBe("KRIS");
});
it("repeats the hour or minutes after NO and ignores B in its menus", () => {
const { world } = intro();
world.choose(-1);
expect(world.dialogue?.choice?.kind).toBe("menu");
const shown = settle(world, [BOY, 3, NO, 22, YES, 59, NO, 0, YES, 1]);
expect(shown).toContain("What?\nNITE 3 o'clock?");
expect(shown).toContain("What?\nNITE 10 o'clock?");
expect(shown).toContain("NITE 10:00!\nNo wonder it's so\ndark!");
expect(world.name).toBe("CHRIS");
});
it("answers the chosen hour as OakText_ResponseToSetTime does", () => {
const response = (hour: number) => {
const { world } = intro();
const shown = settle(world, [BOY, hour, YES, 0, YES, 1]);
return shown.find((page) => page.includes(":00"));
};
expect(response(3)).toBe("NITE 3:00!\nNo wonder it's so\ndark!");
expect(response(4)).toBe("MORN 4:00!\nI overslept!");
// DAY_HOUR + 1: ten o'clock prints DAY but still oversleeps.
expect(response(10)).toBe("DAY 10:00!\nI overslept!");
expect(response(11)).toBe("DAY 11:00!\nYikes! I over-\nslept!");
expect(response(18)).toBe("NITE 6:00!\nNo wonder it's so\ndark!");
expect(response(0)).toBe("NITE 12:00!\nNo wonder it's so\ndark!");
});
it("wraps the stepper and keeps the shortest offset across midnight", () => {
const { world, setNow } = intro("2026-09-25T23:30:00");
world.choose(BOY);
world.advanceDialogue();
world.advanceDialogue();
world.advanceDialogue();
expect(world.dialogue?.choice).toMatchObject({
kind: "clock",
selected: 23,
});
world.moveCursor("up");
expect(world.dialogue?.choice).toMatchObject({ selected: 0 });
world.moveCursor("down");
world.moveCursor("down");
expect(world.dialogue?.choice).toMatchObject({ selected: 22 });
world.moveCursor("up");
world.moveCursor("up");
settle(world, [0, YES, 10, YES, 1]);
expect(world.state.clock.minuteOffset).toBe(40);
// The game clock passes midnight 40 minutes before the device does.
expect(world.clock()).toMatchObject({ hours: 0, minutes: 10 });
setNow("2026-09-26T08:00:00");
expect(world.clock()).toMatchObject({ hours: 8, minutes: 40 });
const early = intro("2026-09-25T00:10:00").world;
settle(early, [BOY, 23, YES, 30, YES, 1]);
expect(early.state.clock.minuteOffset).toBe(-40);
});
it("starts again after a save made during it", () => {
const { world } = intro();
settle(world, [GIRL, 14, YES, 37, YES, NEW_NAME]);
const mid = world.save();
expect(mid.intro).toBe(true);
expect(mid.name).toBe("CHRIS");
const restored = new GameWorld(
new CrystalAssets(bytes),
() => new Date("2026-09-25T14:37:00"),
);
restored.restore(mid);
expect(restored.intro).toBe(true);
expect(restored.dialogue?.pages[0]).toBe(
"Are you a boy?\nOr are you a girl?",
);
settle(restored, [BOY, 14, YES, 37, YES, 3]);
expect(restored.name).toBe("ALLAN");
const done = restored.save();
expect(done.intro).toBe(false);
// Loading a finished save leaves no introduction behind.
const loaded = intro().world;
loaded.restore(done);
expect(loaded.intro).toBe(false);
expect(loaded.busy).toBe(false);
expect(loaded.name).toBe("ALLAN");
});
it("migrates earlier saves as past the introduction on the device clock", () => {
const world = new GameWorld(new CrystalAssets(bytes));
const { intro: _, ...old } = world.save();
const migrated = migrateSave(
{ ...old, clock: { weekdayOffset: 3, dst: true } },
() => 0,
)!;
expect(migrated.intro).toBe(false);
expect(migrated.clock).toEqual({
minuteOffset: 0,
weekdayOffset: 3,
dst: true,
});
world.restore(migrated);
expect(world.intro).toBe(false);
expect(() =>
world.restore({
...world.save(),
clock: { minuteOffset: 720, weekdayOffset: 0, dst: false },
}),
).toThrow();
});
it("reads Oak's, Chris's and Kris's pictures and palettes", () => {
const data = new CrystalAssets(bytes).pokemon;
expect(data.trainerPic(10)).toHaveLength(49 * 16);
expect(data.trainerPic(10).some((byte) => byte !== 0)).toBe(true);
expect(data.playerPic(false)).not.toEqual(data.playerPic(true));
expect(data.trainerPalette(0)).not.toEqual(data.trainerPalette(1));
expect(data.trainerPalette(10)[0]).toEqual([255, 255, 255]);
expect(() => data.trainerPic(0)).toThrow();
});
});
+3 -1
View File
@@ -13,7 +13,9 @@ async function start(page: Page) {
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle,
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { martFixtureWorld } = await import('/tests/mart-fixture.ts');
const world = martFixtureWorld(assets);`) });
});
+9 -3
View File
@@ -8,6 +8,7 @@ import { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { VECTORS } from "../src/game/world/types";
import { settle } from "./world-driver";
import { completeIntro } from "./intro-driver";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
@@ -59,10 +60,13 @@ test("local maps follow a stairs transition and importing a previous position",
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click();
await completeIntro(page);
await page.locator("#tab-saves").click();
const downloaded = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = await downloaded;
await page.locator("#tab-game").click();
await page.locator("#play").click();
for (const direction of path!) {
const [dx, dy] = VECTORS[direction];
@@ -128,6 +132,7 @@ test("Mom's opening scene runs with native keyboard and pointer choices", async
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click();
await completeIntro(page);
const screen = page.locator("#game-screen");
const step = async (key: string, map: string, x: number, y: number) => {
await page.keyboard.down(key);
@@ -437,17 +442,18 @@ test("native player loads the actual ROM without loading an emulator and persist
page.on("request", (request) => requests.push(request.url()));
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await page.locator("#player-name").fill("KAWA");
await page.locator("#player-avatar").selectOption("kris");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-map",
"24:7",
);
await expect(page.locator("#minimap")).toBeVisible();
await expect(page.locator("#minimap")).toHaveAttribute("data-map", "24:7");
await page.locator("#play").click();
// The introduction covers the world and its minimap until it ends.
await expect(page.locator("#minimap")).toBeHidden();
await completeIntro(page, { girl: true, name: "KAWA" });
await expect(page.locator("#minimap")).toBeVisible();
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3");
await page.keyboard.down("ArrowUp");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
+3 -3
View File
@@ -90,7 +90,7 @@ describe("native ROM formats", () => {
sceneOwner: () => undefined,
timeMask: () => 2,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0,
clockDay: () => 0,
text: () => defaultTextContext("KAWA"),
};
const yes = new ScriptRun(new RomReader(bytes), 0, state, host);
@@ -260,7 +260,7 @@ describe.skipIf(!existsSync(romPath))(
expect(save.scenes).toEqual({ "24:6": 1 });
expect(save.phone).toEqual([1]);
// 2026-09-22 is a Tuesday; the chosen Monday is six days ahead.
expect(save.clock).toEqual({ weekdayOffset: 6, dst: true });
expect(save.clock).toEqual({ minuteOffset: 0, weekdayOffset: 6, dst: true });
expect(world.clock().weekday).toBe(1);
expect(world.state.flags.size).toBe(flags + 2);
expect(world.state.events.has(1735)).toBe(true);
@@ -839,7 +839,7 @@ describe.skipIf(!existsSync(romPath))(
sceneOwner: () => undefined,
timeOfDay: () => 1,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
deviceWeekday: () => 0,
clockDay: () => 0,
text: () => defaultTextContext("CHRIS"),
data: assets.pokemon,
player: () => ({ id: 0, gender: 0 as const }),
+3 -1
View File
@@ -14,7 +14,9 @@ async function start(
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle,
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { ${fixture} } = await import('/tests/quest-fixture.ts');
const world = ${fixture}(assets);`) });
});
+6 -2
View File
@@ -12,7 +12,9 @@ test("trainer UI refuses RUN, wins against Joey and saves the reward", async ({
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle,
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { trainerFixtureWorld } = await import('/tests/trainer-fixture.ts');
const world = trainerFixtureWorld(assets, undefined, false, true);`) });
});
@@ -68,7 +70,9 @@ test("Route 30: Bug Catcher Don spots the player, walks over and sends out both
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle,
// Fixture worlds stand in for a saved session, so no introduction runs.
expect(source).toContain("this.world?.beginIntro();");
await route.fulfill({ response, body: source.replace("this.world?.beginIntro();", "").replace(needle,
`const { route30FixtureWorld } = await import('/tests/trainer-fixture.ts');
const world = route30FixtureWorld(assets);`) });
});
+5 -2
View File
@@ -2,7 +2,8 @@ import type { GameWorld } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
/** Drive a world through scenes the way a player would: acknowledge pages,
* answer choices in order (default YES / first weekday) and wait out timed
* answer choices in order (default YES, the first weekday or menu item, or
* the clock's shown value) and wait out timed
* effects. Returns every page that was shown. */
export function settle(
world: GameWorld,
@@ -29,7 +30,9 @@ export function settle(
? answer
: dialogue.choice.kind === "yesno"
? 1
: 0,
: dialogue.choice.kind === "clock"
? dialogue.choice.selected
: 0,
);
} else if (last && dialogue.holdTicks !== undefined) {
if (page !== lastHeld) shown.push(page);