Port multi-hit moves and the special-damage move effects

Multi-hit moves loop back to the script's critical command as EndLoop
does, with its hit-count draws, Triple Kick's growing kicks and Beat Up's
party members, and say "Hit n times!" from the borrowed high byte of the
user's damage taken. ConstantDamage covers Seismic Toss, Night Shade,
Sonicboom, Dragon Rage, Psywave, Super Fang and Flail/Reversal.

One-hit KO moves, Counter, Mirror Coat, False Swipe, Magnitude, Present,
Hidden Power, Return, Frustration, Selfdestruct, Dream Eater, Thief, Pay
Day, freezing, Tri Attack, added confusion, Swagger, thawing by fire
moves, Jump Kick's crash and Minimize with Stomp now run. Pay Day's coins
are paid after a win, and the Amulet Coin is checked at every send-out.
A Focus Band now also holds on at 1 HP.

The original's arithmetic commands are compared with the native ones in
the reference emulator on shared states and random streams.

Part of #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 10:45:07 +02:00
1 parent 802397b668
commit 9e0a2cd2a8
9 files changed
+1707 -29

No files matched your search

+533
View File
@@ -0,0 +1,533 @@
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, type BattleOptions } from "../src/game/battle/battle";
import { C } from "../src/game/battle/constants";
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
import { battleOptions, battleSteps, playBattle } from "./battle-driver";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
// constants/move_constants.asm
const DOUBLESLAP = 3,
PAY_DAY = 6,
STOMP = 23,
DOUBLE_KICK = 24,
JUMP_KICK = 26,
FURY_ATTACK = 31,
HORN_DRILL = 32,
TACKLE = 33,
TWINEEDLE = 41,
SONICBOOM = 49,
EMBER = 52,
COUNTER = 68,
SEISMIC_TOSS = 69,
DRAGON_RAGE = 82,
CONFUSION = 93,
HYPNOSIS = 95,
NIGHT_SHADE = 101,
MINIMIZE = 107,
SELFDESTRUCT = 120,
DREAM_EATER = 138,
PSYWAVE = 149,
SPLASH = 150,
TRI_ATTACK = 161,
SUPER_FANG = 162,
TRIPLE_KICK = 167,
THIEF = 168,
FLAME_WHEEL = 172,
FLAIL = 175,
FALSE_SWIPE = 206,
SWAGGER = 207,
RETURN = 216,
PRESENT = 217,
FRUSTRATION = 218,
MAGNITUDE = 222,
HIDDEN_POWER = 237,
MIRROR_COAT = 243,
BEAT_UP = 251,
ICE_BEAM = 58;
// Species and items
const PIDGEY = 16,
RATTATA = 19,
GEODUDE = 74,
GASTLY = 92,
SNORLAX = 143,
JOLTEON = 135,
ABRA = 63,
MEOWTH = 52,
HITMONLEE = 106,
LAPRAS = 131,
CYNDAQUIL = 155,
TOTODILE = 158;
const AMULET_COIN = 0x5b,
LEFTOVERS = 0x92,
FOCUS_BAND = 0x77,
FLOWER_MAIL = 0x9e;
const FRZ = 1 << C.FRZ,
PSN = 1 << C.PSN;
describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
let assets: CrystalAssets, trainers: TrainerData;
beforeAll(() => {
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
trainers = new TrainerData(assets.reader);
});
const mon = (
species: number,
level: number,
moves: number[],
extra: Partial<PartyMon> = {},
): PartyMon => {
const result = generatePartyMon(assets.pokemon, {
species,
level,
item: 0,
otId: 1234,
ot: "CHRIS",
dvs: 0x823b,
caughtData: 0,
});
result.moves = [0, 1, 2, 3].map((i) => moves[i] ?? 0) as PartyMon["moves"];
result.pp = result.moves.map((move) =>
move ? assets.pokemon.move(move).pp : 0,
) as PartyMon["pp"];
return Object.assign(result, extra);
};
/** A battle against a trainer with this party and no AI layers, so a
* Pokémon with one move always uses it. */
const duel = (
party: PartyMon[],
enemy: PartyMon[] | { wild: { species: number; level: number } },
{ seed = 1, ...options }: { seed?: number } & Partial<BattleOptions> = {},
) => {
const base = battleOptions(
assets,
trainers,
party,
{ wild: { species: PIDGEY, level: 2 } },
{ seed },
);
return new Battle({
...base,
...(Array.isArray(enemy)
? {
wild: undefined,
trainer: {
name: "JOEY",
className: "YOUNGSTER",
party: enemy,
aiFlags: 0,
switchFlags: 0,
reward: 40,
},
}
: enemy),
...options,
});
};
/** Play the first turn: the texts from FIGHT with the first move until
* the next battle menu or the end. */
const firstTurn = (battle: Battle) => {
const steps = battleSteps(battle);
const before = steps.texts.length;
steps.answer({ action: "fight", move: 0 });
return { steps, texts: steps.texts.slice(before) };
};
/** The first seed whose first turn passes `test`. */
const findSeed = (
make: (seed: number) => Battle,
test: (texts: string[], battle: Battle) => boolean,
) => {
for (let seed = 1; seed < 500; seed++) {
const battle = make(seed);
const { texts } = firstTurn(battle);
if (test(texts, battle)) return { seed, battle, texts };
}
throw new Error("No seed found.");
};
describe("multi-hit moves", () => {
it("hits two to five times and says how often", () => {
const counts = new Set<number>();
for (let seed = 1; seed < 60; seed++) {
const battle = duel([mon(CYNDAQUIL, 30, [FURY_ATTACK])], [mon(SNORLAX, 50, [SPLASH])], { seed });
const { texts } = firstTurn(battle);
const missed = texts.some((text) => text.includes("attack missed"));
const hit = texts.map((text) => /^Hit (\d) times!$/.exec(text)).find(Boolean);
if (missed) {
expect(hit).toBeUndefined();
continue;
}
const n = Number(hit![1]);
counts.add(n);
expect(n).toBeGreaterThanOrEqual(2);
expect(n).toBeLessThanOrEqual(5);
// The loop counter and the hit count are spent; the high byte of
// the user's damage taken is cleared again.
expect(battle.player.rolloutCount).toBe(0);
expect(battle.player.damageTaken >> 8).toBe(0);
expect(battle.player.has(3, C.SUBSTATUS_IN_LOOP)).toBe(false);
}
expect([...counts].sort()).toEqual([2, 3, 4, 5]);
});
it("hits twice with Double Kick and Twineedle, and only says effectiveness once", () => {
for (const move of [DOUBLE_KICK, TWINEEDLE]) {
const { texts } = findSeed(
(seed) => duel([mon(HITMONLEE, 30, [move])], [mon(GEODUDE, 50, [SPLASH])], { seed }),
(texts) => texts.includes("Hit 2 times!"),
);
const effectiveness = texts.filter((text) => /effective/.test(text));
// Double Kick is super effective against Geodude; Twineedle is
// neutral. Either way the loop's later hits say nothing.
expect(effectiveness).toHaveLength(move === DOUBLE_KICK ? 1 : 0);
}
});
it("stops without the hit count when the target faints mid-loop", () => {
const enemy = mon(RATTATA, 2, [SPLASH]);
enemy.hp = 1;
const battle = duel([mon(CYNDAQUIL, 30, [DOUBLESLAP])], [enemy]);
const { texts } = playBattle(battle);
expect(texts.join(" ")).toContain("fainted");
expect(texts.some((text) => text.startsWith("Hit "))).toBe(false);
expect(battle.player.has(3, C.SUBSTATUS_IN_LOOP)).toBe(false);
});
it("kicks up to three times with growing damage", () => {
const counts = new Set<number>();
for (let seed = 1; seed < 40; seed++) {
const battle = duel([mon(HITMONLEE, 30, [TRIPLE_KICK])], [mon(SNORLAX, 60, [SPLASH])], { seed });
const { texts } = firstTurn(battle);
const hit = texts.map((text) => /^Hit (\d) times!$/.exec(text)).find(Boolean);
if (hit) counts.add(Number(hit[1]));
}
expect([...counts].sort()).toEqual([1, 2, 3]);
});
it("beats up with each healthy member, by nickname, skipping fainted and statused ones", () => {
const party = [
mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" }),
mon(TOTODILE, 20, [TACKLE], { nickname: "BRAVO", hp: 0 }),
mon(PIDGEY, 20, [TACKLE], { nickname: "CHARLIE", status: PSN }),
mon(RATTATA, 20, [TACKLE], { nickname: "DELTA" }),
];
const { texts } = firstTurn(duel(party, [mon(SNORLAX, 60, [SPLASH])]));
const attacks = texts.filter((text) => text.endsWith("'s attack!"));
expect(attacks).toEqual(["ALPHA's attack!", "DELTA's attack!"]);
expect(texts.some((text) => text.startsWith("Hit "))).toBe(false);
});
it("lets a lone Pokémon beat up once, then ends the move", () => {
const { texts } = firstTurn(duel([mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" })], [mon(SNORLAX, 60, [SPLASH])]));
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!"]);
expect(texts).not.toContain("But it failed!");
});
it("names a trainer's members by species", () => {
const enemy = [mon(SNORLAX, 60, [BEAT_UP]), mon(GEODUDE, 20, [SPLASH])];
const { texts } = firstTurn(duel([mon(PIDGEY, 60, [SPLASH])], enemy));
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["SNORLAX's attack!", "GEODUDE's attack!"]);
});
});
describe("fixed and special damage", () => {
const damageTo = (move: number, enemy: PartyMon, level = 30) => {
const battle = duel([mon(ABRA, level, [move])], [enemy]);
const before = enemy.hp;
const { texts } = firstTurn(battle);
return { damage: before - battle.enemy.mon.hp, texts, battle };
};
it("deals the level, 20 or 40 HP, or half the target's HP", () => {
expect(damageTo(SEISMIC_TOSS, mon(SNORLAX, 50, [SPLASH]), 37).damage).toBe(37);
expect(damageTo(NIGHT_SHADE, mon(GEODUDE, 50, [SPLASH]), 41).damage).toBe(41);
expect(damageTo(SONICBOOM, mon(SNORLAX, 50, [SPLASH])).damage).toBe(20);
expect(damageTo(DRAGON_RAGE, mon(SNORLAX, 50, [SPLASH])).damage).toBe(40);
const snorlax = mon(SNORLAX, 50, [SPLASH]);
const half = snorlax.hp >> 1;
expect(damageTo(SUPER_FANG, snorlax).damage).toBe(half);
});
it("misses an immune type", () => {
const normal = damageTo(NIGHT_SHADE, mon(SNORLAX, 50, [SPLASH]));
expect(normal.damage).toBe(0);
expect(normal.texts.join(" ")).toContain("doesn't affect");
const ghost = damageTo(SEISMIC_TOSS, mon(GASTLY, 50, [SPLASH]));
expect(ghost.damage).toBe(0);
expect(ghost.texts.join(" ")).toContain("doesn't affect");
});
it("rolls Psywave below one and a half times the level", () => {
for (let seed = 1; seed < 20; seed++) {
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([mon(ABRA, 40, [PSYWAVE])], [enemy], { seed });
firstTurn(battle);
const damage = enemy.hp - battle.enemy.mon.hp;
if (!damage) continue;
expect(damage).toBeGreaterThan(0);
expect(damage).toBeLessThan(60);
}
});
it("hits harder with Flail at low HP and resets its power", () => {
const full = damageTo(FLAIL, mon(SNORLAX, 50, [SPLASH]));
const low = (() => {
const user = mon(ABRA, 30, [FLAIL]);
user.hp = 1;
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([user], [enemy]);
firstTurn(battle);
return { damage: enemy.hp - battle.enemy.mon.hp, battle };
})();
expect(low.damage).toBeGreaterThan(full.damage * 4);
expect(low.battle.player.move.power).toBe(1);
});
it("knocks out in one hit or is unaffected against a higher level", () => {
const { texts, battle } = findSeed(
(seed) => duel([mon(SNORLAX, 60, [HORN_DRILL])], [mon(LAPRAS, 20, [SPLASH])], { seed }),
(texts) => texts.includes("It's a one-hit KO!"),
);
expect(battle.enemy.mon.hp).toBe(0);
expect(texts).toContain("It's a one-hit KO!");
const higher = firstTurn(duel([mon(SNORLAX, 20, [HORN_DRILL])], [mon(LAPRAS, 60, [SPLASH])]));
expect(higher.texts.join(" ")).toContain("unaffected");
});
it("leaves 1 HP with False Swipe", () => {
const enemy = mon(RATTATA, 3, [SPLASH]);
const battle = duel([mon(SNORLAX, 60, [FALSE_SWIPE])], [enemy]);
firstTurn(battle);
expect(battle.enemy.mon.hp).toBe(1);
});
it("crashes on a missed Jump Kick for an eighth of the damage", () => {
const { texts, battle } = findSeed(
(seed) => duel([mon(HITMONLEE, 30, [JUMP_KICK])], [mon(SNORLAX, 50, [SPLASH])], { seed }),
(texts) => texts.some((text) => text.includes("crashed")),
);
expect(battle.player.mon.hp).toBeLessThan(battle.player.mon.maxHp);
expect(texts.join(" ")).toContain("missed");
});
it("doubles Stomp's damage against a minimized target", () => {
const battle = duel([mon(SNORLAX, 40, [STOMP])], [mon(GASTLY, 50, [MINIMIZE])]);
firstTurn(battle);
expect(battle.enemy.minimized).toBe(1);
});
it("sets Hidden Power's type and power from the DVs", () => {
// DVs $823b: Poison, 55 power.
const battle = duel([mon(ABRA, 30, [HIDDEN_POWER])], [mon(GEODUDE, 50, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(battle.player.move.type).toBe(C.POISON);
expect(texts.join(" ")).toContain("not very effective");
});
it("deals no damage with Return at very low happiness or Frustration at very high", () => {
for (const [move, happiness] of [[RETURN, 2], [FRUSTRATION, 255]]) {
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([mon(ABRA, 30, [move], { happiness })], [enemy]);
firstTurn(battle);
expect(battle.enemy.mon.hp).toBe(enemy.hp);
}
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([mon(ABRA, 30, [RETURN], { happiness: 255 })], [enemy]);
firstTurn(battle);
expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp);
});
it("announces Magnitude 4 to 10", () => {
const seen = new Set<string>();
for (let seed = 1; seed < 60; seed++) {
const { texts } = firstTurn(duel([mon(GEODUDE, 30, [MAGNITUDE])], [mon(SNORLAX, 50, [SPLASH])], { seed }));
const text = texts.find((t) => t.startsWith("Magnitude "));
if (text) seen.add(text);
}
expect([...seen].sort()).toEqual(
["Magnitude 10!", "Magnitude 4!", "Magnitude 5!", "Magnitude 6!", "Magnitude 7!", "Magnitude 8!", "Magnitude 9!"],
);
});
it("heals the target with a Present, which a full target refuses only with animations off", () => {
const healed = findSeed(
(seed) => {
const enemy = mon(SNORLAX, 50, [SPLASH]);
enemy.hp = 20;
return duel([mon(ABRA, 30, [PRESENT])], [enemy], { seed });
},
(texts) => texts.some((text) => text.includes("regained health")),
);
const quarter = healed.battle.enemy.mon.maxHp >> 2;
expect(healed.battle.enemy.mon.hp).toBe(20 + quarter);
expect(healed.texts.join(" ")).toContain("SNORLAX regained health");
const refused = (battleScene: boolean) =>
firstTurn(duel([mon(ABRA, 30, [PRESENT])], [mon(SNORLAX, 50, [SPLASH])], { seed: healed.seed, battleScene })).texts;
expect(refused(false).join(" ")).toContain("refused the gift");
expect(refused(true).join(" ")).not.toContain("refused the gift");
});
});
describe("Counter and Mirror Coat", () => {
it("returns twice a physical hit taken first", () => {
const player = mon(SNORLAX, 40, [COUNTER]);
const enemy = mon(JOLTEON, 40, [TACKLE]);
const battle = duel([player], [enemy]);
firstTurn(battle);
const taken = player.stats[0] - battle.player.mon.hp;
expect(taken).toBeGreaterThan(0);
expect(enemy.hp - battle.enemy.mon.hp).toBe(taken * 2);
});
it("fails against special moves, and Mirror Coat the other way round", () => {
const counter = duel([mon(SNORLAX, 40, [COUNTER])], [mon(JOLTEON, 40, [EMBER])]);
expect(firstTurn(counter).texts.join(" ")).toContain("missed");
const enemy = mon(JOLTEON, 40, [EMBER]);
const mirror = duel([mon(SNORLAX, 40, [MIRROR_COAT])], [enemy]);
firstTurn(mirror);
const taken = mirror.player.mon.maxHp - mirror.player.mon.hp;
expect(enemy.hp - mirror.enemy.mon.hp).toBe(taken * 2);
});
});
describe("statuses", () => {
it("freezes with an ice move, and the frozen Pokémon cannot move", () => {
const { battle, texts } = findSeed(
(seed) => duel([mon(LAPRAS, 40, [ICE_BEAM])], [mon(SNORLAX, 60, [TACKLE])], { seed }),
(texts) => texts.some((text) => text.includes("frozen solid")),
);
expect(battle.enemy.mon.status & FRZ).toBeTruthy();
expect(texts.join(" ")).not.toContain("SNORLAX used");
});
it("thaws a frozen target with a fire move, whatever the burn chance", () => {
const enemy = mon(SNORLAX, 60, [SPLASH], { status: FRZ });
const battle = duel([mon(CYNDAQUIL, 30, [EMBER])], [enemy]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("was defrosted");
expect(battle.enemy.mon.status).toBe(0);
expect(enemy.status).toBe(0);
});
it("thaws a frozen user of Flame Wheel", () => {
const player = mon(CYNDAQUIL, 30, [FLAME_WHEEL], { status: FRZ });
const battle = duel([player], [mon(SNORLAX, 60, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("was defrosted");
expect(player.status).toBe(0);
});
it("may paralyze, freeze or burn with Tri Attack", () => {
const seen = new Set<string>();
for (let seed = 1; seed < 400 && seen.size < 3; seed++) {
const { texts } = firstTurn(duel([mon(ABRA, 40, [TRI_ATTACK])], [mon(SNORLAX, 60, [SPLASH])], { seed }));
for (const [pattern, name] of [[/paralyzed/, "par"], [/frozen solid/, "frz"], [/burned/, "brn"]] as const)
if (texts.some((text) => pattern.test(text))) seen.add(name);
}
expect([...seen].sort()).toEqual(["brn", "frz", "par"]);
});
it("confuses with Confusion's added effect", () => {
const { battle } = findSeed(
(seed) => duel([mon(ABRA, 40, [CONFUSION])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
(texts) => texts.some((text) => text.includes("became confused")),
);
expect(battle.enemy.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
});
it("eats the dream of a sleeping target only", () => {
const asleep = mon(SNORLAX, 60, [SPLASH], { status: 3 });
const user = mon(ABRA, 40, [DREAM_EATER]);
user.hp = 10;
const battle = duel([user], [asleep]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("dream was eaten");
expect(battle.player.mon.hp).toBeGreaterThan(10);
const awake = firstTurn(duel([mon(ABRA, 40, [DREAM_EATER])], [mon(SNORLAX, 60, [SPLASH])]));
expect(awake.texts.join(" ")).not.toContain("dream was eaten");
});
it("raises the target's Attack and confuses it with Swagger", () => {
const { battle, texts } = findSeed(
(seed) => duel([mon(ABRA, 40, [SWAGGER])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
(texts) => texts.some((text) => text.includes("became confused")),
);
expect(battle.enemy.levels[C.ATTACK]).toBe(9);
expect(texts.join(" ")).toContain("ATTACK went way up");
});
it("sleeps with Hypnosis and the target stays asleep", () => {
const { battle } = findSeed(
(seed) => duel([mon(ABRA, 40, [HYPNOSIS])], [mon(SNORLAX, 60, [TACKLE])], { seed }),
(texts) => texts.some((text) => text.includes("fell asleep")),
);
expect(battle.enemy.mon.status & C.SLP_MASK).toBeTruthy();
});
});
describe("items and money", () => {
it("steals the target's item with Thief, but not MAIL", () => {
const player = mon(MEOWTH, 30, [THIEF]);
const enemy = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS });
const { battle, texts } = findSeed(
(seed) => {
player.item = 0;
enemy.item = LEFTOVERS;
return duel([player], [enemy], { seed });
},
(texts) => texts.some((text) => text.includes("stole")),
);
expect(texts.join(" ")).toContain("stole LEFTOVERS");
expect(player.item).toBe(LEFTOVERS);
expect(battle.player.mon.item).toBe(LEFTOVERS);
expect(battle.enemy.mon.item).toBe(0);
expect(enemy.item).toBe(0);
const mail = mon(SNORLAX, 60, [SPLASH], { item: FLOWER_MAIL });
const thief = mon(MEOWTH, 30, [THIEF]);
firstTurn(duel([thief], [mail]));
expect(thief.item).toBe(0);
});
it("loses the player's item to a trainer's Thief for good", () => {
const player = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS });
const { battle } = findSeed(
(seed) => {
player.item = LEFTOVERS;
return duel([player], [mon(MEOWTH, 30, [THIEF])], { seed });
},
(texts) => texts.some((text) => text.includes("stole")),
);
expect(player.item).toBe(0);
expect(battle.enemy.mon.item).toBe(LEFTOVERS);
});
it("scatters coins with Pay Day and pays them out after a win, doubled by an Amulet Coin", () => {
for (const item of [0, AMULET_COIN]) {
const enemy = mon(SNORLAX, 30, [SPLASH]);
enemy.hp = 1;
const battle = duel([mon(MEOWTH, 20, [PAY_DAY], { item })], [enemy]);
const { texts } = playBattle(battle);
expect(texts).toContain("Coins scattered everywhere!");
const picked = texts.find((text) => text.includes("picked up"));
expect(picked).toBe(`CHRIS picked up ¥${item ? 80 : 40}!`);
}
});
it("checks the Amulet Coin on every send-out", () => {
const enemy = mon(SNORLAX, 30, [SPLASH]);
enemy.hp = 1;
const fainted = mon(PIDGEY, 5, [TACKLE], { hp: 0 });
const battle = duel([mon(PIDGEY, 20, [SPLASH]), mon(MEOWTH, 20, [PAY_DAY], { item: AMULET_COIN }), fainted], [enemy]);
const { texts } = playBattle(battle, {
action: [{ action: "pkmn" }, { action: "fight", move: 0 }],
party: [1],
});
expect(texts.find((text) => text.includes("picked up"))).toBe("CHRIS picked up ¥80!");
});
});
describe("Selfdestruct", () => {
it("faints the user even if it misses", () => {
const player = mon(GEODUDE, 30, [SELFDESTRUCT]);
const battle = duel([player, mon(PIDGEY, 30, [TACKLE])], [mon(GASTLY, 60, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("doesn't affect");
expect(player.hp).toBe(0);
});
});
it("holds on at 1 HP with a Focus Band", () => {
const { texts, battle } = findSeed(
(seed) => {
const enemy = mon(SNORLAX, 60, [SPLASH], { item: FOCUS_BAND });
enemy.hp = 1;
return duel([mon(JOLTEON, 40, [TACKLE])], [enemy], { seed });
},
(texts) => texts.some((text) => text.includes("hung on")),
);
expect(battle.enemy.mon.hp).toBe(1);
expect(texts.join(" ")).toContain("FOCUS BAND");
});
});
+499
View File
@@ -0,0 +1,499 @@
import { test, expect, type Page } from "@playwright/test";
import { existsSync } from "node:fs";
import { C, COMMAND } from "../src/game/battle/constants";
import {
romPath,
symbolsPath,
loadSymbols,
openReference,
} from "./reference-fixture";
/** One side's battle state: its battle_struct, move struct, substatuses,
* stat levels, unboosted stats, screens and counters. */
type Side = {
species: number;
item: number;
dvs: number;
happiness: number;
level: number;
status: number;
hp: number;
maxHp: number;
stats: number[];
types: [number, number];
move: { anim: number; effect: number; power: number; type: number; accuracy: number };
sub: number[];
levels: number[];
unboosted: number[];
screens: number;
rollout: number;
damageTaken: number;
lastCounterMove: number;
};
type Case = {
turn: number;
seeds: number[];
count: number;
sides: [Side, Side];
curDamage: number;
criticalHit: number;
attackMissed: number;
typeModifier: number;
enemyGoesFirst: number;
animParam: number;
battleMode: number;
partyCount: number;
otPartyCount: number;
beatUpHit: number;
/** A battle script and the index of the command after the one run. */
script: number[];
scriptIndex: number;
};
function generator(start: number) {
let seed = start;
const next = (limit: number) => {
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
return (seed >>> 16) % limit;
};
const side = (overrides: Partial<Side> = {}): Side => {
const maxHp = 20 + next(300);
return {
species: 155,
item: 0,
dvs: next(0x10000),
happiness: next(256),
level: 5 + next(95),
status: 0,
hp: 1 + next(maxHp),
maxHp,
stats: Array.from({ length: 5 }, () => 5 + next(400)),
types: [C.NORMAL, C.NORMAL],
move: { anim: 33, effect: 0, power: 40, type: C.NORMAL, accuracy: 255 },
sub: [0, 0, 0, 0, 0],
levels: [7, 7, 7, 7, 7, 7, 7],
unboosted: Array.from({ length: 5 }, () => 5 + next(400)),
screens: 0,
rollout: 0,
damageTaken: 0,
lastCounterMove: 0,
...overrides,
};
};
const make = (overrides: Partial<Case> = {}): Case => ({
turn: next(2),
seeds: Array.from({ length: 10 }, () => next(256)),
count: next(9),
sides: [side(), side()],
curDamage: next(1000),
criticalHit: 0,
attackMissed: 0,
typeModifier: C.EFFECTIVE,
enemyGoesFirst: next(2),
animParam: 0,
battleMode: C.TRAINER_BATTLE,
partyCount: 1 + next(6),
otPartyCount: 1 + next(6),
beatUpHit: 1,
script: [COMMAND.critical, COMMAND.endmove],
scriptIndex: 1,
...overrides,
});
return { next, side, make };
}
/** Run the unpatched commands in the reference emulator with the Link RNG,
* and the native commands on the same state; compare what they leave. */
async function compare(page: Page, routines: string[], cases: Case[]) {
const { at, routine } = loadSymbols();
const commands = routines.map((name) => {
const symbol = routine(name);
if (symbol.bank !== 0x0d) throw new Error(`${name} is not in bank $0d.`);
return {
address: symbol.address,
command: COMMAND[
name.replace("BattleCommand_", "").toLowerCase() as keyof typeof COMMAND
],
};
});
const addresses = {
romBank: at("hROMBank"),
turn: at("hBattleTurn"),
link: at("wLinkMode"),
seeds: at("wLinkBattleRNs"),
count: at("wLinkBattleRNCount"),
damage: at("wCurDamage"),
critical: at("wCriticalHit"),
missed: at("wAttackMissed"),
typeModifier: at("wTypeModifier"),
typeMatchup: at("wTypeMatchup"),
enemyGoesFirst: at("wEnemyGoesFirst"),
animParam: at("wBattleAnimParam"),
battleMode: at("wBattleMode"),
partyCount: at("wPartyCount"),
otPartyCount: at("wOTPartyCount"),
beatUpHit: at("wBeatUpHitAtLeastOnce"),
scriptBuffer: at("wBattleScriptBuffer"),
scriptAddress: at("wBattleScriptBufferAddress"),
sides: [
{
mon: at("wBattleMonSpecies"),
move: at("wPlayerMoveStruct"),
sub: at("wPlayerSubStatus1"),
levels: at("wPlayerStatLevels"),
stats: at("wPlayerStats"),
screens: at("wPlayerScreens"),
rollout: at("wPlayerRolloutCount"),
damageTaken: at("wPlayerDamageTaken"),
lastCounterMove: at("wLastPlayerCounterMove"),
},
{
mon: at("wEnemyMonSpecies"),
move: at("wEnemyMoveStruct"),
sub: at("wEnemySubStatus1"),
levels: at("wEnemyStatLevels"),
stats: at("wEnemyStats"),
screens: at("wEnemyScreens"),
rollout: at("wEnemyRolloutCount"),
damageTaken: at("wEnemyDamageTaken"),
lastCounterMove: at("wLastEnemyCounterMove"),
},
],
};
await openReference(page);
const results = await page.evaluate(
async ({ addresses, commands, cases }) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { Battle } = await import("/src/game/battle/battle.ts");
const file = (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0];
const rom = new Uint8Array(await file.arrayBuffer());
const assets = new CrystalAssets(rom);
const reference = new BinjgbEmulator();
await reference.load(rom, document.createElement("canvas"));
const write = (address: number, value: number) =>
reference.writeMemory(address, value & 255);
const word = (address: number, value: number) => {
write(address, value >> 8);
write(address + 1, value);
};
const read = (address: number) => reference.readMemory(address);
const readWord = (address: number) => (read(address) << 8) | read(address + 1);
// DI; SP=$c7fe; select bank $0d; CALL each command; sentinel; loop.
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, 0x0d, 0xe0, addresses.romBank & 255, 0xea, 0x00, 0x20];
for (const { address } of commands) program.push(0xcd, address & 255, address >> 8);
program.push(0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe);
write(0xff70, 1);
write(addresses.link, 1);
const results = cases.map((input, index) => {
let seeds = [...input.seeds],
count = input.count,
draws = 0;
const party = Array.from({ length: input.partyCount }, () => ({}));
const battle = new Battle({
data: assets.pokemon,
party: party as never,
pokedex: { seen: new Set(), caught: new Set() },
playerId: 1,
playerName: "CHRIS",
timeOfDay: 1,
text: () => ({}) as never,
trainer: {
name: "",
className: "",
party: Array.from({ length: input.otPartyCount }, () => ({})) as never,
aiFlags: 0,
switchFlags: 0,
reward: 0,
},
random: () => {
if (++draws > 1024) throw new Error(`Unbounded RNG in case ${index}`);
const value = seeds[count++];
if (count >= 9) {
count = 0;
seeds = seeds.map((value) => (value * 5 + 1) & 255);
}
return value;
},
});
const internal = battle as unknown as Record<string, unknown> & {
command(id: number): Generator;
};
Object.assign(internal, {
turn: input.turn,
curDamage: input.curDamage,
criticalHit: input.criticalHit,
attackMissed: input.attackMissed,
typeModifier: input.typeModifier,
enemyGoesFirst: input.enemyGoesFirst,
animParam: input.animParam,
battleMode: input.battleMode,
beatUpHit: input.beatUpHit,
script: [...input.script],
scriptIndex: input.scriptIndex,
});
write(addresses.turn, input.turn);
word(addresses.damage, input.curDamage);
write(addresses.critical, input.criticalHit);
write(addresses.missed, input.attackMissed);
write(addresses.typeModifier, input.typeModifier);
write(addresses.typeMatchup, 10);
write(addresses.enemyGoesFirst, input.enemyGoesFirst);
write(addresses.animParam, input.animParam);
write(addresses.battleMode, input.battleMode);
write(addresses.partyCount, input.partyCount);
write(addresses.otPartyCount, input.otPartyCount);
write(addresses.beatUpHit, input.beatUpHit);
write(addresses.count, input.count);
input.seeds.forEach((value, i) => write(addresses.seeds + i, value));
input.script.forEach((value, i) => write(addresses.scriptBuffer + i, value));
const scriptAddress = addresses.scriptBuffer + input.scriptIndex;
write(addresses.scriptAddress, scriptAddress);
write(addresses.scriptAddress + 1, scriptAddress >> 8);
[battle.player, battle.enemy].forEach((side, i) => {
const s = input.sides[i],
a = addresses.sides[i];
side.mon = {
species: s.species,
item: s.item,
moves: [s.move.anim, 0, 0, 0],
dvs: s.dvs,
pp: [10, 0, 0, 0],
happiness: s.happiness,
level: s.level,
status: s.status,
hp: s.hp,
maxHp: s.maxHp,
stats: [...s.stats],
types: [...s.types],
nickname: "",
} as never;
side.move = { ...s.move, pp: 10, chance: 0 };
side.sub = [0, ...s.sub];
side.levels = [...s.levels];
side.stats = [...s.unboosted];
side.screens = s.screens;
side.rolloutCount = s.rollout;
side.damageTaken = s.damageTaken;
side.lastCounterMove = s.lastCounterMove;
const mon = a.mon;
write(mon, s.species);
write(mon + 1, s.item);
word(mon + 6, s.dvs);
write(mon + 12, s.happiness);
write(mon + 13, s.level);
write(mon + 14, s.status);
word(mon + 16, s.hp);
word(mon + 18, s.maxHp);
s.stats.forEach((value, j) => word(mon + 20 + j * 2, value));
write(mon + 30, s.types[0]);
write(mon + 31, s.types[1]);
[s.move.anim, s.move.effect, s.move.power, s.move.type, s.move.accuracy, 10, 0].forEach(
(value, j) => write(a.move + j, value),
);
s.sub.forEach((value, j) => write(a.sub + j, value));
s.levels.forEach((value, j) => write(a.levels + j, value));
s.unboosted.forEach((value, j) => word(a.stats + j * 2, value));
write(a.screens, s.screens);
write(a.rollout, s.rollout);
word(a.damageTaken, s.damageTaken);
write(a.lastCounterMove, s.lastCounterMove);
});
for (const { command } of commands) {
const run = internal.command(command);
if (!run.next().done) throw new Error(`Case ${index} printed a text.`);
}
write(0xc6ff, 0);
program.forEach((value, i) => write(0xc000 + i, value));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 60 && read(0xc6ff) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc6ff) !== 0x42) throw new Error(`Case ${index} did not return`);
const native = {
damage: internal.curDamage,
critical: internal.criticalHit,
missed: internal.attackMissed,
typeModifier: internal.typeModifier,
typeMatchup: internal.matchup,
animParam: internal.animParam,
script: (internal.script as number[]).slice(0, input.script.length),
scriptIndex: internal.scriptIndex,
sides: [battle.player, battle.enemy].map((side) => ({
power: side.move.power,
type: side.move.type,
accuracy: side.move.accuracy,
sub3: side.sub[3],
rollout: side.rolloutCount,
damageTaken: side.damageTaken,
})),
count,
seeds,
};
const original = {
damage: readWord(addresses.damage),
critical: read(addresses.critical),
missed: read(addresses.missed),
typeModifier: read(addresses.typeModifier),
typeMatchup: read(addresses.typeMatchup),
animParam: read(addresses.animParam),
script: input.script.map((_, i) => read(addresses.scriptBuffer + i)),
scriptIndex:
((read(addresses.scriptAddress + 1) << 8) | read(addresses.scriptAddress)) -
addresses.scriptBuffer,
sides: addresses.sides.map((a) => ({
power: read(a.move + 2),
type: read(a.move + 3),
accuracy: read(a.move + 4),
sub3: read(a.sub + 2),
rollout: read(a.rollout),
damageTaken: readWord(a.damageTaken),
})),
count: read(addresses.count),
seeds: Array.from({ length: 10 }, (_, i) => read(addresses.seeds + i)),
};
return { native, original, draws };
});
reference.dispose();
return results;
},
{ addresses, commands, cases },
);
results.forEach((result, index) =>
expect(result.native, `case ${index}: ${JSON.stringify(cases[index])}`).toEqual(result.original),
);
return results;
}
test.beforeEach(() => {
test.skip(
!existsSync(romPath) || !existsSync(symbolsPath),
"Requires the local user ROM and the private PRET rebuild.",
);
});
const GHOST = 8,
PSYCHIC_TYPE = 24,
DARK = 27,
FIGHTING = 1,
WATER = 21;
test("ConstantDamage matches level, fixed, Psywave, Super Fang and Reversal damage", async ({ page }) => {
const { make, side, next } = generator(0x5eed);
const cases: Case[] = [];
const effects = [C.EFFECT_LEVEL_DAMAGE, C.EFFECT_STATIC_DAMAGE, C.EFFECT_PSYWAVE, C.EFFECT_SUPER_FANG, C.EFFECT_REVERSAL];
for (const effect of effects)
for (let i = 0; i < 160; i++) {
const input = make();
const user = input.sides[input.turn];
user.move = { ...user.move, effect, power: [1, 20, 40, 100][next(4)], type: [C.NORMAL, FIGHTING, WATER][next(3)] };
if (effect === C.EFFECT_PSYWAVE && user.level < 2) user.level = 2;
if (effect === C.EFFECT_REVERSAL) {
// HP bar boundaries and maxima above 255, with stats up to 999 so
// TruncateHL_BC scales them.
user.maxHp = [48, 200, 255, 256, 700, 999][next(6)];
user.hp = i < 48 ? Math.max(1, Math.floor((user.maxHp * i) / 48)) : 1 + next(user.maxHp);
user.stats = user.stats.map(() => 1 + next(999));
input.sides[input.turn ^ 1].stats = input.sides[0].stats.map(() => 1 + next(999));
}
if (effect === C.EFFECT_SUPER_FANG) input.sides[input.turn ^ 1].hp = [1, 2, 3, 255, 256, 511, 999][next(7)];
cases.push(input);
}
// Psywave's rejection loop through a regenerated stream.
cases.push(make({ turn: 0, sides: [side({ level: 10, move: { anim: 149, effect: C.EFFECT_PSYWAVE, power: 1, type: 24, accuracy: 255 } }), side()], seeds: Array(10).fill(200) }));
await compare(page, ["BattleCommand_ConstantDamage"], cases);
});
test("OHKO matches level checks, accuracy bonus and one-hit KO damage", async ({ page }) => {
const { make, next } = generator(0x0b0e);
const cases: Case[] = [];
for (let i = 0; i < 600; i++) {
const input = make({ typeModifier: next(4) ? C.EFFECTIVE : 0 });
const user = input.sides[input.turn];
user.move = { ...user.move, effect: C.EFFECT_OHKO, power: 1, accuracy: [0, 76, 178, 255][next(4)] };
if (next(3)) input.sides[input.turn ^ 1].level = Math.max(1, user.level - next(100));
cases.push(input);
}
await compare(page, ["BattleCommand_OHKO"], cases);
});
test("Counter and Mirror Coat match their move, type, order and damage checks", async ({ page }) => {
const { make, next } = generator(0xc0de);
const cases: Case[] = [];
// Physical, special, status, Counter, Mirror Coat, no move.
const lastMoves = [33, 52, 45, 68, 243, 0, 89, 94, 149];
for (const [routine, effect, type] of [
["BattleCommand_Counter", C.EFFECT_COUNTER, FIGHTING],
["BattleCommand_MirrorCoat", C.EFFECT_MIRROR_COAT, PSYCHIC_TYPE],
] as const) {
const batch: Case[] = [];
for (let i = 0; i < 400; i++) {
const input = make({ curDamage: [0, 1, 55, 997, 0x7fff, 0x8000, 0xffff][next(7)] });
const user = input.sides[input.turn],
target = input.sides[input.turn ^ 1];
user.move = { ...user.move, effect, type, power: 1 };
target.lastCounterMove = lastMoves[next(lastMoves.length)];
target.types = [[C.NORMAL, GHOST, DARK, PSYCHIC_TYPE][next(4)], [C.NORMAL, GHOST, DARK][next(3)]] as [number, number];
if (next(4) === 0) target.sub[0] = 1 << C.SUBSTATUS_IDENTIFIED;
batch.push(input);
}
await compare(page, [routine], batch);
cases.push(...batch);
}
expect(cases.length).toBe(800);
});
test("EndLoop matches the hit count draws, Beat Up counts and the loop back", async ({ page }) => {
const { make, next } = generator(0x100b);
const cases: Case[] = [];
// checkhit, critical, damagestats, damagecalc, endloop | raisesub, endmove
const script = [COMMAND.checkhit, COMMAND.critical, COMMAND.damagestats, COMMAND.damagecalc, COMMAND.endloop, COMMAND.raisesub, COMMAND.endmove, 0, 0];
const effects = [C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP];
for (const effect of effects)
for (let i = 0; i < 200; i++) {
const input = make({ script, scriptIndex: 5, battleMode: next(3) ? C.TRAINER_BATTLE : C.WILD_BATTLE });
const user = input.sides[input.turn];
user.move = { ...user.move, effect };
user.damageTaken = next(0x10000);
if (i % 2) {
// A later pass that loops back again.
user.sub[2] = 1 << C.SUBSTATUS_IN_LOOP;
user.rollout = 2 + next(4);
}
// A single Triple Kick, or a final pass, prints "Hit n times!"; the
// fixture cannot answer texts, so those draws are moved on.
if (effect === C.EFFECT_TRIPLE_KICK && !(i % 2)) {
let p = input.count;
while (!(input.seeds[p] & 3) && p < 8) p++;
if ((input.seeds[p] & 3) === 1) input.seeds[p] |= 2;
if (p === 8 && !(input.seeds[p] & 3)) input.seeds[p] = 2;
}
cases.push(input);
}
await compare(page, ["BattleCommand_EndLoop"], cases);
});
test("Triple Kick, Hidden Power and False Swipe match their damage changes", async ({ page }) => {
const { make, next } = generator(0x3ce);
const kicks: Case[] = [];
for (let i = 0; i < 200; i++)
kicks.push(make({ animParam: next(4), curDamage: [0, 1, 300, 997, 0x5555, 0x8000, 0xffff][next(7)] }));
await compare(page, ["BattleCommand_TripleKick"], kicks);
// Hidden Power's type and power, then the damage they give.
const hidden: Case[] = [];
for (let i = 0; i < 600; i++) {
const input = make({ curDamage: 0 });
const user = input.sides[input.turn];
user.dvs = i < 256 ? (i << 8) | (i ^ 0x5a) : next(0x10000);
user.move = { ...user.move, effect: C.EFFECT_HIDDEN_POWER, power: 1, anim: 237 };
hidden.push(input);
}
await compare(page, ["BattleCommand_HiddenPower", "BattleCommand_DamageCalc"], hidden);
const swipes: Case[] = [];
for (let i = 0; i < 300; i++) {
const input = make({ criticalHit: [0, 1, 2][next(3)], curDamage: next(600) });
input.sides[input.turn ^ 1].hp = [1, 2, 255, 256, next(600)][next(5)];
swipes.push(input);
}
await compare(page, ["BattleCommand_FalseSwipe"], swipes);
});