Checks / web (push) Canceled after 0s
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>
245 lines
9.9 KiB
TypeScript
245 lines
9.9 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";
|
|
|
|
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(world.save()).toEqual(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(world.save()).toEqual(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);
|
|
});
|
|
});
|