Facing a Headbutt tree asks to HEADBUTT it, and HEADBUTT from the party menu skips the question. ShakeHeadbuttTree shakes the tree as a sprite over grass, then TreeMonEncounter scores the facing tile against the trainer ID and draws from the map's common or rare tree table. A Pokémon that falls fights as BATTLETYPE_TREE: "fell out of the tree!", and the time of day's AsleepTreeMons species start asleep. Smashable rocks run SmashRockScript, and ROCK SMASH from the party menu acts on the facing rock; after the rock breaks, RockMonEncounter gives the map's rock set a 40% chance of a wild battle. Closes #26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
628 lines
27 KiB
TypeScript
628 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 and smashes it", () => {
|
|
// 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" });
|
|
const shown = useFromMenu(world, "ROCK SMASH");
|
|
for (let i = 0; i < 10 && world.dialogue?.choice; i++) world.advanceDialogue();
|
|
// Random always returns 0 here, so the rock hides a Krabby.
|
|
for (let tick = 0; tick < 500 && !world.battle; tick++) {
|
|
if (world.dialogue) {
|
|
shown.push(world.dialogue.pages[world.dialogue.index]);
|
|
world.advanceDialogue();
|
|
} else world.tick();
|
|
}
|
|
expect(world.notice, world.diagnostic).toBe("");
|
|
expect(shown).toContain("SWIMMY used\nROCK SMASH!");
|
|
expect(world.objects().some((o) => o.x === spot.x && o.y === spot.y)).toBe(false);
|
|
expect(world.battle?.engine.enemy.mon.species).toBe(98); // KRABBY
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|