Open Route 32 to Route 34, the Ruins of Alph and Azalea Town (#32)
Enable Route 32 and its Pokémon Center, the Ruins of Alph with the four chambers, item and word rooms, inner chamber and research center, Union Cave, Route 33, Azalea Town with Kurt's house, the kiln and Bugsy's gym, the Slowpoke Well, Ilex Forest and Route 34. Port what their scripts need: saved WRAM bytes by label (readmem, writemem, loadmem: wFarfetchdPosition, wKurtApricornQuantity), VAR_UNOWNCOUNT and VAR_KURT_APRICORNS, SelectApricornForKurt and verbosegiveitemvar, Unown letters (GetUnownLetter, per-letter pictures, CheckUnownLetter's redraw, UpdateUnownDex, wFirstUnownSeen), the chamber puzzles (_UnownPuzzle), DisplayUnownWords, HoOhChamber and OmanyteChamber. Minigames and specials that draw a whole screen in tiles now share a tile screen effect with ROM graphics, palettes and sprites, answered with buttons; the standard font and textbox frame load for them. The playthrough continues through Kurt's Slowpoke Well, Bugsy, the rival and the Farfetch'd to the Route 34 gate; its helpers move to tests/playthrough-driver.ts. New tests solve the Kabuto puzzle through the world, catch Unown and make Kurt's balls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
15a31f95e4
commit
f4ef244fc7
31 files changed
+2334
-528
No files matched your search
@@ -45,6 +45,7 @@ test("the Map tab shows the Johto atlas with the player and open areas", async (
|
||||
const open = page.locator("#atlas-open li");
|
||||
await expect(open.first()).toHaveText("NEW BARK TOWN");
|
||||
await expect(page.locator("#atlas-open")).toContainText("VIOLET CITY");
|
||||
await expect(page.locator("#atlas-open")).not.toContainText("AZALEA TOWN");
|
||||
await expect(page.locator("#atlas-open")).toContainText("AZALEA TOWN");
|
||||
await expect(page.locator("#atlas-open")).not.toContainText("BLACKTHORN CITY");
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
+3
-1
@@ -17,6 +17,7 @@ const NEW_BARK_TOWN = "24:4",
|
||||
LANDMARK_CHERRYGROVE_CITY = 3,
|
||||
LANDMARK_VIOLET_CITY = 6,
|
||||
LANDMARK_AZALEA_TOWN = 12,
|
||||
LANDMARK_BLACKTHORN_CITY = 0x29,
|
||||
LANDMARK_PALLET_TOWN = 47;
|
||||
|
||||
describe.skipIf(!existsSync(romPath) || !existsSync(`${pret}/data/maps/landmarks.asm`))(
|
||||
@@ -98,7 +99,8 @@ describe.skipIf(!existsSync(romPath))("the region atlas", () => {
|
||||
expect(at(LANDMARK_VIOLET_CITY)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
expect(at(LANDMARK_ROUTE_29)).toMatchObject({ open: true });
|
||||
expect(at(LANDMARK_ROUTE_29).flypoint).toBeUndefined();
|
||||
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: false, flypoint: { visited: false } });
|
||||
expect(at(LANDMARK_AZALEA_TOWN)).toMatchObject({ open: true, flypoint: { visited: false } });
|
||||
expect(at(LANDMARK_BLACKTHORN_CITY)).toMatchObject({ open: false, flypoint: { visited: false } });
|
||||
// Kanto stays closed, so asking for it shows Johto.
|
||||
expect(world.atlas("kanto").region).toBe("johto");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
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 { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("Azalea Town with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const KURTS_HOUSE = "8:4";
|
||||
const EVENT_CLEARED_SLOWPOKE_WELL = 0x2b,
|
||||
EVENT_KURT_GAVE_YOU_LURE_BALL = 0x35,
|
||||
EVENT_GAVE_KURT_RED_APRICORN = 0x258,
|
||||
ENGINE_KURT_MAKING_BALLS = 80;
|
||||
const RED_APRICORN = 0x55,
|
||||
BLU_APRICORN = 0x56,
|
||||
LEVEL_BALL = 0x9f;
|
||||
|
||||
const world = (day: number) => {
|
||||
let seed = 9;
|
||||
const game = new GameWorld(
|
||||
new CrystalAssets(bytes),
|
||||
() => new Date(`2026-09-${day}T12:00:00`),
|
||||
() => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
);
|
||||
game.state.party.push(
|
||||
generatePartyMon(game.assets.pokemon, {
|
||||
species: 155, level: 20, item: 0, otId: game.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0,
|
||||
}),
|
||||
);
|
||||
return game;
|
||||
};
|
||||
/** Answer menus and quantities in order, collecting the pages shown. */
|
||||
const answer = (game: GameWorld, answers: number[]) => {
|
||||
const pages: string[] = [];
|
||||
for (let tick = 0; tick < 5000; tick++) {
|
||||
const dialogue = game.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index];
|
||||
if (page && pages.at(-1) !== page) pages.push(page.replace(/\n/g, " "));
|
||||
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) {
|
||||
const choice = dialogue.choice;
|
||||
if (choice.kind === "martquantity") {
|
||||
pages.push(choice.prompt.replace(/\n/g, " "));
|
||||
const wanted = answers.shift()!;
|
||||
while (choice.selected < wanted) game.moveCursor("up");
|
||||
game.choose(choice.selected);
|
||||
} else game.choose(answers.shift() ?? 0);
|
||||
} else game.advanceDialogue();
|
||||
continue;
|
||||
}
|
||||
if (!game.busy) break;
|
||||
game.tick();
|
||||
}
|
||||
expect(game.notice, game.diagnostic).toBe("");
|
||||
return pages.join(" ");
|
||||
};
|
||||
|
||||
it("makes Level Balls from Red Apricorns by the next day", () => {
|
||||
const game = world(22);
|
||||
game.state.events.add(EVENT_CLEARED_SLOWPOKE_WELL);
|
||||
game.state.events.add(EVENT_KURT_GAVE_YOU_LURE_BALL);
|
||||
game.state.bag.items = [
|
||||
{ item: BLU_APRICORN, quantity: 1 },
|
||||
{ item: RED_APRICORN, quantity: 4 },
|
||||
];
|
||||
game.restore({ ...game.save(), map: KURTS_HOUSE, x: 3, y: 3, direction: "up" });
|
||||
settle(game);
|
||||
game.interact();
|
||||
// The list follows ApricornBalls: RED before BLU. Three of four.
|
||||
let pages = answer(game, [0, 3]);
|
||||
expect(pages).toContain("Which APRICORN");
|
||||
expect(pages).toContain("How many");
|
||||
expect(game.state.events.has(EVENT_GAVE_KURT_RED_APRICORN)).toBe(true);
|
||||
expect(game.state.flags.has(ENGINE_KURT_MAKING_BALLS)).toBe(true);
|
||||
expect(game.state.memory.wKurtApricornQuantity).toBe(3);
|
||||
expect(game.state.bag.items).toEqual([
|
||||
{ item: BLU_APRICORN, quantity: 1 },
|
||||
{ item: RED_APRICORN, quantity: 1 },
|
||||
]);
|
||||
// The next day, ENGINE_KURT_MAKING_BALLS is cleared and the balls are
|
||||
// ready.
|
||||
const next = world(23);
|
||||
next.restore(JSON.parse(JSON.stringify(game.save())));
|
||||
settle(next);
|
||||
expect(next.state.flags.has(ENGINE_KURT_MAKING_BALLS)).toBe(false);
|
||||
// EVENT_TEMPORARY_UNTIL_MAP_RELOAD_1 clears when the house is entered
|
||||
// again (ResetMapBufferEventFlags).
|
||||
next.warpTo(KURTS_HOUSE, 3, 3);
|
||||
settle(next);
|
||||
next.move("up"); // Kurt: this only turns the player
|
||||
next.interact();
|
||||
pages = answer(next, [1]);
|
||||
expect(next.state.bag.balls).toContainEqual({ item: LEVEL_BALL, quantity: 3 });
|
||||
expect(next.state.events.has(EVENT_GAVE_KURT_RED_APRICORN)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns to the apricorn list when the quantity is cancelled", () => {
|
||||
const game = world(22);
|
||||
game.state.events.add(EVENT_CLEARED_SLOWPOKE_WELL);
|
||||
game.state.events.add(EVENT_KURT_GAVE_YOU_LURE_BALL);
|
||||
game.state.bag.items = [{ item: RED_APRICORN, quantity: 2 }];
|
||||
game.restore({ ...game.save(), map: KURTS_HOUSE, x: 3, y: 3, direction: "up" });
|
||||
settle(game);
|
||||
game.interact();
|
||||
// RED, cancel the quantity, then CANCEL the list.
|
||||
for (let tick = 0; tick < 200 && game.dialogue?.choice?.kind !== "menu"; tick++)
|
||||
if (game.dialogue && !game.dialogue.choice) game.advanceDialogue();
|
||||
else game.tick();
|
||||
game.choose(0);
|
||||
expect(game.dialogue?.choice?.kind).toBe("martquantity");
|
||||
game.choose(-1);
|
||||
for (let tick = 0; tick < 200 && game.dialogue?.choice?.kind !== "menu"; tick++)
|
||||
if (game.dialogue && !game.dialogue.choice) game.advanceDialogue();
|
||||
else game.tick();
|
||||
expect(game.dialogue?.choice?.kind).toBe("menu");
|
||||
game.choose(1);
|
||||
const pages = answer(game, []);
|
||||
expect(pages).toContain("letdown");
|
||||
expect(game.state.bag.items).toEqual([{ item: RED_APRICORN, quantity: 2 }]);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ const NEW_BARK_TOWN = "24:4",
|
||||
DARK_CAVE_VIOLET_ENTRANCE = "3:78";
|
||||
const SPAWN_NEW_BARK = 14,
|
||||
SPAWN_CHERRYGROVE = 15,
|
||||
SPAWN_AZALEA = 18;
|
||||
SPAWN_BLACKTHORN = 25;
|
||||
const ENGINE_STORMBADGE = 32,
|
||||
EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 64;
|
||||
const FLY = 0x13,
|
||||
@@ -203,11 +203,11 @@ describe.skipIf(!existsSync(romPath))("Fly, Dig, Teleport and the Escape Rope",
|
||||
it("refuses to fly to a flypoint that is not ported", () => {
|
||||
const { world } = fixture([FLY], ROUTE_29, 50, 10);
|
||||
world.state.flags.add(ENGINE_STORMBADGE);
|
||||
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_AZALEA);
|
||||
visit(world, SPAWN_NEW_BARK, SPAWN_CHERRYGROVE, SPAWN_BLACKTHORN);
|
||||
useMove(world, "FLY");
|
||||
for (let press = 0; press < 4 && flyMap(world)?.name !== "AZALEA TOWN"; press++)
|
||||
for (let press = 0; press < 4 && flyMap(world)?.name !== "BLACKTHORN CITY"; press++)
|
||||
world.moveCursor("down");
|
||||
expect(flyMap(world)?.name).toBe("AZALEA TOWN");
|
||||
expect(flyMap(world)?.name).toBe("BLACKTHORN CITY");
|
||||
world.choose("a");
|
||||
until(world);
|
||||
expect(world.notice).toBe("This destination is not ported yet.");
|
||||
|
||||
+18
-9
@@ -517,16 +517,25 @@ describe.skipIf(!existsSync(romPath))(
|
||||
load(),
|
||||
() => new Date("2026-09-22T12:00:00"),
|
||||
);
|
||||
// Violet City's south edge leads to Route 32, which stays closed.
|
||||
const city = world.assets.map("10:5"),
|
||||
bottom = city.height - 1;
|
||||
const edge = Array.from({ length: city.width }, (_, x) => x).find(
|
||||
(x) =>
|
||||
world.assets.permission(world.assets.collision(city, x, bottom)) === 0,
|
||||
const start = world.save();
|
||||
// New Bark Town's east edge is water leading to Route 27, which
|
||||
// stays closed: a surfer is refused.
|
||||
const town = world.assets.map("24:4"),
|
||||
right = town.width - 1;
|
||||
const edge = Array.from({ length: town.height }, (_, y) => y).find(
|
||||
(y) =>
|
||||
world.assets.permission(world.assets.collision(town, right, y)) === 1,
|
||||
)!;
|
||||
world.restore({ ...world.save(), map: "10:5", x: edge, y: bottom });
|
||||
expect(world.move("down")).toBe(false);
|
||||
expect(world.notice).toBe("Route 32 is not ported yet.");
|
||||
world.restore({
|
||||
...world.save(),
|
||||
map: "24:4",
|
||||
x: right,
|
||||
y: edge,
|
||||
playerState: 4, // PLAYER_SURF
|
||||
});
|
||||
expect(world.move("right")).toBe(false);
|
||||
expect(world.notice).toBe("Route 27 is not ported yet.");
|
||||
world.restore(start);
|
||||
const {
|
||||
scenes,
|
||||
phone,
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
import { expect } from "vitest";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { encodeSave } from "../src/storage/saves";
|
||||
import { CRYSTAL_ROM } from "../src/rom/validate";
|
||||
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";
|
||||
|
||||
const STEP: Record<Direction, [number, number]> = {
|
||||
up: [0, -1],
|
||||
down: [0, 1],
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
};
|
||||
|
||||
/** Warp mats and edges that need their direction pressed (.CheckWarp). */
|
||||
const WARP_EXITS: Partial<Record<number, Direction>> = {
|
||||
0x70: "down",
|
||||
0x76: "left",
|
||||
0x78: "up",
|
||||
0x7e: "right",
|
||||
};
|
||||
|
||||
/** Plays the game through the world's own inputs, as tests/playthrough
|
||||
* does: scripts, battles and walking, with a seeded Random. A stage's save
|
||||
* can be dumped (PLAYTHROUGH_SAVES) and resumed with `resume`. */
|
||||
export class Playthrough {
|
||||
world!: GameWorld;
|
||||
readonly log: string[] = [];
|
||||
/** A wild species to throw Poké Balls at instead of fighting. */
|
||||
wanted = 0;
|
||||
seed = 0x2a;
|
||||
private stage = 0;
|
||||
readonly now = () => new Date("2026-09-22T12:00:00");
|
||||
readonly random = () => {
|
||||
this.seed = (this.seed * 1103515245 + 12345) >>> 0;
|
||||
return (this.seed >>> 16) & 255;
|
||||
};
|
||||
constructor(readonly assets: CrystalAssets) {}
|
||||
|
||||
/** A new world for the introduction. */
|
||||
start() {
|
||||
this.world = new GameWorld(this.assets, this.now, this.random);
|
||||
}
|
||||
/** Continue from a dumped stage. */
|
||||
resume(dump: { save: NativeSave; seed: number }) {
|
||||
this.seed = dump.seed;
|
||||
this.world = new GameWorld(this.assets, this.now, this.random);
|
||||
this.world.restore(dump.save);
|
||||
}
|
||||
|
||||
/** The usable move with the most power, or the first with PP. */
|
||||
strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => {
|
||||
let best = -1;
|
||||
mon.moves.forEach((move, i) => {
|
||||
if (!move || !(mon.pp[i] & 63)) return;
|
||||
if (
|
||||
best < 0 ||
|
||||
this.assets.pokemon.move(move).power >
|
||||
this.assets.pokemon.move(mon.moves[best]).power
|
||||
)
|
||||
best = i;
|
||||
});
|
||||
return Math.max(0, best);
|
||||
};
|
||||
/** 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. */
|
||||
play = (answers: (number | string)[] = []) => {
|
||||
const world = this.world,
|
||||
log = this.log;
|
||||
for (let tick = 0; tick < 100_000; tick++) {
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
const battle = world.battle;
|
||||
if (battle) {
|
||||
if (battle.message && log.at(-1) !== battle.message.text)
|
||||
log.push(battle.message.text);
|
||||
const prompt = battle.prompt;
|
||||
if (prompt?.kind === "action") {
|
||||
const { engine } = battle,
|
||||
mon = engine.player.mon;
|
||||
const ball = world.state.bag.balls.findIndex(
|
||||
(slot) => slot.item === C.POKE_BALL,
|
||||
);
|
||||
if (
|
||||
engine.battleMode === C.WILD_BATTLE &&
|
||||
engine.enemy.mon.species === this.wanted &&
|
||||
!world.state.party.some((member) => member.species === this.wanted) &&
|
||||
ball >= 0
|
||||
)
|
||||
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 world.battleAction({ action: "fight", move: this.strongest(mon) });
|
||||
} 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).
|
||||
else if (prompt?.kind === "party")
|
||||
world.battleChoose(
|
||||
prompt.cancel
|
||||
? -1
|
||||
: world.state.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("");
|
||||
else if (prompt?.kind === "stats" || battle.message?.prompt)
|
||||
world.interact();
|
||||
else world.tick();
|
||||
continue;
|
||||
}
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index];
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (log.at(-1) !== page) log.push(page);
|
||||
if (last && dialogue.choice) {
|
||||
const answer = answers.shift();
|
||||
if (dialogue.choice.kind === "name")
|
||||
world.choose(typeof answer === "string" ? answer : "");
|
||||
else
|
||||
world.choose(
|
||||
typeof answer === "number"
|
||||
? answer
|
||||
: dialogue.choice.kind === "yesno"
|
||||
? 1
|
||||
: dialogue.choice.kind === "clock"
|
||||
? dialogue.choice.selected
|
||||
: 0,
|
||||
);
|
||||
} else if (last && dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
continue;
|
||||
}
|
||||
if (!world.busy) {
|
||||
world.tick(); // scene scripts and phone calls start on idle ticks
|
||||
if (!world.busy) return;
|
||||
continue;
|
||||
}
|
||||
world.tick();
|
||||
}
|
||||
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
||||
};
|
||||
|
||||
/** Walk to a tile on the current map, replanning after ledges, roaming
|
||||
* NPCs and scenes that move the player. A warp target leaves the map. */
|
||||
walk = (x: number, y: number) => {
|
||||
const world = this.world,
|
||||
map = world.mapId,
|
||||
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
this.play();
|
||||
if (warp && world.mapId !== map) return;
|
||||
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
||||
if (world.x === x && world.y === y) return;
|
||||
for (const direction of findPath(world, x, y)) {
|
||||
const [dx, dy] = STEP[direction];
|
||||
const from = [world.x, world.y];
|
||||
if (!world.move(direction)) {
|
||||
for (let tick = 0; tick < 64; tick++) world.tick();
|
||||
break;
|
||||
}
|
||||
this.play();
|
||||
if (
|
||||
world.mapId !== map ||
|
||||
world.x !== from[0] + dx ||
|
||||
world.y !== from[1] + dy
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}: ${world.notice}`,
|
||||
);
|
||||
};
|
||||
/** Step across a map connection from the nearest usable edge tile. */
|
||||
cross = (direction: Direction, target: string) => {
|
||||
const world = this.world,
|
||||
map = world.mapId;
|
||||
const [dx, dy] = STEP[direction];
|
||||
const edge = Array.from(
|
||||
{ length: dx ? world.map.height : world.map.width },
|
||||
(_, i) =>
|
||||
dx
|
||||
? [dx < 0 ? 0 : world.map.width - 1, i]
|
||||
: [i, dy < 0 ? 0 : world.map.height - 1],
|
||||
).flatMap(([x, y]) => {
|
||||
try {
|
||||
return [{ x, y, length: findPath(world, x, y).length }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
edge.sort((a, b) => a.length - b.length);
|
||||
for (const { x, y } of edge) {
|
||||
this.walk(x, y);
|
||||
if (world.move(direction)) {
|
||||
this.play();
|
||||
if (world.mapId === target) return;
|
||||
}
|
||||
if (world.mapId !== map) break;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
|
||||
);
|
||||
};
|
||||
/** Stand in front of a warp leading to `target` and take it. */
|
||||
enter = (target: string, direction: Direction) => {
|
||||
const world = this.world;
|
||||
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
||||
expect(warp, `a warp to ${target}`).toBeDefined();
|
||||
const [dx, dy] = STEP[direction];
|
||||
this.walk(warp.x - dx, warp.y - dy);
|
||||
expect(world.move(direction)).toBe(true);
|
||||
this.play();
|
||||
expect(world.mapId).toBe(target);
|
||||
};
|
||||
/** Leave through an exit mat, which needs the exit direction pressed. */
|
||||
exit = (target: string, direction: Direction = "down") => {
|
||||
const world = this.world;
|
||||
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
||||
this.walk(warp.x, warp.y);
|
||||
if (world.mapId !== target) {
|
||||
expect(world.move(direction)).toBe(true);
|
||||
this.play();
|
||||
}
|
||||
expect(world.mapId).toBe(target);
|
||||
};
|
||||
/** Serialize the save to JSON and load it into a fresh world. */
|
||||
reload = () => {
|
||||
const saved = JSON.parse(JSON.stringify(this.world.save()));
|
||||
// PLAYTHROUGH_SAVES=dir keeps every stage's save, for debugging a
|
||||
// later stage from where the run reached.
|
||||
if (process.env.PLAYTHROUGH_SAVES) {
|
||||
mkdirSync(process.env.PLAYTHROUGH_SAVES, { recursive: true });
|
||||
writeFileSync(
|
||||
`${process.env.PLAYTHROUGH_SAVES}/${String(++this.stage).padStart(3, "0")}.json`,
|
||||
JSON.stringify({ save: saved, seed: this.seed }),
|
||||
);
|
||||
}
|
||||
this.world = new GameWorld(this.assets, this.now, this.random);
|
||||
this.world.restore(saved);
|
||||
expect(this.world.save()).toEqual(saved);
|
||||
};
|
||||
lead = () => this.world.state.party[0];
|
||||
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
|
||||
* tests/human-test-saves.test.ts writes them (kept out of git). */
|
||||
writeTestSave = (name: string) => {
|
||||
if (!process.env.MAKE_TEST_SAVES) return;
|
||||
mkdirSync("test-saves", { recursive: true });
|
||||
writeFileSync(
|
||||
`test-saves/${name}.crystal-save.json`,
|
||||
encodeSave({
|
||||
version: 1,
|
||||
romHash: CRYSTAL_ROM.sha1,
|
||||
coreId: "crystal-native-world-v1",
|
||||
savedAt: new Date().toISOString(),
|
||||
state: new TextEncoder().encode(JSON.stringify(this.world.save())),
|
||||
}),
|
||||
);
|
||||
};
|
||||
/** PokecenterNurseScript from in front of the counter, then back out. */
|
||||
heal = (center: string, city: string) => {
|
||||
this.enter(center, "up");
|
||||
this.walk(3, 3);
|
||||
this.world.move("up"); // the counter: this only turns the player
|
||||
this.world.interact();
|
||||
this.play();
|
||||
expect(
|
||||
this.world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0]),
|
||||
).toBe(true);
|
||||
this.exit(city);
|
||||
};
|
||||
/** Pace between two neighbouring grass tiles (the nearest pair) until
|
||||
* the lead reaches `level`. `rest` heals and comes back here. */
|
||||
train = (level: number, rest: () => void) => {
|
||||
const world = this.world,
|
||||
map = world.mapId,
|
||||
grass = world.assets.grassCollisions();
|
||||
const isGrass = (x: number, y: number) =>
|
||||
x >= 0 &&
|
||||
y >= 0 &&
|
||||
x < world.map.width &&
|
||||
y < world.map.height &&
|
||||
grass.includes(world.assets.collision(world.map, x, y));
|
||||
for (let round = 0; round < 2000 && this.lead().level < level; round++) {
|
||||
if (this.lead().hp * 2 < this.lead().stats[0]) {
|
||||
rest();
|
||||
expect(this.world.mapId).toBe(map);
|
||||
}
|
||||
const pairs: { x: number; y: number; length: number }[] = [];
|
||||
for (let y = 0; y < world.map.height; y++)
|
||||
for (let x = 0; x < world.map.width; x++)
|
||||
if (isGrass(x, y) && isGrass(x + 1, y))
|
||||
try {
|
||||
pairs.push({ x, y, length: findPath(world, x, y).length });
|
||||
} catch {
|
||||
/* unreachable */
|
||||
}
|
||||
pairs.sort((a, b) => a.length - b.length);
|
||||
const { x, y } = pairs[0];
|
||||
this.walk(x, y);
|
||||
this.walk(x + 1, y);
|
||||
}
|
||||
expect(this.lead().level).toBeGreaterThanOrEqual(level);
|
||||
};
|
||||
|
||||
/** Take the stairs (or another warp) to `target`, trying each. A warp
|
||||
* mat or cave edge needs its exit direction pressed (.CheckWarp). */
|
||||
stairs = (target: string) => {
|
||||
const world = this.world;
|
||||
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
|
||||
try {
|
||||
findPath(world, warp.x, warp.y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
this.walk(warp.x, warp.y);
|
||||
if (world.mapId === target) return;
|
||||
const exit = WARP_EXITS[world.assets.collision(world.map, warp.x, warp.y)];
|
||||
if (exit && world.move(exit)) {
|
||||
this.play();
|
||||
if (world.mapId === target) return;
|
||||
}
|
||||
}
|
||||
throw new Error(`No way from ${world.mapId} to ${target}`);
|
||||
};
|
||||
/** Find a way through warps among `maps` to `target`, as a player
|
||||
* trying staircases would: depth first, backing out of dead ends. */
|
||||
route = (
|
||||
target: string,
|
||||
maps: string[],
|
||||
depth = 6,
|
||||
goal?: { x: number; y: number },
|
||||
): boolean => {
|
||||
const world = this.world;
|
||||
const reachable = () => {
|
||||
if (!goal) return true;
|
||||
try {
|
||||
findPath(world, goal.x, goal.y);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (world.mapId === target && reachable()) return true;
|
||||
if (!depth) return false;
|
||||
const here = world.save();
|
||||
for (const warp of world.map.warps) {
|
||||
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
||||
try {
|
||||
findPath(world, warp.x, warp.y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const from = world.mapId;
|
||||
this.walk(warp.x, warp.y);
|
||||
if (world.mapId !== from && this.route(target, maps, depth - 1, goal))
|
||||
return true;
|
||||
world.restore(here);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** Advance to the next menu choice, keeping its pages in the log. */
|
||||
toChoice = () => {
|
||||
const world = this.world,
|
||||
log = this.log;
|
||||
for (let tick = 0; tick < 2000; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index];
|
||||
if (log.at(-1) !== page) log.push(page);
|
||||
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice)
|
||||
return dialogue.choice;
|
||||
if (dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
} else world.tick();
|
||||
}
|
||||
throw new Error(`No choice appeared: ${world.diagnostic}`);
|
||||
};
|
||||
/** Choose a menu item by its label (or a pattern). */
|
||||
choose = (label: string | RegExp) => {
|
||||
const choice = this.toChoice();
|
||||
if (choice.kind !== "menu")
|
||||
throw new Error(`Expected a menu, got ${choice.kind}`);
|
||||
const index = choice.items.findIndex((item) =>
|
||||
typeof label === "string" ? item === label : label.test(item),
|
||||
);
|
||||
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
||||
this.world.choose(index);
|
||||
};
|
||||
/** Choose an item in the pack. */
|
||||
choosePack = (item: number) => {
|
||||
const choice = this.toChoice();
|
||||
if (choice.kind !== "pack")
|
||||
throw new Error(`Expected the pack, got ${choice.kind}`);
|
||||
for (const [pocket, { entries }] of choice.pockets.entries()) {
|
||||
const index = entries.findIndex((entry) => entry.item === item);
|
||||
if (index >= 0) return this.world.choosePackEntry(pocket, index);
|
||||
}
|
||||
throw new Error(`Item ${item} is not in the pack.`);
|
||||
};
|
||||
/** Talk to an object wherever it stands. A trainer who spots the player
|
||||
* on the way battles and moves, so the approach is planned again. */
|
||||
talkTo = (id: number) => {
|
||||
const world = this.world;
|
||||
for (let attempt = 0; attempt < 4; attempt++)
|
||||
for (const [dx, dy, face] of [
|
||||
[-1, 0, "right"],
|
||||
[1, 0, "left"],
|
||||
[0, 1, "up"],
|
||||
[0, -1, "down"],
|
||||
] as const) {
|
||||
const target = world.objects().find((object) => object.id === id)!;
|
||||
const x = target.x + dx,
|
||||
y = target.y + dy;
|
||||
try {
|
||||
if (!(world.x === x && world.y === y)) this.walk(x, y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const now = world.objects().find((object) => object.id === id)!;
|
||||
if (now.x !== target.x || now.y !== target.y) break;
|
||||
world.move(face);
|
||||
world.interact();
|
||||
return;
|
||||
}
|
||||
throw new Error(`No way to talk to object ${id}`);
|
||||
};
|
||||
/** 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) => {
|
||||
const world = this.world;
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
this.choose("PACK");
|
||||
this.choosePack(item);
|
||||
this.choose("USE");
|
||||
expect(this.toChoice().kind).toBe("yesno");
|
||||
world.choose(1);
|
||||
const party = this.toChoice();
|
||||
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
|
||||
world.choose(slot);
|
||||
if (world.liveState.party[slot].moves.every(Boolean)) {
|
||||
expect(this.toChoice().kind).toBe("yesno");
|
||||
world.choose(1);
|
||||
this.toChoice();
|
||||
world.choose(0);
|
||||
}
|
||||
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++)
|
||||
this.toChoice();
|
||||
// The menus work on a draft that is kept once they close.
|
||||
for (let i = 0; i < 10 && world.busy; i++) {
|
||||
if (world.dialogue?.choice) world.advanceDialogue();
|
||||
else world.tick();
|
||||
}
|
||||
this.play();
|
||||
};
|
||||
/** Cut the tree at (x, y) from a walkable side (TryCutOW, then YES). */
|
||||
cut = (x: number, y: number) => {
|
||||
const world = this.world;
|
||||
for (const [face, [dx, dy]] of Object.entries(STEP) as [Direction, [number, number]][]) {
|
||||
try {
|
||||
this.walk(x - dx, y - dy);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
world.move(face);
|
||||
world.interact();
|
||||
this.play([1]);
|
||||
return;
|
||||
}
|
||||
throw new Error(`No side to cut ${x},${y} from`);
|
||||
};
|
||||
/** The current location and party, then the last pages, for a failure. */
|
||||
trace = () =>
|
||||
`${this.world.mapId} ${this.world.x},${this.world.y}; party ${JSON.stringify(
|
||||
this.world.state.party.map(({ species, level, hp }) => ({
|
||||
species,
|
||||
level,
|
||||
hp,
|
||||
})),
|
||||
)}\n${this.log.slice(-30).join("\n---\n")}`;
|
||||
}
|
||||
+228
-464
@@ -1,12 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { encodeSave } from "../src/storage/saves";
|
||||
import { CRYSTAL_ROM } from "../src/rom/validate";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { GameWorld } 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 { Playthrough } from "./playthrough-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
@@ -29,6 +25,27 @@ const NEW_BARK = "24:4",
|
||||
KYLES_HOUSE = "10:11";
|
||||
const DARK_CAVE = "3:78",
|
||||
EARLS_ACADEMY = "10:8";
|
||||
const ROUTE_32 = "10:1",
|
||||
ROUTE_32_CENTER = "10:13",
|
||||
UNION_CAVE_1F = "3:37",
|
||||
ROUTE_33 = "8:6",
|
||||
AZALEA = "8:7",
|
||||
AZALEA_CENTER = "8:1",
|
||||
KURTS_HOUSE = "8:4",
|
||||
AZALEA_GYM = "8:5",
|
||||
SLOWPOKE_WELL_B1F = "3:40",
|
||||
ILEX_GATE = "11:22",
|
||||
ILEX_FOREST = "3:52",
|
||||
ROUTE_34_GATE = "11:23";
|
||||
const MIRACLE_SEED = 0x75,
|
||||
LURE_BALL = 0xa0,
|
||||
HM01 = 0xf3,
|
||||
CUT = 15;
|
||||
const EVENT_CLEARED_SLOWPOKE_WELL = 0x2b,
|
||||
EVENT_BEAT_BUGSY = 0x4be,
|
||||
EVENT_RIVAL_AZALEA_TOWN = 0x6bf,
|
||||
EVENT_HERDED_FARFETCHD = 0x29,
|
||||
ENGINE_HIVEBADGE = 0x1c;
|
||||
const PHONE_BUG_CATCHER_WADE = 16;
|
||||
const FLASH = 148,
|
||||
HM05 = 0xf7;
|
||||
@@ -50,336 +67,28 @@ const EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST = 0x40,
|
||||
describe.skipIf(!existsSync(romPath))(
|
||||
"a continuous opening playthrough",
|
||||
() => {
|
||||
const bytes = existsSync(romPath)
|
||||
? new Uint8Array(readFileSync(romPath))
|
||||
: new Uint8Array();
|
||||
const now = () => new Date("2026-09-22T12:00:00");
|
||||
let seed = 0x2a;
|
||||
const random = () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
};
|
||||
const assets = existsSync(romPath) ? new CrystalAssets(bytes) : undefined;
|
||||
let world: GameWorld;
|
||||
const log: string[] = [];
|
||||
|
||||
/** A wild species to throw Poké Balls at instead of fighting. */
|
||||
let wanted = 0;
|
||||
/** The usable move with the most power, or the first with PP. */
|
||||
const strongest = (mon: { moves: readonly number[]; pp: readonly number[] }) => {
|
||||
let best = -1;
|
||||
mon.moves.forEach((move, i) => {
|
||||
if (!move || !(mon.pp[i] & 63)) return;
|
||||
if (best < 0 || assets!.pokemon.move(move).power > assets!.pokemon.move(mon.moves[best]).power)
|
||||
best = i;
|
||||
});
|
||||
return Math.max(0, best);
|
||||
};
|
||||
/** 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. */
|
||||
const play = (answers: (number | string)[] = []) => {
|
||||
for (let tick = 0; tick < 100_000; tick++) {
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
const battle = world.battle;
|
||||
if (battle) {
|
||||
if (battle.message && log.at(-1) !== battle.message.text)
|
||||
log.push(battle.message.text);
|
||||
const prompt = battle.prompt;
|
||||
if (prompt?.kind === "action") {
|
||||
const { engine } = battle,
|
||||
mon = engine.player.mon;
|
||||
const ball = world.state.bag.balls.findIndex((slot) => slot.item === C.POKE_BALL);
|
||||
if (
|
||||
engine.battleMode === C.WILD_BATTLE &&
|
||||
engine.enemy.mon.species === wanted &&
|
||||
!world.state.party.some((member) => member.species === wanted) &&
|
||||
ball >= 0
|
||||
)
|
||||
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
|
||||
world.battleAction({ action: "fight", move: strongest(mon) });
|
||||
} 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).
|
||||
else if (prompt?.kind === "party")
|
||||
world.battleChoose(
|
||||
prompt.cancel
|
||||
? -1
|
||||
: world.state.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("");
|
||||
else if (prompt?.kind === "stats" || battle.message?.prompt)
|
||||
world.interact();
|
||||
else world.tick();
|
||||
continue;
|
||||
}
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index];
|
||||
const last = dialogue.index === dialogue.pages.length - 1;
|
||||
if (log.at(-1) !== page) log.push(page);
|
||||
if (last && dialogue.choice) {
|
||||
const answer = answers.shift();
|
||||
if (dialogue.choice.kind === "name")
|
||||
world.choose(typeof answer === "string" ? answer : "");
|
||||
else
|
||||
world.choose(
|
||||
typeof answer === "number"
|
||||
? answer
|
||||
: dialogue.choice.kind === "yesno"
|
||||
? 1
|
||||
: dialogue.choice.kind === "clock"
|
||||
? dialogue.choice.selected
|
||||
: 0,
|
||||
);
|
||||
} else if (last && dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
continue;
|
||||
}
|
||||
if (!world.busy) {
|
||||
world.tick(); // scene scripts and phone calls start on idle ticks
|
||||
if (!world.busy) return;
|
||||
continue;
|
||||
}
|
||||
world.tick();
|
||||
}
|
||||
throw new Error(`The world did not settle: ${world.diagnostic}`);
|
||||
};
|
||||
|
||||
const STEP: Record<Direction, [number, number]> = {
|
||||
up: [0, -1],
|
||||
down: [0, 1],
|
||||
left: [-1, 0],
|
||||
right: [1, 0],
|
||||
};
|
||||
/** Walk to a tile on the current map, replanning after ledges, roaming
|
||||
* NPCs and scenes that move the player. A warp target leaves the map. */
|
||||
const walk = (x: number, y: number) => {
|
||||
const map = world.mapId,
|
||||
warp = world.map.warps.some((warp) => warp.x === x && warp.y === y);
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
play();
|
||||
if (warp && world.mapId !== map) return;
|
||||
expect(world.mapId, `walking to ${x},${y}`).toBe(map);
|
||||
if (world.x === x && world.y === y) return;
|
||||
for (const direction of findPath(world, x, y)) {
|
||||
const [dx, dy] = STEP[direction];
|
||||
const from = [world.x, world.y];
|
||||
if (!world.move(direction)) {
|
||||
for (let tick = 0; tick < 64; tick++) world.tick();
|
||||
break;
|
||||
}
|
||||
play();
|
||||
if (
|
||||
world.mapId !== map ||
|
||||
world.x !== from[0] + dx ||
|
||||
world.y !== from[1] + dy
|
||||
)
|
||||
break;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Could not reach ${x},${y} on ${map} from ${world.x},${world.y}`,
|
||||
);
|
||||
};
|
||||
/** Step across a map connection from the nearest usable edge tile. */
|
||||
const cross = (direction: Direction, target: string) => {
|
||||
const map = world.mapId;
|
||||
const [dx, dy] = STEP[direction];
|
||||
const edge = Array.from(
|
||||
{ length: dx ? world.map.height : world.map.width },
|
||||
(_, i) =>
|
||||
dx
|
||||
? [dx < 0 ? 0 : world.map.width - 1, i]
|
||||
: [i, dy < 0 ? 0 : world.map.height - 1],
|
||||
).flatMap(([x, y]) => {
|
||||
try {
|
||||
return [{ x, y, length: findPath(world, x, y).length }];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
edge.sort((a, b) => a.length - b.length);
|
||||
for (const { x, y } of edge) {
|
||||
walk(x, y);
|
||||
if (world.move(direction)) {
|
||||
play();
|
||||
if (world.mapId === target) return;
|
||||
}
|
||||
if (world.mapId !== map) break;
|
||||
}
|
||||
throw new Error(
|
||||
`Could not cross ${direction} from ${map} to ${target}: now ${world.mapId}`,
|
||||
);
|
||||
};
|
||||
/** Stand in front of a warp leading to `target` and take it. */
|
||||
const enter = (target: string, direction: Direction) => {
|
||||
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
||||
expect(warp, `a warp to ${target}`).toBeDefined();
|
||||
const [dx, dy] = STEP[direction];
|
||||
walk(warp.x - dx, warp.y - dy);
|
||||
expect(world.move(direction)).toBe(true);
|
||||
play();
|
||||
expect(world.mapId).toBe(target);
|
||||
};
|
||||
/** Leave through an exit mat, which needs the exit direction pressed. */
|
||||
const exit = (target: string, direction: Direction = "down") => {
|
||||
const warp = world.map.warps.find((warp) => warp.targetMap === target)!;
|
||||
walk(warp.x, warp.y);
|
||||
if (world.mapId !== target) {
|
||||
expect(world.move(direction)).toBe(true);
|
||||
play();
|
||||
}
|
||||
expect(world.mapId).toBe(target);
|
||||
};
|
||||
const reload = () => {
|
||||
const saved = JSON.parse(JSON.stringify(world.save()));
|
||||
world = new GameWorld(assets!, now, random);
|
||||
world.restore(saved);
|
||||
expect(world.save()).toEqual(saved);
|
||||
};
|
||||
|
||||
const lead = () => world.state.party[0];
|
||||
/** With MAKE_TEST_SAVES, a backup for the board's manual checks, as
|
||||
* tests/human-test-saves.test.ts writes them (kept out of git). */
|
||||
const writeTestSave = (name: string) => {
|
||||
if (!process.env.MAKE_TEST_SAVES) return;
|
||||
mkdirSync("test-saves", { recursive: true });
|
||||
writeFileSync(
|
||||
`test-saves/${name}.crystal-save.json`,
|
||||
encodeSave({
|
||||
version: 1,
|
||||
romHash: CRYSTAL_ROM.sha1,
|
||||
coreId: "crystal-native-world-v1",
|
||||
savedAt: new Date().toISOString(),
|
||||
state: new TextEncoder().encode(JSON.stringify(world.save())),
|
||||
}),
|
||||
);
|
||||
};
|
||||
/** PokecenterNurseScript from in front of the counter, then back out. */
|
||||
const heal = (center: string, city: string) => {
|
||||
enter(center, "up");
|
||||
walk(3, 3);
|
||||
world.move("up"); // the counter: this only turns the player
|
||||
world.interact();
|
||||
play();
|
||||
expect(world.state.party.every((mon) => mon.egg || mon.hp === mon.stats[0])).toBe(true);
|
||||
exit(city);
|
||||
};
|
||||
/** Pace between two neighbouring grass tiles (the nearest pair) until
|
||||
* the lead reaches `level`. `rest` heals and comes back here. */
|
||||
const train = (level: number, rest: () => void) => {
|
||||
const map = world.mapId,
|
||||
grass = world.assets.grassCollisions();
|
||||
const isGrass = (x: number, y: number) =>
|
||||
x >= 0 && y >= 0 && x < world.map.width && y < world.map.height &&
|
||||
grass.includes(world.assets.collision(world.map, x, y));
|
||||
for (let round = 0; round < 2000 && lead().level < level; round++) {
|
||||
if (lead().hp * 2 < lead().stats[0]) {
|
||||
rest();
|
||||
expect(world.mapId).toBe(map);
|
||||
}
|
||||
const pairs: { x: number; y: number; length: number }[] = [];
|
||||
for (let y = 0; y < world.map.height; y++)
|
||||
for (let x = 0; x < world.map.width; x++)
|
||||
if (isGrass(x, y) && isGrass(x + 1, y))
|
||||
try {
|
||||
pairs.push({ x, y, length: findPath(world, x, y).length });
|
||||
} catch {
|
||||
/* unreachable */
|
||||
}
|
||||
pairs.sort((a, b) => a.length - b.length);
|
||||
const { x, y } = pairs[0];
|
||||
walk(x, y);
|
||||
walk(x + 1, y);
|
||||
}
|
||||
expect(lead().level).toBeGreaterThanOrEqual(level);
|
||||
};
|
||||
|
||||
/** Take the stairs (or another warp) to `target`, trying each. */
|
||||
const stairs = (target: string) => {
|
||||
for (const warp of world.map.warps.filter((warp) => warp.targetMap === target)) {
|
||||
try {
|
||||
findPath(world, warp.x, warp.y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
walk(warp.x, warp.y);
|
||||
if (world.mapId === target) return;
|
||||
}
|
||||
throw new Error(`No way from ${world.mapId} to ${target}`);
|
||||
};
|
||||
/** Find a way through warps among `maps` to `target`, as a player
|
||||
* trying staircases would: depth first, backing out of dead ends. */
|
||||
const route = (
|
||||
target: string,
|
||||
maps: string[],
|
||||
depth = 6,
|
||||
goal?: { x: number; y: number },
|
||||
): boolean => {
|
||||
const reachable = () => {
|
||||
if (!goal) return true;
|
||||
try {
|
||||
findPath(world, goal.x, goal.y);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (world.mapId === target && reachable()) return true;
|
||||
if (!depth) return false;
|
||||
const here = world.save();
|
||||
for (const warp of world.map.warps) {
|
||||
if (!maps.includes(warp.targetMap) && warp.targetMap !== target) continue;
|
||||
try {
|
||||
findPath(world, warp.x, warp.y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const from = world.mapId;
|
||||
walk(warp.x, warp.y);
|
||||
if (world.mapId !== from && route(target, maps, depth - 1, goal)) return true;
|
||||
world.restore(here);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
/** Advance to the next menu choice, keeping its pages in the log. */
|
||||
const toChoice = () => {
|
||||
for (let tick = 0; tick < 2000; tick++) {
|
||||
const dialogue = world.dialogue;
|
||||
if (dialogue) {
|
||||
const page = dialogue.pages[dialogue.index];
|
||||
if (log.at(-1) !== page) log.push(page);
|
||||
if (dialogue.index === dialogue.pages.length - 1 && dialogue.choice) return dialogue.choice;
|
||||
if (dialogue.holdTicks !== undefined) world.tick();
|
||||
else world.advanceDialogue();
|
||||
} else world.tick();
|
||||
}
|
||||
throw new Error(`No choice appeared: ${world.diagnostic}`);
|
||||
};
|
||||
/** Choose a menu item by its label (or a pattern). */
|
||||
const choose = (label: string | RegExp) => {
|
||||
const choice = toChoice();
|
||||
if (choice.kind !== "menu") throw new Error(`Expected a menu, got ${choice.kind}`);
|
||||
const index = choice.items.findIndex((item) => (typeof label === "string" ? item === label : label.test(item)));
|
||||
expect(index, `${label} in ${choice.items.join(", ")}`).toBeGreaterThanOrEqual(0);
|
||||
world.choose(index);
|
||||
};
|
||||
/** Choose an item in the pack. */
|
||||
const choosePack = (item: number) => {
|
||||
const choice = toChoice();
|
||||
if (choice.kind !== "pack") throw new Error(`Expected the pack, got ${choice.kind}`);
|
||||
for (const [pocket, { entries }] of choice.pockets.entries()) {
|
||||
const index = entries.findIndex((entry) => entry.item === item);
|
||||
if (index >= 0) return world.choosePackEntry(pocket, index);
|
||||
}
|
||||
throw new Error(`Item ${item} is not in the pack.`);
|
||||
};
|
||||
const assets = existsSync(romPath)
|
||||
? new CrystalAssets(new Uint8Array(readFileSync(romPath)))
|
||||
: undefined;
|
||||
const p = new Playthrough(assets!);
|
||||
const {
|
||||
play,
|
||||
walk,
|
||||
cross,
|
||||
enter,
|
||||
exit,
|
||||
reload,
|
||||
lead,
|
||||
writeTestSave,
|
||||
heal,
|
||||
train,
|
||||
stairs,
|
||||
route,
|
||||
toChoice,
|
||||
choose,
|
||||
choosePack,
|
||||
talkTo,
|
||||
} = p;
|
||||
/** From Route 31: through the gate to Violet's Pokémon Center and back. */
|
||||
const restInViolet = () => {
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
@@ -389,56 +98,46 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(ROUTE_31, "right");
|
||||
};
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
log.length = 0;
|
||||
p.log.length = 0;
|
||||
});
|
||||
const traceFailures = () =>
|
||||
onTestFailed(() => {
|
||||
console.log(
|
||||
`${world.mapId} ${world.x},${world.y}; party ${JSON.stringify(
|
||||
world.state.party.map(({ species, level, hp }) => ({
|
||||
species,
|
||||
level,
|
||||
hp,
|
||||
})),
|
||||
)}\n${log.slice(-30).join("\n---\n")}`,
|
||||
);
|
||||
});
|
||||
const traceFailures = () => onTestFailed(() => console.log(p.trace()));
|
||||
|
||||
it("plays the introduction and Mom's scene", () => {
|
||||
world = new GameWorld(assets!, now, random);
|
||||
p.start();
|
||||
traceFailures();
|
||||
world.beginIntro();
|
||||
p.world.beginIntro();
|
||||
play([0, "", 1, "", 1, 0, "KAWA"]);
|
||||
expect(world.intro).toBe(false);
|
||||
expect(world.name).toBe("KAWA");
|
||||
expect(p.world.intro).toBe(false);
|
||||
expect(p.world.name).toBe("KAWA");
|
||||
reload();
|
||||
walk(7, 0);
|
||||
expect(world.mapId).toBe("24:6");
|
||||
expect(p.world.mapId).toBe("24:6");
|
||||
walk(9, 3);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(p.world.move("down")).toBe(true);
|
||||
play([1, 1, 1, 1, 1]);
|
||||
expect(log).toContain("KAWA received\nPOKéGEAR.");
|
||||
expect(p.log).toContain("KAWA received\nPOKéGEAR.");
|
||||
reload();
|
||||
});
|
||||
|
||||
it("meets Elm, takes Cyndaquil and the aide's Potion", () => {
|
||||
traceFailures();
|
||||
walk(6, 7);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(p.world.move("down")).toBe(true);
|
||||
play();
|
||||
expect(world.mapId).toBe(NEW_BARK);
|
||||
expect(p.world.mapId).toBe(NEW_BARK);
|
||||
enter(ELMS_LAB, "up");
|
||||
play([0, 1]);
|
||||
walk(6, 4);
|
||||
world.move("up");
|
||||
world.interact();
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
play([1, 1, ""]);
|
||||
expect(world.state.party.map((mon) => mon.species)).toEqual([155]);
|
||||
expect(p.world.state.party.map((mon) => mon.species)).toEqual([155]);
|
||||
walk(4, 7);
|
||||
expect(world.move("down")).toBe(true);
|
||||
expect(p.world.move("down")).toBe(true);
|
||||
play();
|
||||
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
|
||||
expect(p.world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
|
||||
reload();
|
||||
});
|
||||
|
||||
@@ -451,24 +150,24 @@ describe.skipIf(!existsSync(romPath))(
|
||||
cross("up", ROUTE_30);
|
||||
enter(MR_POKEMONS_HOUSE, "up");
|
||||
play();
|
||||
expect(world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG
|
||||
expect(world.state.flags.has(11)).toBe(true); // the Pokédex
|
||||
expect(p.world.state.bag.keyItems).toEqual([0x45]); // MYSTERY_EGG
|
||||
expect(p.world.state.flags.has(11)).toBe(true); // the Pokédex
|
||||
reload();
|
||||
});
|
||||
|
||||
it("takes Elm's call outside and meets the rival in Cherrygrove", () => {
|
||||
traceFailures();
|
||||
exit(ROUTE_30);
|
||||
expect(log.join("\n")).toContain("It's a disaster!");
|
||||
expect(world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON
|
||||
expect(p.log.join("\n")).toContain("It's a disaster!");
|
||||
expect(p.world.state.events.has(67)).toBe(true); // ELM_CALLED_ABOUT_STOLEN_POKEMON
|
||||
reload();
|
||||
cross("down", CHERRYGROVE);
|
||||
// The rival's scene blocks the way east to Route 29.
|
||||
cross("right", ROUTE_29);
|
||||
const rival = log.join("\n");
|
||||
const rival = p.log.join("\n");
|
||||
expect(rival).toContain("RIVAL ?");
|
||||
expect(rival).toContain("My name's ???.");
|
||||
expect(world.state.scenes[CHERRYGROVE]).toBe(0);
|
||||
expect(p.world.state.scenes[CHERRYGROVE]).toBe(0);
|
||||
reload();
|
||||
});
|
||||
|
||||
@@ -477,17 +176,17 @@ describe.skipIf(!existsSync(romPath))(
|
||||
cross("right", NEW_BARK);
|
||||
enter(ELMS_LAB, "up");
|
||||
walk(4, 6);
|
||||
expect(world.move("up")).toBe(true);
|
||||
expect(p.world.move("up")).toBe(true);
|
||||
play(["SILVER"]);
|
||||
expect(world.state.rivalName).toBe("SILVER");
|
||||
expect(p.world.state.rivalName).toBe("SILVER");
|
||||
reload();
|
||||
walk(5, 3);
|
||||
world.move("up"); // Elm stands at 5,2: this only turns the player.
|
||||
world.interact();
|
||||
p.world.move("up"); // Elm stands at 5,2: this only turns the player.
|
||||
p.world.interact();
|
||||
play();
|
||||
expect(world.state.bag.keyItems).toEqual([]);
|
||||
expect(p.world.state.bag.keyItems).toEqual([]);
|
||||
walk(4, 8);
|
||||
expect(world.state.bag.balls).toEqual([
|
||||
expect(p.world.state.bag.balls).toEqual([
|
||||
{ item: C.POKE_BALL, quantity: 5 },
|
||||
]);
|
||||
reload();
|
||||
@@ -498,13 +197,13 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(NEW_BARK);
|
||||
cross("left", ROUTE_29);
|
||||
cross("left", CHERRYGROVE);
|
||||
const pages = log.join("\n").replace(/\n/g, " ");
|
||||
const pages = p.log.join("\n").replace(/\n/g, " ");
|
||||
expect(pages).toContain("show you how to catch POKéMON?");
|
||||
expect(pages).toContain("That's how you do it");
|
||||
expect(world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON
|
||||
expect(world.state.scenes[ROUTE_29]).toBe(0);
|
||||
expect(p.world.state.events.has(66)).toBe(true); // LEARNED_TO_CATCH_POKEMON
|
||||
expect(p.world.state.scenes[ROUTE_29]).toBe(0);
|
||||
reload();
|
||||
const save = world.save();
|
||||
const save = p.world.save();
|
||||
expect(save.party[0].species).toBe(155);
|
||||
expect(save.pokedex.caught).toContain(155);
|
||||
expect(save.rivalName).toBe("SILVER");
|
||||
@@ -528,9 +227,9 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
// Mom worries when the player has not come home since the Egg; the
|
||||
// call rings on a step after Route 31's callback queued it.
|
||||
expect(world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), log.join("|")).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_TALKED_TO_MOM_AFTER_MYSTERY_EGG_QUEST), p.log.join("|")).toBe(true);
|
||||
exit(VIOLET, "left");
|
||||
expect(world.state.flags.size).toBeGreaterThan(0);
|
||||
expect(p.world.state.flags.size).toBeGreaterThan(0);
|
||||
reload();
|
||||
writeTestSave("violet-city");
|
||||
}, 120_000);
|
||||
@@ -546,12 +245,12 @@ describe.skipIf(!existsSync(romPath))(
|
||||
enter(SPROUT_1F, "up");
|
||||
expect(route(SPROUT_3F, [SPROUT_1F, SPROUT_2F])).toBe(true);
|
||||
walk(10, 3);
|
||||
world.move("up"); // Elder Li: this only turns the player
|
||||
world.interact();
|
||||
p.world.move("up"); // Elder Li: this only turns the player
|
||||
p.world.interact();
|
||||
play();
|
||||
const pages = log.join("\n").replace(/\n/g, " ");
|
||||
const pages = p.log.join("\n").replace(/\n/g, " ");
|
||||
expect(pages).toContain("FLASH");
|
||||
expect(world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_GOT_HM05_FLASH)).toBe(true);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
@@ -562,42 +261,42 @@ describe.skipIf(!existsSync(romPath))(
|
||||
heal(VIOLET_CENTER, VIOLET);
|
||||
enter(VIOLET_GYM, "up");
|
||||
walk(5, 2);
|
||||
world.move("up"); // Falkner: this only turns the player
|
||||
world.interact();
|
||||
p.world.move("up"); // Falkner: this only turns the player
|
||||
p.world.interact();
|
||||
play();
|
||||
expect(world.state.events.has(EVENT_BEAT_FALKNER), log.join("|")).toBe(true);
|
||||
expect(world.state.flags.has(ENGINE_ZEPHYRBADGE)).toBe(true);
|
||||
expect(world.state.events.has(EVENT_GOT_TM31_MUD_SLAP)).toBe(true);
|
||||
expect(world.state.bag.tmsHms[30]).toBe(1); // TM31
|
||||
expect(p.world.state.events.has(EVENT_BEAT_FALKNER), p.log.join("|")).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_ZEPHYRBADGE)).toBe(true);
|
||||
expect(p.world.state.events.has(EVENT_GOT_TM31_MUD_SLAP)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[30]).toBe(1); // TM31
|
||||
reload();
|
||||
exit(VIOLET);
|
||||
// SPECIALCALL_ASSISTANT rings on a step: the aide waits in the
|
||||
// Pokémon Center with the Egg.
|
||||
enter(VIOLET_CENTER, "up");
|
||||
expect(log.join("\n").replace(/\n/g, " ")).toContain("POKéMON CENTER");
|
||||
expect(p.log.join("\n").replace(/\n/g, " ")).toContain("POKéMON CENTER");
|
||||
// The aide's object flag (EVENT_ELMS_AIDE_IN_VIOLET_POKEMON_CENTER).
|
||||
const aide = world.assets.map(VIOLET_CENTER).objects[4];
|
||||
expect(world.state.events.has(aide.eventFlag)).toBe(false);
|
||||
const aide = p.world.assets.map(VIOLET_CENTER).objects[4];
|
||||
expect(p.world.state.events.has(aide.eventFlag)).toBe(false);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("takes the Egg from Elm's aide", () => {
|
||||
traceFailures();
|
||||
walk(4, 4);
|
||||
world.move("up"); // the aide: this only turns the player
|
||||
world.interact();
|
||||
p.world.move("up"); // the aide: this only turns the player
|
||||
p.world.interact();
|
||||
play([1]);
|
||||
expect(world.state.events.has(EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE)).toBe(true);
|
||||
const egg = world.state.party.at(-1)!;
|
||||
expect(p.world.state.events.has(EVENT_GOT_TOGEPI_EGG_FROM_ELMS_AIDE)).toBe(true);
|
||||
const egg = p.world.state.party.at(-1)!;
|
||||
expect(egg).toMatchObject({
|
||||
species: TOGEPI,
|
||||
egg: true,
|
||||
nickname: "EGG",
|
||||
hp: 0,
|
||||
happiness: world.assets.pokemon.baseData(TOGEPI).eggCycles,
|
||||
happiness: p.world.assets.pokemon.baseData(TOGEPI).eggCycles,
|
||||
});
|
||||
// An Egg is neither seen nor caught.
|
||||
expect(world.state.pokedex.caught.has(TOGEPI)).toBe(false);
|
||||
expect(p.world.state.pokedex.caught.has(TOGEPI)).toBe(false);
|
||||
reload();
|
||||
});
|
||||
|
||||
@@ -606,30 +305,30 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(VIOLET);
|
||||
exit(ROUTE_31_GATE, "right");
|
||||
exit(ROUTE_31, "right");
|
||||
wanted = BELLSPROUT;
|
||||
p.wanted = BELLSPROUT;
|
||||
try {
|
||||
for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++) {
|
||||
if (!world.state.bag.balls.length) break;
|
||||
for (let round = 0; round < 400 && !p.world.state.party.some((mon) => mon.species === BELLSPROUT); round++) {
|
||||
if (!p.world.state.bag.balls.length) break;
|
||||
train(lead().level + 1, restInViolet);
|
||||
}
|
||||
} finally {
|
||||
wanted = 0;
|
||||
p.wanted = 0;
|
||||
}
|
||||
const bellsprout = world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||
expect(bellsprout, log.slice(-20).join("|")).toBeGreaterThan(0);
|
||||
const bellsprout = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||
expect(bellsprout, p.log.slice(-20).join("|")).toBeGreaterThan(0);
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
exit(VIOLET, "left");
|
||||
enter(KYLES_HOUSE, "up");
|
||||
const kyle = world.objects().find((object) => object.sprite === world.assets.map(KYLES_HOUSE).objects[1].sprite && object.id === world.assets.map(KYLES_HOUSE).objects[1].id)!;
|
||||
const kyle = p.world.objects().find((object) => object.sprite === p.world.assets.map(KYLES_HOUSE).objects[1].sprite && object.id === p.world.assets.map(KYLES_HOUSE).objects[1].id)!;
|
||||
walk(kyle.x - 1, kyle.y);
|
||||
world.move("right");
|
||||
world.interact();
|
||||
p.world.move("right");
|
||||
p.world.interact();
|
||||
play([1, bellsprout]);
|
||||
const onix = world.state.party.at(-1)!;
|
||||
const onix = p.world.state.party.at(-1)!;
|
||||
expect(onix).toMatchObject({ species: ONIX, nickname: "ROCKY", ot: "KYLE", otId: 48926, dvs: 0x9666 });
|
||||
expect(world.state.party.some((mon) => mon.species === BELLSPROUT && !mon.egg)).toBe(false);
|
||||
expect(world.state.tradeFlags).toBe(1 << 1);
|
||||
expect(world.state.pokedex.caught.has(ONIX)).toBe(true);
|
||||
expect(p.world.state.party.some((mon) => mon.species === BELLSPROUT && !mon.egg)).toBe(false);
|
||||
expect(p.world.state.tradeFlags).toBe(1 << 1);
|
||||
expect(p.world.state.pokedex.caught.has(ONIX)).toBe(true);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
@@ -638,100 +337,165 @@ describe.skipIf(!existsSync(romPath))(
|
||||
exit(VIOLET);
|
||||
exit(ROUTE_31_GATE, "right");
|
||||
exit(ROUTE_31, "right");
|
||||
wanted = BELLSPROUT;
|
||||
p.wanted = BELLSPROUT;
|
||||
try {
|
||||
for (let round = 0; round < 400 && !world.state.party.some((mon) => mon.species === BELLSPROUT); round++)
|
||||
for (let round = 0; round < 400 && !p.world.state.party.some((mon) => mon.species === BELLSPROUT); round++)
|
||||
train(lead().level + 1, restInViolet);
|
||||
} finally {
|
||||
wanted = 0;
|
||||
p.wanted = 0;
|
||||
}
|
||||
const slot = world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||
const slot = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||
expect(slot).toBeGreaterThan(0);
|
||||
const nickname = world.state.party[slot].nickname;
|
||||
const nickname = p.world.state.party[slot].nickname;
|
||||
// PACK, the TM/HM pocket's HM05, USE, YES, then the Bellsprout.
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
expect(p.world.openStartMenu()).toBe(true);
|
||||
choose("PACK");
|
||||
choosePack(HM05);
|
||||
choose("USE");
|
||||
expect(toChoice().kind).toBe("yesno");
|
||||
world.choose(1);
|
||||
p.world.choose(1);
|
||||
const party = toChoice();
|
||||
expect(party.kind === "menu" && party.details?.[slot]).toBe("ABLE");
|
||||
world.choose(slot);
|
||||
if (world.state.party[slot].moves.every(Boolean)) {
|
||||
p.world.choose(slot);
|
||||
if (p.world.state.party[slot].moves.every(Boolean)) {
|
||||
// A full move list asks which move to forget: the first one.
|
||||
expect(toChoice().kind).toBe("yesno");
|
||||
world.choose(1);
|
||||
p.world.choose(1);
|
||||
toChoice();
|
||||
world.choose(0);
|
||||
p.world.choose(0);
|
||||
}
|
||||
for (let i = 0; i < 10 && world.dialogue?.choice?.kind !== "pack"; i++) toChoice();
|
||||
expect(log.join("\n").replace(/\n/g, " ")).toContain("learned FLASH!");
|
||||
for (let i = 0; i < 10 && p.world.dialogue?.choice?.kind !== "pack"; i++) toChoice();
|
||||
expect(p.log.join("\n").replace(/\n/g, " ")).toContain("learned FLASH!");
|
||||
// The menus work on a draft that is kept once they close.
|
||||
for (let i = 0; i < 10 && world.busy; i++) {
|
||||
if (world.dialogue?.choice) world.advanceDialogue();
|
||||
else world.tick();
|
||||
for (let i = 0; i < 10 && p.world.busy; i++) {
|
||||
if (p.world.dialogue?.choice) p.world.advanceDialogue();
|
||||
else p.world.tick();
|
||||
}
|
||||
play();
|
||||
expect(world.state.party[slot].moves).toContain(FLASH);
|
||||
expect(world.state.bag.tmsHms[50 + 4]).toBe(1); // an HM is kept
|
||||
expect(p.world.state.party[slot].moves).toContain(FLASH);
|
||||
expect(p.world.state.bag.tmsHms[50 + 4]).toBe(1); // an HM is kept
|
||||
// Dark Cave is dark until FLASH, which the ZEPHYRBADGE allows.
|
||||
enter(DARK_CAVE, "up");
|
||||
expect(world.state.flash).toBe(false);
|
||||
expect(world.openStartMenu()).toBe(true);
|
||||
expect(p.world.state.flash).toBe(false);
|
||||
expect(p.world.openStartMenu()).toBe(true);
|
||||
choose("POKéMON");
|
||||
choose(nickname);
|
||||
choose("FLASH");
|
||||
play();
|
||||
expect(world.state.flash, log.slice(-6).join("|")).toBe(true);
|
||||
expect(p.world.state.flash, p.log.slice(-6).join("|")).toBe(true);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("registers Bug Catcher Wade's number and reads Earl's blackboard", () => {
|
||||
traceFailures();
|
||||
exit(ROUTE_31);
|
||||
// Talk to Wade wherever he stands (he walks up to battle on sight).
|
||||
// A trainer who spots the player on the way battles and moves, so
|
||||
// the approach is planned again.
|
||||
const talkTo = (id: number) => {
|
||||
for (let attempt = 0; attempt < 4; attempt++)
|
||||
for (const [dx, dy, face] of [[-1, 0, "right"], [1, 0, "left"], [0, 1, "up"], [0, -1, "down"]] as const) {
|
||||
const target = world.objects().find((object) => object.id === id)!;
|
||||
const x = target.x + dx,
|
||||
y = target.y + dy;
|
||||
try {
|
||||
if (!(world.x === x && world.y === y)) walk(x, y);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const now = world.objects().find((object) => object.id === id)!;
|
||||
if (now.x !== target.x || now.y !== target.y) break;
|
||||
world.move(face);
|
||||
world.interact();
|
||||
return;
|
||||
}
|
||||
throw new Error(`No way to talk to object ${id}`);
|
||||
};
|
||||
talkTo(world.map.objects[2].id);
|
||||
talkTo(p.world.map.objects[2].id);
|
||||
play([1]);
|
||||
const pages = log.join("\n").replace(/\n/g, " ");
|
||||
const pages = p.log.join("\n").replace(/\n/g, " ");
|
||||
expect(pages).toContain("BUG CATCHER WADE");
|
||||
expect(world.state.phone).toContain(PHONE_BUG_CATCHER_WADE);
|
||||
expect(p.world.state.phone).toContain(PHONE_BUG_CATCHER_WADE);
|
||||
reload();
|
||||
restInViolet();
|
||||
exit(ROUTE_31_GATE, "left");
|
||||
exit(VIOLET, "left");
|
||||
enter(EARLS_ACADEMY, "up");
|
||||
walk(3, 1);
|
||||
world.move("up");
|
||||
world.interact();
|
||||
p.world.move("up");
|
||||
p.world.interact();
|
||||
choose("PSN");
|
||||
toChoice();
|
||||
expect(log.join("\n").replace(/\n/g, " ")).toContain("steadily loses HP");
|
||||
expect(p.log.join("\n").replace(/\n/g, " ")).toContain("steadily loses HP");
|
||||
// B leaves the blackboard.
|
||||
world.choose(-1);
|
||||
p.world.choose(-1);
|
||||
play();
|
||||
expect(world.busy).toBe(false);
|
||||
expect(p.world.busy).toBe(false);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("takes Route 32 and Union Cave to Azalea Town", () => {
|
||||
traceFailures();
|
||||
exit(VIOLET);
|
||||
cross("down", ROUTE_32);
|
||||
// The man at Route 32's north end gives the Miracle Seed after the
|
||||
// ZEPHYRBADGE and the Egg.
|
||||
expect(p.world.state.bag.items.some((slot) => slot.item === MIRACLE_SEED)).toBe(false);
|
||||
p.talkTo(p.world.map.objects[7].id);
|
||||
play();
|
||||
expect(p.world.state.bag.items.some((slot) => slot.item === MIRACLE_SEED)).toBe(true);
|
||||
stairs(UNION_CAVE_1F);
|
||||
stairs(ROUTE_33);
|
||||
cross("left", AZALEA);
|
||||
reload();
|
||||
}, 240_000);
|
||||
|
||||
it("follows Kurt into the Slowpoke Well and drives out Team Rocket", () => {
|
||||
traceFailures();
|
||||
enter(KURTS_HOUSE, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play();
|
||||
expect(p.log.join(" ")).toContain("Old KURT is on his\nway!");
|
||||
exit(AZALEA);
|
||||
stairs(SLOWPOKE_WELL_B1F);
|
||||
// The three grunts, then the one by the boulder, whose defeat ends
|
||||
// the scene in Kurt's house.
|
||||
for (const index of [0, 2, 3, 1]) {
|
||||
const id = p.world.map.objects[index].id;
|
||||
if (!p.world.objects().some((object) => object.id === id)) continue;
|
||||
talkTo(id);
|
||||
play();
|
||||
}
|
||||
expect(p.world.mapId).toBe(KURTS_HOUSE);
|
||||
expect(p.world.state.events.has(EVENT_CLEARED_SLOWPOKE_WELL)).toBe(true);
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play();
|
||||
expect(p.world.state.bag.balls.some((slot) => slot.item === LURE_BALL)).toBe(true);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("beats Bugsy for the HIVEBADGE and the rival on the way to Ilex Forest", () => {
|
||||
traceFailures();
|
||||
exit(AZALEA);
|
||||
heal(AZALEA_CENTER, AZALEA);
|
||||
enter(AZALEA_GYM, "up");
|
||||
talkTo(p.world.map.objects[0].id);
|
||||
play();
|
||||
expect(p.world.state.events.has(EVENT_BEAT_BUGSY)).toBe(true);
|
||||
expect(p.world.state.flags.has(ENGINE_HIVEBADGE)).toBe(true);
|
||||
expect(p.world.state.bag.tmsHms[48]).toBe(1); // TM49 FURY CUTTER
|
||||
exit(AZALEA);
|
||||
heal(AZALEA_CENTER, AZALEA);
|
||||
// The rival waits at the town's west end (SCENE_AZALEATOWN_RIVAL_BATTLE).
|
||||
stairs(ILEX_GATE);
|
||||
expect(p.world.state.events.has(EVENT_RIVAL_AZALEA_TOWN)).toBe(true);
|
||||
reload();
|
||||
}, 120_000);
|
||||
|
||||
it("herds the Farfetch'd back in Ilex Forest and cuts through to Route 34", () => {
|
||||
traceFailures();
|
||||
stairs(ILEX_FOREST);
|
||||
const STEP = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] } as const;
|
||||
const bird = p.world.map.objects[0].id;
|
||||
// wFarfetchdPosition 1 → 2 from any side, 2 → 8 and 8 → 9 facing
|
||||
// down, then 9 → 10 facing left.
|
||||
for (const face of ["up", "down", "down", "left"] as const) {
|
||||
const at = p.world.objects().find((object) => object.id === bird)!;
|
||||
walk(at.x - STEP[face][0], at.y - STEP[face][1]);
|
||||
p.world.move(face);
|
||||
p.world.interact();
|
||||
play();
|
||||
}
|
||||
expect(p.world.state.memory.wFarfetchdPosition).toBe(10);
|
||||
expect(p.world.state.events.has(EVENT_HERDED_FARFETCHD)).toBe(true);
|
||||
talkTo(p.world.map.objects[2].id); // the charcoal master
|
||||
play();
|
||||
expect(p.world.state.bag.tmsHms[50]).toBe(1); // HM01
|
||||
reload();
|
||||
const bellsprout = p.world.state.party.findIndex((mon) => mon.species === BELLSPROUT);
|
||||
p.teach(HM01, bellsprout);
|
||||
expect(p.world.state.party[bellsprout].moves).toContain(CUT);
|
||||
p.cut(8, 25);
|
||||
expect(p.log).toContain("BELLSPROUT used\nCUT!");
|
||||
stairs(ROUTE_34_GATE);
|
||||
reload();
|
||||
}, 120_000);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { PokemonData } from "../src/game/rom/pokemon";
|
||||
import { JOHTO } from "../src/game/rom/profile";
|
||||
import { RomReader } from "../src/game/rom/reader";
|
||||
import { generatePartyMon, updateUnownDex } from "../src/game/party/pokemon";
|
||||
import { unownUnlocked } from "../src/game/battle/battle";
|
||||
import {
|
||||
initialPieces,
|
||||
moveCursor,
|
||||
solved,
|
||||
} from "../src/game/scripts/unown-puzzle";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
import { OW } from "../src/game/world/constants";
|
||||
import { battleFixtureWorld } from "./battle-fixture";
|
||||
import { settle } from "./world-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const UNOWN = 201;
|
||||
|
||||
describe("GetUnownLetter", () => {
|
||||
it("takes the middle two bits of each DV, divided by 10, plus one", () => {
|
||||
expect(PokemonData.unownLetter(0x0000)).toBe(1);
|
||||
// Every middle bit set: $ff / 10 = 25, letter Z.
|
||||
expect(PokemonData.unownLetter(0x6666)).toBe(26);
|
||||
// Attack %0110 0000 alone: $c0 / 10 = 19, letter T.
|
||||
expect(PokemonData.unownLetter(0x6000)).toBe(20);
|
||||
// Special's bits are the lowest: 3 / 10 = 0.
|
||||
expect(PokemonData.unownLetter(0x0006)).toBe(1);
|
||||
for (let dvs = 0; dvs < 0x10000; dvs += 37) {
|
||||
const letter = PokemonData.unownLetter(dvs);
|
||||
expect(letter).toBeGreaterThanOrEqual(1);
|
||||
expect(letter).toBeLessThanOrEqual(26);
|
||||
}
|
||||
});
|
||||
it("records each new letter once, and the first in the party", () => {
|
||||
const record = { unownDex: [] as number[], firstUnownSeen: 0 };
|
||||
updateUnownDex(record, { species: UNOWN, dvs: 0x6666 }, false);
|
||||
expect(record).toEqual({ unownDex: [26], firstUnownSeen: 0 });
|
||||
updateUnownDex(record, { species: UNOWN, dvs: 0x0000 });
|
||||
updateUnownDex(record, { species: UNOWN, dvs: 0x6666 });
|
||||
updateUnownDex(record, { species: 16, dvs: 0x1234 });
|
||||
expect(record).toEqual({ unownDex: [26, 1], firstUnownSeen: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Unown puzzle's rules", () => {
|
||||
it("moves the cursor around START and CANCEL", () => {
|
||||
expect(moveCursor(0, "up")).toBeUndefined();
|
||||
expect(moveCursor(0, "left")).toBeUndefined();
|
||||
expect(moveCursor(5, "right")).toBeUndefined();
|
||||
expect(moveCursor(7, "down")).toBe(13);
|
||||
// Below the inner pieces' last row lies START/CANCEL.
|
||||
expect(moveCursor(25, "down")).toBeUndefined();
|
||||
expect(moveCursor(24, "down")).toBe(30);
|
||||
expect(moveCursor(30, "right")).toBe(35);
|
||||
expect(moveCursor(35, "left")).toBe(30);
|
||||
expect(moveCursor(30, "down")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("the Ruins of Alph with the local ROM", () => {
|
||||
const bytes = existsSync(romPath) ? new Uint8Array(readFileSync(romPath)) : new Uint8Array();
|
||||
const assets = () => new CrystalAssets(bytes);
|
||||
const KABUTO_CHAMBER = "3:24",
|
||||
INNER_CHAMBER = "3:27";
|
||||
const EVENT_SOLVED_KABUTO_PUZZLE = 0x2a1;
|
||||
|
||||
it("places the sixteen pieces on the border, and knows the solution", () => {
|
||||
const reader = new RomReader(bytes);
|
||||
let seed = 3;
|
||||
const pieces = initialPieces({
|
||||
reader,
|
||||
random: () => (seed = (seed * 75 + 74) % 65537) & 0xff,
|
||||
});
|
||||
expect(pieces.filter(Boolean).sort((a, b) => a - b)).toEqual(
|
||||
Array.from({ length: 16 }, (_, i) => i + 1),
|
||||
);
|
||||
// Only the outer ring and the corners of the bottom row are used.
|
||||
pieces.forEach((piece, place) => {
|
||||
const row = Math.floor(place / 6),
|
||||
column = place % 6;
|
||||
if (piece) expect(row === 0 || row === 5 || column === 0 || column === 5).toBe(true);
|
||||
});
|
||||
const done = Array<number>(36).fill(0);
|
||||
for (let piece = 1; piece <= 16; piece++)
|
||||
done[7 + ((piece - 1) >> 2) * 6 + ((piece - 1) & 3)] = piece;
|
||||
expect(solved(reader, done)).toBe(true);
|
||||
expect(solved(reader, pieces)).toBe(false);
|
||||
});
|
||||
|
||||
it("only lets wild Unown appear with letters from the unlocked sets", () => {
|
||||
const data = new PokemonData(new RomReader(bytes));
|
||||
for (let dvs = 0; dvs < 0x10000; dvs += 7) {
|
||||
const letter = PokemonData.unownLetter(dvs);
|
||||
expect(unownUnlocked(data, 0, dvs)).toBe(false);
|
||||
expect(unownUnlocked(data, 1, dvs)).toBe(letter <= 11);
|
||||
expect(unownUnlocked(data, 1 << 3, dvs)).toBe(letter >= 24);
|
||||
expect(unownUnlocked(data, 0x0f, dvs)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
/** Solve the puzzle on screen as a player would: read the pieces from
|
||||
* the tilemap, then carry each to its place. */
|
||||
const solvePuzzle = (world: GameWorld) => {
|
||||
const r = world.assets.reader;
|
||||
const corners = Array.from({ length: 17 }, (_, piece) =>
|
||||
r.byte(JOHTO["GetCurrentPuzzlePieceVTileCorner.Corners"] + piece),
|
||||
);
|
||||
const placeCell = (place: number) => {
|
||||
const cell = r.word(JOHTO.UnownPuzzleCoordData + place * 6 + 2) - 0xc4a0;
|
||||
return cell;
|
||||
};
|
||||
const screen = () => {
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "screen") throw new Error(`No puzzle on screen: ${world.diagnostic}`);
|
||||
return choice.screen;
|
||||
};
|
||||
const pieces = () =>
|
||||
Array.from({ length: 36 }, (_, place) => {
|
||||
const tile = screen().map[placeCell(place)];
|
||||
const piece = corners.indexOf(tile, 1);
|
||||
return piece > 0 ? piece : 0;
|
||||
});
|
||||
let cursor = 0;
|
||||
const goTo = (target: number) => {
|
||||
// Breadth first over the cursor's moves.
|
||||
const previous = new Map<number, [number, string]>([[cursor, [-1, ""]]]);
|
||||
const queue = [cursor];
|
||||
while (queue.length) {
|
||||
const at = queue.shift()!;
|
||||
for (const direction of ["up", "down", "left", "right"]) {
|
||||
const next = moveCursor(at, direction);
|
||||
if (next === undefined || previous.has(next)) continue;
|
||||
previous.set(next, [at, direction]);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
const path: string[] = [];
|
||||
for (let at = target; at !== cursor; at = previous.get(at)![0])
|
||||
path.unshift(previous.get(at)![1]);
|
||||
for (const direction of path) world.choose(direction);
|
||||
cursor = target;
|
||||
};
|
||||
for (let piece = 1; piece <= 16; piece++) {
|
||||
const from = pieces().indexOf(piece);
|
||||
const to = 7 + ((piece - 1) >> 2) * 6 + ((piece - 1) & 3);
|
||||
goTo(from);
|
||||
world.choose("a");
|
||||
goTo(to);
|
||||
world.choose("a");
|
||||
}
|
||||
};
|
||||
|
||||
it("solves the Kabuto puzzle, unlocks A to K and drops into the inner chamber", () => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
world.restore({ ...world.save(), map: KABUTO_CHAMBER, x: 3, y: 3, direction: "up" });
|
||||
settle(world);
|
||||
// The floor is closed until the puzzle is solved.
|
||||
expect(world.assets.permission(world.assets.collision(world.map, 3, 3))).toBe(0);
|
||||
world.interact();
|
||||
for (let i = 0; i < 20 && world.dialogue?.choice?.kind !== "screen"; i++) world.tick();
|
||||
// START quits without solving.
|
||||
world.openStartMenu();
|
||||
settle(world);
|
||||
expect(world.state.events.has(EVENT_SOLVED_KABUTO_PUZZLE)).toBe(false);
|
||||
world.interact();
|
||||
for (let i = 0; i < 20 && world.dialogue?.choice?.kind !== "screen"; i++) world.tick();
|
||||
solvePuzzle(world);
|
||||
// The finished picture waits for A or B.
|
||||
expect(world.dialogue?.choice?.kind).toBe("screen");
|
||||
world.choose("a");
|
||||
settle(world);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
expect(world.mapId).toBe(INNER_CHAMBER);
|
||||
expect(world.state.events.has(EVENT_SOLVED_KABUTO_PUZZLE)).toBe(true);
|
||||
expect(world.state.flags.has(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the wall's word in Unown letters", () => {
|
||||
const world = battleFixtureWorld(assets());
|
||||
world.restore({ ...world.save(), map: KABUTO_CHAMBER, x: 3, y: 1, direction: "up" });
|
||||
settle(world);
|
||||
world.interact();
|
||||
for (let i = 0; i < 40 && world.dialogue?.choice?.kind !== "screen"; i++) {
|
||||
if (world.dialogue && !world.dialogue.choice) world.advanceDialogue();
|
||||
else world.tick();
|
||||
}
|
||||
const choice = world.dialogue?.choice;
|
||||
expect(choice?.kind).toBe("screen");
|
||||
if (choice?.kind !== "screen") return;
|
||||
expect(choice.screen.overlay).toBe(true);
|
||||
// UNOWNWORDS_ESCAPE: six letters, two tiles wide, in bank 1.
|
||||
const drawn = choice.screen.map.filter((tile) => tile >= 0x100);
|
||||
expect(drawn.length).toBe(6 * 4);
|
||||
world.choose("b");
|
||||
settle(world);
|
||||
expect(world.busy).toBe(false);
|
||||
});
|
||||
|
||||
it("meets Unown in the inner chamber once a set is unlocked, and records them", () => {
|
||||
// The battle fixture's encounter tables would replace the chamber's.
|
||||
let seed = 5;
|
||||
const world = new GameWorld(assets(), () => new Date("2026-09-22T12:00:00"), () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
});
|
||||
world.state.party.push(
|
||||
generatePartyMon(world.assets.pokemon, {
|
||||
species: 157, level: 50, item: 0, otId: world.playerId, ot: "CHRIS", dvs: 0x8888, caughtData: 0,
|
||||
}),
|
||||
);
|
||||
world.state.flags.add(OW.ENGINE_UNLOCKED_UNOWNS_A_TO_K);
|
||||
world.state.bag.balls = [{ item: 1, quantity: 30 }]; // MASTER BALL
|
||||
world.restore({ ...world.save(), map: INNER_CHAMBER, x: 10, y: 8 });
|
||||
settle(world);
|
||||
let caught = false;
|
||||
for (let step = 0; step < 400 && !caught; step++) {
|
||||
world.move(step & 1 ? "up" : "down");
|
||||
for (let tick = 0; tick < 4000; tick++) {
|
||||
const battle = world.battle;
|
||||
if (battle) {
|
||||
if (battle.prompt?.kind === "action") {
|
||||
const ball = world.liveState.bag.balls.findIndex((slot) => slot.item === 1);
|
||||
if (battle.engine.enemy.mon.species === UNOWN && ball >= 0)
|
||||
world.battleAction({ action: "item", item: 1, slot: ball });
|
||||
else world.battleAction({ action: "run" });
|
||||
} else if (battle.prompt?.kind === "name") world.battleName("");
|
||||
else if (battle.prompt?.kind === "yesno") world.battleChoose(0);
|
||||
else if (battle.message?.prompt) world.interact();
|
||||
else world.tick();
|
||||
continue;
|
||||
}
|
||||
if (world.dialogue) {
|
||||
if (world.dialogue.choice) world.choose(0);
|
||||
else world.advanceDialogue();
|
||||
continue;
|
||||
}
|
||||
if (!world.busy) break;
|
||||
world.tick();
|
||||
}
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
caught = world.state.party.some((member) => member.species === UNOWN);
|
||||
}
|
||||
expect(caught).toBe(true);
|
||||
const unown = world.state.party.find((member) => member.species === UNOWN)!;
|
||||
const letter = PokemonData.unownLetter(unown.dvs);
|
||||
expect(letter).toBeLessThanOrEqual(11);
|
||||
expect(world.state.unownDex).toEqual([letter]);
|
||||
expect(world.state.firstUnownSeen).toBeGreaterThanOrEqual(1);
|
||||
// VAR_UNOWNCOUNT counts the letters.
|
||||
expect(world.state.unownDex.length).toBe(1);
|
||||
});
|
||||
});
|
||||
+20
-1
@@ -63,6 +63,22 @@ const DIRECTIONS = [
|
||||
["right", 1, 0],
|
||||
] as const;
|
||||
|
||||
/** .ledge_table's side-wall directions (GetMovementPermissions): side
|
||||
* walls ($b0) and buoys ($c0) block these directions. */
|
||||
const EDGES: readonly (readonly Direction[])[] = [
|
||||
["right"],
|
||||
["left"],
|
||||
["up"],
|
||||
["down"],
|
||||
["down", "right"],
|
||||
["down", "left"],
|
||||
["up", "right"],
|
||||
["up", "left"],
|
||||
];
|
||||
const OPPOSITE = { up: "down", down: "up", left: "right", right: "left" } as const;
|
||||
const edges = (tile: number): readonly Direction[] =>
|
||||
(tile & 0xf0) === 0xb0 || (tile & 0xf0) === 0xc0 ? EDGES[tile & 7] : [];
|
||||
|
||||
/** Shortest walkable path on the current map, avoiding other warps. */
|
||||
export function findPath(
|
||||
world: GameWorld,
|
||||
@@ -82,8 +98,11 @@ export function findPath(
|
||||
y = node.y + dy;
|
||||
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
|
||||
continue;
|
||||
const tile = world.assets.collision(world.map, x, y);
|
||||
if (world.assets.permission(tile) !== 0) continue;
|
||||
if (
|
||||
world.assets.permission(world.assets.collision(world.map, x, y)) !== 0
|
||||
edges(world.assets.collision(world.map, node.x, node.y)).includes(direction) ||
|
||||
edges(tile).includes(OPPOSITE[direction])
|
||||
)
|
||||
continue;
|
||||
if (world.objects().some((object) => object.x === x && object.y === y))
|
||||
|
||||
Reference in new issue
Block a user