Files
Crystal/tests/trainer-world.test.ts
T
kawaandClaude Opus 5.5 7c12c81a27
Checks / web (push) Successful in 2m38s
Private ROM checks / private-rom (push) Successful in 20m37s
Ring with trainers' calls, rematch them and play the radio (Closes #30)
Every registered trainer's callee and caller scripts run, with the
missing specials and commands (RandomPhoneWildMon, RandomUnseenWildMon,
swarms, gettrainerclassname, Kenji's break, the Blue Card and Buena's
password variables). Fight counts are saved, so rematches and gifts work
on every open map, and the daily reset clears the phone's flags.

Random calls follow CheckPhoneCall and the 20/10/5/3-minute call timer;
received calls ring twice with the caller's name, and calls play their
ringing, hang-up and no-signal sounds. Grass and fishing swarms use the
swarm tables.

The radio ports PlayRadioShow and every station frame by frame, with its
music: the Pokégear's radio card tunes by knob, house radios play through
MapRadio, and Buena's show takes the day's password for Blue Card points
that her receptionist exchanges for prizes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:10:02 +02:00

247 lines
10 KiB
TypeScript

import { isRinging } from "./world-driver";
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { C } from "../src/game/battle/constants";
import { OW } from "../src/game/world/constants";
import { trainerFixtureWorld } from "./trainer-fixture";
import { migrateSave, type GameWorld } from "../src/game/world/world";
import { DecorationData } from "../src/game/world/decorations";
import { withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = (tail?: number[], lose = false, winText = false) =>
trainerFixtureWorld(new CrystalAssets(new Uint8Array(readFileSync(romPath))), tail, lose, winText);
function finish(world: GameWorld) {
const messages: string[] = [];
for (let tick = 0; tick < 10000; tick++) {
const view = world.battle;
if (view) {
if (view.message) messages.push(view.message.text);
if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
else if (view.prompt?.kind === "yesno") world.battleChoose(1);
else if (view.prompt?.kind === "forget") world.battleChoose(3);
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
else world.tick();
} else if (world.dialogue) world.advanceDialogue();
else if (world.busy) world.tick();
else return messages.join("\n");
}
throw new Error(`Trainer battle did not settle: ${world.diagnostic}`);
}
describe.skipIf(!existsSync(romPath))("scripted trainer world transactions", () => {
it("commits Joey's ROM reward, party experience, sighting and postbattle event together", () => {
const world = fixture();
const before = world.save();
world.interact();
expect(world.battle).toBeDefined();
expect(world.save()).toEqual(before);
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("JOEY");
expect(world.state.money).toBe(before.money + 64);
expect(world.state.party[0].exp).toBeGreaterThan(before.party[0].exp);
expect(world.state.pokedex.seen.has(19)).toBe(true);
expect(world.state.events.has(256)).toBe(true);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
});
it("reads scripted win text from the script bank's ROM pointer", () => {
const world = fixture(undefined, false, true);
world.interact();
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("Ack! I lost again!");
expect(messages.indexOf("Ack! I lost again!")).toBeLessThan(messages.lastIndexOf("64"));
});
it("caps trainer rewards at the money limit", () => {
const world = fixture();
world.state.money = C.MAX_MONEY - 1;
world.interact();
finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.money).toBe(C.MAX_MONEY);
});
it("sends some of the reward to Mom", () => {
const world = fixture();
world.state.flags.add(OW.ENGINE_MOM_SAVING_MONEY);
world.state.momsMoney = 100;
const before = world.save();
world.interact();
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(messages).toContain("Sent some to MOM!");
expect(world.state.money).toBe(before.money + 48);
expect(world.state.momsMoney).toBe(116);
expect(world.state.momItemTriggerBalance).toBe(OW.MOM_MONEY);
});
describe("Mom's purchases (MomTriesToBuySomething)", () => {
const SUPER_POTION = 0x11,
MOM_ITEMS_1 = [
[SUPER_POTION, 600],
[0x09, 90], // ANTIDOTE
[0x05, 180], // POKE_BALL
[0x13, 450], // ESCAPE_ROPE
[0x04, 500], // GREAT_BALL
];
/** Beat Joey while Mom saves a quarter (¥16) of his ¥64; returns the
* pages of the call that follows, if any. */
function win(world: GameWorld, momsMoney: number) {
world.state.flags.add(OW.ENGINE_MOM_SAVING_MONEY);
world.state.momsMoney = momsMoney - 16;
world.interact();
const pages: string[] = [];
// The queued call starts at the next PlayerEvents.
let idle = 0;
for (let tick = 0; tick < 10000; tick++) {
const view = world.battle;
if (view) {
if (view.prompt?.kind === "action") world.battleAction({ action: "fight", move: 0 });
else if (view.prompt?.kind === "stats" || view.message?.prompt) world.interact();
else world.tick();
continue;
}
const dialogue = world.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
if (pages.at(-1) !== page && !isRinging(page)) pages.push(page);
if (dialogue.holdTicks !== undefined) world.tick();
else world.advanceDialogue();
} else if (world.busy || idle++ < 2) world.tick();
else {
expect(world.notice, world.diagnostic).toBe("");
return pages;
}
}
throw new Error(`Trainer battle did not settle: ${world.diagnostic}`);
}
it("buys MomItems_2's next item once her savings reach its trigger", () => {
const world = fixture();
const pcItems = world.state.pcItems.map((slot) => ({ ...slot }));
const pages = win(world, 900);
expect(pages).toEqual([
"Hi, CHRIS! How are you?",
"I found a useful item shopping, so",
"I bought it with your money. Sorry!",
"It's in your PC. You'll like it!",
"Click!",
"……",
]);
expect(world.state.momsMoney).toBe(300);
expect(world.state.momItem).toBe(1);
expect(world.state.pcItems).toEqual([...pcItems, { item: SUPER_POTION, quantity: 1 }]);
// MomItems_2's next trigger is ¥4000.
expect(win(world, 3999)).toEqual([]);
expect(world.state.momItem).toBe(1);
const saved = world.save();
expect([saved.momItem, saved.momItemTriggerBalance]).toEqual([1, 4600]);
});
it("buys a random MomItems_1 item when her savings land on a ¥2300 step", () => {
const world = fixture();
world.state.momItem = 1;
const pages = win(world, 2300);
expect(pages[1]).toBe("I found a useful item shopping, so");
const bought = world.state.pcItems.at(-1)!;
const entry = MOM_ITEMS_1.find(([item]) => item === bought.item);
expect(entry).toBeDefined();
expect(world.state.momsMoney).toBe(2300 - entry![1]);
expect(world.state.momItem).toBe(1);
expect(world.state.momItemTriggerBalance).toBe(4600);
});
it("raises the ¥2300 step past her savings without buying anything", () => {
const world = fixture();
world.state.momItem = 1;
expect(win(world, 2400)).toEqual([]);
expect(world.state.momsMoney).toBe(2400);
expect(world.state.momItemTriggerBalance).toBe(4600);
});
it("sends dolls to the player's room", () => {
const world = fixture();
world.state.momItem = 3; // ¥10000: the Charmander doll for ¥1800.
const pages = win(world, 10000);
expect(pages.slice(0, 4)).toEqual([
"Hi, CHRIS! How are you?",
"While shopping today, I saw this adorable doll, so",
"I bought it with your money. Sorry!",
"It's in your room. You'll love it!",
]);
expect(world.state.momsMoney).toBe(8200);
expect(world.state.momItem).toBe(4);
const doll = new DecorationData(world.assets.reader, world.assets.pokemon);
const charmander = Array.from({ length: 52 }, (_, i) => i + 1).find(
(id) => doll.name(id) === "CHARMANDER DOLL",
)!;
expect(world.state.events.has(doll.attribute(charmander).event)).toBe(true);
});
it("buys nothing while the PC is full", () => {
const world = fixture();
world.state.pcItems = Array.from({ length: 50 }, (_, i) => ({ item: 0x20 + i, quantity: 1 }));
expect(win(world, 900)).toEqual([]);
expect(world.state.momsMoney).toBe(900);
expect(world.state.momItem).toBe(0);
});
it("migrates saves made before her purchases", () => {
const world = fixture();
const { momItem: _, momItemTriggerBalance: __, ...old } = world.save();
const migrated = migrateSave(old, () => 0)!;
expect([migrated.momItem, migrated.momItemTriggerBalance]).toEqual([0, OW.MOM_MONEY]);
world.restore(migrated);
expect(() => world.restore({ ...migrated, momItem: 11 })).toThrow();
});
});
it("rolls back reward, experience and sightings if the following script is unsupported", () => {
const world = fixture([0xff]);
const before = world.save();
world.interact();
finish(world);
expect(world.notice).toContain("not been ported");
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
});
it("restores a midbattle save without partially paying the reward", () => {
const world = fixture();
const before = world.save();
world.interact();
for (let tick = 0; tick < 1000 && !world.battle?.prompt; tick++) {
if (world.battle?.message?.prompt) world.interact();
else world.tick();
}
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(before));
world.restore(before);
expect(world.battle).toBeUndefined();
expect(world.save()).toEqual(before);
});
it("CANLOSE scripts receive LOSE in wScriptVar without forcing a whiteout", () => {
// ifequal LOSE, .lost; invalid; .lost: setevent 256; end
const world = fixture([0x91], true);
const clerk = world.assets.map("26:4").objects[0];
const start = clerk.script + 12; // after winlosstext/loadtrainer/loadvar/startbattle
const target = ((start + 5) % 0x4000) + 0x4000;
world.assets.reader.bytes.set([0x06, 1, target & 255, target >> 8, 0xff, 0x33, 0, 1, 0x91], start);
const before = world.save();
world.interact();
const messages = finish(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.events.has(256)).toBe(true);
expect(world.state.money).toBe(before.money);
expect(world.save().map).toBe(before.map);
expect(messages).not.toMatch(/whited\s+out/i);
});
});