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:
1 parent
9e0a2cd2a8
commit
c53edb77e5
7 files changed
+713
-39
No files matched your search
+192
-2
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user