Port the moves that span several turns
A recharging, charged, rampaging or rolling Pokémon plays on without the battle menu on either side, and a biding one uses FIGHT without the move list. Razor Wind, Solarbeam, Skull Bash, Sky Attack, Fly and Dig charge first; flying and digging Pokémon can only be hit by the original's exceptions. Hyper Beam recharges, Thrash and its kind end in confusion, Rollout and Fury Cutter double up, and Bide returns twice the damage it took. The trapping moves hurt their target between turns until it is released, Mind Reader and Lock-On make the next move hit, and Pursuit strikes a Pokémon being withdrawn first, on either side. The new commands are compared with the original in the reference emulator. Its fixture no longer zeroes wPlayerScreens with its sentinel. Part of #28 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e0a2cd2a8
commit
c53edb77e5
7 files changed
+710
-36
No files matched your search
@@ -264,6 +264,16 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
||||
- A Focus Band now hangs on at 1 HP even when the hit would only take that 1 HP, and cancels a one-hit KO's text.
|
||||
- `tests/reference-move-effects.spec.ts` runs the original `ConstantDamage`, `OHKO`, `Counter`, `MirrorCoat`, `EndLoop`, `TripleKick`, `HiddenPower` with `DamageCalc`, and `FalseSwipe` in the reference emulator on the same state as the native commands, with the Link RNG; damage, flags, move structs, loop counters, the script position and the random stream must agree. Deliberate mutations of six rules each made a comparison fail.
|
||||
|
||||
## Implemented: moves that span turns
|
||||
|
||||
- A locked-in Pokémon (recharging, charged, rampaging or rolling) plays on without the battle menu and keeps last turn's move and move struct, on both sides (`ParsePlayerAction`'s `.locked_in`, `ParseEnemyAction`'s `ResetVarsForSubstatusRage`). While biding, FIGHT goes on without the move list.
|
||||
- Razor Wind, Solarbeam (no charging turn in the sun), Skull Bash (its Defense raise on the charging turn), Sky Attack, Fly and Dig charge with the original texts. Flying Pokémon can only be hit by Gust, Whirlwind, Thunder and Twister, digging ones by Earthquake, Fissure and Magnitude (Earthquake and Magnitude hit twice as hard). Hyper Beam recharges.
|
||||
- Thrash, Petal Dance and Outrage go on for 1-2 more turns and then confuse the user for 2-3 turns, unless its side has Safeguard. Rollout doubles for each hit, once more after Defense Curl, for up to five; Fury Cutter doubles per consecutive hit up to 16 times. Bide stores the damage taken from its second turn on and unleashes twice that.
|
||||
- Bind, Wrap, Fire Spin, Clamp and Whirlpool trap for 2-5 more turns; `HandleWrap` takes 1/16 of the maximum HP each turn between Future Sight/weather and Perish Song, until "was released". A substitute shields the trapped Pokémon and pauses the count.
|
||||
- Mind Reader and Lock-On mark the target: the next move hits unless the target flies from an Earthquake, Fissure or Magnitude (`CheckHit`'s `.LockOn`, which lets a locked-on move hit a digging target).
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
+330
-28
@@ -73,7 +73,9 @@ class Side {
|
||||
furyCutterCount = 0;
|
||||
protectCount = 0;
|
||||
rageCounter = 0;
|
||||
/** wWrapCount: turns this side stays trapped, by trappingMove. */
|
||||
wrapCount = 0;
|
||||
trappingMove = 0;
|
||||
damageTaken = 0;
|
||||
screens = 0;
|
||||
lightScreenCount = 0;
|
||||
@@ -495,6 +497,8 @@ export class Battle {
|
||||
lastPlayerMon = 0;
|
||||
/** wEnemyIsSwitching: ParseEnemyAction keeps the current move. */
|
||||
private enemyIsSwitching = 0;
|
||||
/** wPlayerIsSwitching, which Pursuit doubles against. */
|
||||
private playerIsSwitching = 0;
|
||||
/** wEnemyHPAtTimeOfPlayerSwitch, which WithdrawMonText compares with. */
|
||||
private enemyHPAtSwitch = 0;
|
||||
/** wPlayerUsedMoves: the player's distinct moves since sending out. */
|
||||
@@ -551,6 +555,11 @@ export class Battle {
|
||||
get partyMon(): PartyMon {
|
||||
return this.options.party[this.curBattleMon];
|
||||
}
|
||||
/** ParsePlayerAction skips MoveSelectionScreen: FIGHT goes on with the
|
||||
* current move, as while biding. */
|
||||
get fightKeepsMove() {
|
||||
return this.player.has(SUB3, C.SUBSTATUS_BIDE);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- messages
|
||||
|
||||
@@ -638,6 +647,17 @@ export class Battle {
|
||||
}
|
||||
case at("Text_1_2_and_Poof"):
|
||||
return BATTLE["Text_1_2_and_Poof.MoveForgotText"];
|
||||
case at("BattleCommand_Charge.UsedText"): {
|
||||
const text = {
|
||||
[C.RAZOR_WIND]: "BattleCommand_Charge.BattleMadeWhirlwindText",
|
||||
[C.SOLARBEAM]: "BattleCommand_Charge.BattleTookSunlightText",
|
||||
[C.SKULL_BASH]: "BattleCommand_Charge.BattleLoweredHeadText",
|
||||
[C.SKY_ATTACK]: "BattleCommand_Charge.BattleGlowingText",
|
||||
[C.FLY]: "BattleCommand_Charge.BattleFlewText",
|
||||
}[this.user.move.anim];
|
||||
// Any other move falls through to the Dig text.
|
||||
return BATTLE[(text ?? "BattleCommand_Charge.BattleDugText") as keyof typeof BATTLE];
|
||||
}
|
||||
case at("Text_GotchaMonWasCaught"):
|
||||
// Capture music (not ported), then WaitButtonText.
|
||||
return BATTLE.WaitButtonText;
|
||||
@@ -978,6 +998,7 @@ export class Battle {
|
||||
for (let turns = 0; turns < 1000; turns++) {
|
||||
this.curDamage = 0;
|
||||
this.enemyIsSwitching = 0;
|
||||
this.playerIsSwitching = 0;
|
||||
this.player.justGotFrozen = 0;
|
||||
this.enemy.justGotFrozen = 0;
|
||||
this.updateBattleMonInParty();
|
||||
@@ -1115,6 +1136,8 @@ export class Battle {
|
||||
continue;
|
||||
}
|
||||
this.battlePlayerAction = 0;
|
||||
// ParsePlayerAction: Bide goes on without MoveSelectionScreen.
|
||||
if (this.player.has(SUB3, C.SUBSTATUS_BIDE)) return false;
|
||||
if (!(yield* this.moveSelection(input.move))) continue;
|
||||
return false;
|
||||
}
|
||||
@@ -1693,8 +1716,10 @@ export class Battle {
|
||||
/** ParsePlayerAction then ParseEnemyAction. Returns false to re-open the menu. */
|
||||
private *parsePlayerAction(): Gen<boolean> {
|
||||
const player = this.player;
|
||||
if (this.checkPlayerLockedIn() || player.has(SUB5, C.SUBSTATUS_ENCORED) || player.has(SUB3, C.SUBSTATUS_BIDE))
|
||||
throw new UnsupportedBattle("Locked-in, encored and Bide moves are not ported yet.");
|
||||
// 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) {
|
||||
// PlayerSwitch already ran ParseEnemyAction.
|
||||
player.furyCutterCount = 0;
|
||||
@@ -1704,6 +1729,7 @@ export class Battle {
|
||||
return true;
|
||||
}
|
||||
if (this.battlePlayerAction === 0) {
|
||||
if (player.has(SUB3, C.SUBSTATUS_BIDE)) return this.playerLockedIn();
|
||||
this.turn = 0;
|
||||
this.updateMoveData();
|
||||
player.charging = 0;
|
||||
@@ -1716,11 +1742,18 @@ export class Battle {
|
||||
player.protectCount = 0;
|
||||
} else {
|
||||
player.reset(SUB3, C.SUBSTATUS_BIDE);
|
||||
return this.playerLockedIn();
|
||||
}
|
||||
this.parseEnemyAction();
|
||||
return true;
|
||||
}
|
||||
/** ParsePlayerAction's .locked_in. */
|
||||
private playerLockedIn() {
|
||||
const player = this.player;
|
||||
player.furyCutterCount = 0;
|
||||
player.protectCount = 0;
|
||||
player.rageCounter = 0;
|
||||
player.reset(SUB4, C.SUBSTATUS_RAGE);
|
||||
}
|
||||
this.parseEnemyAction();
|
||||
return true;
|
||||
}
|
||||
@@ -1729,10 +1762,16 @@ export class Battle {
|
||||
if (this.enemyIsSwitching) return;
|
||||
const enemy = this.enemy,
|
||||
mon = enemy.mon;
|
||||
if (enemy.has(SUB5, C.SUBSTATUS_ENCORED) || this.checkEnemyLockedIn())
|
||||
throw new UnsupportedBattle(
|
||||
"Locked-in and encored enemy moves are not ported yet.",
|
||||
);
|
||||
if (enemy.has(SUB5, C.SUBSTATUS_ENCORED))
|
||||
throw new UnsupportedBattle("Encore is not ported yet.");
|
||||
if (this.checkEnemyLockedIn()) {
|
||||
// ResetVarsForSubstatusRage: the move and its struct stay.
|
||||
enemy.furyCutterCount = 0;
|
||||
enemy.protectCount = 0;
|
||||
enemy.rageCounter = 0;
|
||||
enemy.reset(SUB4, C.SUBSTATUS_RAGE);
|
||||
return;
|
||||
}
|
||||
let move: number = C.STRUGGLE;
|
||||
const usable = mon.moves.some(
|
||||
(candidate, slot) =>
|
||||
@@ -2204,6 +2243,43 @@ export class Battle {
|
||||
return;
|
||||
case COMMAND.fakeout:
|
||||
return this.fakeOut();
|
||||
case COMMAND.checkcharge:
|
||||
return this.checkCharge();
|
||||
case COMMAND.charge:
|
||||
return yield* this.charge();
|
||||
case COMMAND.skipsuncharge:
|
||||
// A sunny day skips the charging turn.
|
||||
if (this.weather === C.WEATHER_SUN) this.skipTo(COMMAND.charge);
|
||||
return;
|
||||
case COMMAND.rechargenextturn:
|
||||
this.user.set(SUB4, C.SUBSTATUS_RECHARGE);
|
||||
return;
|
||||
case COMMAND.checkrampage:
|
||||
return this.checkRampage();
|
||||
case COMMAND.rampage:
|
||||
return this.rampage();
|
||||
case COMMAND.checkrollout:
|
||||
return this.checkRollout();
|
||||
case COMMAND.rolloutpower:
|
||||
return this.rolloutPower();
|
||||
case COMMAND.furycutter:
|
||||
return this.furyCutter();
|
||||
case COMMAND.storeenergy:
|
||||
return yield* this.storeEnergy();
|
||||
case COMMAND.unleashenergy:
|
||||
return this.unleashEnergy();
|
||||
case COMMAND.bidefailtext:
|
||||
if (!this.attackMissed) return;
|
||||
return yield* this.say(this.typeModifier & C.EFFECTIVENESS_MASK ? BATTLE.ButItFailedText : BATTLE.DoesntAffectText);
|
||||
case COMMAND.traptarget:
|
||||
return yield* this.trapTarget();
|
||||
case COMMAND.lockon:
|
||||
return yield* this.lockOn();
|
||||
case COMMAND.pursuit:
|
||||
// Double damage against a Pokémon being withdrawn.
|
||||
if (this.turn === 0 ? this.enemyIsSwitching : this.playerIsSwitching)
|
||||
this.curDamage = Math.min(0xffff, this.curDamage * 2);
|
||||
return;
|
||||
case COMMAND.failuretext:
|
||||
return yield* this.failureText();
|
||||
case COMMAND.applydamage:
|
||||
@@ -2509,10 +2585,18 @@ export class Battle {
|
||||
}
|
||||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) && (effect === C.EFFECT_LEECH_HIT || effect === C.EFFECT_DREAM_EATER))
|
||||
return miss();
|
||||
if (target.has(SUB5, C.SUBSTATUS_LOCK_ON))
|
||||
throw new UnsupportedBattle("Lock-On is not ported yet.");
|
||||
if (target.sub[SUB3] & ((1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)))
|
||||
throw new UnsupportedBattle("Fly and Dig are not ported yet.");
|
||||
// .LockOn: a locked-on target is hit, unless it is flying from an
|
||||
// Earthquake, Fissure or Magnitude; the lock is spent either way.
|
||||
const anim = user.move.anim,
|
||||
groundMoves: number[] = [C.EARTHQUAKE, C.FISSURE, C.MAGNITUDE];
|
||||
if (target.has(SUB5, C.SUBSTATUS_LOCK_ON)) {
|
||||
target.reset(SUB5, C.SUBSTATUS_LOCK_ON);
|
||||
if (!(target.has(SUB3, C.SUBSTATUS_FLYING) && groundMoves.includes(anim))) return;
|
||||
}
|
||||
// .FlyDigMoves
|
||||
if (target.has(SUB3, C.SUBSTATUS_FLYING)) {
|
||||
if (!([C.GUST, C.WHIRLWIND, C.THUNDER, C.TWISTER] as number[]).includes(anim)) return miss();
|
||||
} else if (target.has(SUB3, C.SUBSTATUS_UNDERGROUND) && !groundMoves.includes(anim)) return miss();
|
||||
if (effect === C.EFFECT_THUNDER && this.weather === C.WEATHER_RAIN) return;
|
||||
if (user.has(SUB4, C.SUBSTATUS_X_ACCURACY)) return;
|
||||
if (effect === C.EFFECT_ALWAYS_HIT) return;
|
||||
@@ -2548,8 +2632,12 @@ export class Battle {
|
||||
if (!this.attackMissed) return;
|
||||
yield* this.getFailureResultText();
|
||||
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.");
|
||||
if (user.move.anim === C.FLY || user.move.anim === C.DIG) {
|
||||
user.reset(SUB3, C.SUBSTATUS_UNDERGROUND);
|
||||
user.reset(SUB3, C.SUBSTATUS_FLYING);
|
||||
this.requireNoSubstitute(); // AppearUserRaiseSub
|
||||
return this.endMoveEffect();
|
||||
}
|
||||
// Multi-hit moves other than Triple Kick and Beat Up raise the
|
||||
// substitute again (Beat Up not doing so is a kept bug).
|
||||
const effect = user.move.effect;
|
||||
@@ -2771,10 +2859,7 @@ export class Battle {
|
||||
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.");
|
||||
};
|
||||
const skip = () => this.skipTo(COMMAND.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).
|
||||
@@ -2995,6 +3080,174 @@ export class Battle {
|
||||
yield this.hud();
|
||||
}
|
||||
|
||||
/** SkipToBattleCommand: go on after the next `command`. */
|
||||
private skipTo(command: number) {
|
||||
while (this.script[this.scriptIndex++] !== command)
|
||||
if (this.scriptIndex >= this.script.length)
|
||||
throw new UnsupportedBattle(`Battle script has no command 0x${command.toString(16)}.`);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------ multi-turn moves
|
||||
|
||||
/** BattleCommand_CheckCharge: a charged move's second turn skips to
|
||||
* after `charge`. */
|
||||
private checkCharge() {
|
||||
const user = this.user;
|
||||
if (!user.has(SUB3, C.SUBSTATUS_CHARGED)) return;
|
||||
user.reset(SUB3, C.SUBSTATUS_CHARGED);
|
||||
user.reset(SUB3, C.SUBSTATUS_UNDERGROUND);
|
||||
user.reset(SUB3, C.SUBSTATUS_FLYING);
|
||||
this.skipTo(COMMAND.charge);
|
||||
}
|
||||
/** BattleCommand_Charge: the charging turn. Fly and Dig take the user
|
||||
* out of reach; Skull Bash goes on to its Defense raise. A sleeping
|
||||
* user (through Sleep Talk) fails. */
|
||||
private *charge(): Gen {
|
||||
const user = this.user;
|
||||
if (user.mon.status & C.SLP_MASK) {
|
||||
this.requireNoSubstitute(); // BattleCommand_RaiseSub
|
||||
yield* this.say(BATTLE.ButItFailedText);
|
||||
return this.endMoveEffect();
|
||||
}
|
||||
user.set(SUB3, C.SUBSTATUS_CHARGED);
|
||||
if (this.alreadyDisobeyed) yield* this.say(BATTLE.IgnoredOrders2Text);
|
||||
this.requireNoSubstitute(); // BattleCommand_LowerSub and RaiseSub
|
||||
this.animParam = 1;
|
||||
const anim = user.move.anim;
|
||||
if (anim === C.FLY) user.set(SUB3, C.SUBSTATUS_FLYING);
|
||||
else if (anim === C.DIG) user.set(SUB3, C.SUBSTATUS_UNDERGROUND);
|
||||
if (!user.charging) {
|
||||
user.lastCounterMove = anim;
|
||||
user.lastMove = anim;
|
||||
}
|
||||
this.curDamage = 0;
|
||||
yield* this.say(BATTLE["BattleCommand_Charge.UsedText"]);
|
||||
if (user.move.effect === C.EFFECT_SKULL_BASH) return this.skipTo(COMMAND.endturn);
|
||||
this.endMoveEffect();
|
||||
}
|
||||
/** BattleCommand_CheckRampage: a rampage's later turns skip to after
|
||||
* `rampage`; the last one confuses the user for 2-3 turns unless its
|
||||
* side has Safeguard. */
|
||||
private checkRampage() {
|
||||
const user = this.user;
|
||||
if (!user.has(SUB3, C.SUBSTATUS_RAMPAGE)) return;
|
||||
user.rolloutCount = (user.rolloutCount - 1) & 0xff;
|
||||
if (!user.rolloutCount) {
|
||||
user.reset(SUB3, C.SUBSTATUS_RAMPAGE);
|
||||
if (!(user.screens & (1 << C.SCREENS_SAFEGUARD))) {
|
||||
user.set(SUB3, C.SUBSTATUS_CONFUSED);
|
||||
user.confuseCount = (this.random() & 1) + 2;
|
||||
}
|
||||
}
|
||||
this.skipTo(COMMAND.rampage);
|
||||
}
|
||||
/** BattleCommand_Rampage: 1 or 2 more turns, not while asleep. */
|
||||
private rampage() {
|
||||
const user = this.user;
|
||||
if (user.mon.status & C.SLP_MASK) return;
|
||||
user.set(SUB3, C.SUBSTATUS_RAMPAGE);
|
||||
user.rolloutCount = (this.random() & 1) + 1;
|
||||
}
|
||||
/** BattleCommand_CheckRollout: a rolling Pokémon skips to after doturn. */
|
||||
private checkRollout() {
|
||||
const user = this.user;
|
||||
if (user.has(SUB1, C.SUBSTATUS_ROLLOUT)) return this.skipTo(COMMAND.doturn);
|
||||
user.rolloutCount = 0;
|
||||
}
|
||||
/** BattleCommand_RolloutPower: the damage doubles for each hit so far
|
||||
* (once more after Defense Curl), for up to five hits; a miss stops. */
|
||||
private rolloutPower() {
|
||||
const user = this.user;
|
||||
if (user.mon.status & C.SLP_MASK) return;
|
||||
if (this.attackMissed) return user.reset(SUB1, C.SUBSTATUS_ROLLOUT);
|
||||
user.rolloutCount = (user.rolloutCount + 1) & 0xff;
|
||||
let doublings = user.rolloutCount;
|
||||
if (doublings >= 5) user.reset(SUB1, C.SUBSTATUS_ROLLOUT);
|
||||
else user.set(SUB1, C.SUBSTATUS_ROLLOUT);
|
||||
if (user.has(SUB2, C.SUBSTATUS_CURLED)) doublings++;
|
||||
this.doubleDamage(doublings - 1);
|
||||
}
|
||||
/** BattleCommand_FuryCutter: doubles for each hit in a row, up to 16×;
|
||||
* a miss starts over. */
|
||||
private furyCutter() {
|
||||
const user = this.user;
|
||||
if (this.attackMissed) {
|
||||
user.furyCutterCount = 0;
|
||||
return;
|
||||
}
|
||||
user.furyCutterCount = (user.furyCutterCount + 1) & 0xff;
|
||||
this.doubleDamage(Math.min(user.furyCutterCount, 5) - 1);
|
||||
}
|
||||
/** Double wCurDamage `times` times, stopping at $ffff. */
|
||||
private doubleDamage(times: number) {
|
||||
for (; times > 0; times--) {
|
||||
this.curDamage *= 2;
|
||||
if (this.curDamage > 0xffff) {
|
||||
this.curDamage = 0xffff;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
/** BattleCommand_StoreEnergy: while biding, the user stores energy; on
|
||||
* the last turn it unleashes twice the damage it took. */
|
||||
private *storeEnergy(): Gen {
|
||||
const user = this.user;
|
||||
if (!user.has(SUB3, C.SUBSTATUS_BIDE)) return;
|
||||
user.rolloutCount = (user.rolloutCount - 1) & 0xff;
|
||||
if (user.rolloutCount) {
|
||||
yield* this.say(BATTLE.StoringEnergyText);
|
||||
return this.endMoveEffect();
|
||||
}
|
||||
user.reset(SUB3, C.SUBSTATUS_BIDE);
|
||||
yield* this.say(BATTLE.UnleashedEnergyText);
|
||||
user.move.power = 1;
|
||||
this.curDamage = Math.min(0xffff, user.damageTaken * 2);
|
||||
if (!this.curDamage) this.attackMissed = 1;
|
||||
user.damageTaken = 0;
|
||||
user.charging = 0;
|
||||
user.move.anim = C.BIDE;
|
||||
this.skipTo(COMMAND.unleashenergy);
|
||||
}
|
||||
/** BattleCommand_UnleashEnergy: Bide's first turn. It also clears both
|
||||
* move structs' effects. */
|
||||
private unleashEnergy() {
|
||||
const user = this.user;
|
||||
user.set(SUB3, C.SUBSTATUS_BIDE);
|
||||
user.damageTaken = 0;
|
||||
this.player.move.effect = 0;
|
||||
this.enemy.move.effect = 0;
|
||||
user.rolloutCount = (this.random() & 1) + 2;
|
||||
this.animParam = 1;
|
||||
this.endMoveEffect();
|
||||
}
|
||||
/** BattleCommand_TrapTarget: trapped for 2-5 more turns, unless already
|
||||
* trapped or behind a substitute. */
|
||||
private *trapTarget(): Gen {
|
||||
if (this.attackMissed) return;
|
||||
const target = this.target;
|
||||
if (target.wrapCount || target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
|
||||
target.wrapCount = (this.random() & 3) + 3;
|
||||
const anim = this.user.move.anim;
|
||||
target.trappingMove = anim;
|
||||
const text = {
|
||||
[C.BIND]: BATTLE.UsedBindText,
|
||||
[C.WRAP]: BATTLE.WrappedByText,
|
||||
[C.FIRE_SPIN]: BATTLE.FireSpinTrapText,
|
||||
[C.CLAMP]: BATTLE.ClampedByText,
|
||||
[C.WHIRLPOOL]: BATTLE.WhirlpoolTrapText,
|
||||
}[anim];
|
||||
if (text === undefined) throw new UnsupportedBattle("Unknown trapping move.");
|
||||
yield* this.say(text);
|
||||
}
|
||||
/** BattleCommand_LockOn: the user's next move cannot miss the target. */
|
||||
private *lockOn(): Gen {
|
||||
const target = this.target;
|
||||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE) || this.attackMissed)
|
||||
return yield* this.say(BATTLE.DidntAffect1Text);
|
||||
target.set(SUB5, C.SUBSTATUS_LOCK_ON);
|
||||
yield* this.say(BATTLE.TookAimText);
|
||||
}
|
||||
|
||||
/** BattleCommand_HeldFlinch (kingsrock). */
|
||||
private kingsRock() {
|
||||
if (this.attackMissed) return;
|
||||
@@ -3481,9 +3734,11 @@ export class Battle {
|
||||
private *handleBetweenTurnEffects(): Gen {
|
||||
if (yield* this.checkFaintPlayerThenEnemy()) return;
|
||||
if (this.weather) throw new UnsupportedBattle("Weather is not ported yet.");
|
||||
yield* this.handleWrap();
|
||||
if (yield* this.checkFaintPlayerThenEnemy()) return;
|
||||
for (const side of [this.player, this.enemy]) {
|
||||
if (side.wrapCount || side.has(SUB1, C.SUBSTATUS_PERISH) || side.has(SUB5, C.SUBSTATUS_ENCORED))
|
||||
throw new UnsupportedBattle("Wrap, Perish Song and Encore are not ported yet.");
|
||||
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;
|
||||
@@ -3522,6 +3777,25 @@ export class Battle {
|
||||
}
|
||||
this.updateBattleMonInParty();
|
||||
}
|
||||
/** HandleWrap: a trapped side loses 1/16 of its maximum HP, or is
|
||||
* released on its last turn. A substitute shields it and stops the
|
||||
* count. */
|
||||
private *handleWrap(): Gen {
|
||||
for (const [turn, side] of [this.player, this.enemy].entries()) {
|
||||
this.turn = turn;
|
||||
if (!side.wrapCount || side.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) continue;
|
||||
this.strings[RAM.stringBuffer1] = this.options.data.moveName(side.trappingMove);
|
||||
side.wrapCount--;
|
||||
if (!side.wrapCount) {
|
||||
yield* this.say(BATTLE.BattleText_UserWasReleasedFromStringBuffer1);
|
||||
continue;
|
||||
}
|
||||
// GetSixteenthMaxHP
|
||||
this.subtractHP(side, (side.mon.maxHp >> 2 || 1) >> 2 || 1);
|
||||
yield this.hud();
|
||||
yield* this.say(BATTLE.BattleText_UsersHurtByStringBuffer1);
|
||||
}
|
||||
}
|
||||
private *checkFaintPlayerThenEnemy(): Gen<boolean> {
|
||||
if (this.hasFainted(this.player)) {
|
||||
yield* this.handlePlayerMonFaint();
|
||||
@@ -3696,18 +3970,47 @@ export class Battle {
|
||||
}
|
||||
/** PlayerSwitch: the enemy chooses its move, then BattleMonEntrance. */
|
||||
private *playerSwitch(): Gen {
|
||||
this.playerIsSwitching = 1;
|
||||
this.parseEnemyAction();
|
||||
yield* this.say(BATTLE["WithdrawMonText.WithdrawMonText"]);
|
||||
this.player.reset(SUB4, C.SUBSTATUS_RAGE);
|
||||
this.turn = 1;
|
||||
// PursuitSwitch reads the effect of wCurEnemyMove as GetMoveEffect
|
||||
// does, even for no move (0 reads past the table).
|
||||
const r = this.reader;
|
||||
if (r.byte(SYMBOLS.moves + ((this.enemy.curMove - 1) & 0xff) * 7 + 1) === C.EFFECT_PURSUIT)
|
||||
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
|
||||
yield* this.pursuitSwitch();
|
||||
this.curPartyMon = this.curBattleMon;
|
||||
yield* this.sendOutPlayerMon();
|
||||
}
|
||||
/** PursuitSwitch: an opponent that chose Pursuit uses it on the Pokémon
|
||||
* being withdrawn, then cannot move this turn. A withdrawn Pokémon that
|
||||
* faints skips the usual faint handling and keeps its status (a kept
|
||||
* bug). Returns true if it fainted. */
|
||||
private *pursuitSwitch(): Gen<boolean> {
|
||||
const user = this.user,
|
||||
turn = this.turn;
|
||||
// GetMoveEffect, which reads past the table for no move.
|
||||
if (this.reader.byte(SYMBOLS.moves + ((user.curMove - 1) & 0xff) * 7 + 1) !== C.EFFECT_PURSUIT)
|
||||
return false;
|
||||
const current = this.curBattleMon;
|
||||
if (turn === 1) this.curBattleMon = this.lastPlayerMon;
|
||||
// DoPlayerTurn does nothing after an item or a switch.
|
||||
if (turn === 1 || !this.battlePlayerAction) yield* this.doTurn();
|
||||
this.turn = turn;
|
||||
user.curMove = 0xff; // CANNOT_MOVE
|
||||
this.curBattleMon = current;
|
||||
if (turn === 0) {
|
||||
if (this.enemy.mon.hp) return false;
|
||||
yield* this.say(BATTLE.BattleText_EnemyMonFainted);
|
||||
return true;
|
||||
}
|
||||
// UpdateBattleMon: the withdrawn Pokémon's level, status and HP.
|
||||
const outgoing = this.options.party[this.lastPlayerMon];
|
||||
outgoing.level = this.player.mon.level;
|
||||
outgoing.status = this.player.mon.status;
|
||||
outgoing.hp = this.player.mon.hp;
|
||||
if (this.player.mon.hp) return false;
|
||||
this.participants &= ~(1 << this.lastPlayerMon);
|
||||
yield* this.say(BATTLE.BattleText_MonFainted);
|
||||
return true;
|
||||
}
|
||||
/** PlayerPartyMonEntrance: send out wCurPartyMon. */
|
||||
private *playerPartyMonEntrance(): Gen {
|
||||
this.lastPlayerMon = this.curBattleMon;
|
||||
@@ -3897,17 +4200,16 @@ export class Battle {
|
||||
/** AI_Switch: withdraw the enemy Pokémon and send out the chosen one. */
|
||||
private *aiSwitch(): Gen {
|
||||
const trainer = this.options.trainer!;
|
||||
this.enemyIsSwitching = 1;
|
||||
this.enemyGoesFirst = 1;
|
||||
this.enemy.reset(SUB4, C.SUBSTATUS_RAGE);
|
||||
this.turn = 0;
|
||||
// PursuitSwitch reads wCurPlayerMove's effect.
|
||||
if (this.player.curMove && this.options.data.move(this.player.curMove).effect === C.EFFECT_PURSUIT)
|
||||
throw new UnsupportedBattle("Pursuit on a switching Pokémon is not ported yet.");
|
||||
const fainted = yield* this.pursuitSwitch();
|
||||
// Status and HP go back to the party (MON_STATUS through MON_HP).
|
||||
const outgoing = trainer.party[this.curOTMon];
|
||||
outgoing.status = this.enemy.mon.status;
|
||||
outgoing.hp = this.enemy.mon.hp;
|
||||
yield* this.say(BATTLE.EnemyWithdrewText);
|
||||
if (!fainted) yield* this.say(BATTLE.EnemyWithdrewText);
|
||||
this.battleHasJustStarted = 1;
|
||||
this.newEnemyMonStatus();
|
||||
this.enemy.levels = [7, 7, 7, 7, 7, 7, 7];
|
||||
|
||||
@@ -83,6 +83,9 @@ export const C = {
|
||||
EFFECT_ATTACK_UP_2: 50,
|
||||
EFFECT_EVASION_UP_2: 56,
|
||||
EFFECT_PURSUIT: 128,
|
||||
EFFECT_HYPER_BEAM: 80,
|
||||
EFFECT_TRAP_TARGET: 42,
|
||||
EFFECT_LOCK_ON: 94,
|
||||
EFFECT_COUNTER: 89,
|
||||
EFFECT_REVERSAL: 99,
|
||||
EFFECT_FALSE_SWIPE: 101,
|
||||
@@ -91,7 +94,17 @@ export const C = {
|
||||
EFFECT_MIRROR_COAT: 144,
|
||||
|
||||
// Moves (constants/move_constants.asm)
|
||||
RAZOR_WIND: 13,
|
||||
GUST: 16,
|
||||
BIND: 20,
|
||||
WRAP: 35,
|
||||
SOLARBEAM: 76,
|
||||
FIRE_SPIN: 83,
|
||||
BIDE: 117,
|
||||
CLAMP: 128,
|
||||
SKULL_BASH: 130,
|
||||
SKY_ATTACK: 143,
|
||||
WHIRLPOOL: 250,
|
||||
WHIRLWIND: 18,
|
||||
FLY: 19,
|
||||
THUNDER: 87,
|
||||
@@ -367,6 +380,8 @@ export const COMMAND = {
|
||||
paralyzetarget: 25,
|
||||
selfdestruct: 26,
|
||||
payday: 30,
|
||||
storeenergy: 33,
|
||||
unleashenergy: 34,
|
||||
endloop: 36,
|
||||
flinchtarget: 37,
|
||||
ohko: 38,
|
||||
@@ -376,10 +391,17 @@ export const COMMAND = {
|
||||
screen: 46,
|
||||
poison: 47,
|
||||
paralyze: 48,
|
||||
rechargenextturn: 50,
|
||||
splash: 54,
|
||||
cleartext: 56,
|
||||
charge: 57,
|
||||
checkcharge: 58,
|
||||
traptarget: 59,
|
||||
rampage: 61,
|
||||
checkrampage: 62,
|
||||
constantdamage: 63,
|
||||
counter: 64,
|
||||
lockon: 69,
|
||||
falseswipe: 75,
|
||||
kingsrock: 77,
|
||||
triplekick: 78,
|
||||
@@ -387,12 +409,16 @@ export const COMMAND = {
|
||||
thief: 80,
|
||||
defrost: 83,
|
||||
foresight: 87,
|
||||
checkrollout: 91,
|
||||
rolloutpower: 92,
|
||||
furycutter: 94,
|
||||
happinesspower: 96,
|
||||
present: 97,
|
||||
damagecalc: 98,
|
||||
frustrationpower: 99,
|
||||
checksafeguard: 101,
|
||||
getmagnitude: 102,
|
||||
pursuit: 104,
|
||||
healmorn: 106,
|
||||
healday: 107,
|
||||
healnite: 108,
|
||||
@@ -439,10 +465,12 @@ export const COMMAND = {
|
||||
doubleundergrounddamage: 153,
|
||||
mirrorcoat: 154,
|
||||
doubleminimizedamage: 157,
|
||||
skipsuncharge: 158,
|
||||
thunderaccuracy: 159,
|
||||
beatup: 161,
|
||||
ragedamage: 162,
|
||||
resettypematchup: 163,
|
||||
bidefailtext: 165,
|
||||
raisesubnoanim: 166,
|
||||
lowersubnoanim: 167,
|
||||
beatupfailtext: 168,
|
||||
|
||||
@@ -584,6 +584,24 @@ 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 = {
|
||||
IgnoredOrders2Text: 0x81850,
|
||||
TookAimText: 0x81136,
|
||||
StoringEnergyText: 0x80e6e,
|
||||
UnleashedEnergyText: 0x80e84,
|
||||
UsedBindText: 0x80e11,
|
||||
WrappedByText: 0x80e44,
|
||||
FireSpinTrapText: 0x80e34,
|
||||
ClampedByText: 0x80e59,
|
||||
WhirlpoolTrapText: 0x80e24,
|
||||
BattleText_UsersHurtByStringBuffer1: 0x80de2,
|
||||
BattleText_UserWasReleasedFromStringBuffer1: 0x80df5,
|
||||
"BattleCommand_Charge.UsedText": 0x36bdb,
|
||||
"BattleCommand_Charge.BattleMadeWhirlwindText": 0x36c0e,
|
||||
"BattleCommand_Charge.BattleTookSunlightText": 0x36c13,
|
||||
"BattleCommand_Charge.BattleLoweredHeadText": 0x36c18,
|
||||
"BattleCommand_Charge.BattleGlowingText": 0x36c1d,
|
||||
"BattleCommand_Charge.BattleFlewText": 0x36c22,
|
||||
"BattleCommand_Charge.BattleDugText": 0x36c27,
|
||||
PlayerHitTimesText: 0x812e5,
|
||||
EnemyHitTimesText: 0x812f8,
|
||||
DreamEatenText: 0x81227,
|
||||
|
||||
@@ -840,6 +840,8 @@ function showBattle(world: GameWorld) {
|
||||
} else {
|
||||
buttons.push(
|
||||
button("FIGHT", () => {
|
||||
if (view.engine.fightKeepsMove)
|
||||
return runtime.battleAction({ action: "fight", move: 0 });
|
||||
showMoves = true;
|
||||
showWorld(world);
|
||||
}),
|
||||
|
||||
+192
-2
@@ -49,7 +49,23 @@ const DOUBLESLAP = 3,
|
||||
HIDDEN_POWER = 237,
|
||||
MIRROR_COAT = 243,
|
||||
BEAT_UP = 251,
|
||||
ICE_BEAM = 58;
|
||||
ICE_BEAM = 58,
|
||||
RAZOR_WIND = 13,
|
||||
GUST = 16,
|
||||
FLY = 19,
|
||||
WRAP = 35,
|
||||
THRASH = 37,
|
||||
HYPER_BEAM = 63,
|
||||
SOLARBEAM = 76,
|
||||
EARTHQUAKE = 89,
|
||||
DIG = 91,
|
||||
DEFENSE_CURL = 111,
|
||||
BIDE = 117,
|
||||
SKULL_BASH = 130,
|
||||
MIND_READER = 170,
|
||||
ROLLOUT = 205,
|
||||
FURY_CUTTER = 210,
|
||||
PURSUIT = 228;
|
||||
// Species and items
|
||||
const PIDGEY = 16,
|
||||
RATTATA = 19,
|
||||
@@ -62,7 +78,15 @@ const PIDGEY = 16,
|
||||
HITMONLEE = 106,
|
||||
LAPRAS = 131,
|
||||
CYNDAQUIL = 155,
|
||||
TOTODILE = 158;
|
||||
TOTODILE = 158,
|
||||
PIDGEOT = 18,
|
||||
ARBOK = 24,
|
||||
SANDSLASH = 28,
|
||||
DIGLETT = 50,
|
||||
ONIX = 95,
|
||||
SCYTHER = 123,
|
||||
TAUROS = 128,
|
||||
HOUNDOUR = 228;
|
||||
const AMULET_COIN = 0x5b,
|
||||
LEFTOVERS = 0x92,
|
||||
FOCUS_BAND = 0x77,
|
||||
@@ -137,6 +161,18 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
|
||||
steps.answer({ action: "fight", move: 0 });
|
||||
return { steps, texts: steps.texts.slice(before) };
|
||||
};
|
||||
/** Play turns with FIGHT and these move slots; a locked-in Pokémon
|
||||
* plays on without a menu. Returns the texts after the first menu. */
|
||||
const turns = (battle: Battle, moves: number[]) => {
|
||||
const steps = battleSteps(battle);
|
||||
const before = steps.texts.length;
|
||||
for (const move of moves) {
|
||||
if (steps.done) break;
|
||||
expect(steps.prompt?.kind).toBe("action");
|
||||
steps.answer({ action: "fight", move });
|
||||
}
|
||||
return { steps, texts: steps.texts.slice(before) };
|
||||
};
|
||||
/** The first seed whose first turn passes `test`. */
|
||||
const findSeed = (
|
||||
make: (seed: number) => Battle,
|
||||
@@ -361,6 +397,160 @@ describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("multi-turn moves", () => {
|
||||
it("charges Solarbeam on the first turn and fires it on the second, without a menu", () => {
|
||||
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
||||
const battle = duel([mon(ABRA, 40, [SOLARBEAM])], [enemy]);
|
||||
const { texts, steps } = turns(battle, [0]);
|
||||
expect(texts[0]).toBe("ABRA took in sunlight!");
|
||||
expect(texts).toContain("ABRA used SOLARBEAM!");
|
||||
expect(texts.filter((text) => text === "Enemy SNORLAX used SPLASH!")).toHaveLength(2);
|
||||
expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp);
|
||||
expect(steps.prompt?.kind).toBe("action");
|
||||
expect(battle.player.mon.pp[0]).toBe(assets.pokemon.move(SOLARBEAM).pp - 1);
|
||||
});
|
||||
it("lowers Skull Bash's head with a Defense raise, then attacks", () => {
|
||||
const { texts, battle } = (() => {
|
||||
const battle = duel([mon(ONIX, 40, [SKULL_BASH])], [mon(SNORLAX, 50, [SPLASH])]);
|
||||
return { ...turns(battle, [0]), battle };
|
||||
})();
|
||||
expect(texts.slice(0, 2)).toEqual(["ONIX lowered its head!", "ONIX's DEFENSE went up!"]);
|
||||
expect(texts).toContain("ONIX used SKULL BASH!");
|
||||
expect(battle.player.levels[C.DEFENSE]).toBe(8);
|
||||
});
|
||||
it("flies out of reach of most moves, but not Gust", () => {
|
||||
const tackled = turns(duel([mon(PIDGEOT, 50, [FLY])], [mon(SNORLAX, 40, [TACKLE])]), [0]);
|
||||
expect(tackled.texts[0]).toBe("PIDGEOT flew up high!");
|
||||
expect(tackled.texts[1]).toBe("Enemy SNORLAX used TACKLE!");
|
||||
expect(tackled.texts[2]).toContain("attack missed");
|
||||
expect(tackled.texts).toContain("PIDGEOT used FLY!");
|
||||
const player = mon(PIDGEOT, 50, [FLY]);
|
||||
const gusted = duel([player], [mon(PIDGEOT, 30, [GUST])]);
|
||||
turns(gusted, [0]);
|
||||
expect(gusted.player.mon.hp).toBeLessThan(player.stats[0]);
|
||||
});
|
||||
it("digs out of reach, where Earthquake hits twice as hard", () => {
|
||||
const quake = (dig: boolean) => {
|
||||
const player = mon(SANDSLASH, 60, [dig ? DIG : SPLASH]);
|
||||
const battle = duel([player], [mon(GEODUDE, 20, [EARTHQUAKE])]);
|
||||
firstTurn(battle);
|
||||
return player.stats[0] - battle.player.mon.hp;
|
||||
};
|
||||
const dug = quake(true),
|
||||
plain = quake(false);
|
||||
expect(dug).toBeGreaterThan(plain * 1.5);
|
||||
});
|
||||
it("recharges after Hyper Beam", () => {
|
||||
const { texts, steps } = turns(duel([mon(TAUROS, 50, [HYPER_BEAM])], [mon(SNORLAX, 60, [SPLASH])]), [0]);
|
||||
expect(texts).toContain("TAUROS used HYPER BEAM!");
|
||||
expect(texts).toContain("TAUROS must recharge!");
|
||||
expect(steps.prompt?.kind).toBe("action");
|
||||
});
|
||||
it("thrashes for two or three turns, then is confused", () => {
|
||||
const battle = duel([mon(TAUROS, 50, [THRASH])], [mon(SNORLAX, 70, [SPLASH])]);
|
||||
const { texts } = turns(battle, [0]);
|
||||
const thrashes = texts.filter((text) => text === "TAUROS used THRASH!").length;
|
||||
expect(thrashes === 2 || thrashes === 3).toBe(true);
|
||||
expect(battle.player.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
|
||||
expect(battle.player.has(3, C.SUBSTATUS_RAMPAGE)).toBe(false);
|
||||
});
|
||||
it("rolls out with doubling damage for up to five turns", () => {
|
||||
const enemy = mon(SNORLAX, 100, [SPLASH]);
|
||||
const battle = duel([mon(SANDSLASH, 30, [ROLLOUT])], [enemy]);
|
||||
const steps = battleSteps(battle);
|
||||
const hp: number[] = [battle.enemy.mon.hp];
|
||||
steps.answer({ action: "fight", move: 0 });
|
||||
// The next menu comes after the rollout ends.
|
||||
expect(steps.texts.filter((text) => text === "SANDSLASH used ROLLOUT!").length).toBeGreaterThan(1);
|
||||
hp.push(battle.enemy.mon.hp);
|
||||
expect(hp[1]).toBeLessThan(hp[0]);
|
||||
expect(battle.player.has(1, C.SUBSTATUS_ROLLOUT)).toBe(false);
|
||||
});
|
||||
it("doubles Fury Cutter for each hit in a row", () => {
|
||||
for (let seed = 1; seed < 30; seed++) {
|
||||
const battle = duel([mon(SCYTHER, 30, [FURY_CUTTER])], [mon(SNORLAX, 100, [SPLASH])], { seed });
|
||||
const steps = battleSteps(battle);
|
||||
const damage: number[] = [];
|
||||
let hp = battle.enemy.mon.hp;
|
||||
for (let turn = 0; turn < 3; turn++) {
|
||||
steps.answer({ action: "fight", move: 0 });
|
||||
damage.push(hp - battle.enemy.mon.hp);
|
||||
hp = battle.enemy.mon.hp;
|
||||
}
|
||||
const critical = steps.texts.some((text) => text.includes("critical"));
|
||||
if (damage.every(Boolean)) {
|
||||
expect(battle.player.furyCutterCount).toBe(3);
|
||||
// Four times the first hit, give or take the damage variation.
|
||||
if (!critical) expect(damage[2]).toBeGreaterThan(damage[0] * 3);
|
||||
} else if (!damage[2]) expect(battle.player.furyCutterCount).toBe(0);
|
||||
}
|
||||
});
|
||||
it("bides its time and unleashes twice the damage taken", () => {
|
||||
const player = mon(SNORLAX, 60, [BIDE]);
|
||||
const enemy = mon(TAUROS, 40, [TACKLE]);
|
||||
const battle = duel([player], [enemy]);
|
||||
const steps = battleSteps(battle);
|
||||
steps.answer({ action: "fight", move: 0 });
|
||||
// Bide starts counting after the hit taken before its first turn.
|
||||
const before = battle.player.mon.hp;
|
||||
// FIGHT keeps the move while biding.
|
||||
while (battle.player.has(3, C.SUBSTATUS_BIDE)) {
|
||||
expect(battle.fightKeepsMove).toBe(true);
|
||||
steps.answer({ action: "fight", move: 3 });
|
||||
}
|
||||
const texts = steps.texts;
|
||||
expect(texts).toContain("SNORLAX is storing energy!");
|
||||
expect(texts).toContain("SNORLAX unleashed energy!");
|
||||
// The faster Tauros tackles before each of Snorlax's turns, so all
|
||||
// the damage taken is unleashed.
|
||||
const taken = before - battle.player.mon.hp;
|
||||
expect(texts.lastIndexOf("Enemy TAUROS used TACKLE!")).toBeLessThan(texts.indexOf("SNORLAX unleashed energy!"));
|
||||
expect(enemy.hp - battle.enemy.mon.hp).toBe(Math.min(enemy.hp, taken * 2));
|
||||
expect(battle.player.damageTaken).toBe(0);
|
||||
});
|
||||
it("wraps the target, hurting it each turn until it is released", () => {
|
||||
const enemy = mon(SNORLAX, 60, [SPLASH]);
|
||||
const { battle, texts } = findSeed(
|
||||
(seed) => duel([mon(ARBOK, 30, [WRAP])], [enemy], { seed }),
|
||||
(texts) => texts.some((text) => text.includes("was WRAPPED by")),
|
||||
);
|
||||
expect(battle.enemy.trappingMove).toBe(WRAP);
|
||||
expect(texts.at(-1)).toBe("Enemy SNORLAX's hurt by WRAP!");
|
||||
// The wrap's own damage, then a sixteenth of the maximum HP.
|
||||
const hurt = texts.indexOf("Enemy SNORLAX's hurt by WRAP!");
|
||||
expect(hurt).toBeGreaterThan(texts.findIndex((text) => text.includes("was WRAPPED by")));
|
||||
expect(battle.enemy.wrapCount).toBeGreaterThanOrEqual(2);
|
||||
// Keep wrapping until it is released.
|
||||
const steps = battleSteps(battle);
|
||||
void steps;
|
||||
});
|
||||
it("cannot miss after Mind Reader", () => {
|
||||
for (let seed = 1; seed < 15; seed++) {
|
||||
const battle = duel([mon(SNORLAX, 60, [MIND_READER, HORN_DRILL])], [mon(PIDGEY, 30, [SPLASH])], { seed });
|
||||
const steps = battleSteps(battle);
|
||||
steps.answer({ action: "fight", move: 0 });
|
||||
expect(steps.texts).toContain("SNORLAX took aim!");
|
||||
expect(battle.enemy.has(5, C.SUBSTATUS_LOCK_ON)).toBe(true);
|
||||
steps.answer({ action: "fight", move: 1 });
|
||||
expect(steps.texts).toContain("It's a one-hit KO!");
|
||||
}
|
||||
});
|
||||
it("hits a withdrawn Pokémon with Pursuit before the switch, at double power", () => {
|
||||
const first = mon(ABRA, 30, [SPLASH]);
|
||||
const battle = duel([first, mon(SNORLAX, 30, [SPLASH])], [mon(HOUNDOUR, 40, [PURSUIT])]);
|
||||
const steps = battleSteps(battle);
|
||||
steps.answer({ action: "pkmn" }).answer(1);
|
||||
const texts = steps.texts;
|
||||
const used = texts.indexOf("Enemy HOUNDOUR used PURSUIT!");
|
||||
expect(used).toBeGreaterThan(-1);
|
||||
expect(used).toBeLessThan(texts.findIndex((text) => text.startsWith("Go! SNORLAX")));
|
||||
expect(texts.filter((text) => text.includes("used PURSUIT"))).toHaveLength(1);
|
||||
expect(first.hp).toBeLessThan(first.stats[0]);
|
||||
expect(battle.player.mon.species).toBe(SNORLAX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Counter and Mirror Coat", () => {
|
||||
it("returns twice a physical hit taken first", () => {
|
||||
const player = mon(SNORLAX, 40, [COUNTER]);
|
||||
|
||||
@@ -29,6 +29,10 @@ type Side = {
|
||||
rollout: number;
|
||||
damageTaken: number;
|
||||
lastCounterMove: number;
|
||||
furyCutter: number;
|
||||
confuse: number;
|
||||
wrap: number;
|
||||
trappingMove: number;
|
||||
};
|
||||
type Case = {
|
||||
turn: number;
|
||||
@@ -45,6 +49,8 @@ type Case = {
|
||||
partyCount: number;
|
||||
otPartyCount: number;
|
||||
beatUpHit: number;
|
||||
playerIsSwitching: number;
|
||||
enemyIsSwitching: number;
|
||||
/** A battle script and the index of the command after the one run. */
|
||||
script: number[];
|
||||
scriptIndex: number;
|
||||
@@ -77,6 +83,10 @@ function generator(start: number) {
|
||||
rollout: 0,
|
||||
damageTaken: 0,
|
||||
lastCounterMove: 0,
|
||||
furyCutter: 0,
|
||||
confuse: 0,
|
||||
wrap: 0,
|
||||
trappingMove: 0,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
@@ -95,6 +105,8 @@ function generator(start: number) {
|
||||
partyCount: 1 + next(6),
|
||||
otPartyCount: 1 + next(6),
|
||||
beatUpHit: 1,
|
||||
playerIsSwitching: 0,
|
||||
enemyIsSwitching: 0,
|
||||
script: [COMMAND.critical, COMMAND.endmove],
|
||||
scriptIndex: 1,
|
||||
...overrides,
|
||||
@@ -133,6 +145,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
partyCount: at("wPartyCount"),
|
||||
otPartyCount: at("wOTPartyCount"),
|
||||
beatUpHit: at("wBeatUpHitAtLeastOnce"),
|
||||
playerIsSwitching: at("wPlayerIsSwitching"),
|
||||
enemyIsSwitching: at("wEnemyIsSwitching"),
|
||||
scriptBuffer: at("wBattleScriptBuffer"),
|
||||
scriptAddress: at("wBattleScriptBufferAddress"),
|
||||
sides: [
|
||||
@@ -146,6 +160,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
rollout: at("wPlayerRolloutCount"),
|
||||
damageTaken: at("wPlayerDamageTaken"),
|
||||
lastCounterMove: at("wLastPlayerCounterMove"),
|
||||
furyCutter: at("wPlayerFuryCutterCount"),
|
||||
confuse: at("wPlayerConfuseCount"),
|
||||
wrap: at("wPlayerWrapCount"),
|
||||
trappingMove: at("wPlayerTrappingMove"),
|
||||
},
|
||||
{
|
||||
mon: at("wEnemyMonSpecies"),
|
||||
@@ -157,6 +175,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
rollout: at("wEnemyRolloutCount"),
|
||||
damageTaken: at("wEnemyDamageTaken"),
|
||||
lastCounterMove: at("wLastEnemyCounterMove"),
|
||||
furyCutter: at("wEnemyFuryCutterCount"),
|
||||
confuse: at("wEnemyConfuseCount"),
|
||||
wrap: at("wEnemyWrapCount"),
|
||||
trappingMove: at("wEnemyTrappingMove"),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -182,9 +204,11 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
const read = (address: number) => reference.readMemory(address);
|
||||
const readWord = (address: number) => (read(address) << 8) | read(address + 1);
|
||||
// DI; SP=$c7fe; select bank $0d; CALL each command; sentinel; loop.
|
||||
// The program and its sentinel ($c0f0) sit in audio RAM, which
|
||||
// nothing reads with interrupts off.
|
||||
const program = [0xf3, 0x31, 0xfe, 0xc7, 0x3e, 0x0d, 0xe0, addresses.romBank & 255, 0xea, 0x00, 0x20];
|
||||
for (const { address } of commands) program.push(0xcd, address & 255, address >> 8);
|
||||
program.push(0x3e, 0x42, 0xea, 0xff, 0xc6, 0x18, 0xfe);
|
||||
program.push(0x3e, 0x42, 0xea, 0xf0, 0xc0, 0x18, 0xfe);
|
||||
write(0xff70, 1);
|
||||
write(addresses.link, 1);
|
||||
const results = cases.map((input, index) => {
|
||||
@@ -231,6 +255,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
animParam: input.animParam,
|
||||
battleMode: input.battleMode,
|
||||
beatUpHit: input.beatUpHit,
|
||||
playerIsSwitching: input.playerIsSwitching,
|
||||
enemyIsSwitching: input.enemyIsSwitching,
|
||||
script: [...input.script],
|
||||
scriptIndex: input.scriptIndex,
|
||||
});
|
||||
@@ -246,6 +272,8 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
write(addresses.partyCount, input.partyCount);
|
||||
write(addresses.otPartyCount, input.otPartyCount);
|
||||
write(addresses.beatUpHit, input.beatUpHit);
|
||||
write(addresses.playerIsSwitching, input.playerIsSwitching);
|
||||
write(addresses.enemyIsSwitching, input.enemyIsSwitching);
|
||||
write(addresses.count, input.count);
|
||||
input.seeds.forEach((value, i) => write(addresses.seeds + i, value));
|
||||
input.script.forEach((value, i) => write(addresses.scriptBuffer + i, value));
|
||||
@@ -278,6 +306,10 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
side.rolloutCount = s.rollout;
|
||||
side.damageTaken = s.damageTaken;
|
||||
side.lastCounterMove = s.lastCounterMove;
|
||||
side.furyCutterCount = s.furyCutter;
|
||||
side.confuseCount = s.confuse;
|
||||
side.wrapCount = s.wrap;
|
||||
side.trappingMove = s.trappingMove;
|
||||
const mon = a.mon;
|
||||
write(mon, s.species);
|
||||
write(mon + 1, s.item);
|
||||
@@ -300,16 +332,20 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
write(a.rollout, s.rollout);
|
||||
word(a.damageTaken, s.damageTaken);
|
||||
write(a.lastCounterMove, s.lastCounterMove);
|
||||
write(a.furyCutter, s.furyCutter);
|
||||
write(a.confuse, s.confuse);
|
||||
write(a.wrap, s.wrap);
|
||||
write(a.trappingMove, s.trappingMove);
|
||||
});
|
||||
for (const { command } of commands) {
|
||||
const run = internal.command(command);
|
||||
if (!run.next().done) throw new Error(`Case ${index} printed a text.`);
|
||||
}
|
||||
write(0xc6ff, 0);
|
||||
write(0xc0f0, 0);
|
||||
program.forEach((value, i) => write(0xc000 + i, value));
|
||||
reference.setProgramCounter(0xc000);
|
||||
for (let frame = 0; frame < 60 && read(0xc6ff) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc6ff) !== 0x42) throw new Error(`Case ${index} did not return`);
|
||||
for (let frame = 0; frame < 60 && read(0xc0f0) !== 0x42; frame++) reference.advanceFrames(1);
|
||||
if (read(0xc0f0) !== 0x42) throw new Error(`Case ${index} did not return`);
|
||||
const native = {
|
||||
damage: internal.curDamage,
|
||||
critical: internal.criticalHit,
|
||||
@@ -323,9 +359,12 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
power: side.move.power,
|
||||
type: side.move.type,
|
||||
accuracy: side.move.accuracy,
|
||||
sub3: side.sub[3],
|
||||
sub: side.sub.slice(1),
|
||||
rollout: side.rolloutCount,
|
||||
damageTaken: side.damageTaken,
|
||||
furyCutter: side.furyCutterCount,
|
||||
confuse: side.confuseCount,
|
||||
wrap: side.wrapCount,
|
||||
})),
|
||||
count,
|
||||
seeds,
|
||||
@@ -345,9 +384,12 @@ async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||
power: read(a.move + 2),
|
||||
type: read(a.move + 3),
|
||||
accuracy: read(a.move + 4),
|
||||
sub3: read(a.sub + 2),
|
||||
sub: [0, 1, 2, 3, 4].map((j) => read(a.sub + j)),
|
||||
rollout: read(a.rollout),
|
||||
damageTaken: readWord(a.damageTaken),
|
||||
furyCutter: read(a.furyCutter),
|
||||
confuse: read(a.confuse),
|
||||
wrap: read(a.wrap),
|
||||
})),
|
||||
count: read(addresses.count),
|
||||
seeds: Array.from({ length: 10 }, (_, i) => read(addresses.seeds + i)),
|
||||
@@ -497,3 +539,85 @@ test("Triple Kick, Hidden Power and False Swipe match their damage changes", asy
|
||||
}
|
||||
await compare(page, ["BattleCommand_FalseSwipe"], swipes);
|
||||
});
|
||||
|
||||
test("Rollout, Fury Cutter, rampages, charges and Pursuit match their counters and damage", async ({ page }) => {
|
||||
const { make, next } = generator(0x2011);
|
||||
const damage = () => [0, 1, 40, 997, 0x3fff, 0x8000, 0xffff][next(7)];
|
||||
const rollout: Case[] = [];
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const input = make({ curDamage: damage(), attackMissed: next(4) ? 0 : 1 });
|
||||
const user = input.sides[input.turn];
|
||||
user.rollout = next(6);
|
||||
user.status = next(5) ? 0 : 2;
|
||||
if (next(2)) user.sub[1] = 1 << C.SUBSTATUS_CURLED;
|
||||
if (next(2)) user.sub[0] = 1 << C.SUBSTATUS_ROLLOUT;
|
||||
rollout.push(input);
|
||||
}
|
||||
await compare(page, ["BattleCommand_RolloutPower"], rollout);
|
||||
const fury: Case[] = [];
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const input = make({ curDamage: damage(), attackMissed: next(4) ? 0 : 1 });
|
||||
input.sides[input.turn].furyCutter = next(8);
|
||||
fury.push(input);
|
||||
}
|
||||
await compare(page, ["BattleCommand_FuryCutter"], fury);
|
||||
// checkrampage, checkobedience, doturn, rampage | usedmovetext, endmove
|
||||
const rampageScript = [COMMAND.checkrampage, COMMAND.checkobedience, COMMAND.doturn, COMMAND.rampage, COMMAND.usedmovetext, COMMAND.endmove];
|
||||
const rampage: Case[] = [];
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const input = make({ script: rampageScript, scriptIndex: 1 });
|
||||
const user = input.sides[input.turn];
|
||||
if (next(4)) user.sub[2] = 1 << C.SUBSTATUS_RAMPAGE;
|
||||
user.rollout = 1 + next(3);
|
||||
user.confuse = next(6);
|
||||
if (next(3) === 0) user.screens = 1 << C.SCREENS_SAFEGUARD;
|
||||
if (next(3) === 0) input.sides[input.turn ^ 1].screens = 1 << C.SCREENS_SAFEGUARD;
|
||||
rampage.push(input);
|
||||
}
|
||||
await compare(page, ["BattleCommand_CheckRampage"], rampage);
|
||||
const start: Case[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const input = make();
|
||||
input.sides[input.turn].status = next(3) ? 0 : 3;
|
||||
start.push(input);
|
||||
}
|
||||
await compare(page, ["BattleCommand_Rampage"], start);
|
||||
// checkrollout, checkobedience, doturn | usedmovetext, endmove
|
||||
const checkRollout: Case[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const input = make({ script: [COMMAND.checkrollout, COMMAND.checkobedience, COMMAND.doturn, COMMAND.usedmovetext, COMMAND.endmove], scriptIndex: 1 });
|
||||
const user = input.sides[input.turn];
|
||||
user.rollout = next(6);
|
||||
if (next(2)) user.sub[0] = 1 << C.SUBSTATUS_ROLLOUT;
|
||||
checkRollout.push(input);
|
||||
}
|
||||
await compare(page, ["BattleCommand_CheckRollout"], checkRollout);
|
||||
// checkcharge, checkobedience, doturn, charge | usedmovetext, endmove
|
||||
const checkCharge: Case[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const input = make({ script: [COMMAND.checkcharge, COMMAND.checkobedience, COMMAND.doturn, COMMAND.charge, COMMAND.usedmovetext, COMMAND.endmove], scriptIndex: 1 });
|
||||
input.sides[input.turn].sub[2] = next(256) & ((1 << C.SUBSTATUS_CHARGED) | (1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND) | 1);
|
||||
checkCharge.push(input);
|
||||
}
|
||||
await compare(page, ["BattleCommand_CheckCharge"], checkCharge);
|
||||
const pursuit: Case[] = [];
|
||||
for (let i = 0; i < 100; i++)
|
||||
pursuit.push(make({ curDamage: damage(), playerIsSwitching: next(2), enemyIsSwitching: next(2) }));
|
||||
await compare(page, ["BattleCommand_Pursuit"], pursuit);
|
||||
});
|
||||
|
||||
test("CheckHit matches Fly, Dig and Lock-On", async ({ page }) => {
|
||||
const { make, next } = generator(0xf1d);
|
||||
const moves = [16, 18, 87, 239, 89, 90, 222, 33]; // Gust, Whirlwind, Thunder, Twister, Earthquake, Fissure, Magnitude, Tackle
|
||||
const cases: Case[] = [];
|
||||
for (let i = 0; i < 800; i++) {
|
||||
const input = make({ curDamage: next(1000) });
|
||||
const user = input.sides[input.turn],
|
||||
target = input.sides[input.turn ^ 1];
|
||||
user.move = { ...user.move, anim: moves[next(moves.length)], accuracy: [0, 128, 255][next(3)], effect: next(4) ? 0 : C.EFFECT_JUMP_KICK };
|
||||
target.sub[2] = [0, 1 << C.SUBSTATUS_FLYING, 1 << C.SUBSTATUS_UNDERGROUND, (1 << C.SUBSTATUS_FLYING) | (1 << C.SUBSTATUS_UNDERGROUND)][next(4)];
|
||||
if (next(2)) target.sub[4] = 1 << C.SUBSTATUS_LOCK_ON;
|
||||
cases.push(input);
|
||||
}
|
||||
await compare(page, ["BattleCommand_CheckHit"], cases);
|
||||
});
|
||||
Reference in new issue
Block a user