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