diff --git a/docs/PLAN.md b/docs/PLAN.md index 2fc61d2..8c7485e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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. - 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 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. diff --git a/docs/SESSION_HANDOFF.md b/docs/SESSION_HANDOFF.md index 9cf62ec..e1153c7 100644 --- a/docs/SESSION_HANDOFF.md +++ b/docs/SESSION_HANDOFF.md @@ -1,6 +1,6 @@ # 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. 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 (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. diff --git a/src/game/battle/battle.ts b/src/game/battle/battle.ts index bc02bfd..a6c1f4b 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -88,6 +88,10 @@ class Side { justGotFrozen = 0; perishCount = 0; safeguardCount = 0; + /** wSubstituteHP: a byte, a quarter of the maximum HP. */ + substituteHp = 0; + /** wEnemyBackupDVs: the enemy's own DVs while transformed. */ + backupDvs = 0; /** wFutureSightCount and the damage Future Sight stored. */ futureSightCount = 0; futureSightDamage = 0; @@ -168,6 +172,8 @@ export interface BattleOptions { pokedex: { seen: Set; caught: Set }; playerId: number; playerName: string; + /** wJohtoBadges, for traded Pokémon obedience. */ + johtoBadges?: number; /** wTimeOfDay: morning 0, day 1, night 2. */ timeOfDay: number; random: () => number; @@ -481,6 +487,8 @@ export class Battle { /** wEnemyMonBaseStats: five base stats, catch rate and base exp. */ enemyBase = { stats: [0, 0, 0, 0, 0], catchRate: 0, exp: 0 }; weather: number = C.WEATHER_NONE; + /** wWeatherCount: turns left of the weather. */ + private weatherCount = 0; /** wCurOTMon: the trainer's party index in battle. */ curOTMon = 0; /** wWildMon after a ball: the caught species, or 0. */ @@ -494,6 +502,10 @@ export class Battle { /** wEnemySwitchMonIndex: the AI's chosen party index plus one, or 0. */ private enemySwitchMonIndex = 0; private battleHasJustStarted = 0; + /** wTempEnemyMonSpecies: the enemy's species before any Transform. */ + private enemySpecies = 0; + /** wForcedSwitch: Roar, Whirlwind or Teleport ended a wild battle. */ + private forcedSwitch = false; /** wCurPartyMon: the party index a menu picked. */ private curPartyMon = 0; /** wPartyMenuCursor: the 1-based cursor of the last party menu, from @@ -712,8 +724,10 @@ export class Battle { catchRate: base.catchRate, exp: base.baseExp, }; + // The paralysis and burn penalties are not applied to a trainer's + // Pokémon sent back in with a status (a kept bug). this.enemy.stats = [...this.enemy.mon.stats]; - this.applyStatusEffects(this.enemy); + this.enemySpecies = mon.species; this.options.pokedex.seen.add(mon.species); return; } @@ -730,14 +744,8 @@ export class Battle { do dvs = (this.random() << 8) | this.random(); while (species === C.MAGIKARP && this.rerollMagikarp(dvs)); const stats = calcStats(data, species, dvs, level); - const moves: number[] = []; - for (const learned of data.learnset(species)) { - if (learned.level > level) break; - if (moves.includes(learned.move)) continue; - if (moves.length === 4) moves.shift(); - moves.push(learned.move); - } - while (moves.length < 4) moves.push(0); + const moves = this.levelMoves(species, level); + this.enemySpecies = species; this.enemy.mon = { species, item, @@ -764,6 +772,18 @@ export class Battle { this.wildMoves = [...moves]; this.wildPP = [...this.enemy.mon.pp]; } + /** The last four moves learnt by the level, as a wild Pokémon has them. */ + private levelMoves(species: number, level: number) { + const moves: number[] = []; + for (const learned of this.options.data.learnset(species)) { + if (learned.level > level) break; + if (moves.includes(learned.move)) continue; + if (moves.length === 4) moves.shift(); + moves.push(learned.move); + } + while (moves.length < 4) moves.push(0); + return moves; + } /** LoadEnemyMon's .Magikarp filters, true when the DVs are drawn again. * Their limits are in millimetres but CalcMagikarpLength gives feet and * inches: the two size checks need 6 feet, which no Magikarp reaches @@ -1007,6 +1027,7 @@ export class Battle { this.playerIsSwitching = 0; this.player.justGotFrozen = 0; this.enemy.justGotFrozen = 0; + yield* this.handleBerserkGene(); this.updateBattleMonInParty(); // AIChooseMove runs once before BattleMenu, even with exhausted PP. if (this.options.trainer && !this.checkEnemyLockedIn()) { @@ -1060,7 +1081,6 @@ export class Battle { } } if (!move.power) return 0; - if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); const special = move.type >= C.SPECIAL, attackStat = special ? C.SP_ATTACK : C.ATTACK, defenseStat = special ? C.SP_DEFENSE : C.DEFENSE, @@ -1081,8 +1101,7 @@ export class Battle { : (this.enemy.mon.species === C.CUBONE || this.enemy.mon.species === C.MAROWAK) && this.enemy.mon.item === C.THICK_CLUB; if (boosted) attack = (attack * 2) & 0xffff; [attack, defense] = this.truncate(attack, defense); - if (this.player.mon.species === C.DITTO && this.player.mon.item === C.METAL_POWDER) - throw new UnsupportedBattle("Metal Powder is not ported yet."); + [attack, defense] = this.dittoMetalPowder(this.player, attack, defense); const held = this.data.heldEffect(this.enemy.mon.item), typeBoost = this.data.typeBoost(held.effect), boost = held.effect && typeBoost === move.type ? held.parameter : undefined, @@ -1098,7 +1117,7 @@ export class Battle { }); if (baseDamage === undefined) return 0; return applyTypeMatchups(this.matchups, { - damage: baseDamage, + damage: this.weatherModifier(baseDamage, move.type, move.effect), modifier: C.EFFECTIVE, moveType: move.type, userTypes: this.enemy.mon.types, @@ -1537,10 +1556,22 @@ export class Battle { // Caught: LoadEnemyMon runs again with SUBSTATUS_TRANSFORMED set, which // keeps the DVs, redraws the wild item (restored afterwards) and // recalculates stats. Status, HP and item are kept; moves and PP return - // to wWildMonMoves and wWildMonPP. - if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) - throw new UnsupportedBattle("Catching a transformed Pokémon is not ported yet."); + // to wWildMonMoves and wWildMonPP. A transformed Pokémon is reloaded as + // a Ditto with its backup DVs and keeps that Ditto's moves (a kept bug + // for anything else). if (this.random() >= percent(75) + 1) this.random(); + if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) { + const moves = this.levelMoves(C.DITTO, this.enemy.mon.level); + Object.assign(this.enemy.mon, { + species: C.DITTO, + dvs: this.enemy.backupDvs, + moves, + pp: moves.map((move) => (move ? data.move(move).pp : 0)), + nickname: data.speciesName(C.DITTO), + }); + this.wildMoves = [...moves]; + this.wildPP = [...this.enemy.mon.pp]; + } const enemy = this.enemy.mon, species = enemy.species, level = enemy.level; @@ -1884,6 +1915,7 @@ export class Battle { this.turn = 1; const switched = yield* this.aiSwitchOrTryItem(); yield* this.doTurnFor(0); + if (this.forcedSwitch) return this.endForced(); if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); this.turn = 0; @@ -1892,6 +1924,7 @@ export class Battle { if (!switched) { if (this.tryEnemyFlee()) return yield* this.wildFled(); yield* this.doTurnFor(1); + if (this.forcedSwitch) return this.endForced(); if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); } @@ -1906,6 +1939,7 @@ export class Battle { this.enemyGoesFirst = 1; if (!(yield* this.aiSwitchOrTryItem())) { yield* this.doTurnFor(1); + if (this.forcedSwitch) return this.endForced(); if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); } @@ -1913,6 +1947,7 @@ export class Battle { if (!(yield* this.residualDamage())) return yield* this.handleEnemyMonFaint(); yield this.hud(); yield* this.doTurnFor(0); + if (this.forcedSwitch) return this.endForced(); if (this.hasFainted(this.enemy)) return yield* this.handleEnemyMonFaint(); if (this.hasFainted(this.player)) return yield* this.handlePlayerMonFaint(); this.turn = 0; @@ -1920,6 +1955,10 @@ export class Battle { yield this.hud(); this.battlePlayerAction = 0; } + /** BattleTurn quits after a move set wForcedSwitch. */ + private endForced() { + this.battleEnded = true; + } /** PlayerTurn/EnemyTurn_EndOpponentProtectEndureDestinyBond. */ private *doTurnFor(turn: number): Gen { this.turn = turn; @@ -1965,8 +2004,14 @@ export class Battle { yield* this.say(BATTLE.BattleText_CantEscape); return false; } - if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_ESCAPE) - throw new UnsupportedBattle("Escape items are not ported yet."); + if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_ESCAPE) { + this.turn = 0; + this.strings[RAM.stringBuffer1] = this.options.data.itemName(this.player.mon.item); + yield* this.say(BATTLE.BattleText_UserFledUsingAStringBuffer1); + this.battleResult = (this.battleResult & 0xc0) + C.DRAW; + yield* this.say(BATTLE.BattleText_GotAwaySafely); + return true; + } this.numFleeAttempts = (this.numFleeAttempts + 1) & 0xff; const enemySpeed = this.enemy.mon.stats[C.SPEED]; let escaped = playerSpeed >= enemySpeed; @@ -2140,14 +2185,14 @@ export class Battle { if (user.screens & (1 << C.SCREENS_REFLECT)) defense *= 2; const [attack, def] = this.truncate(mon.stats[C.ATTACK], defense); this.damageCalc(40, mon.level, attack, def); - this.doDamage(this.user, true); + this.doDamage(this.user); yield this.hud(); } private *command(command: number): Gen { switch (command) { case COMMAND.checkobedience: - return this.checkObedience(); + return yield* this.checkObedience(); case COMMAND.usedmovetext: return yield* this.say(BATTLE.UsedMoveText); case COMMAND.doturn: @@ -2167,18 +2212,21 @@ export class Battle { case COMMAND.effectchance: return this.effectChance(); case COMMAND.lowersub: + return this.lowerSub(); case COMMAND.raisesub: + return this.raiseSub(); case COMMAND.lowersubnoanim: case COMMAND.raisesubnoanim: case COMMAND.movedelay: - return this.requireNoSubstitute(); + return; // Presentation only. case COMMAND.moveanimnosub: - case COMMAND.moveanim: - this.requireNoSubstitute(); return this.moveAnimNoSub(); + case COMMAND.moveanim: + this.lowerSub(); + this.moveAnimNoSub(); + return this.raiseSub(); case COMMAND.statupanim: case COMMAND.statdownanim: - this.requireNoSubstitute(); // BattleCommand_StatUpDownAnim if (!this.attackMissed) this.animParam = 0; return; @@ -2369,6 +2417,50 @@ export class Battle { return this.checkFutureSight(); case COMMAND.futuresight: return yield* this.futureSight(); + case COMMAND.substitute: + return yield* this.substitute(); + case COMMAND.transform: + return yield* this.transform(); + case COMMAND.mimic: + return yield* this.mimic(); + case COMMAND.metronome: + return yield* this.metronome(); + case COMMAND.mirrormove: + return yield* this.mirrorMove(); + case COMMAND.sleeptalk: + return yield* this.sleepTalk(); + case COMMAND.snore: + // Fails unless the user is asleep. + if (this.user.mon.status & C.SLP_MASK) return; + this.curDamage = 0; + this.attackMissed = 1; + yield* this.failMove(); + return this.endMoveEffect(); + case COMMAND.conversion: + return yield* this.conversion(); + case COMMAND.conversion2: + return yield* this.conversion2(); + case COMMAND.sketch: + return yield* this.sketch(); + case COMMAND.batonpass: + return yield* this.batonPass(); + case COMMAND.forceswitch: + return yield* this.forceSwitch(); + case COMMAND.teleport: + return yield* this.teleport(); + case COMMAND.startrain: + case COMMAND.startsun: + case COMMAND.startsandstorm: { + // Rain Dance and Sunny Day renew their weather; Sandstorm fails. + const weather = + command === COMMAND.startrain ? C.WEATHER_RAIN : command === COMMAND.startsun ? C.WEATHER_SUN : C.WEATHER_SANDSTORM; + if (weather === C.WEATHER_SANDSTORM && this.weather === weather) return yield* this.say(BATTLE.ButItFailedText); + this.weather = weather; + this.weatherCount = 5; + return yield* this.say( + weather === C.WEATHER_RAIN ? BATTLE.DownpourText : weather === C.WEATHER_SUN ? BATTLE.SunGotBrightText : BATTLE.SandstormBrewedText, + ); + } case COMMAND.pursuit: // Double damage against a Pokémon being withdrawn. if (this.turn === 0 ? this.enemyIsSwitching : this.playerIsSwitching) @@ -2455,17 +2547,94 @@ export class Battle { throw new UnsupportedBattle(`Battle command 0x${command.toString(16)} is not ported.`); } } - /** Substitute presentation is only safe once substitutes exist. */ - private requireNoSubstitute() { - if (this.user.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) - throw new UnsupportedBattle("Substitutes are not ported yet."); + /** BattleCommand_LowerSub: only its wBattleAnimParam outlives the + * animation, which Triple Kick counts from. A substitute is lowered + * (with animations on) for a charging turn, and otherwise unless the + * user is charging. wSomeoneIsRampaging, which also decides it for + * Rollout and rampages, only changes what is drawn and is not kept. */ + private lowerSub() { + const user = this.user, + effect = user.move.effect; + if (!user.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.options.battleScene === false) return; + const charging = + !user.has(SUB3, C.SUBSTATUS_CHARGED) && + ([C.EFFECT_RAZOR_WIND, C.EFFECT_SKY_ATTACK, C.EFFECT_SKULL_BASH, C.EFFECT_SOLARBEAM, C.EFFECT_FLY] as number[]).includes(effect); + if (charging || effect === C.EFFECT_ROLLOUT || effect === C.EFFECT_RAMPAGE || !user.charging) this.animParam = 1; + } + /** BattleCommand_RaiseSub's wBattleAnimParam. */ + private raiseSub() { + if (this.user.has(SUB4, C.SUBSTATUS_SUBSTITUTE) && this.options.battleScene !== false) this.animParam = 2; } /** BattleCommand_CheckObedience: only foreign Pokémon may disobey. */ - private checkObedience() { + private *checkObedience(): Gen { if (this.turn !== 0 || this.user.charging || this.alreadyDisobeyed) return; - if (this.partyMon.otId !== this.options.playerId) - throw new UnsupportedBattle("Traded Pokémon obedience is not ported yet."); + if (this.partyMon.otId === this.options.playerId) return; + const user = this.user, badges = this.options.johtoBadges ?? 0; + const limit = badges & 0x80 ? 101 : badges & 0x20 ? 70 : badges & 8 ? 50 : badges & 2 ? 30 : 10; + if (user.mon.level <= limit) return; + const total = Math.min(255, limit + user.mon.level); + const swap = (byte: number) => ((byte << 4) | (byte >> 4)) & 255; + const rollBelow = (swapped: boolean) => { + for (let attempt = 0; attempt < 4096; attempt++) { + const value = this.random(), roll = swapped ? swap(value) : value; + if (roll < total) return roll; + } + throw new UnsupportedBattle("Obedience random source did not produce a number."); + }; + if (rollBelow(true) < limit) return; + if ((user.curMove === C.SNORE || user.curMove === C.SLEEP_TALK) && user.mon.status & C.SLP_MASK) { + yield* this.say(BATTLE.IgnoredSleepingText); + return this.endMoveEffect(); + } + if (rollBelow(false) < limit) { + const pp = user.mon.pp; + let totalPP = 0; + for (let slot = 0; slot < C.NUM_MOVES && user.mon.moves[slot]; slot++) + totalPP = (totalPP + (pp[slot] & C.PP_MASK)) & 255; + if (user.mon.moves[1] && !user.disabledMove && totalPP !== (pp[user.moveNum] & C.PP_MASK)) { + this.alreadyDisobeyed = 1; + const originalSlot = user.moveNum; + let slot = -1; + for (let attempt = 0; attempt < 4096; attempt++) { + const candidate = this.random() & 3; + if (user.mon.moves[candidate] && candidate !== originalSlot && pp[candidate] & C.PP_MASK) { + slot = candidate; + break; + } + } + if (slot < 0) throw new UnsupportedBattle("Obedience random source did not choose a move."); + user.moveNum = slot; + user.curMove = user.mon.moves[slot]; + this.updateMoveData(); + yield* this.doMove(); + user.moveNum = originalSlot; + } else yield* this.disobedienceNothing(); + } else { + const difference = user.mon.level - limit, roll = swap(this.random()); + if (roll < difference) { + let sleep = 0; + for (let attempt = 0; attempt < 4096 && !sleep; attempt++) + sleep = swap((this.random() * 2) & 255) & C.SLP_MASK; + if (!sleep) throw new UnsupportedBattle("Obedience random source did not produce sleep."); + user.mon.status = sleep; + yield this.hud(); + yield* this.say(BATTLE.BeganToNapText); + } else if (roll - difference < difference) { + yield* this.say(BATTLE.WontObeyText); + yield* this.hitConfusion(); + } else yield* this.disobedienceNothing(); + } + user.lastMove = user.lastCounterMove = 0; + user.reset(SUB5, C.SUBSTATUS_ENCORED); + user.encoreCount = 0; + this.endMoveEffect(); + } + private *disobedienceNothing(): Gen { + yield* this.say([ + BATTLE.LoafingAroundText, BATTLE.WontObeyText, + BATTLE.TurnedAwayText, BATTLE.IgnoredOrdersText, + ][this.random() & 3]); } /** BattleCommand_DoTurn: count the turn and use one PP. */ private *doTurnCommand(): Gen { @@ -2595,10 +2764,21 @@ export class Battle { attack = this.speciesItemBoost(attack, special); [attack, defense] = this.truncate(attack, defense); // DittoMetalPowder boosts the defender's Defense. - if (target.mon.species === C.DITTO && target.mon.item === C.METAL_POWDER) - throw new UnsupportedBattle("Metal Powder is not ported yet."); + [attack, defense] = this.dittoMetalPowder(target, attack, defense); this.damageArgs = { power, level: user.mon.level, attack, defense }; } + /** DittoMetalPowder checks the original species, even after Transform. + * On byte overflow it halves Attack too, keeping the original quirk. */ + private dittoMetalPowder(target: Side, attack: number, defense: number): [number, number] { + const species = target === this.player ? this.partyMon.species : this.enemySpecies; + if (species !== C.DITTO || target.mon.item !== C.METAL_POWDER) return [attack, defense]; + defense += defense >> 1; + if (defense > 255) { + attack = attack >> 1 || 1; + defense = 128 | ((defense & 255) >> 1); + } + return [attack, defense]; + } /** ThickClubBoost / LightBallBoost. */ private speciesItemBoost(attack: number, special: boolean) { const mon = this.user.mon; @@ -2642,9 +2822,8 @@ export class Battle { const user = this.user, target = this.target; if (user.move.anim === C.STRUGGLE) return; - if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); const result = applyTypeMatchups(this.matchups, { - damage: this.curDamage, + damage: this.weatherModifier(this.curDamage, user.move.type, user.move.effect), modifier: this.typeModifier, moveType: user.move.type, userTypes: user.mon.types, @@ -2656,6 +2835,20 @@ export class Battle { this.matchup = result.matchup; if (result.missed) this.attackMissed = 1; } + /** DoWeatherModifiers, before STAB: rain and sun scale Water and Fire + * damage, else rain halves Solarbeam's; the result is at least 1. */ + private weatherModifier(damage: number, type: number, effect: number) { + if (!this.weather) return damage; + const r = this.reader, + find = (table: number, value: number) => { + for (let p = table; r.byte(p) !== 0xff; p += 3) + if (r.byte(p) === this.weather && r.byte(p + 1) === value) return r.byte(p + 2); + }; + const multiplier = find(BATTLE.WeatherTypeModifiers, type) ?? find(BATTLE.WeatherMoveModifiers, effect); + if (multiplier === undefined) return damage; + const value = Math.floor((damage * multiplier) / 10); + return value > 0xffff ? 0xffff : value || 1; + } /** BattleCheckTypeMatchup: wTypeMatchup for the move against the target. */ private battleCheckTypeMatchup() { const target = this.target; @@ -2747,14 +2940,13 @@ export class Battle { if (user.move.anim === C.FLY || user.move.anim === C.DIG) { user.reset(SUB3, C.SUBSTATUS_UNDERGROUND); user.reset(SUB3, C.SUBSTATUS_FLYING); - this.requireNoSubstitute(); // AppearUserRaiseSub return this.endMoveEffect(); } // Multi-hit moves other than Triple Kick and Beat Up raise the // substitute again (Beat Up not doing so is a kept bug). const effect = user.move.effect; if (effect === C.EFFECT_MULTI_HIT || effect === C.EFFECT_DOUBLE_HIT || effect === C.EFFECT_POISON_MULTI_HIT) - this.requireNoSubstitute(); + this.raiseSub(); this.endMoveEffect(); } private *getFailureResultText(): Gen { @@ -2777,7 +2969,7 @@ export class Battle { // dealt, at least 1, ignoring the user's substitute. this.curDamage = this.curDamage >> 3 || 1; yield* this.say(BATTLE.CrashedText); - this.doDamage(this.user, true); + this.doDamage(this.user); yield this.hud(); } @@ -2791,7 +2983,8 @@ export class Battle { hungOn = this.falseSwipe() ? 2 : 0; if (!target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) target.damageTaken = Math.min(0xffff, target.damageTaken + this.curDamage); - this.doDamage(target, false); + if (this.curDamage && target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) yield* this.doSubstituteDamage(); + else this.doDamage(target); yield this.hud(); if (hungOn === 1) yield* this.say(BATTLE.EnduredText); if (hungOn === 2) { @@ -2809,11 +3002,26 @@ export class Battle { if (this.criticalHit === 2) this.criticalHit = 0; return true; } - /** DoEnemyDamage / DoPlayerDamage. */ - private doDamage(side: Side, ignoreSubstitute: boolean) { + /** DoSubstituteDamage: the substitute takes the hit instead. When it + * breaks, the move's effect is cleared unless it hits several times; + * no damage goes through either way. */ + private *doSubstituteDamage(): Gen { + yield* this.say(BATTLE.SubTookDamageText); + const target = this.target; + const left = target.substituteHp - (this.curDamage & 0xff); + if (this.curDamage > 0xff || left <= 0) { + target.substituteHp = left & 0xff; + target.reset(SUB4, C.SUBSTATUS_SUBSTITUTE); + yield* this.say(BATTLE.SubFadedText); + const effect = this.user.move.effect; + if (![C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP].includes(effect as never)) + this.user.move.effect = 0; + } else target.substituteHp = left; + this.curDamage = 0; + } + /** DoEnemyDamage / DoPlayerDamage, past any substitute. */ + private doDamage(side: Side) { if (!this.curDamage) return; - if (!ignoreSubstitute && side.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) - throw new UnsupportedBattle("Substitutes are not ported yet."); const hp = side.mon.hp; if (this.curDamage > hp) this.curDamage = hp; side.mon.hp = hp - this.curDamage; @@ -2843,7 +3051,7 @@ export class Battle { // SUBSTATUS_IN_LOOP still set; the faint handlers clear it. const effect = this.user.move.effect; if ([C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP].includes(effect as never)) - this.requireNoSubstitute(); // BattleCommand_RaiseSub + this.raiseSub(); this.endMoveEffect(); } private *buildOpponentRage(): Gen { @@ -3192,7 +3400,8 @@ export class Battle { const user = this.user; user.mon.status = 0; user.mon.hp = 0; - this.requireNoSubstitute(); // BattleCommand_LowerSub + this.animParam = 1; + this.lowerSub(); user.reset(SUB4, C.SUBSTATUS_LEECH_SEED); this.target.reset(SUB5, C.SUBSTATUS_DESTINY_BOND); yield this.hud(); @@ -3403,14 +3612,15 @@ export class Battle { target.set(SUB1, C.SUBSTATUS_IN_LOVE); yield* this.say(BATTLE.FellInLoveText); } - /** CheckOppositeGender. */ + /** CheckOppositeGender: the player's party Pokémon against the enemy's + * own species and DVs, even transformed. */ private oppositeGender() { - if (this.enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED)) - throw new UnsupportedBattle("Attract against a transformed Pokémon is not ported yet."); const data = this.options.data, + enemy = this.enemy, player = data.gender(this.partyMon.species, this.partyMon.dvs), - enemy = data.gender(this.enemy.mon.species, this.enemy.mon.dvs); - return player !== undefined && enemy !== undefined && player !== enemy; + dvs = enemy.has(SUB5, C.SUBSTATUS_TRANSFORMED) ? enemy.backupDvs : enemy.mon.dvs, + opponent = data.gender(this.enemySpecies, dvs); + return player !== undefined && opponent !== undefined && player !== opponent; } /** BattleCommand_ResetStats (Haze): every stat level back to normal. */ private *resetStats(): Gen { @@ -3528,13 +3738,398 @@ export class Battle { return this.endMoveEffect(); } user.futureSightCount = 4; - this.requireNoSubstitute(); // BattleCommand_LowerSub and RaiseSub + this.lowerSub(); yield* this.say(BATTLE.ForesawAttackText); + this.raiseSub(); user.futureSightDamage = this.curDamage; this.curDamage = 0; this.endMoveEffect(); } + // ------------------------------------------------ substitutes and copies + + /** BattleCommand_Substitute: a quarter of the maximum HP, which needs + * more HP than that; it also frees the user from a trap. The party + * struct keeps its HP until the next update. */ + private *substitute(): Gen { + const user = this.user, + mon = user.mon; + if (user.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) { + if (user.charging) this.raiseSub(); + return yield* this.say(BATTLE.HasSubstituteText); + } + const quarter = (mon.maxHp >> 2) & 0xff; + user.substituteHp = quarter; + if (mon.hp <= quarter) { + if (user.charging) this.raiseSub(); + return yield* this.say(BATTLE.TooWeakSubText); + } + mon.hp -= quarter; + user.set(SUB4, C.SUBSTATUS_SUBSTITUTE); + user.wrapCount = 0; + user.trappingMove = 0; + if (this.options.battleScene !== false) this.animParam = 0; + yield this.hud(); + yield* this.say(BATTLE.MadeSubstituteText); + } + /** BattleCommand_Transform: the target's species, moves (5 PP each, 1 + * for Sketch), DVs, stats, types, unboosted stats and stat levels. The + * enemy keeps its own DVs in wEnemyBackupDVs. */ + private *transform(): Gen { + const user = this.user, + target = this.target; + user.lastCounterMove = user.lastMove = 0; // ClearLastMove + if (target.has(SUB5, C.SUBSTATUS_TRANSFORMED) || this.hiddenOpponent()) return yield* this.say(BATTLE.ButItFailedText); + this.animParam = 1; + user.set(SUB5, C.SUBSTATUS_TRANSFORMED); + user.disableCount = user.disabledMove = 0; // ResetActorDisable + if (this.turn === 0) user.moveNum = 0; + else user.backupDvs = user.mon.dvs; + const mon = user.mon, + from = target.mon; + mon.species = from.species; + mon.moves = [...from.moves]; + mon.dvs = from.dvs; + mon.stats = [...from.stats]; + mon.types = [...from.types]; + mon.pp = mon.moves.map((move) => (!move ? 0 : move === C.SKETCH ? 1 : 5)); + user.stats = [...target.stats]; + user.levels = [...target.levels]; + this.strings[RAM.stringBuffer1] = this.options.data.speciesName(from.species); + this.animParam = 2; + yield this.hud(); + yield* this.say(BATTLE.TransformedText); + } + /** BattleCommand_Mimic: the target's last move replaces Mimic, with 5 + * PP, for this battle only. */ + private *mimic(): Gen { + const user = this.user, + last = this.target.lastCounterMove; + user.lastCounterMove = user.lastMove = 0; + if (this.attackMissed || this.hiddenOpponent() || !last || last === C.STRUGGLE || user.mon.moves.includes(last)) + return yield* this.failMove(); + const slot = user.mon.moves.lastIndexOf(C.MIMIC); + if (slot < 0) throw new UnsupportedBattle("Mimic without Mimic in the move list is not ported yet."); + user.mon.moves[slot] = last; + user.mon.pp[slot] = 5; + this.strings[RAM.stringBuffer1] = this.options.data.moveName(last); + yield* this.say(BATTLE.MimicLearnedMoveText); + } + /** BattleCommand_Metronome: a random move outside MetronomeExcepts that + * the user does not know. */ + private *metronome(): Gen { + const user = this.user; + user.lastCounterMove = user.lastMove = 0; + if (!user.charging) this.lowerSubKeepingParam(); + const excepts = this.data.byteList(BATTLE.MetronomeExcepts); + for (let attempt = 0; attempt < 4096; attempt++) { + const move = this.random(); + if (move > 251 || excepts.includes(move) || user.mon.moves.includes(move)) continue; + user.curMove = move; + this.updateMoveData(); + return yield* this.resetTurn(); + } + throw new UnsupportedBattle("Metronome random source did not produce a move."); + } + /** BattleCommand_MirrorMove: the target's last move, unless the user + * knows it. */ + private *mirrorMove(): Gen { + const user = this.user, + last = this.target.lastCounterMove; + user.lastCounterMove = user.lastMove = 0; + if (!last || user.mon.moves.includes(last)) { + yield* this.say(BATTLE.MirrorMoveFailedText); + return this.endMoveEffect(); + } + user.curMove = last; + user.move = this.moveStruct(last); + this.strings[RAM.stringBuffer1] = this.strings[RAM.stringBuffer2] = this.options.data.moveName(last); + if (!user.charging) this.lowerSubKeepingParam(); + yield* this.resetTurn(); + } + /** BattleCommand_SleepTalk: a random other move of a sleeping user, not + * disabled or two-turn; it needs at least two moves. */ + private *sleepTalk(): Gen { + const user = this.user, + mon = user.mon, + data = this.options.data; + user.lastCounterMove = user.lastMove = 0; + const twoTurn = (move: number) => + ([C.EFFECT_SKULL_BASH, C.EFFECT_RAZOR_WIND, C.EFFECT_SKY_ATTACK, C.EFFECT_SOLARBEAM, C.EFFECT_FLY, C.EFFECT_BIDE] as number[]).includes( + data.move(move).effect, + ); + const fails = () => (this.alreadyFailed ? [] : this.say(BATTLE.ButItFailedText)); + if (this.attackMissed || !(mon.status & C.SLP_MASK) || !mon.moves[1]) return yield* fails(); + let usable = false; + for (const move of mon.moves) { + if (!move) break; + if (move === user.curMove || move === user.disabledMove) continue; + if (!twoTurn(move)) { + usable = true; + break; + } + } + if (!usable) return yield* fails(); + for (let attempt = 0; attempt < 4096; attempt++) { + const move = mon.moves[this.random() & 3]; + if (!move || move === user.move.anim || move === user.disabledMove || twoTurn(move)) continue; + user.curMove = move; + if (!user.charging) this.lowerSubKeepingParam(); + this.updateMoveData(); + return yield* this.resetTurn(); + } + throw new UnsupportedBattle("Sleep Talk random source did not produce a move."); + } + /** LowerSub while keeping wBattleAnimParam, as the move callers do. */ + private lowerSubKeepingParam() { + const param = this.animParam; + this.lowerSub(); + this.animParam = param; + } + /** ResetTurn: run the called move as if charging (no PP, no last move), + * then end the caller's script. */ + private *resetTurn(): Gen { + this.user.charging = 1; + this.alreadyDisobeyed = 0; + yield* this.doMove(); + this.endMoveEffect(); + } + /** BattleCommand_Conversion: a random type among the user's moves that + * is neither its own nor the ??? type. */ + private *conversion(): Gen { + const user = this.user, + data = this.options.data; + const types: number[] = []; + for (const move of user.mon.moves) { + if (!move) break; + types.push(data.move(move).type); + } + const allowed = (type: number | undefined) => + type !== undefined && type !== C.CURSE_TYPE && !user.mon.types.includes(type); + if (!types.some(allowed)) return yield* this.say(BATTLE.ButItFailedText); + for (let attempt = 0; attempt < 4096; attempt++) { + const type = types[this.random() & 3]; + if (!allowed(type)) continue; + user.mon.types = [type!, type!]; + this.strings[RAM.stringBuffer1] = data.typeName(type!); + return yield* this.say(BATTLE.TransformedTypeText); + } + throw new UnsupportedBattle("Conversion random source did not produce a type."); + } + /** BattleCommand_Conversion2: a random type that resists the target's + * last move. */ + private *conversion2(): Gen { + const user = this.user, + last = this.target.lastCounterMove, + data = this.options.data; + if (this.attackMissed || !last) return yield* this.failMove(); + const moveType = data.move(last).type; + if (moveType === C.CURSE_TYPE) return yield* this.failMove(); + for (let attempt = 0; attempt < 4096; attempt++) { + const type = this.random() & 0x1f; + if (!(type < C.UNUSED_TYPES || (type >= C.UNUSED_TYPES_END && type < C.TYPES_END))) continue; + user.mon.types = [type, type]; + this.matchup = this.typeMatchup(moveType, user.mon.types, user.has(SUB1, C.SUBSTATUS_IDENTIFIED)); + if (this.matchup >= C.EFFECTIVE) continue; + this.strings[RAM.stringBuffer1] = data.typeName(type); + return yield* this.say(BATTLE.TransformedTypeText); + } + throw new UnsupportedBattle("Conversion2 random source did not produce a type."); + } + /** BattleCommand_Sketch: the target's last move replaces Sketch for + * good, in the party struct too (or the wild Pokémon's kept moves). */ + private *sketch(): Gen { + const user = this.user, + target = this.target, + last = target.lastCounterMove, + data = this.options.data; + user.lastCounterMove = user.lastMove = 0; + if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.has(SUB5, C.SUBSTATUS_TRANSFORMED)) + return yield* this.say(BATTLE.DidntAffect1Text); + if (!last || last === C.STRUGGLE || user.mon.moves.includes(last)) return yield* this.say(BATTLE.DidntAffect1Text); + const slot = user.mon.moves.lastIndexOf(C.SKETCH); + if (slot < 0) throw new UnsupportedBattle("Sketch without Sketch in the move list is not ported yet."); + const pp = data.move(last).pp; + user.mon.moves[slot] = last; + user.mon.pp[slot] = pp; + if (this.turn === 1 && this.battleMode === C.WILD_BATTLE) { + this.wildMoves[slot] = last; + this.wildPP[slot] = pp; + } else { + const party = this.turn === 0 ? this.partyMon : this.options.trainer!.party[this.curOTMon]; + party.moves[slot] = last; + party.pp[slot] = pp; + } + this.strings[RAM.stringBuffer1] = data.moveName(last); + yield* this.say(BATTLE.SketchedText); + } + + // ------------------------------------------------------- moves that switch + + /** BattleCommand_BatonPass: the user's substatuses and stat levels go + * to the Pokémon chosen to replace it, which is sent out silently. */ + private *batonPass(): Gen { + if (this.turn === 0) { + if (!this.options.party.some((mon, index) => index !== this.curBattleMon && mon.hp)) + return yield* this.say(BATTLE.ButItFailedText); + this.updateBattleMonInParty(); + const slot = yield* this.forcePickSwitchMon(); + // PassedBattleMonEntrance + this.curPartyMon = this.curBattleMon = slot; + this.addBattleParticipant(); + this.initBattleMon(); + this.applyStatLevelMultipliers(this.player); + this.checkAmuletCoin(); + yield this.hud(); + this.turn = 0; + yield* this.spikesDamage(this.player); + } else { + const trainer = this.options.trainer; + if (this.battleMode === C.WILD_BATTLE || !trainer!.party.some((mon, index) => index !== this.curOTMon && mon.hp)) + return yield* this.say(BATTLE.ButItFailedText); + this.updateEnemyMonInParty(); + this.enemySwitchMonIndex = 0; + yield* this.enemySwitchSetMode(); + this.resetBattleParticipants(); + this.applyStatLevelMultipliers(this.enemy); + this.turn = 1; + yield* this.spikesDamage(this.enemy); + } + // ResetBatonPassStatus + const user = this.user; + if (!(user.mon.status & C.SLP_MASK)) user.reset(SUB1, C.SUBSTATUS_NIGHTMARE); + user.disableCount = user.disabledMove = 0; + this.breakAttraction(); + user.reset(SUB5, C.SUBSTATUS_TRANSFORMED); + user.reset(SUB5, C.SUBSTATUS_ENCORED); + user.lastMove = 0; + this.player.wrapCount = this.enemy.wrapCount = 0; + } + /** ForcePickSwitchMonInBattle: a fit Pokémon other than the one out; + * the menu cannot be left. */ + private *forcePickSwitchMon(): Gen { + for (;;) { + const slot = yield* this.partyMenu(BATTLE.WhichPKMNString, false, false); + if (!(yield* this.checkFitToFight(slot))) continue; + if (slot !== this.curBattleMon) return slot; + yield* this.say(BATTLE.BattleText_MonIsAlreadyOut); + } + } + /** ApplyStatLevelMultiplierOnAllStats: the stat levels on the unboosted + * stats, without the paralysis and burn penalties. */ + private applyStatLevelMultipliers(side: Side) { + for (let stat = 0; stat < 5; stat++) { + const [numerator, denominator] = this.data.statMultiplier(side.levels[stat]); + side.mon.stats[stat] = Math.min(999, Math.floor((side.stats[stat] * numerator) / denominator)) || 1; + } + } + /** The battle types that Roar, Whirlwind and Teleport cannot end. */ + private cannotFlee() { + return ([C.BATTLETYPE_FORCESHINY, C.BATTLETYPE_TRAP, C.BATTLETYPE_CELEBI, C.BATTLETYPE_SUICUNE] as number[]).includes(this.battleType); + } + /** The wild level-based escape: a lower level escapes when a random + * number below the sum of both levels plus 1 reaches a quarter of the + * other's level. */ + private levelEscape(level: number, other: number) { + if (level >= other) return true; + const limit = (level + other + 1) & 0xff; + for (let attempt = 0; attempt < 4096; attempt++) { + const roll = this.random(); + if (roll < limit) return roll >= other >> 2; + } + throw new UnsupportedBattle("Escape random source did not produce a number."); + } + /** The wild level: wCurPartyLevel, which holds the wild Pokémon's. */ + private get wildLevel() { + return this.options.wild?.level ?? this.enemy.mon.level; + } + /** BattleCommand_ForceSwitch: Roar and Whirlwind end a wild battle, or + * drag out a random healthy Pokémon of a trainer's that already moved. */ + private *forceSwitch(): Gen { + const fail = () => { + this.lowerSub(); + this.raiseSub(); + return this.say(BATTLE.ButItFailedText); + }; + if (this.cannotFlee() || this.attackMissed) return yield* fail(); + const player = this.turn === 0; + if (this.battleMode === C.WILD_BATTLE) { + const flee = player + ? this.levelEscape(this.player.mon.level, this.wildLevel) + : this.levelEscape(this.wildLevel, this.player.mon.level); + if (!flee) return yield* fail(); + this.updateBattleMonInParty(); + this.forcedFlee(); + this.animParam = 1; + return yield* this.say(this.user.move.anim === C.ROAR ? BATTLE.FledInFearText : BATTLE.BlownAwayText); + } + if (player) { + const party = this.options.trainer!.party; + if (!party.some((mon, index) => index !== this.curOTMon && mon.hp) || !this.enemyGoesFirst) return yield* fail(); + this.updateEnemyMonInParty(); + this.animParam = 1; + const slot = this.randomSwitch(party, this.curOTMon); + // ForceEnemySwitch + this.enemySwitchMonIndex = slot + 1; + this.loadEnemyMonToSwitchTo(); + this.newEnemyMonStatus(); + this.enemy.levels = [7, 7, 7, 7, 7, 7, 7]; + this.turn = 1; + this.breakAttraction(); + this.resetBattleParticipants(); + yield this.hud(); + yield* this.say(BATTLE.DraggedOutText); + return yield* this.spikesDamage(this.enemy); + } + const party = this.options.party; + if (!party.some((mon, index) => index !== this.curBattleMon && mon.hp) || this.enemyGoesFirst === 1) return yield* fail(); + this.updateBattleMonInParty(); + this.animParam = 1; + // SwitchPlayerMon + this.curPartyMon = this.randomSwitch(party, this.curBattleMon); + this.lastPlayerMon = this.curBattleMon; + this.curBattleMon = this.curPartyMon; + this.addBattleParticipant(); + this.initBattleMon(); + this.player.levels = [7, 7, 7, 7, 7, 7, 7]; + this.newStatus(this.player); + this.breakAttraction(); + this.checkAmuletCoin(); + this.turn = 0; + yield this.hud(); + yield* this.say(BATTLE.DraggedOutText); + yield* this.spikesDamage(this.player); + } + /** ForceSwitch's random pick: a party index (Random & 7) other than the + * current one, with HP. */ + private randomSwitch(party: readonly PartyMon[], current: number) { + for (let attempt = 0; attempt < 4096; attempt++) { + const index = this.random() & 7; + if (index < party.length && index !== current && party[index].hp) return index; + } + throw new UnsupportedBattle("Roar random source did not produce a Pokémon."); + } + /** wForcedSwitch with SetBattleDraw: the wild battle ends at once. */ + private forcedFlee() { + this.battleResult = (this.battleResult & 0xc0) | C.DRAW; + this.forcedSwitch = true; + } + /** BattleCommand_Teleport: escape a wild battle, unless trapped; a wild + * Pokémon always manages it (a kept bug). */ + private *teleport(): Gen { + const fail = () => this.say(BATTLE.ButItFailedText); + if (this.cannotFlee() || this.target.has(SUB5, C.SUBSTATUS_CANT_RUN) || this.battleMode !== C.WILD_BATTLE) + return yield* fail(); + if (this.turn === 0) { + if (!this.levelEscape(this.player.mon.level, this.wildLevel)) return yield* fail(); + } else this.levelEscape(this.wildLevel, this.player.mon.level); + this.updateBattleMonInParty(); + this.forcedFlee(); + this.animParam = 1; + this.lowerSub(); + yield* this.say(BATTLE.FledFromBattleText); + } + /** SkipToBattleCommand: go on after the next `command`. */ private skipTo(command: number) { while (this.script[this.scriptIndex++] !== command) @@ -3560,15 +4155,16 @@ export class Battle { private *charge(): Gen { const user = this.user; if (user.mon.status & C.SLP_MASK) { - this.requireNoSubstitute(); // BattleCommand_RaiseSub + this.raiseSub(); yield* this.say(BATTLE.ButItFailedText); return this.endMoveEffect(); } user.set(SUB3, C.SUBSTATUS_CHARGED); if (this.alreadyDisobeyed) yield* this.say(BATTLE.IgnoredOrders2Text); - this.requireNoSubstitute(); // BattleCommand_LowerSub and RaiseSub + this.lowerSub(); this.animParam = 1; const anim = user.move.anim; + if (anim !== C.FLY && anim !== C.DIG) this.raiseSub(); if (anim === C.FLY) user.set(SUB3, C.SUBSTATUS_FLYING); else if (anim === C.DIG) user.set(SUB3, C.SUBSTATUS_UNDERGROUND); if (!user.charging) { @@ -3811,8 +4407,7 @@ export class Battle { private consumeHeldItem(side: Side) { const effect = this.data.heldEffect(side.mon.item).effect; if (!this.data.byteList(BATTLE.ConsumableEffects).includes(effect)) return; - side.mon.item = 0; - if (side === this.player) this.partyMon.item = 0; + this.removeHeldItem(side); } /** HandleHPHealingItem for the opponent: a Berry below half HP. */ private *handleHPHealingItem(): Gen { @@ -3835,8 +4430,7 @@ export class Battle { side.reset(SUB3, C.SUBSTATUS_CONFUSED); this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item); yield* this.say(BATTLE.BattleText_ItemHealedConfusion); - side.mon.item = 0; - if (side === this.player) this.partyMon.item = 0; + this.removeHeldItem(side); } private *poisonTarget(): Gen { const target = this.target; @@ -4028,7 +4622,8 @@ export class Battle { if (mon.hp === mon.maxHp) return yield* this.say(BATTLE.HPIsFullText); let index = 2; if (this.options.timeOfDay !== time) index--; - if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); + // Twice as much in the sun, half in rain or a sandstorm. + if (this.weather) index += this.weather === C.WEATHER_SUN ? 1 : -1; const quarter = mon.maxHp >> 2 || 1; const amount = [quarter >> 1 || 1, quarter, mon.maxHp >> 1 || 1, mon.maxHp][index]; mon.hp = Math.min(mon.maxHp, mon.hp + amount); @@ -4185,6 +4780,72 @@ export class Battle { // ------------------------------------------------------- turn ending + /** HandleBerserkGene, before the menu and AI. Confusion keeps its old + * count; zero therefore wraps to 255 on the first confused turn. */ + private *handleBerserkGene(): Gen { + for (const [turn, side] of [this.player, this.enemy].entries()) { + if (side.mon.item !== C.BERSERK_GENE) continue; + this.turn = turn; + this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item); + this.removeHeldItem(side); + const confused = side.has(SUB3, C.SUBSTATUS_CONFUSED); + side.set(SUB3, C.SUBSTATUS_CONFUSED); + const anim = side.move.anim; + side.move.anim = 0; + this.attackMissed = this.effectFailed = 0; + this.statUp(C.ATTACK | 0x10); + side.move.anim = anim; + yield* this.say(BATTLE.BattleText_UsersStringBuffer1Activated); + yield* this.statUpMessage(); + if (!confused) { + this.turn ^= 1; + yield* this.say(BATTLE.BecameConfusedText); + } + } + } + /** Item removal used by routines which consume unconditionally. */ + private removeHeldItem(side: Side) { + side.mon.item = 0; + if (side === this.player) this.partyMon.item = 0; + else if (this.options.trainer) this.options.trainer.party[this.curOTMon].item = 0; + } + /** HandleMysteryberry checks the saved move list and PP before the + * battle copy, so Transform and Mimic do not replenish copied moves. */ + private *handleMysteryBerry(): Gen { + for (const [turn, side] of [this.player, this.enemy].entries()) { + if (this.data.heldEffect(side.mon.item).effect !== C.HELD_RESTORE_PP) continue; + this.turn = turn; + const stored = turn === 0 ? this.partyMon : this.options.trainer?.party[this.curOTMon]; + const moves = stored?.moves ?? this.wildMoves, pp = stored?.pp ?? this.wildPP; + for (let slot = 0; slot < C.NUM_MOVES && moves[slot]; slot++) { + if (pp[slot] & C.PP_MASK) continue; + const amount = moves[slot] === C.SKETCH ? 1 : 5; + pp[slot] = (pp[slot] + amount) & 255; + if (moves[slot] === side.mon.moves[slot] && !side.has(SUB5, C.SUBSTATUS_TRANSFORMED)) + side.mon.pp[slot] = (side.mon.pp[slot] + amount) & 255; + this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item); + this.removeHeldItem(side); + yield* this.say(BATTLE.BattleText_UserRecoveredPPUsing); + break; + } + } + } + /** HandleStatBoostingHeldItems. No item in the pinned ROM uses these + * effects; retain their failure checks and their place between turns. */ + private *handleStatBoostingHeldItems(): Gen { + for (const [turn, side] of [this.player, this.enemy].entries()) { + const effect = this.data.heldEffect(side.mon.item).effect; + if (effect < C.HELD_ATTACK_UP || effect > C.HELD_EVASION_UP) continue; + this.turn = turn; + this.statUp(effect - C.HELD_ATTACK_UP); + if (this.failedMessage) continue; + this.strings[RAM.stringBuffer1] = this.options.data.itemName(side.mon.item); + this.removeHeldItem(side); + yield* this.say(BATTLE.BattleText_UsersStringBuffer1Activated); + yield* this.statUpMessage(); + } + } + /** ResidualDamage for the side whose turn it is: poison and burn (Toxic * a growing number of sixteenths), Leech Seed, Nightmare and Curse. * Returns false if it fainted. */ @@ -4232,21 +4893,17 @@ export class Battle { side.mon.hp = Math.max(0, side.mon.hp - amount); if (side === this.player) this.updateBattleMonInParty(); } - /** HandleBetweenTurnEffects. Effects no early move can cause are refused. */ + /** HandleBetweenTurnEffects in non-Link order. */ private *handleBetweenTurnEffects(): Gen { if (yield* this.checkFaintPlayerThenEnemy()) return; yield* this.handleFutureSight(); if (yield* this.checkFaintPlayerThenEnemy()) return; - if (this.weather) throw new UnsupportedBattle("Weather is not ported yet."); + yield* this.handleWeather(); + if (yield* this.checkFaintPlayerThenEnemy()) return; yield* this.handleWrap(); if (yield* this.checkFaintPlayerThenEnemy()) return; yield* this.handlePerishSong(); if (yield* this.checkFaintPlayerThenEnemy()) return; - for (const side of [this.player, this.enemy]) { - const effect = this.data.heldEffect(side.mon.item).effect; - if (effect === C.HELD_RESTORE_PP || (effect >= C.HELD_ATTACK_UP && effect <= C.HELD_EVASION_UP)) - throw new UnsupportedBattle("Mystery Berry and stat-raising held items are not ported yet."); - } // HandleLeftovers: each side heals itself; the text names it as target. for (const side of [this.player, this.enemy]) { const mon = side.mon; @@ -4259,6 +4916,7 @@ export class Battle { yield this.hud(); yield* this.say(BATTLE.BattleText_TargetRecoveredWithItem); } + yield* this.handleMysteryBerry(); // HandleDefrost: a 10% thaw for a side that did not just freeze. for (const side of [this.player, this.enemy]) { if (!(side.mon.status & (1 << C.FRZ)) || side.justGotFrozen) continue; @@ -4271,6 +4929,7 @@ export class Battle { } yield* this.handleSafeguard(); yield* this.handleScreens(); + yield* this.handleStatBoostingHeldItems(); // HandleHealingItems: turn 0 handles the enemy's items, then turn 1. for (const turn of [0, 1]) { this.turn = turn; @@ -4350,6 +5009,27 @@ export class Battle { this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 1 || 1); yield this.hud(); } + /** HandleWeather: the weather's message, or its end after five turns; + * a sandstorm hurts all but Rock, Ground, Steel and digging Pokémon. */ + private *handleWeather(): Gen { + if (!this.weather) return; + const index = this.weather - 1; + if (!--this.weatherCount) { + yield* this.say([BATTLE.BattleText_TheRainStopped, BATTLE.BattleText_TheSunlightFaded, BATTLE.BattleText_TheSandstormSubsided][index]); + this.weather = C.WEATHER_NONE; + return; + } + yield* this.say([BATTLE.BattleText_RainContinuesToFall, BATTLE.BattleText_TheSunlightIsStrong, BATTLE.BattleText_TheSandstormRages][index]); + if (this.weather !== C.WEATHER_SANDSTORM) return; + for (const [turn, side] of [this.player, this.enemy].entries()) { + this.turn = turn; + if (side.has(SUB3, C.SUBSTATUS_UNDERGROUND)) continue; + if (([C.ROCK, C.GROUND, C.STEEL] as number[]).some((type) => this.isType(side, type))) continue; + this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 1 || 1); + yield this.hud(); + yield* this.say(BATTLE.SandstormHitsText); + } + } /** HandleWrap: a trapped side loses 1/16 of its maximum HP, or is * released on its last turn. A substitute shields it and stops the * count. */ @@ -5020,15 +5700,30 @@ export class Battle { if (!this.player.mon.hp && !playerFaintedFirst) this.updateFaintedPlayerMon(); if (!this.options.party.some((mon) => mon.hp > 0)) return; this.battleResult &= 0xc0; // WIN - if (this.options.party.some((mon) => mon.hp > 0 && mon.item === C.EXP_SHARE)) - throw new UnsupportedBattle("Exp. Share is not ported yet."); - yield* this.giveExperience(); + const holders = () => this.options.party.reduce( + (bits, mon, index) => bits | (mon.hp > 0 && mon.item === C.EXP_SHARE ? 1 << index : 0), 0, + ); + // Halve the bytes first, then divide each pool among its recipients. + const shared = Boolean(holders()); + yield* this.giveExperience(shared); + const recipients = holders(); + if (recipients) { + const participants = this.participants; + this.participants = recipients; + yield* this.giveExperience(shared); + this.participants = participants; + } } - private *giveExperience(): Gen { + private *giveExperience(shared = false): Gen { const party = this.options.party, data = this.options.data; const count = [0, 1, 2, 3, 4, 5].filter((index) => this.participants & (1 << index)).length; const base = { ...this.enemyBase, stats: [...this.enemyBase.stats] }; + if (shared) { + base.stats = base.stats.map((value) => value >> 1); + base.catchRate >>= 1; + base.exp >>= 1; + } if (count >= 2) { base.stats = base.stats.map((value) => Math.floor(value / count)); base.catchRate = Math.floor(base.catchRate / count); diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts index 2db7f29..7312f16 100644 --- a/src/game/battle/constants.ts +++ b/src/game/battle/constants.ts @@ -105,6 +105,8 @@ export const C = { SKULL_BASH: 130, SKY_ATTACK: 143, WHIRLPOOL: 250, + ROAR: 46, + TRANSFORM: 144, MIRROR_MOVE: 119, REST: 156, ENCORE: 227, @@ -131,11 +133,16 @@ export const C = { NORMAL: 0, FLYING: 2, POISON: 3, + ROCK: 5, + GROUND: 4, + STEEL: 9, BIRD: 6, GHOST: 8, GRASS: 22, UNUSED_TYPES: 10, UNUSED_TYPES_END: 20, + TYPES_END: 28, + CURSE_TYPE: 19, FIRE: 20, SPECIAL: 20, ICE: 25, @@ -156,6 +163,7 @@ export const C = { POKE_BALL: 5, LUCKY_PUNCH: 30, METAL_POWDER: 35, + BERSERK_GENE: 0x98, STICK: 105, THICK_CLUB: 118, EXP_SHARE: 57, @@ -387,10 +395,13 @@ export const COMMAND = { freezetarget: 24, paralyzetarget: 25, selfdestruct: 26, + mirrormove: 27, payday: 30, + conversion: 31, resetstats: 32, storeenergy: 33, unleashenergy: 34, + forceswitch: 35, endloop: 36, flinchtarget: 37, ohko: 38, @@ -400,10 +411,14 @@ export const COMMAND = { confuse: 42, confusetarget: 43, heal: 44, + transform: 45, screen: 46, poison: 47, paralyze: 48, + substitute: 49, rechargenextturn: 50, + mimic: 51, + metronome: 52, leechseed: 53, splash: 54, disable: 55, @@ -417,7 +432,11 @@ export const COMMAND = { counter: 64, encore: 65, painsplit: 66, + snore: 67, + conversion2: 68, lockon: 69, + sketch: 70, + sleeptalk: 72, destinybond: 73, spite: 74, falseswipe: 75, @@ -434,6 +453,7 @@ export const COMMAND = { spikes: 86, foresight: 87, perishsong: 88, + startsandstorm: 89, endure: 90, checkrollout: 91, rolloutpower: 92, @@ -446,12 +466,15 @@ export const COMMAND = { safeguard: 100, checksafeguard: 101, getmagnitude: 102, + batonpass: 103, pursuit: 104, clearhazards: 105, healmorn: 106, healday: 107, healnite: 108, hiddenpower: 109, + startrain: 110, + startsun: 111, attackup: 112, defenseup: 113, speedup: 114, @@ -500,6 +523,7 @@ export const COMMAND = { doubleminimizedamage: 157, skipsuncharge: 158, thunderaccuracy: 159, + teleport: 160, beatup: 161, ragedamage: 162, resettypematchup: 163, diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index 061f3d4..ddd7a06 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -584,6 +584,33 @@ export const MENU = { /** Battle engine tables and texts, keyed by their PRET labels so that * \`npm run check:reference\` can verify each address by name. */ 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, EvadedText: 0x81407, LeechSeedSapsText: 0x8080e, @@ -711,6 +738,15 @@ export const BATTLE = { BattleText_MonFainted: 0x80a75, BattleText_MonHasNoMovesLeft: 0x80c72, 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_TheMoveIsDisabled: 0x80c5b, BattleText_TheresNoPPLeftForThisMove: 0x80c39, diff --git a/src/game/world/world.ts b/src/game/world/world.ts index 821d360..6fd62ad 100644 --- a/src/game/world/world.ts +++ b/src/game/world/world.ts @@ -2446,6 +2446,9 @@ export class GameWorld { pokedex: draft.pokedex, playerId: this.playerId, 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, random: () => this.random() & 0xff, wild: effect.wild, diff --git a/tests/battle-browser.spec.ts b/tests/battle-browser.spec.ts index 0bd1873..7edc6cf 100644 --- a/tests/battle-browser.spec.ts +++ b/tests/battle-browser.spec.ts @@ -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(); else { 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()) { await menu.getByRole("button", { name: "YES", exact: true }).click(); diff --git a/tests/battle-fixture.ts b/tests/battle-fixture.ts index 0f78c27..2db1117 100644 --- a/tests/battle-fixture.ts +++ b/tests/battle-fixture.ts @@ -24,7 +24,9 @@ export function battleFixtureWorld(assets: CrystalAssets, scenario: BattleScenar mon.exp = 178; // One battle away from level 6 and Smokescreen. if (scenario === "loss") mon.hp = 1; 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]; } world.state.party.push(mon); diff --git a/tests/battle-held-items.test.ts b/tests/battle-held-items.test.ts new file mode 100644 index 0000000..4f16e15 --- /dev/null +++ b/tests/battle-held-items.test.ts @@ -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 = {}) => { + 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 = {}) => 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) => { + 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; + updateMoveData(): void; + handleMysteryBerry(): Generator; + handleStatBoostingHeldItems(): Generator; + 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 = {}, 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); + }); +}); diff --git a/tests/move-effects.test.ts b/tests/move-effects.test.ts index 500371e..352fd75 100644 --- a/tests/move-effects.test.ts +++ b/tests/move-effects.test.ts @@ -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]); diff --git a/tests/reference-held-items.spec.ts b/tests/reference-held-items.spec.ts new file mode 100644 index 0000000..54208dd --- /dev/null +++ b/tests/reference-held-items.spec.ts @@ -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([]); +}); diff --git a/tests/reference-move-effects.spec.ts b/tests/reference-move-effects.spec.ts index 4557d1a..ea419ac 100644 --- a/tests/reference-move-effects.spec.ts +++ b/tests/reference-move-effects.spec.ts @@ -51,6 +51,7 @@ type Case = { beatUpHit: number; playerIsSwitching: number; enemyIsSwitching: number; + weather?: number; /** A battle script and the index of the command after the one run. */ script: number[]; scriptIndex: number; @@ -142,6 +143,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) { enemyGoesFirst: at("wEnemyGoesFirst"), animParam: at("wBattleAnimParam"), battleMode: at("wBattleMode"), + weather: at("wBattleWeather"), partyCount: at("wPartyCount"), otPartyCount: at("wOTPartyCount"), beatUpHit: at("wBeatUpHitAtLeastOnce"), @@ -257,6 +259,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) { beatUpHit: input.beatUpHit, playerIsSwitching: input.playerIsSwitching, enemyIsSwitching: input.enemyIsSwitching, + weather: input.weather ?? 0, script: [...input.script], scriptIndex: input.scriptIndex, }); @@ -269,6 +272,7 @@ async function compare(page: Page, routines: string[], cases: Case[]) { write(addresses.enemyGoesFirst, input.enemyGoesFirst); write(addresses.animParam, input.animParam); write(addresses.battleMode, input.battleMode); + write(addresses.weather, input.weather ?? 0); write(addresses.partyCount, input.partyCount); write(addresses.otPartyCount, input.otPartyCount); 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); }); + +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); +}); diff --git a/tests/route30.test.ts b/tests/route30.test.ts index 087c5c5..e4db188 100644 --- a/tests/route30.test.ts +++ b/tests/route30.test.ts @@ -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", () => { 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]; const before = world.save(); world.move("up");