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:
1 parent
eb03a20d8f
commit
f8e0f9c65a
20 files changed
+1902
-96
No files matched your search
@@ -58,7 +58,7 @@ async function finish(page: Page, action: "fight" | "run" = "fight") {
|
||||
if (action === "run") await menu.getByRole("button", { name: "RUN", exact: true }).click();
|
||||
else {
|
||||
await fight.click();
|
||||
await menu.getByRole("button", { name: /TACKLE|TRANSFORM/ }).click();
|
||||
await menu.getByRole("button", { name: /TACKLE|TRANSFORM|SCRATCH/ }).click();
|
||||
}
|
||||
} else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) {
|
||||
await menu.getByRole("button", { name: "YES", exact: true }).click();
|
||||
@@ -115,6 +115,8 @@ test("keyboard and touch D-pad navigate battle moves, cancel and run", async ({
|
||||
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible();
|
||||
// A real pointer press on the on-screen D-pad (synthetic events cannot be captured).
|
||||
await page.locator('[data-game-button="down"]').click();
|
||||
await expect(menu.getByRole("button", { name: "PKMN", exact: true })).toHaveClass(/battle-selected/);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(menu.getByRole("button", { name: "PACK", exact: true })).toHaveClass(/battle-selected/);
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(menu.getByRole("button", { name: "RUN", exact: true })).toHaveClass(/battle-selected/);
|
||||
@@ -178,6 +180,7 @@ test("battle PACK throws Poké Balls, names the catch and saves it", async ({ pa
|
||||
named = true;
|
||||
} else if (await pack.count()) {
|
||||
await pack.click();
|
||||
await menu.getByRole("button", { name: "BALL POCKET", exact: true }).click();
|
||||
const ball = menu.getByRole("button", { name: /POKé BALL/ });
|
||||
await expect(ball).toContainText("×");
|
||||
await ball.click();
|
||||
@@ -206,7 +209,67 @@ test("battle PACK closes with B and returns to the command menu", async ({ page
|
||||
await page.clock.runFor(1200);
|
||||
}
|
||||
await menu.getByRole("button", { name: "PACK", exact: true }).click();
|
||||
// BattlePack opens on the Items pocket first.
|
||||
await expect(menu.getByRole("button", { name: "ITEM POCKET", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await menu.getByRole("button", { name: "BALL POCKET", exact: true }).click();
|
||||
await expect(menu.getByRole("button", { name: /POKé BALL/ })).toBeVisible();
|
||||
await page.keyboard.press("KeyZ");
|
||||
await expect(menu.getByRole("button", { name: "FIGHT", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("battle PKMN switches through the party menu and its submenu, then shares the experience", async ({ page }) => {
|
||||
await start(page, "switch");
|
||||
const menu = page.locator("#battle-menu");
|
||||
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PKMN", exact: true }).count()); tick++) {
|
||||
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
|
||||
await page.clock.runFor(1200);
|
||||
}
|
||||
await menu.getByRole("button", { name: "PKMN", exact: true }).click();
|
||||
await expect(page.locator("#battle-text")).toHaveText("Choose a POKéMON.");
|
||||
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
|
||||
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||||
// BattleMonMenu: B returns to the party list.
|
||||
await expect(menu.getByRole("button", { name: "STATS", exact: true })).toBeDisabled();
|
||||
await page.keyboard.press("KeyZ");
|
||||
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||||
await menu.getByRole("button", { name: "SWITCH", exact: true }).click();
|
||||
// The withdrawal and send-out texts continue on their own.
|
||||
const shown: string[] = [];
|
||||
for (let tick = 0; tick < 20 && (await page.locator("#battle-player-name").innerText()) !== "TOTODILE"; tick++) {
|
||||
shown.push(await page.locator("#battle-text").innerText());
|
||||
await page.clock.runFor(300);
|
||||
}
|
||||
await expect(page.locator("#battle-player-name")).toHaveText("TOTODILE");
|
||||
expect(shown.join("\n").replace(/\n/g, " ")).toContain("CYNDAQUIL, that's enough! Come back!");
|
||||
const messages = await finish(page);
|
||||
// Pidgey's 23 experience points are halved for the two participants.
|
||||
expect(messages.replace(/\n/g, " ")).toContain("CYNDAQUIL gained 11 EXP. Points!");
|
||||
expect(messages.replace(/\n/g, " ")).toContain("TOTODILE gained 11 EXP. Points!");
|
||||
const { state } = await exportState(page);
|
||||
expect(state.party.map((mon: { species: number }) => mon.species)).toEqual([155, 158]);
|
||||
expect(state.party[1].exp).toBe(135 + 11);
|
||||
});
|
||||
|
||||
test("battle PACK uses a Potion from the Items pocket on the Pokémon out", async ({ page }) => {
|
||||
await start(page, "potion");
|
||||
const menu = page.locator("#battle-menu");
|
||||
for (let tick = 0; tick < 10 && !(await menu.getByRole("button", { name: "PACK", exact: true }).count()); tick++) {
|
||||
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
|
||||
await page.clock.runFor(1200);
|
||||
}
|
||||
await menu.getByRole("button", { name: "PACK", exact: true }).click();
|
||||
await menu.getByRole("button", { name: /^POTION/ }).click();
|
||||
await expect(page.locator("#battle-text")).toHaveText("Use on which POKéMON?");
|
||||
await menu.getByRole("button", { name: /^CYNDAQUIL/ }).click();
|
||||
const shown: string[] = [];
|
||||
for (let tick = 0; tick < 20 && !shown.some((text) => text.includes("recovered")); tick++) {
|
||||
shown.push((await page.locator("#battle-text").innerText()).replace(/\n/g, " "));
|
||||
if (await menu.getByRole("button", { name: /Continue/ }).count()) await page.keyboard.press("KeyX");
|
||||
else await page.clock.runFor(300);
|
||||
}
|
||||
// From 3 HP, a Potion's 20 stops at the level 5 maximum of 19.
|
||||
expect(shown).toContain("CYNDAQUIL recovered 16HP!");
|
||||
await finish(page, "run");
|
||||
const { state } = await exportState(page);
|
||||
expect(state.bag.items).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { CrystalAssets } from "../src/game/rom/assets";
|
||||
import type { TrainerData } from "../src/game/rom/trainers";
|
||||
import type {
|
||||
Battle,
|
||||
BattleEffect,
|
||||
BattleInput,
|
||||
BattleOptions,
|
||||
} from "../src/game/battle/battle";
|
||||
import type { Bag } from "../src/game/party/bag";
|
||||
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
|
||||
import { defaultTextContext } from "../src/game/scripts/text";
|
||||
|
||||
export const partyMon = (
|
||||
assets: CrystalAssets,
|
||||
species: number,
|
||||
level: number,
|
||||
): PartyMon =>
|
||||
generatePartyMon(assets.pokemon, {
|
||||
species,
|
||||
level,
|
||||
item: 0,
|
||||
otId: 1234,
|
||||
ot: "CHRIS",
|
||||
dvs: 0x823b,
|
||||
caughtData: 0,
|
||||
});
|
||||
|
||||
/** Battle options for a wild Pokémon or a ROM trainer, with a seeded
|
||||
* stand-in for the hardware Random routine. */
|
||||
export function battleOptions(
|
||||
assets: CrystalAssets,
|
||||
trainers: TrainerData,
|
||||
party: PartyMon[],
|
||||
enemy:
|
||||
| { wild: { species: number; level: number } }
|
||||
| { trainer: [number, number] },
|
||||
{ seed = 1, bag }: { seed?: number; bag?: Bag } = {},
|
||||
): BattleOptions {
|
||||
const base = {
|
||||
data: assets.pokemon,
|
||||
party,
|
||||
bag,
|
||||
playerId: 1234,
|
||||
playerName: "CHRIS",
|
||||
timeOfDay: 1,
|
||||
pokedex: { seen: new Set<number>(), caught: new Set<number>() },
|
||||
random: () => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 16) & 255;
|
||||
},
|
||||
text: () => defaultTextContext("CHRIS"),
|
||||
};
|
||||
if ("wild" in enemy) return { ...base, wild: enemy.wild };
|
||||
const trainer = trainers.trainer(...enemy.trainer);
|
||||
return {
|
||||
...base,
|
||||
trainer: {
|
||||
name: trainer.name,
|
||||
className: trainer.className,
|
||||
party: trainers.createParty(trainer),
|
||||
aiFlags: trainer.attributes.aiMoveFlags,
|
||||
switchFlags: trainer.attributes.aiItemSwitchFlags,
|
||||
reward: trainer.reward,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Run a battle. Menus take queued answers per prompt kind, then the
|
||||
* defaults: the first move, YES, cancelling a move to forget and the
|
||||
* first party slot. */
|
||||
export function playBattle(
|
||||
battle: Battle,
|
||||
queued: Partial<Record<BattleEffect["kind"], BattleInput[]>> = {},
|
||||
) {
|
||||
const texts: string[] = [],
|
||||
prompts: BattleEffect[] = [];
|
||||
const run = battle.run();
|
||||
let input: BattleInput;
|
||||
for (let step = 0; step < 3000; step++) {
|
||||
const next = run.next(input);
|
||||
if (next.done) return { texts, prompts, result: next.value.result };
|
||||
const effect = next.value;
|
||||
input = undefined;
|
||||
if (effect.kind === "text") texts.push(effect.text.replace(/\n/g, " "));
|
||||
else if (effect.kind !== "hud") {
|
||||
prompts.push(effect);
|
||||
const answer = queued[effect.kind]?.shift();
|
||||
input =
|
||||
answer !== undefined
|
||||
? answer
|
||||
: effect.kind === "action"
|
||||
? { action: "fight", move: 0 }
|
||||
: effect.kind === "yesno"
|
||||
? 1
|
||||
: effect.kind === "forget"
|
||||
? 4
|
||||
: effect.kind === "party"
|
||||
? 0
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
throw new Error("Battle did not end.");
|
||||
}
|
||||
+10
-1
@@ -2,7 +2,7 @@ import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import { GameWorld } from "../src/game/world/world";
|
||||
|
||||
export type BattleScenario = "win" | "loss" | "unsupported" | "catch";
|
||||
export type BattleScenario = "win" | "loss" | "unsupported" | "catch" | "switch" | "potion";
|
||||
|
||||
/** Test-only encounters on an existing floor: production maps stay gated.
|
||||
* Everything after selection runs through the ROM's real wild-battle script. */
|
||||
@@ -28,7 +28,16 @@ export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenar
|
||||
mon.pp = [10, 0, 0, 0];
|
||||
}
|
||||
world.state.party.push(mon);
|
||||
if (scenario === "switch")
|
||||
world.state.party.push(generatePartyMon(assets.pokemon, {
|
||||
species: 158, level: 5, item: 0, otId: world.playerId,
|
||||
ot: "CHRIS", dvs: 0x823b, caughtData: 0,
|
||||
}));
|
||||
if (scenario === "catch") world.state.bag.balls = [{ item: 5, quantity: 10 }]; // POKé BALL
|
||||
if (scenario === "potion") {
|
||||
mon.hp = 3;
|
||||
world.state.bag.items = [{ item: 18, quantity: 1 }]; // POTION
|
||||
}
|
||||
world.state.pokedex.seen.add(155);
|
||||
world.state.pokedex.caught.add(155);
|
||||
world.state.money = 3001;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { TrainerData } from "../src/game/rom/trainers";
|
||||
import { Battle, UnsupportedBattle } from "../src/game/battle/battle";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import type { Bag } from "../src/game/party/bag";
|
||||
import type { PartyMon } from "../src/game/party/pokemon";
|
||||
import { battleOptions, partyMon, playBattle } from "./battle-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const PIDGEY = 16,
|
||||
CYNDAQUIL = 155,
|
||||
TOTODILE = 158;
|
||||
const BRIGHTPOWDER = 0x03,
|
||||
ANTIDOTE = 0x09,
|
||||
PARLYZ_HEAL = 0x0d,
|
||||
MAX_POTION = 0x0f,
|
||||
POTION = 0x12,
|
||||
X_ATTACK = 0x31,
|
||||
PSNCUREBERRY = 0x4a,
|
||||
BERRY = 0xad;
|
||||
const PSN = 1 << 3,
|
||||
PAR = 1 << 6;
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("healing items in battle", () => {
|
||||
let assets: CrystalAssets, trainers: TrainerData;
|
||||
beforeAll(() => {
|
||||
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
trainers = new TrainerData(assets.reader);
|
||||
});
|
||||
const setup = (party: PartyMon[], items: Bag["items"], level = 3) => {
|
||||
const bag: Bag = { items, balls: [], keyItems: [] };
|
||||
const battle = new Battle(
|
||||
battleOptions(
|
||||
assets,
|
||||
trainers,
|
||||
party,
|
||||
{ wild: { species: PIDGEY, level } },
|
||||
{ bag },
|
||||
),
|
||||
);
|
||||
return { battle, bag };
|
||||
};
|
||||
const use = (item: number, slot = 0) => ({
|
||||
action: "item" as const,
|
||||
item,
|
||||
slot,
|
||||
});
|
||||
|
||||
it("restores HP with a Potion, takes the turn and uses up the Potion", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.hp = 5;
|
||||
const { battle, bag } = setup([mon], [{ item: POTION, quantity: 2 }]);
|
||||
const { texts, prompts } = playBattle(battle, { action: [use(POTION)] });
|
||||
expect(prompts[1]).toEqual({
|
||||
kind: "party",
|
||||
text: "Use on which POKéMON?",
|
||||
menu: false,
|
||||
cancel: true,
|
||||
});
|
||||
const healed = texts.indexOf("CYNDAQUIL recovered 20HP!");
|
||||
expect(healed).toBeGreaterThan(0);
|
||||
// The item takes the turn: the wild Pokémon moves next.
|
||||
expect(texts[healed + 1]).toMatch(/^Enemy PIDGEY used /);
|
||||
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("stops at the maximum HP and reports only the HP actually restored", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.hp = mon.stats[0] - 3;
|
||||
const { battle, bag } = setup([mon], [{ item: MAX_POTION, quantity: 1 }]);
|
||||
const { texts } = playBattle(battle, { action: [use(MAX_POTION)] });
|
||||
expect(texts).toContain("CYNDAQUIL recovered 3HP!");
|
||||
expect(bag.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("heals a Pokémon that is not out without changing the one in battle", () => {
|
||||
const out = partyMon(assets, CYNDAQUIL, 10),
|
||||
resting = partyMon(assets, TOTODILE, 10);
|
||||
resting.hp = 1;
|
||||
const { battle } = setup([out, resting], [{ item: BERRY, quantity: 1 }]);
|
||||
const { texts } = playBattle(battle, { action: [use(BERRY)], party: [1] });
|
||||
expect(texts).toContain("TOTODILE recovered 10HP!");
|
||||
expect(resting.hp).toBe(11);
|
||||
expect(battle.curBattleMon).toBe(0);
|
||||
});
|
||||
|
||||
it("does nothing at full HP or on a fainted Pokémon, and keeps the turn", () => {
|
||||
const out = partyMon(assets, CYNDAQUIL, 10),
|
||||
fainted = partyMon(assets, TOTODILE, 10);
|
||||
fainted.hp = 0;
|
||||
const { battle, bag } = setup(
|
||||
[out, fainted],
|
||||
[{ item: POTION, quantity: 1 }],
|
||||
);
|
||||
const { texts, prompts } = playBattle(battle, {
|
||||
action: [use(POTION), use(POTION)],
|
||||
party: [0, 1],
|
||||
});
|
||||
expect(
|
||||
texts.filter((text) => text === "It won't have any effect."),
|
||||
).toHaveLength(2);
|
||||
expect(prompts.slice(0, 5).map((prompt) => prompt.kind)).toEqual([
|
||||
"action",
|
||||
"party",
|
||||
"action",
|
||||
"party",
|
||||
"action",
|
||||
]);
|
||||
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("returns to the battle menu when the party menu is cancelled", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.hp = 5;
|
||||
const { battle, bag } = setup([mon], [{ item: POTION, quantity: 1 }]);
|
||||
const { prompts } = playBattle(battle, {
|
||||
action: [use(POTION)],
|
||||
party: [-1],
|
||||
});
|
||||
expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([
|
||||
"action",
|
||||
"party",
|
||||
"action",
|
||||
]);
|
||||
expect(bag.items).toEqual([{ item: POTION, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("cures poison with an Antidote or a PSNCUREBERRY, and only poison", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.status = PSN;
|
||||
const { battle, bag } = setup(
|
||||
[mon],
|
||||
[
|
||||
{ item: PARLYZ_HEAL, quantity: 1 },
|
||||
{ item: ANTIDOTE, quantity: 1 },
|
||||
{ item: PSNCUREBERRY, quantity: 1 },
|
||||
],
|
||||
);
|
||||
const { texts } = playBattle(battle, {
|
||||
action: [use(PARLYZ_HEAL, 0), use(ANTIDOTE, 1)],
|
||||
});
|
||||
expect(texts[texts.indexOf("It won't have any effect.") + 1]).toBe(
|
||||
"CYNDAQUIL's cured of poison.",
|
||||
);
|
||||
expect(mon.status).toBe(0);
|
||||
expect(bag.items.map(({ item }) => item)).toEqual([
|
||||
PARLYZ_HEAL,
|
||||
PSNCUREBERRY,
|
||||
]);
|
||||
|
||||
const berry = partyMon(assets, CYNDAQUIL, 10);
|
||||
berry.status = PSN;
|
||||
const second = setup([berry], [{ item: PSNCUREBERRY, quantity: 1 }]);
|
||||
expect(
|
||||
playBattle(second.battle, { action: [use(PSNCUREBERRY)] }).texts,
|
||||
).toContain("CYNDAQUIL's cured of poison.");
|
||||
expect(second.bag.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("restores the paralysed Pokémon's Speed with a Parlyz Heal", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.status = PAR;
|
||||
const { battle } = setup([mon], [{ item: PARLYZ_HEAL, quantity: 1 }]);
|
||||
const speeds: number[] = [];
|
||||
const run = battle.run();
|
||||
let input: Parameters<typeof run.next>[0];
|
||||
for (let step = 0, actions = 0; step < 500; step++) {
|
||||
const next = run.next(input);
|
||||
if (next.done) break;
|
||||
input = undefined;
|
||||
const effect = next.value;
|
||||
if (effect.kind === "action") {
|
||||
speeds.push(battle.player.mon.stats[C.SPEED]);
|
||||
input = actions++ === 0 ? use(PARLYZ_HEAL) : { action: "run" };
|
||||
} else if (effect.kind === "party") input = 0;
|
||||
if (actions > 1 && effect.kind === "action") break;
|
||||
}
|
||||
expect(speeds[0]).toBe(Math.max(1, mon.stats[3] >> 2));
|
||||
expect(speeds[1]).toBe(mon.stats[3]);
|
||||
expect(battle.player.mon.status).toBe(0);
|
||||
});
|
||||
|
||||
it("answers an item with no battle use with Oak's warning", () => {
|
||||
const { battle, bag } = setup(
|
||||
[partyMon(assets, CYNDAQUIL, 10)],
|
||||
[{ item: BRIGHTPOWDER, quantity: 1 }],
|
||||
);
|
||||
const { texts } = playBattle(battle, { action: [use(BRIGHTPOWDER)] });
|
||||
expect(texts[texts.indexOf("Go! CYNDAQUIL!") + 1]).toBe(
|
||||
"OAK: CHRIS! This isn't the time to use that!",
|
||||
);
|
||||
expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("refuses items whose battle effects are not ported", () => {
|
||||
const { battle } = setup(
|
||||
[partyMon(assets, CYNDAQUIL, 10)],
|
||||
[{ item: X_ATTACK, quantity: 1 }],
|
||||
);
|
||||
expect(() => playBattle(battle, { action: [use(X_ATTACK)] })).toThrow(
|
||||
UnsupportedBattle,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { TrainerData } from "../src/game/rom/trainers";
|
||||
import { Battle } from "../src/game/battle/battle";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import type { PartyMon } from "../src/game/party/pokemon";
|
||||
import { battleOptions, partyMon, playBattle } from "./battle-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||
const PIDGEY = 16,
|
||||
RATTATA = 19,
|
||||
CYNDAQUIL = 155,
|
||||
TOTODILE = 158;
|
||||
|
||||
describe.skipIf(!existsSync(romPath))("switching Pokémon in battle", () => {
|
||||
let assets: CrystalAssets, trainers: TrainerData;
|
||||
beforeAll(() => {
|
||||
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||
trainers = new TrainerData(assets.reader);
|
||||
});
|
||||
const mon = (species: number, level: number) =>
|
||||
partyMon(assets, species, level);
|
||||
const options = (
|
||||
party: PartyMon[],
|
||||
enemy: Parameters<typeof battleOptions>[3],
|
||||
seed = 1,
|
||||
) => battleOptions(assets, trainers, party, enemy, { seed });
|
||||
const play = playBattle;
|
||||
|
||||
it("switches from the PKMN menu: the enemy still attacks and both share the experience", () => {
|
||||
const first = mon(CYNDAQUIL, 10),
|
||||
second = mon(TOTODILE, 10);
|
||||
const battle = new Battle(
|
||||
options([first, second], { wild: { species: PIDGEY, level: 3 } }),
|
||||
);
|
||||
const { texts, prompts } = play(battle, {
|
||||
action: [{ action: "pkmn" }],
|
||||
party: [0, 1],
|
||||
});
|
||||
expect(prompts[1]).toEqual({
|
||||
kind: "party",
|
||||
text: "Choose a POKéMON.",
|
||||
menu: true,
|
||||
cancel: true,
|
||||
});
|
||||
const switched = texts.indexOf("CYNDAQUIL is already out.");
|
||||
expect(switched).toBeGreaterThan(0);
|
||||
// The enemy's HP is untouched, so the withdrawal is "that's enough".
|
||||
expect(texts.slice(switched + 1, switched + 4)).toEqual([
|
||||
"CYNDAQUIL, that's enough! Come back!",
|
||||
"Go! TOTODILE!",
|
||||
expect.stringMatching(/^Enemy PIDGEY used /),
|
||||
]);
|
||||
expect(battle.curBattleMon).toBe(1);
|
||||
expect(battle.lastPlayerMon).toBe(0);
|
||||
// Base experience 55 × level 3 ÷ 7 = 23, halved for two participants.
|
||||
expect(texts).toContain("CYNDAQUIL gained 11 EXP. Points!");
|
||||
expect(texts).toContain("TOTODILE gained 11 EXP. Points!");
|
||||
});
|
||||
|
||||
it("goes back to the battle menu when the party menu is cancelled", () => {
|
||||
const battle = new Battle(
|
||||
options([mon(CYNDAQUIL, 10), mon(TOTODILE, 10)], {
|
||||
wild: { species: PIDGEY, level: 3 },
|
||||
}),
|
||||
);
|
||||
const { prompts, texts } = play(battle, {
|
||||
action: [{ action: "pkmn" }],
|
||||
party: [-1],
|
||||
});
|
||||
expect(prompts.slice(0, 3).map((prompt) => prompt.kind)).toEqual([
|
||||
"action",
|
||||
"party",
|
||||
"action",
|
||||
]);
|
||||
expect(texts).toContain("CYNDAQUIL gained 23 EXP. Points!");
|
||||
expect(battle.curBattleMon).toBe(0);
|
||||
});
|
||||
|
||||
it("measures the withdrawal against the enemy's HP when the Pokémon went out", () => {
|
||||
const battle = new Battle(
|
||||
options(
|
||||
[mon(CYNDAQUIL, 12), mon(TOTODILE, 10)],
|
||||
{ wild: { species: RATTATA, level: 12 } },
|
||||
3,
|
||||
),
|
||||
);
|
||||
// Tackle once, then switch: the lost HP picks the text.
|
||||
const { texts } = play(battle, {
|
||||
action: [{ action: "fight", move: 0 }, { action: "pkmn" }],
|
||||
party: [1],
|
||||
});
|
||||
const withdrawal = texts.find((text) => text.startsWith("CYNDAQUIL, "))!;
|
||||
expect([
|
||||
"CYNDAQUIL, come back!",
|
||||
"CYNDAQUIL, OK! Come back!",
|
||||
"CYNDAQUIL, good! Come back!",
|
||||
]).toContain(withdrawal);
|
||||
});
|
||||
|
||||
it("asks for the next Pokémon after a faint and refuses a fainted choice", () => {
|
||||
const first = mon(CYNDAQUIL, 5),
|
||||
second = mon(TOTODILE, 10),
|
||||
fainted = mon(PIDGEY, 5);
|
||||
first.hp = 1;
|
||||
fainted.hp = 0;
|
||||
const battle = new Battle(
|
||||
options(
|
||||
[first, fainted, second],
|
||||
{ wild: { species: RATTATA, level: 8 } },
|
||||
5,
|
||||
),
|
||||
);
|
||||
const { texts, prompts } = play(battle, { party: [1, 2] });
|
||||
expect(texts).toContain("CYNDAQUIL fainted!");
|
||||
expect(texts).toContain("Use next POKéMON?");
|
||||
const forced = prompts.find((prompt) => prompt.kind === "party");
|
||||
expect(forced).toEqual({
|
||||
kind: "party",
|
||||
text: "Which POKéMON?",
|
||||
menu: false,
|
||||
cancel: false,
|
||||
});
|
||||
expect(texts).toContain("There's no will to battle!");
|
||||
const sentOut = texts.findIndex((text) => text.endsWith("TOTODILE!"));
|
||||
expect(texts[sentOut]).toMatch(
|
||||
/^(Go!|Do it!|Go for it,|Your foe's weak! Get'm,) TOTODILE!$/,
|
||||
);
|
||||
expect(battle.curBattleMon).toBe(2);
|
||||
// The fainted Pokémon did not share the experience.
|
||||
expect(first.exp).toBe(assets.pokemon.expAtLevel(3, 5));
|
||||
});
|
||||
|
||||
it("keeps asking for a Pokémon when the forced party menu is cancelled", () => {
|
||||
const first = mon(CYNDAQUIL, 5);
|
||||
first.hp = 1;
|
||||
const battle = new Battle(
|
||||
options(
|
||||
[first, mon(TOTODILE, 10)],
|
||||
{ wild: { species: RATTATA, level: 8 } },
|
||||
5,
|
||||
),
|
||||
);
|
||||
const { prompts } = play(battle, { party: [-1, -1, 1] });
|
||||
expect(prompts.filter((prompt) => prompt.kind === "party")).toHaveLength(3);
|
||||
expect(battle.curBattleMon).toBe(1);
|
||||
});
|
||||
|
||||
it("runs after declining the next Pokémon, with the first party Pokémon's Speed", () => {
|
||||
const first = mon(CYNDAQUIL, 5);
|
||||
first.hp = 1;
|
||||
const battle = new Battle(
|
||||
options(
|
||||
[first, mon(TOTODILE, 10)],
|
||||
{ wild: { species: RATTATA, level: 8 } },
|
||||
5,
|
||||
),
|
||||
);
|
||||
// The party's stored Speed, not the fainted battle Pokémon's, decides.
|
||||
first.stats[3] = 999;
|
||||
const { texts, result } = play(battle, { yesno: [0] });
|
||||
expect(texts.slice(-2)).toEqual(["Use next POKéMON?", "Got away safely!"]);
|
||||
expect(result).toBe(C.DRAW);
|
||||
});
|
||||
|
||||
it("offers a switch in Shift mode when a trainer sends out the next Pokémon", () => {
|
||||
// Youngster Mikey: Pidgey, then Rattata.
|
||||
const first = mon(CYNDAQUIL, 15),
|
||||
second = mon(TOTODILE, 15);
|
||||
const battle = new Battle(options([first, second], { trainer: [22, 2] }));
|
||||
const { texts } = play(battle, { yesno: [1], party: [1] });
|
||||
const offer = texts.indexOf("YOUNGSTER MIKEY is about to use RATTATA.");
|
||||
expect(offer).toBeGreaterThan(0);
|
||||
expect(texts.slice(offer + 1, offer + 5)).toEqual([
|
||||
"Will CHRIS change POKéMON?",
|
||||
"YOUNGSTER MIKEY sent out RATTATA!",
|
||||
expect.stringMatching(/^CYNDAQUIL, /),
|
||||
"Go! TOTODILE!",
|
||||
]);
|
||||
expect(texts).toContain("YOUNGSTER MIKEY was defeated!");
|
||||
});
|
||||
|
||||
it("keeps the Pokémon in when the offer is declined", () => {
|
||||
const battle = new Battle(
|
||||
options([mon(CYNDAQUIL, 15), mon(TOTODILE, 15)], { trainer: [22, 2] }),
|
||||
);
|
||||
const { texts } = play(battle, { yesno: [0] });
|
||||
const offer = texts.findIndex((text) => text.includes("Will CHRIS change"));
|
||||
expect(texts[offer + 1]).toBe("YOUNGSTER MIKEY sent out RATTATA!");
|
||||
expect(texts.some((text) => text.startsWith("CYNDAQUIL, "))).toBe(false);
|
||||
expect(battle.curBattleMon).toBe(0);
|
||||
});
|
||||
|
||||
it("sends out both sides without an offer after a double faint", () => {
|
||||
// Struggle's recoil faints the attacker as the trainer's Pidgey faints.
|
||||
const first = mon(CYNDAQUIL, 15);
|
||||
first.hp = 1;
|
||||
first.pp = [0, 0, 0, 0];
|
||||
const battle = new Battle(
|
||||
options([first, mon(TOTODILE, 15)], { trainer: [22, 2] }),
|
||||
);
|
||||
const { texts, prompts } = play(battle, { party: [1] });
|
||||
const fainted = texts.indexOf("Enemy PIDGEY fainted!");
|
||||
expect(fainted).toBeGreaterThan(0);
|
||||
// The fainted Pokémon is no longer a participant, so nobody gains
|
||||
// experience for the Pidgey.
|
||||
expect(texts.slice(fainted, fainted + 4)).toEqual([
|
||||
"Enemy PIDGEY fainted!",
|
||||
"CYNDAQUIL fainted!",
|
||||
"Go! TOTODILE!",
|
||||
"YOUNGSTER MIKEY sent out RATTATA!",
|
||||
]);
|
||||
expect(texts.some((text) => text.includes("change POKéMON"))).toBe(false);
|
||||
expect(prompts.find((prompt) => prompt.kind === "party")).toMatchObject({
|
||||
cancel: false,
|
||||
});
|
||||
expect(texts).toContain("YOUNGSTER MIKEY was defeated!");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
runScript,
|
||||
} from "../src/game/scripts/interpreter";
|
||||
import { generatePartyMon } from "../src/game/party/pokemon";
|
||||
import type { Evolution } from "../src/game/rom/pokemon";
|
||||
import { readMovement } from "../src/game/scripts/movement";
|
||||
import { defaultTextContext, readText } from "../src/game/scripts/text";
|
||||
import { settle, walkTo } from "./world-driver";
|
||||
@@ -613,6 +614,108 @@ describe.skipIf(!existsSync(romPath))(
|
||||
);
|
||||
},
|
||||
);
|
||||
it("decodes evolution lists, including stat and happiness triggers", () => {
|
||||
const data = load().pokemon;
|
||||
expect(data.evolutions(155)).toEqual([
|
||||
{ method: "level", level: 14, species: 156 },
|
||||
]);
|
||||
expect(data.evolutions(133).map((e) => e.method)).toEqual([
|
||||
"item",
|
||||
"item",
|
||||
"item",
|
||||
"happiness",
|
||||
"happiness",
|
||||
]);
|
||||
expect(data.evolutions(133)[4]).toEqual({
|
||||
method: "happiness",
|
||||
time: "nite",
|
||||
species: 197,
|
||||
});
|
||||
expect(data.evolutions(61)).toContainEqual({
|
||||
method: "trade",
|
||||
item: 82, // KING'S ROCK
|
||||
species: 186,
|
||||
});
|
||||
expect(data.evolutions(64)).toEqual([
|
||||
{ method: "trade", item: null, species: 65 },
|
||||
]);
|
||||
expect(data.evolutions(236)).toEqual([
|
||||
{ method: "stat", level: 20, attack: "lt", species: 107 },
|
||||
{ method: "stat", level: 20, attack: "gt", species: 106 },
|
||||
{ method: "stat", level: 20, attack: "eq", species: 237 },
|
||||
]);
|
||||
expect(data.evolutions(251)).toEqual([]);
|
||||
// The four-byte stat records must not shift the learnset.
|
||||
expect(data.learnset(236)[0]).toEqual({ level: 1, move: 33 });
|
||||
});
|
||||
it.skipIf(
|
||||
!existsSync(".reference/pokecrystal/data/pokemon/evos_attacks.asm"),
|
||||
)("matches every evolution list against the pinned PRET source", () => {
|
||||
const data = load().pokemon;
|
||||
const root = ".reference/pokecrystal";
|
||||
const constants = (file: string, first: number) => {
|
||||
const ids = new Map<string, number>();
|
||||
let value = first;
|
||||
for (const line of readFileSync(`${root}/${file}`, "utf8").split(
|
||||
"\n",
|
||||
)) {
|
||||
if (/^DEF NUM_/.test(line)) break;
|
||||
const name = /^\s*const (\w+)/.exec(line)?.[1];
|
||||
if (name) ids.set(name, value++);
|
||||
}
|
||||
return ids;
|
||||
};
|
||||
const species = constants("constants/pokemon_constants.asm", 1),
|
||||
items = constants("constants/item_constants.asm", 0);
|
||||
const id = (map: Map<string, number>, name: string) => {
|
||||
const value = map.get(name);
|
||||
if (value === undefined) throw new Error(`Unknown ${name}`);
|
||||
return value;
|
||||
};
|
||||
const lists: Evolution[][] = [];
|
||||
let current: Evolution[] | null = null;
|
||||
for (const line of readFileSync(
|
||||
`${root}/data/pokemon/evos_attacks.asm`,
|
||||
"utf8",
|
||||
).split("\n")) {
|
||||
if (/^\w+EvosAttacks:/.test(line)) lists.push((current = []));
|
||||
const args = /^\s*db (EVOLVE_\w+), (.+)$/.exec(line);
|
||||
if (!args || !current) {
|
||||
if (/^\s*db 0 ; no more evolutions/.test(line)) current = null;
|
||||
continue;
|
||||
}
|
||||
const [a, b, c] = args[2].split(",").map((s) => s.trim());
|
||||
if (args[1] === "EVOLVE_LEVEL")
|
||||
current.push({ method: "level", level: +a, species: id(species, b) });
|
||||
else if (args[1] === "EVOLVE_ITEM")
|
||||
current.push({
|
||||
method: "item",
|
||||
item: id(items, a),
|
||||
species: id(species, b),
|
||||
});
|
||||
else if (args[1] === "EVOLVE_TRADE")
|
||||
current.push({
|
||||
method: "trade",
|
||||
item: a === "-1" ? null : id(items, a),
|
||||
species: id(species, b),
|
||||
});
|
||||
else if (args[1] === "EVOLVE_HAPPINESS")
|
||||
current.push({
|
||||
method: "happiness",
|
||||
time: a.slice(3).toLowerCase() as "anytime",
|
||||
species: id(species, b),
|
||||
});
|
||||
else
|
||||
current.push({
|
||||
method: "stat",
|
||||
level: +a,
|
||||
attack: b.slice(4, 6).toLowerCase() as "gt",
|
||||
species: id(species, c),
|
||||
});
|
||||
}
|
||||
expect(lists).toHaveLength(251);
|
||||
lists.forEach((list, i) => expect(data.evolutions(i + 1)).toEqual(list));
|
||||
});
|
||||
it("gives the chosen starter and the aide's Potion, then saves them", () => {
|
||||
let seed = 7;
|
||||
const random = () => (seed = (seed * 73 + 41) & 255);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
+143
-27
@@ -1,23 +1,41 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { CrystalAssets } from "../src/game/rom/assets";
|
||||
import { BattleData } from "../src/game/battle/data";
|
||||
import { applyTypeMatchups, calculateDamage } from "../src/game/battle/battle";
|
||||
import { C } from "../src/game/battle/constants";
|
||||
import { NATIVE_MAPS } from "../src/game/rom/profile";
|
||||
import { calcStat } from "../src/game/party/pokemon";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const romPath = resolve(
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
|
||||
);
|
||||
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
|
||||
|
||||
test("native decompression matches original GB assembly executing in the reference emulator", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(!existsSync(romPath), "Requires the local user ROM.");
|
||||
/** Banked addresses from the private PRET rebuild's symbol file. */
|
||||
function loadSymbols() {
|
||||
const symbols = new Map(
|
||||
readFileSync(symbolsPath, "utf8")
|
||||
.split("\n")
|
||||
.flatMap((line) => {
|
||||
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
|
||||
return match
|
||||
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
at: (name: string) => symbols.get(name)!.address,
|
||||
routine: (name: string) => symbols.get(name)!,
|
||||
};
|
||||
}
|
||||
|
||||
/** Open the reference page and hand it the ROM through an isolated file
|
||||
* input, without an HTTP ROM endpoint. */
|
||||
async function openReference(page: Page) {
|
||||
await page.goto("/reference.html");
|
||||
// File chooser supplies bytes to an isolated test input, without an HTTP ROM endpoint.
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
@@ -25,6 +43,13 @@ test("native decompression matches original GB assembly executing in the referen
|
||||
document.body.append(input);
|
||||
});
|
||||
await page.locator("#reference-fixture").setInputFiles(romPath);
|
||||
}
|
||||
|
||||
test("native decompression matches original GB assembly executing in the reference emulator", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(!existsSync(romPath), "Requires the local user ROM.");
|
||||
await openReference(page);
|
||||
const result = await page.evaluate(async () => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||
@@ -113,23 +138,11 @@ test("native decompression matches original GB assembly executing in the referen
|
||||
test("native damage and type effectiveness match the original battle routines", async ({
|
||||
page,
|
||||
}) => {
|
||||
const symbolsPath = ".reference/pokecrystal/pokecrystal11.sym";
|
||||
test.skip(
|
||||
!existsSync(romPath) || !existsSync(symbolsPath),
|
||||
"Requires the local user ROM and the private PRET rebuild.",
|
||||
);
|
||||
const symbols = new Map(
|
||||
readFileSync(symbolsPath, "utf8")
|
||||
.split("\n")
|
||||
.flatMap((line) => {
|
||||
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
|
||||
return match
|
||||
? [[match[3], { bank: parseInt(match[1], 16), address: parseInt(match[2], 16) }] as const]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
const at = (name: string) => symbols.get(name)!.address;
|
||||
const routine = (name: string) => symbols.get(name)!;
|
||||
const { at, routine } = loadSymbols();
|
||||
const bytes = new Uint8Array(readFileSync(romPath));
|
||||
const assets = new CrystalAssets(bytes);
|
||||
const data = new BattleData(assets.reader);
|
||||
@@ -164,14 +177,7 @@ test("native damage and type effectiveness match the original battle routines",
|
||||
identified: next(4) === 0,
|
||||
damage: next(1000),
|
||||
}));
|
||||
await page.goto("/reference.html");
|
||||
await page.evaluate(() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.id = "reference-fixture";
|
||||
document.body.append(input);
|
||||
});
|
||||
await page.locator("#reference-fixture").setInputFiles(romPath);
|
||||
await openReference(page);
|
||||
const addresses = {
|
||||
curDamage: at("wCurDamage"),
|
||||
critical: at("wCriticalHit"),
|
||||
@@ -294,3 +300,113 @@ test("native damage and type effectiveness match the original battle routines",
|
||||
).toEqual([original.damage, original.modifier, original.missed]);
|
||||
});
|
||||
});
|
||||
|
||||
test("native stats and experience curves match the original routines", async ({ page }) => {
|
||||
test.skip(
|
||||
!existsSync(romPath) || !existsSync(symbolsPath),
|
||||
"Requires the local user ROM and the private PRET rebuild.",
|
||||
);
|
||||
const { at, routine } = loadSymbols();
|
||||
const pokemon = new CrystalAssets(new Uint8Array(readFileSync(romPath))).pokemon;
|
||||
let seed = 0x5eed;
|
||||
const next = (limit: number) => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 8) % limit;
|
||||
};
|
||||
// Legal stats stay below 999; levels above 100 reach the MAX_STAT_VALUE cap.
|
||||
const statCases = Array.from({ length: 300 }, () => ({
|
||||
base: Array.from({ length: 6 }, () => (next(4) === 0 ? 200 + next(56) : 1 + next(255))),
|
||||
level: next(6) === 0 ? 101 + next(155) : next(5) === 0 ? 100 : 1 + next(100),
|
||||
dvs: next(0x10000),
|
||||
statExp: Array.from({ length: 5 }, () => (next(3) === 0 ? 0xffff - next(256) : next(0x10000))),
|
||||
useStatExp: next(4) !== 0,
|
||||
}));
|
||||
await openReference(page);
|
||||
const addresses = {
|
||||
baseStats: at("wBaseStats"),
|
||||
level: at("wCurPartyLevel"),
|
||||
growthRate: at("wBaseGrowthRate"),
|
||||
product: at("hProduct"),
|
||||
calcMonStats: routine("CalcMonStats"),
|
||||
calcExpAtLevel: routine("CalcExpAtLevel"),
|
||||
};
|
||||
const results = await page.evaluate(
|
||||
async ({ addresses, statCases }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(new Uint8Array(await file.arrayBuffer()), document.createElement("canvas"));
|
||||
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
||||
const read = (address: number, length: number) =>
|
||||
Array.from({ length }, (_, i) => reference.readMemory(address + i));
|
||||
// Trampoline: DI; SP=$c7fe; switch to the routine's bank; set BC, DE
|
||||
// and HL; CALL the original routine; write a sentinel; loop.
|
||||
const call = (
|
||||
target: { bank: number; address: number },
|
||||
bc: number,
|
||||
de: number,
|
||||
hl: number,
|
||||
frames = 2,
|
||||
) => {
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
||||
0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8, 0x21, hl & 0xff, hl >> 8,
|
||||
0xcd, target.address & 0xff, target.address >> 8,
|
||||
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
||||
];
|
||||
write(0xc6ff, 0);
|
||||
program.forEach((value, index) => write(0xc000 + index, value));
|
||||
reference.setProgramCounter(0xc000);
|
||||
reference.advanceFrames(frames);
|
||||
return reference.readMemory(0xc6ff) === 0x42;
|
||||
};
|
||||
write(0xff70, 1); // WRAM bank 1 holds the base data and party level
|
||||
// A party struct at $c100 (stat experience at +11, DVs at +21) and
|
||||
// the six computed stats at $c140.
|
||||
const mon = 0xc100,
|
||||
out = 0xc140;
|
||||
const stats = statCases.map((input) => {
|
||||
input.base.forEach((value, i) => write(addresses.baseStats + i, value));
|
||||
write(addresses.level, input.level);
|
||||
input.statExp.forEach((value, i) => {
|
||||
write(mon + 11 + i * 2, value >> 8);
|
||||
write(mon + 12 + i * 2, value);
|
||||
});
|
||||
write(mon + 21, input.dvs >> 8);
|
||||
write(mon + 22, input.dvs);
|
||||
// B = stat experience flag; HL = MON_STAT_EXP - 1; DE = output.
|
||||
// GetSquareRoot counts up to 255 per stat, so allow more frames.
|
||||
const returned = call(addresses.calcMonStats, Number(input.useStatExp) << 8, out, mon + 10, 8);
|
||||
const bytes = read(out, 12);
|
||||
return { returned, stats: Array.from({ length: 6 }, (_, i) => (bytes[i * 2] << 8) | bytes[i * 2 + 1]) };
|
||||
});
|
||||
const experience: { returned: boolean; exp: number }[] = [];
|
||||
for (let growth = 0; growth < 6; growth++)
|
||||
for (let level = 1; level <= 100; level++) {
|
||||
write(addresses.growthRate, growth);
|
||||
// D = level
|
||||
const returned = call(addresses.calcExpAtLevel, 0, level << 8, 0);
|
||||
const [high, middle, low] = read(addresses.product + 1, 3);
|
||||
experience.push({ returned, exp: (high << 16) | (middle << 8) | low });
|
||||
}
|
||||
reference.dispose();
|
||||
return { stats, experience };
|
||||
},
|
||||
{ addresses, statCases },
|
||||
);
|
||||
statCases.forEach((input, index) => {
|
||||
const original = results.stats[index];
|
||||
expect(original.returned, `stat case ${index} returned`).toBe(true);
|
||||
const native = input.base.map((base, stat) =>
|
||||
calcStat(base, input.dvs, stat, input.level, input.useStatExp ? input.statExp : undefined),
|
||||
);
|
||||
expect(native, `stat case ${index}: ${JSON.stringify(input)}`).toEqual(original.stats);
|
||||
});
|
||||
expect(results.stats.some(({ stats }) => stats.includes(999))).toBe(true);
|
||||
results.experience.forEach((original, index) => {
|
||||
const growth = Math.floor(index / 100),
|
||||
level = (index % 100) + 1;
|
||||
expect(original.returned, `experience ${growth}/${level} returned`).toBe(true);
|
||||
expect(pokemon.expAtLevel(growth, level), `experience ${growth}/${level}`).toBe(original.exp);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user