Port the moves that span several turns

A recharging, charged, rampaging or rolling Pokémon plays on without the
battle menu on either side, and a biding one uses FIGHT without the move
list. Razor Wind, Solarbeam, Skull Bash, Sky Attack, Fly and Dig charge
first; flying and digging Pokémon can only be hit by the original's
exceptions. Hyper Beam recharges, Thrash and its kind end in confusion,
Rollout and Fury Cutter double up, and Bide returns twice the damage it
took.

The trapping moves hurt their target between turns until it is released,
Mind Reader and Lock-On make the next move hit, and Pursuit strikes a
Pokémon being withdrawn first, on either side.

The new commands are compared with the original in the reference
emulator. Its fixture no longer zeroes wPlayerScreens with its sentinel.

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:57:46 +02:00
1 parent 9e0a2cd2a8
commit c53edb77e5
7 files changed
+713 -39

No files matched your search

+192 -2
View File
@@ -49,7 +49,23 @@ const DOUBLESLAP = 3,
HIDDEN_POWER = 237,
MIRROR_COAT = 243,
BEAT_UP = 251,
ICE_BEAM = 58;
ICE_BEAM = 58,
RAZOR_WIND = 13,
GUST = 16,
FLY = 19,
WRAP = 35,
THRASH = 37,
HYPER_BEAM = 63,
SOLARBEAM = 76,
EARTHQUAKE = 89,
DIG = 91,
DEFENSE_CURL = 111,
BIDE = 117,
SKULL_BASH = 130,
MIND_READER = 170,
ROLLOUT = 205,
FURY_CUTTER = 210,
PURSUIT = 228;
// Species and items
const PIDGEY = 16,
RATTATA = 19,
@@ -62,7 +78,15 @@ const PIDGEY = 16,
HITMONLEE = 106,
LAPRAS = 131,
CYNDAQUIL = 155,
TOTODILE = 158;
TOTODILE = 158,
PIDGEOT = 18,
ARBOK = 24,
SANDSLASH = 28,
DIGLETT = 50,
ONIX = 95,
SCYTHER = 123,
TAUROS = 128,
HOUNDOUR = 228;
const AMULET_COIN = 0x5b,
LEFTOVERS = 0x92,
FOCUS_BAND = 0x77,
@@ -137,6 +161,18 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
steps.answer({ action: "fight", move: 0 });
return { steps, texts: steps.texts.slice(before) };
};
/** Play turns with FIGHT and these move slots; a locked-in Pokémon
* plays on without a menu. Returns the texts after the first menu. */
const turns = (battle: Battle, moves: number[]) => {
const steps = battleSteps(battle);
const before = steps.texts.length;
for (const move of moves) {
if (steps.done) break;
expect(steps.prompt?.kind).toBe("action");
steps.answer({ action: "fight", move });
}
return { steps, texts: steps.texts.slice(before) };
};
/** The first seed whose first turn passes `test`. */
const findSeed = (
make: (seed: number) => Battle,
@@ -361,6 +397,160 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
});
});
describe("multi-turn moves", () => {
it("charges Solarbeam on the first turn and fires it on the second, without a menu", () => {
const enemy = mon(SNORLAX, 50, [SPLASH]);
const battle = duel([mon(ABRA, 40, [SOLARBEAM])], [enemy]);
const { texts, steps } = turns(battle, [0]);
expect(texts[0]).toBe("ABRA took in sunlight!");
expect(texts).toContain("ABRA used SOLARBEAM!");
expect(texts.filter((text) => text === "Enemy SNORLAX used SPLASH!")).toHaveLength(2);
expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp);
expect(steps.prompt?.kind).toBe("action");
expect(battle.player.mon.pp[0]).toBe(assets.pokemon.move(SOLARBEAM).pp - 1);
});
it("lowers Skull Bash's head with a Defense raise, then attacks", () => {
const { texts, battle } = (() => {
const battle = duel([mon(ONIX, 40, [SKULL_BASH])], [mon(SNORLAX, 50, [SPLASH])]);
return { ...turns(battle, [0]), battle };
})();
expect(texts.slice(0, 2)).toEqual(["ONIX lowered its head!", "ONIX's DEFENSE went up!"]);
expect(texts).toContain("ONIX used SKULL BASH!");
expect(battle.player.levels[C.DEFENSE]).toBe(8);
});
it("flies out of reach of most moves, but not Gust", () => {
const tackled = turns(duel([mon(PIDGEOT, 50, [FLY])], [mon(SNORLAX, 40, [TACKLE])]), [0]);
expect(tackled.texts[0]).toBe("PIDGEOT flew up high!");
expect(tackled.texts[1]).toBe("Enemy SNORLAX used TACKLE!");
expect(tackled.texts[2]).toContain("attack missed");
expect(tackled.texts).toContain("PIDGEOT used FLY!");
const player = mon(PIDGEOT, 50, [FLY]);
const gusted = duel([player], [mon(PIDGEOT, 30, [GUST])]);
turns(gusted, [0]);
expect(gusted.player.mon.hp).toBeLessThan(player.stats[0]);
});
it("digs out of reach, where Earthquake hits twice as hard", () => {
const quake = (dig: boolean) => {
const player = mon(SANDSLASH, 60, [dig ? DIG : SPLASH]);
const battle = duel([player], [mon(GEODUDE, 20, [EARTHQUAKE])]);
firstTurn(battle);
return player.stats[0] - battle.player.mon.hp;
};
const dug = quake(true),
plain = quake(false);
expect(dug).toBeGreaterThan(plain * 1.5);
});
it("recharges after Hyper Beam", () => {
const { texts, steps } = turns(duel([mon(TAUROS, 50, [HYPER_BEAM])], [mon(SNORLAX, 60, [SPLASH])]), [0]);
expect(texts).toContain("TAUROS used HYPER BEAM!");
expect(texts).toContain("TAUROS must recharge!");
expect(steps.prompt?.kind).toBe("action");
});
it("thrashes for two or three turns, then is confused", () => {
const battle = duel([mon(TAUROS, 50, [THRASH])], [mon(SNORLAX, 70, [SPLASH])]);
const { texts } = turns(battle, [0]);
const thrashes = texts.filter((text) => text === "TAUROS used THRASH!").length;
expect(thrashes === 2 || thrashes === 3).toBe(true);
expect(battle.player.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
expect(battle.player.has(3, C.SUBSTATUS_RAMPAGE)).toBe(false);
});
it("rolls out with doubling damage for up to five turns", () => {
const enemy = mon(SNORLAX, 100, [SPLASH]);
const battle = duel([mon(SANDSLASH, 30, [ROLLOUT])], [enemy]);
const steps = battleSteps(battle);
const hp: number[] = [battle.enemy.mon.hp];
steps.answer({ action: "fight", move: 0 });
// The next menu comes after the rollout ends.
expect(steps.texts.filter((text) => text === "SANDSLASH used ROLLOUT!").length).toBeGreaterThan(1);
hp.push(battle.enemy.mon.hp);
expect(hp[1]).toBeLessThan(hp[0]);
expect(battle.player.has(1, C.SUBSTATUS_ROLLOUT)).toBe(false);
});
it("doubles Fury Cutter for each hit in a row", () => {
for (let seed = 1; seed < 30; seed++) {
const battle = duel([mon(SCYTHER, 30, [FURY_CUTTER])], [mon(SNORLAX, 100, [SPLASH])], { seed });
const steps = battleSteps(battle);
const damage: number[] = [];
let hp = battle.enemy.mon.hp;
for (let turn = 0; turn < 3; turn++) {
steps.answer({ action: "fight", move: 0 });
damage.push(hp - battle.enemy.mon.hp);
hp = battle.enemy.mon.hp;
}
const critical = steps.texts.some((text) => text.includes("critical"));
if (damage.every(Boolean)) {
expect(battle.player.furyCutterCount).toBe(3);
// Four times the first hit, give or take the damage variation.
if (!critical) expect(damage[2]).toBeGreaterThan(damage[0] * 3);
} else if (!damage[2]) expect(battle.player.furyCutterCount).toBe(0);
}
});
it("bides its time and unleashes twice the damage taken", () => {
const player = mon(SNORLAX, 60, [BIDE]);
const enemy = mon(TAUROS, 40, [TACKLE]);
const battle = duel([player], [enemy]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
// Bide starts counting after the hit taken before its first turn.
const before = battle.player.mon.hp;
// FIGHT keeps the move while biding.
while (battle.player.has(3, C.SUBSTATUS_BIDE)) {
expect(battle.fightKeepsMove).toBe(true);
steps.answer({ action: "fight", move: 3 });
}
const texts = steps.texts;
expect(texts).toContain("SNORLAX is storing energy!");
expect(texts).toContain("SNORLAX unleashed energy!");
// The faster Tauros tackles before each of Snorlax's turns, so all
// the damage taken is unleashed.
const taken = before - battle.player.mon.hp;
expect(texts.lastIndexOf("Enemy TAUROS used TACKLE!")).toBeLessThan(texts.indexOf("SNORLAX unleashed energy!"));
expect(enemy.hp - battle.enemy.mon.hp).toBe(Math.min(enemy.hp, taken * 2));
expect(battle.player.damageTaken).toBe(0);
});
it("wraps the target, hurting it each turn until it is released", () => {
const enemy = mon(SNORLAX, 60, [SPLASH]);
const { battle, texts } = findSeed(
(seed) => duel([mon(ARBOK, 30, [WRAP])], [enemy], { seed }),
(texts) => texts.some((text) => text.includes("was WRAPPED by")),
);
expect(battle.enemy.trappingMove).toBe(WRAP);
expect(texts.at(-1)).toBe("Enemy SNORLAX's hurt by WRAP!");
// The wrap's own damage, then a sixteenth of the maximum HP.
const hurt = texts.indexOf("Enemy SNORLAX's hurt by WRAP!");
expect(hurt).toBeGreaterThan(texts.findIndex((text) => text.includes("was WRAPPED by")));
expect(battle.enemy.wrapCount).toBeGreaterThanOrEqual(2);
// Keep wrapping until it is released.
const steps = battleSteps(battle);
void steps;
});
it("cannot miss after Mind Reader", () => {
for (let seed = 1; seed < 15; seed++) {
const battle = duel([mon(SNORLAX, 60, [MIND_READER, HORN_DRILL])], [mon(PIDGEY, 30, [SPLASH])], { seed });
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts).toContain("SNORLAX took aim!");
expect(battle.enemy.has(5, C.SUBSTATUS_LOCK_ON)).toBe(true);
steps.answer({ action: "fight", move: 1 });
expect(steps.texts).toContain("It's a one-hit KO!");
}
});
it("hits a withdrawn Pokémon with Pursuit before the switch, at double power", () => {
const first = mon(ABRA, 30, [SPLASH]);
const battle = duel([first, mon(SNORLAX, 30, [SPLASH])], [mon(HOUNDOUR, 40, [PURSUIT])]);
const steps = battleSteps(battle);
steps.answer({ action: "pkmn" }).answer(1);
const texts = steps.texts;
const used = texts.indexOf("Enemy HOUNDOUR used PURSUIT!");
expect(used).toBeGreaterThan(-1);
expect(used).toBeLessThan(texts.findIndex((text) => text.startsWith("Go! SNORLAX")));
expect(texts.filter((text) => text.includes("used PURSUIT"))).toHaveLength(1);
expect(first.hp).toBeLessThan(first.stats[0]);
expect(battle.player.mon.species).toBe(SNORLAX);
});
});
describe("Counter and Mirror Coat", () => {
it("returns twice a physical hit taken first", () => {
const player = mon(SNORLAX, 40, [COUNTER]);
+130 -6
View File
@@ -29,6 +29,10 @@ type Side = {
rollout: number;
damageTaken: number;
lastCounterMove: number;
furyCutter: number;
confuse: number;
wrap: number;
trappingMove: number;
};
type Case = {
turn: number;
@@ -45,6 +49,8 @@ type Case = {
partyCount: number;
otPartyCount: number;
beatUpHit: number;
playerIsSwitching: number;
enemyIsSwitching: number;
/** A battle script and the index of the command after the one run. */
script: number[];
scriptIndex: number;
@@ -77,6 +83,10 @@ function generator(start: number) {
rollout: 0,
damageTaken: 0,
lastCounterMove: 0,
furyCutter: 0,
confuse: 0,
wrap: 0,
trappingMove: 0,
...overrides,
};
};
@@ -95,6 +105,8 @@ function generator(start: number) {
partyCount: 1 + next(6),
otPartyCount: 1 + next(6),
beatUpHit: 1,
playerIsSwitching: 0,
enemyIsSwitching: 0,
script: [COMMAND.critical, COMMAND.endmove],
scriptIndex: 1,
...overrides,
@@ -133,6 +145,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
partyCount: at("wPartyCount"),
otPartyCount: at("wOTPartyCount"),
beatUpHit: at("wBeatUpHitAtLeastOnce"),
playerIsSwitching: at("wPlayerIsSwitching"),
enemyIsSwitching: at("wEnemyIsSwitching"),
scriptBuffer: at("wBattleScriptBuffer"),
scriptAddress: at("wBattleScriptBufferAddress"),
sides: [
@@ -146,6 +160,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
rollout: at("wPlayerRolloutCount"),
damageTaken: at("wPlayerDamageTaken"),
lastCounterMove: at("wLastPlayerCounterMove"),
furyCutter: at("wPlayerFuryCutterCount"),
confuse: at("wPlayerConfuseCount"),
wrap: at("wPlayerWrapCount"),
trappingMove: at("wPlayerTrappingMove"),
},
{
mon: at("wEnemyMonSpecies"),
@@ -157,6 +175,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
rollout: at("wEnemyRolloutCount"),
damageTaken: at("wEnemyDamageTaken"),
lastCounterMove: at("wLastEnemyCounterMove"),
furyCutter: at("wEnemyFuryCutterCount"),
confuse: at("wEnemyConfuseCount"),
wrap: at("wEnemyWrapCount"),
trappingMove: at("wEnemyTrappingMove"),
},
],
};
@@ -182,9 +204,11 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
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.
// The program and its sentinel ($c0f0) sit in audio RAM, which
// nothing reads with interrupts off.
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);
program.push(0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe);
write(0xff70, 1);
write(addresses.link, 1);
const results = cases.map((input, index) => {
@@ -231,6 +255,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
animParam: input.animParam,
battleMode: input.battleMode,
beatUpHit: input.beatUpHit,
playerIsSwitching: input.playerIsSwitching,
enemyIsSwitching: input.enemyIsSwitching,
script: [...input.script],
scriptIndex: input.scriptIndex,
});
@@ -246,6 +272,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
write(addresses.partyCount, input.partyCount);
write(addresses.otPartyCount, input.otPartyCount);
write(addresses.beatUpHit, input.beatUpHit);
write(addresses.playerIsSwitching, input.playerIsSwitching);
write(addresses.enemyIsSwitching, input.enemyIsSwitching);
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));
@@ -278,6 +306,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
side.rolloutCount = s.rollout;
side.damageTaken = s.damageTaken;
side.lastCounterMove = s.lastCounterMove;
side.furyCutterCount = s.furyCutter;
side.confuseCount = s.confuse;
side.wrapCount = s.wrap;
side.trappingMove = s.trappingMove;
const mon = a.mon;
write(mon, s.species);
write(mon + 1, s.item);
@@ -300,16 +332,20 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
write(a.rollout, s.rollout);
word(a.damageTaken, s.damageTaken);
write(a.lastCounterMove, s.lastCounterMove);
write(a.furyCutter, s.furyCutter);
write(a.confuse, s.confuse);
write(a.wrap, s.wrap);
write(a.trappingMove, s.trappingMove);
});
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);
write(0xc0f0, 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`);
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw new Error(`Case ${index} did not return`);
const native = {
damage: internal.curDamage,
critical: internal.criticalHit,
@@ -323,9 +359,12 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
power: side.move.power,
type: side.move.type,
accuracy: side.move.accuracy,
sub3: side.sub[3],
sub: side.sub.slice(1),
rollout: side.rolloutCount,
damageTaken: side.damageTaken,
furyCutter: side.furyCutterCount,
confuse: side.confuseCount,
wrap: side.wrapCount,
})),
count,
seeds,
@@ -345,9 +384,12 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
power: read(a.move + 2),
type: read(a.move + 3),
accuracy: read(a.move + 4),
sub3: read(a.sub + 2),
sub: [0, 1, 2, 3, 4].map((j) => read(a.sub + j)),
rollout: read(a.rollout),
damageTaken: readWord(a.damageTaken),
furyCutter: read(a.furyCutter),
confuse: read(a.confuse),
wrap: read(a.wrap),
})),
count: read(addresses.count),
seeds: Array.from({ length: 10 }, (_, i) => read(addresses.seeds + i)),
@@ -497,3 +539,85 @@ test("Triple Kick, Hidden Power and False Swipe match their damage changes", asy
}
await compare(page, ["BattleCommand_FalseSwipe"], swipes);
});
test("Rollout, Fury Cutter, rampages, charges and Pursuit match their counters and damage", async ({ page }) => {
const { make, next } = generator(0x2011);
const damage = () => [0, 1, 40, 997, 0x3fff, 0x8000, 0xffff][next(7)];
const rollout: Case[] = [];
for (let i = 0; i < 400; i++) {
const input = make({ curDamage: damage(), attackMissed: next(4) ? 0 : 1 });
const user = input.sides[input.turn];
user.rollout = next(6);
user.status = next(5) ? 0 : 2;
if (next(2)) user.sub[1] = 1 << C.SUBSTATUS_CURLED;
if (next(2)) user.sub[0] = 1 << C.SUBSTATUS_ROLLOUT;
rollout.push(input);
}
await compare(page, ["BattleCommand_RolloutPower"], rollout);
const fury: Case[] = [];
for (let i = 0; i < 300; i++) {
const input = make({ curDamage: damage(), attackMissed: next(4) ? 0 : 1 });
input.sides[input.turn].furyCutter = next(8);
fury.push(input);
}
await compare(page, ["BattleCommand_FuryCutter"], fury);
// checkrampage, checkobedience, doturn, rampage | usedmovetext, endmove
const rampageScript = [COMMAND.checkrampage, COMMAND.checkobedience, COMMAND.doturn, COMMAND.rampage, COMMAND.usedmovetext, COMMAND.endmove];
const rampage: Case[] = [];
for (let i = 0; i < 300; i++) {
const input = make({ script: rampageScript, scriptIndex: 1 });
const user = input.sides[input.turn];
if (next(4)) user.sub[2] = 1 << C.SUBSTATUS_RAMPAGE;
user.rollout = 1 + next(3);
user.confuse = next(6);
if (next(3) === 0) user.screens = 1 << C.SCREENS_SAFEGUARD;
if (next(3) === 0) input.sides[input.turn ^ 1].screens = 1 << C.SCREENS_SAFEGUARD;
rampage.push(input);
}
await compare(page, ["BattleCommand_CheckRampage"], rampage);
const start: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make();
input.sides[input.turn].status = next(3) ? 0 : 3;
start.push(input);
}
await compare(page, ["BattleCommand_Rampage"], start);
// checkrollout, checkobedience, doturn | usedmovetext, endmove
const checkRollout: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make({ script: [COMMAND.checkrollout, COMMAND.checkobedience, COMMAND.doturn, COMMAND.usedmovetext, COMMAND.endmove], scriptIndex: 1 });
const user = input.sides[input.turn];
user.rollout = next(6);
if (next(2)) user.sub[0] = 1 << C.SUBSTATUS_ROLLOUT;
checkRollout.push(input);
}
await compare(page, ["BattleCommand_CheckRollout"], checkRollout);
// checkcharge, checkobedience, doturn, charge | usedmovetext, endmove
const checkCharge: Case[] = [];
for (let i = 0; i < 100; i++) {
const input = make({ script: [COMMAND.checkcharge, COMMAND.checkobedience, COMMAND.doturn, COMMAND.charge, COMMAND.usedmovetext, COMMAND.endmove], scriptIndex: 1 });
input.sides[input.turn].sub[2] = next(256) & ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND) | 1);
checkCharge.push(input);
}
await compare(page, ["BattleCommand_CheckCharge"], checkCharge);
const pursuit: Case[] = [];
for (let i = 0; i < 100; i++)
pursuit.push(make({ curDamage: damage(), playerIsSwitching: next(2), enemyIsSwitching: next(2) }));
await compare(page, ["BattleCommand_Pursuit"], pursuit);
});
test("CheckHit matches Fly, Dig and Lock-On", async ({ page }) => {
const { make, next } = generator(0xf1d);
const moves = [16, 18, 87, 239, 89, 90, 222, 33]; // Gust, Whirlwind, Thunder, Twister, Earthquake, Fissure, Magnitude, Tackle
const cases: Case[] = [];
for (let i = 0; i < 800; i++) {
const input = make({ curDamage: next(1000) });
const user = input.sides[input.turn],
target = input.sides[input.turn ^ 1];
user.move = { ...user.move, anim: moves[next(moves.length)], accuracy: [0, 128, 255][next(3)], effect: next(4) ? 0 : C.EFFECT_JUMP_KICK };
target.sub[2] = [0, 1 << C.SUBSTATUS_FLYING, 1 << C.SUBSTATUS_UNDERGROUND, (1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)][next(4)];
if (next(2)) target.sub[4] = 1 << C.SUBSTATUS_LOCK_ON;
cases.push(input);
}
await compare(page, ["BattleCommand_CheckHit"], cases);
});