Finish the opening milestone's switching, items and acceptance run

- Decode evolution lists from the ROM; all 251 match the pinned PRET source.
- Compare CalcMonStats and CalcExpAtLevel against the original routines in
  the reference emulator.
- Add a continuous opening playthrough from the intro to the catching
  tutorial, reloading a JSON save after every stage.
- Port player switching in battle: the PKMN menu, forced choices after a
  faint, Shift mode's offer and double faints, with the original texts.
- Port HP and status healing items in battle through the ROM's ItemEffects,
  HealingHPAmounts and StatusHealingActions tables.
- Add the battle party menu and the Items pocket to the UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-09-30 11:04:11 +02:00
1 parent eb03a20d8f
commit f8e0f9c65a
20 files changed
+1902 -96

No files matched your search

+321
View File
@@ -0,0 +1,321 @@
import { beforeEach, describe, expect, it, onTestFailed } from "vitest";
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";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const NEW_BARK = "24:4",
ELMS_LAB = "24:5",
ROUTE_29 = "24:3",
CHERRYGROVE = "26:3",
ROUTE_30 = "26:1",
MR_POKEMONS_HOUSE = "26:10";
/** The acceptance run for PLAN section 2: a new game played from the
* introduction to the catching tutorial through the world's own inputs,
* without fixtures or story flags set by hand. Every stage ends with a
* save serialized to JSON and loaded into a fresh world. The stages share
* one world and run in order, so run the file rather than a single stage. */
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[] = [];
/** Run scripts and battles until the player is free again. Battles use
* the first 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;
if (engine.battleMode === C.WILD_BATTLE && mon.hp * 2 < mon.maxHp)
world.battleAction({ action: "run" });
else
world.battleAction({
action: "fight",
move: Math.max(
0,
mon.pp.findIndex((pp, i) => mon.moves[i] && pp & 63),
),
});
} else if (prompt?.kind === "yesno") world.battleChoose(1);
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);
};
beforeEach(() => {
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")}`,
);
});
it("plays the introduction and Mom's scene", () => {
world = new GameWorld(assets!, now, random);
traceFailures();
world.beginIntro();
play([0, "", 1, "", 1, 0, "KAWA"]);
expect(world.intro).toBe(false);
expect(world.name).toBe("KAWA");
reload();
walk(7, 0);
expect(world.mapId).toBe("24:6");
walk(9, 3);
expect(world.move("down")).toBe(true);
play([1, 1, 1, 1, 1]);
expect(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);
play();
expect(world.mapId).toBe(NEW_BARK);
enter(ELMS_LAB, "up");
play([0, 1]);
walk(6, 4);
world.move("up");
world.interact();
play([1, 1, ""]);
expect(world.state.party.map((mon) => mon.species)).toEqual([155]);
walk(4, 7);
expect(world.move("down")).toBe(true);
play();
expect(world.state.bag.items).toEqual([{ item: 18, quantity: 1 }]);
reload();
});
it("walks to Mr. Pokémon's house through Route 29, Cherrygrove and Route 30", () => {
traceFailures();
exit(NEW_BARK);
cross("left", ROUTE_29);
cross("left", CHERRYGROVE);
reload();
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
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
reload();
cross("down", CHERRYGROVE);
// The rival's scene blocks the way east to Route 29.
cross("right", ROUTE_29);
const rival = log.join("\n");
expect(rival).toContain("RIVAL ?");
expect(rival).toContain("My name's ???.");
expect(world.state.scenes[CHERRYGROVE]).toBe(0);
reload();
});
it("names the rival for the officer and returns the Egg to Elm", () => {
traceFailures();
cross("right", NEW_BARK);
enter(ELMS_LAB, "up");
walk(4, 6);
expect(world.move("up")).toBe(true);
play(["SILVER"]);
expect(world.state.rivalName).toBe("SILVER");
reload();
walk(5, 3);
world.move("up"); // Elm stands at 5,2: this only turns the player.
world.interact();
play();
expect(world.state.bag.keyItems).toEqual([]);
walk(4, 8);
expect(world.state.bag.balls).toEqual([
{ item: C.POKE_BALL, quantity: 5 },
]);
reload();
});
it("watches the catching tutorial on Route 29 and reaches Cherrygrove", () => {
traceFailures();
exit(NEW_BARK);
cross("left", ROUTE_29);
cross("left", CHERRYGROVE);
const pages = 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);
reload();
const save = world.save();
expect(save.party[0].species).toBe(155);
expect(save.pokedex.caught).toContain(155);
expect(save.rivalName).toBe("SILVER");
expect(save.intro).toBe(false);
});
},
);