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

+17
View File
@@ -282,6 +282,23 @@ The numbered sections below keep their full scope. Each one opens with a checkli
- The trainer's party struct now follows its Pokémon where the original updates it (`UpdateEnemyMonInParty`: status changes, heals, waking up), and moves used by the enemy spend PP in its party struct or in `wWildMonPP`, so a caught Pokémon keeps the PP it used and a trainer's Pokémon sent back in keeps its spent PP. - The trainer's party struct now follows its Pokémon where the original updates it (`UpdateEnemyMonInParty`: status changes, heals, waking up), and moves used by the enemy spend PP in its party struct or in `wWildMonPP`, so a caught Pokémon keeps the PP it used and a trainer's Pokémon sent back in keeps its spent PP.
- A move missing from its user's move list (after Metronome, for instance) is refused by Spite, Disable and Encore, which search on past the list in the original. - A move missing from its user's move list (after Metronome, for instance) is refused by Spite, Disable and Encore, which search on past the list in the original.
## Implemented: weather, substitutes and moves that copy or switch
- Rain Dance and Sunny Day (renewed when used again) and Sandstorm (which fails when already raging) last five turns with the original messages between turns; a sandstorm hurts all but Rock, Ground, Steel and digging Pokémon. `DoWeatherModifiers` scales Water and Fire damage before STAB (else rain halves Solarbeam's), in the AI's estimate too; Thunder, Solarbeam's charge, freezing and the time-based heals follow the weather.
- Substitute (a quarter of the maximum HP, which needs more HP than that) takes the hits in `DoSubstituteDamage`, and its break clears the attacker's move effect unless it hits several times. The animation commands now keep their `wBattleAnimParam` effects with a substitute up, which Triple Kick counts from.
- Transform (5 PP per move, the enemy's own DVs kept for Attract and capture), Mimic, Metronome, Mirror Move and Sleep Talk (which run the called move through `ResetTurn`), Snore, Conversion, Conversion2 and Sketch (for good, in the party struct or the wild Pokémon's kept moves). Catching a transformed wild Pokémon gives a Ditto with its own DVs and moves, as in the original.
- Baton Pass hands the substatuses and stat levels to a Pokémon chosen without a send-out text; Roar and Whirlwind end a wild battle or drag out a random healthy Pokémon of a trainer's; Teleport ends a wild battle, which a wild Pokémon always manages (a kept bug).
- A trainer's Pokémon sent back in with a status no longer has its paralysis or burn penalty applied, as in the original.
- Every move's effect script now runs. The battle rollback tests use Psywave at level 1 instead, which loops forever in the original and stays refused. `tests/reference-move-effects.spec.ts` also compares `Stab` with the weather; two deliberate mutations of the weather modifier failed it.
## Implemented: held items, Exp. Share and obedience
- Smoke Ball escapes wild battles after the trapping checks; Metal Powder uses the original species even after Transform, with the byte-overflow quirk in both damage and AI estimates. MysteryBerry restores the first exhausted stored move (one PP for Sketch, otherwise five), preserves PP Ups and leaves copied moves alone. Berserk Gene activates before the menu and keeps the original confusion-count bug. The unused held stat-raising effects also follow their between-turn order.
- Exp. Share halves experience and stat-experience bytes before splitting the participant and holder pools. Healthy holders receive the second pool, including a holder that also fought; fainted holders are excluded. Existing traded, trainer, Lucky Egg and Pokérus modifiers still apply to each award.
- Traded Pokémon obey according to the world's Johto badges. Disobedience preserves the original random draws and can ignore orders, nap, hurt itself or use another move; it spends only the alternate move's PP and breaks Encore where the original does.
- Consumed healing and confusion berries are removed from a trainer's stored party too, so switching cannot restore them.
- Local-ROM regressions cover these paths; the reference emulator compares Metal Powder over 320 cases in each browser. The other new routines have source review and unit coverage, without a direct emulator comparison yet. Smart AI handlers remain unfinished; card #28 is still in progress.
## Native battles: current state ## Native battles: current state
The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items. The battle core, ROM tables, encounter hooks, battle UI and whiteout hooks are present. Unit tests cover wins, escape, loss, experience, move learning and capture; reference tests compare damage, type effectiveness, stat calculation and experience curves. Browser tests drive a test-only wild encounter through the real UI: victory with level-up and move learning, escape, whiteout, catching with a nickname, keyboard and on-screen D-pad menu navigation, mid-battle backup restore, rollback of unsupported effects, switching through the party menu and a Potion from the Items pocket. Real Route 29 encounters now use the ROM's encounter tables. Only the special Kurt and Park balls remain unported among battle items.
+60 -1
View File
@@ -1,6 +1,6 @@
# Recovered Claude session # Recovered Claude session
Latest continuation: see **Field items, fishing, Select and MAIL: 2026-10-03** Latest continuation: see **Battle effects, held items and obedience: 2026-10-03**
at the end of this file. Earlier sections are historical snapshots. at the end of this file. Earlier sections are historical snapshots.
Recovered on 2026-09-23 from the local Claude Code transcript Recovered on 2026-09-23 from the local Claude Code transcript
@@ -1165,3 +1165,62 @@ Rod was checked once in Chromium and WebKit.
Next: the remaining battle commands, move effects and Smart AI handlers Next: the remaining battle commands, move effects and Smart AI handlers
(board #28). (board #28).
## Battle effects, held items and obedience: 2026-10-03
Recovered the latest Claude Code session from
`~/.claude/projects/-Users-kawa-Crystal/d316d957-8ee4-4b55-aa7b-12be1bcd8726.jsonl`.
Its request was "continue the plan", taking card #28. It hit the usage limit
at 09:25 UTC while researching held items, Exp. Share and CheckObedience.
Claude had committed three groups:
- `9e0a2cd`: multi-hit and special-damage effects.
- `c53edb7`: moves spanning several turns.
- `8f5b464`: status and field move effects.
Its next group was uncommitted: weather, Substitute, Transform, Mimic,
Metronome, Mirror Move, Sleep Talk, Snore, Conversion/Conversion2, Sketch,
Baton Pass, Roar/Whirlwind and Teleport. Every move's effect script now has
a handler. Rollback fixtures use level-1 Psywave, which would loop in the
original and remains explicitly refused. These changes were preserved;
the recovered tree passed 546 unit tests before further edits. Claude's
last recorded browser run had 147 passed and 19 skipped.
The resumed increment adds:
- Smoke Ball, Metal Powder (also in the AI damage estimate), MysteryBerry,
Berserk Gene and the original unused held stat-raising effects. Item
order and stored/battle PP follow `engine/battle/core.asm`. Metal Powder
checks original species after Transform and retains byte overflow;
Berserk Gene retains the 256-turn/stale confusion-count bug.
- Exp. Share's separate participant/holder awards, halving bytes before
dividing each pool, including stat experience and existing modifiers.
- Traded Pokémon obedience, including badge thresholds, alternate moves,
nap, self-damage and idle outcomes. The world supplies its Johto badge
byte. RNG rejection loops fail closed for an invalid random source.
- Correct removal of consumed trainer healing/confusion berries from
the stored party, preventing them from returning on a switch.
- `tests/battle-held-items.test.ts` (30 local-ROM tests) and
`tests/reference-held-items.spec.ts` (320 Metal Powder cases per browser,
running the unmodified original routine). The new tests skip without
the private ROM; the emulator test also needs the pinned symbol file.
The reference harness must keep sentinels out of `$c6ff` (player screens).
The move-effect and held-item specs use audio RAM `$c0f0`, with interrupts
disabled, instead.
Validation on the final code: `npm test` passed 576 tests, with 3 skipped;
without the private ROM, 136 passed and 443 skipped. The full Chromium/WebKit
suite passed 149 tests, with 19 intentional skips, including the original
Tin Tower pillars case on this run. Native/reference builds, profile and
pinned-reference verification, vendor integrity, release isolation and
`git diff --check` passed. Metal Powder has direct emulator evidence;
the other new held-item, experience and obedience paths have local unit
coverage and source review. No CI or physical-device validation was run.
Next: complete the Smart AI effect dispatch in `src/game/battle/ai.ts`;
card #28 remains open. Other explicit boundaries include the special balls,
Unown generation, Beat Up with Eggs, and original routines that run off a
move list or loop indefinitely. Johto badge stat boosts are still absent;
the new badge option implements obedience only. Do not claim the battle
engine or the full card is complete. Delivery is tracked by the commits
referencing card #28; its commit list includes the recovered groups above.
+764 -69
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -105,6 +105,8 @@ export const C = {
SKULL_BASH: 130, SKULL_BASH: 130,
SKY_ATTACK: 143, SKY_ATTACK: 143,
WHIRLPOOL: 250, WHIRLPOOL: 250,
ROAR: 46,
TRANSFORM: 144,
MIRROR_MOVE: 119, MIRROR_MOVE: 119,
REST: 156, REST: 156,
ENCORE: 227, ENCORE: 227,
@@ -131,11 +133,16 @@ export const C = {
NORMAL: 0, NORMAL: 0,
FLYING: 2, FLYING: 2,
POISON: 3, POISON: 3,
ROCK: 5,
GROUND: 4,
STEEL: 9,
BIRD: 6, BIRD: 6,
GHOST: 8, GHOST: 8,
GRASS: 22, GRASS: 22,
UNUSED_TYPES: 10, UNUSED_TYPES: 10,
UNUSED_TYPES_END: 20, UNUSED_TYPES_END: 20,
TYPES_END: 28,
CURSE_TYPE: 19,
FIRE: 20, FIRE: 20,
SPECIAL: 20, SPECIAL: 20,
ICE: 25, ICE: 25,
@@ -156,6 +163,7 @@ export const C = {
POKE_BALL: 5, POKE_BALL: 5,
LUCKY_PUNCH: 30, LUCKY_PUNCH: 30,
METAL_POWDER: 35, METAL_POWDER: 35,
BERSERK_GENE: 0x98,
STICK: 105, STICK: 105,
THICK_CLUB: 118, THICK_CLUB: 118,
EXP_SHARE: 57, EXP_SHARE: 57,
@@ -387,10 +395,13 @@ export const COMMAND = {
freezetarget: 24, freezetarget: 24,
paralyzetarget: 25, paralyzetarget: 25,
selfdestruct: 26, selfdestruct: 26,
mirrormove: 27,
payday: 30, payday: 30,
conversion: 31,
resetstats: 32, resetstats: 32,
storeenergy: 33, storeenergy: 33,
unleashenergy: 34, unleashenergy: 34,
forceswitch: 35,
endloop: 36, endloop: 36,
flinchtarget: 37, flinchtarget: 37,
ohko: 38, ohko: 38,
@@ -400,10 +411,14 @@ export const COMMAND = {
confuse: 42, confuse: 42,
confusetarget: 43, confusetarget: 43,
heal: 44, heal: 44,
transform: 45,
screen: 46, screen: 46,
poison: 47, poison: 47,
paralyze: 48, paralyze: 48,
substitute: 49,
rechargenextturn: 50, rechargenextturn: 50,
mimic: 51,
metronome: 52,
leechseed: 53, leechseed: 53,
splash: 54, splash: 54,
disable: 55, disable: 55,
@@ -417,7 +432,11 @@ export const COMMAND = {
counter: 64, counter: 64,
encore: 65, encore: 65,
painsplit: 66, painsplit: 66,
snore: 67,
conversion2: 68,
lockon: 69, lockon: 69,
sketch: 70,
sleeptalk: 72,
destinybond: 73, destinybond: 73,
spite: 74, spite: 74,
falseswipe: 75, falseswipe: 75,
@@ -434,6 +453,7 @@ export const COMMAND = {
spikes: 86, spikes: 86,
foresight: 87, foresight: 87,
perishsong: 88, perishsong: 88,
startsandstorm: 89,
endure: 90, endure: 90,
checkrollout: 91, checkrollout: 91,
rolloutpower: 92, rolloutpower: 92,
@@ -446,12 +466,15 @@ export const COMMAND = {
safeguard: 100, safeguard: 100,
checksafeguard: 101, checksafeguard: 101,
getmagnitude: 102, getmagnitude: 102,
batonpass: 103,
pursuit: 104, pursuit: 104,
clearhazards: 105, clearhazards: 105,
healmorn: 106, healmorn: 106,
healday: 107, healday: 107,
healnite: 108, healnite: 108,
hiddenpower: 109, hiddenpower: 109,
startrain: 110,
startsun: 111,
attackup: 112, attackup: 112,
defenseup: 113, defenseup: 113,
speedup: 114, speedup: 114,
@@ -500,6 +523,7 @@ export const COMMAND = {
doubleminimizedamage: 157, doubleminimizedamage: 157,
skipsuncharge: 158, skipsuncharge: 158,
thunderaccuracy: 159, thunderaccuracy: 159,
teleport: 160,
beatup: 161, beatup: 161,
ragedamage: 162, ragedamage: 162,
resettypematchup: 163, resettypematchup: 163,
+36
View File
@@ -584,6 +584,33 @@ export const MENU = {
/** Battle engine tables and texts, keyed by their PRET labels so that /** Battle engine tables and texts, keyed by their PRET labels so that
* \`npm run check:reference\` can verify each address by name. */ * \`npm run check:reference\` can verify each address by name. */
export const BATTLE = { export const BATTLE = {
DownpourText: 0x817a3,
SunGotBrightText: 0x817b8,
SandstormBrewedText: 0x816e4,
BattleText_RainContinuesToFall: 0x8091f,
BattleText_TheSunlightIsStrong: 0x80938,
BattleText_TheSandstormRages: 0x80951,
BattleText_TheRainStopped: 0x80967,
BattleText_TheSunlightFaded: 0x8097a,
BattleText_TheSandstormSubsided: 0x8098f,
SandstormHitsText: 0x8084d,
MadeSubstituteText: 0x81362,
HasSubstituteText: 0x81378,
TooWeakSubText: 0x8138d,
SubTookDamageText: 0x813ad,
SubFadedText: 0x813d0,
TransformedText: 0x81499,
MimicLearnedMoveText: 0x813e6,
MirrorMoveFailedText: 0x815c1,
TransformedTypeText: 0x81452,
SketchedText: 0x81143,
FledFromBattleText: 0x812ac,
FledInFearText: 0x812c1,
BlownAwayText: 0x812d2,
DraggedOutText: 0x81544,
MetronomeExcepts: 0x37454,
WeatherTypeModifiers: 0xfbe13,
WeatherMoveModifiers: 0xfbe20,
WasSeededText: 0x813f8, WasSeededText: 0x813f8,
EvadedText: 0x81407, EvadedText: 0x81407,
LeechSeedSapsText: 0x8080e, LeechSeedSapsText: 0x8080e,
@@ -711,6 +738,15 @@ export const BATTLE = {
BattleText_MonFainted: 0x80a75, BattleText_MonFainted: 0x80a75,
BattleText_MonHasNoMovesLeft: 0x80c72, BattleText_MonHasNoMovesLeft: 0x80c72,
BattleText_StringBuffer1GrewToLevel: 0x80c9c, BattleText_StringBuffer1GrewToLevel: 0x80c9c,
BattleText_UserRecoveredPPUsing: 0x80899,
BattleText_UserFledUsingAStringBuffer1: 0x80b89,
BattleText_UsersStringBuffer1Activated: 0x80bde,
LoafingAroundText: 0x80f19,
BeganToNapText: 0x80f31,
WontObeyText: 0x80f44,
TurnedAwayText: 0x80f54,
IgnoredOrdersText: 0x80f66,
IgnoredSleepingText: 0x80f7b,
BattleText_TargetRecoveredWithItem: 0x80880, BattleText_TargetRecoveredWithItem: 0x80880,
BattleText_TheMoveIsDisabled: 0x80c5b, BattleText_TheMoveIsDisabled: 0x80c5b,
BattleText_TheresNoPPLeftForThisMove: 0x80c39, BattleText_TheresNoPPLeftForThisMove: 0x80c39,
+3
View File
@@ -2446,6 +2446,9 @@ export class GameWorld {
pokedex: draft.pokedex, pokedex: draft.pokedex,
playerId: this.playerId, playerId: this.playerId,
playerName: this.name, playerName: this.name,
johtoBadges: Array.from({ length: 8 }, (_, bit) =>
draft.flags.has(OW.ENGINE_ZEPHYRBADGE + bit) ? 1 << bit : 0,
).reduce((bits, badge) => bits | badge, 0),
timeOfDay: this.clockTimeOfDay, timeOfDay: this.clockTimeOfDay,
random: () => this.random() & 0xff, random: () => this.random() & 0xff,
wild: effect.wild, wild: effect.wild,
+1 -1
View File
@@ -58,7 +58,7 @@ async function finish(page: Page, action: "fight" | "run" = "fight") {
if (action === "run") await menu.getByRole("button", { name: "RUN", exact: true }).click(); if (action === "run") await menu.getByRole("button", { name: "RUN", exact: true }).click();
else { else {
await fight.click(); await fight.click();
await menu.getByRole("button", { name: /TACKLE|TRANSFORM|SCRATCH/ }).click(); await menu.getByRole("button", { name: /TACKLE|PSYWAVE|SCRATCH/ }).click();
} }
} else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) { } else if (await menu.getByRole("button", { name: "YES", exact: true }).count()) {
await menu.getByRole("button", { name: "YES", exact: true }).click(); await menu.getByRole("button", { name: "YES", exact: true }).click();
+3 -1
View File
@@ -24,7 +24,9 @@ export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenar
mon.exp = 178; // One battle away from level 6 and Smokescreen. mon.exp = 178; // One battle away from level 6 and Smokescreen.
if (scenario === "loss") mon.hp = 1; if (scenario === "loss") mon.hp = 1;
if (scenario === "unsupported") { if (scenario === "unsupported") {
mon.moves = [144, 0, 0, 0]; // Transform is explicitly refused. // Psywave at level 1 loops forever in the original, so it stays refused.
mon.level = 1;
mon.moves = [149, 0, 0, 0];
mon.pp = [10, 0, 0, 0]; mon.pp = [10, 0, 0, 0];
} }
world.state.party.push(mon); world.state.party.push(mon);
+298
View File
@@ -0,0 +1,298 @@
import { beforeAll, describe, expect, it, vi } 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 BattleEffect, type BattleOptions } from "../src/game/battle/battle";
import { C, COMMAND } from "../src/game/battle/constants";
import { type PartyMon } from "../src/game/party/pokemon";
import { battleOptions, battleSteps, partyMon, playBattle } from "./battle-driver";
const romPath = process.env.CRYSTAL_ROM_PATH ?? "Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const SPLASH = 150, TACKLE = 33, TRANSFORM = 144, MIMIC = 102, SMOKE_BALL = 0x6a;
describe.skipIf(!existsSync(romPath))("held items and obedience 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 = 143, level = 30, moves = [SPLASH], extra: Partial<PartyMon> = {}) => {
const result = partyMon(assets, species, level);
result.moves = Array.from({ length: 4 }, (_, 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);
};
const duel = (party: PartyMon[], enemy = [mon()], extra: Partial<BattleOptions> = {}) => new Battle({
...battleOptions(assets, trainers, party, { wild: { species: 129, level: 10 } }),
wild: undefined,
trainer: { name: "JOEY", className: "YOUNGSTER", party: enemy, aiFlags: 0, switchFlags: 0, reward: 40 },
...extra,
});
const drain = (run: Generator<BattleEffect, unknown, unknown>) => {
const texts: string[] = [];
for (let next = run.next(); !next.done; next = run.next()) {
if (next.value.kind === "text") texts.push(next.value.text.replace(/\n/g, " "));
else expect(next.value.kind).toBe("hud");
}
return texts.join(" ");
};
// Direct routine access lets branch tests supply exactly the BattleRandom
// bytes consumed by CheckObedience, without menu/AI draws obscuring them.
const internal = (battle: Battle) => battle as unknown as {
turn: number; alreadyDisobeyed: number; attackMissed: number; effectFailed: number;
script: number[]; scriptIndex: number; enemySpecies: number;
checkObedience(): Generator<BattleEffect, void, unknown>;
updateMoveData(): void;
handleMysteryBerry(): Generator<BattleEffect, void, unknown>;
handleStatBoostingHeldItems(): Generator<BattleEffect, void, unknown>;
dittoMetalPowder(side: Battle["player"], attack: number, defense: number): [number, number];
data: { heldEffect(item: number): { effect: number; parameter: number } };
};
it("Smoke Ball escapes without a speed roll or consumption, after the trap checks", () => {
const holder = mon(143, 5, [SPLASH], { item: SMOKE_BALL });
const battle = duel([holder], [], { trainer: undefined, wild: { species: 129, level: 20 } });
const steps = battleSteps(battle);
battle.enemy.mon.stats[C.SPEED] = 999;
battle.enemy.set(5, C.SUBSTATUS_CANT_RUN);
steps.answer({ action: "run" });
expect(steps.done).toBe(false);
expect(steps.texts.join(" ")).toContain("Can't escape");
battle.enemy.reset(5, C.SUBSTATUS_CANT_RUN);
steps.answer({ action: "run" });
expect(steps.done).toBe(true);
expect(steps.texts.join(" ")).toContain("fled using a SMOKE BALL");
expect(steps.texts.join(" ")).toContain("Got away safely");
expect(holder.item).toBe(SMOKE_BALL);
});
it("Smoke Ball cannot escape a trainer", () => {
const battle = duel([mon(143, 30, [SPLASH], { item: SMOKE_BALL })]);
const steps = battleSteps(battle).answer({ action: "run" });
expect(steps.done).toBe(false);
expect(steps.texts.join(" ")).toContain("No! There's no running");
});
it.each([0, 1])("MysteryBerry replenishes the first exhausted stored move on side %i", (sideIndex) => {
const holder = mon(143, 30, [SPLASH, TACKLE], { item: C.MYSTERYBERRY, pp: [0x81, 0xc0, 0, 0] });
const battle = duel(sideIndex ? [mon()] : [holder], sideIndex ? [holder] : [mon()]);
const steps = battleSteps(battle);
steps.answer({ action: "fight", move: 0 });
const side = sideIndex ? battle.enemy : battle.player;
expect(holder.pp[0]).toBe(0x85);
expect(holder.pp[1]).toBe(0xc0);
expect(side.mon.pp[0]).toBe(0x85);
expect(holder.item).toBe(0);
expect(side.mon.item).toBe(0);
expect(steps.texts.join(" ")).toContain("recovered PP using");
});
it("MysteryBerry stays held when only a copied move has no PP", () => {
const holder = mon(132, 30, [TRANSFORM], { item: C.MYSTERYBERRY });
const battle = duel([holder]);
battleSteps(battle).answer({ action: "fight", move: 0 });
battle.player.mon.pp[0] = 0;
drain(internal(battle).handleMysteryBerry());
expect(holder.item).toBe(C.MYSTERYBERRY);
expect(battle.player.mon.pp[0]).toBe(0);
});
it.each([TRANSFORM, MIMIC])("MysteryBerry restores stored move %i without refilling its copy", (move) => {
const holder = mon(132, 30, [move], { item: C.MYSTERYBERRY, pp: [1, 0, 0, 0] });
const battle = duel([holder], [mon(135)]);
const steps = battleSteps(battle).answer({ action: "fight", move: 0 });
expect(steps.done).toBe(false);
expect(holder.pp[0]).toBe(5);
expect(holder.item).toBe(0);
expect(battle.player.mon.moves[0]).toBe(SPLASH);
expect(battle.player.mon.pp[0]).toBe(5);
});
it("MysteryBerry restores Sketch by one PP and preserves PP Ups", () => {
const holder = mon(235, 30, [SPLASH, C.SKETCH], { item: C.MYSTERYBERRY, pp: [10, 0xc0, 0, 0] });
const battle = duel([holder]);
battleSteps(battle).answer({ action: "fight", move: 0 });
expect(holder.pp[1]).toBe(0xc1);
expect(battle.player.mon.pp[1]).toBe(0xc1);
});
it.each([0xad, 0x53])("a trainer's consumed berry %i is removed from its stored party too", (item) => {
// Berry and Bitter Berry exercise ConsumeHeldItem and the separate
// UseConfusionHealingItem removal path.
const holder = mon(143, 30, [SPLASH], { item });
const battle = duel([mon()], [holder]);
const steps = battleSteps(battle);
if (item === 0xad) battle.enemy.mon.hp = 20;
else {
battle.enemy.set(3, C.SUBSTATUS_CONFUSED);
battle.enemy.confuseCount = 5;
}
steps.answer({ action: "fight", move: 0 });
expect(battle.enemy.mon.item).toBe(0);
expect(holder.item).toBe(0);
});
it.each([0, 1])("Berserk Gene activates before the menu on side %i, with the 256-turn confusion bug", (sideIndex) => {
const holder = mon(143, 30, [SPLASH], { item: C.BERSERK_GENE });
const battle = duel(sideIndex ? [mon()] : [holder], sideIndex ? [holder] : [mon()]);
const steps = battleSteps(battle), side = sideIndex ? battle.enemy : battle.player;
expect(holder.item).toBe(0);
expect(side.levels[C.ATTACK]).toBe(9);
expect(side.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
expect(side.confuseCount).toBe(0);
expect(steps.texts.join(" ")).toContain("BERSERK GENE activated");
steps.answer({ action: "fight", move: 0 });
expect(side.confuseCount).toBe(255);
expect(steps.texts.filter((text) => text.includes("BERSERK GENE activated"))).toHaveLength(1);
});
it("Metal Powder checks original species on either side and keeps the overflow quirk", () => {
const battle = duel([mon(C.DITTO, 30, [TRANSFORM], { item: C.METAL_POWDER })], [mon(C.DITTO, 30, [TRANSFORM], { item: C.METAL_POWDER })]);
battleSteps(battle);
for (const side of [battle.player, battle.enemy]) {
side.mon.species = 143;
expect(internal(battle).dittoMetalPowder(side, 101, 170)).toEqual([101, 255]);
expect(internal(battle).dittoMetalPowder(side, 101, 171)).toEqual([50, 128]);
expect(internal(battle).dittoMetalPowder(side, 1, 255)).toEqual([1, 191]);
}
});
it("unused held stat effects respect failure and consume both party and battle items on success", () => {
const holder = mon(143, 30, [SPLASH], { item: C.MYSTERYBERRY });
const battle = duel([holder]);
battleSteps(battle);
const state = internal(battle);
const spy = vi.spyOn(state.data, "heldEffect").mockImplementation((item) => ({ effect: item ? C.HELD_ATTACK_UP : 0, parameter: 0 }));
state.attackMissed = 1;
drain(state.handleStatBoostingHeldItems());
expect(holder.item).toBe(C.MYSTERYBERRY);
state.attackMissed = state.effectFailed = 0;
drain(state.handleStatBoostingHeldItems());
expect(holder.item).toBe(0);
expect(battle.player.mon.item).toBe(0);
expect(battle.player.levels[C.ATTACK]).toBe(8);
spy.mockRestore();
});
it.each([1, 2])("Exp. Share divides its half between %i healthy holders, excluding fainted holders", (count) => {
const fighter = mon(143, 50, [TACKLE]);
const holders = Array.from({ length: count }, () => mon(143, 30, [SPLASH], { item: C.EXP_SHARE }));
const fainted = mon(143, 30, [SPLASH], { item: C.EXP_SHARE, hp: 0 });
const party = [fighter, ...holders, fainted], before = party.map((p) => p.exp);
const battle = duel(party, [], { trainer: undefined, wild: { species: 16, level: 10 } });
expect(playBattle(battle).result).toBe(C.WIN);
const base = assets.pokemon.baseData(16);
expect(fighter.exp - before[0]).toBe(Math.floor((base.baseExp >> 1) * 10 / 7));
holders.forEach((holder, i) => {
expect(holder.exp - before[i + 1]).toBe(Math.floor(Math.floor((base.baseExp >> 1) / count) * 10 / 7));
expect(holder.statExp).toEqual(base.stats.slice(0, 5).map((value) => Math.floor((value >> 1) / count)));
});
expect(fainted.exp).toBe(before.at(-1));
expect(fainted.statExp).toEqual([0, 0, 0, 0, 0]);
});
it("an active Exp. Share holder receives both halves, including Pokérus stat experience", () => {
const holder = mon(143, 50, [TACKLE], { item: C.EXP_SHARE, pokerus: 1 });
const before = holder.exp;
const battle = duel([holder], [], { trainer: undefined, wild: { species: 16, level: 10 } });
const result = playBattle(battle);
const base = assets.pokemon.baseData(16);
expect(holder.exp - before).toBe(2 * Math.floor((base.baseExp >> 1) * 10 / 7));
expect(holder.statExp).toEqual(base.stats.slice(0, 5).map((value) => 4 * (value >> 1)));
expect(result.texts.filter((text) => text.includes("gained"))).toHaveLength(2);
});
const obedience = (bytes: number[], extra: Partial<BattleOptions> = {}, level = 50) => {
const holder = mon(143, level, [SPLASH, TACKLE], { otId: 4321 });
const battle = duel([holder], [mon()], extra);
battleSteps(battle);
const state = internal(battle);
state.turn = 0;
state.script = [COMMAND.checkobedience, COMMAND.usedmovetext, COMMAND.endmove];
state.scriptIndex = 1;
battle.player.curMove = SPLASH;
battle.player.moveNum = 0;
state.updateMoveData();
const random = vi.spyOn(battle, "random").mockImplementation(() => {
const byte = bytes.shift();
if (byte === undefined) throw new Error("Unexpected obedience RNG draw");
return byte;
});
return { battle, holder, state, random, texts: () => drain(state.checkObedience()) };
};
it.each([[0, 10], [2, 30], [8, 50], [0x20, 70], [0x80, 100]])("badge mask %i guarantees obedience through level %i without RNG", (johtoBadges, level) => {
const test = obedience([], { johtoBadges }, level);
expect(test.texts()).toBe("");
expect(test.random).not.toHaveBeenCalled();
});
it("owned Pokémon obey regardless of level, and foreign Pokémon can pass their first roll", () => {
const owned = obedience([], {}, 100);
owned.holder.otId = 1234;
expect(owned.texts()).toBe("");
expect(owned.random).not.toHaveBeenCalled();
const foreign = obedience([0xff, 0]); // reject 255, then obey
expect(foreign.texts()).toBe("");
expect(foreign.random).toHaveBeenCalledTimes(2);
});
it("disobedience loafs, clears Encore and remembered moves, and spends no PP", () => {
const test = obedience([1, 20, 0xff, 0]); // swap(1)=16: disobey; neither nap nor self-hit
test.battle.player.set(5, C.SUBSTATUS_ENCORED);
test.battle.player.encoreCount = 3;
test.battle.player.lastMove = test.battle.player.lastCounterMove = TACKLE;
const pp = [...test.holder.pp];
expect(test.texts()).toContain("loafing around");
expect(test.holder.pp).toEqual(pp);
expect(test.battle.player.lastMove).toBe(0);
expect(test.battle.player.lastCounterMove).toBe(0);
expect(test.battle.player.encoreCount).toBe(0);
expect(test.battle.player.has(5, C.SUBSTATUS_ENCORED)).toBe(false);
expect(test.state.script[1]).toBe(COMMAND.endmove);
});
it("disobedience can nap or inflict confusion damage without using PP", () => {
const nap = obedience([1, 20, 0, 0, 8]); // reject zero sleep, then one turn
expect(nap.texts()).toContain("began to nap");
expect(nap.battle.player.mon.status).toBe(1);
const hit = obedience([1, 20, 3]); // swap(3)=48, in [40,80)
const hp = hit.battle.player.mon.hp;
expect(hit.texts()).toContain("hurt itself");
expect(hit.battle.player.mon.hp).toBeLessThan(hp);
expect(hit.battle.player.mon.pp).toEqual(hit.holder.pp);
});
it("a sleeping foreign Pokémon ignores Snore before the second obedience roll", () => {
const test = obedience([1]);
test.battle.player.curMove = C.SNORE;
test.battle.player.mon.status = 3;
test.battle.player.lastMove = TACKLE;
test.state.updateMoveData();
expect(test.texts()).toContain("ignored orders");
expect(test.random).toHaveBeenCalledTimes(1);
expect(test.battle.player.lastMove).toBe(TACKLE); // IgnoreSleepOnly returns early
});
it("disobedience can use a different move, spending only that slot's PP", () => {
const test = obedience([1, 0, 0, 1, 255, 255, 0]);
// 16 fails obedience; 0 selects UseInstead; reject original slot 0,
// choose Tackle, then its critical, damage-variation and hit draws.
const pp = [...test.holder.pp], hp = test.battle.enemy.mon.hp;
expect(test.texts()).toContain("instead");
expect(test.battle.enemy.mon.hp).toBeLessThan(hp);
expect(test.holder.pp).toEqual([pp[0], pp[1] - 1, 0, 0]);
expect(test.battle.player.moveNum).toBe(0);
expect(test.state.alreadyDisobeyed).toBe(1);
});
it.each(["disabled", "no PP", "one move"])("disobedience loafs when an alternate move is unavailable: %s", (reason) => {
const test = obedience([1, 0, 0]);
if (reason === "disabled") test.battle.player.disabledMove = TACKLE;
else if (reason === "no PP") test.battle.player.mon.pp[1] = 0xc0;
else test.battle.player.mon.moves[1] = 0;
expect(test.texts()).toContain("loafing around");
expect(test.random).toHaveBeenCalledTimes(3);
expect(test.state.alreadyDisobeyed).toBe(0);
});
});
+193 -2
View File
@@ -95,7 +95,25 @@ const DOUBLESLAP = 3,
RAPID_SPIN = 0xe5, RAPID_SPIN = 0xe5,
PSYCH_UP = 0xf4, PSYCH_UP = 0xf4,
ANCIENTPOWER = 0xf6, 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 // Species and items
const PIDGEY = 16, const PIDGEY = 16,
RATTATA = 19, RATTATA = 19,
@@ -122,7 +140,10 @@ const PIDGEY = 16,
NIDORAN_F = 0x1d, NIDORAN_F = 0x1d,
NIDORAN_M = 0x20, NIDORAN_M = 0x20,
STARMIE = 0x79, STARMIE = 0x79,
FORRETRESS = 0xcd; FORRETRESS = 0xcd,
DITTO = 0x84,
SMEARGLE = 0xeb,
PORYGON = 0x89;
const AMULET_COIN = 0x5b, const AMULET_COIN = 0x5b,
LEFTOVERS = 0x92, LEFTOVERS = 0x92,
FOCUS_BAND = 0x77, 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", () => { describe("Counter and Mirror Coat", () => {
it("returns twice a physical hit taken first", () => { it("returns twice a physical hit taken first", () => {
const player = mon(SNORLAX, 40, [COUNTER]); const player = mon(SNORLAX, 40, [COUNTER]);
+82
View File
@@ -0,0 +1,82 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { C } from "../src/game/battle/constants";
import { romPath, symbolsPath, loadSymbols, openReference } from "./reference-fixture";
test.skip(!existsSync(romPath) || !existsSync(symbolsPath), "Needs the private ROM and pinned reference rebuild.");
test("Metal Powder matches the original species check and byte overflow", async ({ page }) => {
const { at, routine } = loadSymbols();
const addresses = {
command: routine("DittoMetalPowder"),
romBank: at("hROMBank"),
turn: at("hBattleTurn"),
curBattleMon: at("wCurBattleMon"),
partySpecies: at("wPartyMon1Species"),
enemySpecies: at("wTempEnemyMonSpecies"),
playerItem: at("wBattleMonItem"),
enemyItem: at("wEnemyMonItem"),
};
await openReference(page);
const differences = await page.evaluate(async ({ addresses, ditto, powder }) => {
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 read = (address: number) => reference.readMemory(address);
const failures: unknown[] = [];
let compared = 0;
write(0xff70, 1);
write(addresses.curBattleMon, 0);
for (const turn of [0, 1]) for (const species of [ditto, 25]) for (const item of [powder, 0])
for (const attack of [1, 2, 101, 255]) for (const defense of [1, 2, 100, 169, 170, 171, 172, 200, 254, 255]) {
const battle = new Battle({
data: assets.pokemon, party: [{ species }] as never,
pokedex: { seen: new Set(), caught: new Set() },
playerId: 1, playerName: "CHRIS", timeOfDay: 1,
text: () => ({}) as never, random: () => 0,
});
const internal = battle as unknown as {
enemySpecies: number;
dittoMetalPowder(side: unknown, attack: number, defense: number): number[];
};
internal.enemySpecies = species;
const target = turn ? battle.player : battle.enemy;
target.mon = { species: 25, item } as never; // transformed battle species
const native = internal.dittoMetalPowder(target, attack, defense);
write(addresses.turn, turn);
write(addresses.partySpecies, species);
write(addresses.enemySpecies, species);
write(addresses.playerItem, item);
write(addresses.enemyItem, item);
const { bank, address } = addresses.command;
// DI; SP; ROM bank; BC; call the unmodified routine; save B/C;
// sentinel in audio RAM (no interrupts); loop.
const program = [
0xf3, 0x31, 0xfe, 0xc7,
0x3e, bank, 0xe0, addresses.romBank & 255, 0xea, 0x00, 0x20,
0x06, attack, 0x0e, defense, 0xcd, address & 255, address >> 8,
0x78, 0xea, 0xe0, 0xc0, 0x79, 0xea, 0xe1, 0xc0,
0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe,
];
write(0xc0f0, 0);
program.forEach((byte, i) => write(0xc000 + i, byte));
reference.setProgramCounter(0xc000);
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
if (read(0xc0f0) !== 0x42) throw new Error("DittoMetalPowder did not return");
const original = [read(0xc0e0), read(0xc0e1)];
if (original.some((value, i) => value !== native[i]))
failures.push({ turn, species, item, attack, defense, original, native });
compared++;
}
reference.dispose();
return { compared, failures };
}, { addresses, ditto: C.DITTO, powder: C.METAL_POWDER });
expect(differences.compared).toBe(320);
expect(differences.failures).toEqual([]);
});
+25
View File
@@ -51,6 +51,7 @@ type Case = {
beatUpHit: number; beatUpHit: number;
playerIsSwitching: number; playerIsSwitching: number;
enemyIsSwitching: number; enemyIsSwitching: number;
weather?: number;
/** A battle script and the index of the command after the one run. */ /** A battle script and the index of the command after the one run. */
script: number[]; script: number[];
scriptIndex: number; scriptIndex: number;
@@ -142,6 +143,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
enemyGoesFirst: at("wEnemyGoesFirst"), enemyGoesFirst: at("wEnemyGoesFirst"),
animParam: at("wBattleAnimParam"), animParam: at("wBattleAnimParam"),
battleMode: at("wBattleMode"), battleMode: at("wBattleMode"),
weather: at("wBattleWeather"),
partyCount: at("wPartyCount"), partyCount: at("wPartyCount"),
otPartyCount: at("wOTPartyCount"), otPartyCount: at("wOTPartyCount"),
beatUpHit: at("wBeatUpHitAtLeastOnce"), beatUpHit: at("wBeatUpHitAtLeastOnce"),
@@ -257,6 +259,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
beatUpHit: input.beatUpHit, beatUpHit: input.beatUpHit,
playerIsSwitching: input.playerIsSwitching, playerIsSwitching: input.playerIsSwitching,
enemyIsSwitching: input.enemyIsSwitching, enemyIsSwitching: input.enemyIsSwitching,
weather: input.weather ?? 0,
script: [...input.script], script: [...input.script],
scriptIndex: input.scriptIndex, scriptIndex: input.scriptIndex,
}); });
@@ -269,6 +272,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
write(addresses.enemyGoesFirst, input.enemyGoesFirst); write(addresses.enemyGoesFirst, input.enemyGoesFirst);
write(addresses.animParam, input.animParam); write(addresses.animParam, input.animParam);
write(addresses.battleMode, input.battleMode); write(addresses.battleMode, input.battleMode);
write(addresses.weather, input.weather ?? 0);
write(addresses.partyCount, input.partyCount); write(addresses.partyCount, input.partyCount);
write(addresses.otPartyCount, input.otPartyCount); write(addresses.otPartyCount, input.otPartyCount);
write(addresses.beatUpHit, input.beatUpHit); write(addresses.beatUpHit, input.beatUpHit);
@@ -621,3 +625,24 @@ test("CheckHit matches Fly, Dig and Lock-On", async ({ page }) => {
} }
await compare(page, ["BattleCommand_CheckHit"], cases); await compare(page, ["BattleCommand_CheckHit"], cases);
}); });
test("Stab matches weather, STAB and type effectiveness", async ({ page }) => {
const { make, next } = generator(0x57ab);
// Normal, Fighting, Flying, Ground, Ghost, Fire, Water, Grass, Electric, Psychic, Dark.
const types = [0, 1, 2, 4, 8, 20, 21, 22, 23, 24, 27];
const cases: Case[] = [];
for (let i = 0; i < 1000; i++) {
const input = make({
curDamage: [0, 1, 2, 3, 40, 997, 0x7fff, 0xaaaa, 0xffff][next(9)],
typeModifier: next(4) ? C.EFFECTIVE : 0,
});
const user = input.sides[input.turn],
target = input.sides[input.turn ^ 1];
user.move = { ...user.move, type: types[next(types.length)], effect: next(4) ? 0 : C.EFFECT_SOLARBEAM };
user.types = [types[next(types.length)], types[next(types.length)]];
target.types = [types[next(types.length)], types[next(types.length)]];
if (next(4) === 0) target.sub[0] = 1 << C.SUBSTATUS_IDENTIFIED;
cases.push({ ...input, weather: next(4) } as Case);
}
await compare(page, ["BattleCommand_Stab"], cases);
});
+3 -1
View File
@@ -130,7 +130,9 @@ describe.skipIf(!existsSync(romPath))("Route 30 trainers with the local ROM", ()
it("does not re-trigger a refused sighting until the player moves", () => { it("does not re-trigger a refused sighting until the player moves", () => {
const world = fixture({ x: 1, y: 11, direction: "up" }); const world = fixture({ x: 1, y: 11, direction: "up" });
world.state.party[0].moves = [144, 0, 0, 0]; // Transform is refused // Psywave at level 1 loops forever in the original, so it stays refused.
world.state.party[0].level = 1;
world.state.party[0].moves = [149, 0, 0, 0];
world.state.party[0].pp = [10, 0, 0, 0]; world.state.party[0].pp = [10, 0, 0, 0];
const before = world.save(); const before = world.save();
world.move("up"); world.move("up");