Files
Crystal/tests/overworld-rom.test.ts
T
Sébastien QUEROLandClaude Opus 5.5 3456aa4e11
Checks / web (push) Canceled after 0s
Port field moves from the menu, SEL with Select, and moving Pokémon in Bill's PC
Field moves chosen in the party menu run their original checks and the
ROM's from-menu scripts (Cut, Surf, Strength, Flash, Whirlpool, Waterfall,
Sweet Scent), and Softboiled and Milk Drink share HP. SEL registers a key
item that Select then uses, replacing the Key Items shortcut, and Bill's
PC moves Pokémon between the party and every box.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:22:56 +02:00

619 lines
27 KiB
TypeScript

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 { emptyScriptState } from "../src/game/scripts/interpreter";
import { generatePartyMon } from "../src/game/party/pokemon";
import type { Direction } from "../src/game/world/types";
import { settle, walkTo } from "./world-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const BURNED_TOWER_1F = "3:13",
BURNED_TOWER_B1F = "3:14";
const EVENT_HOLE_IN_BURNED_TOWER = 818;
/** A world on maps that normal play does not enable yet. */
function fixture(maps: string[]) {
const assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || maps.includes(id);
const world = new GameWorld(assets, () => new Date("2026-09-22T12:00:00"), () => 0);
world.state = emptyScriptState();
return { assets, world };
}
function hold(world: GameWorld, direction: Direction | undefined, ticks: number) {
for (let tick = 0; tick < ticks; tick++) world.tick(direction);
}
describe.skipIf(!existsSync(romPath))("overworld foundation with the local ROM", () => {
it("changeblock in Burned Tower's TILES callback closes the hole until the event is set", () => {
const { assets, world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
world.state.scenes[BURNED_TOWER_1F] = 2; // SCENE_BURNEDTOWER1F_NOOP
world.warpTo(BURNED_TOWER_1F, 10, 10);
expect(assets.permission(assets.collision(world.map, 10, 9))).toBe(0);
expect(assets.collision(world.map, 10, 9)).not.toBe(0x60);
// The ROM's own block data still has the hole; only the working copy changed.
expect(assets.collision(assets.map(BURNED_TOWER_1F), 10, 9)).toBe(0x60);
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
world.warpTo(BURNED_TOWER_1F, 10, 10);
expect(assets.collision(world.map, 10, 9)).toBe(0x60);
});
it("falls through a pit with FallIntoMapScript's skyfall and earthquake", () => {
const { world } = fixture([BURNED_TOWER_1F, BURNED_TOWER_B1F]);
world.state.scenes[BURNED_TOWER_1F] = 2;
world.state.scenes[BURNED_TOWER_B1F] = 1;
world.state.events.add(EVENT_HOLE_IN_BURNED_TOWER);
world.warpTo(BURNED_TOWER_1F, 10, 10);
// Turn, then walk up onto the hole.
hold(world, "up", 4);
hold(world, "up", 8);
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_1F, 10, 9]);
// The arrival tick falls through; the next starts the rest of the script.
hold(world, undefined, 2);
expect(world.notice, world.diagnostic).toBe("");
expect([world.mapId, world.x, world.y]).toEqual([BURNED_TOWER_B1F, 10, 9]);
expect(world.activity).toBe("scene");
// Invisible above the screen at first, then falling into view.
expect(world.poses().some((pose) => pose.id === 0)).toBe(false);
let shook = false;
for (let tick = 0; tick < 200 && world.busy; tick++) {
world.tick();
if (world.screenShake) shook = true;
}
expect(world.busy).toBe(false);
expect(shook).toBe(true);
expect(world.notice, world.diagnostic).toBe("");
expect(world.poses().find((pose) => pose.id === 0)).toMatchObject({ x: 160, y: 144 });
});
it("rides the Goldenrod Dept. Store elevator through its menu and -1 warps", () => {
const [store1F, store3F, elevator] = ["11:11", "11:13", "11:17"];
const { world } = fixture([store1F, store3F, elevator]);
world.warpTo(store1F, 2, 1);
walkTo(world, 2, 0);
expect([world.mapId, world.x, world.y]).toEqual([elevator, 1, 3]);
// The arrival warp is numbered -1, so the entrance became the backup.
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
walkTo(world, 3, 1);
world.move("up");
world.interact();
const menu = world.dialogue!;
const shown = settle(world, [3]);
expect(shown.join("\n")).toContain("Now on: 1F");
expect(menu.choice).toMatchObject({ kind: "menu" });
expect((menu.choice as { items: string[] }).items).toEqual([
"B1F", "1F", "2F", "3F", "4F", "5F", "6F",
]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.backupWarp).toEqual({ map: store3F, warp: 3 });
walkTo(world, 2, 3);
world.move("down");
settle(world);
// Arrival at warp 3 (2, 0), then the doorway's forced step down.
expect([world.mapId, world.x, world.y]).toEqual([store3F, 2, 1]);
expect(world.notice, world.diagnostic).toBe("");
});
it("keeps the floor when the elevator menu is cancelled or the same floor is chosen", () => {
const [store1F, elevator] = ["11:11", "11:17"];
const { world } = fixture([store1F, elevator]);
world.warpTo(store1F, 2, 1);
walkTo(world, 2, 0);
walkTo(world, 3, 1);
world.move("up");
for (const answer of [-1, 1]) {
world.interact();
settle(world, [answer]);
expect(world.notice, world.diagnostic).toBe("");
expect(world.state.backupWarp).toEqual({ map: store1F, warp: 4 });
}
});
describe("wild encounters (CanEncounterWildMon and ChooseWildEncounter)", () => {
const UNION_CAVE_1F = "3:37";
const cave = () => {
const { assets, world } = fixture([UNION_CAVE_1F]);
world.state.party = [
generatePartyMon(assets.pokemon, {
species: 155, level: 20, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
}),
];
return { assets, world };
};
/** Walk back and forth until a battle starts or the steps run out. */
const pace = (world: GameWorld, steps: number, directions: Direction[]) => {
for (let step = 0; step < steps && !world.battle; step++) {
world.move(directions[step % directions.length]);
for (let tick = 0; tick < 4 && !world.battle; tick++) world.tick();
}
return world.battle?.engine;
};
it("starts on any cave floor after the five-step cooldown", () => {
const { world } = cave();
world.warpTo(UNION_CAVE_1F, 9, 6);
const battle = pace(world, 12, ["right", "left"]);
expect(world.notice, world.diagnostic).toBe("");
expect(battle).toBeDefined();
expect(world.tilesWalked).toBe(5);
});
it("stays off with wildoff", () => {
const { world } = cave();
world.state.noWildEncounters = true;
world.warpTo(UNION_CAVE_1F, 9, 6);
expect(pace(world, 12, ["right", "left"])).toBeUndefined();
});
it("uses the water table's rate and slots while surfing", () => {
const { assets, world } = cave();
const sea = assets.waterWildMons(UNION_CAVE_1F)!;
expect(sea).toBeDefined();
world.state.playerState = 4; // PLAYER_SURF
world.restore({ ...world.save(), map: UNION_CAVE_1F, x: 7, y: 4, direction: "right", objects: [] });
const battle = pace(world, 12, ["right", "left"]);
expect(battle).toBeDefined();
// Random is always 0: the first slot, with no level variety.
expect(battle!.enemy.mon).toMatchObject({
species: sea.slots[0].species,
level: sea.slots[0].level,
});
});
});
it("draws overworld Pokémon from their party menu icons (GetMonSprite)", () => {
const { assets } = fixture([]);
const unown = assets.sprite(0x80)!; // SPRITE_UNOWN
expect(unown.walking).toBe(true);
expect(unown.palette).toBe(0);
expect(unown.graphics).toHaveLength(8 * 16);
expect(assets.sprite(0x80 + 35)).toBeUndefined();
expect(assets.sprite(103)).toBeUndefined();
});
it("surfing onto a whirlpool spins the player back with Script_ForcedMovement", () => {
const { assets, world } = fixture([]);
// Find a whirlpool with water on the side a surfer comes from.
let found: { map: string; x: number; y: number } | undefined;
for (let group = 1; group <= 26 && !found; group++)
for (let number = 1; number <= 40 && !found; number++) {
let map;
try {
map = assets.map(`${group}:${number}`);
} catch {
continue;
}
for (let y = 1; y < map.height - 1 && !found; y++)
for (let x = 0; x < map.width && !found; x++)
if (
assets.collision(map, x, y) === 0x24 &&
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
assets.permission(assets.collision(map, x, y + 2)) === 1 &&
!map.objects.some((o) => o.x === x && (o.y === y + 1 || o.y === y + 2))
)
found = { map: map.id, x, y };
}
expect(found).toBeDefined();
const { map, x, y } = found!;
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === map;
world.state.playerState = 4; // PLAYER_SURF
world.restore({ ...world.save(), map, x, y: y + 2, direction: "up", objects: [] });
hold(world, "up", 8);
expect([world.x, world.y]).toEqual([x, y + 1]);
hold(world, "up", 9);
expect([world.x, world.y]).toEqual([x, y]);
expect(world.activity).toBe("scene");
const directions = new Set<string>();
for (let tick = 0; tick < 120 && world.busy; tick++) {
world.tick();
directions.add(world.direction);
}
expect(world.notice, world.diagnostic).toBe("");
// .MovementData_up: spin, turn_in DOWN back off the whirlpool, spin,
// then face down.
expect([world.x, world.y, world.direction]).toEqual([x, y + 1, "down"]);
expect(directions.size).toBe(4);
});
});
describe.skipIf(!existsSync(romPath))("field moves with the local ROM", () => {
const TOTODILE = 158;
const partyWith = (assets: CrystalAssets, moves: number[], species = TOTODILE) => {
const mon = generatePartyMon(assets.pokemon, {
species, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
});
mon.moves = [...moves, 0, 0, 0, 0].slice(0, 4) as typeof mon.moves;
mon.pp = [10, 10, 10, 10];
mon.nickname = "SWIMMY";
return [mon];
};
/** Find a tile on some map, searching every group. */
const find = (
assets: CrystalAssets,
test: (map: ReturnType<CrystalAssets["map"]>, x: number, y: number) => boolean,
) => {
for (let group = 1; group <= 26; group++)
for (let number = 1; number <= 60; number++) {
let map;
try {
map = assets.map(`${group}:${number}`);
} catch {
continue;
}
for (let y = 1; y < map.height - 1; y++)
for (let x = 1; x < map.width - 1; x++) if (test(map, x, y)) return { map: map.id, x, y };
}
throw new Error("No suitable tile.");
};
it("surfs from New Bark Town's shore through AskSurfScript and lands again", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]); // SURF
world.state.flags.add(30); // ENGINE_FOGBADGE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSURF!");
expect(world.state.playerState).toBe(4);
expect([world.x, world.y]).toEqual([18, 7]);
// The surfer sprite (SPRITE_SURF) replaces Chris.
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(0x53);
// Water saves and reloads with the surfer on it.
world.restore(world.save());
expect(world.state.playerState).toBe(4);
world.move("left");
expect([world.x, world.y, world.state.playerState]).toEqual([17, 7, 0]);
});
it("does not offer Surf without the Fog Badge or a Pokémon that knows it", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]);
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
world.interact();
expect(world.dialogue).toBeUndefined();
world.state.flags.add(30);
world.state.party = partyWith(assets, [0x0f]);
world.interact();
expect(world.dialogue).toBeUndefined();
});
it("cuts a tree with AskCutScript until the map loads again", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
[1, 2, 3, 12, 25].includes(map.tileset) &&
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x0f]); // CUT
world.state.flags.add(28); // ENGINE_HIVEBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
const before = world.map.blocks.slice();
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nCUT!");
expect(world.map.blocks).not.toEqual(before);
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).toBe(0);
world.move("up");
expect([world.x, world.y]).toEqual([spot.x, spot.y]);
world.warpTo(spot.map, spot.x, spot.y + 1);
expect(world.map.blocks).toEqual(before);
});
it("explains a Cut tree without the move", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x21]);
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown.length).toBeGreaterThan(0);
expect(assets.permission(assets.collision(world.map, spot.x, spot.y))).not.toBe(0);
});
it("uses Strength through AskStrengthScript and pushes a boulder", () => {
const { assets, world } = fixture([]);
// A Strength boulder object with floor below it and two floor tiles above.
const spot = find(assets, (map, x, y) => {
const boulder = map.objects.find(
(o) => o.x === x && o.y === y && o.movement === 25 && o.eventFlag === 0xffff,
);
return Boolean(
boulder &&
map.callbacks.length === 0 &&
[y + 1, y - 1, y - 2].every(
(row) =>
assets.permission(assets.collision(map, x, row)) === 0 &&
!map.objects.some((o) => o !== boulder && o.x === x && o.y === row),
),
);
});
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x46]); // STRENGTH
world.state.flags.add(29); // ENGINE_PLAINBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "up" });
world.interact();
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSTRENGTH!");
expect(world.state.flags.has(24)).toBe(true); // ENGINE_STRENGTH_ACTIVE
for (let tick = 0; tick < 20; tick++) world.tick("up");
const boulder = world.objects().find((o) => o.movement === 25 && o.x === spot.x);
expect(boulder?.y).toBe(spot.y - 1);
// Talking to it again: "Boulders may now be moved!"
settle(world);
});
it("rides the Bicycle on a route and gets off indoors", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x21]);
world.state.bag.keyItems.push(7); // BICYCLE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
expect(world.useBicycle()).toBe(true);
const shown = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("got on the\nBICYCLE.");
expect(world.state.playerState).toBe(1);
expect(world.poses().find((pose) => pose.id === 0)?.sprite).toBe(2); // SPRITE_CHRIS_BIKE
// Turn left (four ticks), then four ticks per tile on the bike.
for (let tick = 0; tick < 4; tick++) world.tick("left");
world.tick("left");
expect(world.x).toBe(12);
for (let tick = 0; tick < 4; tick++) world.tick("left");
expect(world.x).toBe(11);
// Entering the house (indoors) gets the player off (CheckUpdatePlayerSprite).
settle(world);
walkTo(world, 13, 5);
expect(world.mapId).toBe("24:6");
expect(world.state.playerState).toBe(0);
expect(world.useBicycle()).toBe(false);
});
it("offers Whirlpool and Waterfall only with the move and badge", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x39]);
const whirlpool = find(assets, (map, x, y) =>
assets.collision(map, x, y) === 0x24 &&
assets.permission(assets.collision(map, x, y + 1)) === 1 &&
!map.objects.some((o) => o.x === x && o.y === y + 1),
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === whirlpool.map;
world.state.playerState = 4;
world.restore({ ...world.save(), map: whirlpool.map, x: whirlpool.x, y: whirlpool.y + 1, direction: "up", objects: [] });
world.interact();
const mighty = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(mighty.length).toBeGreaterThan(0);
world.state.party = partyWith(assets, [0x39, 250]); // SURF, WHIRLPOOL
world.state.flags.add(33); // ENGINE_GLACIERBADGE
const before = world.map.blocks.slice();
world.interact();
const used = settle(world).join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(used).toContain("used\nWHIRLPOOL!");
expect(world.map.blocks).not.toEqual(before);
});
/** Start → POKéMON → the first Pokémon → `move`; returns the pages
* shown until the menu closed or a choice (the party menu) returned. */
const useFromMenu = (world: GameWorld, move: string) => {
expect(world.openStartMenu()).toBe(true);
const choose = (label: string) => {
const choice = world.dialogue?.choice;
if (choice?.kind !== "menu") throw new Error(`No menu for ${label}`);
const index = choice.items.findIndex((item) => item.startsWith(label));
expect(index, `${label} in ${choice.items}`).toBeGreaterThanOrEqual(0);
world.choose(index);
};
choose("POKéMON");
choose("SWIMMY");
choose(move);
const pages: string[] = [];
for (let i = 0; i < 50 && world.dialogue && !world.dialogue.choice; i++) {
pages.push(world.dialogue.pages[world.dialogue.index]);
world.advanceDialogue();
}
return pages;
};
/** B out of every menu, then let any queued script finish. */
const closeMenus = (world: GameWorld) => {
for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue();
return settle(world).join("\n");
};
it("surfs from the party menu, after the badge and facing checks", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x0f, 0x39]); // CUT, SURF
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 17, y: 7, direction: "right", objects: [] });
expect(world.dialogue).toBeUndefined();
// CUT and SURF come first in the submenu.
expect(useFromMenu(world, "SURF")).toEqual(["Sorry! A new BADGE\nis required."]);
closeMenus(world);
world.state.flags.add(30); // ENGINE_FOGBADGE
world.restore({ ...world.save(), direction: "up" });
expect(useFromMenu(world, "SURF")).toEqual(["You can't SURF\nhere."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "right" });
const shown = [...useFromMenu(world, "SURF"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
// No "Want to SURF?" question from the menu.
expect(shown).not.toContain("?");
expect(shown).toContain("SWIMMY used\nSURF!");
expect([world.x, world.y, world.state.playerState]).toEqual([18, 7, 4]);
expect(useFromMenu(world, "SURF")).toEqual(["You're already\nSURFING."]);
closeMenus(world);
});
it("cuts from the party menu, or finds nothing to cut", () => {
const { assets, world } = fixture([]);
const spot = find(assets, (map, x, y) =>
[1, 2, 3, 12, 25].includes(map.tileset) &&
assets.collision(map, x, y) === 0x12 &&
assets.permission(assets.collision(map, x, y + 1)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y + 1) &&
map.callbacks.length === 0,
);
const ported = assets.isPorted.bind(assets);
assets.isPorted = (id) => ported(id) || id === spot.map;
world.state.party = partyWith(assets, [0x0f]);
world.state.flags.add(28); // ENGINE_HIVEBADGE
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "down" });
expect(useFromMenu(world, "CUT")).toEqual(["There's nothing to\nCUT here."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "up" });
const before = world.map.blocks.slice();
const shown = [...useFromMenu(world, "CUT"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nCUT!");
expect(world.map.blocks).not.toEqual(before);
});
it("uses Strength from the party menu", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0x46]);
world.state.flags.add(29); // ENGINE_PLAINBADGE
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
const shown = [...useFromMenu(world, "STRENGTH"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("SWIMMY used\nSTRENGTH!");
expect(world.state.flags.has(24)).toBe(true); // ENGINE_STRENGTH_ACTIVE
});
it("lights a dark cave with Flash, which fails elsewhere", () => {
// Dark Cave's Violet entrance (PALETTE_DARK).
const DARK_CAVE = "3:78";
const { assets, world } = fixture([DARK_CAVE]);
const map = assets.map(DARK_CAVE);
expect(map.paletteMode).toBe(4);
let spot = { map: DARK_CAVE, x: 0, y: 0 };
search: for (let y = 1; y < map.height - 1; y++)
for (let x = 1; x < map.width - 1; x++)
if (
assets.permission(assets.collision(map, x, y)) === 0 &&
!map.objects.some((o) => o.x === x && o.y === y)
) {
spot = { map: DARK_CAVE, x, y };
break search;
}
world.state.party = partyWith(assets, [0x94]); // FLASH
world.state.flags.add(27); // ENGINE_ZEPHYRBADGE
world.warpTo(spot.map, spot.x, spot.y);
expect(world.timeOfDay).toBe(3);
const shown = [...useFromMenu(world, "FLASH"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(shown).toContain("A blinding FLASH\nlights the area!");
expect(world.state.flash).toBe(true);
expect(world.timeOfDay).toBe(2);
expect(useFromMenu(world, "FLASH")).toEqual(["Can't use that\nhere."]);
closeMenus(world);
});
it("recognises a smashable rock for Rock Smash, which is not ported further", () => {
// A rock (SPRITEMOVEDATA_SMASHABLE_ROCK) in Dark Cave at 27, 6.
const spot = { map: "3:78", x: 27, y: 6 };
const { assets, world } = fixture([spot.map]);
expect(
assets.map(spot.map).objects.some(
(o) => o.x === spot.x && o.y === spot.y && o.movement === 0x18,
),
).toBe(true);
world.state.party = partyWith(assets, [0xf9]); // ROCK_SMASH
world.warpTo(spot.map, spot.x, spot.y + 1);
world.restore({ ...world.save(), direction: "down" });
expect(useFromMenu(world, "ROCK SMASH")).toEqual(["Can't use that\nhere."]);
closeMenus(world);
world.restore({ ...world.save(), direction: "up" });
useFromMenu(world, "ROCK SMASH");
expect(world.notice).toBe("Smashing rocks is not ported yet.");
closeMenus(world);
expect(world.diagnostic).toBe("");
});
it("draws a wild Pokémon with Sweet Scent in grass, and nothing elsewhere", () => {
const { assets, world } = fixture([]);
world.state.party = partyWith(assets, [0xe6]); // SWEET_SCENT
world.state.scenes["24:4"] = 1;
world.restore({ ...world.save(), map: "24:4", x: 13, y: 7, direction: "down", objects: [] });
const nothing = [...useFromMenu(world, "SWEET SCENT"), closeMenus(world)].join("\n");
expect(world.notice, world.diagnostic).toBe("");
expect(nothing).toContain("SWIMMY used\nSWEET SCENT!");
expect(nothing).toContain("nothing");
const grass = find(assets, (map, x, y) =>
map.id === "24:3" &&
assets.grassCollisions().includes(assets.collision(map, x, y)) &&
!map.objects.some((o) => o.x === x && o.y === y),
);
world.warpTo(grass.map, grass.x, grass.y);
useFromMenu(world, "SWEET SCENT");
for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue();
for (let tick = 0; tick < 200 && !world.battle; tick++) {
if (world.dialogue) world.advanceDialogue();
world.tick();
}
expect(world.notice, world.diagnostic).toBe("");
expect(world.battle).toBeDefined();
});
});
describe.skipIf(!existsSync(romPath))("Strength boulders and stone tables with the local ROM", () => {
it("drops a boulder pushed into Ice Path B1F's hole through its stone table script", () => {
const ICE_PATH_B1F = "3:62";
const EVENT_BOULDER_IN_ICE_PATH_2A = 1806;
const { assets, world } = fixture([ICE_PATH_B1F]);
const mon = generatePartyMon(assets.pokemon, {
species: 158, level: 30, item: 0, otId: 1, ot: "CHRIS", dvs: 0, caughtData: 0,
});
world.state.party = [mon];
world.warpTo(ICE_PATH_B1F, 4, 5);
world.state.flags.add(24); // ENGINE_STRENGTH_ACTIVE
world.state.events.add(EVENT_BOULDER_IN_ICE_PATH_2A);
world.restore({
...world.save(),
x: 4,
y: 5,
direction: "down",
objects: [{ id: 3, x: 4, y: 6, direction: "down" }],
});
world.state.flags.add(24);
expect(world.objects().some((o) => o.id === 3)).toBe(true);
world.tick("down");
const shown: string[] = [];
for (let tick = 0; tick < 40 && world.activity !== "scene"; tick++) world.tick();
expect(world.activity).toBe("scene");
shown.push(...settle(world));
expect(world.notice, world.diagnostic).toBe("");
expect(shown.join("\n")).toMatch(/fell\s+through/i);
expect(world.objects().some((o) => o.id === 3)).toBe(false);
expect(world.state.events.has(EVENT_BOULDER_IN_ICE_PATH_2A)).toBe(false);
});
});