Port the remaining battle items, the stats screen and hBattleTurn at the menu
- Run every non-ball item effect usable in battle from the ROM's ItemEffects: Full Restore, revives, bitter medicine, Bitter Berry, Ethers and Elixers, X items, X Accuracy, Guard Spec, Dire Hit and Poké Doll. - Read happiness changes from HappinessChanges, including the larger level-up gain where a Pokémon was caught. - Model hBattleTurn at the battle menu, including the trainer AI's writes, and drop the status-heal refusal. - Port the stats screen for battles and Bill's PC. - Compare the send-out and withdraw texts, RestoreHealth, RestorePP, ChangeHappiness and CalcPlayerStats with the original routines. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cfd083c16d
commit
b13e09ec20
22 files changed
+1547
-171
No files matched your search
@@ -94,6 +94,7 @@ describe("trainer AI native scoring", () => {
|
||||
move: 2,
|
||||
index: 1,
|
||||
scores: [20, 20, 80, 80],
|
||||
enemyTurn: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -124,8 +125,34 @@ describe("trainer AI native scoring", () => {
|
||||
move: 2,
|
||||
index: 1,
|
||||
scores: [20, 20, 80, 80],
|
||||
enemyTurn: false,
|
||||
});
|
||||
});
|
||||
it("reports when AI_Types, AI_Status or AIDamageCalc set hBattleTurn", () => {
|
||||
const run = (
|
||||
weights: number,
|
||||
moves = [move(1), move(2)],
|
||||
playerTypes = [0, 0],
|
||||
) => {
|
||||
const input = fixture(moves, [0, 1, 2, 3, 0, 1, 2, 3]);
|
||||
input.weights = weights;
|
||||
input.player.mon.types = playerTypes;
|
||||
input.damage = () => 0;
|
||||
return chooseTrainerMove(input).enemyTurn;
|
||||
};
|
||||
expect(run(0)).toBe(false);
|
||||
expect(run(4)).toBe(true); // Types
|
||||
expect(run(64)).toBe(true); // Aggressive's damage estimates
|
||||
expect(run(256)).toBe(true); // Status, for damaging moves
|
||||
// A poison move against a Poison type skips the matchup and the write;
|
||||
// a status move with no power checks nothing.
|
||||
const poison = [
|
||||
move(1, C.EFFECT_POISON, 0),
|
||||
move(2, C.EFFECT_ATTACK_UP, 0),
|
||||
];
|
||||
expect(run(256, poison, [C.POISON, C.POISON])).toBe(false);
|
||||
expect(run(256, poison)).toBe(true);
|
||||
});
|
||||
it("refuses a random source that never produces a candidate", () => {
|
||||
const input = fixture();
|
||||
input.random = () => 3;
|
||||
@@ -140,6 +167,7 @@ describe("trainer AI native scoring", () => {
|
||||
move: 3,
|
||||
index: 2,
|
||||
scores: [80, 80, 20, 80],
|
||||
enemyTurn: false,
|
||||
});
|
||||
});
|
||||
it("still scores exhausted moves and consumes randomness before Struggle", () => {
|
||||
@@ -153,6 +181,7 @@ describe("trainer AI native scoring", () => {
|
||||
move: 1,
|
||||
index: 0,
|
||||
scores: [78, 80, 80, 80],
|
||||
enemyTurn: false,
|
||||
});
|
||||
});
|
||||
it("still selects when the only move is disabled", () => {
|
||||
@@ -163,6 +192,7 @@ describe("trainer AI native scoring", () => {
|
||||
move: 1,
|
||||
index: 0,
|
||||
scores: [80, 20, 80, 80],
|
||||
enemyTurn: false,
|
||||
});
|
||||
});
|
||||
it("refuses empty move lists instead of the cartridge's infinite loop", () => {
|
||||
|
||||
@@ -229,7 +229,7 @@ test("battle PKMN switches through the party menu and its submenu, then shares t
|
||||
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 expect(menu.getByRole("button", { name: "STATS", exact: true })).toBeEnabled();
|
||||
await page.keyboard.press("KeyZ");
|
||||
await menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||||
await menu.getByRole("button", { name: "SWITCH", exact: true }).click();
|
||||
@@ -273,3 +273,30 @@ test("battle PACK uses a Potion from the Items pocket on the Pokémon out", asyn
|
||||
const { state } = await exportState(page);
|
||||
expect(state.bag.items).toEqual([]);
|
||||
});
|
||||
|
||||
test("battle PKMN STATS shows the stats screen's three pages and returns with B", 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 menu.getByRole("button", { name: /^TOTODILE/ }).click();
|
||||
await menu.getByRole("button", { name: "STATS", exact: true }).click();
|
||||
const stats = page.locator("#stats-screen");
|
||||
await expect(stats).toBeVisible();
|
||||
await expect(page.locator("#stats-header")).toContainText("№.158");
|
||||
await expect(page.locator("#stats-header")).toContainText("/TOTODILE");
|
||||
await expect(page.locator("#stats-page")).toContainText("WATER");
|
||||
await expect(page.locator("#stats-page")).toContainText("EXP POINTS");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.locator("#stats-page")).toContainText("SCRATCH");
|
||||
await page.locator("#stats-next").click();
|
||||
await expect(page.locator("#stats-page")).toContainText("SPCL.ATK");
|
||||
await expect(page.locator("#stats-page")).toContainText("OT/");
|
||||
await page.keyboard.press("KeyZ");
|
||||
await expect(stats).toBeHidden();
|
||||
// BattleMenuPKMN_ReturnFromStats: back to the party menu.
|
||||
await expect(menu.getByRole("button", { name: /^CYNDAQUIL/ })).toBeVisible();
|
||||
});
|
||||
+36
-3
@@ -66,8 +66,8 @@ export function battleOptions(
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
* defaults: the first move, YES, cancelling a move to forget, and the
|
||||
* first party slot or move slot. */
|
||||
export function playBattle(
|
||||
battle: Battle,
|
||||
queued: Partial<Record<BattleEffect["kind"], BattleInput[]>> = {},
|
||||
@@ -94,10 +94,43 @@ export function playBattle(
|
||||
? 1
|
||||
: effect.kind === "forget"
|
||||
? 4
|
||||
: effect.kind === "party"
|
||||
: effect.kind === "party" || effect.kind === "move"
|
||||
? 0
|
||||
: undefined;
|
||||
}
|
||||
}
|
||||
throw new Error("Battle did not end.");
|
||||
}
|
||||
|
||||
/** Step a battle by hand: texts are collected up to each prompt, and
|
||||
* `answer` feeds the current prompt's input. */
|
||||
export function battleSteps(battle: Battle) {
|
||||
const run = battle.run();
|
||||
const texts: string[] = [];
|
||||
let next = run.next();
|
||||
const collect = () => {
|
||||
while (
|
||||
!next.done &&
|
||||
(next.value.kind === "text" || next.value.kind === "hud")
|
||||
) {
|
||||
if (next.value.kind === "text")
|
||||
texts.push(next.value.text.replace(/\n/g, " "));
|
||||
next = run.next();
|
||||
}
|
||||
};
|
||||
collect();
|
||||
return {
|
||||
texts,
|
||||
get prompt(): BattleEffect | undefined {
|
||||
return next.done ? undefined : next.value;
|
||||
},
|
||||
get done() {
|
||||
return Boolean(next.done);
|
||||
},
|
||||
answer(input: BattleInput) {
|
||||
next = run.next(input);
|
||||
collect();
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
+248
-7
@@ -2,11 +2,16 @@ 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 { Battle } 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";
|
||||
import {
|
||||
battleOptions,
|
||||
battleSteps,
|
||||
partyMon,
|
||||
playBattle,
|
||||
} from "./battle-driver";
|
||||
|
||||
const romPath =
|
||||
process.env.CRYSTAL_ROM_PATH ??
|
||||
@@ -16,6 +21,18 @@ const PIDGEY = 16,
|
||||
TOTODILE = 158;
|
||||
const BRIGHTPOWDER = 0x03,
|
||||
ANTIDOTE = 0x09,
|
||||
FULL_RESTORE = 0x0e,
|
||||
MAX_ELIXER = 0x15,
|
||||
X_ACCURACY = 0x21,
|
||||
POKE_DOLL = 0x25,
|
||||
REVIVE = 0x27,
|
||||
GUARD_SPEC = 0x29,
|
||||
DIRE_HIT = 0x2c,
|
||||
ETHER = 0x3f,
|
||||
BITTER_BERRY = 0x53,
|
||||
ENERGYPOWDER = 0x79,
|
||||
REVIVAL_HERB = 0x7c,
|
||||
MYSTERYBERRY = 0x96,
|
||||
PARLYZ_HEAL = 0x0d,
|
||||
MAX_POTION = 0x0f,
|
||||
POTION = 0x12,
|
||||
@@ -196,13 +213,237 @@ describe.skipIf(!existsSync(romPath))("healing items in battle", () => {
|
||||
expect(bag.items).toEqual([{ item: BRIGHTPOWDER, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("refuses items whose battle effects are not ported", () => {
|
||||
const { battle } = setup(
|
||||
it("raises a stat with an X item, even past the limit, and uses the turn", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.happiness = 70;
|
||||
const { battle, bag } = setup([mon], [{ item: X_ATTACK, quantity: 2 }]);
|
||||
const steps = battleSteps(battle).answer(use(X_ATTACK));
|
||||
expect(steps.texts.slice(2, 5)).toEqual([
|
||||
"CHRIS used the X ATTACK.",
|
||||
"CYNDAQUIL's ATTACK went up!",
|
||||
"Enemy PIDGEY used TACKLE!",
|
||||
]);
|
||||
expect(battle.player.levels[C.ATTACK]).toBe(8);
|
||||
// HappinessChanges' X item row: +1 below 100.
|
||||
expect(mon.happiness).toBe(71);
|
||||
battle.player.levels[C.ATTACK] = 13;
|
||||
steps.answer(use(X_ATTACK));
|
||||
expect(steps.texts).toContain("CYNDAQUIL's ATTACK won't rise anymore!");
|
||||
expect(bag.items).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[X_ACCURACY, C.SUBSTATUS_X_ACCURACY],
|
||||
[GUARD_SPEC, C.SUBSTATUS_MIST],
|
||||
[DIRE_HIT, C.SUBSTATUS_FOCUS_ENERGY],
|
||||
])(
|
||||
"sets item %i's flag once, then keeps the item and the turn",
|
||||
(item, bit) => {
|
||||
const { battle, bag } = setup(
|
||||
[partyMon(assets, CYNDAQUIL, 10)],
|
||||
[{ item, quantity: 2 }],
|
||||
);
|
||||
const steps = battleSteps(battle).answer(use(item));
|
||||
expect(battle.player.sub[4] & (1 << bit)).toBeTruthy();
|
||||
const before = steps.texts.length;
|
||||
steps.answer(use(item));
|
||||
expect(steps.texts.slice(before)).toEqual(["It won't have any effect."]);
|
||||
expect(steps.prompt?.kind).toBe("action");
|
||||
expect(bag.items).toEqual([{ item, quantity: 1 }]);
|
||||
},
|
||||
);
|
||||
|
||||
it("ends a wild battle with a Poké Doll, and warns in a trainer battle", () => {
|
||||
const wild = setup(
|
||||
[partyMon(assets, CYNDAQUIL, 10)],
|
||||
[{ item: X_ATTACK, quantity: 1 }],
|
||||
[{ item: POKE_DOLL, quantity: 1 }],
|
||||
);
|
||||
expect(() => playBattle(battle, { action: [use(X_ATTACK)] })).toThrow(
|
||||
UnsupportedBattle,
|
||||
const { texts, result } = playBattle(wild.battle, {
|
||||
action: [use(POKE_DOLL)],
|
||||
});
|
||||
expect(texts.at(-1)).toBe("CHRIS used the POKé DOLL.");
|
||||
expect(result).toBe(C.DRAW);
|
||||
expect(wild.bag.items).toEqual([]);
|
||||
|
||||
const bag: Bag = {
|
||||
items: [{ item: POKE_DOLL, quantity: 1 }],
|
||||
balls: [],
|
||||
keyItems: [],
|
||||
};
|
||||
const trainer = new Battle(
|
||||
battleOptions(
|
||||
assets,
|
||||
trainers,
|
||||
[partyMon(assets, CYNDAQUIL, 15)],
|
||||
{ trainer: [22, 1] },
|
||||
{ bag },
|
||||
),
|
||||
);
|
||||
const steps = battleSteps(trainer).answer(use(POKE_DOLL));
|
||||
expect(steps.texts.at(-1)).toBe(
|
||||
"OAK: CHRIS! This isn't the time to use that!",
|
||||
);
|
||||
expect(bag.items).toEqual([{ item: POKE_DOLL, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("restores HP and status with a Full Restore, or only the status at full HP", () => {
|
||||
const hurt = partyMon(assets, CYNDAQUIL, 10);
|
||||
hurt.hp = 3;
|
||||
hurt.status = PSN;
|
||||
const first = setup([hurt], [{ item: FULL_RESTORE, quantity: 1 }]);
|
||||
const steps = battleSteps(first.battle).answer(use(FULL_RESTORE)).answer(0);
|
||||
expect(steps.texts).toContain(
|
||||
`CYNDAQUIL recovered ${hurt.stats[0] - 3}HP!`,
|
||||
);
|
||||
expect(first.battle.player.mon.status).toBe(0);
|
||||
|
||||
const healthy = partyMon(assets, CYNDAQUIL, 10);
|
||||
healthy.status = PSN;
|
||||
const second = setup([healthy], [{ item: FULL_RESTORE, quantity: 1 }]);
|
||||
expect(
|
||||
playBattle(second.battle, { action: [use(FULL_RESTORE)] }).texts,
|
||||
).toContain("CYNDAQUIL's health returned.");
|
||||
expect(healthy.status).toBe(0);
|
||||
});
|
||||
|
||||
it("revives a fainted Pokémon, which shares the experience again", () => {
|
||||
// As in the forced switch test, the Rattata faints the first Pokémon.
|
||||
const first = partyMon(assets, CYNDAQUIL, 5),
|
||||
second = partyMon(assets, TOTODILE, 10);
|
||||
first.hp = 1;
|
||||
const bag: Bag = {
|
||||
items: [{ item: REVIVE, quantity: 1 }],
|
||||
balls: [],
|
||||
keyItems: [],
|
||||
};
|
||||
const battle = new Battle(
|
||||
battleOptions(
|
||||
assets,
|
||||
trainers,
|
||||
[first, second],
|
||||
{ wild: { species: 19, level: 8 } },
|
||||
{ seed: 5, bag },
|
||||
),
|
||||
);
|
||||
const { texts } = playBattle(battle, {
|
||||
party: [1, 0],
|
||||
action: [{ action: "fight", move: 0 }, use(REVIVE)],
|
||||
});
|
||||
expect(texts).toContain("CYNDAQUIL fainted!");
|
||||
expect(texts).toContain("CYNDAQUIL is revitalized.");
|
||||
// ReviveHalfHP: half the maximum, rounded down.
|
||||
expect(first.hp).toBeLessThanOrEqual(first.stats[0] >> 1);
|
||||
expect(texts.some((text) => text.startsWith("CYNDAQUIL gained"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(bag.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("makes bitter medicine cost happiness", () => {
|
||||
const fainted = partyMon(assets, TOTODILE, 10),
|
||||
out = partyMon(assets, CYNDAQUIL, 10);
|
||||
fainted.hp = 0;
|
||||
fainted.happiness = 70;
|
||||
const herb = setup([out, fainted], [{ item: REVIVAL_HERB, quantity: 1 }]);
|
||||
const { texts } = playBattle(herb.battle, {
|
||||
action: [use(REVIVAL_HERB)],
|
||||
party: [1],
|
||||
});
|
||||
expect(texts).toContain("It looks bitter…");
|
||||
expect(fainted.hp).toBe(fainted.stats[0]);
|
||||
expect(fainted.happiness).toBe(55); // -15 below 100
|
||||
|
||||
const hurt = partyMon(assets, CYNDAQUIL, 10);
|
||||
hurt.hp = 3;
|
||||
hurt.happiness = 150;
|
||||
const powder = setup([hurt], [{ item: ENERGYPOWDER, quantity: 1 }]);
|
||||
expect(
|
||||
playBattle(powder.battle, { action: [use(ENERGYPOWDER)] }).texts,
|
||||
).toContain("It looks bitter…");
|
||||
expect(hurt.happiness).toBe(145); // -5 below 200
|
||||
});
|
||||
|
||||
it("cures confusion with a Bitter Berry, and does nothing otherwise", () => {
|
||||
const { battle, bag } = setup(
|
||||
[partyMon(assets, CYNDAQUIL, 10)],
|
||||
[{ item: BITTER_BERRY, quantity: 2 }],
|
||||
);
|
||||
const steps = battleSteps(battle);
|
||||
steps.answer(use(BITTER_BERRY));
|
||||
expect(steps.texts).toContain("It won't have any effect.");
|
||||
battle.player.sub[3] |= 1 << C.SUBSTATUS_CONFUSED;
|
||||
steps.answer(use(BITTER_BERRY));
|
||||
expect(steps.texts).toContain("CHRIS used the BITTER BERRY.");
|
||||
expect(steps.texts.some((text) => text.includes("confused"))).toBe(true);
|
||||
expect(battle.player.sub[3] & (1 << C.SUBSTATUS_CONFUSED)).toBe(0);
|
||||
expect(bag.items).toEqual([{ item: BITTER_BERRY, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it("restores PP with Ethers, Elixers and the MysteryBerry", () => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.pp[0] = (1 << 6) | 1; // One PP Up: TACKLE's maximum is 42.
|
||||
mon.pp[1] = 2;
|
||||
const { battle, bag } = setup(
|
||||
[mon],
|
||||
[
|
||||
{ item: ETHER, quantity: 1 },
|
||||
{ item: MYSTERYBERRY, quantity: 1 },
|
||||
{ item: MAX_ELIXER, quantity: 1 },
|
||||
],
|
||||
);
|
||||
const steps = battleSteps(battle).answer(use(ETHER)).answer(0);
|
||||
expect(steps.prompt).toMatchObject({
|
||||
kind: "move",
|
||||
text: "Restore the PP of\nwhich move?",
|
||||
});
|
||||
// Going back from the move list reopens the party menu.
|
||||
steps.answer(-1);
|
||||
expect(steps.prompt?.kind).toBe("party");
|
||||
steps.answer(0).answer(0);
|
||||
expect(steps.texts).toContain("PP was restored.");
|
||||
expect(mon.pp[0]).toBe((1 << 6) | 11);
|
||||
// BattleRestorePP: the Pokémon out takes the new PP.
|
||||
expect(battle.player.mon.pp[0] & 63).toBe(11);
|
||||
steps.answer(use(MYSTERYBERRY, 0)).answer(0).answer(1);
|
||||
expect(mon.pp[1]).toBe(7);
|
||||
steps.answer(use(MAX_ELIXER, 0)).answer(0);
|
||||
expect(mon.pp[0]).toBe((1 << 6) | 42);
|
||||
expect(mon.pp[1] & 63).toBe(assets.pokemon.move(mon.moves[1]).pp);
|
||||
expect(bag.items).toEqual([]);
|
||||
});
|
||||
|
||||
it("reapplies the paralysed enemy's Speed penalty when a heal follows a full turn", () => {
|
||||
// After HandleHealingItems, hBattleTurn holds the enemy, so
|
||||
// CalcPlayerStats penalises the enemy again (the original's behaviour).
|
||||
const mon = partyMon(assets, CYNDAQUIL, 10);
|
||||
mon.status = PSN;
|
||||
const { battle } = setup([mon], [{ item: ANTIDOTE, quantity: 1 }], 5);
|
||||
const steps = battleSteps(battle).answer({ action: "fight", move: 1 });
|
||||
battle.enemy.mon.status = PAR;
|
||||
const speed = battle.enemy.mon.stats[C.SPEED];
|
||||
steps.answer(use(ANTIDOTE)).answer(0);
|
||||
expect(steps.texts).toContain("CYNDAQUIL's cured of poison.");
|
||||
expect(battle.enemy.mon.stats[C.SPEED]).toBe(Math.max(1, speed >> 2));
|
||||
});
|
||||
|
||||
it("raises happiness more when leveling up where the Pokémon was caught", () => {
|
||||
const level = (landmark: number) => {
|
||||
const mon = partyMon(assets, CYNDAQUIL, 5);
|
||||
mon.exp = 178; // One Pidgey from level 6
|
||||
mon.happiness = 70;
|
||||
mon.caughtData = (mon.caughtData & ~0x7f) | 3;
|
||||
const bag: Bag = { items: [], balls: [], keyItems: [] };
|
||||
const options = battleOptions(
|
||||
assets,
|
||||
trainers,
|
||||
[mon],
|
||||
{ wild: { species: PIDGEY, level: 3 } },
|
||||
{ bag },
|
||||
);
|
||||
playBattle(new Battle({ ...options, landmark }));
|
||||
return mon.happiness;
|
||||
};
|
||||
expect(level(3)).toBe(80); // HAPPINESS_GAINLEVELATHOME: +10
|
||||
expect(level(4)).toBe(75); // HAPPINESS_GAINLEVEL: +5
|
||||
});
|
||||
});
|
||||
@@ -226,6 +226,46 @@ describe.skipIf(!existsSync(romPath))("the Pokémon Center PC", () => {
|
||||
expect(world.save()).toEqual(saved);
|
||||
});
|
||||
|
||||
it("opens the stats screen from Bill's PC for party and boxed Pokémon", () => {
|
||||
const world = fixture();
|
||||
const data = world.assets.pokemon;
|
||||
const pidgey = world.state.party[1];
|
||||
pidgey.hp = 1;
|
||||
pidgey.status = 0x08;
|
||||
open(world);
|
||||
pick(world, "BILL's PC");
|
||||
pick(world, /^DEPOSIT/);
|
||||
pick(world, "PIDGEY");
|
||||
const stats = () => {
|
||||
const choice = world.dialogue?.choice;
|
||||
if (choice?.kind !== "menu") throw new Error("Expected a menu");
|
||||
world.choose(choice.items.indexOf("STATS"));
|
||||
for (let tick = 0; tick < 10 && !world.statsScreen; tick++) world.tick();
|
||||
const shown = world.statsScreen;
|
||||
world.advanceDialogue();
|
||||
until(world);
|
||||
return shown;
|
||||
};
|
||||
// A party Pokémon shows its own HP and status.
|
||||
expect(stats()).toMatchObject({ species: PIDGEY, hp: 1, status: 0x08 });
|
||||
expect(world.statsScreen).toBeUndefined();
|
||||
expect(menuItems(world)).toEqual(["DEPOSIT", "STATS", "RELEASE", "CANCEL"]);
|
||||
pick(world, "DEPOSIT");
|
||||
pick(world, "CANCEL");
|
||||
pick(world, /^WITHDRAW/);
|
||||
pick(world, "PIDGEY");
|
||||
// CalcTempmonStats: a boxed Pokémon shows computed stats, full HP and
|
||||
// no status.
|
||||
const boxed = stats()!;
|
||||
expect(boxed.status).toBe(0);
|
||||
expect(boxed.hp).toBe(boxed.stats[0]);
|
||||
expect(boxed.stats).toEqual(
|
||||
calcStats(data, PIDGEY, pidgey.dvs, 4, pidgey.statExp),
|
||||
);
|
||||
expect(menuItems(world)).toEqual(["WITHDRAW", "STATS", "RELEASE", "CANCEL"]);
|
||||
expect(world.notice, world.diagnostic).toBe("");
|
||||
});
|
||||
|
||||
it("refuses to withdraw into a full party or deposit into a full box", () => {
|
||||
const world = fixture();
|
||||
const party = world.state.party;
|
||||
|
||||
@@ -2,10 +2,18 @@ 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 {
|
||||
Battle,
|
||||
applyTypeMatchups,
|
||||
calculateDamage,
|
||||
restoreHealth,
|
||||
restoredPP,
|
||||
sendOutText,
|
||||
withdrawText,
|
||||
} 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 { calcStat, maxPP } from "../src/game/party/pokemon";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const romPath = resolve(
|
||||
@@ -410,3 +418,260 @@ test("native stats and experience curves match the original routines", async ({
|
||||
expect(pokemon.expAtLevel(growth, level), `experience ${growth}/${level}`).toBe(original.exp);
|
||||
});
|
||||
});
|
||||
|
||||
test("native switch texts, healing, PP, happiness and CalcPlayerStats 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 bytes = new Uint8Array(readFileSync(romPath));
|
||||
const assets = new CrystalAssets(bytes);
|
||||
const pokemon = assets.pokemon;
|
||||
let seed = 0xbeef;
|
||||
const next = (limit: number) => {
|
||||
seed = (seed * 1103515245 + 12345) >>> 0;
|
||||
return (seed >>> 8) % limit;
|
||||
};
|
||||
const maxHp = () => (next(4) === 0 ? 4 + next(60) : 4 + next(996));
|
||||
const cheerCases = Array.from({ length: 300 }, () => {
|
||||
const max = maxHp();
|
||||
return { max, hp: next(max + 1), atSwitch: next(max + 1) };
|
||||
});
|
||||
const healthCases = Array.from({ length: 300 }, () => {
|
||||
const max = 1 + next(999);
|
||||
const amounts = [10, 20, 50, 60, 80, 100, 200, 999, next(0x10000)];
|
||||
return { max, hp: next(max + 1), amount: amounts[next(amounts.length)] };
|
||||
});
|
||||
const ppItems = [C.MAX_ELIXER, 0x3f, C.MAX_ETHER, C.ELIXER, C.MYSTERYBERRY];
|
||||
const ppCases = Array.from({ length: 200 }, () => ({
|
||||
move: 1 + next(251),
|
||||
pp: (next(4) << 6) | next(64),
|
||||
item: ppItems[next(ppItems.length)],
|
||||
}));
|
||||
const happinessCases = Array.from({ length: 300 }, () => ({
|
||||
happiness: next(256),
|
||||
action: 1 + next(19),
|
||||
cursor: next(7),
|
||||
mon: next(6),
|
||||
egg: next(8) === 0,
|
||||
}));
|
||||
const statCases = Array.from({ length: 200 }, () => ({
|
||||
levels: Array.from({ length: 5 }, () => 1 + next(13)),
|
||||
stats: Array.from({ length: 5 }, () => 1 + next(999)),
|
||||
status: [0, 1 << 6, 1 << 4, 1 << 3][next(4)],
|
||||
enemyStatus: [0, 1 << 6, 1 << 4][next(3)],
|
||||
enemyStats: Array.from({ length: 5 }, () => 1 + next(999)),
|
||||
turn: next(2),
|
||||
}));
|
||||
await openReference(page);
|
||||
const skipToTextbox = routine("SendOutMonText.skip_to_textbox");
|
||||
const addresses = {
|
||||
// The ROM copy loaded in the page gets `ret` instead of SendOutMonText's
|
||||
// final `jp BattleTextbox`, so the routine returns its text in HL.
|
||||
patch: skipToTextbox.bank * 0x4000 + skipToTextbox.address - 0x4000,
|
||||
sendOut: routine("SendOutMonText"),
|
||||
withdraw: { ...routine("WithdrawMonText.WithdrawMonText"), address: at("WithdrawMonText.WithdrawMonText") + 5 },
|
||||
restoreHealth: routine("RestoreHealth"),
|
||||
restorePP: routine("RestorePP"),
|
||||
changeHappiness: routine("ChangeHappiness"),
|
||||
calcPlayerStats: routine("CalcPlayerStats"),
|
||||
multiplicand: at("hMultiplicand"),
|
||||
enemyHP: at("wEnemyMonHP"),
|
||||
enemyMaxHP: at("wEnemyMonMaxHP"),
|
||||
hpAtSwitch: at("wEnemyHPAtTimeOfPlayerSwitch"),
|
||||
link: at("wLinkMode"),
|
||||
curPartyMon: at("wCurPartyMon"),
|
||||
partyMon1: at("wPartyMon1"),
|
||||
partySpecies: at("wPartySpecies"),
|
||||
partyCount: at("wPartyCount"),
|
||||
menuCursorY: at("wMenuCursorY"),
|
||||
hpBuffer1: at("wHPBuffer1"),
|
||||
ppItem: at("wTempRestorePPItem"),
|
||||
monType: at("wMonType"),
|
||||
battleMode: at("wBattleMode"),
|
||||
partyMenuCursor: at("wPartyMenuCursor"),
|
||||
battleMonHappiness: at("wBattleMonHappiness"),
|
||||
playerLevels: at("wPlayerAtkLevel"),
|
||||
playerStats: at("wPlayerStats"),
|
||||
battleMonStats: at("wBattleMonAttack"),
|
||||
battleMonStatus: at("wBattleMonStatus"),
|
||||
enemyStatus: at("wEnemyMonStatus"),
|
||||
enemyStats: at("wEnemyMonAttack"),
|
||||
johto: at("wJohtoBadges"),
|
||||
turn: at("hBattleTurn"),
|
||||
text: {
|
||||
GoMonText: at("GoMonText"),
|
||||
DoItMonText: at("DoItMonText"),
|
||||
GoForItMonText: at("GoForItMonText"),
|
||||
YourFoesWeakGetmMonText: at("YourFoesWeakGetmMonText"),
|
||||
ThatsEnoughComeBackText: at("ThatsEnoughComeBackText"),
|
||||
ComeBackText: at("ComeBackText"),
|
||||
OKComeBackText: at("OKComeBackText"),
|
||||
GoodComeBackText: at("GoodComeBackText"),
|
||||
},
|
||||
};
|
||||
const results = await page.evaluate(
|
||||
async ({ addresses, cheerCases, healthCases, ppCases, happinessCases, statCases }) => {
|
||||
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||
const file = (document.querySelector("#reference-fixture") as HTMLInputElement).files![0];
|
||||
const rom = new Uint8Array(await file.arrayBuffer());
|
||||
if (rom[addresses.patch] !== 0xc3) throw new Error("Unexpected SendOutMonText ending.");
|
||||
rom[addresses.patch] = 0xc9;
|
||||
const reference = new BinjgbEmulator();
|
||||
await reference.load(rom, document.createElement("canvas"));
|
||||
const write = (address: number, value: number) => reference.writeMemory(address, value & 0xff);
|
||||
const word = (address: number, value: number) => {
|
||||
write(address, value >> 8);
|
||||
write(address + 1, value);
|
||||
};
|
||||
const readWord = (address: number) => (reference.readMemory(address) << 8) | reference.readMemory(address + 1);
|
||||
// Trampoline: DI; SP=$c7fe; switch banks; set BC and DE; CALL; store
|
||||
// HL at $c6fd; write a sentinel; loop.
|
||||
const call = (target: { bank: number; address: number }, bc = 0, de = 0) => {
|
||||
const program = [
|
||||
0xf3, 0x31, 0xfe, 0xc7, 0x3e, target.bank, 0xe0, 0x9d, 0xea, 0x00, 0x20,
|
||||
0x01, bc & 0xff, bc >> 8, 0x11, de & 0xff, de >> 8,
|
||||
0xcd, target.address & 0xff, target.address >> 8,
|
||||
0x7d, 0xea, 0xfd, 0xc6, 0x7c, 0xea, 0xfe, 0xc6,
|
||||
0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe,
|
||||
];
|
||||
write(0xc6ff, 0);
|
||||
program.forEach((value, index) => write(0xc000 + index, value));
|
||||
reference.setProgramCounter(0xc000);
|
||||
reference.advanceFrames(2);
|
||||
if (reference.readMemory(0xc6ff) !== 0x42) throw new Error(`Routine at ${target.address.toString(16)} did not return.`);
|
||||
return reference.readMemory(0xc6fd) | (reference.readMemory(0xc6fe) << 8);
|
||||
};
|
||||
write(0xff70, 1);
|
||||
write(addresses.link, 0);
|
||||
write(addresses.johto, 0);
|
||||
const textName = (hl: number) =>
|
||||
Object.entries(addresses.text).find(([, address]) => address === hl)?.[0] ?? `0x${hl.toString(16)}`;
|
||||
const cheers = cheerCases.map((input) => {
|
||||
word(addresses.enemyHP, input.hp);
|
||||
word(addresses.enemyMaxHP, input.max);
|
||||
const sendOut = textName(call(addresses.sendOut));
|
||||
word(addresses.hpAtSwitch, input.atSwitch);
|
||||
write(addresses.multiplicand, 0);
|
||||
const withdraw = textName(call(addresses.withdraw));
|
||||
return { sendOut, withdraw };
|
||||
});
|
||||
const mon = addresses.partyMon1;
|
||||
write(addresses.curPartyMon, 0);
|
||||
const health = healthCases.map((input) => {
|
||||
word(mon + 34, input.hp);
|
||||
word(mon + 36, input.max);
|
||||
// ItemRestoreHP's IsMonFainted loads the maximum into wHPBuffer1
|
||||
// (low byte first), which a full restore copies.
|
||||
write(addresses.hpBuffer1, input.max);
|
||||
write(addresses.hpBuffer1 + 1, input.max >> 8);
|
||||
call(addresses.restoreHealth, 0, input.amount);
|
||||
return readWord(mon + 34);
|
||||
});
|
||||
const pp = ppCases.map((input) => {
|
||||
write(mon + 2, input.move);
|
||||
write(mon + 23, input.pp);
|
||||
write(addresses.menuCursorY, 0);
|
||||
write(addresses.ppItem, input.item);
|
||||
call(addresses.restorePP);
|
||||
return reference.readMemory(mon + 23);
|
||||
});
|
||||
write(addresses.partyCount, 6);
|
||||
write(addresses.battleMode, 1);
|
||||
const happiness = happinessCases.map((input) => {
|
||||
for (let slot = 0; slot < 6; slot++) {
|
||||
write(addresses.partySpecies + slot, input.egg && slot === input.mon ? 0xfd : 155);
|
||||
write(mon + slot * 48 + 27, input.happiness);
|
||||
}
|
||||
write(addresses.curPartyMon, input.mon);
|
||||
write(addresses.partyMenuCursor, input.cursor);
|
||||
write(addresses.battleMonHappiness, 0x55);
|
||||
call(addresses.changeHappiness, input.action);
|
||||
return {
|
||||
party: reference.readMemory(mon + input.mon * 48 + 27),
|
||||
battle: reference.readMemory(addresses.battleMonHappiness),
|
||||
};
|
||||
});
|
||||
const stats = statCases.map((input) => {
|
||||
input.levels.forEach((level, i) => write(addresses.playerLevels + i, level));
|
||||
input.stats.forEach((value, i) => word(addresses.playerStats + i * 2, value));
|
||||
input.enemyStats.forEach((value, i) => word(addresses.enemyStats + i * 2, value));
|
||||
write(addresses.battleMonStatus, input.status);
|
||||
write(addresses.enemyStatus, input.enemyStatus);
|
||||
write(addresses.turn, input.turn);
|
||||
call(addresses.calcPlayerStats);
|
||||
return {
|
||||
player: Array.from({ length: 5 }, (_, i) => readWord(addresses.battleMonStats + i * 2)),
|
||||
enemy: Array.from({ length: 5 }, (_, i) => readWord(addresses.enemyStats + i * 2)),
|
||||
turn: reference.readMemory(addresses.turn),
|
||||
};
|
||||
});
|
||||
reference.dispose();
|
||||
return { cheers, health, pp, happiness, stats };
|
||||
},
|
||||
{ addresses, cheerCases, healthCases, ppCases, happinessCases, statCases },
|
||||
);
|
||||
cheerCases.forEach((input, index) => {
|
||||
expect(
|
||||
{ sendOut: sendOutText(input.hp, input.max), withdraw: withdrawText(input.atSwitch, input.hp, input.max) },
|
||||
`cheer case ${index}: ${JSON.stringify(input)}`,
|
||||
).toEqual(results.cheers[index]);
|
||||
});
|
||||
healthCases.forEach((input, index) =>
|
||||
expect(restoreHealth(input.hp, input.max, input.amount), `health case ${index}: ${JSON.stringify(input)}`).toBe(
|
||||
results.health[index],
|
||||
),
|
||||
);
|
||||
ppCases.forEach((input, index) => {
|
||||
const max = maxPP(pokemon, input.move, input.pp >> 6) & 63;
|
||||
expect(restoredPP(input.pp, max, input.item) ?? input.pp, `PP case ${index}: ${JSON.stringify(input)}`).toBe(
|
||||
results.pp[index],
|
||||
);
|
||||
});
|
||||
// ChangeHappiness and CalcPlayerStats run on a battle's own state.
|
||||
const battle = new Battle({
|
||||
data: pokemon,
|
||||
party: [],
|
||||
pokedex: { seen: new Set(), caught: new Set() },
|
||||
playerId: 1,
|
||||
playerName: "CHRIS",
|
||||
timeOfDay: 1,
|
||||
random: () => 0,
|
||||
text: () => ({}) as never,
|
||||
});
|
||||
const internals = battle as unknown as {
|
||||
options: { party: { species: number; happiness: number }[] };
|
||||
partyMenuCursor: number;
|
||||
turn: number;
|
||||
changeHappiness(index: number, action: number): void;
|
||||
calcPlayerStats(): void;
|
||||
};
|
||||
happinessCases.forEach((input, index) => {
|
||||
internals.options.party = Array.from({ length: 6 }, (_, slot) => ({
|
||||
species: input.egg && slot === input.mon ? C.EGG : 155,
|
||||
happiness: input.happiness,
|
||||
}));
|
||||
internals.partyMenuCursor = input.cursor;
|
||||
battle.player.mon = { happiness: 0x55 } as never;
|
||||
internals.changeHappiness(input.mon, input.action);
|
||||
expect(
|
||||
{ party: internals.options.party[input.mon].happiness, battle: battle.player.mon.happiness },
|
||||
`happiness case ${index}: ${JSON.stringify(input)}`,
|
||||
).toEqual(results.happiness[index]);
|
||||
});
|
||||
statCases.forEach((input, index) => {
|
||||
battle.player.levels = [...input.levels, 7, 7];
|
||||
battle.player.stats = [...input.stats];
|
||||
battle.player.mon = { stats: [0, 0, 0, 0, 0], status: input.status } as never;
|
||||
battle.enemy.mon = { stats: [...input.enemyStats], status: input.enemyStatus } as never;
|
||||
internals.turn = input.turn;
|
||||
internals.calcPlayerStats();
|
||||
expect(
|
||||
{ player: battle.player.mon.stats, enemy: battle.enemy.mon.stats, turn: internals.turn },
|
||||
`stat case ${index}: ${JSON.stringify(input)}`,
|
||||
).toEqual(results.stats[index]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user