Open Mr. Pokémon's house, the first rival battle and the return to Elm
Checks / web (push) Canceled after 0s

Mr. Pokémon's ROM scene gives the Mystery Egg and Pokédex, heals the
party, sets the Cherrygrove spawn with blackoutmod and queues Elm's
call. Cherrygrove's rival scene runs both triggers and all three
RIVAL1_1 parties as a CANLOSE battle; reloadmap returns to the script.
Smart AI is accepted for effects with no AI_Smart_EffectHandlers entry
(Normal Hit, Attack Down, Defense Down) and still refuses others before
any random draw, including for reserve Pokémon.

Special calls read SpecialPhoneCallList: the entry's condition decides,
and the ROM's CheckSpecialPhoneCall.script and Script_ReceivePhoneCall
run its caller script through memcall, ending with HangUp's timed
"Click!". Ringing and the caller box are not presented yet.

In Elm's lab, NameRival shows the ROM's "RIVAL'S NAME?" title (the
prompt previously had no page and soft-locked) and applies InitName's
default to blank names. Handing over the Egg and the aide's Poké Balls
run from the ROM. Saves gain rivalName and specialPhoneCall.

The quest fixtures use a seeded RNG (a constant byte never ends the
damage-variation loop) and no longer carry the house's moved objects
into Cherrygrove.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-24 19:38:20 +02:00
1 parent 7c1c0cc45b
commit dc10343520
18 files changed
+812 -34

No files matched your search

