Port multi-hit moves and the special-damage move effects

Multi-hit moves loop back to the script's critical command as EndLoop
does, with its hit-count draws, Triple Kick's growing kicks and Beat Up's
party members, and say "Hit n times!" from the borrowed high byte of the
user's damage taken. ConstantDamage covers Seismic Toss, Night Shade,
Sonicboom, Dragon Rage, Psywave, Super Fang and Flail/Reversal.

One-hit KO moves, Counter, Mirror Coat, False Swipe, Magnitude, Present,
Hidden Power, Return, Frustration, Selfdestruct, Dream Eater, Thief, Pay
Day, freezing, Tri Attack, added confusion, Swagger, thawing by fire
moves, Jump Kick's crash and Minimize with Stomp now run. Pay Day's coins
are paid after a win, and the Amulet Coin is checked at every send-out.
A Focus Band now also holds on at 1 HP.

The original's arithmetic commands are compared with the native ones in
the reference emulator on shared states and random streams.

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 10:45:07 +02:00
1 parent 802397b668
commit 9e0a2cd2a8
9 files changed
+1707 -29

No files matched your search

+592 -27
View File
@@ -133,6 +133,8 @@ export type BattleOutcome = {
prizeMoney?: number;
/** The part of the prize sent to Mom's savings. */
momMoney?: number;
/** wPayDayMoney as CheckPayDay adds it to the wallet after a win. */
payDayMoney?: number;
evolvable?: number;
};
@@ -191,6 +193,9 @@ export interface BattleOptions {
mom?: { saving: number; money: number };
/** wOptions' BATTLE_SHIFT clear: the SHIFT battle style (the default). */
shift?: boolean;
/** wOptions' BATTLE_SCENE clear: battle animations on (the default).
* Some texts only show with them off (_CheckBattleScene). */
battleScene?: boolean;
}
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
@@ -501,6 +506,17 @@ export class Battle {
private typeModifier: number = C.EFFECTIVE;
private alreadyDisobeyed = 0;
private alreadyFailed = 0;
/** wTypeMatchup from the last BattleCheckTypeMatchup. */
private matchup: number = C.EFFECTIVE;
/** wBattleAnimParam, which Triple Kick also reads as its kick count. */
private animParam = 0;
/** wPayDayMoney: Pay Day's coins, paid out after a win. */
private payDayMoney = 0;
/** wAmuletCoin: a Pokémon sent out held an Amulet Coin. */
private amuletCoin = false;
/** wCurBeatUpPartyMon and wBeatUpHitAtLeastOnce. */
private beatUpMon = 0;
private beatUpHit = 0;
private turnEnded = 0;
private loweredStat = 0;
private failedMessage = 0;
@@ -823,6 +839,28 @@ export class Battle {
/** StartBattle through ExitBattle. */
*run(): Gen<BattleOutcome> {
const outcome = yield* this.doBattle();
// ExitBattle: CheckPayDay after a win.
if (outcome.result & 0x0f) return outcome;
const payDayMoney = yield* this.checkPayDay();
return payDayMoney ? { ...outcome, payDayMoney } : outcome;
}
/** CheckPayDay: the coins, doubled by an Amulet Coin, for the wallet. */
private *checkPayDay(): Gen<number> {
let money = this.payDayMoney;
if (!money) return 0;
if (this.amuletCoin) money = Math.min(0xffffff, money * 2);
this.numbers[RAM.payDayMoney] = money;
yield* this.say(BATTLE.BattleText_PlayerPickedUpPayDayMoney);
return money;
}
/** CheckAmuletCoin, at every SendOutPlayerMon. */
private checkAmuletCoin() {
if (this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN)
this.amuletCoin = true;
}
private *doBattle(): Gen<BattleOutcome> {
const trainer = this.options.trainer;
if (trainer) {
validateTrainerAI(trainer.aiFlags, trainer.party.flatMap((mon) =>
@@ -867,8 +905,7 @@ export class Battle {
);
this.resetBattleParticipants();
this.initBattleMon();
const amuletCoin =
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
this.checkAmuletCoin();
this.newStatus(this.player);
this.newStatus(this.enemy);
yield* this.sendOutMonText();
@@ -896,7 +933,7 @@ export class Battle {
if (trainer.winText) yield* this.say(trainer.winText);
return {
result: this.battleResult,
...(yield* this.giveMoney(trainer.reward, amuletCoin)),
...(yield* this.giveMoney(trainer.reward, this.amuletCoin)),
evolvable: this.evolvable,
};
}
@@ -1934,7 +1971,7 @@ export class Battle {
}
/** EndMoveEffect: stop reading the effect script. */
private endMoveEffect() {
this.script.splice(this.scriptIndex, this.script.length, 0xff, 0xff, 0xff);
this.script.splice(this.scriptIndex, 3, 0xff, 0xff, 0xff);
}
private *endTurn(): Gen {
this.turnEnded = 1;
@@ -1947,6 +1984,7 @@ export class Battle {
if (user.curMove === 0xff) return yield* this.endTurn(); // CANNOT_MOVE
this.attackMissed = 0;
this.effectFailed = 0;
this.animParam = 0;
this.alreadyDisobeyed = 0;
this.alreadyFailed = 0;
this.typeModifier = C.EFFECTIVE;
@@ -2073,12 +2111,99 @@ export class Battle {
return this.effectChance();
case COMMAND.lowersub:
case COMMAND.raisesub:
case COMMAND.moveanimnosub:
case COMMAND.moveanim:
case COMMAND.statupanim:
case COMMAND.statdownanim:
case COMMAND.lowersubnoanim:
case COMMAND.raisesubnoanim:
case COMMAND.movedelay:
return this.requireNoSubstitute();
case COMMAND.moveanimnosub:
case COMMAND.moveanim:
this.requireNoSubstitute();
return this.moveAnimNoSub();
case COMMAND.statupanim:
case COMMAND.statdownanim:
this.requireNoSubstitute();
// BattleCommand_StatUpDownAnim
if (!this.attackMissed) this.animParam = 0;
return;
case COMMAND.cleartext:
return; // An empty BattleTextbox.
case COMMAND.startloop:
this.user.rolloutCount = 0;
return;
case COMMAND.endloop:
return yield* this.endLoop();
case COMMAND.supereffectivelooptext:
if (this.user.has(SUB3, C.SUBSTATUS_IN_LOOP)) return;
return yield* this.superEffectiveText();
case COMMAND.triplekick:
// The damage once more for each kick so far, up to $ffff.
this.curDamage = Math.min(0xffff, this.curDamage * (this.animParam + 1));
return;
case COMMAND.kickcounter:
this.animParam = (this.animParam + 1) & 0xff;
return;
case COMMAND.beatup:
return yield* this.beatUp();
case COMMAND.beatupfailtext:
return yield* this.beatUpFailText();
case COMMAND.constantdamage:
return this.constantDamage();
case COMMAND.resettypematchup:
this.resetTypeMatchup();
return;
case COMMAND.ohko:
return yield* this.ohko();
case COMMAND.counter:
return this.counter(false);
case COMMAND.mirrorcoat:
return this.counter(true);
case COMMAND.payday:
return yield* this.payDay();
case COMMAND.thief:
return yield* this.thief();
case COMMAND.selfdestruct:
return yield* this.selfdestruct();
case COMMAND.eatdream:
return yield* this.drainTarget(BATTLE.DreamEatenText);
case COMMAND.freezetarget:
return yield* this.freezeTarget();
case COMMAND.tristatuschance:
return yield* this.triStatusChance();
case COMMAND.defrost:
return yield* this.defrostUser();
case COMMAND.confusetarget:
return yield* this.confuseTarget();
case COMMAND.getmagnitude:
return yield* this.getMagnitude();
case COMMAND.present:
return yield* this.present();
case COMMAND.hiddenpower:
return this.hiddenPower();
case COMMAND.happinesspower:
// BattleCommand_HappinessPower: happiness × 10 / 25, so a
// Pokémon below 3 happiness deals no damage (a kept bug).
this.damageArgs.power = Math.floor((this.user.mon.happiness * 10) / 25) & 0xff;
return;
case COMMAND.frustrationpower:
this.damageArgs.power = Math.floor(((0xff - this.user.mon.happiness) * 10) / 25) & 0xff;
return;
case COMMAND.falseswipe:
this.falseSwipe();
return;
case COMMAND.doubleundergrounddamage:
if (this.target.has(SUB3, C.SUBSTATUS_UNDERGROUND)) this.curDamage = Math.min(0xffff, this.curDamage * 2);
return;
case COMMAND.doubleminimizedamage:
if (this.target.minimized) this.curDamage = Math.min(0xffff, this.curDamage * 2);
return;
case COMMAND.thunderaccuracy:
// Overwrites the move's accuracy; rain's 100% is redundant with
// CheckHit's own rain check.
if (this.weather === C.WEATHER_RAIN) this.user.move.accuracy = 0xff;
else if (this.weather === C.WEATHER_SUN) this.user.move.accuracy = percent(50) + 1;
return;
case COMMAND.fakeout:
return this.fakeOut();
case COMMAND.failuretext:
return yield* this.failureText();
case COMMAND.applydamage:
@@ -2151,7 +2276,7 @@ export class Battle {
default:
if (command >= COMMAND.attackup && command <= COMMAND.evasionup2) {
const stat = (command - COMMAND.attackup) % 7;
return this.raiseStat(stat | (command >= COMMAND.attackup2 ? 0x10 : 0));
return this.statUp(stat | (command >= COMMAND.attackup2 ? 0x10 : 0));
}
if (command >= COMMAND.attackdown && command <= COMMAND.evasiondown2) {
const stat = (command - COMMAND.attackdown) % 7;
@@ -2340,8 +2465,23 @@ export class Battle {
});
this.curDamage = result.damage;
this.typeModifier = result.modifier;
this.matchup = result.matchup;
if (result.missed) this.attackMissed = 1;
}
/** BattleCheckTypeMatchup: wTypeMatchup for the move against the target. */
private battleCheckTypeMatchup() {
const target = this.target;
return (this.matchup = this.typeMatchup(this.user.move.type, target.mon.types, target.has(SUB1, C.SUBSTATUS_IDENTIFIED)));
}
/** BattleCommand_ResetTypeMatchup: an immune target makes the move miss;
* otherwise wTypeMatchup becomes EFFECTIVE. Returns wTypeMatchup. */
private resetTypeMatchup() {
if (this.battleCheckTypeMatchup()) return (this.matchup = C.EFFECTIVE);
this.curDamage = 0;
this.typeModifier = 0;
this.attackMissed = 1;
return 0;
}
/** BattleCommand_DamageVariation: 217-255 / 255. */
private damageVariation() {
if (this.curDamage < 2) return;
@@ -2410,6 +2550,11 @@ export class Battle {
const user = this.user;
if (user.move.anim === C.FLY || user.move.anim === C.DIG)
throw new UnsupportedBattle("Fly and Dig are not ported yet.");
// Multi-hit moves other than Triple Kick and Beat Up raise the
// substitute again (Beat Up not doing so is a kept bug).
const effect = user.move.effect;
if (effect === C.EFFECT_MULTI_HIT || effect === C.EFFECT_DOUBLE_HIT || effect === C.EFFECT_POISON_MULTI_HIT)
this.requireNoSubstitute();
this.endMoveEffect();
}
private *getFailureResultText(): Gen {
@@ -2427,8 +2572,13 @@ export class Battle {
}
yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? protectedText : text);
this.criticalHit = 0;
if (this.user.move.effect === C.EFFECT_JUMP_KICK && this.typeModifier & C.EFFECTIVENESS_MASK)
throw new UnsupportedBattle("Jump Kick crash damage is not ported yet.");
if (this.user.move.effect !== C.EFFECT_JUMP_KICK || !(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
// (Hi) Jump Kick crashes for an eighth of the damage it would have
// dealt, at least 1, ignoring the user's substitute.
this.curDamage = this.curDamage >> 3 || 1;
yield* this.say(BATTLE.CrashedText);
this.doDamage(this.user, true);
yield this.hud();
}
/** BattleCommand_ApplyDamage. */
@@ -2448,15 +2598,14 @@ export class Battle {
yield* this.say(BATTLE.HungOnText);
}
}
/** BattleCommand_FalseSwipe's rule: leave 1 HP if the hit would faint. */
/** BattleCommand_FalseSwipe: a hit that would faint leaves 1 HP (no
* damage at all against 1 HP) and is no longer a one-hit KO. Returns
* true (carry) when it changed the damage. */
private falseSwipe(): boolean {
const hp = this.target.mon.hp;
if (this.curDamage < hp) return false;
this.curDamage = hp - 1;
if (this.curDamage <= 0) {
this.curDamage = 0;
return false;
}
this.curDamage = (hp - 1) & 0xffff;
if (this.criticalHit === 2) this.criticalHit = 0;
return true;
}
/** DoEnemyDamage / DoPlayerDamage. */
@@ -2484,9 +2633,11 @@ export class Battle {
if (this.target.mon.hp) return;
if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND))
throw new UnsupportedBattle("Destiny Bond is not ported yet.");
// 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;
if ([C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP].includes(effect as never))
throw new UnsupportedBattle("Multi-hit moves are not ported yet.");
this.requireNoSubstitute(); // BattleCommand_RaiseSub
this.endMoveEffect();
}
private *buildOpponentRage(): Gen {
@@ -2510,6 +2661,340 @@ export class Battle {
}
this.curDamage = damage;
}
/** BattleCommand_MoveAnimNoSub's effect on wBattleAnimParam: multi-hit
* moves alternate it, Triple Kick keeps its kick count, others clear it. */
private moveAnimNoSub() {
if (this.attackMissed) return;
const effect = this.user.move.effect;
if (effect === C.EFFECT_MULTI_HIT || effect === C.EFFECT_CONVERSION || effect === C.EFFECT_DOUBLE_HIT || effect === C.EFFECT_POISON_MULTI_HIT)
this.animParam = (this.animParam & 1) ^ 1;
else if (effect !== C.EFFECT_TRIPLE_KICK) this.animParam = 0;
}
// ------------------------------------------------------ multi-hit moves
/** BattleCommand_EndLoop. The first pass draws the extra hits into the
* user's rollout count and the total into the high byte of its
* wDamageTaken, which "Hit n times!" prints; later passes count down.
* Each repeat resumes at the script's `critical`. */
private *endLoop(): Gen {
const user = this.user,
effect = user.move.effect;
if (user.has(SUB3, C.SUBSTATUS_IN_LOOP)) {
user.rolloutCount = (user.rolloutCount - 1) & 0xff;
if (user.rolloutCount) return this.loopBackToCritical();
return yield* this.doneLoop();
}
user.set(SUB3, C.SUBSTATUS_IN_LOOP);
let extra: number;
if (effect === C.EFFECT_POISON_MULTI_HIT || effect === C.EFFECT_DOUBLE_HIT) extra = 1;
else if (effect === C.EFFECT_BEAT_UP) {
const count =
this.turn === 0
? this.options.party.length
: this.battleMode === C.WILD_BATTLE
? 1
: this.options.trainer!.party.length;
if (count === 1) {
// .only_one_beatup: no raisesub or King's Rock (a kept bug).
user.reset(SUB3, C.SUBSTATUS_IN_LOOP);
yield* this.beatUpFailText();
return this.endMoveEffect();
}
extra = count - 1;
} else if (effect === C.EFFECT_TRIPLE_KICK) {
let kicks: number;
do kicks = this.random() & 3;
while (!kicks);
if (kicks === 1) {
this.setHitCount(user, 1);
return yield* this.doneLoop();
}
extra = kicks - 1;
} else {
// Two or three hits, or a second draw for two to five.
let draw = this.random() & 3;
if (draw >= 2) draw = this.random() & 3;
extra = draw + 1;
}
user.rolloutCount = extra;
this.setHitCount(user, extra + 1);
this.loopBackToCritical();
}
private setHitCount(side: Side, hits: number) {
side.damageTaken = ((hits & 0xff) << 8) | (side.damageTaken & 0xff);
}
/** EndLoop's .done_loop: the hit count text (not for Beat Up), then the
* high byte of wDamageTaken is cleared. */
private *doneLoop(): Gen {
const user = this.user;
user.reset(SUB3, C.SUBSTATUS_IN_LOOP);
if (user.move.effect !== C.EFFECT_BEAT_UP) {
this.numbers[this.turn === 0 ? RAM.playerDamageTaken : RAM.enemyDamageTaken] = user.damageTaken >> 8;
yield* this.say(this.turn === 0 ? BATTLE.PlayerHitTimesText : BATTLE.EnemyHitTimesText);
}
user.damageTaken &= 0xff;
}
/** EndLoop's .loop_back_to_critical: search back for `critical`. */
private loopBackToCritical() {
let index = this.scriptIndex;
while (index >= 0 && this.script[index] !== COMMAND.critical) index--;
if (index < 0) throw new UnsupportedBattle("Multi-hit script has no critical command.");
this.scriptIndex = index;
}
/** BattleCommand_BeatUp: the next party member's base Attack and level
* against the target's base Defense. A fainted or statused member skips
* to after buildopponentrage. Wild Pokémon hit with their own stats but
* never count a hit, so "But it failed!" follows (a kept bug). */
private *beatUp(): Gen {
this.curDamage = 0;
const user = this.user,
data = this.options.data;
const player = this.turn === 0,
party = player ? this.options.party : this.options.trainer?.party;
if (!user.has(SUB3, C.SUBSTATUS_IN_LOOP)) {
user.rolloutCount = 0;
this.beatUpMon = 0;
this.beatUpHit = 0;
} else {
const count = player ? this.options.party.length : this.options.trainer!.party.length;
this.beatUpMon = (count - user.rolloutCount) & 0xff;
}
if (!party) {
this.strings[RAM.stringBuffer1] = data.speciesName(this.enemy.mon.species);
yield* this.say(BATTLE.BeatUpAttackText);
return this.damageStats(); // EnemyAttackDamage
}
const mon = party[this.beatUpMon];
if (!mon) throw new UnsupportedBattle("Beat Up ran past the party.");
if (party.some((member) => member.species === C.EGG))
throw new UnsupportedBattle("Beat Up with an Egg in the party is not ported yet.");
// The player's members go by nickname, a trainer's by species name.
this.strings[RAM.stringBuffer1] = player ? mon.nickname : data.speciesName(mon.species);
const skip = () => {
while (this.script[this.scriptIndex++] !== COMMAND.buildopponentrage)
if (this.scriptIndex >= this.script.length) throw new UnsupportedBattle("Beat Up script has no buildopponentrage.");
};
if (!mon.hp) return skip();
// The player's side takes the battle status when wCurBattleMon equals
// the member's HP low byte rather than its index (a kept bug).
const active = player ? this.curBattleMon === (mon.hp & 0xff) : this.curOTMon === this.beatUpMon;
if ((active ? user.mon.status : mon.status) !== 0) return skip();
this.beatUpHit = 1;
yield* this.say(BATTLE.BeatUpAttackText);
this.damageArgs = {
power: user.move.power,
level: mon.level,
attack: data.baseData(mon.species).stats[C.ATTACK + 1],
defense: data.baseData(this.target.mon.species).stats[C.DEFENSE + 1],
};
}
/** BattleCommand_BeatUpFailText. */
private *beatUpFailText(): Gen {
if (!this.beatUpHit) yield* this.say(BATTLE.ButItFailedText);
}
// ------------------------------------------------- special damage moves
/** BattleCommand_ConstantDamage: the level, the move's power, Psywave's
* random damage below 1.5 × level, half the target's HP, or Flail and
* Reversal's power from the user's HP bar. */
private constantDamage() {
const user = this.user,
level = user.mon.level;
switch (user.move.effect) {
case C.EFFECT_LEVEL_DAMAGE:
this.curDamage = level;
return;
case C.EFFECT_PSYWAVE: {
const limit = (level + (level >> 1)) & 0xff;
// A limit of 1 or less loops forever in the original.
if (limit <= 1) throw new UnsupportedBattle("Psywave at this level freezes the original.");
for (let attempt = 0; attempt < 4096; attempt++) {
const damage = this.random();
if (damage && damage < limit) {
this.curDamage = damage;
return;
}
}
throw new UnsupportedBattle("Psywave random source did not produce damage.");
}
case C.EFFECT_SUPER_FANG:
this.curDamage = this.target.mon.hp >> 1 || 1;
return;
case C.EFFECT_REVERSAL:
return this.reversalDamage();
default:
this.curDamage = user.move.power;
}
}
/** ConstantDamage's .reversal: HP bar pixels (HP × 48 / max HP, scaled
* down by 4 when max HP has a high byte) pick FlailReversalPower's power
* for a normal damage calculation, then the move's power becomes 1. */
private reversalDamage() {
const user = this.user,
{ hp, maxHp } = user.mon;
let dividend = hp * 48,
divisor = maxHp & 0xff;
if (maxHp >> 8) {
divisor = (maxHp >> 2) & 0xff;
dividend = (dividend >> 2) & 0xffff;
}
if (!divisor) throw new UnsupportedBattle("Reversal divides by zero.");
const pixels = Math.floor(dividend / divisor) & 0xff;
let p = BATTLE.FlailReversalPower;
while (this.reader.byte(p) < pixels) p += 2;
user.move.power = this.reader.byte(p + 1);
this.damageStats();
this.damageCalcCommand();
user.move.power = 1;
}
/** BattleCommand_OHKO: fails against a higher level; otherwise each
* level of difference adds 2 to the accuracy before CheckHit. */
private *ohko(): Gen {
this.curDamage = 0;
const user = this.user,
difference = user.mon.level - this.target.mon.level;
if (!(this.typeModifier & C.EFFECTIVENESS_MASK) || difference < 0) {
this.criticalHit = 0xff; // "is unaffected!"
this.attackMissed = 1;
return;
}
user.move.accuracy = Math.min(0xff, user.move.accuracy + ((difference * 2) & 0xff));
yield* this.checkHit();
this.curDamage = 0xffff;
this.criticalHit = 2;
}
/** BattleCommand_Counter and BattleCommand_MirrorCoat: twice the damage
* of the opponent's last physical (special) move, if the opponent moved
* first. wCurDamage still holds that damage, even after the opponent
* used an item instead (a kept bug). */
private counter(special: boolean) {
this.attackMissed = 1;
const data = this.options.data,
last = this.target.lastCounterMove;
if (!last) return;
const move = data.move(last);
if (move.effect === (special ? C.EFFECT_MIRROR_COAT : C.EFFECT_COUNTER)) return;
if (!this.resetTypeMatchup()) return;
if (!(this.enemyGoesFirst ^ this.turn)) return;
if (!move.power || move.type >= C.SPECIAL !== special) return;
if (!this.curDamage) return;
this.curDamage = Math.min(0xffff, this.curDamage * 2);
this.attackMissed = 0;
}
/** BattleCommand_GetMagnitude: MagnitudePower's power for damagecalc. */
private *getMagnitude(): Gen {
const roll = this.random();
let p = BATTLE.MagnitudePower;
while (this.reader.byte(p) < roll) p += 3;
this.damageArgs.power = this.reader.byte(p + 1);
this.numbers[RAM.textDecimalByte] = this.reader.byte(p + 2);
yield* this.say(BATTLE.MagnitudeText);
}
/** BattleCommand_Present: a PresentPower power for damagecalc, or a
* quarter of the target's maximum HP as a gift. A target at full HP
* refuses it, which is only said with battle animations off. */
private *present(): Gen {
this.stab();
if (!this.matchup || this.attackMissed) return;
const roll = this.random(),
r = this.reader;
let p = BATTLE.PresentPower;
for (let index = 0; r.byte(p) !== 0xff; index++, p += 2)
if (r.byte(p) >= roll) {
this.animParam = index;
this.damageArgs.power = r.byte(p + 1);
return;
}
this.animParam = 3;
const target = this.target.mon;
if (target.hp === target.maxHp) {
if (this.options.battleScene === false) yield* this.say(BATTLE.PresentFailedText);
return this.endMoveEffect();
}
// GetQuarterMaxHP of the target, then RestoreHP.
target.hp = Math.min(target.maxHp, (target.hp + (target.maxHp >> 2 || 1)) & 0xffff);
yield this.hud();
this.turn ^= 1;
yield* this.say(BATTLE.RegainedHealthText);
this.turn ^= 1;
this.updateOpponentInParty();
this.endMoveEffect();
}
/** HiddenPowerDamage: the type and power from the user's DVs, then the
* damage stats for that type. */
private hiddenPower() {
if (this.attackMissed) return;
const dvs = this.user.mon.dvs,
attackDefense = dvs >> 8,
speedSpecial = dvs & 0xff;
const bits =
((attackDefense >> 4) & 8) | ((attackDefense & 8) >> 1) | ((speedSpecial >> 6) & 2) | ((speedSpecial & 8) >> 3);
const power = ((bits * 5 + (speedSpecial & 3)) >> 1) + 31;
let type = ((attackDefense & 3) | (((attackDefense >> 4) & 3) << 2)) + 1;
if (type >= C.BIRD) type++;
if (type >= C.UNUSED_TYPES) type += C.UNUSED_TYPES_END - C.UNUSED_TYPES;
this.user.move.type = type;
this.damageStats();
this.damageArgs.power = power;
}
/** BattleCommand_FakeOut: flinches unless the target has a substitute,
* sleeps, is frozen or moved first; otherwise the move misses. */
private fakeOut() {
if (this.attackMissed) return;
const target = this.target;
if (
target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) ||
target.mon.status & ((1 << C.FRZ) | C.SLP_MASK) ||
this.enemyGoesFirst ^ this.turn
) {
this.attackMissed = 1;
return;
}
target.set(SUB3, C.SUBSTATUS_FLINCHED);
target.reset(SUB4, C.SUBSTATUS_RECHARGE);
}
/** BattleCommand_PayDay: twice the user's level in coins. */
private *payDay(): Gen {
this.payDayMoney = (this.payDayMoney + ((this.user.mon.level * 2) & 0xff)) & 0xffffff;
yield* this.say(BATTLE.CoinsScatteredText);
}
/** BattleCommand_Thief: an item-less user takes the target's item, but
* not MAIL. A trainer's Pokémon keeps a stolen item for the rest of the
* battle, so the player loses it for good. */
private *thief(): Gen {
const data = this.options.data,
user = this.user,
target = this.target;
if (user.mon.item) return;
const item = target.mon.item;
if (!item || data.itemIsMail(item) || this.effectFailed) return;
const otMon = this.options.trainer?.party[this.curOTMon];
target.mon.item = 0;
user.mon.item = item;
if (this.turn === 0) {
this.partyMon.item = item;
if (otMon) otMon.item = 0;
} else {
this.partyMon.item = 0;
if (otMon) otMon.item = item;
}
this.strings[RAM.stringBuffer1] = data.itemName(item);
yield* this.say(BATTLE.StoleText);
}
/** BattleCommand_Selfdestruct: the user faints, hit or miss, and loses
* Leech Seed; the target's Destiny Bond is cleared. */
private *selfdestruct(): Gen {
const user = this.user;
user.mon.status = 0;
user.mon.hp = 0;
this.requireNoSubstitute(); // BattleCommand_LowerSub
user.reset(SUB4, C.SUBSTATUS_LEECH_SEED);
this.target.reset(SUB5, C.SUBSTATUS_DESTINY_BOND);
yield this.hud();
}
/** BattleCommand_HeldFlinch (kingsrock). */
private kingsRock() {
if (this.attackMissed) return;
@@ -2528,6 +3013,68 @@ export class Battle {
private isType(side: Side, type: number) {
return side.mon.types[0] === type || side.mon.types[1] === type;
}
/** CheckMoveTypeMatchesTarget: the move shares a type with the target,
* unless it is Normal (Tri Attack). */
private moveTypeMatchesTarget() {
const type = this.user.move.type;
return type !== C.NORMAL && this.isType(this.target, type);
}
/** Defrost: a fire move thaws a frozen target, in the party struct too
* (wOTPartyMons for a trainer; a wild Pokémon has none to keep). */
private *defrostOpponent(): Gen {
const target = this.target;
if (!(target.mon.status & (1 << C.FRZ))) return;
target.mon.status = 0;
if (this.turn === 1) this.partyMon.status = 0;
else if (this.options.trainer) this.options.trainer.party[this.curOTMon].status = 0;
this.updateOpponentInParty();
yield this.hud();
yield* this.say(BATTLE.DefrostedOpponentText);
}
/** BattleCommand_Defrost: Flame Wheel and Sacred Fire thaw their user;
* a wild Pokémon's party struct is left alone. */
private *defrostUser(): Gen {
const user = this.user;
if (!(user.mon.status & (1 << C.FRZ))) return;
user.mon.status &= ~(1 << C.FRZ);
if (this.turn === 0) this.partyMon.status &= ~(1 << C.FRZ);
else if (this.battleMode !== C.WILD_BATTLE)
this.options.trainer!.party[this.curOTMon].status &= ~(1 << C.FRZ);
yield this.hud();
yield* this.say(BATTLE.WasDefrostedText);
}
/** BattleCommand_FreezeTarget. */
private *freezeTarget(): Gen {
const target = this.target;
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || target.mon.status) return;
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
if (this.weather === C.WEATHER_SUN) return;
if (this.moveTypeMatchesTarget()) return; // no freezing an Ice type
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_FREEZE) return;
if (this.effectFailed || this.safeguarded()) return;
target.mon.status |= 1 << C.FRZ;
this.updateOpponentInParty();
yield this.hud();
yield* this.say(BATTLE.WasFrozenText);
if (yield* this.useHeldStatusHealingItem()) return;
// OpponentCantMove and EndRechargeOpp; a freeze this turn skips
// HandleDefrost.
this.turn ^= 1;
this.cantMove();
this.turn ^= 1;
target.reset(SUB4, C.SUBSTATUS_RECHARGE);
target.justGotFrozen = 1;
}
/** BattleCommand_TriStatusChance: one of paralysis, freeze or burn. */
private *triStatusChance(): Gen {
this.effectChance();
let choice: number;
do choice = (this.random() >> 4) & 3;
while (!choice);
if (choice === 1) yield* this.paralyzeTarget();
else if (choice === 2) yield* this.freezeTarget();
else yield* this.burnTarget();
}
/** UseHeldStatusHealingItem for the opponent. Returns true if it cured. */
private *useHeldStatusHealingItem(): Gen<boolean> {
const side = this.target,
@@ -2598,12 +3145,9 @@ export class Battle {
private *burnTarget(): Gen {
const target = this.target;
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
if (target.mon.status) {
if (target.mon.status & (1 << C.FRZ)) throw new UnsupportedBattle("Defrosting is not ported yet.");
return;
}
if (target.mon.status) return yield* this.defrostOpponent();
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
if (this.user.move.type !== C.NORMAL && this.isType(target, this.user.move.type)) return;
if (this.moveTypeMatchesTarget()) return; // no burning a Fire type
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_BURN) return;
if (this.effectFailed || this.safeguarded()) return;
target.mon.status |= 1 << C.BRN;
@@ -2715,19 +3259,34 @@ export class Battle {
if (effect === C.EFFECT_CONFUSE_HIT || effect === C.EFFECT_SNORE || effect === C.EFFECT_SWAGGER) return;
return yield* this.didntAffect2();
}
yield* this.finishConfusingTarget();
}
/** BattleCommand_ConfuseTarget: a move's added confusion, silent when
* it fails. */
private *confuseTarget(): Gen {
const target = this.target;
if (this.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_CONFUSE) return;
if (this.effectFailed || this.safeguarded() || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
if (target.has(SUB3, C.SUBSTATUS_CONFUSED)) return;
yield* this.finishConfusingTarget();
}
/** BattleCommand_FinishConfusingTarget: confused for 2-5 turns. */
private *finishConfusingTarget(): Gen {
const target = this.target;
target.set(SUB3, C.SUBSTATUS_CONFUSED);
target.confuseCount = (this.random() & 3) + 2;
yield* this.say(BATTLE.BecameConfusedText);
yield* this.useConfusionHealingItem();
}
/** BattleCommand_DrainTarget: SapHealth restores half the damage. */
private *drainTarget(): Gen {
/** BattleCommand_DrainTarget and BattleCommand_EatDream: SapHealth
* restores half the damage. */
private *drainTarget(text: number = BATTLE.SuckedHealthText): Gen {
const mon = this.user.mon;
const amount = this.curDamage >> 1 || 1;
mon.hp = Math.min(mon.maxHp, mon.hp + amount);
if (this.turn === 0) this.updateBattleMonInParty();
yield this.hud();
yield* this.say(BATTLE.SuckedHealthText);
yield* this.say(text);
}
/** BattleCommand_Recoil: a quarter of the damage dealt, at least 1 HP. */
private *recoil(): Gen {
@@ -2797,7 +3356,12 @@ export class Battle {
this.calcStats(user);
}
this.failedMessage = 0;
if (user.move.anim === C.MINIMIZE) throw new UnsupportedBattle("Minimize is not ported yet.");
}
/** BattleCommand_StatUp: RaiseStat, then MinimizeDropSub records a
* successful Minimize for Stomp. */
private statUp(stat: number) {
this.raiseStat(stat);
if (!this.failedMessage && this.user.move.anim === C.MINIMIZE) this.user.minimized = 1;
}
/** BattleCommand_StatDown. */
private statDown(stat: number) {
@@ -3155,6 +3719,7 @@ export class Battle {
private *sendOutPlayerMon(): Gen {
this.addBattleParticipant();
this.initBattleMon();
this.checkAmuletCoin();
yield* this.sendOutMonText();
this.newStatus(this.player); // with ResetPlayerStatLevels
this.breakAttraction();
+40
View File
@@ -83,6 +83,12 @@ export const C = {
EFFECT_ATTACK_UP_2: 50,
EFFECT_EVASION_UP_2: 56,
EFFECT_PURSUIT: 128,
EFFECT_COUNTER: 89,
EFFECT_REVERSAL: 99,
EFFECT_FALSE_SWIPE: 101,
EFFECT_HIDDEN_POWER: 135,
EFFECT_FAKE_OUT: 141,
EFFECT_MIRROR_COAT: 144,
// Moves (constants/move_constants.asm)
GUST: 16,
@@ -108,6 +114,9 @@ export const C = {
NORMAL: 0,
FLYING: 2,
POISON: 3,
BIRD: 6,
UNUSED_TYPES: 10,
UNUSED_TYPES_END: 20,
FIRE: 20,
SPECIAL: 20,
ICE: 25,
@@ -352,10 +361,15 @@ export const COMMAND = {
poisontarget: 19,
sleeptarget: 20,
draintarget: 21,
eatdream: 22,
burntarget: 23,
freezetarget: 24,
paralyzetarget: 25,
selfdestruct: 26,
payday: 30,
endloop: 36,
flinchtarget: 37,
ohko: 38,
recoil: 39,
confuse: 42,
confusetarget: 43,
@@ -363,13 +377,26 @@ export const COMMAND = {
poison: 47,
paralyze: 48,
splash: 54,
cleartext: 56,
constantdamage: 63,
counter: 64,
falseswipe: 75,
kingsrock: 77,
triplekick: 78,
kickcounter: 79,
thief: 80,
defrost: 83,
foresight: 87,
happinesspower: 96,
present: 97,
damagecalc: 98,
frustrationpower: 99,
checksafeguard: 101,
getmagnitude: 102,
healmorn: 106,
healday: 107,
healnite: 108,
hiddenpower: 109,
attackup: 112,
defenseup: 113,
speedup: 114,
@@ -406,12 +433,25 @@ export const COMMAND = {
statdownanim: 145,
statupanim: 146,
switchturn: 147,
fakeout: 148,
rage: 151,
doubleflyingdamage: 152,
doubleundergrounddamage: 153,
mirrorcoat: 154,
doubleminimizedamage: 157,
thunderaccuracy: 159,
beatup: 161,
ragedamage: 162,
resettypematchup: 163,
raisesubnoanim: 166,
lowersubnoanim: 167,
beatupfailtext: 168,
clearmissdamage: 169,
movedelay: 170,
moveanim: 171,
tristatuschance: 172,
supereffectivelooptext: 173,
startloop: 174,
curl: 175,
endmove: 0xff,
endturn: 0xfe,
+19
View File
@@ -166,6 +166,10 @@ export const RAM = {
blueCardBalance: 0xdc4b,
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
moneyTemp: 0xffc3,
playerDamageTaken: 0xc682,
enemyDamageTaken: 0xc684,
payDayMoney: 0xc6ec,
textDecimalByte: 0xd265,
} as const;
/** Event constants named directly by translated special routines. */
@@ -580,6 +584,21 @@ 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 = {
PlayerHitTimesText: 0x812e5,
EnemyHitTimesText: 0x812f8,
DreamEatenText: 0x81227,
WasFrozenText: 0x8125d,
WasDefrostedText: 0x8162b,
CoinsScatteredText: 0x81435,
StoleText: 0x815da,
MagnitudeText: 0x81751,
BeatUpAttackText: 0x8182d,
PresentFailedText: 0x8183b,
CrashedText: 0x81046,
BattleText_PlayerPickedUpPayDayMoney: 0x80730,
MagnitudePower: 0x379b4,
PresentPower: 0x37907,
FlailReversalPower: 0x35807,
HookedPokemonAttackedText: 0x8075c,
MagikarpLengths: 0xfbca8,
ItemUsedText: 0xf83d,
+4
View File
@@ -47,6 +47,10 @@ export function validOptions(value: unknown): value is GameOptions {
export const shiftBattleStyle = (options: GameOptions) =>
!(options.options & (1 << OW.BATTLE_SHIFT));
/** StartMenu's MENU_ACCOUNT test: describe the highlighted item. */
/** _CheckBattleScene: BATTLE_SCENE clear means battle animations are on. */
export const battleSceneOn = (options: GameOptions) =>
!(options.options & (1 << OW.BATTLE_SCENE));
export const menuAccount = (options: GameOptions) =>
Boolean(options.options2 & (1 << OW.MENU_ACCOUNT));
+7 -2
View File
@@ -29,6 +29,7 @@ import {
import {
OPTIONS_BUTTONS,
shiftBattleStyle,
battleSceneOn,
validOptions,
defaultOptions,
type GameOptions,
@@ -2474,6 +2475,7 @@ export class GameWorld {
money: draft.momsMoney,
},
shift: shiftBattleStyle(draft.options),
battleScene: battleSceneOn(draft.options),
});
this.battle = { engine, selected: 1 };
this.battleRun = engine.run();
@@ -3102,8 +3104,11 @@ export class GameWorld {
if (next.done) {
if (this.script) {
const draft = this.script.run.state,
{ prizeMoney = 0, momMoney = 0 } = next.value;
draft.money = Math.min(C.MAX_MONEY, draft.money + prizeMoney);
{ prizeMoney = 0, momMoney = 0, payDayMoney = 0 } = next.value;
draft.money = Math.min(
C.MAX_MONEY,
draft.money + prizeMoney + payDayMoney,
);
draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
}
this.battle = undefined;