Port the status and field move effects

Toxic, Leech Seed, Nightmare and Curse hurt in ResidualDamage's order.
Protect, Detect, Endure, Destiny Bond, Spite, Heal Bell, Disable, Encore,
Attract, Mist, Focus Energy, Haze, the healing moves and Rest, Mean Look,
Pain Split, Belly Drum, Psych Up, Safeguard, Perish Song, Spikes, Rapid
Spin, AncientPower and Future Sight now run, with Future Sight, Perish
Song, Safeguard and Encore handled between turns and Spikes on entry.

A trainer's party struct now follows its Pokémon where the original
updates it, and the enemy's moves spend PP there or in the wild
Pokémon's kept PP, so a caught Pokémon keeps the PP it used.

Part of #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sébastien QUEROLandClaude Opus 5.5 committed 2026-10-03 11:11:19 +02:00
1 parent c53edb77e5
commit 8f5b464efc
6 files changed
+1036 -52

No files matched your search

+623 -46
View File
@@ -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<boolean> {
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) {