Files
Crystal/tests/trainer-world.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 47698565ab Port the trainer card and the play time
The player's name in the start menu opens TrainerCard: page 1 with the
name, ID, money, Pokédex count and play time, page 2 with the Johto
leaders and the badges owned, turned and closed with the original
buttons. The card picture, leader faces and animated badges come from the
ROM with _CGB_TrainerCard's palettes. GameTimer now counts the play time
from the end of the introduction, capped at 999:59:59, and saves keep it.

Closes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:32:14 +02:00

246 lines
10 KiB
TypeScript

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