Port weather, copying moves, held items and obedience (#28)
Checks / web (push) Canceled after 0s

Complete the weather, Substitute, Transform, called-move and forced-switch effects. Add held-item behavior, Exp. Share awards and badge-based traded Pokemon obedience, and persist trainer berry consumption.

Validated with 576 passing unit tests and 149 browser tests, native/reference builds, reference/vendor/release checks and 320 Metal Powder emulator comparisons per browser. Smart AI and special balls/battle types remain on #28.
This commit is contained in:
Sébastien QUEROL committed 2026-10-03 15:03:19 +02:00
1 parent 8f5b464efc
commit 32d736ecae
13 files changed
+1509 -75

No files matched your search

+193 -2
View File
@@ -95,7 +95,25 @@ const DOUBLESLAP = 3,
RAPID_SPIN = 0xe5,
PSYCH_UP = 0xf4,
ANCIENTPOWER = 0xf6,
FUTURE_SIGHT = 0xf8;
FUTURE_SIGHT = 0xf8,
RAIN_DANCE = 0xf0,
SUNNY_DAY = 0xf1,
SANDSTORM = 0xc9,
SUBSTITUTE = 0xa4,
TRANSFORM = 0x90,
MIMIC = 0x66,
METRONOME = 0x76,
MIRROR_MOVE = 0x77,
SLEEP_TALK = 0xd6,
SNORE = 0xad,
CONVERSION = 0xa0,
CONVERSION2 = 0xb0,
SKETCH = 0xa6,
BATON_PASS = 0xe2,
ROAR = 0x2e,
TELEPORT = 0x64,
WATER_GUN = 0x37,
SYNTHESIS = 0xeb;
// Species and items
const PIDGEY = 16,
RATTATA = 19,
@@ -122,7 +140,10 @@ const PIDGEY = 16,
NIDORAN_F = 0x1d,
NIDORAN_M = 0x20,
STARMIE = 0x79,
FORRETRESS = 0xcd;
FORRETRESS = 0xcd,
DITTO = 0x84,
SMEARGLE = 0xeb,
PORYGON = 0x89;
const AMULET_COIN = 0x5b,
LEFTOVERS = 0x92,
FOCUS_BAND = 0x77,
@@ -872,6 +893,176 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
});
});
describe("weather, substitutes and moves that copy or switch", () => {
it("makes it rain for five turns, strengthening Water moves", () => {
const damage = (rain: boolean) => {
const enemy = mon(SNORLAX, 60, [SPLASH]);
const battle = duel([mon(STARMIE, 40, [rain ? RAIN_DANCE : SPLASH, WATER_GUN])], [enemy], { seed: 3 });
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
return { taken: enemy.hp - battle.enemy.mon.hp, steps, battle };
};
const rain = damage(true),
dry = damage(false);
expect(rain.steps.texts.join(" ")).toContain("A downpour started!");
expect(rain.steps.texts.join(" ")).toContain("Rain continues to fall.");
expect(rain.taken).toBeGreaterThan(dry.taken * 1.3);
for (let turn = 0; turn < 3; turn++) rain.steps.answer({ action: "fight", move: 1 });
expect(rain.steps.texts).toContain("The rain stopped.");
expect(rain.battle.weather).toBe(C.WEATHER_NONE);
});
it("skips Solarbeam's charge and heals more with Synthesis in the sun", () => {
const player = mon(BULBASAUR, 60, [SUNNY_DAY, SOLARBEAM, SYNTHESIS]);
player.hp = 20;
const battle = duel([player], [mon(SNORLAX, 60, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts.join(" ")).toContain("The sunlight got bright!");
steps.answer({ action: "fight", move: 1 });
expect(steps.texts).not.toContain("BULBASAUR took in sunlight!");
expect(steps.texts).toContain("BULBASAUR used SOLARBEAM!");
const hp = battle.player.mon.hp;
steps.answer({ action: "fight", move: 2 });
// Synthesis gives half the maximum HP by day, all of it in the sun.
expect(battle.player.mon.hp).toBe(Math.min(player.stats[0], hp + player.stats[0]));
});
it("buffets Pokémon other than Rock, Ground and Steel types in a sandstorm", () => {
const battle = duel([mon(GEODUDE, 40, [SANDSTORM, SPLASH])], [mon(SNORLAX, 60, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
expect(steps.texts).toContain("A SANDSTORM brewed!");
expect(steps.texts).toContain("The SANDSTORM hits Enemy SNORLAX!");
expect(steps.texts).not.toContain("The SANDSTORM hits GEODUDE!");
// Two turns' worth: the turn it started and the next.
expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe(((battle.enemy.mon.maxHp >> 2) >> 1) * 2);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts).toContain("But it failed!");
});
it("makes a substitute that takes the hits and blocks status moves", () => {
const player = mon(SNORLAX, 60, [SUBSTITUTE, SPLASH]);
const battle = duel([player], [mon(PIDGEY, 30, [TACKLE])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
const quarter = player.stats[0] >> 2;
expect(steps.texts.join(" ")).toContain("made a SUBSTITUTE");
expect(battle.player.mon.hp).toBe(player.stats[0] - quarter);
expect(steps.texts.join(" ")).toContain("The SUBSTITUTE took damage");
expect(battle.player.mon.hp).toBe(player.stats[0] - quarter);
expect(battle.player.substituteHp).toBeLessThan(quarter);
steps.answer({ action: "fight", move: 0 });
expect(steps.texts).toContain("SNORLAX has a SUBSTITUTE!");
const status = firstTurn(duel([mon(SNORLAX, 60, [SUBSTITUTE])], [mon(PIDGEY, 30, [POISONPOWDER])]));
expect(status.texts).toContain("It didn't affect SNORLAX!");
const weak = mon(SNORLAX, 60, [SUBSTITUTE]);
weak.hp = weak.stats[0] >> 2;
expect(firstTurn(duel([weak], [mon(PIDGEY, 30, [SPLASH])])).texts.join(" ")).toContain("Too weak to make a SUBSTITUTE");
// A fast Jolteon's substitute, then a strong Tackle that breaks it.
const breaker = duel([mon(JOLTEON, 30, [SUBSTITUTE])], [mon(SNORLAX, 60, [TACKLE])]);
const broken = firstTurn(breaker);
expect(broken.texts.join(" ")).toContain("SUBSTITUTE faded");
expect(breaker.player.has(4, C.SUBSTATUS_SUBSTITUTE)).toBe(false);
});
it("transforms into the target with 5 PP per move", () => {
const battle = duel([mon(DITTO, 40, [TRANSFORM])], [mon(SNORLAX, 60, [SPLASH, TACKLE])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("TRANSFORMED into SNORLAX");
expect(battle.player.mon.species).toBe(SNORLAX);
expect(battle.player.mon.moves.slice(0, 2)).toEqual([SPLASH, TACKLE]);
expect(battle.player.mon.pp.slice(0, 3)).toEqual([5, 5, 0]);
});
it("catches a transformed wild Pokémon as a Ditto with its own DVs", () => {
const party = [mon(SNORLAX, 40, [SPLASH])];
const bag = { items: [], balls: [{ item: C.MASTER_BALL, quantity: 1 }], keyItems: [] };
const battle = duel(party, { wild: { species: DITTO, level: 10 } }, { bag, caughtData: () => 0 });
const steps = battleSteps(battle);
const dvs = battle.enemy.mon.dvs;
steps.answer({ action: "fight", move: 0 });
expect(battle.enemy.has(5, C.SUBSTATUS_TRANSFORMED)).toBe(true);
steps.answer({ action: "item", item: C.MASTER_BALL, slot: 0 });
while (!steps.done) steps.answer(steps.prompt?.kind === "yesno" ? 0 : undefined);
expect(party[1].species).toBe(DITTO);
expect(party[1].dvs).toBe(dvs);
expect(party[1].moves[0]).toBe(TRANSFORM);
});
it("mimics the target's last move for the battle", () => {
const player = mon(STARMIE, 60, [MIMIC]);
const battle = duel([player], [mon(SNORLAX, 30, [TACKLE])]);
const { texts } = turns(battle, [0, 0]);
expect(texts.join(" ")).toContain("learned TACKLE");
expect(battle.player.mon.moves[0]).toBe(TACKLE);
expect(player.moves[0]).toBe(MIMIC);
});
it("calls a random move with Metronome", () => {
const { texts } = firstTurn(duel([mon(STARMIE, 60, [METRONOME])], [mon(SNORLAX, 60, [SPLASH])]));
expect(texts[0]).toBe("STARMIE used METRONOME!");
expect(texts[1]).toMatch(/^STARMIE used /);
expect(texts[1]).not.toBe("STARMIE used METRONOME!");
});
it("mirrors the target's last move", () => {
const battle = duel([mon(SNORLAX, 30, [MIRROR_MOVE])], [mon(STARMIE, 60, [TACKLE])]);
const { texts } = firstTurn(battle);
const used = texts.indexOf("SNORLAX used MIRROR MOVE!");
expect(texts[used + 1]).toBe("SNORLAX used TACKLE!");
const first = firstTurn(duel([mon(STARMIE, 60, [MIRROR_MOVE])], [mon(SNORLAX, 30, [TACKLE])]));
expect(first.texts.join(" ")).toContain("MIRROR MOVE failed");
});
it("talks in its sleep with another move, and snores only when asleep", () => {
const sleeper = mon(SNORLAX, 60, [SLEEP_TALK, TACKLE, SNORE], { status: 7 });
const { texts } = firstTurn(duel([sleeper], [mon(PIDGEY, 30, [SPLASH])]));
expect(texts).toContain("SNORLAX used SLEEP TALK!");
expect(texts.some((text) => text === "SNORLAX used TACKLE!" || text === "SNORLAX used SNORE!")).toBe(true);
const awake = firstTurn(duel([mon(SNORLAX, 60, [SNORE])], [mon(PIDGEY, 30, [SPLASH])]));
expect(awake.texts).toContain("But it failed!");
});
it("changes its type with Conversion and Conversion2", () => {
const battle = duel([mon(PORYGON, 40, [CONVERSION, WATER_GUN])], [mon(SNORLAX, 60, [SPLASH])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("transformed into the WATER-type");
expect(battle.player.mon.types).toEqual([21, 21]);
// Jolteon moves first, so its Tackle is the last move to resist.
const resist = duel([mon(PORYGON, 30, [CONVERSION2])], [mon(JOLTEON, 20, [TACKLE])]);
firstTurn(resist);
const [type] = resist.player.mon.types;
expect([5, 8, 9]).toContain(type); // Rock, Ghost or Steel
});
it("sketches the target's last move for good", () => {
// Jolteon moves first, so Sketch has a move to copy.
const player = mon(SMEARGLE, 20, [SKETCH]);
const battle = duel([player], [mon(JOLTEON, 30, [TACKLE])]);
const { texts } = firstTurn(battle);
expect(texts.join(" ")).toContain("SKETCHED TACKLE");
expect(player.moves[0]).toBe(TACKLE);
expect(player.pp[0]).toBe(assets.pokemon.move(TACKLE).pp);
});
it("passes stat changes and the substitute along with Baton Pass", () => {
const party = [mon(STARMIE, 60, [SWORDS_DANCE, BATON_PASS]), mon(SNORLAX, 30, [TACKLE])];
const battle = duel(party, [mon(PIDGEY, 30, [SPLASH])]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 }).answer({ action: "fight", move: 1 });
expect(steps.prompt?.kind).toBe("party");
steps.answer(0).answer(1);
expect(steps.texts).toContain("STARMIE is already out.");
expect(battle.player.mon.species).toBe(SNORLAX);
expect(battle.player.levels[C.ATTACK]).toBe(9);
const [numerator, denominator] = [2, 1];
expect(battle.player.mon.stats[C.ATTACK]).toBe(Math.min(999, Math.floor((party[1].stats[1] * numerator) / denominator)));
});
it("ends a wild battle with Roar or Teleport, and drags out a trainer's Pokémon", () => {
const roar = duel([mon(SNORLAX, 60, [ROAR])], { wild: { species: PIDGEY, level: 5 } });
const roared = playBattle(roar);
expect(roared.result).toBe(C.DRAW);
expect(roared.texts).toContain("Enemy PIDGEY fled in fear!");
const teleport = duel([mon(STARMIE, 60, [TELEPORT])], { wild: { species: PIDGEY, level: 5 } });
const teleported = playBattle(teleport);
expect(teleported.result).toBe(C.DRAW);
expect(teleported.texts.join(" ")).toContain("fled from battle");
const trainer = duel([mon(SNORLAX, 30, [ROAR])], [mon(PIDGEY, 30, [SPLASH]), mon(RATTATA, 30, [SPLASH])]);
const dragged = firstTurn(trainer);
expect(dragged.texts.join(" ")).toContain("was dragged out");
expect(trainer.enemy.mon.species).toBe(RATTATA);
});
});
describe("Counter and Mirror Coat", () => {
it("returns twice a physical hit taken first", () => {
const player = mon(SNORLAX, 40, [COUNTER]);