Open Routes 35 to 37, the National Park and Ecruteak to the Fog Badge (#32)

Enable Route 35, Route 36, Route 37, the National Park and its gates,
Ecruteak City and its buildings, the Tin Tower entrance, the Wise Trio's
room and both floors of the Burned Tower. The story runs from Floria,
the SquirtBottle and Sudowoodo through the Kimono Girls' HM03 Surf, the
rival in the Burned Tower and the beasts' release to Ecruteak Gym,
Morty, the FOGBADGE and TM30.

Port roaming Pokémon (InitRoamMons, UpdateRoamMons, JumpRoamMons,
CheckEncounterRoamMon) with their state in the save; only Continue moves
them on restore, so a failed import's rollback keeps them in place.
Port the Bug-Catching Contest: registration on contest days, Park Balls,
contestants, the timer, QUIT, the catch-and-swap prompt, judging from
the ROM's contestant table, prizes and the saved contest state.

Port the Game Corner's SlotMachine and CardFlip on a new realtime tile
screen that advances two frames per tick with buffered buttons: reel
strips and scrolling, bias tables, the seven streak, Golem and Chansey,
payouts, the Card Flip board, cursor and navigation. Their tilemaps are
checked against the ROM's own data.

Also port getnum, a plain warp that continues its script on the new map,
and PokemonNames' EGG entry. A trainer whose script is ObjectEvent (the
Burned Tower rival) is no longer sighted once its own flag is set, as
the original's read of bit 1 of wNumKeyItems gives with the usual key
items.

The playthrough walks through the National Park and continues to Morty;
its driver chooses moves by type matchup, buys at Marts and uses Super
Potions and Awakenings in trainer battles. New tests cover roaming, the
contest, the Game Corner and Ecruteak's side content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Opus 5.5 committed 2026-10-12 13:10:17 +02:00
1 parent 0930b780fc
commit 2640fa2725
31 files changed
+4743 -91

No files matched your search

