diff --git a/docs/PLAN.md b/docs/PLAN.md index 5f6d868..2fc61d2 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -274,6 +274,14 @@ The numbered sections below keep their full scope. Each one opens with a checkli - Pursuit doubles against a Pokémon being withdrawn, and `PursuitSwitch` runs it before the player's or the trainer's switch; the Pursuer then cannot move. A withdrawn Pokémon fainting this way keeps its status and gets no faint handling (a kept bug). - The reference comparisons also cover `RolloutPower`, `FuryCutter`, `CheckRampage`, `Rampage`, `CheckRollout`, `CheckCharge`, `Pursuit` and `CheckHit` with Fly, Dig and Lock-On; six deliberate mutations each failed. The fixture's sentinel moved off `wPlayerScreens` (`$c6ff`), which it had been zeroing. +## Implemented: status and field moves + +- Toxic (one more sixteenth each turn), Leech Seed (the seeder regains what is drained; not on Grass types), Nightmare and Curse (a Ghost type halves its HP to curse; others trade Speed for Attack and Defense, or say "ABILITY won't rise anymore!" at the top) hurt in `ResidualDamage`'s order. +- Protect, Detect and Endure (`ProtectChance`: only for the side moving first, halving per use in a row), Destiny Bond, Spite (the PP goes in the party struct, or the wild Pokémon's kept PP), Heal Bell (all six party slots), Disable, Encore (the target switches to its last move at once if it has not moved; FIGHT keeps an encored player's move), Attract with the 50% immobility, Mist, Focus Energy, Haze, Recover, Softboiled, Milk Drink and Rest, Mean Look and Spider Web, Pain Split (the party structs keep their HP until the next update), Belly Drum (two stages stay when it fails, a kept bug), Psych Up, Safeguard, Perish Song, Spikes, Rapid Spin, AncientPower and Future Sight. +- `HandleBetweenTurnEffects` runs Future Sight, trapping and Perish Song with the faint checks between them, then Safeguard, and ends with `HandleEncore`. Spikes hurt Pokémon sent out after a faint or by the player's switch; the trainer AI's own switch skips them in single play, as in the original. A trainer's last Perish count makes the AI switch at the highest chance. +- 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. + ## 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/src/game/battle/battle.ts b/src/game/battle/battle.ts index 5849c27..bc02bfd 100644 --- a/src/game/battle/battle.ts +++ b/src/game/battle/battle.ts @@ -86,6 +86,11 @@ class Side { lastCounterMove = 0; minimized = 0; justGotFrozen = 0; + perishCount = 0; + safeguardCount = 0; + /** wFutureSightCount and the damage Future Sight stored. */ + futureSightCount = 0; + futureSightDamage = 0; has(byte: number, bit: number) { return Boolean(this.sub[byte] & (1 << bit)); } @@ -143,8 +148,9 @@ export type BattleOutcome = { export interface BattleTrainer { name: string; className: string; - /** wOTPartyMons: HP and status change only when a Pokémon faints or is - * withdrawn, as in the original. */ + /** wOTPartyMons: level, status and HP follow the Pokémon out only where + * the original updates them (UpdateEnemyMonInParty: status changes and + * heals, a faint or a withdrawal); damage alone leaves them stale. */ party: PartyMon[]; aiFlags: number; /** TrainerClassAttributes' AI item/switch word (SWITCH_* in the low byte). */ @@ -558,7 +564,7 @@ export class Battle { /** ParsePlayerAction skips MoveSelectionScreen: FIGHT goes on with the * current move, as while biding. */ get fightKeepsMove() { - return this.player.has(SUB3, C.SUBSTATUS_BIDE); + return this.player.has(SUB3, C.SUBSTATUS_BIDE) || this.player.has(SUB5, C.SUBSTATUS_ENCORED); } // ------------------------------------------------------------- messages @@ -1136,8 +1142,8 @@ export class Battle { continue; } this.battlePlayerAction = 0; - // ParsePlayerAction: Bide goes on without MoveSelectionScreen. - if (this.player.has(SUB3, C.SUBSTATUS_BIDE)) return false; + // ParsePlayerAction: Bide and Encore go on without MoveSelectionScreen. + if (this.fightKeepsMove) return false; if (!(yield* this.moveSelection(input.move))) continue; return false; } @@ -1718,9 +1724,10 @@ export class Battle { const player = this.player; // A locked-in or biding Pokémon keeps last turn's move and move struct. if (this.checkPlayerLockedIn()) return this.playerLockedIn(); - if (player.has(SUB5, C.SUBSTATUS_ENCORED)) - throw new UnsupportedBattle("Encore is not ported yet."); - if (this.battlePlayerAction === C.BATTLEPLAYERACTION_SWITCH) { + // An encored Pokémon takes its last move, whatever the action. + const encored = player.has(SUB5, C.SUBSTATUS_ENCORED); + if (encored) player.curMove = player.lastMove; + else if (this.battlePlayerAction === C.BATTLEPLAYERACTION_SWITCH) { // PlayerSwitch already ran ParseEnemyAction. player.furyCutterCount = 0; player.protectCount = 0; @@ -1728,8 +1735,8 @@ export class Battle { player.reset(SUB4, C.SUBSTATUS_RAGE); return true; } - if (this.battlePlayerAction === 0) { - if (player.has(SUB3, C.SUBSTATUS_BIDE)) return this.playerLockedIn(); + if (encored || this.battlePlayerAction === 0) { + if (!encored && player.has(SUB3, C.SUBSTATUS_BIDE)) return this.playerLockedIn(); this.turn = 0; this.updateMoveData(); player.charging = 0; @@ -1762,9 +1769,8 @@ export class Battle { if (this.enemyIsSwitching) return; const enemy = this.enemy, mon = enemy.mon; - if (enemy.has(SUB5, C.SUBSTATUS_ENCORED)) - throw new UnsupportedBattle("Encore is not ported yet."); - if (this.checkEnemyLockedIn()) { + const encored = enemy.has(SUB5, C.SUBSTATUS_ENCORED); + if (!encored && this.checkEnemyLockedIn()) { // ResetVarsForSubstatusRage: the move and its struct stay. enemy.furyCutterCount = 0; enemy.protectCount = 0; @@ -1772,14 +1778,16 @@ export class Battle { enemy.reset(SUB4, C.SUBSTATUS_RAGE); return; } - let move: number = C.STRUGGLE; - const usable = mon.moves.some( + let move: number = encored ? enemy.lastMove : C.STRUGGLE; + const usable = !encored && mon.moves.some( (candidate, slot) => candidate && candidate !== enemy.disabledMove && mon.pp[slot] & C.PP_MASK, ); - if (this.options.trainer && usable) { + if (encored) { + // The move number stays the AI's or last turn's choice. + } else if (this.options.trainer && usable) { move = enemy.curMove; } else for (let slot = 0; slot < 4; slot++) { @@ -1999,6 +2007,10 @@ export class Battle { yield* this.checkTurn(); if (this.turnEnded) return; this.updateMoveData(); + yield* this.doMove(); + } + /** DoMove: run the user's move effect script. */ + private *doMove(): Gen { this.script = this.data.effectScript(this.user.move.effect); this.scriptIndex = 0; for (let steps = 0; steps < 256; steps++) { @@ -2040,7 +2052,7 @@ export class Battle { if (awake) { yield* this.say(BATTLE.WokeUpText); this.cantMove(); - if (this.turn === 0) this.updateBattleMonInParty(); + this.updateUserInParty(); yield this.hud(); user.reset(SUB1, C.SUBSTATUS_NIGHTMARE); } else { @@ -2085,8 +2097,14 @@ export class Battle { } } } - if (user.has(SUB1, C.SUBSTATUS_IN_LOVE)) - throw new UnsupportedBattle("Attraction is not ported yet."); + if (user.has(SUB1, C.SUBSTATUS_IN_LOVE)) { + yield* this.say(BATTLE.InLoveWithText); + if (this.random() >= percent(50) + 1) { + yield* this.say(BATTLE.InfatuationText); + this.cantMove(); + return yield* this.endTurn(); + } + } if (user.disabledMove && user.disabledMove === user.curMove) { user.reset(SUB3, C.SUBSTATUS_CHARGED); this.strings[RAM.stringBuffer1] = this.options.data.moveName(user.curMove); @@ -2275,6 +2293,82 @@ export class Battle { return yield* this.trapTarget(); case COMMAND.lockon: return yield* this.lockOn(); + case COMMAND.leechseed: + return yield* this.leechSeed(); + case COMMAND.nightmare: + return yield* this.nightmare(); + case COMMAND.curse: + return yield* this.curse(); + case COMMAND.destinybond: + this.user.set(SUB5, C.SUBSTATUS_DESTINY_BOND); + return yield* this.say(BATTLE.DestinyBondEffectText); + case COMMAND.protect: + case COMMAND.endure: + return yield* this.protect(command === COMMAND.endure); + case COMMAND.spite: + return yield* this.spite(); + case COMMAND.healbell: + return yield* this.healBell(); + case COMMAND.disable: + return yield* this.disable(); + case COMMAND.encore: + return yield* this.encore(); + case COMMAND.attract: + return yield* this.attract(); + case COMMAND.mist: + case COMMAND.focusenergy: { + const mist = command === COMMAND.mist, + bit = mist ? C.SUBSTATUS_MIST : C.SUBSTATUS_FOCUS_ENERGY; + if (this.user.has(SUB4, bit)) return yield* this.say(BATTLE.ButItFailedText); + this.user.set(SUB4, bit); + return yield* this.say(mist ? BATTLE.MistText : BATTLE.GettingPumpedText); + } + case COMMAND.resetstats: + return yield* this.resetStats(); + case COMMAND.heal: + return yield* this.heal(); + case COMMAND.arenatrap: + // Mean Look marks the user: its opponent can't run or switch. + if (this.hiddenOpponent() || this.user.has(SUB5, C.SUBSTATUS_CANT_RUN)) + return yield* this.say(BATTLE.ButItFailedText); + this.user.set(SUB5, C.SUBSTATUS_CANT_RUN); + return yield* this.say(BATTLE.CantEscapeNowText); + case COMMAND.painsplit: + return yield* this.painSplit(); + case COMMAND.bellydrum: + return yield* this.bellyDrum(); + case COMMAND.psychup: + return yield* this.psychUp(); + case COMMAND.safeguard: { + const user = this.user; + if (user.screens & (1 << C.SCREENS_SAFEGUARD)) return yield* this.say(BATTLE.ButItFailedText); + user.screens |= 1 << C.SCREENS_SAFEGUARD; + user.safeguardCount = 5; + return yield* this.say(BATTLE.CoveredByVeilText); + } + case COMMAND.perishsong: + return yield* this.perishSong(); + case COMMAND.spikes: { + // Spikes go on the target's side. + const target = this.target; + if (target.screens & (1 << C.SCREENS_SPIKES)) return yield* this.failMove(); + target.screens |= 1 << C.SCREENS_SPIKES; + return yield* this.say(BATTLE.SpikesText); + } + case COMMAND.clearhazards: + return yield* this.clearHazards(); + case COMMAND.allstatsup: + // AncientPower: each stat in turn, without a failure text. + for (let stat = C.ATTACK; stat <= C.SP_DEFENSE; stat++) { + this.attackMissed = 0; + this.statUp(stat); + yield* this.statUpMessage(); + } + return; + case COMMAND.checkfuturesight: + return this.checkFutureSight(); + case COMMAND.futuresight: + return yield* this.futureSight(); case COMMAND.pursuit: // Double damage against a Pokémon being withdrawn. if (this.turn === 0 ? this.enemyIsSwitching : this.playerIsSwitching) @@ -2398,11 +2492,29 @@ export class Battle { } user.mon.pp[slot]--; if (user.has(SUB5, C.SUBSTATUS_TRANSFORMED)) return; - if (this.turn === 0) { - // The party copy loses PP too, unless the move came from Mimic. - if (user.curMove === C.MIMIC || this.partyMon.moves[slot] !== C.MIMIC) this.partyMon.pp[slot]--; + // The party struct loses the PP too, or a wild Pokémon's wWildMonPP, + // which it keeps if caught; not for a move learnt by Mimic + // (CheckMimicUsed). + const party = + this.turn === 0 + ? this.partyMon + : this.battleMode !== C.WILD_BATTLE + ? this.options.trainer!.party[this.curOTMon] + : undefined; + if (party) { + if (user.curMove !== C.MIMIC && party.moves[slot] === C.MIMIC) return; + this.consumePP(party.pp, slot); + } else { + if (user.mon.moves[slot] !== C.MIMIC && this.wildMoves[slot] === C.MIMIC) return; + this.consumePP(this.wildPP, slot); } } + /** DoTurn's .consume_pp on a party struct, which keeps step with the + * battle struct here. */ + private consumePP(pp: number[], slot: number) { + if (!(pp[slot] & C.PP_MASK)) throw new UnsupportedBattle("Party PP out of step with the battle's."); + pp[slot]--; + } private critical() { this.criticalHit = 0; @@ -2673,14 +2785,15 @@ export class Battle { private *applyDamage(): Gen { const target = this.target; let hungOn = 0; - if (target.has(SUB1, C.SUBSTATUS_ENDURE)) throw new UnsupportedBattle("Endure is not ported yet."); const item = this.data.heldEffect(target.mon.item); - if (item.effect === C.HELD_FOCUS_BAND && this.random() < item.parameter) + if (target.has(SUB1, C.SUBSTATUS_ENDURE)) hungOn = this.falseSwipe() ? 1 : 0; + else if (item.effect === C.HELD_FOCUS_BAND && this.random() < item.parameter) 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); yield this.hud(); + if (hungOn === 1) yield* this.say(BATTLE.EnduredText); if (hungOn === 2) { this.strings[RAM.stringBuffer1] = this.options.data.itemName(target.mon.item); yield* this.say(BATTLE.HungOnText); @@ -2719,8 +2832,13 @@ export class Battle { /** BattleCommand_CheckFaint: end the move if the target fainted. */ private *checkFaint(): Gen { if (this.target.mon.hp) return; - if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND)) - throw new UnsupportedBattle("Destiny Bond is not ported yet."); + if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND)) { + // The user faints too, and the move ends there. + yield* this.say(BATTLE.TookDownWithItText); + this.user.mon.hp = 0; + yield this.hud(); + return this.endMoveEffect(); + } // A multi-hit move stops here, without "Hit n times!" and with // SUBSTATUS_IN_LOOP still set; the faint handlers clear it. const effect = this.user.move.effect; @@ -3080,6 +3198,343 @@ export class Battle { yield this.hud(); } + // ------------------------------------------------- status and field moves + + /** CheckHiddenOpponent: the target is flying or underground. */ + private hiddenOpponent() { + return Boolean(this.target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND))); + } + /** FailMove: "But it failed!", or "It failed!" against Protect. */ + private *failMove(): Gen { + yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? BATTLE.ItFailedText : BATTLE.ButItFailedText); + } + /** The slot of the move an opponent last used. The original searches on + * past the move list when the move is not there (after Metronome, for + * instance), which is refused. */ + private moveSlot(side: Side, move: number) { + const slot = side.mon.moves.indexOf(move); + if (slot < 0) throw new UnsupportedBattle("A move that is not in the move list is not ported yet."); + return slot; + } + /** BattleCommand_LeechSeed: not on a Grass type or a substitute. */ + private *leechSeed(): Gen { + const target = this.target; + if (this.attackMissed || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.say(BATTLE.EvadedText); + if (this.isType(target, C.GRASS)) return yield* this.say(BATTLE.DoesntAffectText); + if (target.has(SUB4, C.SUBSTATUS_LEECH_SEED)) return yield* this.say(BATTLE.EvadedText); + target.set(SUB4, C.SUBSTATUS_LEECH_SEED); + yield* this.say(BATTLE.WasSeededText); + } + /** BattleCommand_Nightmare: only on a sleeping target in reach. */ + private *nightmare(): Gen { + const target = this.target; + if ( + this.hiddenOpponent() || + target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || + !(target.mon.status & C.SLP_MASK) || + target.has(SUB1, C.SUBSTATUS_NIGHTMARE) + ) + return yield* this.say(BATTLE.ButItFailedText); + target.set(SUB1, C.SUBSTATUS_NIGHTMARE); + yield* this.say(BATTLE.StartedNightmareText); + } + /** BattleCommand_Curse: a Ghost type halves its HP to curse the target; + * others lower their Speed and raise Attack and Defense. */ + private *curse(): Gen { + const user = this.user, + target = this.target; + if (this.isType(user, C.GHOST)) { + if (this.hiddenOpponent() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.has(SUB1, C.SUBSTATUS_CURSE)) + return yield* this.say(BATTLE.ButItFailedText); + target.set(SUB1, C.SUBSTATUS_CURSE); + this.subtractHP(user, user.mon.maxHp >> 1 || 1); + this.updateUserInParty(); + yield this.hud(); + return yield* this.say(BATTLE.PutACurseText); + } + if (user.levels[C.ATTACK] >= C.MAX_STAT_LEVEL && user.levels[C.DEFENSE] >= C.MAX_STAT_LEVEL) { + // GetStatName's eighth name: "ABILITY won't rise anymore!" + this.loweredStat = C.ABILITY; + this.statName(); + return yield* this.say(BATTLE.WontRiseAnymoreText); + } + this.animParam = 1; + this.lowerOwnStat(C.SPEED); + this.turn ^= 1; + yield* this.statDownMessage(); + this.attackMissed = 0; + this.turn ^= 1; + this.statUp(C.ATTACK); + yield* this.statUpMessage(); + this.attackMissed = 0; + this.statUp(C.DEFENSE); + yield* this.statUpMessage(); + } + /** LowerStat: a stat of the side whose turn it is, without Mist or a + * random failure. */ + private lowerOwnStat(stat: number) { + this.loweredStat = stat; + const side = this.user, + index = stat & 0xf; + let level = side.levels[index] - 1; + if (!level) { + this.failedMessage = 2; + return; + } + if (stat & 0xf0) level = level - 1 || 1; + side.levels[index] = level; + if (index < C.ACCURACY) { + if (side.mon.stats[index] === 1) { + side.levels[index] = level + 1; + this.failedMessage = 2; + return; + } + this.calcStats(side); + } + this.failedMessage = 0; + } + /** BattleCommand_Protect and BattleCommand_Endure (ProtectChance): fails + * for the side that moves last or behind a substitute; each use in a row + * halves the chance. */ + private *protect(endure: boolean): Gen { + const user = this.user; + let chance = 0xff; + let failed = Boolean(this.enemyGoesFirst ^ this.turn) || user.has(SUB4, C.SUBSTATUS_SUBSTITUTE); + for (let count = user.protectCount; !failed && count; count--) { + chance >>= 1; + if (!chance) failed = true; + } + if (!failed) { + let roll: number; + do roll = this.random(); + while (!roll); + failed = roll - 1 >= chance; + } + if (failed) { + user.protectCount = 0; + return yield* this.say(BATTLE.ButItFailedText); + } + user.protectCount = (user.protectCount + 1) & 0xff; + user.set(SUB1, endure ? C.SUBSTATUS_ENDURE : C.SUBSTATUS_PROTECT); + yield* this.say(endure ? BATTLE.BracedItselfText : BATTLE.ProtectedItselfText); + } + /** BattleCommand_Spite: 2-5 PP off the target's last move, in its party + * struct too unless it is transformed (a wild Pokémon's kept PP). */ + private *spite(): Gen { + const target = this.target, + last = target.lastCounterMove; + if (this.attackMissed || !last || last === C.STRUGGLE) return yield* this.didntAffect2(); + const slot = this.moveSlot(target, last), + pp = target.mon.pp[slot]; + if (!(pp & C.PP_MASK)) return yield* this.didntAffect2(); + this.strings[RAM.stringBuffer1] = this.options.data.moveName(last); + const amount = Math.min(pp & C.PP_MASK, (this.random() & 3) + 2); + target.mon.pp[slot] = pp - amount; + if (!target.has(SUB5, C.SUBSTATUS_TRANSFORMED)) { + if (this.turn === 1) this.partyMon.pp[slot] = pp - amount; + else if (this.battleMode === C.WILD_BATTLE) this.wildPP[slot] = pp - amount; + else this.options.trainer!.party[this.curOTMon].pp[slot] = pp - amount; + } + this.numbers[RAM.textDecimalByte] = amount; + yield* this.say(BATTLE.SpiteEffectText); + } + /** BattleCommand_HealBell: the user and all six party slots lose their + * status; Nightmare ends, Toxic's flag does not. */ + private *healBell(): Gen { + const user = this.user; + user.reset(SUB1, C.SUBSTATUS_NIGHTMARE); + user.mon.status = 0; + for (const mon of (this.turn === 0 ? this.options.party : this.options.trainer?.party) ?? []) mon.status = 0; + yield this.hud(); + yield* this.say(BATTLE.BellChimedText); + this.calcStats(user); + } + /** BattleCommand_Disable: the target's last move for 1-7 more turns. */ + private *disable(): Gen { + const target = this.target, + last = target.lastCounterMove; + if (this.attackMissed || target.disableCount || !last || last === C.STRUGGLE) return yield* this.failMove(); + const slot = this.moveSlot(target, last); + if (!target.mon.pp[slot]) return yield* this.failMove(); + let turns: number; + do turns = this.random() & 7; + while (!turns); + target.disableCount = turns + 1 + ((slot + 1) << 4); + target.disabledMove = last; + this.strings[RAM.stringBuffer1] = this.options.data.moveName(last); + yield* this.say(BATTLE.WasDisabledText); + } + /** BattleCommand_Encore: the target repeats its last move for 3-6 turns; + * if it has not moved yet this turn, it switches to that move now. */ + private *encore(): Gen { + const target = this.target, + last = target.lastMove; + if (!last || last === C.STRUGGLE || last === C.ENCORE || last === C.MIRROR_MOVE) return yield* this.didntAffect2(); + if (!(target.mon.pp[this.moveSlot(target, last)] & C.PP_MASK)) return yield* this.didntAffect2(); + if (this.attackMissed || target.has(SUB5, C.SUBSTATUS_ENCORED)) return yield* this.didntAffect2(); + target.set(SUB5, C.SUBSTATUS_ENCORED); + target.encoreCount = (this.random() & 3) + 3; + if (!(this.enemyGoesFirst ^ this.turn)) { + target.moveNum = this.moveSlot(target, last); + target.curMove = last; + target.move = this.moveStruct(last); + } + yield* this.say(BATTLE.GotAnEncoreText); + } + /** GetMoveData into a move struct. */ + private moveStruct(id: number): MoveStruct { + const move = this.options.data.move(id); + return { + anim: move.id, + effect: move.effect, + power: move.power, + type: move.type, + accuracy: move.accuracy, + pp: move.pp, + chance: move.chance, + }; + } + /** BattleCommand_Attract: the opposite gender falls in love. The + * player's side counts its party Pokémon's gender. */ + private *attract(): Gen { + const target = this.target; + if (this.attackMissed || !this.oppositeGender() || this.hiddenOpponent() || target.has(SUB1, C.SUBSTATUS_IN_LOVE)) + return yield* this.failMove(); + target.set(SUB1, C.SUBSTATUS_IN_LOVE); + yield* this.say(BATTLE.FellInLoveText); + } + /** CheckOppositeGender. */ + 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, + 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; + } + /** BattleCommand_ResetStats (Haze): every stat level back to normal. */ + private *resetStats(): Gen { + const turn = this.turn; + for (const [side, sideTurn] of [[this.player, 0], [this.enemy, 1]] as const) { + side.levels = [7, 7, 7, 7, 7, 7, 7]; + this.turn = sideTurn; + this.calcStats(side); + } + this.turn = turn; + yield* this.say(BATTLE.EliminatedStatsText); + } + /** BattleCommand_Heal: half the maximum HP, or all of it with Rest, + * which also puts the user to sleep for two turns. */ + private *heal(): Gen { + const user = this.user, + mon = user.mon; + if (mon.hp === mon.maxHp) return yield* this.say(BATTLE.HPIsFullText); + const rest = user.move.anim === C.REST; + if (rest) { + user.reset(SUB5, C.SUBSTATUS_TOXIC); + const statused = mon.status !== 0; + mon.status = C.REST_SLEEP_TURNS + 1; + yield this.hud(); + yield* this.say(statused ? BATTLE.RestedText : BATTLE.WentToSleepText); + this.calcStats(user); + } + mon.hp = Math.min(mon.maxHp, (mon.hp + (rest ? mon.maxHp : mon.maxHp >> 1 || 1)) & 0xffff); + this.updateUserInParty(); + yield this.hud(); + yield* this.say(BATTLE.RegainedHealthText); + } + /** BattleCommand_PainSplit: both take the average of their HP, up to + * their maximum. The party structs are left as they were. */ + private *painSplit(): Gen { + if (this.attackMissed || this.target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return yield* this.didntAffect2(); + const average = (this.player.mon.hp + this.enemy.mon.hp) >> 1; + for (const side of [this.player, this.enemy]) side.mon.hp = Math.min(side.mon.maxHp, average); + this.curDamage = 0; + yield this.hud(); + yield* this.say(BATTLE.SharedPainText); + } + /** BattleCommand_BellyDrum: half the maximum HP for the highest Attack. + * The first two stages are raised before the HP check, so they stay + * even when it fails (a kept bug). */ + private *bellyDrum(): Gen { + const user = this.user; + this.statUp(C.ATTACK | 0x10); + const half = user.mon.maxHp >> 1 || 1; + if (this.attackMissed || half >= user.mon.hp) return yield* this.say(BATTLE.ButItFailedText); + this.subtractHP(user, half); + this.updateUserInParty(); + yield this.hud(); + for (let times = C.MAX_STAT_LEVEL - C.BASE_STAT_LEVEL - 1; times > 0; times--) this.statUp(C.ATTACK | 0x10); + yield* this.say(BATTLE.BellyDrumText); + } + /** BattleCommand_PsychUp: copy the target's stat levels, unless they + * are all normal. */ + private *psychUp(): Gen { + const user = this.user, + target = this.target; + if (target.levels.every((level) => level === C.BASE_STAT_LEVEL)) return yield* this.say(BATTLE.ButItFailedText); + user.levels = [...target.levels]; + this.calcStats(user); + yield* this.say(BATTLE.CopiedStatsText); + } + /** BattleCommand_PerishSong: both sides get a count of 4, unless both + * already have one. */ + private *perishSong(): Gen { + const sides = [this.player, this.enemy]; + if (sides.every((side) => side.has(SUB1, C.SUBSTATUS_PERISH))) return yield* this.say(BATTLE.ButItFailedText); + for (const side of sides) + if (!side.has(SUB1, C.SUBSTATUS_PERISH)) { + side.set(SUB1, C.SUBSTATUS_PERISH); + side.perishCount = 4; + } + yield* this.say(BATTLE.StartPerishText); + } + /** BattleCommand_ClearHazards (Rapid Spin): the user sheds Leech Seed, + * its side's Spikes and a trap. */ + private *clearHazards(): Gen { + const user = this.user; + if (user.has(SUB4, C.SUBSTATUS_LEECH_SEED)) { + user.reset(SUB4, C.SUBSTATUS_LEECH_SEED); + yield* this.say(BATTLE.ShedLeechSeedText); + } + if (user.screens & (1 << C.SCREENS_SPIKES)) { + user.screens &= ~(1 << C.SCREENS_SPIKES); + yield* this.say(BATTLE.BlewSpikesText); + } + if (!user.wrapCount) return; + user.wrapCount = 0; + yield* this.say(BATTLE.ReleasedByText); + } + /** BattleCommand_CheckFutureSight: on the turn it strikes, the stored + * damage skips to after `futuresight`. */ + private checkFutureSight() { + const user = this.user; + if (user.futureSightCount !== 1) return; + user.futureSightCount = 0; + this.curDamage = user.futureSightDamage; + this.skipTo(COMMAND.futuresight); + } + /** BattleCommand_FutureSight: store this turn's damage for three turns + * later, unless one is already coming. */ + private *futureSight(): Gen { + const user = this.user; + if (!user.charging) { + user.lastCounterMove = user.move.anim; + user.lastMove = user.move.anim; + } + if (user.futureSightCount) { + this.curDamage = 0; + yield* this.say(BATTLE.ButItFailedText); + return this.endMoveEffect(); + } + user.futureSightCount = 4; + this.requireNoSubstitute(); // BattleCommand_LowerSub and RaiseSub + yield* this.say(BATTLE.ForesawAttackText); + user.futureSightDamage = this.curDamage; + this.curDamage = 0; + this.endMoveEffect(); + } + /** SkipToBattleCommand: go on after the next `command`. */ private skipTo(command: number) { while (this.script[this.scriptIndex++] !== command) @@ -3476,11 +3931,16 @@ export class Battle { } if (target.mon.status || this.aiRandomFail() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed) return yield* this.say(BATTLE.DidntAffect1Text); - if (this.user.move.effect === C.EFFECT_TOXIC) throw new UnsupportedBattle("Toxic is not ported yet."); + // Toxic badly poisons, starting its damage counter over. + const toxic = this.user.move.effect === C.EFFECT_TOXIC; + if (toxic) { + target.set(SUB5, C.SUBSTATUS_TOXIC); + target.toxicCount = 0; + } target.mon.status |= 1 << C.PSN; this.updateOpponentInParty(); yield this.hud(); - yield* this.say(BATTLE.WasPoisonedText); + yield* this.say(toxic ? BATTLE.BadlyPoisonedText : BATTLE.WasPoisonedText); yield* this.useHeldStatusHealingItem(); } private *paralyzeCommand(): Gen { @@ -3572,12 +4032,28 @@ export class Battle { 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); - if (this.turn === 0) this.updateBattleMonInParty(); + this.updateUserInParty(); yield this.hud(); yield* this.say(BATTLE.RegainedHealthText); } + /** UpdateOpponentInParty and UpdateUserInParty. */ private updateOpponentInParty() { if (this.turn === 1) this.updateBattleMonInParty(); + else this.updateEnemyMonInParty(); + } + private updateUserInParty() { + if (this.turn === 0) this.updateBattleMonInParty(); + else this.updateEnemyMonInParty(); + } + /** UpdateEnemyMonInParty: a trainer's Pokémon's level, status and HP go + * back to wOTPartyMons; wild Pokémon have none. */ + private updateEnemyMonInParty() { + if (this.battleMode === C.WILD_BATTLE) return; + const mon = this.options.trainer?.party[this.curOTMon]; + if (!mon) return; + mon.level = this.enemy.mon.level; + mon.status = this.enemy.mon.status; + mon.hp = this.enemy.mon.hp; } // ------------------------------------------------------ stat changes @@ -3709,21 +4185,47 @@ export class Battle { // ------------------------------------------------------- turn ending - /** ResidualDamage for the side whose turn it is. Returns false if it fainted. */ + /** 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. */ private *residualDamage(): Gen { const user = this.user, - mon = user.mon; + mon = user.mon, + quarter = mon.maxHp >> 2 || 1; if (!mon.hp) return false; if (mon.status & ((1 << C.PSN) | (1 << C.BRN))) { yield* this.say(mon.status & (1 << C.BRN) ? BATTLE.HurtByBurnText : BATTLE.HurtByPoisonText); - if (user.has(SUB5, C.SUBSTATUS_TOXIC)) throw new UnsupportedBattle("Toxic is not ported yet."); - const quarter = mon.maxHp >> 2 || 1; - this.subtractHP(user, quarter >> 1 || 1); + let damage = quarter >> 1 || 1; + if (user.has(SUB5, C.SUBSTATUS_TOXIC)) { + user.toxicCount = (user.toxicCount + 1) & 0xff; + damage = ((quarter >> 2 || 1) * (user.toxicCount || 0x100)) & 0xffff; + } + this.subtractHP(user, damage); yield this.hud(); } if (!mon.hp) return false; - if (user.sub[SUB4] & (1 << C.SUBSTATUS_LEECH_SEED) || user.sub[SUB1] & ((1 << C.SUBSTATUS_NIGHTMARE) | (1 << C.SUBSTATUS_CURSE))) - throw new UnsupportedBattle("Leech Seed, Nightmare and Curse are not ported yet."); + if (user.has(SUB4, C.SUBSTATUS_LEECH_SEED)) { + // The seeder regains the HP actually drained. + const drained = Math.min(mon.hp, quarter >> 1 || 1); + this.subtractHP(user, drained); + const seeder = this.target; + seeder.mon.hp = Math.min(seeder.mon.maxHp, seeder.mon.hp + drained); + if (seeder === this.player) this.updateBattleMonInParty(); + yield this.hud(); + yield* this.say(BATTLE.LeechSeedSapsText); + } + if (!mon.hp) return false; + if (user.has(SUB1, C.SUBSTATUS_NIGHTMARE)) { + this.subtractHP(user, quarter); + yield this.hud(); + yield* this.say(BATTLE.HasANightmareText); + } + if (!mon.hp) return false; + if (user.has(SUB1, C.SUBSTATUS_CURSE)) { + this.subtractHP(user, quarter); + yield this.hud(); + yield* this.say(BATTLE.HurtByCurseText); + } return mon.hp > 0; } private subtractHP(side: Side, amount: number) { @@ -3732,15 +4234,15 @@ export class Battle { } /** HandleBetweenTurnEffects. Effects no early move can cause are refused. */ 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.handleWrap(); if (yield* this.checkFaintPlayerThenEnemy()) return; + yield* this.handlePerishSong(); + if (yield* this.checkFaintPlayerThenEnemy()) return; for (const side of [this.player, this.enemy]) { - if (side.has(SUB1, C.SUBSTATUS_PERISH) || side.has(SUB5, C.SUBSTATUS_ENCORED)) - throw new UnsupportedBattle("Perish Song and Encore are not ported yet."); - if (side.screens & ~((1 << C.SCREENS_LIGHT_SCREEN) | (1 << C.SCREENS_REFLECT))) - throw new UnsupportedBattle("Other screen effects are not ported yet."); 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."); @@ -3767,6 +4269,7 @@ export class Battle { yield this.hud(); yield* this.say(BATTLE.DefrostedOpponentText); } + yield* this.handleSafeguard(); yield* this.handleScreens(); // HandleHealingItems: turn 0 handles the enemy's items, then turn 1. for (const turn of [0, 1]) { @@ -3776,6 +4279,76 @@ export class Battle { yield* this.useConfusionHealingItem(); } this.updateBattleMonInParty(); + yield* this.handleEncore(); + } + /** HandleFutureSight: three turns after its use, the stored attack hits + * through DoMove, without CheckTurn. */ + private *handleFutureSight(): Gen { + for (const [turn, side] of [this.player, this.enemy].entries()) { + this.turn = turn; + if (!side.futureSightCount) continue; + side.futureSightCount--; + if (side.futureSightCount !== 1) continue; + yield* this.say(BATTLE.BattleText_TargetWasHitByFutureSight); + const move = side.curMove; + side.curMove = C.FUTURE_SIGHT; + this.updateMoveData(); + this.attackMissed = 0; + this.alreadyDisobeyed = 0; + this.typeModifier = C.EFFECTIVE; + yield* this.doMove(); + this.curDamage = 0; + side.curMove = move; + this.updateBattleMonInParty(); + this.updateEnemyMonInParty(); + } + } + /** HandlePerishSong: each count goes down; at 0 the Pokémon faints. */ + private *handlePerishSong(): Gen { + for (const [turn, side] of [this.player, this.enemy].entries()) { + this.turn = turn; + if (!side.has(SUB1, C.SUBSTATUS_PERISH)) continue; + side.perishCount = (side.perishCount - 1) & 0xff; + this.numbers[RAM.textDecimalByte] = side.perishCount; + yield* this.say(BATTLE.PerishCountText); + if (side.perishCount) continue; + side.reset(SUB1, C.SUBSTATUS_PERISH); + side.mon.hp = 0; + if (turn === 0) this.partyMon.hp = 0; + else if (this.battleMode !== C.WILD_BATTLE) this.options.trainer!.party[this.curOTMon].hp = 0; + yield this.hud(); + } + } + /** HandleSafeguard: five turns, the use included. */ + private *handleSafeguard(): Gen { + for (const [turn, side] of [this.player, this.enemy].entries()) { + if (!(side.screens & (1 << C.SCREENS_SAFEGUARD))) continue; + side.safeguardCount = (side.safeguardCount - 1) & 0xff; + if (side.safeguardCount) continue; + side.screens &= ~(1 << C.SCREENS_SAFEGUARD); + this.turn = turn; + yield* this.say(BATTLE.BattleText_SafeguardFaded); + } + } + /** HandleEncore: an encore ends after its turns, or when the move in + * the chosen slot runs out of PP. */ + private *handleEncore(): Gen { + for (const [turn, side] of [this.player, this.enemy].entries()) { + if (!side.has(SUB5, C.SUBSTATUS_ENCORED)) continue; + side.encoreCount = (side.encoreCount - 1) & 0xff; + if (side.encoreCount && side.mon.pp[side.moveNum] & C.PP_MASK) continue; + side.reset(SUB5, C.SUBSTATUS_ENCORED); + this.turn = turn ^ 1; + yield* this.say(BATTLE.BattleText_TargetsEncoreEnded); + } + } + /** SpikesDamage: a Pokémon sent out onto Spikes loses an eighth of its + * maximum HP, unless it is a Flying type. */ + private *spikesDamage(side: Side): Gen { + if (!(side.screens & (1 << C.SCREENS_SPIKES)) || this.isType(side, C.FLYING)) return; + yield* this.say(BATTLE.BattleText_UserHurtBySpikes); + this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 1 || 1); + yield this.hud(); } /** 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 @@ -3846,8 +4419,7 @@ export class Battle { else yield* this.enemySwitch(); this.resetBattleParticipants(); this.turn = 1; - if (this.enemy.screens & (1 << C.SCREENS_SPIKES)) - throw new UnsupportedBattle("Spikes are not ported yet."); + yield* this.spikesDamage(this.enemy); this.enemy.move.anim = 0; this.battlePlayerAction = 0; } @@ -4028,8 +4600,7 @@ export class Battle { this.breakAttraction(); yield this.hud(); this.turn = 0; - if (this.player.screens & (1 << C.SCREENS_SPIKES)) - throw new UnsupportedBattle("Spikes are not ported yet."); + yield* this.spikesDamage(this.player); } /** SendOutMonText: the cheer depends on the enemy's remaining HP. */ private *sendOutMonText(): Gen { @@ -4295,8 +4866,14 @@ export class Battle { }; if (!aliveOthers()) return 0; - if (this.enemy.has(SUB1, C.SUBSTATUS_PERISH)) - throw new UnsupportedBattle("Perish Song is not ported yet."); + if (this.enemy.has(SUB1, C.SUBSTATUS_PERISH) && this.enemy.perishCount === 1) { + // A last Perish count switches at the maximum chance, to the best + // candidate or else the first one standing. + const { e, score } = standingSuperEffective(resisting(quarterHP(aliveOthers()))); + if (e === 2) return (score + 0x30) & 0xff; + const alive = aliveOthers(); + for (let index = 0; index < 6; index++) if (alive & bit(index)) return index + 0x30; + } if (this.checkPlayerMoveTypeMatchups() > C.EFFECTIVE) return 0; const lastCounter = this.player.lastCounterMove; if (lastCounter) { diff --git a/src/game/battle/constants.ts b/src/game/battle/constants.ts index aa0fbec..2db7f29 100644 --- a/src/game/battle/constants.ts +++ b/src/game/battle/constants.ts @@ -105,6 +105,10 @@ export const C = { SKULL_BASH: 130, SKY_ATTACK: 143, WHIRLPOOL: 250, + MIRROR_MOVE: 119, + REST: 156, + ENCORE: 227, + FUTURE_SIGHT: 248, WHIRLWIND: 18, FLY: 19, THUNDER: 87, @@ -128,6 +132,8 @@ export const C = { FLYING: 2, POISON: 3, BIRD: 6, + GHOST: 8, + GRASS: 22, UNUSED_TYPES: 10, UNUSED_TYPES_END: 20, FIRE: 20, @@ -189,6 +195,8 @@ export const C = { SP_DEFENSE: 4, ACCURACY: 5, EVASION: 6, + ABILITY: 7, + REST_SLEEP_TURNS: 2, BASE_STAT_LEVEL: 7, MAX_STAT_LEVEL: 13, MAX_LEVEL: 100, @@ -380,19 +388,25 @@ export const COMMAND = { paralyzetarget: 25, selfdestruct: 26, payday: 30, + resetstats: 32, storeenergy: 33, unleashenergy: 34, endloop: 36, flinchtarget: 37, ohko: 38, recoil: 39, + mist: 40, + focusenergy: 41, confuse: 42, confusetarget: 43, + heal: 44, screen: 46, poison: 47, paralyze: 48, rechargenextturn: 50, + leechseed: 53, splash: 54, + disable: 55, cleartext: 56, charge: 57, checkcharge: 58, @@ -401,24 +415,39 @@ export const COMMAND = { checkrampage: 62, constantdamage: 63, counter: 64, + encore: 65, + painsplit: 66, lockon: 69, + destinybond: 73, + spite: 74, falseswipe: 75, + healbell: 76, kingsrock: 77, triplekick: 78, kickcounter: 79, thief: 80, + arenatrap: 81, + nightmare: 82, defrost: 83, + curse: 84, + protect: 85, + spikes: 86, foresight: 87, + perishsong: 88, + endure: 90, checkrollout: 91, rolloutpower: 92, furycutter: 94, + attract: 95, happinesspower: 96, present: 97, damagecalc: 98, frustrationpower: 99, + safeguard: 100, checksafeguard: 101, getmagnitude: 102, pursuit: 104, + clearhazards: 105, healmorn: 106, healday: 107, healnite: 108, @@ -460,16 +489,21 @@ export const COMMAND = { statupanim: 146, switchturn: 147, fakeout: 148, + bellydrum: 149, + psychup: 150, rage: 151, doubleflyingdamage: 152, doubleundergrounddamage: 153, mirrorcoat: 154, + checkfuturesight: 155, + futuresight: 156, doubleminimizedamage: 157, skipsuncharge: 158, thunderaccuracy: 159, beatup: 161, ragedamage: 162, resettypematchup: 163, + allstatsup: 164, bidefailtext: 165, raisesubnoanim: 166, lowersubnoanim: 167, diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts index a862932..061f3d4 100644 --- a/src/game/rom/profile.ts +++ b/src/game/rom/profile.ts @@ -584,6 +584,47 @@ 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 = { + WasSeededText: 0x813f8, + EvadedText: 0x81407, + LeechSeedSapsText: 0x8080e, + StartedNightmareText: 0x8160b, + HasANightmareText: 0x80822, + PutACurseText: 0x8163d, + HurtByCurseText: 0x80836, + DestinyBondEffectText: 0x81156, + TookDownWithItText: 0x810da, + ProtectedItselfText: 0x81665, + BracedItselfText: 0x816f9, + EnduredText: 0x80eb0, + SpiteEffectText: 0x8117f, + BellChimedText: 0x811a0, + WasDisabledText: 0x8141d, + GotAnEncoreText: 0x81109, + BattleText_TargetsEncoreEnded: 0x80c8a, + FellInLoveText: 0x8170b, + InLoveWithText: 0x80ec4, + InfatuationText: 0x80eda, + MistText: 0x8130b, + GettingPumpedText: 0x81338, + EliminatedStatsText: 0x81476, + WentToSleepText: 0x80fd7, + RestedText: 0x80fe9, + CantEscapeNowText: 0x815f7, + SharedPainText: 0x8111b, + BellyDrumText: 0x817d2, + CopiedStatsText: 0x817f6, + CoveredByVeilText: 0x8171c, + BattleText_SafeguardFaded: 0x808d2, + StartPerishText: 0x816c2, + PerishCountText: 0x80864, + SpikesText: 0x81691, + BattleText_UserHurtBySpikes: 0x80bae, + ShedLeechSeedText: 0x8177a, + BlewSpikesText: 0x8178e, + ReleasedByText: 0x81764, + ForesawAttackText: 0x81817, + BattleText_TargetWasHitByFutureSight: 0x808b6, + BadlyPoisonedText: 0x811e6, IgnoredOrders2Text: 0x81850, TookAimText: 0x81136, StoringEnergyText: 0x80e6e, diff --git a/tests/capture.test.ts b/tests/capture.test.ts index 76f777e..8dfd5b1 100644 --- a/tests/capture.test.ts +++ b/tests/capture.test.ts @@ -148,8 +148,11 @@ describe.skipIf(!existsSync(romPath))("capture with the local ROM", () => { statExp: [0, 0, 0, 0, 0], }); expect(caught.hp).toBeLessThan(caught.stats[0]); - // wWildMonMoves and wWildMonPP: full PP of the moves it appeared with. - expect(caught.pp).toEqual(caught.moves.map((move) => (move ? data.move(move).pp : 0))); + // wWildMonMoves and wWildMonPP: the moves it appeared with, less the + // PP it spent in the battle (DoTurn consumes wWildMonPP too). + const full = caught.moves.map((move) => (move ? data.move(move).pp : 0)); + expect(caught.pp).toEqual(enemy.pp); + expect(caught.pp.reduce((a, b) => a + b, 0)).toBeLessThan(full.reduce((a, b) => a + b, 0)); expect(validPartyMon(data, caught)).toBe(true); expect(pokedex.caught.has(RATTATA)).toBe(true); const thrown = texts.filter((text) => text === "CHRIS used the POKé BALL.").length; diff --git a/tests/move-effects.test.ts b/tests/move-effects.test.ts index deef30a..500371e 100644 --- a/tests/move-effects.test.ts +++ b/tests/move-effects.test.ts @@ -65,7 +65,37 @@ const DOUBLESLAP = 3, MIND_READER = 170, ROLLOUT = 205, FURY_CUTTER = 210, - PURSUIT = 228; + PURSUIT = 228, + SWORDS_DANCE = 0x0e, + GROWL = 0x2d, + DISABLE = 0x32, + MIST = 0x36, + LEECH_SEED = 0x49, + POISONPOWDER = 0x4d, + TOXIC = 0x5c, + RECOVER = 0x69, + HAZE = 0x72, + FOCUS_ENERGY = 0x74, + REST = 0x9c, + NIGHTMARE = 0xab, + CURSE = 0xae, + SPITE = 0xb4, + PROTECT = 0xb6, + BELLY_DRUM = 0xbb, + SPIKES = 0xbf, + DESTINY_BOND = 0xc2, + PERISH_SONG = 0xc3, + ENDURE = 0xcb, + MEAN_LOOK = 0xd4, + ATTRACT = 0xd5, + HEAL_BELL = 0xd7, + SAFEGUARD = 0xdb, + PAIN_SPLIT = 0xdc, + ENCORE = 0xe3, + RAPID_SPIN = 0xe5, + PSYCH_UP = 0xf4, + ANCIENTPOWER = 0xf6, + FUTURE_SIGHT = 0xf8; // Species and items const PIDGEY = 16, RATTATA = 19, @@ -86,7 +116,13 @@ const PIDGEY = 16, ONIX = 95, SCYTHER = 123, TAUROS = 128, - HOUNDOUR = 228; + HOUNDOUR = 228, + BULBASAUR = 1, + GENGAR = 0x5e, + NIDORAN_F = 0x1d, + NIDORAN_M = 0x20, + STARMIE = 0x79, + FORRETRESS = 0xcd; const AMULET_COIN = 0x5b, LEFTOVERS = 0x92, FOCUS_BAND = 0x77, @@ -180,8 +216,8 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => { ) => { for (let seed = 1; seed < 500; seed++) { const battle = make(seed); - const { texts } = firstTurn(battle); - if (test(texts, battle)) return { seed, battle, texts }; + const { texts, steps } = firstTurn(battle); + if (test(texts, battle)) return { seed, battle, texts, steps }; } throw new Error("No seed found."); }; @@ -551,6 +587,291 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => { }); }); + describe("status and field moves", () => { + /** Play turns choosing these move slots, returning the texts. */ + const play = (battle: Battle, moves: number[]) => { + const steps = battleSteps(battle); + for (const move of moves) { + if (steps.done || steps.prompt?.kind !== "action") break; + steps.answer({ action: "fight", move }); + } + return steps; + }; + it("badly poisons with Toxic, one more sixteenth each turn", () => { + const enemy = mon(SNORLAX, 60, [SPLASH]); + const { battle, steps } = findSeed( + (seed) => duel([mon(GENGAR, 40, [TOXIC, SPLASH])], [enemy], { seed }), + (texts) => texts.some((text) => text.includes("badly poisoned")), + ); + const sixteenth = (enemy.stats[0] >> 2) >> 2; + expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth); + steps.answer({ action: "fight", move: 1 }); + expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth * 3); + steps.answer({ action: "fight", move: 1 }); + expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - sixteenth * 6); + expect(battle.enemy.toxicCount).toBe(3); + }); + it("seeds the target, draining an eighth to the seeder each turn, but not a Grass type", () => { + const { battle, texts } = findSeed( + (seed) => { + const player = mon(BULBASAUR, 60, [LEECH_SEED]); + player.hp = 10; + return duel([player], [mon(SNORLAX, 30, [SPLASH])], { seed }); + }, + (texts) => texts.some((text) => text.includes("was seeded")), + ); + const eighth = (battle.enemy.mon.maxHp >> 2) >> 1; + expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe(eighth); + expect(battle.player.mon.hp).toBe(10 + eighth); + expect(texts).toContain("LEECH SEED saps Enemy SNORLAX!"); + const grass = firstTurn(duel([mon(BULBASAUR, 40, [LEECH_SEED])], [mon(BULBASAUR, 40, [SPLASH])])); + expect(grass.texts.join(" ")).toContain("doesn't affect"); + }); + it("gives a sleeping target a nightmare that costs a quarter each turn", () => { + const enemy = mon(SNORLAX, 60, [SPLASH], { status: 5 }); + const battle = duel([mon(GENGAR, 40, [NIGHTMARE])], [enemy]); + const { texts } = firstTurn(battle); + expect(texts.join(" ")).toContain("started to have a NIGHTMARE"); + expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - (enemy.stats[0] >> 2)); + const awake = firstTurn(duel([mon(GENGAR, 40, [NIGHTMARE])], [mon(SNORLAX, 60, [SPLASH])])); + expect(awake.texts).toContain("But it failed!"); + }); + it("curses differently for Ghost types", () => { + const ghost = mon(GENGAR, 40, [CURSE]); + const enemy = mon(SNORLAX, 60, [SPLASH]); + const battle = duel([ghost], [enemy]); + const { texts } = firstTurn(battle); + expect(texts.join(" ")).toContain("put a CURSE"); + expect(ghost.hp).toBe(ghost.stats[0] - (ghost.stats[0] >> 1)); + expect(battle.enemy.mon.hp).toBe(enemy.stats[0] - (enemy.stats[0] >> 2)); + const other = duel([mon(SNORLAX, 40, [CURSE])], [mon(SNORLAX, 60, [SPLASH])]); + const curse = firstTurn(other); + expect(other.player.levels.slice(0, 3)).toEqual([8, 8, 6]); + const used = curse.texts.indexOf("SNORLAX used CURSE!"); + expect(curse.texts.slice(used + 1, used + 4)).toEqual(["SNORLAX's SPEED fell!", "SNORLAX's ATTACK went up!", "SNORLAX's DEFENSE went up!"]); + }); + it("takes the attacker down with Destiny Bond", () => { + // Starmie moves first, so its Destiny Bond holds through Snorlax's hit. + const player = mon(STARMIE, 20, [DESTINY_BOND]); + const battle = duel([player], [mon(SNORLAX, 60, [TACKLE])]); + const { texts } = playBattle(battle); + expect(texts.join(" ")).toContain("took down with it"); + expect(battle.enemy.mon.hp).toBe(0); + }); + it("protects against a move, with less chance on each use in a row", () => { + const battle = duel([mon(STARMIE, 40, [PROTECT])], [mon(SNORLAX, 30, [TACKLE])]); + const { texts } = firstTurn(battle); + expect(texts).toContain("STARMIE PROTECTED itself!"); + expect(texts).toContain("STARMIE's PROTECTING itself!"); + let failures = 0; + for (let seed = 1; seed < 40; seed++) { + const steps = play(duel([mon(STARMIE, 40, [PROTECT])], [mon(SNORLAX, 30, [TACKLE])], { seed }), [0, 0, 0]); + failures += steps.texts.filter((text) => text === "But it failed!").length; + } + expect(failures).toBeGreaterThan(0); + }); + it("endures a knockout hit with 1 HP", () => { + const player = mon(RATTATA, 10, [ENDURE]); + const battle = duel([player], [mon(SNORLAX, 60, [TACKLE])]); + const { texts } = firstTurn(battle); + expect(texts.join(" ")).toContain("braced itself"); + expect(texts.join(" ")).toContain("ENDURED the hit"); + expect(battle.player.mon.hp).toBe(1); + }); + it("spites the last move's PP, in the trainer's party struct too", () => { + const enemy = mon(SNORLAX, 60, [TACKLE]); + const battle = duel([mon(GENGAR, 40, [SPITE])], [enemy]); + const { texts } = turns(battle, [0, 0]); + const spite = texts.find((text) => text.includes("was reduced by")); + const amount = Number(/reduced by (\d)/.exec(spite!)![1]); + expect(amount).toBeGreaterThanOrEqual(2); + // Two Tackles and the Spite in between, in the party struct too. + expect(battle.enemy.mon.pp[0]).toBe(assets.pokemon.move(TACKLE).pp - 2 - amount); + expect(enemy.pp[0] & 63).toBe(assets.pokemon.move(TACKLE).pp - 2 - amount); + }); + it("cures the whole party with Heal Bell", () => { + const party = [mon(CYNDAQUIL, 40, [HEAL_BELL], { status: PSN }), mon(PIDGEY, 10, [TACKLE], { status: 1 << C.PAR })]; + const battle = duel(party, [mon(SNORLAX, 60, [SPLASH])]); + const { texts } = firstTurn(battle); + expect(texts.join(" ")).toContain("A bell chimed!"); + expect(party.map((member) => member.status)).toEqual([0, 0]); + }); + it("disables the target's last move, leaving it to Struggle", () => { + const battle = duel([mon(GENGAR, 60, [DISABLE])], [mon(SNORLAX, 20, [TACKLE])]); + // Disable fails before Snorlax has moved; the Tackle it already chose + // is then stopped, and Struggle follows. + const { texts } = turns(battle, [0, 0, 0]); + expect(texts).toContain("Enemy SNORLAX's TACKLE was DISABLED!"); + expect(texts).toContain("Enemy SNORLAX's TACKLE is DISABLED!"); + expect(texts).toContain("Enemy SNORLAX used STRUGGLE!"); + }); + it("encores the target into repeating its last move", () => { + const enemy = mon(SNORLAX, 30, [SPLASH, TACKLE]); + const battle = duel([mon(STARMIE, 60, [ENCORE, SWORDS_DANCE])], [enemy]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 1 }); + const lastMove = battle.enemy.lastMove; + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts.join(" ")).toContain("got an ENCORE"); + expect(battle.enemy.has(5, C.SUBSTATUS_ENCORED)).toBe(true); + steps.answer({ action: "fight", move: 1 }); + const used = steps.texts.filter((text) => text.startsWith("Enemy SNORLAX used")).slice(-1)[0]; + expect(used).toBe(`Enemy SNORLAX used ${assets.pokemon.moveName(lastMove)}!`); + }); + it("keeps the player's encored move whatever FIGHT chooses", () => { + const battle = duel([mon(SNORLAX, 30, [TACKLE, SPLASH])], [mon(STARMIE, 60, [ENCORE])]); + // Starmie's first Encore fails, as Snorlax has not moved yet. + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 1 }).answer({ action: "fight", move: 1 }); + expect(battle.player.has(5, C.SUBSTATUS_ENCORED)).toBe(true); + expect(battle.fightKeepsMove).toBe(true); + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts.filter((text) => text === "SNORLAX used SPLASH!")).toHaveLength(3); + expect(steps.texts).not.toContain("SNORLAX used TACKLE!"); + }); + it("makes the opposite gender fall in love, which may stop it", () => { + const { battle } = findSeed( + (seed) => duel([mon(NIDORAN_M, 40, [ATTRACT])], [mon(NIDORAN_F, 40, [TACKLE])], { seed }), + (texts) => texts.some((text) => text.includes("fell in love")), + ); + expect(battle.enemy.has(1, C.SUBSTATUS_IN_LOVE)).toBe(true); + const same = firstTurn(duel([mon(NIDORAN_M, 40, [ATTRACT])], [mon(NIDORAN_M, 40, [SPLASH])])); + expect(same.texts).toContain("But it failed!"); + const immobile = findSeed( + (seed) => duel([mon(NIDORAN_M, 40, [ATTRACT, SPLASH])], [mon(NIDORAN_F, 40, [TACKLE])], { seed }), + (texts) => texts.some((text) => text.includes("infatuation kept it from attacking")), + ); + expect(immobile.texts.join(" ")).toContain("is in love with"); + }); + it("shrouds the user in Mist against stat drops, and pumps it up with Focus Energy", () => { + const battle = duel([mon(STARMIE, 60, [MIST, FOCUS_ENERGY])], [mon(SNORLAX, 30, [GROWL])]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts.join(" ")).toContain("shrouded in MIST"); + expect(steps.texts.join(" ")).toContain("protected by MIST"); + expect(battle.player.levels[C.ATTACK]).toBe(7); + steps.answer({ action: "fight", move: 1 }); + expect(steps.texts.join(" ")).toContain("getting pumped"); + expect(battle.player.has(4, C.SUBSTATUS_FOCUS_ENERGY)).toBe(true); + }); + it("eliminates all stat changes with Haze", () => { + const battle = duel([mon(STARMIE, 60, [SWORDS_DANCE, HAZE])], [mon(SNORLAX, 30, [GROWL])]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 0 }); + steps.answer({ action: "fight", move: 1 }); + expect(steps.texts.join(" ")).toContain("All stat changes were eliminated!"); + expect(battle.player.levels).toEqual([7, 7, 7, 7, 7, 7, 7]); + }); + it("recovers half its HP, or all of it with Rest and two turns of sleep", () => { + const player = mon(STARMIE, 60, [RECOVER, REST]); + player.hp = 20; + const battle = duel([player], [mon(SNORLAX, 10, [SPLASH])]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 0 }); + expect(battle.player.mon.hp).toBe(20 + (player.stats[0] >> 1)); + steps.answer({ action: "fight", move: 1 }); + expect(steps.texts.join(" ")).toContain("STARMIE went to sleep!"); + expect(battle.player.mon.hp).toBe(player.stats[0]); + expect(battle.player.mon.status).toBe(C.REST_SLEEP_TURNS + 1); + const full = firstTurn(duel([mon(STARMIE, 60, [RECOVER])], [mon(SNORLAX, 10, [SPLASH])])); + expect(full.texts).toContain("STARMIE's HP is full!"); + }); + it("stops the player running after Mean Look", () => { + const battle = duel([mon(STARMIE, 60, [SPLASH])], { wild: { species: GENGAR, level: 5 } }); + const internal = battle as unknown as { enemy: { mon: { moves: number[] } } }; + void internal; + const steps = battleSteps(battle); + battle.enemy.set(5, C.SUBSTATUS_CANT_RUN); + steps.answer({ action: "run" }); + expect(steps.texts).toContain("Can't escape!"); + }); + it("shares pain to the average HP", () => { + const player = mon(GENGAR, 40, [PAIN_SPLIT]); + player.hp = 10; + const enemy = mon(SNORLAX, 60, [SPLASH]); + const battle = duel([player], [enemy]); + firstTurn(battle); + const average = (10 + enemy.stats[0]) >> 1; + expect(battle.enemy.mon.hp).toBe(average); + expect(battle.player.mon.hp).toBe(Math.min(average, player.stats[0])); + }); + it("drums up the highest Attack for half the HP, keeping two stages when it fails", () => { + const drummer = mon(SNORLAX, 40, [BELLY_DRUM]); + const battle = duel([drummer], [mon(PIDGEY, 10, [SPLASH])]); + firstTurn(battle); + expect(battle.player.levels[C.ATTACK]).toBe(13); + expect(drummer.hp).toBe(drummer.stats[0] - (drummer.stats[0] >> 1)); + const weak = mon(SNORLAX, 40, [BELLY_DRUM]); + weak.hp = 10; + const failed = duel([weak], [mon(PIDGEY, 10, [SPLASH])]); + expect(firstTurn(failed).texts).toContain("But it failed!"); + expect(failed.player.levels[C.ATTACK]).toBe(9); + }); + it("copies the target's stat changes with Psych Up", () => { + const battle = duel([mon(STARMIE, 60, [PSYCH_UP])], [mon(SNORLAX, 30, [SWORDS_DANCE])]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts).toContain("But it failed!"); + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts.join(" ")).toContain("copied the stat changes of Enemy SNORLAX"); + expect(battle.player.levels[C.ATTACK]).toBe(9); + }); + it("guards against status with Safeguard for five turns", () => { + const battle = duel([mon(STARMIE, 60, [SAFEGUARD, SPLASH])], [mon(SNORLAX, 30, [POISONPOWDER])]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts.join(" ")).toContain("covered by a veil"); + expect(steps.texts.join(" ")).toContain("protected by SAFEGUARD"); + for (let turn = 0; turn < 4; turn++) steps.answer({ action: "fight", move: 1 }); + expect(steps.texts.join(" ")).toContain("SAFEGUARD faded"); + }); + it("counts Perish Song down until both faint", () => { + const battle = duel([mon(STARMIE, 60, [PERISH_SONG, SPLASH]), mon(PIDGEY, 10, [TACKLE])], [mon(SNORLAX, 60, [SPLASH])]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 0 }); + for (let turn = 0; turn < 3 && steps.prompt?.kind === "action"; turn++) + steps.answer({ action: "fight", move: 1 }); + const counts = steps.texts.filter((text) => text.includes("PERISH count is")); + expect(counts.slice(0, 2)).toEqual(["STARMIE's PERISH count is 3!", "Enemy SNORLAX's PERISH count is 3!"]); + expect(steps.texts.join(" ")).toContain("PERISH count is 0!"); + expect(battle.enemy.mon.hp).toBe(0); + }); + it("hurts the next Pokémon sent onto Spikes, which Rapid Spin blows away", () => { + const lead = mon(PIDGEY, 5, [TACKLE]); + lead.hp = 1; + const next = mon(SNORLAX, 30, [SPLASH]); + const battle = duel([mon(FORRETRESS, 60, [SPIKES, TACKLE])], [lead, next]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts.join(" ")).toContain("SPIKES scattered all around Enemy PIDGEY"); + steps.answer({ action: "fight", move: 1 }); + expect(steps.texts.join(" ")).toContain("hurt by SPIKES"); + expect(battle.enemy.mon.maxHp - battle.enemy.mon.hp).toBe((next.stats[0] >> 2) >> 1); + const spinner = duel([mon(FORRETRESS, 60, [RAPID_SPIN])], [mon(FORRETRESS, 60, [SPIKES])]); + const spun = turns(spinner, [0, 0]); + expect(spun.texts.join(" ")).toContain("blew away SPIKES"); + }); + it("may raise every stat with AncientPower", () => { + const { battle } = findSeed( + (seed) => duel([mon(ONIX, 60, [ANCIENTPOWER])], [mon(SNORLAX, 60, [SPLASH])], { seed }), + (texts) => texts.some((text) => text.includes("SPCL.DEF went up")), + ); + expect(battle.player.levels.slice(0, 5)).toEqual([8, 8, 8, 8, 8]); + }); + it("hits two turns later with Future Sight", () => { + const battle = duel([mon(STARMIE, 60, [FUTURE_SIGHT, SPLASH])], [mon(SNORLAX, 60, [SPLASH])]); + const steps = battleSteps(battle); + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts.join(" ")).toContain("foresaw an attack"); + const full = battle.enemy.mon.hp; + steps.answer({ action: "fight", move: 0 }); + expect(steps.texts).toContain("But it failed!"); + steps.answer({ action: "fight", move: 1 }); + expect(steps.texts.at(-1)).toBe("Enemy SNORLAX was hit by FUTURE SIGHT!"); + expect(battle.enemy.mon.hp).toBeLessThan(full); + }); + }); + describe("Counter and Mirror Coat", () => { it("returns twice a physical hit taken first", () => { const player = mon(SNORLAX, 40, [COUNTER]);