+37 -3
View File
@@ -79,10 +79,44 @@ function fixture(moves = [move(1), move(2)], bytes = [0]): TrainerAIInput {
}
describe("trainer AI native scoring", () => {
it("rejects the untranslated Smart layer, including Rival1, before consuming randomness", () => {
for (const weights of [16, 0x3d3, -1, 65536])
it("accepts all defined layer bits and rejects invalid layer words", () => {
for (const weights of [-1, 65536, 1.5, NaN])
expect(() => validateTrainerAI(weights)).toThrow(/not translated/);
expect(() => validateTrainerAI(0xffef)).not.toThrow();
for (const weights of [16, 0x3d3, 0xffff])
expect(() => validateTrainerAI(weights)).not.toThrow();
});
it.each([C.EFFECT_ATTACK_DOWN, C.EFFECT_DEFENSE_DOWN])(
"Smart leaves the first rival's attack and stat-lowering effect %i unchanged",
(effect) => {
const input = fixture([move(1), move(2, effect, 0)], [3, 1]);
input.weights = 16;
expect(chooseTrainerMove(input)).toEqual({
move: 2,
index: 1,
scores: [20, 20, 80, 80],
});
},
);
it("refuses untranslated Smart effects before any earlier layer consumes randomness", () => {
const input = fixture(
[move(1, C.EFFECT_ATTACK_DOWN, 0), move(2, C.EFFECT_REFLECT, 0)],
[],
);
input.weights = 0x3d3;
input.enemy.mon.pp[1] = 0; // Exhaustion does not bypass scoring or validation.
expect(() => chooseTrainerMove(input)).toThrow(/Smart AI for move effect/);
});
it("validates Smart effects across a supplied full trainer party", () => {
expect(() =>
validateTrainerAI(0x3d3, [
C.EFFECT_NORMAL_HIT,
C.EFFECT_ATTACK_DOWN,
C.EFFECT_DEFENSE_DOWN,
]),
).not.toThrow();
expect(() =>
validateTrainerAI(0x3d3, [C.EFFECT_NORMAL_HIT, C.EFFECT_SLEEP]),
).toThrow(/Smart AI/);
});
it("uses masked random slots and retries blanks without first-slot tie bias", () => {
const input = fixture(undefined, [3, 6, 253]);
+303
View File
@@ -0,0 +1,303 @@
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 } 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("");
// TrainerClassNames' "RIVAL" and Rival1Group's placeholder name "?".
expect(shown).toContain("RIVAL ?\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("RIVAL ?\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!");
expect(pages.at(-1)).toBe("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);
for (let tick = 0; tick < 500 && !world.dialogue; tick++) world.tick();
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).at(-1)).toBe("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);
});
});
+129
View File
@@ -0,0 +1,129 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc");
test.beforeEach(() => test.skip(!existsSync(romPath), "Requires the local user ROM."));
async function start(
page: Page,
fixture: "questFixtureWorld" | "questRivalFixtureWorld" | "questLabFixtureWorld",
) {
await page.route("**/src/game/runtime.ts", async (route) => {
const response = await route.fetch();
const source = await response.text();
const needle = "const world = new GameWorld(assets);";
expect(source).toContain(needle);
await route.fulfill({ response, body: source.replace(needle,
`const { ${fixture} } = await import('/tests/quest-fixture.ts');
const world = ${fixture}(assets);`) });
});
await page.clock.install({ time: new Date("2026-09-22T12:00:00") });
await page.goto("/");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await page.clock.pauseAt(new Date("2026-09-22T12:05:00"));
await page.locator("#play").click();
}
async function step(page: Page, direction: string) {
await page.keyboard.down(`Arrow${direction}`);
await page.clock.runFor(70);
await page.keyboard.up(`Arrow${direction}`);
await page.clock.runFor(300);
}
async function drive(page: Page, name = "") {
const messages: string[] = [];
for (let guard = 0; guard < 180; guard++) {
if (await page.locator("#battle").isVisible()) {
messages.push(await page.locator("#battle-text").innerText());
const menu = page.locator("#battle-menu");
const fight = menu.getByRole("button", { name: "FIGHT", exact: true });
if (await fight.count()) {
await fight.click();
await menu.getByRole("button", { name: /SCRATCH/ }).click();
} else if (await menu.getByRole("button", { name: /Continue/ }).count()) {
await menu.getByRole("button", { name: /Continue/ }).click();
} else await page.clock.runFor(1200);
} else if (await page.locator("#dialogue-choice input").isVisible()) {
messages.push(await page.locator("#dialogue-text").innerText());
const input = page.locator("#dialogue-choice input");
await expect(input).toBeFocused();
await input.fill(name);
await input.press("Enter");
await page.clock.runFor(500);
} else if (await page.locator("#native-dialogue").isVisible()) {
messages.push(await page.locator("#dialogue-text").innerText());
await page.keyboard.press("KeyX");
await page.clock.runFor(500);
} else if (await page.locator("#game-screen").getAttribute("data-activity") !== "idle") {
await page.clock.runFor(500);
} else {
await expect(page.locator("#game-notice")).toHaveText("");
return messages.join("\n");
}
}
throw new Error("Quest scene did not finish.");
}
async function backup(page: Page) {
await page.locator("#tab-saves").click();
const downloaded = page.waitForEvent("download");
await page.locator("#backup").click();
const path = (await (await downloaded).path())!;
const record = JSON.parse(readFileSync(path, "utf8"));
return { path, state: JSON.parse(Buffer.from(record.state, "base64").toString("utf8")) };
}
test("Mr. Pokémon's ROM scene grants the Egg and Pokédex, heals and survives reload", async ({ page }) => {
await start(page, "questFixtureWorld");
await step(page, "Up");
await expect(page.locator("#game-screen")).toHaveAttribute("data-map", "26:10");
const shown = await drive(page);
expect(shown).toContain("MYSTERY EGG");
expect(shown).toContain("POKéDEX");
const { path, state } = await backup(page);
expect(state.events).toContain(30);
expect(state.bag.keyItems).toContain(0x45);
expect(state.scenes).toMatchObject({ "26:10": 1, "26:3": 1, "24:5": 3 });
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
expect(state.specialPhoneCall).toBe(2); // SPECIALCALL_ROBBED
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles(path);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
const restored = await backup(page);
expect(restored.state).toEqual(state);
});
test("the real Cherrygrove return scene battles the rival and commits its exit", async ({ page }) => {
await start(page, "questRivalFixtureWorld");
await drive(page); // a pending outdoor call may run before the encounter
await step(page, "Right");
const shown = await drive(page);
expect(shown).toContain("CHIKORITA");
expect(shown).toContain("???");
await expect(page.locator("#battle")).toBeHidden();
const { state } = await backup(page);
expect(state.scenes["26:3"]).toBe(0);
expect(state.pokedex.seen).toContain(152);
expect(state.party[0].hp).toBe(state.party[0].stats[0]);
expect(state.events).toContain(30);
});
test("the officer in Elm's lab names the rival through the naming field", async ({ page }) => {
await start(page, "questLabFixtureWorld");
await step(page, "Up");
await expect(page.locator("#game-screen")).toHaveAttribute("data-map", "24:5");
await drive(page);
for (let y = 11; y > 5; y--) await step(page, "Up");
const shown = await drive(page, "BLUE");
expect(shown).toContain("RIVAL'S NAME?");
expect(shown).toContain("BLUE");
const { state } = await backup(page);
expect(state.rivalName).toBe("BLUE");
expect(state.scenes["24:5"]).toBe(2);
});
+60
View File
@@ -0,0 +1,60 @@
import { CrystalAssets } from "../src/game/rom/assets";
import { generatePartyMon } from "../src/game/party/pokemon";
import { GameWorld } from "../src/game/world/world";
import { settle } from "./world-driver";
// EVENT_GOT_CYNDAQUIL/TOTODILE/CHIKORITA_FROM_ELM.
const STARTER_EVENTS: Record<number, number> = { 155: 27, 158: 28, 152: 29 };
/** Only the starting position and earlier starter choice are fixtures.
* The house rewards and return scenes come from the untouched ROM scripts. */
export function questFixtureWorld(assets: CrystalAssets, species = 158) {
// A seeded Random: a constant byte never ends BattleRandom retry loops
// such as BattleCommand_DamageVariation's.
let seed = 1;
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
});
const mon = generatePartyMon(assets.pokemon, {
species, level: 20, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0xffff, caughtData: 0,
});
mon.moves = [10, 0, 0, 0];
mon.pp = [35, 0, 0, 0];
mon.hp = 1; // Mr. Pokémon must actually heal the party.
world.state.party = [mon];
world.state.pokedex.seen.add(species);
world.state.pokedex.caught.add(species);
world.state.events.add(26); // GOT_A_POKEMON_FROM_ELM
world.state.events.add(STARTER_EVENTS[species]);
world.restore({ ...world.save(), map: "26:1", x: 17, y: 6, direction: "up" });
return world;
}
/** Independent rival UI fixture, derived from completing the actual house
* scene, then relocated to the city's return trigger. This is not a full walk. */
export function questRivalFixtureWorld(assets: CrystalAssets, species = 158) {
const world = questFixtureWorld(assets, species);
if (!world.move("up")) throw new Error("Could not enter Mr. Pokémon's house.");
settle(world);
if (world.notice || !world.state.events.has(30))
throw new Error(`House fixture did not complete: ${world.diagnostic}`);
// The house's moved objects (Mr. Pokémon, Oak) must not carry over. The
// Guide Gent still stands at 32,6; one step right reaches 33,7's trigger.
world.restore({ ...world.save(), map: "26:3", x: 32, y: 7, direction: "right", objects: [] });
return world;
}
/** The house scene completed for real, then relocated below Elm's lab door
* in New Bark Town. Elm's robbed call stays queued: the door step warps,
* and calls do not ring indoors. */
export function questLabFixtureWorld(assets: CrystalAssets) {
const world = questFixtureWorld(assets);
if (!world.move("up")) throw new Error("Could not enter Mr. Pokémon's house.");
settle(world);
if (world.notice || !world.state.events.has(30))
throw new Error(`House fixture did not complete: ${world.diagnostic}`);
world.restore({ ...world.save(), map: "24:4", x: 6, y: 4, direction: "up", objects: [] });
return world;
}
+5 -3
View File
@@ -172,7 +172,7 @@ describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", ()
expect(potions()).toBe(1);
});
it("runs the Route 30 Pokémon battle scene and the Berry house, but keeps Mr. Pokémon's house closed", () => {
it("runs the Route 30 Pokémon battle scene and the Berry house, and enters Mr. Pokémon's house", () => {
const world = fixture({ x: 5, y: 27, direction: "up" });
world.interact();
expect(settle(world).length).toBeGreaterThan(1);
@@ -191,7 +191,9 @@ describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", ()
expect(world.state.bag.items.length).toBe(berry + 1);
world.restore({ ...world.save(), map: ROUTE_30, x: 17, y: 6, direction: "up" });
expect(world.move("up")).toBe(false);
expect(world.notice).toMatch(/not ported/);
expect(world.move("up")).toBe(true);
settle(world);
expect(world.mapId).toBe("26:10");
expect(world.notice, world.diagnostic).toBe("");
});
});
+25 -3
View File
@@ -255,15 +255,37 @@ describe.skipIf(!existsSync(romPath))("trainer battles", () => {
run.next({ action: "fight", move: 0 });
expect(internals.playerUsedMoves).toEqual([options.party[0].moves[0], 0, 0, 0]);
});
it("refuses the untranslated Smart AI and empty parties before mutating the party or Pokédex", () => {
for (const kind of ["empty", "ai"] as const) {
it.each([1, 2, 3])(
"completes first rival starter variant %i with its ROM AI flags",
(id) => {
const { options } = setup(15, [9, id]);
expect(options.trainer!.aiFlags).toBe(0x3d3);
const result = finish(new Battle(options));
expect(result.result).toBe(0);
expect(result.prizeMoney).toBe(300);
expect(result.texts).toContain("was defeated!");
},
);
it("refuses untranslated Smart effects, including reserves, and empty parties before mutation", () => {
for (const kind of ["empty", "ai", "reserve"] as const) {
const { options } = setup();
if (kind === "empty") options.trainer!.party = [];
else options.trainer!.aiFlags |= 1 << 4;
else {
options.trainer!.aiFlags |= 1 << 4;
const unsupported = structuredClone(options.trainer!.party[0]);
unsupported.moves = [115, 0, 0, 0]; // Reflect has an untranslated Smart handler.
if (kind === "reserve") options.trainer!.party.push(unsupported);
else options.trainer!.party[0] = unsupported;
}
const before = structuredClone(options.party);
const enemyBefore = structuredClone(options.trainer!.party);
let draws = 0;
options.random = () => { draws++; return 0; };
expect(() => new Battle(options).run().next()).toThrow(UnsupportedBattle);
expect(options.party).toEqual(before);
expect(options.trainer!.party).toEqual(enemyBefore);
expect(options.pokedex.seen.size).toBe(0);
expect(draws).toBe(0);
}
});
});