POKéGEAR in the start menu runs the clock, Town Map and phone cards with the original joypad rules. Calls to Elm and Mom run their callee scripts, with the out-of-area and out-of-hours cases and HangUp's corrected timing. Mom saves a quarter of trainer prizes once asked, talking to her at home opens BankOfMom (GET, SAVE, CHANGE and the DST checks) with its digit amount box, and MomTriesToBuySomething buys items and dolls with her savings. Saves gain Mom's money, her next item and its trigger balance. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3456aa4e11
commit
209778cabe
29 files changed
+2726
-47
No files matched your search
@@ -0,0 +1,340 @@
|
||||
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 { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { ENGINE_FLAGS } from "../src/game/rom/profile";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
import { Pokegear, type PokegearView } from "../src/game/scripts/pokegear";
|
||||
import {
|
||||
emptyScriptState,
|
||||
type ScriptHost,
|
||||
} from "../src/game/scripts/interpreter";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const NEW_BARK_TOWN = "24:4",
|
||||
PLAYERS_HOUSE_1F = "24:6",
|
||||
CYNDAQUIL = 155;
|
||||
const EVENT_GOT_A_POKEMON_FROM_ELM = 26,
|
||||
EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON = 30,
|
||||
EVENT_GAVE_MYSTERY_EGG_TO_ELM = 31,
|
||||
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64,
|
||||
EVENT_DUDE_TALKED_TO_YOU = 65;
|
||||
const LANDMARK_ROUTE_29 = 2;
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the Pokégear", () => {
|
||||
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
/** Outdoors in New Bark Town on a Friday at noon, with Mom's and Elm's
|
||||
* numbers, the Pokégear and its map and phone cards. */
|
||||
function fixture(map = NEW_BARK_TOWN, x = 13, y = 7) {
|
||||
const world = new GameWorld(
|
||||
assets(),
|
||||
() => new Date("2026-09-25T12:00:00"),
|
||||
() => 0x42,
|
||||
);
|
||||
world.state.party = [
|
||||
generatePartyMon(world.assets.pokemon, {
|
||||
species: CYNDAQUIL,
|
||||
level: 8,
|
||||
item: 0,
|
||||
otId: world.playerId,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x9abc,
|
||||
caughtData: 0,
|
||||
}),
|
||||
];
|
||||
world.state.flags.add(ENGINE_FLAGS.pokegear);
|
||||
world.state.flags.add(OW.ENGINE_PHONE_CARD);
|
||||
world.state.flags.add(OW.ENGINE_MAP_CARD);
|
||||
world.state.phone = [OW.PHONECONTACT_MOM, OW.PHONECONTACT_ELM];
|
||||
world.state.events.add(EVENT_GOT_A_POKEMON_FROM_ELM);
|
||||
world.state.scenes[NEW_BARK_TOWN] = 1;
|
||||
// Arriving through a warp clears the map's temporary events, such as
|
||||
// the bedroom's EVENT_TEMPORARY_UNTIL_MAP_RELOAD_8 that Mom checks.
|
||||
for (let event = 0; event < 8; event++) world.state.events.delete(event);
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map,
|
||||
x,
|
||||
y,
|
||||
direction: "down",
|
||||
objects: [],
|
||||
});
|
||||
return world;
|
||||
}
|
||||
/** Acknowledge pages up to the next choice; returns the pages seen. */
|
||||
function until(world: GameWorld) {
|
||||
const pages: string[] = [];
|
||||
for (let tick = 0; tick < 2000; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (!dialogue) {
|
||||
if (!world.busy) return pages;
|
||||
world.tick();
|
||||
continue;
|
||||
}
|
||||
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
|
||||
if (page && pages.at(-1) !== page) pages.push(page);
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (last && dialogue.choice) return pages;
|
||||
if (last && dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
}
|
||||
throw new Error("No choice appeared.");
|
||||
}
|
||||
/** Open the Pokégear from the start menu. */
|
||||
function openGear(world: GameWorld) {
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
until(world);
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "menu") throw new Error("Expected the start menu.");
|
||||
world.choose(choice.items.indexOf("POKéGEAR"));
|
||||
return until(world);
|
||||
}
|
||||
function view(world: GameWorld): PokegearView {
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "pokegear") throw new Error(`Expected the Pokégear, got ${choice?.kind}`);
|
||||
return choice.view;
|
||||
}
|
||||
function press(world: GameWorld, button: "a" | "b" | "up" | "down" | "left" | "right") {
|
||||
if (button === "a") world.interact();
|
||||
else if (button === "b") world.advanceDialogue();
|
||||
else world.moveCursor(button);
|
||||
return until(world);
|
||||
}
|
||||
/** B out of the Pokégear and the start menu. */
|
||||
function close(world: GameWorld) {
|
||||
for (let i = 0; i < 10 && world.busy; i++) {
|
||||
if (world.dialogue?.choice) world.advanceDialogue();
|
||||
until(world);
|
||||
}
|
||||
settle(world);
|
||||
expect(world.diagnostic).toBe("");
|
||||
expect(world.pokegear).toBeUndefined();
|
||||
}
|
||||
|
||||
it("opens on the clock card and switches cards with left and right", () => {
|
||||
const world = fixture();
|
||||
expect(openGear(world)).toEqual(["Press any button to exit."]);
|
||||
expect(view(world)).toMatchObject({
|
||||
card: "clock",
|
||||
cards: ["clock", "map", "phone"],
|
||||
clock: { day: "FRIDAY", time: "12:00 PM" },
|
||||
});
|
||||
expect(world.pokegear).toBe(view(world));
|
||||
// Left does nothing on the clock card.
|
||||
press(world, "left");
|
||||
expect(view(world).card).toBe("clock");
|
||||
press(world, "right");
|
||||
expect(view(world)).toMatchObject({
|
||||
card: "map",
|
||||
map: { kanto: false, cursor: 1, player: 1, name: "NEW BARK TOWN" },
|
||||
});
|
||||
press(world, "up");
|
||||
expect(view(world).map).toMatchObject({ cursor: LANDMARK_ROUTE_29, name: "ROUTE 29" });
|
||||
press(world, "down");
|
||||
press(world, "down");
|
||||
// The cursor wraps to the last Johto landmark.
|
||||
expect(view(world).map?.cursor).toBe(OW.JOHTO_LANDMARK_LAST);
|
||||
press(world, "right");
|
||||
expect(view(world).phone).toEqual({
|
||||
rows: ["MOM:", "PROF.ELM:", "----------", "----------"],
|
||||
cursor: 0,
|
||||
signal: true,
|
||||
});
|
||||
expect(world.dialogue?.pages).toEqual(["Whom do you want\nto call?"]);
|
||||
// There is no radio card; right stays on the phone.
|
||||
press(world, "right");
|
||||
expect(view(world).card).toBe("phone");
|
||||
press(world, "left");
|
||||
// The map card keeps its cursor while the Pokégear is open.
|
||||
expect(view(world).map?.cursor).toBe(OW.JOHTO_LANDMARK_LAST);
|
||||
press(world, "left");
|
||||
expect(view(world).card).toBe("clock");
|
||||
// Any button leaves the clock card for the start menu.
|
||||
press(world, "a");
|
||||
expect(world.dialogue?.choice?.kind).toBe("menu");
|
||||
expect(world.pokegear).toBeUndefined();
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("follows the game clock while the clock card is open", () => {
|
||||
let now = new Date("2026-09-25T00:05:00");
|
||||
const world = new GameWorld(assets(), () => now, () => 0x42);
|
||||
world.state.flags.add(ENGINE_FLAGS.pokegear);
|
||||
openGear(world);
|
||||
expect(view(world).clock).toEqual({ day: "FRIDAY", time: "12:05 AM" });
|
||||
now = new Date("2026-09-25T09:41:00");
|
||||
world.tick();
|
||||
expect(world.pokegear?.clock).toEqual({ day: "FRIDAY", time: " 9:41 AM" });
|
||||
// Without the map or phone card, right does nothing.
|
||||
press(world, "right");
|
||||
expect(view(world)).toMatchObject({ card: "clock", cards: ["clock"] });
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("scrolls the phone list through all ten slots", () => {
|
||||
const world = fixture();
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
for (let i = 0; i < 3; i++) press(world, "down");
|
||||
expect(view(world).phone).toMatchObject({ cursor: 3 });
|
||||
for (let i = 0; i < 10; i++) press(world, "down");
|
||||
// The list scrolls until its last four slots show.
|
||||
expect(view(world).phone).toMatchObject({
|
||||
cursor: 3,
|
||||
rows: ["----------", "----------", "----------", "----------"],
|
||||
});
|
||||
// A on an empty slot does nothing.
|
||||
press(world, "a");
|
||||
expect(view(world).phone?.submenu).toBeUndefined();
|
||||
for (let i = 0; i < 9; i++) press(world, "up");
|
||||
expect(view(world).phone).toMatchObject({ cursor: 0, rows: ["MOM:", "PROF.ELM:", "----------", "----------"] });
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("calls Elm with his callee script", () => {
|
||||
const world = fixture();
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
press(world, "down");
|
||||
press(world, "a");
|
||||
// Mom and Elm cannot be deleted.
|
||||
expect(view(world).phone).toMatchObject({
|
||||
chosen: 1,
|
||||
submenu: { items: ["CALL", "CANCEL"], selected: 0 },
|
||||
});
|
||||
// The submenu stops at its ends.
|
||||
press(world, "down");
|
||||
press(world, "down");
|
||||
expect(view(world).phone?.submenu?.selected).toBe(1);
|
||||
press(world, "a");
|
||||
expect(view(world).phone?.submenu).toBeUndefined();
|
||||
press(world, "a");
|
||||
press(world, "up");
|
||||
const pages = press(world, "a");
|
||||
expect(pages[0]).toBe("……");
|
||||
expect(pages.slice(1, -3)).toEqual([
|
||||
"Hello, CHRIS?",
|
||||
"Try not to overdo it.",
|
||||
"Be sure to heal your POKéMON if they are hurt.",
|
||||
]);
|
||||
expect(pages.slice(-3)).toEqual(["Click!", "……", "Whom do you want to call?"]);
|
||||
expect(view(world).phone?.chosen).toBeUndefined();
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("tells the player to talk to a contact on the same map", () => {
|
||||
const world = fixture(PLAYERS_HOUSE_1F, 7, 3);
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
press(world, "a");
|
||||
const pages = press(world, "a");
|
||||
expect(pages).toContain("Just go talk to that person!");
|
||||
close(world);
|
||||
});
|
||||
|
||||
it("calls Mom, whose lecture after the catching lesson starts her savings", () => {
|
||||
const world = fixture();
|
||||
world.state.events.add(EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON);
|
||||
world.state.events.add(EVENT_GAVE_MYSTERY_EGG_TO_ELM);
|
||||
world.state.events.add(EVENT_DUDE_TALKED_TO_YOU);
|
||||
world.state.specialPhoneCall = 1;
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
press(world, "a");
|
||||
const lecture = press(world, "a");
|
||||
expect(lecture).toContain("Hello?");
|
||||
expect(world.dialogue?.choice?.kind).toBe("yesno");
|
||||
// Saves made during the call keep the state from before the menu.
|
||||
expect(world.save().events).not.toContain(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST);
|
||||
world.choose(1);
|
||||
const answer = until(world);
|
||||
expect(answer.slice(0, 2)).toEqual([
|
||||
"OK. I'll save your money.",
|
||||
"CHRIS, keep it up! I'm rooting for you, baby!",
|
||||
]);
|
||||
expect(answer.slice(-3)).toEqual(["Click!", "……", "Whom do you want to call?"]);
|
||||
close(world);
|
||||
expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST)).toBe(true);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
|
||||
expect(world.state.specialPhoneCall).toBe(0);
|
||||
|
||||
// The next call asks about her savings from where the player is.
|
||||
world.state.momsMoney = 1234;
|
||||
openGear(world);
|
||||
press(world, "right");
|
||||
press(world, "right");
|
||||
press(world, "a");
|
||||
const greeting = press(world, "a");
|
||||
expect(greeting).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Hello?",
|
||||
"Oh, hi, CHRIS! Working hard?",
|
||||
"What? You're in NEW BARK TOWN?",
|
||||
"Come see your MOM sometime!",
|
||||
]),
|
||||
);
|
||||
expect(greeting.slice(-2).join(" ")).toMatch(
|
||||
/¥1234.*Do you want to keep on saving\?$/,
|
||||
);
|
||||
world.choose(0);
|
||||
expect(until(world)).toContain("OK. I won't save your money.");
|
||||
close(world);
|
||||
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports no service where the map has none", () => {
|
||||
const world = fixture();
|
||||
const state = emptyScriptState();
|
||||
state.flags.add(OW.ENGINE_PHONE_CARD);
|
||||
state.phone = [OW.PHONECONTACT_ELM];
|
||||
const scriptHost: ScriptHost = {
|
||||
...(world as unknown as { host(id: number): ScriptHost }).host(0),
|
||||
phoneService: () => 1,
|
||||
};
|
||||
const gear = new Pokegear(state, {
|
||||
reader: world.assets.reader,
|
||||
data: world.assets.pokemon,
|
||||
text: () => scriptHost.text(),
|
||||
scriptHost: () => scriptHost,
|
||||
landmarkOf: () => 1,
|
||||
}).run();
|
||||
const effects: unknown[] = [];
|
||||
let effect = gear.next();
|
||||
const answers = ["right", "a", "a", undefined, undefined, "b", "b"];
|
||||
while (!effect.done) {
|
||||
effects.push(effect.value);
|
||||
effect = gear.next(answers.shift());
|
||||
}
|
||||
const phone = effects.find(
|
||||
(e) => (e as { view?: PokegearView }).view?.card === "phone",
|
||||
) as { view: PokegearView };
|
||||
expect(phone.view.phone?.signal).toBe(false);
|
||||
expect(effects).toContainEqual({ kind: "text", pages: ["……"], holdTicks: 60 });
|
||||
expect(effects).toContainEqual({
|
||||
kind: "text",
|
||||
pages: ["You're out of the\nservice area."],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Mom's savings in saves", () => {
|
||||
const world = fixture();
|
||||
world.state.momsMoney = 4321;
|
||||
const save = world.save();
|
||||
expect(save.momsMoney).toBe(4321);
|
||||
const { momsMoney: _, ...old } = save;
|
||||
const migrated = migrateSave(old, () => 0)!;
|
||||
expect(migrated.momsMoney).toBe(0);
|
||||
world.restore(migrated);
|
||||
expect(world.state.momsMoney).toBe(0);
|
||||
expect(() => world.restore({ ...save, momsMoney: -1 })).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user