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>
193 lines
7.2 KiB
TypeScript
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();
|
|
});
|
|
});
|