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>
246 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|