+4 -1
View File
@@ -165,7 +165,10 @@ test("pausing battle freezes messages and importing its backup restores committe
await page.locator("#play").click();
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "6");
const restored = await exportState(page);
expect(restored.state).toEqual(state);
// Importing continues the game: _BackUpMapIndices records the loaded
// map for the roaming Pokémon, and everything else is as committed.
expect(restored.state.roamMonsMap).toBe("24:7");
expect({ ...restored.state, roamMonsMap: state.roamMonsMap }).toEqual(state);
});
test("an unsupported battle effect closes the UI without persisting partial changes", async ({ page }) => {
+9 -5
View File
@@ -4,7 +4,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
import { battleFixtureWorld, ENCOUNTER_WALK, type BattleScenario } from "./battle-fixture";
import type { GameWorld } from "../src/game/world/world";
import { generatePartyMon, calcStats } from "../src/game/party/pokemon";
import { withoutPlayTime } from "./world-driver";
import { afterRestore, withoutPlayTime } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const fixture = (scenario: BattleScenario = "win") =>
@@ -95,7 +95,8 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
expect(world.state.pokedex.caught.has(16)).toBe(false);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
});
it("evolves a Pokémon that levelled up once the battle is won", () => {
@@ -118,7 +119,8 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
expect(world.state.pokedex.caught.has(156)).toBe(true);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
});
it("considers only the Pokémon that levelled up (wEvolvableFlags)", () => {
@@ -174,7 +176,8 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
expect(saved.party[0].statExp.some((value) => value > 0)).toBe(true);
expect(saved.party[0].stats).not.toEqual(calcStats(world.assets.pokemon, 155, mon.dvs, 100, saved.party[0].statExp));
expect(() => world.restore(saved)).not.toThrow();
expect(world.save()).toEqual(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
const corrupt = structuredClone(saved);
corrupt.party[0].stats[1] = 999;
expect(() => world.restore(corrupt)).toThrow("Invalid native save");
@@ -208,7 +211,8 @@ describe.skipIf(!existsSync(romPath))("world battle integration with the local R
expect(withoutPlayTime(world.save())).toEqual(withoutPlayTime(committed));
world.restore(committed);
expect(world.battle).toBeUndefined();
expect(world.save()).toEqual(committed);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(committed, reloaded));
expect(world.move("right")).toBe(true);
});
+373
View File
@@ -0,0 +1,373 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, migrateSave } from "../src/game/world/world";
import { emptyScriptState } from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Direction } from "../src/game/world/types";
import { settle, findPath } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
// Item IDs read from constants/item_constants.asm, not reference-checked
// here (only ROM/RAM addresses in profile.ts are).
const SUN_STONE = 169,
EVERSTONE = 112,
BERRY = 173,
GOLD_BERRY = 174;
// CATERPIE and WEEDLE (constants/pokemon_constants.asm): ContestMons'
// first two entries, each 20%.
const CATERPIE = 10,
WEEDLE = 13;
const ROUTE35_GATE = "10:15",
ROUTE36_GATE = "10:17",
NATIONAL_PARK = "3:15",
CONTEST_PARK = "3:16";
// Date#getDay() matches the ROM's SUNDAY..SATURDAY (0-6) directly, as the
// rest of the native clock already assumes (world.ts's weekday math).
const CONTEST_DAY = "2026-09-22T12:00:00"; // Tuesday
const OFF_DAY = "2026-09-25T12:00:00"; // Friday
// A COLL_LONG_GRASS patch reachable from the Route 36 gate's warp-in
// point (33, 18); the Route 35 gate's own (10, 47) lands in a separate,
// grass-less walkable region of this map.
const GRASS = { x: 31, y: 17 };
const GRASS2 = { x: 30, y: 17 };
function fixture(options: { day?: string; party?: number[] } = {}) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
let now = new Date(options.day ?? CONTEST_DAY);
let seed = 7;
const forced: number[] = [];
const random = () => {
if (forced.length) return forced.shift()!;
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
};
const world = new GameWorld(assets, () => now, random);
const species = options.party ?? [155]; // Cyndaquil: strong enough that nothing faints.
world.state.party = species.map((sp) =>
generatePartyMon(assets.pokemon, {
species: sp,
level: 60,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0xffff,
caughtData: 0,
}),
);
world.state.money = 3000;
return {
world,
forced,
advance: (ms: number) => {
now = new Date(now.getTime() + ms);
},
};
}
/** Teleport straight into a gate or the park, bypassing Route 35/36/37
* (opened separately; see the task brief). */
function enterAt(
world: GameWorld,
map: string,
x: number,
y: number,
direction: Direction = "down",
) {
world.restore({ ...world.save(), map, x, y, direction, objects: [] });
}
/** Walk to a tile, stopping early if a step starts a battle (findPath's
* BFS keeps walking around the contest's roaming NPCs). */
function walkToOrBattle(world: GameWorld, x: number, y: number) {
for (const direction of findPath(world, x, y)) {
if (world.battle) return;
if (!world.move(direction)) throw new Error(`Blocked moving ${direction}.`);
for (let tick = 0; tick < 40 && world.busy && !world.battle; tick++)
world.tick();
if (world.battle) return;
}
}
/** Drive a Bug-Catching Contest battle: PARKBALL× on every action prompt,
* as the contest's own battle menu requires. */
function driveContestBattle(world: GameWorld, switchIn = true) {
for (let tick = 0; tick < 20_000; tick++) {
const view = world.battle;
if (!view) return;
const prompt = view.prompt;
if (prompt?.kind === "action")
world.battleAction({ action: "item", item: 0, slot: 0 });
else if (prompt?.kind === "yesno") world.battleChoose(1);
else if (prompt?.kind === "contestswitch")
world.battleChoose(switchIn ? 1 : 0);
else if (prompt?.kind === "name") world.battleName("");
else if (prompt?.kind === "stats" || view.message?.prompt) world.interact();
else world.tick();
}
throw new Error("The contest battle did not settle.");
}
/** Registers at the Route 35 gate (a one-Pokémon party skips the "leave
* the rest behind" question) and lands in the contest map. */
function register(world: GameWorld) {
enterAt(world, ROUTE35_GATE, 2, 2, "up");
world.interact();
settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.mapId).toBe(CONTEST_PARK);
}
describe.skipIf(!existsSync(romPath))("the Bug-Catching Contest", () => {
it("the gate's officer offers the contest on Tuesday/Thursday/Saturday", () => {
const { world } = fixture({ day: CONTEST_DAY });
register(world);
expect(world.state.bugContest).toEqual({ parkBalls: 20, mon: null });
expect(world.state.bugContestEnd).toBeGreaterThan(Date.parse(CONTEST_DAY));
expect(world.state.flags.has(17)).toBe(true); // ENGINE_BUG_CONTEST_TIMER
expect(world.x).toBe(10);
expect(world.y).toBe(47);
});
it("an off day only explains the contest, without starting one", () => {
const { world } = fixture({ day: OFF_DAY });
enterAt(world, ROUTE35_GATE, 0, 2, "down");
world.interact();
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/PARK/);
expect(world.state.bugContest).toBeUndefined();
expect(world.mapId).toBe(ROUTE35_GATE);
});
it("leaves the rest of the party with the contest helper when it has more than one Pokémon", () => {
const { world } = fixture({ party: [155, 16] }); // Cyndaquil, Pidgey
enterAt(world, ROUTE35_GATE, 2, 2, "up");
world.interact();
settle(world); // "Would you like to try?" / "use the first Pokémon?" / promptbuttons
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party).toHaveLength(1);
expect(world.state.party[0].species).toBe(155);
expect(world.mapId).toBe(CONTEST_PARK);
});
it("refuses to register with a fainted or an Egg first Pokémon", () => {
{
const { world } = fixture({ party: [155, 16] });
world.state.party[0].hp = 0;
enterAt(world, ROUTE35_GATE, 2, 2, "up");
world.interact();
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/can't battle/);
expect(world.state.bugContest).toBeUndefined();
}
{
const { world } = fixture({ party: [155, 16] });
world.state.party[0].egg = true;
enterAt(world, ROUTE35_GATE, 2, 2, "up");
world.interact();
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/EGG/);
expect(world.state.bugContest).toBeUndefined();
}
});
it("catches a bug, catches a second and swaps, then quits early for judging and a prize", () => {
const { world, forced } = fixture({ day: CONTEST_DAY });
// The Route 36 gate's own registration lands at (33, 18), which (unlike
// the Route 35 gate's (10, 47)) can reach this map's grass.
enterAt(world, ROUTE36_GATE, 0, 4, "up");
world.interact();
settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.mapId).toBe(CONTEST_PARK);
// SetUpFiveStepWildEncounterCooldown: walk off the five-step cooldown
// from entering the map on safe (non-grass) ground before approaching it.
walkToOrBattle(world, 28, 18);
expect(world.battle).toBeUndefined();
// Force the first encounter (long grass: 40% of 255) onto Caterpie
// (ContestMons' first 20% entry) at its minimum level.
forced.push(0, 0, 0);
walkToOrBattle(world, GRASS.x, GRASS.y);
expect(world.battle).toBeDefined();
driveContestBattle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bugContest?.mon?.species).toBe(CATERPIE);
expect(world.state.bugContest?.parkBalls).toBeLessThan(20);
const ballsAfterFirst = world.state.bugContest!.parkBalls;
// A second catch (the next ContestMons entry, Weedle) offers a swap.
forced.push(0, 50, 0);
walkToOrBattle(world, GRASS2.x, GRASS2.y);
if (!world.battle) walkToOrBattle(world, GRASS.x, GRASS.y);
expect(world.battle).toBeDefined();
driveContestBattle(world, true);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bugContest?.mon?.species).toBe(WEEDLE);
expect(world.state.bugContest!.parkBalls).toBeLessThan(ballsAfterFirst);
// QUIT replaces SAVE in the start menu, and PACK is not offered.
world.openStartMenu();
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
expect(menu.kind).toBe("menu");
expect(menu.items.some((label) => /PACK/.test(label))).toBe(false);
const quitIndex = menu.items.findIndex((label) => /QUIT/.test(label));
expect(quitIndex).toBeGreaterThanOrEqual(0);
world.choose(quitIndex);
expect(world.dialogue?.choice?.kind).toBe("yesno");
world.choose(1); // "Do you want to finish now?"
// BugContestResultsScript: the ready-to-judge text, third/second/first
// place announcements, the player's own prize (or consolation Berry),
// the returned party (none left behind here) and the nickname offer.
const shown = settle(world, [1, ""]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/Weedle|WEEDLE/i);
expect(world.state.bugContest).toBeUndefined();
expect(world.state.bugContestEnd).toBeUndefined();
expect(world.state.flags.has(17)).toBe(false);
expect(world.mapId).toBe(ROUTE36_GATE);
// The judged Weedle joined the party (which had room) with the
// National Park as its caught location.
const caught = world.state.party.at(-1)!;
expect(caught.species).toBe(WEEDLE);
expect(caught.caughtData & 0x7f).toBe(0x13); // LANDMARK_NATIONAL_PARK
});
it("ends the contest and prints the judging when the twenty minutes run out", () => {
const { world, advance } = fixture({ day: CONTEST_DAY });
register(world);
advance(21 * 60_000);
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/time|Time/);
expect(world.state.bugContest).toBeUndefined();
expect(world.mapId).toBe(ROUTE36_GATE);
});
it("awards first place and the Sun Stone to an outstanding catch", () => {
const { world } = fixture({ day: CONTEST_DAY });
register(world);
// A maximum-level, maximum-stat catch outscores every AI contestant.
world.state.bugContest = {
parkBalls: 15,
mon: generatePartyMon(world.assets.pokemon, {
species: 127, // Pinsir: a real ContestMons rare entry, high stats.
level: 100,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0xffff,
caughtData: 0,
}),
};
const before = world.state.bag.items.find((slot) => slot.item === SUN_STONE)?.quantity ?? 0;
world.openStartMenu();
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
world.choose(menu.items.findIndex((label) => /QUIT/.test(label)));
world.choose(1);
const shown = settle(world, [1, ""]);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/winner/i);
const after = world.state.bag.items.find((slot) => slot.item === SUN_STONE)?.quantity ?? 0;
expect(after).toBe(before + 1);
});
it("boxes the catch when the party is full, with the National Park landmark", () => {
const { world } = fixture({ day: CONTEST_DAY, party: [155, 16, 19, 1, 4, 7] });
// Registering with more than one Pokémon leaves the other five with
// the helper (ContestDropOffMons); ContestReturnMons restores them
// during judging, which is where the party is actually full again.
register(world);
expect(world.state.party).toHaveLength(1);
expect(world.state.contestLeftParty).toHaveLength(5);
world.state.bugContest = {
parkBalls: 10,
mon: generatePartyMon(world.assets.pokemon, {
species: CATERPIE,
level: 7,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0,
caughtData: 0,
}),
};
world.openStartMenu();
const menu = world.dialogue!.choice as { kind: "menu"; items: string[] };
world.choose(menu.items.findIndex((label) => /QUIT/.test(label)));
world.choose(1);
const shown = settle(world, [1, ""]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party).toHaveLength(6); // unchanged; went to the box
const box = world.state.storage.boxes[world.state.storage.current];
expect(box[0]?.species).toBe(CATERPIE);
expect(box[0]?.caughtData & 0x7f).toBe(0x13);
expect(shown.join("\n")).toMatch(/PC|BOX/i);
});
it("saves and restores mid-contest, keeping the Park Balls, caught mon and deadline", () => {
const { world } = fixture({ day: CONTEST_DAY });
register(world);
world.state.bugContest = {
parkBalls: 12,
mon: generatePartyMon(world.assets.pokemon, {
species: WEEDLE,
level: 9,
item: 0,
otId: world.playerId,
ot: "CHRIS",
dvs: 0x1234,
caughtData: 0,
}),
};
const saved = world.save();
expect(saved.bugContest).toEqual({ parkBalls: 12, mon: world.state.bugContest.mon });
expect(saved.bugContestEnd).toBe(world.state.bugContestEnd);
const assets2 = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const reloaded = new GameWorld(assets2, () => new Date(CONTEST_DAY), () => 0);
reloaded.restore(saved);
expect(reloaded.notice, reloaded.diagnostic).toBe("");
expect(reloaded.state.bugContest).toEqual(world.state.bugContest);
expect(reloaded.state.bugContestEnd).toBe(world.state.bugContestEnd);
// The deadline survives the reload and still ends the contest on time.
const laterSave = { ...reloaded.save() };
const assets3 = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
let later = new Date(Date.parse(CONTEST_DAY) + 21 * 60_000);
const reloaded2 = new GameWorld(assets3, () => later, () => 0);
reloaded2.restore(laterSave);
settle(reloaded2, [1, ""]); // accept the judged Weedle's nickname offer
expect(reloaded2.notice, reloaded2.diagnostic).toBe("");
expect(reloaded2.state.bugContest).toBeUndefined();
});
it("migrates an older save with no contest fields into no contest running", () => {
const { world } = fixture();
const save = world.save() as Record<string, unknown>;
delete save.bugContest;
delete save.bugContestEnd;
const migrated = migrateSave(save, () => 1);
expect(migrated).toBeDefined();
expect(migrated!.bugContest).toBeUndefined();
expect(migrated!.bugContestEnd).toBeUndefined();
world.restore(migrated);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.bugContest).toBeUndefined();
});
it("the National Park's own NPCs and items work outside the contest", () => {
const { world } = fixture({ day: OFF_DAY });
enterAt(world, NATIONAL_PARK, 27, 41, "up"); // facing Teacher1 at (27,40)
world.interact();
const shown = settle(world);
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/QUICK CLAW/);
const item = world.assets.pokemon.itemName(73); // QUICK_CLAW
expect(world.state.bag.items.some((slot) => world.assets.pokemon.itemName(slot.item) === item)).toBe(true);
});
});
+5 -3
View File
@@ -5,7 +5,7 @@ import { GameWorld } from "../src/game/world/world";
import { generatePartyMon, validPartyMon } from "../src/game/party/pokemon";
import { C } from "../src/game/battle/constants";
import { battleFixtureWorld, ENCOUNTER_WALK } from "./battle-fixture";
import { settle } from "./world-driver";
import { afterRestore, settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
@@ -159,7 +159,8 @@ describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the
expect(world.state.bag.balls[0].quantity).toBeLessThan(10);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
expect(validPartyMon(world.assets.pokemon, world.state.party[1])).toBe(true);
});
@@ -219,7 +220,8 @@ describe.skipIf(!existsSync(romPath))("the catching tutorial and capture in the
expect(world.state.pokedex.caught.has(PIDGEY)).toBe(true);
const saved = world.save();
world.restore(saved);
expect(world.save()).toEqual(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
});
it("keeps the ball when the party and the box are full", () => {
+284
View File
@@ -0,0 +1,284 @@
import { 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 { settle } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))("Ecruteak side content with the local ROM", () => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const ROUTE_36 = "10:3",
ROUTE_37 = "10:4",
ECRUTEAK = "4:9",
ECRUTEAK_ITEMFINDER_HOUSE = "4:8",
ECRUTEAK_CENTER = "4:3",
ECRUTEAK_GYM = "4:7",
BURNED_TOWER_1F = "3:13",
BURNED_TOWER_B1F = "3:14";
const HARD_STONE = 0x7d,
MAGNET = 0x6c,
ITEMFINDER = 0x37,
ETHER = 0x3f,
ULTRA_BALL = 2,
HP_UP = 0x1a;
const EVENT_GOT_HARD_STONE_FROM_ARTHUR = 0x67,
EVENT_GOT_MAGNET_FROM_SUNNY = 0x69,
EVENT_GOT_ITEMFINDER = 0x5a;
// def_scene_scripts numbers each map's own scenes from 0 in file order:
// EcruteakGymForcedToLeaveScene is 0 (the default), EcruteakGymNoopScene
// (opened by BurnedTowerB1F's ReleaseTheBeasts) is 1.
const SCENE_ECRUTEAKGYM_NOOP = 1;
const EVENT_RELEASED_THE_BEASTS = 0x7b,
EVENT_BEAT_MORTY = 0x4c0,
ENGINE_FOGBADGE = 30;
/** A world on `date`. */
const world = (date: string) => {
let seed = 13;
return new GameWorld(
new CrystalAssets(bytes),
() => new Date(date),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
};
/** Stand beside the object at (x, y) on `map` (across a counter if one
* is in between), face it and press A. The first warpTo runs the map's
* OBJECTS callback (which the weekday gifts depend on) and settles any
* entry scene; repositioning after that with restore(), rather than a
* second settled move, reaches the approach tile in the same tick, so a
* wandering object (radius > 0) has no window to step away first.
* restore() also resets object positions to home, since wandering is
* not saved. */
const talk = (game: GameWorld, map: string, x: number, y: number) => {
const target = game.assets.map(map);
const STEP = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
} as const;
for (const [face, [dx, dy]] of Object.entries(STEP) as [
keyof typeof STEP,
readonly [number, number],
][]) {
const across =
game.assets.collision(target, x - dx, y - dy) === 0x90 ? 2 : 1;
const px = x - dx * across,
py = y - dy * across;
if (
px < 0 ||
py < 0 ||
px >= target.width ||
py >= target.height ||
game.assets.permission(game.assets.collision(target, px, py)) !== 0
)
continue;
try {
game.warpTo(map, px, py);
} catch {
continue;
}
settle(game);
game.restore({ ...game.save(), x: px, y: py, direction: face });
game.interact();
return;
}
throw new Error(`No place to talk to ${x},${y} on ${map}`);
};
/** Advance to the next choice, keeping the pages shown. */
const next = (game: GameWorld, pages: string[] = []) => {
for (let tick = 0; tick < 4000; tick++) {
const dialogue = game.dialogue;
if (dialogue) {
const page = dialogue.pages[dialogue.index].replace(/\n/g, " ");
if (page && pages.at(-1) !== page) pages.push(page);
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
return dialogue.choice;
if (dialogue.index === dialogue.pages.length - 1 && dialogue.holdTicks !== undefined)
game.tick();
else game.advanceDialogue();
} else if (game.busy) game.tick();
else return undefined;
}
throw new Error(`No choice: ${game.diagnostic}`);
};
/** Accept yes/no prompts and advance text until the interaction ends. */
const finish = (game: GameWorld, pages: string[] = []) => {
for (let step = 0; step < 200; step++) {
const choice = next(game, pages);
if (!choice) {
expect(game.notice, game.diagnostic).toBe("");
return pages.join(" ");
}
if (choice.kind === "name") game.choose("");
else game.choose(choice.kind === "yesno" ? 1 : 0);
}
throw new Error("Interaction did not finish.");
};
it("gives Arthur's Hard Stone only on Thursday, once met", () => {
const notThursday = world("2026-09-22T12:00:00"); // Tuesday
talk(notThursday, ROUTE_36, 46, 6); // Arthur is hidden on a non-Thursday.
expect(notThursday.notice).toBe("Nothing to interact with here.");
const game = world("2026-09-24T12:00:00"); // Thursday
talk(game, ROUTE_36, 46, 6);
let pages: string[] = [];
let text = finish(game, pages);
expect(text).toMatch(/Who are\s+you/);
expect(game.state.events.has(EVENT_GOT_HARD_STONE_FROM_ARTHUR)).toBe(true);
expect(game.state.bag.items).toContainEqual({ item: HARD_STONE, quantity: 1 });
// Talking again on the same day shows the "already have it" text.
talk(game, ROUTE_36, 46, 6);
pages = [];
text = finish(game, pages);
expect(text).toMatch(/second son/i);
});
it("gives Sunny's Magnet only on Sunday, once met", () => {
const game = world("2026-09-20T12:00:00"); // Sunday
talk(game, ROUTE_37, 16, 8);
const pages: string[] = [];
const text = finish(game, pages);
expect(text).toMatch(/SUNNY: Hi!/);
expect(game.state.events.has(EVENT_GOT_MAGNET_FROM_SUNNY)).toBe(true);
expect(game.state.bag.items).toContainEqual({ item: MAGNET, quantity: 1 });
});
it("gives the Itemfinder from the house south of Tin Tower", () => {
const game = world("2026-09-22T12:00:00");
talk(game, ECRUTEAK_ITEMFINDER_HOUSE, 2, 3);
const pages: string[] = [];
const text = finish(game, pages);
expect(text).toMatch(/true spirit/i);
expect(game.state.events.has(EVENT_GOT_ITEMFINDER)).toBe(true);
// ITEMFINDER is a key item (ItemAttributes' pocket byte).
expect(game.state.bag.keyItems).toContain(ITEMFINDER);
});
it("answers CheckMobileAdapterStatusSpecial's non-mobile branch at the Pokémon Center", () => {
const game = world("2026-09-22T12:00:00");
// The default scene (EcruteakPokecenter1FMeetBillScene) plays out and
// moves the player on the first visit; let it finish before walking
// over to the PokéFan.
game.warpTo(ECRUTEAK_CENTER, 3, 7);
finish(game);
talk(game, ECRUTEAK_CENTER, 7, 6);
const text = finish(game);
// CheckMobileAdapterStatusSpecial always clears wScriptVar in this
// revision (CheckMobileAdapterStatus is the Japanese version's
// routine), so the non-mobile KIMONO GIRLS text always shows.
expect(text).toMatch(/KIMONO GIRLS/);
expect(text).not.toMatch(/OLIVINE CITY/);
});
it("keeps Morty's gym closed until the Burned Tower beasts are released", () => {
// EcruteakGymForcedToLeaveScene is the default scene (0); only
// BurnedTowerB1F's ReleaseTheBeasts (board #32's roaming increment,
// out of scope here) sets SCENE_ECRUTEAKGYM_NOOP to open it. Morty's
// own dialogue in Burned Tower 1F explains why: he is investigating
// the tower with Eusine instead of running the gym.
const game = world("2026-09-22T12:00:00");
game.warpTo(ECRUTEAK_GYM, 5, 17);
const pages = settle(game);
expect(game.notice, game.diagnostic).toBe("");
expect(pages.join(" ")).toMatch(/is absent/i);
expect(game.mapId).toBe(ECRUTEAK);
expect(game.battle).toBeUndefined();
});
it("finds Burned Tower 1F's hidden items and HP Up, and cannot smash its rock yet", () => {
const game = world("2026-09-22T12:00:00");
game.warpTo(BURNED_TOWER_1F, 9, 15);
settle(game); // the Eusine scene (sdefer) runs on the first idle tick
expect(game.notice, game.diagnostic).toBe("");
game.restore({ ...game.save(), x: 7, y: 7, direction: "right" });
game.interact();
finish(game);
expect(game.state.bag.items).toContainEqual({ item: ETHER, quantity: 1 });
game.restore({ ...game.save(), x: 13, y: 12, direction: "up" });
game.interact();
finish(game);
expect(game.state.bag.balls).toContainEqual({ item: ULTRA_BALL, quantity: 1 });
game.restore({ ...game.save(), x: 14, y: 3, direction: "up" });
game.interact();
finish(game);
expect(game.state.bag.items).toContainEqual({ item: HP_UP, quantity: 1 });
game.restore({ ...game.save(), x: 15, y: 5, direction: "up" });
game.interact();
expect(finish(game)).toMatch(/Maybe a.*POKéMON.*break/i);
});
it("asks for Strength at Burned Tower B1F's boulder, which is not obtainable yet", () => {
// The TM Endure behind it stays unreachable until a much later card
// gives Strength; AskStrengthScript (jumpstd StrengthBoulderScript)
// should still answer sensibly rather than refusing outright.
const game = world("2026-09-22T12:00:00");
game.warpTo(BURNED_TOWER_B1F, 17, 9);
settle(game);
game.restore({ ...game.save(), x: 17, y: 9, direction: "up" });
game.interact();
expect(finish(game)).toMatch(/may be able to move this/i);
});
it("opens Ecruteak Gym's guide and statue text once Morty is reachable, then again once he is beaten", () => {
// BurnedTowerB1F's ReleaseTheBeasts sets SCENE_ECRUTEAKGYM_NOOP; drive
// that directly, as the playthrough's own roaming-beast stage (out of
// scope for this focused file) otherwise would.
const game = world("2026-09-22T12:00:00");
game.warpTo(ECRUTEAK_GYM, 5, 17);
game.state.scenes[ECRUTEAK_GYM] = SCENE_ECRUTEAKGYM_NOOP;
settle(game);
expect(game.mapId).toBe(ECRUTEAK_GYM);
game.restore({ ...game.save(), x: 7, y: 16, direction: "up" }); // the Gym Guide
game.interact();
let text = finish(game);
expect(text).toMatch(/secret mo-?\s*tives/i);
game.restore({ ...game.save(), x: 3, y: 16, direction: "up" }); // the left statue
game.interact();
text = finish(game);
expect(text).toMatch(/#MON GYM|POKéMON GYM/);
expect(text).not.toMatch(/WINNING TRAINERS/);
// EcruteakGymGuideScript checks EVENT_BEAT_MORTY; EcruteakGymStatue
// checks ENGINE_FOGBADGE instead. Beating Morty sets both together,
// but this isolated test drives each script without a real battle.
game.state.events.add(EVENT_BEAT_MORTY);
game.state.flags.add(ENGINE_FOGBADGE);
game.restore({ ...game.save(), x: 7, y: 16, direction: "up" });
game.interact();
text = finish(game);
expect(text).toMatch(/cowering in/i);
game.restore({ ...game.save(), x: 6, y: 16, direction: "up" }); // the right statue
game.interact();
text = finish(game);
expect(text).toMatch(/LEADER:\s*MORTY/i);
expect(text).toMatch(/WINNING TRAINERS/);
});
it("shows Ecruteak City's beasts-released text once they are released", () => {
const before = world("2026-09-22T12:00:00");
talk(before, ECRUTEAK, 3, 9); // EcruteakCityLass2 (the old tower)
expect(finish(before)).toMatch(/tower that.*used to be here/i);
const after = world("2026-09-22T12:00:00");
after.state.events.add(EVENT_RELEASED_THE_BEASTS);
talk(after, ECRUTEAK, 3, 9);
expect(finish(after)).toMatch(/ran off in differ-?\s*ent directions/i);
});
});
+588
View File
@@ -0,0 +1,588 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { RomReader } from "../src/game/rom/reader";
import { PokemonData } from "../src/game/rom/pokemon";
import { emptyScriptState, type ScriptEffect } from "../src/game/scripts/interpreter";
import { defaultTextContext, readText } from "../src/game/scripts/text";
import { C } from "../src/game/battle/constants";
import {
slotMachine,
initBias,
SLOTS_NO_BIAS,
SLOTS_SEVEN,
makeReel,
spinAllReels,
ReelAction,
type SlotsSession,
} from "../src/game/scripts/slot-machine";
import {
cardFlip,
shuffleDeck,
cardFlipPayout,
moveCardFlipCursor,
} from "../src/game/scripts/card-flip";
import type { SpecialContext } from "../src/game/scripts/special-context";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { settle } from "./world-driver";
import { JOHTO } from "../src/game/rom/profile";
import { SCREEN_WIDTH } from "../src/game/scripts/screen";
const romPath =
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
/** A simple deterministic byte stream, like the other seeded-RNG tests. */
function seededRandom(seed: number) {
let s = seed >>> 0;
return () => {
s = (s * 1103515245 + 12345) >>> 0;
return (s >>> 16) & 0xff;
};
}
/** A minimal SpecialContext over the real ROM: enough state for the Game
* Corner's coin checks and text, built the same way interpreter.ts's own
* `specialContext` is. */
function makeContext(
reader: RomReader,
data: PokemonData,
random: () => number,
coins: number,
): SpecialContext {
const state = emptyScriptState();
state.coins = coins;
state.bag.keyItems.push(C.COIN_CASE);
const pages: string[] = [];
const strings: Record<number, string> = {},
numbers: Record<number, number> = {};
// Merge the mutable string/number buffers into the text context, as
// interpreter.ts's own `text()` does with `this.strings`/`this.numbers`.
const text = () => ({ ...defaultTextContext("CHRIS"), strings, numbers });
return {
reader,
data,
state,
strings,
numbers,
text,
random,
player: () => ({ id: 1, name: "CHRIS" }),
today: () => 0,
weekday: () => 0,
write: (address: number) => {
const content = readText(reader, address, text());
if (content) pages.push(...content.split("\n\n"));
},
take: () => pages.splice(0, pages.length),
flush: function* () {
if (pages.length) yield { kind: "text", pages: pages.splice(0, pages.length) } as ScriptEffect;
},
selectMon: function* () {
return -1;
},
};
}
type Answer = number | string | undefined;
/** Drive a Game Corner generator to completion: `screens` answers "screen"
* effects in order (defaulting to A once exhausted, which both confirms a
* choice and passes every non-interactive realtime frame); `plays` answers
* "yesno" effects in order (defaulting to YES); the bet menu always picks
* `bet` coins. */
function play<T>(
generator: Generator<ScriptEffect, T, Answer>,
options: { bet?: number; screens?: string[]; plays?: boolean[] } = {},
): T {
const screens = [...(options.screens ?? [])];
const plays = [...(options.plays ?? [])];
let result = generator.next();
let steps = 0;
while (!result.done) {
if (++steps > 300_000) throw new Error("The Game Corner session did not finish.");
const effect = result.value;
let answer: Answer;
if (effect.kind === "menu") answer = options.bet !== undefined ? 3 - options.bet : 1;
else if (effect.kind === "yesno") answer = (plays.length ? plays.shift() : true) ? 1 : 0;
else if (effect.kind === "screen") answer = screens.length ? screens.shift() : "a";
result = generator.next(answer);
}
return result.value;
}
/** ChooseCard_HandleJoypad's board (CollapseCursorPosition: Y*6+X), moved
* one cell per press as this port's `moveCardFlipCursor` does. */
function moveButtons(fromX: number, fromY: number, toX: number, toY: number): string[] {
const buttons: string[] = [];
for (let i = 0; i < Math.abs(toY - fromY); i++) buttons.push(toY > fromY ? "down" : "up");
for (let i = 0; i < Math.abs(toX - fromX); i++) buttons.push(toX > fromX ? "right" : "left");
return buttons;
}
describe("CardFlip_CheckWinCondition's paytable", () => {
it("pays the species pair, exact species, level pair, exact level and exact card cells", () => {
// Card 0: species 0 (Pikachu), level 0 ("1").
expect(cardFlipPayout(0, 0)).toBe(0); // row 0's outer cells always lose
expect(cardFlipPayout(1, 0)).toBe(0);
expect(cardFlipPayout(2, 0)).toBe(6); // Pikachu/Jigglypuff pair
expect(cardFlipPayout(4, 0)).toBe(0); // Poliwag/Oddish pair does not match
expect(cardFlipPayout(8, 0)).toBe(12); // row 1, exact species (Pikachu)
expect(cardFlipPayout(12, 0)).toBe(9); // row 2, level pair ("1"-"2")
expect(cardFlipPayout(13, 0)).toBe(18); // row 2, exact level ("1")
expect(cardFlipPayout(14, 0)).toBe(72); // row 2, exact card (Pikachu "1")
expect(cardFlipPayout(14, 1)).toBe(0); // the same cell loses for a different card
// Card 23: species 3 (Oddish), level 5 ("6").
expect(cardFlipPayout(5, 23)).toBe(6); // Poliwag/Oddish pair
expect(cardFlipPayout(11, 23)).toBe(12); // row 1, exact species (Oddish)
expect(cardFlipPayout(42, 23)).toBe(9); // row 6/7's shared level pair ("5"-"6")
expect(cardFlipPayout(43, 23)).toBe(18); // row 7, exact level ("6")
expect(cardFlipPayout(47, 23)).toBe(72); // row 7, exact card (Oddish "6")
});
});
describe("ChooseCard_HandleJoypad's exact navigation", () => {
const at = (cursorX: number, cursorY: number) => ({ cursorX, cursorY });
it("snaps left/right to the nearest even cell in the Pokémon-pair row (Y=0)", () => {
const session = at(5, 0);
moveCardFlipCursor(session, "left");
// 5 & 0xe = 4, which is not below 3, so it becomes 4 - 2.
expect(session).toEqual({ cursorX: 2, cursorY: 0 });
const session2 = at(3, 0);
moveCardFlipCursor(session2, "left");
// 3 & 0xe = 2, which is below 3, so the cursor jumps to the mon-group
// junction cell instead of stopping on the pair row.
expect(session2).toEqual({ cursorX: 1, cursorY: 2 });
const session3 = at(4, 0);
moveCardFlipCursor(session3, "right");
// 4 & 0xe = 4, which is not below 4, so right is refused (but the
// mask is still applied, a kept quirk of the original).
expect(session3).toEqual({ cursorX: 4, cursorY: 0 });
});
it("snaps to the mon-group junction from the Pokémon-group row (Y=1) too, without masking", () => {
const session = at(2, 1);
moveCardFlipCursor(session, "left");
expect(session).toEqual({ cursorX: 1, cursorY: 2 });
const session2 = at(4, 1);
moveCardFlipCursor(session2, "left");
expect(session2).toEqual({ cursorX: 3, cursorY: 1 });
});
it("snaps up/down to the nearest even cell in the number-pair column (X=0), or to the number-group junction", () => {
const session = at(0, 5);
moveCardFlipCursor(session, "up");
expect(session).toEqual({ cursorX: 0, cursorY: 2 });
const session2 = at(0, 3);
moveCardFlipCursor(session2, "up");
expect(session2).toEqual({ cursorX: 2, cursorY: 1 });
const session3 = at(0, 4);
moveCardFlipCursor(session3, "down");
expect(session3).toEqual({ cursorX: 0, cursorY: 6 });
const session4 = at(0, 6);
moveCardFlipCursor(session4, "down");
expect(session4).toEqual({ cursorX: 0, cursorY: 6 });
});
it("moves one cell at a time everywhere else, refusing past either edge", () => {
const session = at(3, 5);
moveCardFlipCursor(session, "left");
expect(session).toEqual({ cursorX: 2, cursorY: 5 });
moveCardFlipCursor(session, "right");
moveCardFlipCursor(session, "right");
expect(session).toEqual({ cursorX: 4, cursorY: 5 });
moveCardFlipCursor(session, "right");
expect(session).toEqual({ cursorX: 5, cursorY: 5 }); // the last column
moveCardFlipCursor(session, "right");
expect(session).toEqual({ cursorX: 5, cursorY: 5 }); // refused past it
const bottom = at(2, 7);
moveCardFlipCursor(bottom, "down");
expect(bottom).toEqual({ cursorX: 2, cursorY: 7 }); // the last row, refused
});
});
describe.skipIf(!existsSync(romPath))("the slot machine with the local ROM", () => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const reader = new RomReader(bytes);
const data = new PokemonData(reader);
describe("Slots_InitBias", () => {
it("re-rolls a fresh bias every round, except when it is already SLOTS_SEVEN", () => {
// Slots_InitBias's own "and a; ret z" guard: a seven bias is never
// re-rolled, keeping last round's streak untouched.
expect(initBias({ reader, random: () => 0 }, SLOTS_SEVEN, false)).toBe(SLOTS_SEVEN);
// 255 falls past every real entry, into the table's own 100% "no
// bias" slice for both tables.
expect(initBias({ reader, random: () => 255 }, SLOTS_NO_BIAS, false)).toBe(SLOTS_NO_BIAS);
expect(initBias({ reader, random: () => 255 }, SLOTS_NO_BIAS, true)).toBe(SLOTS_NO_BIAS);
});
it("the lucky machine (wScriptVar true) widens the cherry slice well past the normal table's", () => {
// Normal's cumulative cherry threshold is 19 percent (48 of 255);
// Lucky's is 31 percent + 1 (80 of 255). A byte of 60 falls past
// Normal's cherry slice (into "no bias") but still inside Lucky's.
const byte = 60;
const normal = initBias({ reader, random: () => byte }, SLOTS_NO_BIAS, false);
const lucky = initBias({ reader, random: () => byte }, SLOTS_NO_BIAS, true);
expect(normal).toBe(SLOTS_NO_BIAS);
expect(lucky).not.toBe(SLOTS_NO_BIAS);
});
});
describe("the Golem and Chansey/egg replay (reel 3's real frame-by-frame hops)", () => {
const sevenEverywhere = Array<number>(18).fill(SLOTS_SEVEN);
// Positions 0 and 15 (the wraparound repeat of 0) are the seven; every
// other position (including the reel's own starting position, 14) is
// not, so InitGolem's own first check does not already count as a win.
const oneSevenReel3 = [0, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 0, 4, 4];
// Every position but the reel's own starting one (14) is the seven, so
// InitChansey's own first check does not already count as a win, but
// wherever DropReel's 16-rate spin leaves it afterward still does.
const allButStartingSeven = Array<number>(18).fill(SLOTS_SEVEN).map((value, i) => (i === 14 ? 4 : value));
function newSession(reel3Tilemap: readonly number[], bias: number): SlotsSession {
return {
reels: [makeReel(sevenEverywhere), makeReel(sevenEverywhere), makeReel(reel3Tilemap)],
bias,
bet: 1, // only the middle line, so reel1/reel2's own middle entry decides the match
matched: -1,
firstTwoReelsMatching: true,
firstTwoReelsMatchingSevens: true,
buildingMatch: 0,
payout: 0,
delay: 0,
phase: 0,
joypadSumA: false,
flashFrame: 0,
resultLines: [],
};
}
function runUntilStopped(session: SlotsSession) {
const host = { reader, random: () => 0 };
for (let tick = 0; tick < 5000 && session.reels[2].action !== ReelAction.DoNothing; tick++)
spinAllReels(session, host);
if (session.reels[2].action !== ReelAction.DoNothing)
throw new Error("Reel 3 never stopped.");
}
it("Golem's replay advances reel 3 by exactly the probe's real hop count, not the probe's own discarded position", () => {
const session = newSession(oneSevenReel3, SLOTS_SEVEN);
const reel3 = session.reels[2];
reel3.action = ReelAction.InitGolem;
const startPosition = reel3.position;
runUntilStopped(session);
// Slots_GetNumberOfGolems' seven-bias probe (reel.position starts at
// 14): the very first incremented position (raw 15, the tilemap's
// own wraparound repeat of index 0) already matches, so the probe
// reports one hop, and reel 3's real position should have advanced
// by exactly that one hop for real.
expect(reel3.position).toBe((startPosition + 1) % 15);
expect(session.matched).toBe(SLOTS_SEVEN);
expect(session.golem).toBeUndefined();
});
it("Chansey's replay walks in, lays the egg and lets DropReel stop reel 3 on seven", () => {
const session = newSession(allButStartingSeven, SLOTS_NO_BIAS); // Chansey always targets seven, regardless of bias
const reel3 = session.reels[2];
reel3.action = ReelAction.InitChansey;
runUntilStopped(session);
expect(session.matched).toBe(SLOTS_SEVEN);
// The egg always finishes its bounce (and clears itself) before
// DropReel's own match check runs, whether that check succeeds or
// (EggAgain) loops back through Chansey for another egg.
expect(session.egg).toBeUndefined();
});
});
it("spins through a seeded random source, over several rounds, to a payout", () => {
const context = makeContext(reader, data, seededRandom(7), 150);
const rounds = 30;
const plays = Array<boolean>(rounds - 1).fill(true).concat([false]);
play(slotMachine(context, true), { bet: 3, plays });
// 30 rounds at 3 coins each (90 total) must have paid out at least
// once for the balance to end this high; a run that only ever lost
// could not exceed 150 - 90.
expect(context.state.coins).toBeGreaterThan(150 - rounds * 3);
});
it("the lucky machine plays a full round without refusing", () => {
const context = makeContext(reader, data, seededRandom(21), 50);
play(slotMachine(context, true), { bet: 1, plays: [false] });
expect(context.state.coins).toBeGreaterThanOrEqual(0);
expect(context.state.coins).toBeLessThanOrEqual(C.MAX_COINS);
});
it("refuses to start with no coins, and takes nothing", () => {
const context = makeContext(reader, data, seededRandom(1), 0);
play(slotMachine(context, false), {});
expect(context.state.coins).toBe(0);
});
it("refuses to start without a Coin Case, even with coins", () => {
const context = makeContext(reader, data, seededRandom(1), 50);
context.state.bag.keyItems = [];
play(slotMachine(context, false), {});
expect(context.state.coins).toBe(50);
});
it("the coin case never exceeds 9999 while paying out", () => {
const context = makeContext(reader, data, seededRandom(13), 9990);
const rounds = 40;
const plays = Array<boolean>(rounds - 1).fill(true).concat([false]);
play(slotMachine(context, true), { bet: 3, plays });
expect(context.state.coins).toBeLessThanOrEqual(C.MAX_COINS);
});
it("quitting the bet menu (B) ends the game without spending coins", () => {
const context = makeContext(reader, data, seededRandom(9), 20);
const generator = slotMachine(context, false);
const first = generator.next();
expect(first.done).toBe(false);
const second = generator.next(-1); // B cancels Slots_AskBet's menu
expect(second.done).toBe(true);
expect(context.state.coins).toBe(20);
});
});
describe.skipIf(!existsSync(romPath))("Card Flip with the local ROM", () => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const reader = new RomReader(bytes);
const data = new PokemonData(reader);
it("a round the player bets on the dealt card's exact species wins", () => {
const deck = shuffleDeck({ random: seededRandom(5) });
const species = deck[0] & 3;
const context = makeContext(reader, data, seededRandom(5), 50);
const buttons = ["a", ...moveButtons(2, 2, 2 + species, 1), "a"];
play(cardFlip(context), { screens: buttons, plays: [true, false] });
expect(context.state.coins).toBe(50 - 3 + 12);
});
it("a round the player bets on the wrong exact species and loses", () => {
// ChooseCard_HandleJoypad's own snapping keeps the cursor off the
// board's four truly "impossible" cells (row 0-1, col 0-1) from any
// reachable approach, so this port's exact translation of it cannot
// reach one either; betting the wrong exact species is an equally
// sure loss that stays reachable.
const deck = shuffleDeck({ random: seededRandom(6) });
const wrongSpecies = ((deck[0] & 3) + 1) % 4;
const context = makeContext(reader, data, seededRandom(6), 50);
const buttons = ["a", ...moveButtons(2, 2, 2 + wrongSpecies, 1), "a"];
play(cardFlip(context), { screens: buttons, plays: [true, false] });
expect(context.state.coins).toBe(50 - 3);
});
it("refuses to start with no coins, and takes nothing", () => {
const context = makeContext(reader, data, seededRandom(1), 0);
play(cardFlip(context), {});
expect(context.state.coins).toBe(0);
});
it("refuses to start without a Coin Case, even with coins", () => {
const context = makeContext(reader, data, seededRandom(1), 50);
context.state.bag.keyItems = [];
play(cardFlip(context), {});
expect(context.state.coins).toBe(50);
});
it("the coin case never exceeds 9999, even betting the dealt card's exact match (72 coins)", () => {
const deck = shuffleDeck({ random: seededRandom(5) });
const card = deck[0];
const level = card >> 2,
species = card & 3;
const pairIndex = Math.floor(level / 2),
isPairRow = level % 2 === 0;
const row = 2 + pairIndex * 2 + (isPairRow ? 0 : 1);
const col = 2 + species;
const context = makeContext(reader, data, seededRandom(5), 9995);
const buttons = ["a", ...moveButtons(2, 2, col, row), "a"];
play(cardFlip(context), { screens: buttons, plays: [true, false] });
expect(context.state.coins).toBe(C.MAX_COINS);
});
it("declining to play with three coins ends the game without spending any", () => {
const context = makeContext(reader, data, seededRandom(2), 30);
play(cardFlip(context), { plays: [false] });
expect(context.state.coins).toBe(30);
});
});
describe.skipIf(!existsSync(romPath))(
"the Game Corner's realtime screen through the world, with the local ROM",
() => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const GAME_CORNER = "11:19";
function enterGameCorner(coins: number) {
let seed = 17;
const game = new GameWorld(
new CrystalAssets(bytes),
() => new Date("2026-09-22T12:00:00"),
() => {
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 0xff;
},
);
game.state.coins = coins;
game.state.bag.keyItems.push(C.COIN_CASE);
return game;
}
/** Stand beside the object at (x, y), face it and press A, as
* tests/goldenrod.test.ts's own `talk` helper does. No settle() runs
* after `interact()`: the special's first effect may be a realtime
* screen, which the generic turn-based driver cannot answer. */
function talk(game: GameWorld, map: string, x: number, y: number) {
const STEP = {
up: [0, -1],
down: [0, 1],
left: [-1, 0],
right: [1, 0],
} as const;
for (const [face, [dx, dy]] of Object.entries(STEP) as [
keyof typeof STEP,
readonly [number, number],
][]) {
try {
game.warpTo(map, x - dx, y - dy);
} catch {
continue;
}
settle(game);
game.move(face);
settle(game);
game.interact();
return;
}
throw new Error(`No place to talk to ${x},${y} on ${map}`);
}
/** Turn-based effects (Slots_AskBet's menu, a refusal's text) up to
* the first realtime screen, picking the first menu item and YES. */
function driveToScreen(game: GameWorld) {
for (let tick = 0; tick < 2000; tick++) {
const dialogue = game.dialogue;
if (dialogue?.choice?.kind === "screen") return;
if (dialogue) {
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) game.choose(dialogue.choice.kind === "yesno" ? 1 : 0);
else if (last && dialogue.holdTicks !== undefined) game.tick();
else game.advanceDialogue();
} else if (game.busy) game.tick();
else return;
}
throw new Error("Did not reach a screen choice.");
}
/** The realtime screen itself, holding A every fourth tick (as a
* player holding the button would, since setButton only reports
* presses) until Slots_AskPlayAgain's yes/no replaces it. */
function driveRealtimeToPlayAgain(game: GameWorld) {
for (let tick = 0; tick < 20_000; tick++) {
const choice = game.dialogue?.choice;
if (choice?.kind === "yesno") return;
if (choice?.kind === "screen" && (choice as { realtime?: boolean }).realtime) {
if (tick % 4 === 0) game.bufferScreenButton("a");
game.tick();
continue;
}
if (game.dialogue) {
const dialogue = game.dialogue;
const last = dialogue.index === dialogue.pages.length - 1;
if (last && dialogue.choice) game.choose(dialogue.choice.kind === "menu" ? 0 : 1);
else if (last && dialogue.holdTicks !== undefined) game.tick();
else game.advanceDialogue();
} else game.tick();
}
throw new Error("Did not reach the play-again question.");
}
it("opens the lucky slot machine as a realtime screen, driven through world.tick", () => {
const game = enterGameCorner(50);
// GoldenrodGameCornerLuckySlotsMachineScript's fixed tile.
talk(game, GAME_CORNER, 7, 7);
driveToScreen(game);
const choice = game.dialogue?.choice;
expect(choice?.kind).toBe("screen");
expect((choice as { realtime?: boolean }).realtime).toBe(true);
driveRealtimeToPlayAgain(game);
expect(game.dialogue?.choice?.kind).toBe("yesno");
// A bet was taken (3 coins) before any payout; a win would only add
// to the balance, never make it exceed the starting amount after
// the bet.
expect(game.state.coins).toBeLessThanOrEqual(50);
expect(game.state.coins).toBeLessThanOrEqual(50 - 3 + 300);
game.choose(0); // Slots_AskPlayAgain: No.
settle(game);
expect(game.busy).toBe(false);
expect(game.dialogue).toBeUndefined();
});
it("opens Card Flip as a realtime screen, driven through world.tick", () => {
const game = enterGameCorner(50);
talk(game, GAME_CORNER, 13, 7); // GoldenrodGameCornerCardFlipMachineScript
// CardFlip.AskPlayWithThree is a plain yes/no before any screen.
driveToScreen(game);
expect(game.dialogue?.choice?.kind).toBe("screen");
driveRealtimeToPlayAgain(game);
expect(game.dialogue?.choice?.kind).toBe("yesno");
expect(game.state.coins).toBeLessThanOrEqual(50 - 3 + 72);
game.choose(0); // CardFlip.PlayAgain: No.
settle(game);
expect(game.busy).toBe(false);
});
},
);
describe.skipIf(!existsSync(romPath))(
"the port's own static tilemaps match the ROM's data, copied the asm's way",
() => {
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
const reader = new RomReader(bytes);
const data = new PokemonData(reader);
/** Drive a Game Corner generator to its first "screen" effect that
* carries sprites (PlaceYourBet for Card Flip, any spinning frame for
* the slot machine; ChooseACard's own screen has none), answering
* every other effect the same way `play()` does. A regression here
* would mean the board/frame these screens draw moved or changed
* shape without anyone noticing a pixel. */
function firstScreenWithSprites<T>(generator: Generator<ScriptEffect, T, Answer>) {
let result = generator.next();
let steps = 0;
while (!result.done) {
if (++steps > 2000) throw new Error("Did not reach a screen with sprites.");
const effect = result.value;
let answer: Answer;
if (effect.kind === "menu") answer = 1; // bet 2
else if (effect.kind === "yesno") answer = 1; // yes
else if (effect.kind === "screen") {
if (effect.screen.sprites && effect.screen.sprites.length > 0) return effect.screen;
answer = "a";
}
result = generator.next(answer);
}
throw new Error("The generator finished before a screen with sprites appeared.");
}
it("the slot machine's static frame matches SlotsTilemap's own flat 20x12 copy (InitGFX's CopyBytes)", () => {
const context = makeContext(reader, data, seededRandom(11), 50);
const screen = firstScreenWithSprites(slotMachine(context, false));
const ground = Array.from(reader.slice(JOHTO.SlotsTilemap, SCREEN_WIDTH * 12));
const port = Array.from(screen.map.slice(0, SCREEN_WIDTH * 12));
// Mask out the two cells PrintNum/illuminateBetLights redraw every
// tick (the CREDIT/PAYOUT digits at row 1, and the bet-light column
// at x=3/16 rows 2-7): everywhere else must still match the ROM's
// own static SlotsTilemap bytes exactly.
for (let col = 5; col < 9; col++) port[1 * SCREEN_WIDTH + col] = ground[1 * SCREEN_WIDTH + col];
for (let col = 11; col < 15; col++) port[1 * SCREEN_WIDTH + col] = ground[1 * SCREEN_WIDTH + col];
for (let row = 2; row < 8; row++)
for (const col of [3, 16]) port[row * SCREEN_WIDTH + col] = ground[row * SCREEN_WIDTH + col];
expect(port).toEqual(ground);
});
it("Card Flip's paytable board matches CardFlipTilemap's own 12-row by 11-column copy (CardFlip_InitTilemap's `lb bc, 12, 11`)", () => {
const context = makeContext(reader, data, seededRandom(11), 50);
const screen = firstScreenWithSprites(cardFlip(context));
const ground = reader.slice(JOHTO.CardFlipTilemap, 12 * 11);
const board: number[] = [];
for (let row = 0; row < 12; row++)
for (let col = 0; col < 11; col++) board.push(screen.map[row * SCREEN_WIDTH + 9 + col]);
expect(board).toEqual(Array.from(ground));
// Columns 0-1 (left of the dealt-card box, which PlaceYourBet's own
// `cards: "chosen"` view draws at x=2..6) stay untouched background,
// never leaked-into by a row/column dimension mismatch.
for (let row = 0; row < 12; row++)
for (let col = 0; col < 2; col++) expect(screen.map[row * SCREEN_WIDTH + col]).toBe(0x29);
});
},
);
+3 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { GameWorld, WALK_TICKS } from "../src/game/world/world";
import { VECTORS, type Direction, type GameMap } from "../src/game/world/types";
import { settle } from "./world-driver";
import { afterRestore, settle } from "./world-driver";
import { MovementAssets, connectWarp, fixture } from "./movement-fixture";
describe("native ledge movement", () => {
@@ -118,7 +118,8 @@ describe("native ledge movement", () => {
restored.restore(beforeLanding);
expect(restored.move("down")).toBe(true);
expect(restored.activity).toBe("scene");
expect(restored.save()).toEqual(beforeLanding);
const reloaded = restored.save();
expect(reloaded).toEqual(afterRestore(beforeLanding, reloaded));
expect(restored.state.events.has(8)).toBe(false);
settle(restored);
expect(restored.state.events.has(8)).toBe(true);
+96 -22
View File
@@ -6,7 +6,9 @@ import type { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld, type NativeSave } from "../src/game/world/world";
import type { Direction } from "../src/game/world/types";
import { C } from "../src/game/battle/constants";
import { findPath } from "./world-driver";
import { applyTypeMatchups } from "../src/game/battle/battle";
import { BattleData } from "../src/game/battle/data";
import { afterRestore, findPath } from "./world-driver";
const STEP: Record<Direction, [number, number]> = {
up: [0, -1],
@@ -51,29 +53,46 @@ export class Playthrough {
this.world.restore(dump.save);
}
/** The usable move with the most power, or the first with PP; a move
* Disable blocks (its slot in the side's disable count) is skipped. */
/** The usable move that hits hardest for its power, STAB and type
* matchups against `targetTypes`, or the first with PP; a move Disable
* blocks (its slot in the side's disable count) is skipped. */
strongest = (
mon: { moves: readonly number[]; pp: readonly number[] },
mon: {
moves: readonly number[];
pp: readonly number[];
types: readonly number[];
},
targetTypes: readonly number[],
disableCount = 0,
) => {
const disabled = disableCount ? ((disableCount >> 4) & 0xf) - 1 : -1;
this.matchups ??= new BattleData(this.assets.reader).typeMatchups();
const score = (move: number) =>
applyTypeMatchups(this.matchups!, {
damage: this.assets.pokemon.move(move).power * 10,
modifier: 0,
moveType: this.assets.pokemon.move(move).type,
userTypes: mon.types,
targetTypes,
identified: false,
}).damage;
let best = -1;
mon.moves.forEach((move, i) => {
if (!move || !(mon.pp[i] & 63) || i === disabled) return;
if (
best < 0 ||
this.assets.pokemon.move(move).power >
this.assets.pokemon.move(mon.moves[best]).power
)
best = i;
if (best < 0 || score(move) > score(mon.moves[best])) best = i;
});
return Math.max(0, best);
};
private matchups?: ReturnType<BattleData["typeMatchups"]>;
/** Run scripts and battles until the player is free again. Battles use
* the strongest move with PP; a wild battle below half HP is run from, as
* a cautious player would. New moves are learned when there is room,
* and other questions take the queued answers or their defaults. */
* the strongest move with PP; a wild battle below half HP is run from,
* and a trainer battle (which cannot be run from) instead reaches for
* the bag through the battle's own item action (BattleMenu_Pack), as a
* cautious player who restocked before a hard fight would: a carried
* Awakening wakes the active Pokémon before Sleep/Dream Eater can lock
* it out of a whole fight, and a carried Super Potion heals it below
* half HP. New moves are learned when there is room, and other
* questions take the queued answers or their defaults. */
play = (answers: (number | string)[] = []) => {
const world = this.world,
log = this.log;
@@ -90,6 +109,12 @@ export class Playthrough {
const ball = world.liveState.bag.balls.findIndex(
(slot) => slot.item === C.POKE_BALL,
);
const potion = world.liveState.bag.items.findIndex(
(slot) => slot.item === C.SUPER_POTION,
);
const awakening = world.liveState.bag.items.findIndex(
(slot) => slot.item === C.AWAKENING,
);
if (
engine.battleMode === C.WILD_BATTLE &&
engine.enemy.mon.species === this.wanted &&
@@ -99,22 +124,43 @@ export class Playthrough {
world.battleAction({ action: "item", item: C.POKE_BALL, slot: ball });
else if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
world.battleAction({ action: "run" });
else if (
engine.battleMode !== C.WILD_BATTLE &&
(mon.status & C.SLP_MASK) !== 0 &&
awakening >= 0
)
world.battleAction({ action: "item", item: C.AWAKENING, slot: awakening });
else if (
engine.battleMode !== C.WILD_BATTLE &&
mon.hp * 2 < mon.maxHp &&
potion >= 0
)
world.battleAction({ action: "item", item: C.SUPER_POTION, slot: potion });
else
world.battleAction({
action: "fight",
move: this.strongest(mon, engine.player.disableCount),
move: this.strongest(
mon,
engine.enemy.mon.types,
engine.player.disableCount,
),
});
} else if (prompt?.kind === "yesno") world.battleChoose(1);
// SHIFT's party menu: keep the Pokémon out (or send the first
// that can fight when one must go).
// that can fight when one must go). UseItem_SelectMon shows the
// same prompt kind for the item action just above, distinguished
// by its own text (BattlePack's RestoreHPEffect heals whichever
// slot is chosen; the active Pokémon is the one that asked for it).
else if (prompt?.kind === "party")
world.battleChoose(
prompt.cancel
? -1
: world.liveState.party.findIndex(
(mon, i) =>
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
),
prompt.text === "Use on which POKéMON?"
? battle.engine.curBattleMon
: prompt.cancel
? -1
: world.liveState.party.findIndex(
(mon, i) =>
i !== battle.engine.curBattleMon && !mon.egg && mon.hp > 0,
),
);
else if (prompt?.kind === "forget") world.battleChoose(4);
else if (prompt?.kind === "name") world.battleName("");
@@ -254,7 +300,8 @@ export class Playthrough {
}
this.world = new GameWorld(this.assets, this.now, this.random);
this.world.restore(saved);
expect(this.world.save()).toEqual(saved);
const reloaded = this.world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
};
lead = () => this.world.state.party[0];
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
@@ -445,6 +492,33 @@ export class Playthrough {
}
throw new Error(`No way to talk to object ${id}`);
};
/** Talk to the clerk at `clerk` and buy `quantity` of `item` through
* the world's own BuyMenu (BuyMenuLoop, SelectQuantityToBuy): BUY, the
* item in the stock list, the quantity, YES, back out of the list,
* then QUIT, as tests/mart.test.ts and tests/goldenrod.test.ts drive the
* martmenu/martlist/martquantity choices directly. */
buy = (clerk: number, item: number, quantity: number) => {
const world = this.world;
this.talkTo(clerk);
expect(this.toChoice().kind).toBe("martmenu");
world.choose(0); // BUY
const list = this.toChoice();
if (list.kind !== "martlist")
throw new Error(`Expected a mart list, got ${list.kind}`);
const index = list.entries.findIndex((entry) => entry.item === item);
expect(index, `item ${item} in the mart's stock`).toBeGreaterThanOrEqual(0);
world.choose(index);
expect(this.toChoice().kind).toBe("martquantity");
world.choose(quantity);
expect(this.toChoice().kind).toBe("yesno");
world.choose(1);
// "Here you are!", then the stock list again (BuyMenuLoop repeats).
expect(this.toChoice().kind).toBe("martlist");
world.choose(-1); // back to the mart's own menu
expect(this.toChoice().kind).toBe("martmenu");
world.choose(2); // QUIT
this.play();
};
/** PACK, the TM/HM pocket's `item`, USE, YES, then party slot `slot`;
* a full move list forgets the first move. */
teach = (item: number, slot: number) => {
+236
View File
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { C } from "../src/game/battle/constants";
import { Playthrough } from "./playthrough-driver";
import { findPath } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
@@ -42,7 +43,22 @@ const ROUTE_32 = "10:1",
GOLDENROD_CENTER = "11:20",
GOLDENROD_GYM = "11:3",
BIKE_SHOP = "11:4",
FLOWER_SHOP = "11:8",
RADIO_TOWER_1F = "3:17";
const ROUTE_35 = "10:2",
ROUTE_35_NATIONAL_PARK_GATE = "10:15",
NATIONAL_PARK = "3:15",
ROUTE_36_NATIONAL_PARK_GATE = "10:17",
ROUTE_35_GATE = "10:14",
ROUTE_36 = "10:3",
ROUTE_37 = "10:4",
ECRUTEAK = "4:9",
ECRUTEAK_CENTER = "4:3",
ECRUTEAK_MART = "4:6",
ECRUTEAK_GYM = "4:7",
DANCE_THEATER = "4:5",
BURNED_TOWER_1F = "3:13",
BURNED_TOWER_B1F = "3:14";
const MIRACLE_SEED = 0x75,
LURE_BALL = 0xa0,
HM01 = 0xf3,
@@ -68,6 +84,27 @@ const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
EVENT_GOT_TM31_MUD_SLAP = 0x8,
EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE = 0x2d,
ENGINE_ZEPHYRBADGE = 0x1b;
const SQUIRTBOTTLE = 0xaf;
const EVENT_MET_FLORIA = 0xb9,
EVENT_GOT_SQUIRTBOTTLE = 0x5c,
EVENT_FOUGHT_SUDOWOODO = 0x2a,
EVENT_GOT_HM03_SURF = 0x12,
EVENT_RIVAL_BURNED_TOWER = 0x6c5,
EVENT_HOLE_IN_BURNED_TOWER = 0x332;
const EVENT_RELEASED_THE_BEASTS = 0x7b,
EVENT_BURNED_TOWER_MORTY = 0x764,
EVENT_ECRUTEAK_GYM_GRAMPS = 0x7a8,
EVENT_ECRUTEAK_CITY_GRAMPS = 0x7a9,
EVENT_BEAT_SAGE_JEFFREY = 0x415,
EVENT_BEAT_SAGE_PING = 0x416,
EVENT_BEAT_MEDIUM_MARTHA = 0x58b,
EVENT_BEAT_MEDIUM_GRACE = 0x58c,
EVENT_BEAT_MORTY = 0x4c0,
EVENT_GOT_TM30_SHADOW_BALL = 0xc,
ENGINE_FOGBADGE = 30,
RAIKOU = 0xf3,
ENTEI = 0xf4,
ROUTE_42 = "2:5";
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
@@ -98,6 +135,7 @@ describe.skipIf(!existsSync(romPath))(
choose,
choosePack,
talkTo,
buy,
} = p;
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
const restInViolet = () => {
@@ -108,6 +146,25 @@ describe.skipIf(!existsSync(romPath))(
exit(ROUTE_31, "right");
};
/** Route 35 and Route 36 meet only through the National Park and
* its two gates (Route 36's Sudowoodo still blocks the way from
* Violet City). */
const toRoute36 = () => {
stairs(ROUTE_35_GATE);
stairs(ROUTE_35);
stairs(ROUTE_35_NATIONAL_PARK_GATE);
stairs(NATIONAL_PARK);
stairs(ROUTE_36_NATIONAL_PARK_GATE);
stairs(ROUTE_36);
};
const toGoldenrod = () => {
stairs(ROUTE_36_NATIONAL_PARK_GATE);
stairs(NATIONAL_PARK);
stairs(ROUTE_35_NATIONAL_PARK_GATE);
stairs(ROUTE_35);
stairs(ROUTE_35_GATE);
stairs(GOLDENROD);
};
beforeEach(() => {
p.log.length = 0;
@@ -559,5 +616,184 @@ describe.skipIf(!existsSync(romPath))(
exit(GOLDENROD);
reload();
}, 120_000);
it("meets Floria on Route 36 and gets the SquirtBottle from the flower shop", () => {
traceFailures();
toRoute36();
// ROUTE36_FLORIA is the eighth object in Route36_MapEvents.
talkTo(p.world.map.objects[7].id);
play();
expect(p.world.state.events.has(EVENT_MET_FLORIA)).toBe(true);
toGoldenrod();
enter(FLOWER_SHOP, "up");
// Floria walked here from Route 36; talk to her first
// (EVENT_TALKED_TO_FLORIA_AT_FLOWER_SHOP), then the Teacher gives
// the SquirtBottle since the PLAINBADGE is already held.
talkTo(p.world.map.objects[1].id);
play();
talkTo(p.world.map.objects[0].id);
play([1]);
// The Squirtbottle is a key item (ItemAttributes' pocket byte).
expect(p.world.state.bag.keyItems).toContain(SQUIRTBOTTLE);
expect(p.world.state.events.has(EVENT_GOT_SQUIRTBOTTLE)).toBe(true);
exit(GOLDENROD);
reload();
}, 240_000);
it("waters the weird tree on Route 36 and battles Sudowoodo, then crosses Route 37 to Ecruteak City", () => {
traceFailures();
toRoute36();
const tree = p.world.map.objects[2].id; // ROUTE36_WEIRD_TREE
talkTo(tree);
play([1]);
expect(p.world.state.events.has(EVENT_FOUGHT_SUDOWOODO)).toBe(true);
cross("up", ROUTE_37);
cross("up", ECRUTEAK);
expect(p.world.mapId).toBe(ECRUTEAK);
heal(ECRUTEAK_CENTER, ECRUTEAK);
reload();
}, 240_000);
it("beats all five Kimono Girls for HM03 Surf from the Dance Theater's gentleman", () => {
traceFailures();
enter(DANCE_THEATER, "up");
// The five Kimono Girls are objects 2 through 6 (file order); the
// gentleman who gives Surf is object 7. An already-beaten Kimono
// Girl's trainer header just shows her after-text, so healing
// between fights (five single-Pokémon trainers in a row otherwise
// wear the lead down) is safe to repeat.
for (let id = 2; id <= 6; id++) {
if (lead().hp < lead().stats[0]) {
exit(ECRUTEAK);
heal(ECRUTEAK_CENTER, ECRUTEAK);
enter(DANCE_THEATER, "up");
}
talkTo(id);
play();
}
talkTo(7);
play([1]);
expect(p.world.state.bag.tmsHms[52]).toBe(1); // HM03 (50 TMs + HM03 - 1)
expect(p.world.state.events.has(EVENT_GOT_HM03_SURF)).toBe(true);
exit(ECRUTEAK);
reload();
}, 240_000);
it("meets Eusine and the rival at the Burned Tower, falling to the basement", () => {
traceFailures();
// Stock up on Super Potions first: the rival's Croconaw hits the
// Fire-type lead for super-effective damage on top of Haunter's
// Curse, so a careful player restocks before a hard fight rather
// than going in on whatever is left from Route 36.
enter(ECRUTEAK_MART, "up");
const money = p.world.state.money;
buy(p.world.map.objects[0].id, C.SUPER_POTION, 10);
expect(p.world.state.money).toBe(money - 700 * 10);
expect(p.world.state.bag.items).toContainEqual({ item: C.SUPER_POTION, quantity: 10 });
exit(ECRUTEAK);
enter(BURNED_TOWER_1F, "up");
// BurnedTower1FMeetEusineScene runs automatically on the first idle
// tick (sdefer), as Ecruteak's Pokémon Center's Bill scene does.
expect(p.log.join(" ")).toContain("EUSINE: My name's");
// The only way to any tile by the rival crosses the coordinate event
// at (11, 9); walking there triggers BurnedTowerRivalBattleScript,
// which ends with the player falling to the basement, so the usual
// walk() helper (which expects to stay on this map unless stepping
// onto a listed warp) does not fit here.
// A loss whites out (healed) to Ecruteak's spawn point outside the
// Pokémon Center with the scene still set, so the player tries again.
for (let attempt = 0; attempt < 3; attempt++) {
for (const direction of findPath(p.world, 11, 9)) {
if (!p.world.move(direction)) break;
play();
if (p.world.mapId !== BURNED_TOWER_1F) break;
}
if (p.world.mapId === BURNED_TOWER_B1F) break;
expect(p.world.mapId, "whited out to Ecruteak").toBe(ECRUTEAK);
enter(BURNED_TOWER_1F, "up");
}
expect(p.world.state.events.has(EVENT_RIVAL_BURNED_TOWER)).toBe(true);
expect(p.world.state.events.has(EVENT_HOLE_IN_BURNED_TOWER)).toBe(true);
expect(p.world.mapId).toBe(BURNED_TOWER_B1F);
reload();
}, 240_000);
it("releases the beasts, says goodbye to Eusine and climbs back to Ecruteak", () => {
traceFailures();
// The coordinate event at (10, 6) runs ReleaseTheBeasts: the three
// beasts flee one by one through InitRoamMons, which starts Raikou
// and Entei roaming (Suicune stays put until Cianwood).
walk(10, 6);
play();
expect(p.world.state.events.has(EVENT_RELEASED_THE_BEASTS)).toBe(true);
expect(p.world.state.events.has(EVENT_BURNED_TOWER_MORTY)).toBe(true);
expect(p.world.state.events.has(EVENT_ECRUTEAK_GYM_GRAMPS)).toBe(true);
expect(p.world.state.events.has(EVENT_ECRUTEAK_CITY_GRAMPS)).toBe(false);
expect(p.world.state.roamMons[0]).toMatchObject({ species: RAIKOU, mapId: ROUTE_42 });
expect(p.world.state.roamMons[1]).toMatchObject({ species: ENTEI, mapId: ROUTE_37 });
// Eusine appears once the beasts are gone (BurnedTowerB1F_EUSINE's
// own flag, set by `appear` rather than a scene variable) and says
// goodbye before leaving through the tower's own exit sound.
talkTo(p.world.map.objects[8].id);
play();
expect(p.log.join(" ")).toContain("I'm all choked up!");
// Multiple small stairs lead back to 1F now that the ladder at (6,
// 14) is in place (BurnedTowerB1FLadderCallback); any of them works.
stairs(BURNED_TOWER_1F);
exit(ECRUTEAK);
reload();
}, 120_000);
it("beats Ecruteak Gym's trainers and Morty for the FOGBADGE and TM30", () => {
traceFailures();
// Morty's own Gengar leans on Hypnosis and Dream Eater; a careful
// player also stocks Awakenings, not just Super Potions, before a
// Ghost-type gym.
enter(ECRUTEAK_MART, "up");
buy(p.world.map.objects[0].id, C.AWAKENING, 5);
expect(p.world.state.bag.items).toContainEqual({ item: C.AWAKENING, quantity: 5 });
exit(ECRUTEAK);
// EcruteakGymForcedToLeaveScene (the default scene) no longer plays:
// ReleaseTheBeasts already set SCENE_ECRUTEAKGYM_NOOP for this map.
enter(ECRUTEAK_GYM, "up");
// The trainers stand past Morty's own invisible-floor maze; findPath
// already treats every unlisted pit tile as impassable (the same
// warps the player would fall through), so it finds the real floor
// on its own, the way a player who knows the safe path would.
const [morty, jeffrey, ping, martha, grace] = p.world.map.objects.map(
(object) => object.id,
);
// Ghost types shrug off Normal moves entirely ("No normal-type
// attack can harm them!", straight from Sage Ping's own text), so
// EMBER is the lead's only way to damage any of them; a cautious
// player heals between fights (restoring its PP, not just HP) so
// a long multi-Pokémon trainer never finds EMBER run dry.
for (const trainer of [jeffrey, ping, martha, grace]) {
exit(ECRUTEAK);
heal(ECRUTEAK_CENTER, ECRUTEAK);
enter(ECRUTEAK_GYM, "up");
talkTo(trainer);
play();
}
expect(p.world.state.events.has(EVENT_BEAT_SAGE_JEFFREY)).toBe(true);
expect(p.world.state.events.has(EVENT_BEAT_SAGE_PING)).toBe(true);
expect(p.world.state.events.has(EVENT_BEAT_MEDIUM_MARTHA)).toBe(true);
expect(p.world.state.events.has(EVENT_BEAT_MEDIUM_GRACE)).toBe(true);
exit(ECRUTEAK);
heal(ECRUTEAK_CENTER, ECRUTEAK);
enter(ECRUTEAK_GYM, "up");
talkTo(morty);
play([1]);
expect(p.world.state.events.has(EVENT_BEAT_MORTY), p.log.join("|")).toBe(true);
expect(p.world.state.flags.has(ENGINE_FOGBADGE)).toBe(true);
expect(p.log.join(" ")).toContain("FOGBADGE");
// MortyText_FogBadgeSpeech and the TM30 Shadow Ball gift follow in
// the same conversation (EVENT_GOT_TM30_SHADOW_BALL was not yet set).
expect(p.world.state.events.has(EVENT_GOT_TM30_SHADOW_BALL)).toBe(true);
expect(p.world.state.bag.tmsHms[29]).toBe(1); // TM30
expect(p.log.join(" ")).toContain("SHADOW BALL");
exit(ECRUTEAK);
reload();
}, 240_000);
},
);
+420
View File
@@ -0,0 +1,420 @@
import { describe, expect, it } from "vitest";
import { existsSync, readFileSync } from "node:fs";
import { CrystalAssets } from "../src/game/rom/assets";
import { RomReader } from "../src/game/rom/reader";
import { OVERWORLD } from "../src/game/rom/profile";
import { GameWorld, migrateSave } from "../src/game/world/world";
import {
ScriptRun,
emptyScriptState,
type ScriptHost,
} from "../src/game/scripts/interpreter";
import { PokemonData } from "../src/game/rom/pokemon";
import { defaultTextContext } from "../src/game/scripts/text";
import { generatePartyMon } from "../src/game/party/pokemon";
import { C } from "../src/game/battle/constants";
import type { RoamMon } from "../src/game/battle/battle";
import {
initRoamMons,
checkEncounterRoamMon,
updateRoamMons,
jumpRoamMons,
} from "../src/game/world/roam-mons";
import { afterRestore } from "./world-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
// RAIKOU, ENTEI (constants/pokemon_constants.asm).
const RAIKOU = 243,
ENTEI = 244,
SUICUNE = 245;
// Open Johto routes that are also RoamMaps entries (confirmed against the
// ROM below): Route 29, Route 32 and Route 33, which directly connect.
const ROUTE_29 = "24:3",
ROUTE_32 = "10:1",
ROUTE_33 = "8:6";
// InitRoamMons' own starting maps: Route 42 and Route 37 (not open, but
// decodable like any of the ROM's 26 map groups).
const ROUTE_42 = "2:5",
ROUTE_37 = "10:4";
/** special InitRoamMons' index in SpecialsPointers (data/events/special_pointers.asm). */
const INIT_ROAM_MONS_SPECIAL = 105;
function assets() {
return new CrystalAssets(new Uint8Array(readFileSync(romPath)));
}
/** Every map RoamMaps lists, read straight from the ROM: a roaming beast's
* new position must always be one of these sixteen. */
function roamMapIds(r: RomReader): Set<string> {
const ids = new Set<string>();
let p = OVERWORLD.RoamMaps;
while (r.byte(p) !== 0xff) {
ids.add(`${r.byte(p)}:${r.byte(p + 1)}`);
p += 4 + 2 * r.byte(p + 2);
}
return ids;
}
const emptyRoamer = (): RoamMon => ({ species: 0, level: 0, mapId: null, hp: 0, dvs: 0 });
describe.skipIf(!existsSync(romPath))("roaming Pokémon (Raikou, Entei, Suicune)", () => {
describe("the RoamMaps graph (direct ROM reads)", () => {
it("lists Route 29, Route 32 and Route 33 with Route 32 between the other two", () => {
const r = assets().reader;
const ids = roamMapIds(r);
expect(ids).toContain(ROUTE_29);
expect(ids).toContain(ROUTE_32);
expect(ids).toContain(ROUTE_33);
expect(ids.size).toBe(16);
});
});
describe("initRoamMons", () => {
it("starts Raikou on Route 42 and Entei on Route 37 at level 40 with HP 0, leaving Suicune untouched", () => {
const suicune: RoamMon = { species: SUICUNE, level: 12, mapId: "1:1", hp: 7, dvs: 0x55aa };
const result = initRoamMons([emptyRoamer(), emptyRoamer(), suicune]);
expect(result[0]).toEqual({ species: RAIKOU, level: 40, mapId: ROUTE_42, hp: 0, dvs: 0 });
expect(result[1]).toEqual({ species: ENTEI, level: 40, mapId: ROUTE_37, hp: 0, dvs: 0 });
expect(result[2]).toEqual(suicune);
});
it("runs through the real special dispatch (special 105), as the ported interpreter resolves it", () => {
// special InitRoamMons; end, placed in an unused bank-0 spot, exactly
// as tests/zz-specials.local.test.ts probes every special routine.
const rom = new Uint8Array(new Uint8Array(readFileSync(romPath)));
rom.set(
[0x0f, INIT_ROAM_MONS_SPECIAL & 255, INIT_ROAM_MONS_SPECIAL >> 8, 0x91],
0x3ff0,
);
const reader = new RomReader(rom);
const host: ScriptHost = {
mapId: ROUTE_29,
lastTalked: 0,
sceneOwner: () => undefined,
timeOfDay: () => 1,
clock: () => ({ weekday: 0, hours: 12, minutes: 0 }),
clockDay: () => 0,
text: () => defaultTextContext("A"),
data: new PokemonData(reader),
player: () => ({ id: 1, gender: 0 }),
random: () => 0,
landmark: () => 0,
facing: () => 0,
position: () => ({ x: 0, y: 0 }),
objectPosition: () => undefined,
playerCollision: () => 0,
environment: () => 0,
phoneService: () => 0,
facingObject: () => undefined,
};
const state = emptyScriptState();
state.roamMons[2] = { species: SUICUNE, level: 5, mapId: "1:1", hp: 9, dvs: 0x1234 };
const run = new ScriptRun(reader, 0x3ff0, state, host);
for (let i = 0; i < 10 && run.resume(0); i++);
expect(run.state.roamMons[0]).toEqual({ species: RAIKOU, level: 40, mapId: ROUTE_42, hp: 0, dvs: 0 });
expect(run.state.roamMons[1]).toEqual({ species: ENTEI, level: 40, mapId: ROUTE_37, hp: 0, dvs: 0 });
expect(run.state.roamMons[2]).toEqual({ species: SUICUNE, level: 5, mapId: "1:1", hp: 9, dvs: 0x1234 });
});
});
describe("checkEncounterRoamMon", () => {
it("draws once: the top 100 values never encounter, a draw & 3 == 0 never encounters, and the rest pick Raikou/Entei/Suicune by index", () => {
const roamMons: RoamMon[] = [
{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 },
{ species: ENTEI, level: 40, mapId: "9:9", hp: 0, dvs: 0 },
{ species: SUICUNE, level: 40, mapId: "9:9", hp: 0, dvs: 0 },
];
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 100)).toBeUndefined();
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 0)).toBeUndefined();
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 1)).toBe(roamMons[0]);
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 2)).toBeUndefined();
expect(checkEncounterRoamMon(roamMons, ROUTE_29, () => 3)).toBeUndefined();
});
});
describe("UpdateRoamMons and JumpRoamMons (direct ROM reads)", () => {
it("picks a random connection of the beast's own RoamMaps entry, redrawing an out-of-range index", () => {
const r = assets().reader;
// Route 32 (10:1) lists three neighbors in this order: Route 36
// (10:3), Route 31 (26:2), Route 33 (8:6). The first draw's index (3)
// is past the connection count and must be redrawn.
const draws = [3, 4];
const moved = updateRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
null,
"9:9",
() => draws.shift()!,
);
expect(moved[0].mapId).toBe("10:3");
});
it("redraws when the chosen connection repeats the last map it was on", () => {
const r = assets().reader;
const draws = [2, 1]; // index 2 (Route 33) repeats lastMapId, then index 1 (Route 31).
const moved = updateRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
ROUTE_33,
"9:9",
() => draws.shift()!,
);
expect(moved[0].mapId).toBe("26:2");
});
it("a 1-in-32 draw jumps instead, avoiding the player's own map rather than the beast's", () => {
const r = assets().reader;
// draw & 0x1f == 0 triggers JumpRoamMon; its own index draw (5) lands
// on RoamMaps entry 5 (Route 34, 11:1), which is not the player's map.
const draws = [0, 5];
const moved = updateRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 }],
null,
"9:9",
() => draws.shift()!,
);
expect(moved[0].mapId).toBe("11:1");
});
it("leaves a beast on an unlisted map untouched and draws nothing", () => {
const r = assets().reader;
const moved = updateRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: "9:9", hp: 0, dvs: 0 }],
null,
"9:9",
() => {
throw new Error("should not draw");
},
);
expect(moved[0].mapId).toBe("9:9");
});
it("skips a beast whose map is null (GROUP_N_A), drawing nothing", () => {
const r = assets().reader;
const moved = updateRoamMons(r, [emptyRoamer()], null, "9:9", () => {
throw new Error("should not draw");
});
expect(moved[0].mapId).toBeNull();
});
it("JumpRoamMons picks uniformly among the 16 listed maps, redrawing only the player's own map", () => {
const r = assets().reader;
// Entry 0 is Route 29 itself, the player's map: redraw. Entry 1 is
// Route 30 (26:1).
const draws = [0, 1];
const jumped = jumpRoamMons(
r,
[{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 }],
ROUTE_29,
() => draws.shift()!,
);
expect(jumped[0].mapId).toBe("26:1");
});
});
describe("map transitions hook UpdateRoamMons/JumpRoamMons", () => {
it("a map connection crossing moves an active beast and records the map", () => {
const world = new GameWorld(assets());
// Column x=7 runs clear from y=86 to the map's southern edge (y=90),
// which connects straight into Route 33.
world.warpTo(ROUTE_32, 7, 86);
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 };
let crossed = false;
for (let tries = 0; tries < 6 && !crossed; tries++)
crossed = world.move("down") && world.mapId === ROUTE_33;
expect(crossed).toBe(true);
expect(world.state.roamMonsMap).toBe(ROUTE_33);
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
});
it("a door warp moves an active beast too (MAPSETUP_DOOR falls through into MAPSETUP_TRAIN's UpdateRoamMons)", () => {
const world = new GameWorld(assets());
const warp = world.assets.map(ROUTE_32).warps.find((w) => w.targetMap === "10:13")!;
world.warpTo(ROUTE_32, warp.x, warp.y - 1);
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 };
expect(world.move("down")).toBe(true);
expect(world.mapId).toBe("10:13");
expect(world.state.roamMonsMap).toBe("10:13");
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
});
it("continuing a save (JumpRoamMons) relocates an active beast off the loaded map", () => {
const world = new GameWorld(assets());
const saved = world.save();
saved.map = ROUTE_32;
saved.x = 10;
saved.y = 80;
saved.roamMons = [
{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 },
emptyRoamer(),
emptyRoamer(),
];
world.restore(saved);
expect(world.state.roamMonsMap).toBe(ROUTE_32);
expect(world.state.roamMons[0].mapId).not.toBe(ROUTE_32);
expect(roamMapIds(world.assets.reader)).toContain(world.state.roamMons[0].mapId);
});
it("a rollback restore (not a Continue) leaves the beasts where they were", () => {
const world = new GameWorld(assets());
const saved = world.save();
saved.map = ROUTE_32;
saved.x = 10;
saved.y = 80;
saved.roamMons = [
{ species: RAIKOU, level: 40, mapId: ROUTE_32, hp: 0, dvs: 0 },
emptyRoamer(),
emptyRoamer(),
];
world.restore(saved, false);
expect(world.save()).toEqual(saved);
});
});
describe("a roaming encounter", () => {
/** An open Johto route (Route 29) with a forced, deterministic
* encounter rate: the roam check itself still runs for real. */
function roamEncounterWorld() {
const a = assets();
a.grassCollisions = () => Array.from({ length: 256 }, (_, i) => i);
a.grassWildMons = (id) =>
id === ROUTE_29
? {
rates: [256, 256, 256],
slots: [0, 1, 2].map(() => Array.from({ length: 7 }, () => ({ species: 16, level: 3 }))),
}
: undefined;
let mode: "fixed" | "lcg" = "fixed";
let seed = 7;
const world = new GameWorld(
a,
() => new Date("2026-09-22T12:00:00"),
() => {
if (mode === "fixed") return 1;
seed = (seed * 1103515245 + 12345) >>> 0;
return (seed >>> 16) & 255;
},
);
const mon = generatePartyMon(a.pokemon, {
species: 155, level: 30, item: 0, otId: world.playerId,
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
});
world.state.party = [mon];
world.state.roamMons[0] = { species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 0, dvs: 0 };
world.warpTo(ROUTE_29, 30, 9);
return { world, setMode: (next: "fixed" | "lcg") => (mode = next) };
}
/** Walk several steps in whichever direction is open, without depending
* on the route's exact terrain: SetUpFiveStepWildEncounterCooldown
* skips the encounter check on the first five steps after any map
* entry, so a single step is never enough to reach it. */
function stepSomewhere(world: GameWorld, steps = 6): boolean {
for (let i = 0; i < steps; i++) {
if (world.battle) return true;
let moved = false;
for (const direction of ["down", "right", "up", "left"] as const)
if (world.move(direction)) {
moved = true;
break;
}
if (!moved) return false;
}
return true;
}
it("CheckEncounterRoamMon starts a BATTLETYPE_ROAMING battle naming the beast on this map", () => {
const { world } = roamEncounterWorld();
expect(stepSomewhere(world)).toBe(true);
expect(world.notice, world.diagnostic).toBe("");
expect(world.battle).toBeDefined();
const engine = world.battle!.engine;
expect(engine.battleType).toBe(C.BATTLETYPE_ROAMING);
expect(engine.enemy.mon.species).toBe(RAIKOU);
});
it("catching it with a Master Ball clears the roaming slot and adds it to the party", () => {
const { world, setMode } = roamEncounterWorld();
world.state.bag.balls = [{ item: C.MASTER_BALL, quantity: 1 }];
expect(stepSomewhere(world)).toBe(true);
expect(world.battle).toBeDefined();
setMode("lcg");
for (let tick = 0; tick < 20_000 && world.busy; tick++) {
const view = world.battle;
if (!view) {
if (world.dialogue) world.advanceDialogue();
else world.tick();
continue;
}
const prompt = view.prompt;
if (prompt?.kind === "action")
world.battleAction({ action: "item", item: C.MASTER_BALL, slot: 0 });
else if (prompt?.kind === "yesno") world.battleChoose(0);
else if (view.message?.prompt) world.interact();
else world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.party.some((p) => p.species === RAIKOU)).toBe(true);
expect(world.state.roamMons[0]).toMatchObject({ species: 0, mapId: null, hp: 0 });
});
});
describe("save format", () => {
it("round-trips a beast's species, level, HP and DVs (the map itself may jump on continue)", () => {
const world = new GameWorld(assets());
world.state.roamMons = [
{ species: RAIKOU, level: 40, mapId: ROUTE_29, hp: 123, dvs: 0xabcd },
emptyRoamer(),
emptyRoamer(),
];
const saved = world.save();
world.restore(saved);
const [raikou, entei, suicune] = world.state.roamMons;
expect({ species: raikou.species, level: raikou.level, hp: raikou.hp, dvs: raikou.dvs }).toEqual(
{ species: RAIKOU, level: 40, hp: 123, dvs: 0xabcd },
);
expect(raikou.mapId).not.toBeNull();
expect(entei.mapId).toBeNull();
expect(suicune.mapId).toBeNull();
});
it("round-trips through save()/restore() with afterRestore's allowance", () => {
const world = new GameWorld(assets());
world.state.roamMons[1] = { species: ENTEI, level: 40, mapId: ROUTE_37, hp: 200, dvs: 0x4242 };
const saved = world.save();
world.restore(saved);
const reloaded = world.save();
expect(reloaded).toEqual(afterRestore(saved, reloaded));
});
it("rejects a save with an invalid roamMons entry", () => {
const world = new GameWorld(assets());
const saved = world.save();
const bad = {
...saved,
roamMons: [{ species: 999, level: 0, mapId: null, hp: 0, dvs: 0 }, emptyRoamer(), emptyRoamer()],
};
expect(() => world.restore(bad)).toThrow("Invalid native save");
});
it("rejects a save missing a roam slot", () => {
const world = new GameWorld(assets());
const saved = world.save();
const bad = { ...saved, roamMons: [emptyRoamer(), emptyRoamer()] };
expect(() => world.restore(bad)).toThrow("Invalid native save");
});
it("migrates an older save to three empty, GROUP_N_A roam slots", () => {
const world = new GameWorld(assets());
const saved = world.save() as Record<string, unknown>;
delete saved.roamMons;
delete saved.roamMonsMap;
const migrated = migrateSave(saved, () => 1);
expect(migrated?.roamMons).toEqual([emptyRoamer(), emptyRoamer(), emptyRoamer()]);
expect(migrated?.roamMonsMap).toBeNull();
expect(() => world.restore(migrated)).not.toThrow();
});
});
});
+21
View File
@@ -79,6 +79,15 @@ const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" } as con
const edges = (tile: number): readonly Direction[] =>
(tile & 0xf0) === 0xb0 || (tile & 0xf0) === 0xc0 ? EDGES[tile & 7] : [];
/** CheckWarpCollision's own two hazard nybbles ($6x pits, $7x doors/
* stairs/carpets): a tile only needs avoiding here if stepping onto it
* could actually trigger a warp. Burned Tower B1F's fall/ladder warps
* (e.g. the coordinates `warpcheck` and a `changeblock`-revealed ladder
* read) sit on ordinary $00 floor and never fire from an ordinary step,
* so they are left walkable like any other floor tile. */
const isWarpHazard = (tile: number) =>
(tile & 0xf0) === 0x60 || (tile & 0xf0) === 0x70;
/** Shortest walkable path on the current map, avoiding other warps. */
export function findPath(
world: GameWorld,
@@ -108,6 +117,7 @@ export function findPath(
if (world.objects().some((object) => object.x === x && object.y === y))
continue;
if (
isWarpHazard(tile) &&
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== targetX || y !== targetY)
)
@@ -158,3 +168,14 @@ export function withoutPlayTime<T extends { gameTime?: unknown }>(save: T) {
const { gameTime: _, ...rest } = save;
return rest;
}
/** Restoring a save always moves the roam-mon bookkeeping to the map just
* loaded (Continue's own farcall JumpRoamMons, then _BackUpMapIndices),
* even before any roaming Pokémon has been released; a round-trip
* comparison needs `reloaded`'s own `roamMons`/`roamMonsMap` in place of
* `saved`'s to still match everything else exactly. */
export function afterRestore<
T extends { roamMons?: unknown; roamMonsMap?: unknown },
>(saved: T, reloaded: T) {
return { ...saved, roamMons: reloaded.roamMons, roamMonsMap: reloaded.roamMonsMap };
}