Routes 26 and 27 with their houses, Tohjo Falls, the Victory Road gate, Victory Road, Route 23, Indigo Plateau, the Elite Four's rooms, Lance's room and the Hall of Fame are open. - halloffame (game/scripts/league.ts): the music fades as SAVING RECORD shows, the win and the team (without Eggs) are recorded, each Pokémon's back picture passes and its front picture slides in for its "New Hall of Famer!" page and cry, then the player's card with the play time and Professor Oak's rating; the play time stops meanwhile. The world then continues at New Bark as Continue does after the cartridge's reset. - credits and the credits after the Hall of Fame (CreditsMachine): the ROM's script, strings, banner Pokémon and THE END, frame by frame as VBlank_Credits presents them, with the frames each command group runs over measured on the cartridge. B shortens credits seen before. - wait, appear/disappear of the player, and rival names in battle texts (PlaceEnemysName prints wRivalName alone: "???" before he is named). - The tile-screen renderer wraps hSCX/hSCY at 256 pixels and gives rows their own hSCX. - Reference: the credits match the cartridge on every frame (BG map, banner tiles, CRAM palettes, LY overrides, parser), with and without B; the ceremony's pages match for every species and the player's card for Chris and Kris. - Playthrough: five stages from Tin Tower to the Hall of Fame (HM07 from Ice Path, Tohjo Falls' waterfall, Victory Road's ladders, the Elite Four and Lance); the driver follows warps within a map and fights carefully from here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
14 KiB
TypeScript
313 lines
14 KiB
TypeScript
import { beforeAll, 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 { settle, walkTo , isRinging } from "./world-driver";
|
|
import { questFixtureWorld, questLabFixtureWorld, questRivalFixtureWorld } from "./quest-fixture";
|
|
|
|
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
|
const HOUSE = "26:10", EGG = 0x45;
|
|
|
|
describe.skipIf(!existsSync(romPath))("Mr. Pokémon's house with the local ROM", () => {
|
|
function fixture(species = 155, refuseHealing = false) {
|
|
const bytes = new Uint8Array(readFileSync(romPath));
|
|
// FadeInFromBlackSpecial's table entry: refuse the routine after rewards
|
|
// and healing to check the complete interaction's rollback boundary.
|
|
if (refuseHealing) bytes.set([0, 0, 0x40], 0xc0bf);
|
|
const assets = new CrystalAssets(bytes);
|
|
const world = new GameWorld(assets, () => new Date("2026-09-24T12:00:00"));
|
|
const mon = generatePartyMon(assets.pokemon, {
|
|
species, level: 5, item: 0, otId: world.playerId,
|
|
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
|
|
});
|
|
mon.hp = 1;
|
|
mon.status = 8;
|
|
mon.pp[0] = 0;
|
|
world.state.party = [mon];
|
|
world.state.events.add(species === 155 ? 27 : species === 158 ? 28 : 29);
|
|
world.state.events.add(1794);
|
|
world.state.events.add(1939);
|
|
world.restore({ ...world.save(), map: "26:1", x: 17, y: 6, direction: "up" });
|
|
expect(world.move("up")).toBe(true);
|
|
expect(world.mapId).toBe(HOUSE);
|
|
return world;
|
|
}
|
|
|
|
it.each([[155, 1601], [158, 1602], [152, 1600]])("gives the Egg and Pokédex, heals starter %i, and sets the return quest", (species, stolenBall) => {
|
|
const world = fixture(species);
|
|
const pages = settle(world).join("\n");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(pages).toContain("MYSTERY EGG");
|
|
expect(pages).toContain("DEX");
|
|
expect(world.state.bag.keyItems).toEqual([EGG]);
|
|
expect(world.state.flags.has(11)).toBe(true);
|
|
for (const event of [30, 1737, 1725, 1938, stolenBall])
|
|
expect(world.state.events.has(event), String(event)).toBe(true);
|
|
for (const event of [1794, 1939]) expect(world.state.events.has(event)).toBe(false);
|
|
expect(world.state.lastSpawnMap).toBe("26:3");
|
|
expect(world.state.scenes).toMatchObject({ [HOUSE]: 1, "26:3": 1, "24:5": 3 });
|
|
expect(world.state.specialPhoneCall).toBe(2);
|
|
expect(world.objects().some((object) => object.id === 3)).toBe(false);
|
|
const mon = world.state.party[0];
|
|
expect(mon.hp).toBe(mon.stats[0]);
|
|
expect(mon.status).toBe(0);
|
|
expect(mon.pp[0]).toBe(world.assets.pokemon.move(mon.moves[0]).pp);
|
|
// The completed scene and Oak's removal survive reload without replay.
|
|
world.restore(world.save());
|
|
expect(settle(world)).toEqual([]);
|
|
expect(world.objects().some((object) => object.id === 3)).toBe(false);
|
|
world.interact();
|
|
expect(settle(world).join("\n")).toContain("depending on");
|
|
expect(world.state.bag.keyItems).toEqual([EGG]);
|
|
});
|
|
|
|
it("reloads during Oak's speech from committed state and replays rewards once", () => {
|
|
const world = fixture();
|
|
for (let tick = 0; tick < 3000; tick++) {
|
|
if (world.dialogue?.pages[world.dialogue.index].includes("OAK: Aha")) break;
|
|
if (world.dialogue) world.advanceDialogue();
|
|
else world.tick();
|
|
}
|
|
expect(world.dialogue?.pages[world.dialogue.index]).toContain("OAK: Aha");
|
|
const saved = world.save();
|
|
expect(saved.bag.keyItems).toEqual([]);
|
|
expect(saved.events).not.toContain(30);
|
|
world.restore(saved);
|
|
expect(settle(world).join("\n")).toContain("MYSTERY EGG");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(world.state.bag.keyItems).toEqual([EGG]);
|
|
});
|
|
|
|
it("rolls back rewards, healing and Oak's departure when a late command is refused", () => {
|
|
const world = fixture(155, true);
|
|
const before = world.save();
|
|
for (let tick = 0; tick < 5000 && !world.notice; tick++) {
|
|
if (world.dialogue) world.advanceDialogue();
|
|
else world.tick();
|
|
}
|
|
expect(world.notice).toContain("not been ported");
|
|
expect(world.state.bag.keyItems).toEqual([]);
|
|
expect(world.state.party[0]).toEqual(before.party[0]);
|
|
expect(world.state.events.has(30)).toBe(false);
|
|
expect(world.state.events.has(1737)).toBe(false);
|
|
expect(world.state.lastSpawnMap).toBe(before.lastSpawnMap);
|
|
expect(world.state.flags.has(11)).toBe(false);
|
|
expect(world.objects().find((object) => object.id === 3)).toMatchObject({ x: 6, y: 5 });
|
|
expect(world.save()).toMatchObject({ x: before.x, y: before.y });
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!existsSync(romPath))("Cherrygrove's first rival battle with the local ROM", () => {
|
|
const RIVAL = 3; // CHERRYGROVECITY_RIVAL, after the Guide Gent
|
|
let assets: CrystalAssets;
|
|
beforeAll(() => {
|
|
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
});
|
|
/** Play through scripts and battles, fighting with the first move. */
|
|
const drive = (world: GameWorld) => {
|
|
const shown: string[] = [];
|
|
for (let tick = 0; tick < 50_000; tick++) {
|
|
const view = world.battle;
|
|
if (view) {
|
|
if (view.message && shown.at(-1) !== view.message.text) shown.push(view.message.text);
|
|
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;
|
|
}
|
|
if (world.dialogue) {
|
|
shown.push(world.dialogue.pages[world.dialogue.index]);
|
|
if (world.dialogue.holdTicks !== undefined) world.tick();
|
|
else world.advanceDialogue();
|
|
continue;
|
|
}
|
|
if (!world.busy) return shown.join("\n");
|
|
world.tick();
|
|
}
|
|
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
|
};
|
|
|
|
// loadtrainer RIVAL1, RIVAL1_1_<the starter Elm kept>.
|
|
it.each([[158, 152], [152, 155], [155, 158]])("with starter %i, battles the rival's %i and commits the exit", (species, rivalSpecies) => {
|
|
const world = questRivalFixtureWorld(assets, species);
|
|
const money = world.state.money;
|
|
expect(world.move("right")).toBe(true);
|
|
const shown = drive(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
// PlaceEnemysName prints a rival's wRivalName alone: "???" until the
|
|
// officer asks for it.
|
|
expect(shown).toContain("???\nwas defeated!");
|
|
expect(shown).toContain("My name's ???.");
|
|
expect(world.state.pokedex.seen.has(rivalSpecies)).toBe(true);
|
|
expect(world.state.money).toBe(money + 300);
|
|
expect(world.state.scenes["26:3"]).toBe(0); // SCENE_CHERRYGROVECITY_NOOP
|
|
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
|
|
const mon = world.state.party[0];
|
|
expect(mon.hp).toBe(mon.stats[0]);
|
|
// The finished scene does not replay after reload.
|
|
world.restore(world.save());
|
|
expect(drive(world)).toBe("");
|
|
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
|
|
});
|
|
|
|
it("runs CherrygroveRivalSceneNorth from the east as well", () => {
|
|
const world = questRivalFixtureWorld(assets);
|
|
world.restore({ ...world.save(), x: 34, y: 6, direction: "left" });
|
|
expect(world.move("left")).toBe(true);
|
|
expect(drive(world)).toContain("???\nwas defeated!");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(world.state.scenes["26:3"]).toBe(0);
|
|
expect(world.objects().some((object) => object.id === RIVAL)).toBe(false);
|
|
world.restore(world.save());
|
|
});
|
|
|
|
it("continues the scene after a loss without whiting out (BATTLETYPE_CANLOSE)", () => {
|
|
const world = questRivalFixtureWorld(assets);
|
|
const mon = world.state.party[0];
|
|
mon.hp = 1;
|
|
mon.moves = [43, 0, 0, 0]; // Leer cannot win.
|
|
mon.pp = [30, 0, 0, 0];
|
|
const money = world.state.money;
|
|
expect(world.move("right")).toBe(true);
|
|
const shown = drive(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(shown).toContain("fainted!");
|
|
expect(shown).toContain("waste of time");
|
|
expect(world.mapId).toBe("26:3");
|
|
expect(world.state.money).toBe(money);
|
|
expect(world.state.scenes["26:3"]).toBe(0);
|
|
expect(world.state.party[0].hp).toBe(world.state.party[0].stats[0]);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!existsSync(romPath))("Elm's robbed call with the local ROM", () => {
|
|
const ELM_CALLED = 67; // EVENT_ELM_CALLED_ABOUT_STOLEN_POKEMON
|
|
let assets: CrystalAssets;
|
|
beforeAll(() => {
|
|
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
});
|
|
const afterHouse = () => {
|
|
const world = questFixtureWorld(assets);
|
|
expect(world.move("up")).toBe(true);
|
|
settle(world);
|
|
expect(world.state.specialPhoneCall).toBe(2); // SPECIALCALL_ROBBED
|
|
return world;
|
|
};
|
|
|
|
it("waits indoors, then rings on the next outdoor step and ends with the hang-up", () => {
|
|
const world = afterHouse();
|
|
// SpecialCallOnlyWhenOutside: a step inside the house does not ring.
|
|
expect(world.move("left") || world.move("right")).toBe(true);
|
|
expect(settle(world)).toEqual([]);
|
|
expect(world.state.specialPhoneCall).toBe(2);
|
|
world.restore({ ...world.save(), map: "26:1", x: 17, y: 8, direction: "down", objects: [] });
|
|
expect(world.move("down")).toBe(true);
|
|
const pages = settle(world);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(pages[0]).toContain("It's a disaster!");
|
|
// HangUp: "Click!", then Phone_CallEnd's boops.
|
|
expect(pages.slice(-2)).toEqual(["Click!", "……"]);
|
|
expect(world.state.specialPhoneCall).toBe(0);
|
|
expect(world.state.events.has(ELM_CALLED)).toBe(true);
|
|
world.restore(world.save());
|
|
expect(world.move("down")).toBe(true);
|
|
expect(settle(world)).toEqual([]);
|
|
});
|
|
|
|
it("replays the whole call after a reload mid-call, from the committed step", () => {
|
|
const world = afterHouse();
|
|
world.restore({ ...world.save(), map: "26:1", x: 17, y: 8, direction: "down", objects: [] });
|
|
expect(world.move("down")).toBe(true);
|
|
// The phone rings first (RingTwice_StartCall), then Elm speaks.
|
|
const ringing = () => !world.dialogue || isRinging(world.dialogue.pages[world.dialogue.index]);
|
|
let rang = false;
|
|
for (let tick = 0; tick < 500 && ringing(); tick++) {
|
|
rang ||= Boolean(world.dialogue?.pages[0].startsWith("☎ PROF.ELM:"));
|
|
world.tick();
|
|
}
|
|
expect(rang).toBe(true);
|
|
expect(world.dialogue?.pages[0]).toContain("disaster");
|
|
const saved = world.save();
|
|
expect(saved.specialPhoneCall).toBe(2);
|
|
expect(saved.events).not.toContain(ELM_CALLED);
|
|
expect(saved).toMatchObject({ x: 17, y: 8 });
|
|
world.restore(saved);
|
|
expect(world.move("down")).toBe(true);
|
|
expect(settle(world).slice(-2)).toEqual(["Click!", "……"]);
|
|
expect(world.state.specialPhoneCall).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!existsSync(romPath))("the return to Elm's lab with the local ROM", () => {
|
|
const LAB = "24:5", OFFICER = 7, ELM = 2; // ELMSLAB_OFFICER, ELMSLAB_ELM
|
|
let assets: CrystalAssets;
|
|
beforeAll(() => {
|
|
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
|
});
|
|
/** Enter the lab and step onto MeetCopScript's tile, answering the naming screen. */
|
|
const meetOfficer = (name: string) => {
|
|
const world = questLabFixtureWorld(assets);
|
|
expect(world.move("up")).toBe(true);
|
|
expect(world.mapId).toBe(LAB);
|
|
walkTo(world, 4, 6);
|
|
expect(world.move("up")).toBe(true);
|
|
return { world, pages: settle(world, [name]) };
|
|
};
|
|
|
|
it("names the rival on the naming screen and remembers it across reload", () => {
|
|
const { world, pages } = meetOfficer("BLUE");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(pages).toContain("RIVAL'S NAME?");
|
|
expect(pages).toContain("OK! So BLUE\nwas his name.");
|
|
expect(world.state.rivalName).toBe("BLUE");
|
|
expect(world.state.scenes[LAB]).toBe(2); // SCENE_ELMSLAB_NOOP
|
|
expect(world.objects().some((object) => object.id === OFFICER)).toBe(false);
|
|
world.restore(world.save());
|
|
expect(world.state.rivalName).toBe("BLUE");
|
|
expect(world.objects().some((object) => object.id === OFFICER)).toBe(false);
|
|
});
|
|
|
|
it.each(["", " "])("InitName gives the blank name %j the ROM default", (name) => {
|
|
const { world, pages } = meetOfficer(name);
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(world.state.rivalName).toBe("SILVER");
|
|
expect(pages).toContain("OK! So SILVER\nwas his name.");
|
|
});
|
|
|
|
it("replays the officer scene from the committed step after a reload at the naming screen", () => {
|
|
const world = questLabFixtureWorld(assets);
|
|
world.move("up");
|
|
walkTo(world, 4, 6);
|
|
world.move("up");
|
|
expect(settle(world)).toContain("RIVAL'S NAME?"); // waits: no name queued
|
|
const saved = world.save();
|
|
expect(saved.rivalName).toBe("???");
|
|
expect(saved).toMatchObject({ x: 4, y: 6 });
|
|
world.restore(saved);
|
|
expect(world.move("up")).toBe(true);
|
|
settle(world, ["GARY"]);
|
|
expect(world.state.rivalName).toBe("GARY");
|
|
});
|
|
|
|
it("hands the Egg to Elm, then the aide gives five Poké Balls", () => {
|
|
const { world } = meetOfficer("BLUE");
|
|
walkTo(world, 5, 3);
|
|
world.move("up"); // Elm stands at 5,2: this only turns the player.
|
|
world.interact();
|
|
const elm = settle(world).join("\n");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(elm).toContain("handed\nthe MYSTERY EGG");
|
|
expect(world.state.bag.keyItems).toEqual([]);
|
|
expect(world.state.scenes[LAB]).toBe(6); // SCENE_ELMSLAB_AIDE_GIVES_POKE_BALLS
|
|
expect(world.state.scenes["24:3"]).toBe(1); // SCENE_ROUTE29_CATCH_TUTORIAL
|
|
const aide = walkTo(world, 4, 8).join("\n");
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(aide).toContain("received\nPOKé BALL.");
|
|
expect(world.state.bag.balls).toEqual([{ item: 5, quantity: 5 }]); // POKE_BALL
|
|
world.restore(world.save());
|
|
expect(world.state.bag.balls).toEqual([{ item: 5, quantity: 5 }]);
|
|
expect(world.objects().some((object) => object.id === ELM)).toBe(true);
|
|
});
|
|
});
|