Files
Crystal/tests/intro.test.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

193 lines
7.2 KiB
TypeScript

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();
});
});