Files
Sébastien QUEROLandClaude Opus 5.5 209778cabe
Checks / web (push) Canceled after 0s
Port the Pokégear, phone calls and Mom's bank
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>
2026-10-01 11:38:22 +02:00

400 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } 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 {
shiftClockHour,
type MomBankButton,
type MomBankView,
} from "../src/game/scripts/mom";
import { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const 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_FIRST_TIME_BANKING_WITH_MOM = 118;
const GET = 0,
SAVE = 1,
CHANGE = 2;
describe("DSTChecks' clock shift", () => {
it("moves the clock an hour, carrying across the half-day offset limit", () => {
const clock = { minuteOffset: 30, weekdayOffset: 2, dst: false };
shiftClockHour(clock, 1);
expect(clock).toEqual({ minuteOffset: 90, weekdayOffset: 2, dst: false });
clock.minuteOffset = 700;
shiftClockHour(clock, 1);
// 760 minutes ahead is a day ahead less 680 minutes.
expect(clock).toMatchObject({ minuteOffset: -680, weekdayOffset: 3 });
shiftClockHour(clock, -1);
expect(clock).toMatchObject({
minuteOffset: -740 + 1440,
weekdayOffset: 2,
});
clock.minuteOffset = -700;
clock.weekdayOffset = 0;
shiftClockHour(clock, -1);
expect(clock).toMatchObject({ minuteOffset: 680, weekdayOffset: 6 });
});
});
describe.skipIf(!existsSync(romPath))("BankOfMom", () => {
const assets = () => new CrystalAssets(new Uint8Array(readFileSync(romPath)));
/** The player faces Mom in the living room on a Friday, after the
* Mystery Egg was delivered. */
function fixture(time = "2026-09-25T12:00:00") {
const world = new GameWorld(
assets(),
() => new Date(time),
() => 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);
for (const event of [
EVENT_GOT_A_POKEMON_FROM_ELM,
EVENT_GOT_MYSTERY_EGG_FROM_MR_POKEMON,
EVENT_GAVE_MYSTERY_EGG_TO_ELM,
])
world.state.events.add(event);
world.state.scenes[PLAYERS_HOUSE_1F] = 1;
world.state.money = 3000;
world.restore({
...world.save(),
map: PLAYERS_HOUSE_1F,
x: 7,
y: 3,
direction: "down",
objects: [],
});
return world;
}
/** A world whose bank is already open, as after the first visit. */
function banking(time?: string) {
const world = fixture(time);
world.state.flags.add(OW.ENGINE_MOM_ACTIVE);
world.state.events.add(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST);
world.state.momsMoney = 2300;
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.");
}
function talk(world: GameWorld) {
world.interact();
return until(world);
}
function answer(world: GameWorld, value: number) {
world.choose(value);
return until(world);
}
function view(world: GameWorld): MomBankView {
const choice = world.dialogue?.choice;
if (choice?.kind !== "mombank")
throw new Error(`Expected Mom's amount box, got ${choice?.kind}`);
return choice.view;
}
function press(world: GameWorld, ...buttons: MomBankButton[]) {
let pages: string[] = [];
for (const button of buttons) {
if (button === "a") world.interact();
else if (button === "b") world.advanceDialogue();
else world.moveCursor(button);
pages = until(world);
}
return pages;
}
/** Open the menu and choose GET, SAVE or CHANGE. */
function menu(world: GameWorld, item: number) {
expect(talk(world)).toEqual([
"Hi! Welcome home! You're trying very hard, I see.",
"I've kept your room tidy.",
"Or is this about your money?",
]);
const pages = answer(world, 1);
expect(pages).toEqual(["What do you want to do?"]);
const choice = world.dialogue?.choice;
expect(choice).toMatchObject({
kind: "menu",
items: ["GET", "SAVE", "CHANGE", "CANCEL"],
selected: 0,
});
return answer(world, item);
}
/** Close the last text and check the interaction ended cleanly. */
function finish(world: GameWorld) {
expect(until(world)).toEqual([]);
settle(world);
expect(world.busy).toBe(false);
expect(world.diagnostic).toBe("");
}
/** Key in an amount digit by digit, from the leftmost of six digits. */
function enter(world: GameWorld, amount: number) {
const digits = String(amount).padStart(6, "0");
press(world, "left", "left", "left", "left", "left");
for (const digit of digits) {
for (let i = 0; i < Number(digit); i++) press(world, "up");
press(world, "right");
}
expect(view(world).amount).toBe(amount);
}
it("offers to save money the first time, then only cheers the player on", () => {
const world = fixture();
const offer = talk(world);
expect(offer[0]).toBe("Wow, that's a cute POKéMON.");
expect(offer.at(-1)).toBe("Do you want me to save your money?");
expect(world.dialogue?.choice?.kind).toBe("yesno");
// Saves made during the visit keep the state from before it.
expect(world.save().events).not.toContain(
EVENT_FIRST_TIME_BANKING_WITH_MOM,
);
world.choose(1);
const pages = [...until(world)];
expect(pages.slice(0, 3)).toEqual([
"OK, I'll take care of your money.",
"………………",
"Be careful.",
]);
expect(pages.at(-1)).toBe("Now, go on!");
finish(world);
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
expect(world.state.events.has(EVENT_FIRST_TIME_BANKING_WITH_MOM)).toBe(
true,
);
expect(
world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST),
).toBe(true);
// Until New Bark Town's callback clears it, Mom only cheers.
const cheer = talk(world);
expect(cheer.join(" ")).toMatch(/behind you/i);
finish(world);
});
it("does not start saving when the player declines", () => {
const world = fixture();
talk(world);
const pages = answer(world, 0);
expect(pages[0]).toBe("Be careful.");
finish(world);
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
});
it("deposits money, refusing more than the player holds", () => {
const world = banking();
expect(menu(world, SAVE)).toEqual(["How much do you want to save?"]);
expect(view(world)).toEqual({
labels: ["SAVED", "HELD", "DEPOSIT"],
saved: 2300,
held: 3000,
amount: 0,
cursor: 5,
});
enter(world, 5000);
expect(press(world, "a")).toEqual([
"You don't have that much.",
"How much do you want to save?",
]);
// .StoreMoney starts again from zero on the last digit.
expect(view(world)).toMatchObject({ amount: 0, cursor: 5 });
enter(world, 1000);
expect(press(world, "a")).toEqual(["Your money's safe here! Get going!"]);
finish(world);
expect(world.state.money).toBe(2000);
expect(world.state.momsMoney).toBe(3300);
});
it("withdraws money, refusing more than Mom saved", () => {
const world = banking();
expect(menu(world, GET)).toEqual(["How much do you want to take?"]);
expect(view(world).labels[2]).toBe("WITHDRAW");
enter(world, 2301);
expect(press(world, "a")).toEqual([
"You haven't saved that much.",
"How much do you want to take?",
]);
enter(world, 2300);
expect(press(world, "a")).toEqual(["CHRIS, don't give up!"]);
finish(world);
expect(world.state.money).toBe(5300);
expect(world.state.momsMoney).toBe(0);
});
it("refuses amounts that would pass MAX_MONEY on either side", () => {
const world = banking();
world.state.money = 999_000;
menu(world, GET);
enter(world, 1000);
expect(press(world, "a")).toEqual([
"You can't take that much.",
"How much do you want to take?",
]);
enter(world, 999);
press(world, "a");
finish(world);
expect(world.state.money).toBe(999_999);
expect(world.state.momsMoney).toBe(1301);
world.state.momsMoney = 999_000;
menu(world, SAVE);
enter(world, 1000);
expect(press(world, "a")).toEqual([
"You can't save that much.",
"How much do you want to save?",
]);
press(world, "b");
finish(world);
expect(world.state.momsMoney).toBe(999_000);
});
it("steps digits through GiveMoney and TakeMoney's limits", () => {
const world = banking();
menu(world, SAVE);
// The cursor stops at both ends.
press(world, "right");
expect(view(world).cursor).toBe(5);
press(world, "down");
expect(view(world).amount).toBe(0);
press(world, "up", "up", "left", "up");
expect(view(world)).toMatchObject({ amount: 12, cursor: 4 });
for (let i = 0; i < 6; i++) press(world, "left");
expect(view(world).cursor).toBe(0);
for (let i = 0; i < 10; i++) press(world, "up");
expect(view(world).amount).toBe(999_999);
press(world, "down");
expect(view(world).amount).toBe(899_999);
for (let i = 0; i < 9; i++) press(world, "down");
expect(view(world).amount).toBe(0);
// Start and Select are not read.
world.choose("start");
expect(view(world).amount).toBe(0);
// A with nothing entered cancels.
expect(press(world, "a")).toEqual(["Just do what you can."]);
finish(world);
expect(world.state.momsMoney).toBe(2300);
});
it("cancels with B, from the box and from the menu", () => {
const world = banking();
menu(world, SAVE);
press(world, "up");
expect(press(world, "b")).toEqual(["Just do what you can."]);
finish(world);
expect(world.state.money).toBe(3000);
menu(world, -1);
finish(world);
});
it("starts or stops her savings with CHANGE", () => {
const world = banking();
expect(menu(world, CHANGE)).toEqual(["Do you want to save some money?"]);
expect(answer(world, 1)).toEqual([
"OK, I'll save your money. Trust me!",
"CHRIS, stick with it!",
]);
finish(world);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(true);
menu(world, CHANGE);
expect(answer(world, 0)).toEqual(["Just do what you can."]);
finish(world);
expect(world.state.flags.has(OW.ENGINE_MOM_SAVING_MONEY)).toBe(false);
expect(world.state.flags.has(OW.ENGINE_MOM_ACTIVE)).toBe(true);
});
it("asks about Daylight Saving Time when it is not about money", () => {
const world = banking();
talk(world);
expect(answer(world, 0)).toEqual([
"Do you want to switch to Daylight Saving Time?",
]);
expect(answer(world, 1)).toEqual([
"I set the clock forward by one hour.",
"Just do what you can.",
]);
finish(world);
expect(world.state.clock.dst).toBe(true);
expect(world.clock().hours).toBe(13);
talk(world);
expect(answer(world, 0)).toEqual(["Is Daylight Saving Time over?"]);
expect(answer(world, 1)).toEqual([
"I put the clock back one hour.",
"Just do what you can.",
]);
finish(world);
expect(world.state.clock.dst).toBe(false);
expect(world.clock().hours).toBe(12);
// Declining leaves the clock alone.
talk(world);
answer(world, 0);
expect(answer(world, 0)).toEqual(["Just do what you can."]);
finish(world);
expect(world.state.clock).toMatchObject({ dst: false, minuteOffset: 0 });
});
it("will not change the clock within an hour of midnight", () => {
const world = banking("2026-09-25T23:30:00");
talk(world);
expect(answer(world, 0)).toEqual([
"Do you want to adjust your clock",
"for Daylight Saving Time?",
]);
expect(answer(world, 1)).toEqual([
"I lost the in- struction booklet for the POKéGEAR.",
"Come back again in a while.",
"Just do what you can.",
]);
finish(world);
expect(world.state.clock).toMatchObject({ dst: false, minuteOffset: 0 });
const dst = banking("2026-09-26T00:30:00");
dst.state.clock.dst = true;
talk(dst);
expect(answer(dst, 0)).toEqual([
"Do you want to adjust your clock",
"for Daylight Saving Time?",
]);
expect(answer(dst, 0)).toEqual(["Just do what you can."]);
finish(dst);
expect(dst.state.clock.dst).toBe(true);
});
});