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:
1 parent
802397b668
commit
9e0a2cd2a8
9 files changed
+1707
-29
No files matched your search
@@ -255,6 +255,15 @@ The numbered sections below keep their full scope. Each one opens with a checkli
|
|||||||
- Wild Magikarp now battle: `LoadEnemyMon` draws their DVs again as the original does, through `CalcMagikarpLength` (compared with the original routine) and its filters, whose millimetre limits only work as a floor of four feet that about 39% of draws skip (39 percent + 1), nowhere in map group 9 or on maps numbered 6 (the Lake of Rage check). Unown are still refused.
|
- Wild Magikarp now battle: `LoadEnemyMon` draws their DVs again as the original does, through `CalcMagikarpLength` (compared with the original routine) and its filters, whose millimetre limits only work as a floor of four feet that about 39% of draws skip (39 percent + 1), nowhere in map group 9 or on maps numbered 6 (the Lake of Rage check). Unown are still refused.
|
||||||
- Save format 3 gained `repel`, `coins` and `blueCardBalance`, migrated by absence to 0.
|
- Save format 3 gained `repel`, `coins` and `blueCardBalance`, migrated by absence to 0.
|
||||||
|
|
||||||
|
## Implemented: multi-hit and special-damage moves
|
||||||
|
|
||||||
|
- Multi-hit moves loop as `BattleCommand_EndLoop` does: back to the script's `critical`, so accuracy is checked once; two to five hits (a second draw above two), two for Double Kick, Bonemerang and Twineedle, one to three kicks for Triple Kick with growing damage (its count lives in `wBattleAnimParam`). "Hit n times!" reads the high byte of the user's `wDamageTaken`, which the loop borrows; effectiveness is said on the first hit only. A target that faints mid-loop ends the move without the count.
|
||||||
|
- Beat Up hits once per party member that is neither fainted nor statused, with its base Attack and level against the target's base Defense. The original's bugs are kept: the player's active member is recognised by comparing `wCurBattleMon` with the HP's low byte, a lone Pokémon skips King's Rock, and a wild user says "But it failed!" after hitting. A party with an Egg is refused until eggs exist.
|
||||||
|
- `ConstantDamage`: Seismic Toss and Night Shade (the level), Sonicboom and Dragon Rage, Psywave (below 1.5 × level; a level-1 user is refused, as the original loops forever), Super Fang and Flail/Reversal (HP bar pixels into `FlailReversalPower`, then the move's power becomes 1). Immune types make them miss (`ResetTypeMatchup`).
|
||||||
|
- One-hit KO moves, Counter and Mirror Coat (twice the opponent's last physical or special damage when it moved first, still working after the opponent used an item), False Swipe, Magnitude, Present (a full target refuses the gift, said only with battle animations off), Hidden Power, Return and Frustration (no damage at the extremes of happiness, a kept bug), Selfdestruct and Explosion, Dream Eater, Thief (not MAIL; a trainer's Pokémon keeps the player's item), Pay Day (paid out after a win, doubled by an Amulet Coin, which is now checked at every send-out), freezing, Tri Attack, added confusion, Swagger, Flame Wheel and Sacred Fire thawing their user, fire moves thawing their target, Jump Kick's crash damage, Minimize with Stomp's double damage, Earthquake against a digging target and Thunder's weather accuracy.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## Native battles: current state
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -165,6 +165,10 @@ const ram = new Map(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const ramNames = {
|
const ramNames = {
|
||||||
|
playerDamageTaken: "wPlayerDamageTaken",
|
||||||
|
enemyDamageTaken: "wEnemyDamageTaken",
|
||||||
|
payDayMoney: "wPayDayMoney",
|
||||||
|
textDecimalByte: "wTextDecimalByte",
|
||||||
battleReward: "wBattleReward",
|
battleReward: "wBattleReward",
|
||||||
stringBuffer1: "wStringBuffer1",
|
stringBuffer1: "wStringBuffer1",
|
||||||
stringBuffer2: "wStringBuffer2",
|
stringBuffer2: "wStringBuffer2",
|
||||||
|
|||||||
+592
-27
@@ -133,6 +133,8 @@ export type BattleOutcome = {
|
|||||||
prizeMoney?: number;
|
prizeMoney?: number;
|
||||||
/** The part of the prize sent to Mom's savings. */
|
/** The part of the prize sent to Mom's savings. */
|
||||||
momMoney?: number;
|
momMoney?: number;
|
||||||
|
/** wPayDayMoney as CheckPayDay adds it to the wallet after a win. */
|
||||||
|
payDayMoney?: number;
|
||||||
evolvable?: number;
|
evolvable?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,6 +193,9 @@ export interface BattleOptions {
|
|||||||
mom?: { saving: number; money: number };
|
mom?: { saving: number; money: number };
|
||||||
/** wOptions' BATTLE_SHIFT clear: the SHIFT battle style (the default). */
|
/** wOptions' BATTLE_SHIFT clear: the SHIFT battle style (the default). */
|
||||||
shift?: boolean;
|
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. */
|
/** BATTLERESULT_BOX_FULL: a catch filled the current box. */
|
||||||
@@ -501,6 +506,17 @@ export class Battle {
|
|||||||
private typeModifier: number = C.EFFECTIVE;
|
private typeModifier: number = C.EFFECTIVE;
|
||||||
private alreadyDisobeyed = 0;
|
private alreadyDisobeyed = 0;
|
||||||
private alreadyFailed = 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 turnEnded = 0;
|
||||||
private loweredStat = 0;
|
private loweredStat = 0;
|
||||||
private failedMessage = 0;
|
private failedMessage = 0;
|
||||||
@@ -823,6 +839,28 @@ export class Battle {
|
|||||||
|
|
||||||
/** StartBattle through ExitBattle. */
|
/** StartBattle through ExitBattle. */
|
||||||
*run(): Gen<BattleOutcome> {
|
*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;
|
const trainer = this.options.trainer;
|
||||||
if (trainer) {
|
if (trainer) {
|
||||||
validateTrainerAI(trainer.aiFlags, trainer.party.flatMap((mon) =>
|
validateTrainerAI(trainer.aiFlags, trainer.party.flatMap((mon) =>
|
||||||
@@ -867,8 +905,7 @@ export class Battle {
|
|||||||
);
|
);
|
||||||
this.resetBattleParticipants();
|
this.resetBattleParticipants();
|
||||||
this.initBattleMon();
|
this.initBattleMon();
|
||||||
const amuletCoin =
|
this.checkAmuletCoin();
|
||||||
this.data.heldEffect(this.player.mon.item).effect === C.HELD_AMULET_COIN;
|
|
||||||
this.newStatus(this.player);
|
this.newStatus(this.player);
|
||||||
this.newStatus(this.enemy);
|
this.newStatus(this.enemy);
|
||||||
yield* this.sendOutMonText();
|
yield* this.sendOutMonText();
|
||||||
@@ -896,7 +933,7 @@ export class Battle {
|
|||||||
if (trainer.winText) yield* this.say(trainer.winText);
|
if (trainer.winText) yield* this.say(trainer.winText);
|
||||||
return {
|
return {
|
||||||
result: this.battleResult,
|
result: this.battleResult,
|
||||||
...(yield* this.giveMoney(trainer.reward, amuletCoin)),
|
...(yield* this.giveMoney(trainer.reward, this.amuletCoin)),
|
||||||
evolvable: this.evolvable,
|
evolvable: this.evolvable,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1934,7 +1971,7 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
/** EndMoveEffect: stop reading the effect script. */
|
/** EndMoveEffect: stop reading the effect script. */
|
||||||
private endMoveEffect() {
|
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 {
|
private *endTurn(): Gen {
|
||||||
this.turnEnded = 1;
|
this.turnEnded = 1;
|
||||||
@@ -1947,6 +1984,7 @@ export class Battle {
|
|||||||
if (user.curMove === 0xff) return yield* this.endTurn(); // CANNOT_MOVE
|
if (user.curMove === 0xff) return yield* this.endTurn(); // CANNOT_MOVE
|
||||||
this.attackMissed = 0;
|
this.attackMissed = 0;
|
||||||
this.effectFailed = 0;
|
this.effectFailed = 0;
|
||||||
|
this.animParam = 0;
|
||||||
this.alreadyDisobeyed = 0;
|
this.alreadyDisobeyed = 0;
|
||||||
this.alreadyFailed = 0;
|
this.alreadyFailed = 0;
|
||||||
this.typeModifier = C.EFFECTIVE;
|
this.typeModifier = C.EFFECTIVE;
|
||||||
@@ -2073,12 +2111,99 @@ export class Battle {
|
|||||||
return this.effectChance();
|
return this.effectChance();
|
||||||
case COMMAND.lowersub:
|
case COMMAND.lowersub:
|
||||||
case COMMAND.raisesub:
|
case COMMAND.raisesub:
|
||||||
case COMMAND.moveanimnosub:
|
case COMMAND.lowersubnoanim:
|
||||||
case COMMAND.moveanim:
|
case COMMAND.raisesubnoanim:
|
||||||
case COMMAND.statupanim:
|
|
||||||
case COMMAND.statdownanim:
|
|
||||||
case COMMAND.movedelay:
|
case COMMAND.movedelay:
|
||||||
return this.requireNoSubstitute();
|
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:
|
case COMMAND.failuretext:
|
||||||
return yield* this.failureText();
|
return yield* this.failureText();
|
||||||
case COMMAND.applydamage:
|
case COMMAND.applydamage:
|
||||||
@@ -2151,7 +2276,7 @@ export class Battle {
|
|||||||
default:
|
default:
|
||||||
if (command >= COMMAND.attackup && command <= COMMAND.evasionup2) {
|
if (command >= COMMAND.attackup && command <= COMMAND.evasionup2) {
|
||||||
const stat = (command - COMMAND.attackup) % 7;
|
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) {
|
if (command >= COMMAND.attackdown && command <= COMMAND.evasiondown2) {
|
||||||
const stat = (command - COMMAND.attackdown) % 7;
|
const stat = (command - COMMAND.attackdown) % 7;
|
||||||
@@ -2340,8 +2465,23 @@ export class Battle {
|
|||||||
});
|
});
|
||||||
this.curDamage = result.damage;
|
this.curDamage = result.damage;
|
||||||
this.typeModifier = result.modifier;
|
this.typeModifier = result.modifier;
|
||||||
|
this.matchup = result.matchup;
|
||||||
if (result.missed) this.attackMissed = 1;
|
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. */
|
/** BattleCommand_DamageVariation: 217-255 / 255. */
|
||||||
private damageVariation() {
|
private damageVariation() {
|
||||||
if (this.curDamage < 2) return;
|
if (this.curDamage < 2) return;
|
||||||
@@ -2410,6 +2550,11 @@ export class Battle {
|
|||||||
const user = this.user;
|
const user = this.user;
|
||||||
if (user.move.anim === C.FLY || user.move.anim === C.DIG)
|
if (user.move.anim === C.FLY || user.move.anim === C.DIG)
|
||||||
throw new UnsupportedBattle("Fly and Dig are not ported yet.");
|
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();
|
this.endMoveEffect();
|
||||||
}
|
}
|
||||||
private *getFailureResultText(): Gen {
|
private *getFailureResultText(): Gen {
|
||||||
@@ -2427,8 +2572,13 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? protectedText : text);
|
yield* this.say(this.target.has(SUB1, C.SUBSTATUS_PROTECT) ? protectedText : text);
|
||||||
this.criticalHit = 0;
|
this.criticalHit = 0;
|
||||||
if (this.user.move.effect === C.EFFECT_JUMP_KICK && this.typeModifier & C.EFFECTIVENESS_MASK)
|
if (this.user.move.effect !== C.EFFECT_JUMP_KICK || !(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
|
||||||
throw new UnsupportedBattle("Jump Kick crash damage is not ported yet.");
|
// (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. */
|
/** BattleCommand_ApplyDamage. */
|
||||||
@@ -2448,15 +2598,14 @@ export class Battle {
|
|||||||
yield* this.say(BATTLE.HungOnText);
|
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 {
|
private falseSwipe(): boolean {
|
||||||
const hp = this.target.mon.hp;
|
const hp = this.target.mon.hp;
|
||||||
if (this.curDamage < hp) return false;
|
if (this.curDamage < hp) return false;
|
||||||
this.curDamage = hp - 1;
|
this.curDamage = (hp - 1) & 0xffff;
|
||||||
if (this.curDamage <= 0) {
|
if (this.criticalHit === 2) this.criticalHit = 0;
|
||||||
this.curDamage = 0;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
/** DoEnemyDamage / DoPlayerDamage. */
|
/** DoEnemyDamage / DoPlayerDamage. */
|
||||||
@@ -2484,9 +2633,11 @@ export class Battle {
|
|||||||
if (this.target.mon.hp) return;
|
if (this.target.mon.hp) return;
|
||||||
if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND))
|
if (this.target.has(SUB5, C.SUBSTATUS_DESTINY_BOND))
|
||||||
throw new UnsupportedBattle("Destiny Bond is not ported yet.");
|
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;
|
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))
|
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();
|
this.endMoveEffect();
|
||||||
}
|
}
|
||||||
private *buildOpponentRage(): Gen {
|
private *buildOpponentRage(): Gen {
|
||||||
@@ -2510,6 +2661,340 @@ export class Battle {
|
|||||||
}
|
}
|
||||||
this.curDamage = damage;
|
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). */
|
/** BattleCommand_HeldFlinch (kingsrock). */
|
||||||
private kingsRock() {
|
private kingsRock() {
|
||||||
if (this.attackMissed) return;
|
if (this.attackMissed) return;
|
||||||
@@ -2528,6 +3013,68 @@ export class Battle {
|
|||||||
private isType(side: Side, type: number) {
|
private isType(side: Side, type: number) {
|
||||||
return side.mon.types[0] === type || side.mon.types[1] === type;
|
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. */
|
/** UseHeldStatusHealingItem for the opponent. Returns true if it cured. */
|
||||||
private *useHeldStatusHealingItem(): Gen<boolean> {
|
private *useHeldStatusHealingItem(): Gen<boolean> {
|
||||||
const side = this.target,
|
const side = this.target,
|
||||||
@@ -2598,12 +3145,9 @@ export class Battle {
|
|||||||
private *burnTarget(): Gen {
|
private *burnTarget(): Gen {
|
||||||
const target = this.target;
|
const target = this.target;
|
||||||
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
|
if (target.has(SUB4, C.SUBSTATUS_SUBSTITUTE)) return;
|
||||||
if (target.mon.status) {
|
if (target.mon.status) return yield* this.defrostOpponent();
|
||||||
if (target.mon.status & (1 << C.FRZ)) throw new UnsupportedBattle("Defrosting is not ported yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!(this.typeModifier & C.EFFECTIVENESS_MASK)) return;
|
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.data.heldEffect(target.mon.item).effect === C.HELD_PREVENT_BURN) return;
|
||||||
if (this.effectFailed || this.safeguarded()) return;
|
if (this.effectFailed || this.safeguarded()) return;
|
||||||
target.mon.status |= 1 << C.BRN;
|
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;
|
if (effect === C.EFFECT_CONFUSE_HIT || effect === C.EFFECT_SNORE || effect === C.EFFECT_SWAGGER) return;
|
||||||
return yield* this.didntAffect2();
|
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.set(SUB3, C.SUBSTATUS_CONFUSED);
|
||||||
target.confuseCount = (this.random() & 3) + 2;
|
target.confuseCount = (this.random() & 3) + 2;
|
||||||
yield* this.say(BATTLE.BecameConfusedText);
|
yield* this.say(BATTLE.BecameConfusedText);
|
||||||
yield* this.useConfusionHealingItem();
|
yield* this.useConfusionHealingItem();
|
||||||
}
|
}
|
||||||
/** BattleCommand_DrainTarget: SapHealth restores half the damage. */
|
/** BattleCommand_DrainTarget and BattleCommand_EatDream: SapHealth
|
||||||
private *drainTarget(): Gen {
|
* restores half the damage. */
|
||||||
|
private *drainTarget(text: number = BATTLE.SuckedHealthText): Gen {
|
||||||
const mon = this.user.mon;
|
const mon = this.user.mon;
|
||||||
const amount = this.curDamage >> 1 || 1;
|
const amount = this.curDamage >> 1 || 1;
|
||||||
mon.hp = Math.min(mon.maxHp, mon.hp + amount);
|
mon.hp = Math.min(mon.maxHp, mon.hp + amount);
|
||||||
if (this.turn === 0) this.updateBattleMonInParty();
|
if (this.turn === 0) this.updateBattleMonInParty();
|
||||||
yield this.hud();
|
yield this.hud();
|
||||||
yield* this.say(BATTLE.SuckedHealthText);
|
yield* this.say(text);
|
||||||
}
|
}
|
||||||
/** BattleCommand_Recoil: a quarter of the damage dealt, at least 1 HP. */
|
/** BattleCommand_Recoil: a quarter of the damage dealt, at least 1 HP. */
|
||||||
private *recoil(): Gen {
|
private *recoil(): Gen {
|
||||||
@@ -2797,7 +3356,12 @@ export class Battle {
|
|||||||
this.calcStats(user);
|
this.calcStats(user);
|
||||||
}
|
}
|
||||||
this.failedMessage = 0;
|
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. */
|
/** BattleCommand_StatDown. */
|
||||||
private statDown(stat: number) {
|
private statDown(stat: number) {
|
||||||
@@ -3155,6 +3719,7 @@ export class Battle {
|
|||||||
private *sendOutPlayerMon(): Gen {
|
private *sendOutPlayerMon(): Gen {
|
||||||
this.addBattleParticipant();
|
this.addBattleParticipant();
|
||||||
this.initBattleMon();
|
this.initBattleMon();
|
||||||
|
this.checkAmuletCoin();
|
||||||
yield* this.sendOutMonText();
|
yield* this.sendOutMonText();
|
||||||
this.newStatus(this.player); // with ResetPlayerStatLevels
|
this.newStatus(this.player); // with ResetPlayerStatLevels
|
||||||
this.breakAttraction();
|
this.breakAttraction();
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export const C = {
|
|||||||
EFFECT_ATTACK_UP_2: 50,
|
EFFECT_ATTACK_UP_2: 50,
|
||||||
EFFECT_EVASION_UP_2: 56,
|
EFFECT_EVASION_UP_2: 56,
|
||||||
EFFECT_PURSUIT: 128,
|
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)
|
// Moves (constants/move_constants.asm)
|
||||||
GUST: 16,
|
GUST: 16,
|
||||||
@@ -108,6 +114,9 @@ export const C = {
|
|||||||
NORMAL: 0,
|
NORMAL: 0,
|
||||||
FLYING: 2,
|
FLYING: 2,
|
||||||
POISON: 3,
|
POISON: 3,
|
||||||
|
BIRD: 6,
|
||||||
|
UNUSED_TYPES: 10,
|
||||||
|
UNUSED_TYPES_END: 20,
|
||||||
FIRE: 20,
|
FIRE: 20,
|
||||||
SPECIAL: 20,
|
SPECIAL: 20,
|
||||||
ICE: 25,
|
ICE: 25,
|
||||||
@@ -352,10 +361,15 @@ export const COMMAND = {
|
|||||||
poisontarget: 19,
|
poisontarget: 19,
|
||||||
sleeptarget: 20,
|
sleeptarget: 20,
|
||||||
draintarget: 21,
|
draintarget: 21,
|
||||||
|
eatdream: 22,
|
||||||
burntarget: 23,
|
burntarget: 23,
|
||||||
freezetarget: 24,
|
freezetarget: 24,
|
||||||
paralyzetarget: 25,
|
paralyzetarget: 25,
|
||||||
|
selfdestruct: 26,
|
||||||
|
payday: 30,
|
||||||
|
endloop: 36,
|
||||||
flinchtarget: 37,
|
flinchtarget: 37,
|
||||||
|
ohko: 38,
|
||||||
recoil: 39,
|
recoil: 39,
|
||||||
confuse: 42,
|
confuse: 42,
|
||||||
confusetarget: 43,
|
confusetarget: 43,
|
||||||
@@ -363,13 +377,26 @@ export const COMMAND = {
|
|||||||
poison: 47,
|
poison: 47,
|
||||||
paralyze: 48,
|
paralyze: 48,
|
||||||
splash: 54,
|
splash: 54,
|
||||||
|
cleartext: 56,
|
||||||
|
constantdamage: 63,
|
||||||
|
counter: 64,
|
||||||
|
falseswipe: 75,
|
||||||
kingsrock: 77,
|
kingsrock: 77,
|
||||||
|
triplekick: 78,
|
||||||
|
kickcounter: 79,
|
||||||
|
thief: 80,
|
||||||
|
defrost: 83,
|
||||||
foresight: 87,
|
foresight: 87,
|
||||||
|
happinesspower: 96,
|
||||||
|
present: 97,
|
||||||
damagecalc: 98,
|
damagecalc: 98,
|
||||||
|
frustrationpower: 99,
|
||||||
checksafeguard: 101,
|
checksafeguard: 101,
|
||||||
|
getmagnitude: 102,
|
||||||
healmorn: 106,
|
healmorn: 106,
|
||||||
healday: 107,
|
healday: 107,
|
||||||
healnite: 108,
|
healnite: 108,
|
||||||
|
hiddenpower: 109,
|
||||||
attackup: 112,
|
attackup: 112,
|
||||||
defenseup: 113,
|
defenseup: 113,
|
||||||
speedup: 114,
|
speedup: 114,
|
||||||
@@ -406,12 +433,25 @@ export const COMMAND = {
|
|||||||
statdownanim: 145,
|
statdownanim: 145,
|
||||||
statupanim: 146,
|
statupanim: 146,
|
||||||
switchturn: 147,
|
switchturn: 147,
|
||||||
|
fakeout: 148,
|
||||||
rage: 151,
|
rage: 151,
|
||||||
doubleflyingdamage: 152,
|
doubleflyingdamage: 152,
|
||||||
|
doubleundergrounddamage: 153,
|
||||||
|
mirrorcoat: 154,
|
||||||
|
doubleminimizedamage: 157,
|
||||||
|
thunderaccuracy: 159,
|
||||||
|
beatup: 161,
|
||||||
ragedamage: 162,
|
ragedamage: 162,
|
||||||
|
resettypematchup: 163,
|
||||||
|
raisesubnoanim: 166,
|
||||||
|
lowersubnoanim: 167,
|
||||||
|
beatupfailtext: 168,
|
||||||
clearmissdamage: 169,
|
clearmissdamage: 169,
|
||||||
movedelay: 170,
|
movedelay: 170,
|
||||||
moveanim: 171,
|
moveanim: 171,
|
||||||
|
tristatuschance: 172,
|
||||||
|
supereffectivelooptext: 173,
|
||||||
|
startloop: 174,
|
||||||
curl: 175,
|
curl: 175,
|
||||||
endmove: 0xff,
|
endmove: 0xff,
|
||||||
endturn: 0xfe,
|
endturn: 0xfe,
|
||||||
|
|||||||
@@ -166,6 +166,10 @@ export const RAM = {
|
|||||||
blueCardBalance: 0xdc4b,
|
blueCardBalance: 0xdc4b,
|
||||||
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
|
/** hMoneyTemp: HRAM, not WRAM like the other entries above. */
|
||||||
moneyTemp: 0xffc3,
|
moneyTemp: 0xffc3,
|
||||||
|
playerDamageTaken: 0xc682,
|
||||||
|
enemyDamageTaken: 0xc684,
|
||||||
|
payDayMoney: 0xc6ec,
|
||||||
|
textDecimalByte: 0xd265,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Event constants named directly by translated special routines. */
|
/** 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
|
/** Battle engine tables and texts, keyed by their PRET labels so that
|
||||||
* \`npm run check:reference\` can verify each address by name. */
|
* \`npm run check:reference\` can verify each address by name. */
|
||||||
export const BATTLE = {
|
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,
|
HookedPokemonAttackedText: 0x8075c,
|
||||||
MagikarpLengths: 0xfbca8,
|
MagikarpLengths: 0xfbca8,
|
||||||
ItemUsedText: 0xf83d,
|
ItemUsedText: 0xf83d,
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export function validOptions(value: unknown): value is GameOptions {
|
|||||||
export const shiftBattleStyle = (options: GameOptions) =>
|
export const shiftBattleStyle = (options: GameOptions) =>
|
||||||
!(options.options & (1 << OW.BATTLE_SHIFT));
|
!(options.options & (1 << OW.BATTLE_SHIFT));
|
||||||
/** StartMenu's MENU_ACCOUNT test: describe the highlighted item. */
|
/** 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) =>
|
export const menuAccount = (options: GameOptions) =>
|
||||||
Boolean(options.options2 & (1 << OW.MENU_ACCOUNT));
|
Boolean(options.options2 & (1 << OW.MENU_ACCOUNT));
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
OPTIONS_BUTTONS,
|
OPTIONS_BUTTONS,
|
||||||
shiftBattleStyle,
|
shiftBattleStyle,
|
||||||
|
battleSceneOn,
|
||||||
validOptions,
|
validOptions,
|
||||||
defaultOptions,
|
defaultOptions,
|
||||||
type GameOptions,
|
type GameOptions,
|
||||||
@@ -2474,6 +2475,7 @@ export class GameWorld {
|
|||||||
money: draft.momsMoney,
|
money: draft.momsMoney,
|
||||||
},
|
},
|
||||||
shift: shiftBattleStyle(draft.options),
|
shift: shiftBattleStyle(draft.options),
|
||||||
|
battleScene: battleSceneOn(draft.options),
|
||||||
});
|
});
|
||||||
this.battle = { engine, selected: 1 };
|
this.battle = { engine, selected: 1 };
|
||||||
this.battleRun = engine.run();
|
this.battleRun = engine.run();
|
||||||
@@ -3102,8 +3104,11 @@ export class GameWorld {
|
|||||||
if (next.done) {
|
if (next.done) {
|
||||||
if (this.script) {
|
if (this.script) {
|
||||||
const draft = this.script.run.state,
|
const draft = this.script.run.state,
|
||||||
{ prizeMoney = 0, momMoney = 0 } = next.value;
|
{ prizeMoney = 0, momMoney = 0, payDayMoney = 0 } = next.value;
|
||||||
draft.money = Math.min(C.MAX_MONEY, draft.money + prizeMoney);
|
draft.money = Math.min(
|
||||||
|
C.MAX_MONEY,
|
||||||
|
draft.money + prizeMoney + payDayMoney,
|
||||||
|
);
|
||||||
draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
|
draft.momsMoney = Math.min(C.MAX_MONEY, draft.momsMoney + momMoney);
|
||||||
}
|
}
|
||||||
this.battle = undefined;
|
this.battle = undefined;
|
||||||
|
|||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { CrystalAssets } from "../src/game/rom/assets";
|
||||||
|
import { TrainerData } from "../src/game/rom/trainers";
|
||||||
|
import { Battle, type BattleOptions } from "../src/game/battle/battle";
|
||||||
|
import { C } from "../src/game/battle/constants";
|
||||||
|
import { generatePartyMon, type PartyMon } from "../src/game/party/pokemon";
|
||||||
|
import { battleOptions, battleSteps, playBattle } from "./battle-driver";
|
||||||
|
|
||||||
|
const romPath =
|
||||||
|
process.env.CRYSTAL_ROM_PATH ??
|
||||||
|
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
|
||||||
|
|
||||||
|
// constants/move_constants.asm
|
||||||
|
const DOUBLESLAP = 3,
|
||||||
|
PAY_DAY = 6,
|
||||||
|
STOMP = 23,
|
||||||
|
DOUBLE_KICK = 24,
|
||||||
|
JUMP_KICK = 26,
|
||||||
|
FURY_ATTACK = 31,
|
||||||
|
HORN_DRILL = 32,
|
||||||
|
TACKLE = 33,
|
||||||
|
TWINEEDLE = 41,
|
||||||
|
SONICBOOM = 49,
|
||||||
|
EMBER = 52,
|
||||||
|
COUNTER = 68,
|
||||||
|
SEISMIC_TOSS = 69,
|
||||||
|
DRAGON_RAGE = 82,
|
||||||
|
CONFUSION = 93,
|
||||||
|
HYPNOSIS = 95,
|
||||||
|
NIGHT_SHADE = 101,
|
||||||
|
MINIMIZE = 107,
|
||||||
|
SELFDESTRUCT = 120,
|
||||||
|
DREAM_EATER = 138,
|
||||||
|
PSYWAVE = 149,
|
||||||
|
SPLASH = 150,
|
||||||
|
TRI_ATTACK = 161,
|
||||||
|
SUPER_FANG = 162,
|
||||||
|
TRIPLE_KICK = 167,
|
||||||
|
THIEF = 168,
|
||||||
|
FLAME_WHEEL = 172,
|
||||||
|
FLAIL = 175,
|
||||||
|
FALSE_SWIPE = 206,
|
||||||
|
SWAGGER = 207,
|
||||||
|
RETURN = 216,
|
||||||
|
PRESENT = 217,
|
||||||
|
FRUSTRATION = 218,
|
||||||
|
MAGNITUDE = 222,
|
||||||
|
HIDDEN_POWER = 237,
|
||||||
|
MIRROR_COAT = 243,
|
||||||
|
BEAT_UP = 251,
|
||||||
|
ICE_BEAM = 58;
|
||||||
|
// Species and items
|
||||||
|
const PIDGEY = 16,
|
||||||
|
RATTATA = 19,
|
||||||
|
GEODUDE = 74,
|
||||||
|
GASTLY = 92,
|
||||||
|
SNORLAX = 143,
|
||||||
|
JOLTEON = 135,
|
||||||
|
ABRA = 63,
|
||||||
|
MEOWTH = 52,
|
||||||
|
HITMONLEE = 106,
|
||||||
|
LAPRAS = 131,
|
||||||
|
CYNDAQUIL = 155,
|
||||||
|
TOTODILE = 158;
|
||||||
|
const AMULET_COIN = 0x5b,
|
||||||
|
LEFTOVERS = 0x92,
|
||||||
|
FOCUS_BAND = 0x77,
|
||||||
|
FLOWER_MAIL = 0x9e;
|
||||||
|
const FRZ = 1 << C.FRZ,
|
||||||
|
PSN = 1 << C.PSN;
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(romPath))("move effects with the local ROM", () => {
|
||||||
|
let assets: CrystalAssets, trainers: TrainerData;
|
||||||
|
beforeAll(() => {
|
||||||
|
assets = new CrystalAssets(new Uint8Array(readFileSync(romPath)));
|
||||||
|
trainers = new TrainerData(assets.reader);
|
||||||
|
});
|
||||||
|
const mon = (
|
||||||
|
species: number,
|
||||||
|
level: number,
|
||||||
|
moves: number[],
|
||||||
|
extra: Partial<PartyMon> = {},
|
||||||
|
): PartyMon => {
|
||||||
|
const result = generatePartyMon(assets.pokemon, {
|
||||||
|
species,
|
||||||
|
level,
|
||||||
|
item: 0,
|
||||||
|
otId: 1234,
|
||||||
|
ot: "CHRIS",
|
||||||
|
dvs: 0x823b,
|
||||||
|
caughtData: 0,
|
||||||
|
});
|
||||||
|
result.moves = [0, 1, 2, 3].map((i) => moves[i] ?? 0) as PartyMon["moves"];
|
||||||
|
result.pp = result.moves.map((move) =>
|
||||||
|
move ? assets.pokemon.move(move).pp : 0,
|
||||||
|
) as PartyMon["pp"];
|
||||||
|
return Object.assign(result, extra);
|
||||||
|
};
|
||||||
|
/** A battle against a trainer with this party and no AI layers, so a
|
||||||
|
* Pokémon with one move always uses it. */
|
||||||
|
const duel = (
|
||||||
|
party: PartyMon[],
|
||||||
|
enemy: PartyMon[] | { wild: { species: number; level: number } },
|
||||||
|
{ seed = 1, ...options }: { seed?: number } & Partial<BattleOptions> = {},
|
||||||
|
) => {
|
||||||
|
const base = battleOptions(
|
||||||
|
assets,
|
||||||
|
trainers,
|
||||||
|
party,
|
||||||
|
{ wild: { species: PIDGEY, level: 2 } },
|
||||||
|
{ seed },
|
||||||
|
);
|
||||||
|
return new Battle({
|
||||||
|
...base,
|
||||||
|
...(Array.isArray(enemy)
|
||||||
|
? {
|
||||||
|
wild: undefined,
|
||||||
|
trainer: {
|
||||||
|
name: "JOEY",
|
||||||
|
className: "YOUNGSTER",
|
||||||
|
party: enemy,
|
||||||
|
aiFlags: 0,
|
||||||
|
switchFlags: 0,
|
||||||
|
reward: 40,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: enemy),
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
/** Play the first turn: the texts from FIGHT with the first move until
|
||||||
|
* the next battle menu or the end. */
|
||||||
|
const firstTurn = (battle: Battle) => {
|
||||||
|
const steps = battleSteps(battle);
|
||||||
|
const before = steps.texts.length;
|
||||||
|
steps.answer({ action: "fight", move: 0 });
|
||||||
|
return { steps, texts: steps.texts.slice(before) };
|
||||||
|
};
|
||||||
|
/** The first seed whose first turn passes `test`. */
|
||||||
|
const findSeed = (
|
||||||
|
make: (seed: number) => Battle,
|
||||||
|
test: (texts: string[], battle: Battle) => boolean,
|
||||||
|
) => {
|
||||||
|
for (let seed = 1; seed < 500; seed++) {
|
||||||
|
const battle = make(seed);
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
if (test(texts, battle)) return { seed, battle, texts };
|
||||||
|
}
|
||||||
|
throw new Error("No seed found.");
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("multi-hit moves", () => {
|
||||||
|
it("hits two to five times and says how often", () => {
|
||||||
|
const counts = new Set<number>();
|
||||||
|
for (let seed = 1; seed < 60; seed++) {
|
||||||
|
const battle = duel([mon(CYNDAQUIL, 30, [FURY_ATTACK])], [mon(SNORLAX, 50, [SPLASH])], { seed });
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
const missed = texts.some((text) => text.includes("attack missed"));
|
||||||
|
const hit = texts.map((text) => /^Hit (\d) times!$/.exec(text)).find(Boolean);
|
||||||
|
if (missed) {
|
||||||
|
expect(hit).toBeUndefined();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const n = Number(hit![1]);
|
||||||
|
counts.add(n);
|
||||||
|
expect(n).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(n).toBeLessThanOrEqual(5);
|
||||||
|
// The loop counter and the hit count are spent; the high byte of
|
||||||
|
// the user's damage taken is cleared again.
|
||||||
|
expect(battle.player.rolloutCount).toBe(0);
|
||||||
|
expect(battle.player.damageTaken >> 8).toBe(0);
|
||||||
|
expect(battle.player.has(3, C.SUBSTATUS_IN_LOOP)).toBe(false);
|
||||||
|
}
|
||||||
|
expect([...counts].sort()).toEqual([2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hits twice with Double Kick and Twineedle, and only says effectiveness once", () => {
|
||||||
|
for (const move of [DOUBLE_KICK, TWINEEDLE]) {
|
||||||
|
const { texts } = findSeed(
|
||||||
|
(seed) => duel([mon(HITMONLEE, 30, [move])], [mon(GEODUDE, 50, [SPLASH])], { seed }),
|
||||||
|
(texts) => texts.includes("Hit 2 times!"),
|
||||||
|
);
|
||||||
|
const effectiveness = texts.filter((text) => /effective/.test(text));
|
||||||
|
// Double Kick is super effective against Geodude; Twineedle is
|
||||||
|
// neutral. Either way the loop's later hits say nothing.
|
||||||
|
expect(effectiveness).toHaveLength(move === DOUBLE_KICK ? 1 : 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops without the hit count when the target faints mid-loop", () => {
|
||||||
|
const enemy = mon(RATTATA, 2, [SPLASH]);
|
||||||
|
enemy.hp = 1;
|
||||||
|
const battle = duel([mon(CYNDAQUIL, 30, [DOUBLESLAP])], [enemy]);
|
||||||
|
const { texts } = playBattle(battle);
|
||||||
|
expect(texts.join(" ")).toContain("fainted");
|
||||||
|
expect(texts.some((text) => text.startsWith("Hit "))).toBe(false);
|
||||||
|
expect(battle.player.has(3, C.SUBSTATUS_IN_LOOP)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("kicks up to three times with growing damage", () => {
|
||||||
|
const counts = new Set<number>();
|
||||||
|
for (let seed = 1; seed < 40; seed++) {
|
||||||
|
const battle = duel([mon(HITMONLEE, 30, [TRIPLE_KICK])], [mon(SNORLAX, 60, [SPLASH])], { seed });
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
const hit = texts.map((text) => /^Hit (\d) times!$/.exec(text)).find(Boolean);
|
||||||
|
if (hit) counts.add(Number(hit[1]));
|
||||||
|
}
|
||||||
|
expect([...counts].sort()).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("beats up with each healthy member, by nickname, skipping fainted and statused ones", () => {
|
||||||
|
const party = [
|
||||||
|
mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" }),
|
||||||
|
mon(TOTODILE, 20, [TACKLE], { nickname: "BRAVO", hp: 0 }),
|
||||||
|
mon(PIDGEY, 20, [TACKLE], { nickname: "CHARLIE", status: PSN }),
|
||||||
|
mon(RATTATA, 20, [TACKLE], { nickname: "DELTA" }),
|
||||||
|
];
|
||||||
|
const { texts } = firstTurn(duel(party, [mon(SNORLAX, 60, [SPLASH])]));
|
||||||
|
const attacks = texts.filter((text) => text.endsWith("'s attack!"));
|
||||||
|
expect(attacks).toEqual(["ALPHA's attack!", "DELTA's attack!"]);
|
||||||
|
expect(texts.some((text) => text.startsWith("Hit "))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lets a lone Pokémon beat up once, then ends the move", () => {
|
||||||
|
const { texts } = firstTurn(duel([mon(CYNDAQUIL, 30, [BEAT_UP], { nickname: "ALPHA" })], [mon(SNORLAX, 60, [SPLASH])]));
|
||||||
|
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["ALPHA's attack!"]);
|
||||||
|
expect(texts).not.toContain("But it failed!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names a trainer's members by species", () => {
|
||||||
|
const enemy = [mon(SNORLAX, 60, [BEAT_UP]), mon(GEODUDE, 20, [SPLASH])];
|
||||||
|
const { texts } = firstTurn(duel([mon(PIDGEY, 60, [SPLASH])], enemy));
|
||||||
|
expect(texts.filter((text) => text.endsWith("'s attack!"))).toEqual(["SNORLAX's attack!", "GEODUDE's attack!"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("fixed and special damage", () => {
|
||||||
|
const damageTo = (move: number, enemy: PartyMon, level = 30) => {
|
||||||
|
const battle = duel([mon(ABRA, level, [move])], [enemy]);
|
||||||
|
const before = enemy.hp;
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
return { damage: before - battle.enemy.mon.hp, texts, battle };
|
||||||
|
};
|
||||||
|
it("deals the level, 20 or 40 HP, or half the target's HP", () => {
|
||||||
|
expect(damageTo(SEISMIC_TOSS, mon(SNORLAX, 50, [SPLASH]), 37).damage).toBe(37);
|
||||||
|
expect(damageTo(NIGHT_SHADE, mon(GEODUDE, 50, [SPLASH]), 41).damage).toBe(41);
|
||||||
|
expect(damageTo(SONICBOOM, mon(SNORLAX, 50, [SPLASH])).damage).toBe(20);
|
||||||
|
expect(damageTo(DRAGON_RAGE, mon(SNORLAX, 50, [SPLASH])).damage).toBe(40);
|
||||||
|
const snorlax = mon(SNORLAX, 50, [SPLASH]);
|
||||||
|
const half = snorlax.hp >> 1;
|
||||||
|
expect(damageTo(SUPER_FANG, snorlax).damage).toBe(half);
|
||||||
|
});
|
||||||
|
it("misses an immune type", () => {
|
||||||
|
const normal = damageTo(NIGHT_SHADE, mon(SNORLAX, 50, [SPLASH]));
|
||||||
|
expect(normal.damage).toBe(0);
|
||||||
|
expect(normal.texts.join(" ")).toContain("doesn't affect");
|
||||||
|
const ghost = damageTo(SEISMIC_TOSS, mon(GASTLY, 50, [SPLASH]));
|
||||||
|
expect(ghost.damage).toBe(0);
|
||||||
|
expect(ghost.texts.join(" ")).toContain("doesn't affect");
|
||||||
|
});
|
||||||
|
it("rolls Psywave below one and a half times the level", () => {
|
||||||
|
for (let seed = 1; seed < 20; seed++) {
|
||||||
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
||||||
|
const battle = duel([mon(ABRA, 40, [PSYWAVE])], [enemy], { seed });
|
||||||
|
firstTurn(battle);
|
||||||
|
const damage = enemy.hp - battle.enemy.mon.hp;
|
||||||
|
if (!damage) continue;
|
||||||
|
expect(damage).toBeGreaterThan(0);
|
||||||
|
expect(damage).toBeLessThan(60);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("hits harder with Flail at low HP and resets its power", () => {
|
||||||
|
const full = damageTo(FLAIL, mon(SNORLAX, 50, [SPLASH]));
|
||||||
|
const low = (() => {
|
||||||
|
const user = mon(ABRA, 30, [FLAIL]);
|
||||||
|
user.hp = 1;
|
||||||
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
||||||
|
const battle = duel([user], [enemy]);
|
||||||
|
firstTurn(battle);
|
||||||
|
return { damage: enemy.hp - battle.enemy.mon.hp, battle };
|
||||||
|
})();
|
||||||
|
expect(low.damage).toBeGreaterThan(full.damage * 4);
|
||||||
|
expect(low.battle.player.move.power).toBe(1);
|
||||||
|
});
|
||||||
|
it("knocks out in one hit or is unaffected against a higher level", () => {
|
||||||
|
const { texts, battle } = findSeed(
|
||||||
|
(seed) => duel([mon(SNORLAX, 60, [HORN_DRILL])], [mon(LAPRAS, 20, [SPLASH])], { seed }),
|
||||||
|
(texts) => texts.includes("It's a one-hit KO!"),
|
||||||
|
);
|
||||||
|
expect(battle.enemy.mon.hp).toBe(0);
|
||||||
|
expect(texts).toContain("It's a one-hit KO!");
|
||||||
|
const higher = firstTurn(duel([mon(SNORLAX, 20, [HORN_DRILL])], [mon(LAPRAS, 60, [SPLASH])]));
|
||||||
|
expect(higher.texts.join(" ")).toContain("unaffected");
|
||||||
|
});
|
||||||
|
it("leaves 1 HP with False Swipe", () => {
|
||||||
|
const enemy = mon(RATTATA, 3, [SPLASH]);
|
||||||
|
const battle = duel([mon(SNORLAX, 60, [FALSE_SWIPE])], [enemy]);
|
||||||
|
firstTurn(battle);
|
||||||
|
expect(battle.enemy.mon.hp).toBe(1);
|
||||||
|
});
|
||||||
|
it("crashes on a missed Jump Kick for an eighth of the damage", () => {
|
||||||
|
const { texts, battle } = findSeed(
|
||||||
|
(seed) => duel([mon(HITMONLEE, 30, [JUMP_KICK])], [mon(SNORLAX, 50, [SPLASH])], { seed }),
|
||||||
|
(texts) => texts.some((text) => text.includes("crashed")),
|
||||||
|
);
|
||||||
|
expect(battle.player.mon.hp).toBeLessThan(battle.player.mon.maxHp);
|
||||||
|
expect(texts.join(" ")).toContain("missed");
|
||||||
|
});
|
||||||
|
it("doubles Stomp's damage against a minimized target", () => {
|
||||||
|
const battle = duel([mon(SNORLAX, 40, [STOMP])], [mon(GASTLY, 50, [MINIMIZE])]);
|
||||||
|
firstTurn(battle);
|
||||||
|
expect(battle.enemy.minimized).toBe(1);
|
||||||
|
});
|
||||||
|
it("sets Hidden Power's type and power from the DVs", () => {
|
||||||
|
// DVs $823b: Poison, 55 power.
|
||||||
|
const battle = duel([mon(ABRA, 30, [HIDDEN_POWER])], [mon(GEODUDE, 50, [SPLASH])]);
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
expect(battle.player.move.type).toBe(C.POISON);
|
||||||
|
expect(texts.join(" ")).toContain("not very effective");
|
||||||
|
});
|
||||||
|
it("deals no damage with Return at very low happiness or Frustration at very high", () => {
|
||||||
|
for (const [move, happiness] of [[RETURN, 2], [FRUSTRATION, 255]]) {
|
||||||
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
||||||
|
const battle = duel([mon(ABRA, 30, [move], { happiness })], [enemy]);
|
||||||
|
firstTurn(battle);
|
||||||
|
expect(battle.enemy.mon.hp).toBe(enemy.hp);
|
||||||
|
}
|
||||||
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
||||||
|
const battle = duel([mon(ABRA, 30, [RETURN], { happiness: 255 })], [enemy]);
|
||||||
|
firstTurn(battle);
|
||||||
|
expect(battle.enemy.mon.hp).toBeLessThan(enemy.hp);
|
||||||
|
});
|
||||||
|
it("announces Magnitude 4 to 10", () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let seed = 1; seed < 60; seed++) {
|
||||||
|
const { texts } = firstTurn(duel([mon(GEODUDE, 30, [MAGNITUDE])], [mon(SNORLAX, 50, [SPLASH])], { seed }));
|
||||||
|
const text = texts.find((t) => t.startsWith("Magnitude "));
|
||||||
|
if (text) seen.add(text);
|
||||||
|
}
|
||||||
|
expect([...seen].sort()).toEqual(
|
||||||
|
["Magnitude 10!", "Magnitude 4!", "Magnitude 5!", "Magnitude 6!", "Magnitude 7!", "Magnitude 8!", "Magnitude 9!"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("heals the target with a Present, which a full target refuses only with animations off", () => {
|
||||||
|
const healed = findSeed(
|
||||||
|
(seed) => {
|
||||||
|
const enemy = mon(SNORLAX, 50, [SPLASH]);
|
||||||
|
enemy.hp = 20;
|
||||||
|
return duel([mon(ABRA, 30, [PRESENT])], [enemy], { seed });
|
||||||
|
},
|
||||||
|
(texts) => texts.some((text) => text.includes("regained health")),
|
||||||
|
);
|
||||||
|
const quarter = healed.battle.enemy.mon.maxHp >> 2;
|
||||||
|
expect(healed.battle.enemy.mon.hp).toBe(20 + quarter);
|
||||||
|
expect(healed.texts.join(" ")).toContain("SNORLAX regained health");
|
||||||
|
const refused = (battleScene: boolean) =>
|
||||||
|
firstTurn(duel([mon(ABRA, 30, [PRESENT])], [mon(SNORLAX, 50, [SPLASH])], { seed: healed.seed, battleScene })).texts;
|
||||||
|
expect(refused(false).join(" ")).toContain("refused the gift");
|
||||||
|
expect(refused(true).join(" ")).not.toContain("refused the gift");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Counter and Mirror Coat", () => {
|
||||||
|
it("returns twice a physical hit taken first", () => {
|
||||||
|
const player = mon(SNORLAX, 40, [COUNTER]);
|
||||||
|
const enemy = mon(JOLTEON, 40, [TACKLE]);
|
||||||
|
const battle = duel([player], [enemy]);
|
||||||
|
firstTurn(battle);
|
||||||
|
const taken = player.stats[0] - battle.player.mon.hp;
|
||||||
|
expect(taken).toBeGreaterThan(0);
|
||||||
|
expect(enemy.hp - battle.enemy.mon.hp).toBe(taken * 2);
|
||||||
|
});
|
||||||
|
it("fails against special moves, and Mirror Coat the other way round", () => {
|
||||||
|
const counter = duel([mon(SNORLAX, 40, [COUNTER])], [mon(JOLTEON, 40, [EMBER])]);
|
||||||
|
expect(firstTurn(counter).texts.join(" ")).toContain("missed");
|
||||||
|
const enemy = mon(JOLTEON, 40, [EMBER]);
|
||||||
|
const mirror = duel([mon(SNORLAX, 40, [MIRROR_COAT])], [enemy]);
|
||||||
|
firstTurn(mirror);
|
||||||
|
const taken = mirror.player.mon.maxHp - mirror.player.mon.hp;
|
||||||
|
expect(enemy.hp - mirror.enemy.mon.hp).toBe(taken * 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("statuses", () => {
|
||||||
|
it("freezes with an ice move, and the frozen Pokémon cannot move", () => {
|
||||||
|
const { battle, texts } = findSeed(
|
||||||
|
(seed) => duel([mon(LAPRAS, 40, [ICE_BEAM])], [mon(SNORLAX, 60, [TACKLE])], { seed }),
|
||||||
|
(texts) => texts.some((text) => text.includes("frozen solid")),
|
||||||
|
);
|
||||||
|
expect(battle.enemy.mon.status & FRZ).toBeTruthy();
|
||||||
|
expect(texts.join(" ")).not.toContain("SNORLAX used");
|
||||||
|
});
|
||||||
|
it("thaws a frozen target with a fire move, whatever the burn chance", () => {
|
||||||
|
const enemy = mon(SNORLAX, 60, [SPLASH], { status: FRZ });
|
||||||
|
const battle = duel([mon(CYNDAQUIL, 30, [EMBER])], [enemy]);
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
expect(texts.join(" ")).toContain("was defrosted");
|
||||||
|
expect(battle.enemy.mon.status).toBe(0);
|
||||||
|
expect(enemy.status).toBe(0);
|
||||||
|
});
|
||||||
|
it("thaws a frozen user of Flame Wheel", () => {
|
||||||
|
const player = mon(CYNDAQUIL, 30, [FLAME_WHEEL], { status: FRZ });
|
||||||
|
const battle = duel([player], [mon(SNORLAX, 60, [SPLASH])]);
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
expect(texts.join(" ")).toContain("was defrosted");
|
||||||
|
expect(player.status).toBe(0);
|
||||||
|
});
|
||||||
|
it("may paralyze, freeze or burn with Tri Attack", () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (let seed = 1; seed < 400 && seen.size < 3; seed++) {
|
||||||
|
const { texts } = firstTurn(duel([mon(ABRA, 40, [TRI_ATTACK])], [mon(SNORLAX, 60, [SPLASH])], { seed }));
|
||||||
|
for (const [pattern, name] of [[/paralyzed/, "par"], [/frozen solid/, "frz"], [/burned/, "brn"]] as const)
|
||||||
|
if (texts.some((text) => pattern.test(text))) seen.add(name);
|
||||||
|
}
|
||||||
|
expect([...seen].sort()).toEqual(["brn", "frz", "par"]);
|
||||||
|
});
|
||||||
|
it("confuses with Confusion's added effect", () => {
|
||||||
|
const { battle } = findSeed(
|
||||||
|
(seed) => duel([mon(ABRA, 40, [CONFUSION])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
|
||||||
|
(texts) => texts.some((text) => text.includes("became confused")),
|
||||||
|
);
|
||||||
|
expect(battle.enemy.has(3, C.SUBSTATUS_CONFUSED)).toBe(true);
|
||||||
|
});
|
||||||
|
it("eats the dream of a sleeping target only", () => {
|
||||||
|
const asleep = mon(SNORLAX, 60, [SPLASH], { status: 3 });
|
||||||
|
const user = mon(ABRA, 40, [DREAM_EATER]);
|
||||||
|
user.hp = 10;
|
||||||
|
const battle = duel([user], [asleep]);
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
expect(texts.join(" ")).toContain("dream was eaten");
|
||||||
|
expect(battle.player.mon.hp).toBeGreaterThan(10);
|
||||||
|
const awake = firstTurn(duel([mon(ABRA, 40, [DREAM_EATER])], [mon(SNORLAX, 60, [SPLASH])]));
|
||||||
|
expect(awake.texts.join(" ")).not.toContain("dream was eaten");
|
||||||
|
});
|
||||||
|
it("raises the target's Attack and confuses it with Swagger", () => {
|
||||||
|
const { battle, texts } = findSeed(
|
||||||
|
(seed) => duel([mon(ABRA, 40, [SWAGGER])], [mon(SNORLAX, 60, [SPLASH])], { seed }),
|
||||||
|
(texts) => texts.some((text) => text.includes("became confused")),
|
||||||
|
);
|
||||||
|
expect(battle.enemy.levels[C.ATTACK]).toBe(9);
|
||||||
|
expect(texts.join(" ")).toContain("ATTACK went way up");
|
||||||
|
});
|
||||||
|
it("sleeps with Hypnosis and the target stays asleep", () => {
|
||||||
|
const { battle } = findSeed(
|
||||||
|
(seed) => duel([mon(ABRA, 40, [HYPNOSIS])], [mon(SNORLAX, 60, [TACKLE])], { seed }),
|
||||||
|
(texts) => texts.some((text) => text.includes("fell asleep")),
|
||||||
|
);
|
||||||
|
expect(battle.enemy.mon.status & C.SLP_MASK).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("items and money", () => {
|
||||||
|
it("steals the target's item with Thief, but not MAIL", () => {
|
||||||
|
const player = mon(MEOWTH, 30, [THIEF]);
|
||||||
|
const enemy = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS });
|
||||||
|
const { battle, texts } = findSeed(
|
||||||
|
(seed) => {
|
||||||
|
player.item = 0;
|
||||||
|
enemy.item = LEFTOVERS;
|
||||||
|
return duel([player], [enemy], { seed });
|
||||||
|
},
|
||||||
|
(texts) => texts.some((text) => text.includes("stole")),
|
||||||
|
);
|
||||||
|
expect(texts.join(" ")).toContain("stole LEFTOVERS");
|
||||||
|
expect(player.item).toBe(LEFTOVERS);
|
||||||
|
expect(battle.player.mon.item).toBe(LEFTOVERS);
|
||||||
|
expect(battle.enemy.mon.item).toBe(0);
|
||||||
|
expect(enemy.item).toBe(0);
|
||||||
|
const mail = mon(SNORLAX, 60, [SPLASH], { item: FLOWER_MAIL });
|
||||||
|
const thief = mon(MEOWTH, 30, [THIEF]);
|
||||||
|
firstTurn(duel([thief], [mail]));
|
||||||
|
expect(thief.item).toBe(0);
|
||||||
|
});
|
||||||
|
it("loses the player's item to a trainer's Thief for good", () => {
|
||||||
|
const player = mon(SNORLAX, 60, [SPLASH], { item: LEFTOVERS });
|
||||||
|
const { battle } = findSeed(
|
||||||
|
(seed) => {
|
||||||
|
player.item = LEFTOVERS;
|
||||||
|
return duel([player], [mon(MEOWTH, 30, [THIEF])], { seed });
|
||||||
|
},
|
||||||
|
(texts) => texts.some((text) => text.includes("stole")),
|
||||||
|
);
|
||||||
|
expect(player.item).toBe(0);
|
||||||
|
expect(battle.enemy.mon.item).toBe(LEFTOVERS);
|
||||||
|
});
|
||||||
|
it("scatters coins with Pay Day and pays them out after a win, doubled by an Amulet Coin", () => {
|
||||||
|
for (const item of [0, AMULET_COIN]) {
|
||||||
|
const enemy = mon(SNORLAX, 30, [SPLASH]);
|
||||||
|
enemy.hp = 1;
|
||||||
|
const battle = duel([mon(MEOWTH, 20, [PAY_DAY], { item })], [enemy]);
|
||||||
|
const { texts } = playBattle(battle);
|
||||||
|
expect(texts).toContain("Coins scattered everywhere!");
|
||||||
|
const picked = texts.find((text) => text.includes("picked up"));
|
||||||
|
expect(picked).toBe(`CHRIS picked up ¥${item ? 80 : 40}!`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("checks the Amulet Coin on every send-out", () => {
|
||||||
|
const enemy = mon(SNORLAX, 30, [SPLASH]);
|
||||||
|
enemy.hp = 1;
|
||||||
|
const fainted = mon(PIDGEY, 5, [TACKLE], { hp: 0 });
|
||||||
|
const battle = duel([mon(PIDGEY, 20, [SPLASH]), mon(MEOWTH, 20, [PAY_DAY], { item: AMULET_COIN }), fainted], [enemy]);
|
||||||
|
const { texts } = playBattle(battle, {
|
||||||
|
action: [{ action: "pkmn" }, { action: "fight", move: 0 }],
|
||||||
|
party: [1],
|
||||||
|
});
|
||||||
|
expect(texts.find((text) => text.includes("picked up"))).toBe("CHRIS picked up ¥80!");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Selfdestruct", () => {
|
||||||
|
it("faints the user even if it misses", () => {
|
||||||
|
const player = mon(GEODUDE, 30, [SELFDESTRUCT]);
|
||||||
|
const battle = duel([player, mon(PIDGEY, 30, [TACKLE])], [mon(GASTLY, 60, [SPLASH])]);
|
||||||
|
const { texts } = firstTurn(battle);
|
||||||
|
expect(texts.join(" ")).toContain("doesn't affect");
|
||||||
|
expect(player.hp).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds on at 1 HP with a Focus Band", () => {
|
||||||
|
const { texts, battle } = findSeed(
|
||||||
|
(seed) => {
|
||||||
|
const enemy = mon(SNORLAX, 60, [SPLASH], { item: FOCUS_BAND });
|
||||||
|
enemy.hp = 1;
|
||||||
|
return duel([mon(JOLTEON, 40, [TACKLE])], [enemy], { seed });
|
||||||
|
},
|
||||||
|
(texts) => texts.some((text) => text.includes("hung on")),
|
||||||
|
);
|
||||||
|
expect(battle.enemy.mon.hp).toBe(1);
|
||||||
|
expect(texts.join(" ")).toContain("FOCUS BAND");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { C, COMMAND } from "../src/game/battle/constants";
|
||||||
|
import {
|
||||||
|
romPath,
|
||||||
|
symbolsPath,
|
||||||
|
loadSymbols,
|
||||||
|
openReference,
|
||||||
|
} from "./reference-fixture";
|
||||||
|
|
||||||
|
/** One side's battle state: its battle_struct, move struct, substatuses,
|
||||||
|
* stat levels, unboosted stats, screens and counters. */
|
||||||
|
type Side = {
|
||||||
|
species: number;
|
||||||
|
item: number;
|
||||||
|
dvs: number;
|
||||||
|
happiness: number;
|
||||||
|
level: number;
|
||||||
|
status: number;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
stats: number[];
|
||||||
|
types: [number, number];
|
||||||
|
move: { anim: number; effect: number; power: number; type: number; accuracy: number };
|
||||||
|
sub: number[];
|
||||||
|
levels: number[];
|
||||||
|
unboosted: number[];
|
||||||
|
screens: number;
|
||||||
|
rollout: number;
|
||||||
|
damageTaken: number;
|
||||||
|
lastCounterMove: number;
|
||||||
|
};
|
||||||
|
type Case = {
|
||||||
|
turn: number;
|
||||||
|
seeds: number[];
|
||||||
|
count: number;
|
||||||
|
sides: [Side, Side];
|
||||||
|
curDamage: number;
|
||||||
|
criticalHit: number;
|
||||||
|
attackMissed: number;
|
||||||
|
typeModifier: number;
|
||||||
|
enemyGoesFirst: number;
|
||||||
|
animParam: number;
|
||||||
|
battleMode: number;
|
||||||
|
partyCount: number;
|
||||||
|
otPartyCount: number;
|
||||||
|
beatUpHit: number;
|
||||||
|
/** A battle script and the index of the command after the one run. */
|
||||||
|
script: number[];
|
||||||
|
scriptIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function generator(start: number) {
|
||||||
|
let seed = start;
|
||||||
|
const next = (limit: number) => {
|
||||||
|
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||||
|
return (seed >>> 16) % limit;
|
||||||
|
};
|
||||||
|
const side = (overrides: Partial<Side> = {}): Side => {
|
||||||
|
const maxHp = 20 + next(300);
|
||||||
|
return {
|
||||||
|
species: 155,
|
||||||
|
item: 0,
|
||||||
|
dvs: next(0x10000),
|
||||||
|
happiness: next(256),
|
||||||
|
level: 5 + next(95),
|
||||||
|
status: 0,
|
||||||
|
hp: 1 + next(maxHp),
|
||||||
|
maxHp,
|
||||||
|
stats: Array.from({ length: 5 }, () => 5 + next(400)),
|
||||||
|
types: [C.NORMAL, C.NORMAL],
|
||||||
|
move: { anim: 33, effect: 0, power: 40, type: C.NORMAL, accuracy: 255 },
|
||||||
|
sub: [0, 0, 0, 0, 0],
|
||||||
|
levels: [7, 7, 7, 7, 7, 7, 7],
|
||||||
|
unboosted: Array.from({ length: 5 }, () => 5 + next(400)),
|
||||||
|
screens: 0,
|
||||||
|
rollout: 0,
|
||||||
|
damageTaken: 0,
|
||||||
|
lastCounterMove: 0,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const make = (overrides: Partial<Case> = {}): Case => ({
|
||||||
|
turn: next(2),
|
||||||
|
seeds: Array.from({ length: 10 }, () => next(256)),
|
||||||
|
count: next(9),
|
||||||
|
sides: [side(), side()],
|
||||||
|
curDamage: next(1000),
|
||||||
|
criticalHit: 0,
|
||||||
|
attackMissed: 0,
|
||||||
|
typeModifier: C.EFFECTIVE,
|
||||||
|
enemyGoesFirst: next(2),
|
||||||
|
animParam: 0,
|
||||||
|
battleMode: C.TRAINER_BATTLE,
|
||||||
|
partyCount: 1 + next(6),
|
||||||
|
otPartyCount: 1 + next(6),
|
||||||
|
beatUpHit: 1,
|
||||||
|
script: [COMMAND.critical, COMMAND.endmove],
|
||||||
|
scriptIndex: 1,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
return { next, side, make };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the unpatched commands in the reference emulator with the Link RNG,
|
||||||
|
* and the native commands on the same state; compare what they leave. */
|
||||||
|
async function compare(page: Page, routines: string[], cases: Case[]) {
|
||||||
|
const { at, routine } = loadSymbols();
|
||||||
|
const commands = routines.map((name) => {
|
||||||
|
const symbol = routine(name);
|
||||||
|
if (symbol.bank !== 0x0d) throw new Error(`${name} is not in bank $0d.`);
|
||||||
|
return {
|
||||||
|
address: symbol.address,
|
||||||
|
command: COMMAND[
|
||||||
|
name.replace("BattleCommand_", "").toLowerCase() as keyof typeof COMMAND
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const addresses = {
|
||||||
|
romBank: at("hROMBank"),
|
||||||
|
turn: at("hBattleTurn"),
|
||||||
|
link: at("wLinkMode"),
|
||||||
|
seeds: at("wLinkBattleRNs"),
|
||||||
|
count: at("wLinkBattleRNCount"),
|
||||||
|
damage: at("wCurDamage"),
|
||||||
|
critical: at("wCriticalHit"),
|
||||||
|
missed: at("wAttackMissed"),
|
||||||
|
typeModifier: at("wTypeModifier"),
|
||||||
|
typeMatchup: at("wTypeMatchup"),
|
||||||
|
enemyGoesFirst: at("wEnemyGoesFirst"),
|
||||||
|
animParam: at("wBattleAnimParam"),
|
||||||
|
battleMode: at("wBattleMode"),
|
||||||
|
partyCount: at("wPartyCount"),
|
||||||
|
otPartyCount: at("wOTPartyCount"),
|
||||||
|
beatUpHit: at("wBeatUpHitAtLeastOnce"),
|
||||||
|
scriptBuffer: at("wBattleScriptBuffer"),
|
||||||
|
scriptAddress: at("wBattleScriptBufferAddress"),
|
||||||
|
sides: [
|
||||||
|
{
|
||||||
|
mon: at("wBattleMonSpecies"),
|
||||||
|
move: at("wPlayerMoveStruct"),
|
||||||
|
sub: at("wPlayerSubStatus1"),
|
||||||
|
levels: at("wPlayerStatLevels"),
|
||||||
|
stats: at("wPlayerStats"),
|
||||||
|
screens: at("wPlayerScreens"),
|
||||||
|
rollout: at("wPlayerRolloutCount"),
|
||||||
|
damageTaken: at("wPlayerDamageTaken"),
|
||||||
|
lastCounterMove: at("wLastPlayerCounterMove"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mon: at("wEnemyMonSpecies"),
|
||||||
|
move: at("wEnemyMoveStruct"),
|
||||||
|
sub: at("wEnemySubStatus1"),
|
||||||
|
levels: at("wEnemyStatLevels"),
|
||||||
|
stats: at("wEnemyStats"),
|
||||||
|
screens: at("wEnemyScreens"),
|
||||||
|
rollout: at("wEnemyRolloutCount"),
|
||||||
|
damageTaken: at("wEnemyDamageTaken"),
|
||||||
|
lastCounterMove: at("wLastEnemyCounterMove"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await openReference(page);
|
||||||
|
const results = await page.evaluate(
|
||||||
|
async ({ addresses, commands, cases }) => {
|
||||||
|
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
|
||||||
|
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
|
||||||
|
const { Battle } = await import("/src/game/battle/battle.ts");
|
||||||
|
const file = (
|
||||||
|
document.querySelector("#reference-fixture") as HTMLInputElement
|
||||||
|
).files![0];
|
||||||
|
const rom = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const assets = new CrystalAssets(rom);
|
||||||
|
const reference = new BinjgbEmulator();
|
||||||
|
await reference.load(rom, document.createElement("canvas"));
|
||||||
|
const write = (address: number, value: number) =>
|
||||||
|
reference.writeMemory(address, value & 255);
|
||||||
|
const word = (address: number, value: number) => {
|
||||||
|
write(address, value >> 8);
|
||||||
|
write(address + 1, value);
|
||||||
|
};
|
||||||
|
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.
|
||||||
|
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);
|
||||||
|
write(0xff70, 1);
|
||||||
|
write(addresses.link, 1);
|
||||||
|
const results = cases.map((input, index) => {
|
||||||
|
let seeds = [...input.seeds],
|
||||||
|
count = input.count,
|
||||||
|
draws = 0;
|
||||||
|
const party = Array.from({ length: input.partyCount }, () => ({}));
|
||||||
|
const battle = new Battle({
|
||||||
|
data: assets.pokemon,
|
||||||
|
party: party as never,
|
||||||
|
pokedex: { seen: new Set(), caught: new Set() },
|
||||||
|
playerId: 1,
|
||||||
|
playerName: "CHRIS",
|
||||||
|
timeOfDay: 1,
|
||||||
|
text: () => ({}) as never,
|
||||||
|
trainer: {
|
||||||
|
name: "",
|
||||||
|
className: "",
|
||||||
|
party: Array.from({ length: input.otPartyCount }, () => ({})) as never,
|
||||||
|
aiFlags: 0,
|
||||||
|
switchFlags: 0,
|
||||||
|
reward: 0,
|
||||||
|
},
|
||||||
|
random: () => {
|
||||||
|
if (++draws > 1024) throw new Error(`Unbounded RNG in case ${index}`);
|
||||||
|
const value = seeds[count++];
|
||||||
|
if (count >= 9) {
|
||||||
|
count = 0;
|
||||||
|
seeds = seeds.map((value) => (value * 5 + 1) & 255);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const internal = battle as unknown as Record<string, unknown> & {
|
||||||
|
command(id: number): Generator;
|
||||||
|
};
|
||||||
|
Object.assign(internal, {
|
||||||
|
turn: input.turn,
|
||||||
|
curDamage: input.curDamage,
|
||||||
|
criticalHit: input.criticalHit,
|
||||||
|
attackMissed: input.attackMissed,
|
||||||
|
typeModifier: input.typeModifier,
|
||||||
|
enemyGoesFirst: input.enemyGoesFirst,
|
||||||
|
animParam: input.animParam,
|
||||||
|
battleMode: input.battleMode,
|
||||||
|
beatUpHit: input.beatUpHit,
|
||||||
|
script: [...input.script],
|
||||||
|
scriptIndex: input.scriptIndex,
|
||||||
|
});
|
||||||
|
write(addresses.turn, input.turn);
|
||||||
|
word(addresses.damage, input.curDamage);
|
||||||
|
write(addresses.critical, input.criticalHit);
|
||||||
|
write(addresses.missed, input.attackMissed);
|
||||||
|
write(addresses.typeModifier, input.typeModifier);
|
||||||
|
write(addresses.typeMatchup, 10);
|
||||||
|
write(addresses.enemyGoesFirst, input.enemyGoesFirst);
|
||||||
|
write(addresses.animParam, input.animParam);
|
||||||
|
write(addresses.battleMode, input.battleMode);
|
||||||
|
write(addresses.partyCount, input.partyCount);
|
||||||
|
write(addresses.otPartyCount, input.otPartyCount);
|
||||||
|
write(addresses.beatUpHit, input.beatUpHit);
|
||||||
|
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));
|
||||||
|
const scriptAddress = addresses.scriptBuffer + input.scriptIndex;
|
||||||
|
write(addresses.scriptAddress, scriptAddress);
|
||||||
|
write(addresses.scriptAddress + 1, scriptAddress >> 8);
|
||||||
|
[battle.player, battle.enemy].forEach((side, i) => {
|
||||||
|
const s = input.sides[i],
|
||||||
|
a = addresses.sides[i];
|
||||||
|
side.mon = {
|
||||||
|
species: s.species,
|
||||||
|
item: s.item,
|
||||||
|
moves: [s.move.anim, 0, 0, 0],
|
||||||
|
dvs: s.dvs,
|
||||||
|
pp: [10, 0, 0, 0],
|
||||||
|
happiness: s.happiness,
|
||||||
|
level: s.level,
|
||||||
|
status: s.status,
|
||||||
|
hp: s.hp,
|
||||||
|
maxHp: s.maxHp,
|
||||||
|
stats: [...s.stats],
|
||||||
|
types: [...s.types],
|
||||||
|
nickname: "",
|
||||||
|
} as never;
|
||||||
|
side.move = { ...s.move, pp: 10, chance: 0 };
|
||||||
|
side.sub = [0, ...s.sub];
|
||||||
|
side.levels = [...s.levels];
|
||||||
|
side.stats = [...s.unboosted];
|
||||||
|
side.screens = s.screens;
|
||||||
|
side.rolloutCount = s.rollout;
|
||||||
|
side.damageTaken = s.damageTaken;
|
||||||
|
side.lastCounterMove = s.lastCounterMove;
|
||||||
|
const mon = a.mon;
|
||||||
|
write(mon, s.species);
|
||||||
|
write(mon + 1, s.item);
|
||||||
|
word(mon + 6, s.dvs);
|
||||||
|
write(mon + 12, s.happiness);
|
||||||
|
write(mon + 13, s.level);
|
||||||
|
write(mon + 14, s.status);
|
||||||
|
word(mon + 16, s.hp);
|
||||||
|
word(mon + 18, s.maxHp);
|
||||||
|
s.stats.forEach((value, j) => word(mon + 20 + j * 2, value));
|
||||||
|
write(mon + 30, s.types[0]);
|
||||||
|
write(mon + 31, s.types[1]);
|
||||||
|
[s.move.anim, s.move.effect, s.move.power, s.move.type, s.move.accuracy, 10, 0].forEach(
|
||||||
|
(value, j) => write(a.move + j, value),
|
||||||
|
);
|
||||||
|
s.sub.forEach((value, j) => write(a.sub + j, value));
|
||||||
|
s.levels.forEach((value, j) => write(a.levels + j, value));
|
||||||
|
s.unboosted.forEach((value, j) => word(a.stats + j * 2, value));
|
||||||
|
write(a.screens, s.screens);
|
||||||
|
write(a.rollout, s.rollout);
|
||||||
|
word(a.damageTaken, s.damageTaken);
|
||||||
|
write(a.lastCounterMove, s.lastCounterMove);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
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`);
|
||||||
|
const native = {
|
||||||
|
damage: internal.curDamage,
|
||||||
|
critical: internal.criticalHit,
|
||||||
|
missed: internal.attackMissed,
|
||||||
|
typeModifier: internal.typeModifier,
|
||||||
|
typeMatchup: internal.matchup,
|
||||||
|
animParam: internal.animParam,
|
||||||
|
script: (internal.script as number[]).slice(0, input.script.length),
|
||||||
|
scriptIndex: internal.scriptIndex,
|
||||||
|
sides: [battle.player, battle.enemy].map((side) => ({
|
||||||
|
power: side.move.power,
|
||||||
|
type: side.move.type,
|
||||||
|
accuracy: side.move.accuracy,
|
||||||
|
sub3: side.sub[3],
|
||||||
|
rollout: side.rolloutCount,
|
||||||
|
damageTaken: side.damageTaken,
|
||||||
|
})),
|
||||||
|
count,
|
||||||
|
seeds,
|
||||||
|
};
|
||||||
|
const original = {
|
||||||
|
damage: readWord(addresses.damage),
|
||||||
|
critical: read(addresses.critical),
|
||||||
|
missed: read(addresses.missed),
|
||||||
|
typeModifier: read(addresses.typeModifier),
|
||||||
|
typeMatchup: read(addresses.typeMatchup),
|
||||||
|
animParam: read(addresses.animParam),
|
||||||
|
script: input.script.map((_, i) => read(addresses.scriptBuffer + i)),
|
||||||
|
scriptIndex:
|
||||||
|
((read(addresses.scriptAddress + 1) << 8) | read(addresses.scriptAddress)) -
|
||||||
|
addresses.scriptBuffer,
|
||||||
|
sides: addresses.sides.map((a) => ({
|
||||||
|
power: read(a.move + 2),
|
||||||
|
type: read(a.move + 3),
|
||||||
|
accuracy: read(a.move + 4),
|
||||||
|
sub3: read(a.sub + 2),
|
||||||
|
rollout: read(a.rollout),
|
||||||
|
damageTaken: readWord(a.damageTaken),
|
||||||
|
})),
|
||||||
|
count: read(addresses.count),
|
||||||
|
seeds: Array.from({ length: 10 }, (_, i) => read(addresses.seeds + i)),
|
||||||
|
};
|
||||||
|
return { native, original, draws };
|
||||||
|
});
|
||||||
|
reference.dispose();
|
||||||
|
return results;
|
||||||
|
},
|
||||||
|
{ addresses, commands, cases },
|
||||||
|
);
|
||||||
|
results.forEach((result, index) =>
|
||||||
|
expect(result.native, `case ${index}: ${JSON.stringify(cases[index])}`).toEqual(result.original),
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
test.skip(
|
||||||
|
!existsSync(romPath) || !existsSync(symbolsPath),
|
||||||
|
"Requires the local user ROM and the private PRET rebuild.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const GHOST = 8,
|
||||||
|
PSYCHIC_TYPE = 24,
|
||||||
|
DARK = 27,
|
||||||
|
FIGHTING = 1,
|
||||||
|
WATER = 21;
|
||||||
|
|
||||||
|
test("ConstantDamage matches level, fixed, Psywave, Super Fang and Reversal damage", async ({ page }) => {
|
||||||
|
const { make, side, next } = generator(0x5eed);
|
||||||
|
const cases: Case[] = [];
|
||||||
|
const effects = [C.EFFECT_LEVEL_DAMAGE, C.EFFECT_STATIC_DAMAGE, C.EFFECT_PSYWAVE, C.EFFECT_SUPER_FANG, C.EFFECT_REVERSAL];
|
||||||
|
for (const effect of effects)
|
||||||
|
for (let i = 0; i < 160; i++) {
|
||||||
|
const input = make();
|
||||||
|
const user = input.sides[input.turn];
|
||||||
|
user.move = { ...user.move, effect, power: [1, 20, 40, 100][next(4)], type: [C.NORMAL, FIGHTING, WATER][next(3)] };
|
||||||
|
if (effect === C.EFFECT_PSYWAVE && user.level < 2) user.level = 2;
|
||||||
|
if (effect === C.EFFECT_REVERSAL) {
|
||||||
|
// HP bar boundaries and maxima above 255, with stats up to 999 so
|
||||||
|
// TruncateHL_BC scales them.
|
||||||
|
user.maxHp = [48, 200, 255, 256, 700, 999][next(6)];
|
||||||
|
user.hp = i < 48 ? Math.max(1, Math.floor((user.maxHp * i) / 48)) : 1 + next(user.maxHp);
|
||||||
|
user.stats = user.stats.map(() => 1 + next(999));
|
||||||
|
input.sides[input.turn ^ 1].stats = input.sides[0].stats.map(() => 1 + next(999));
|
||||||
|
}
|
||||||
|
if (effect === C.EFFECT_SUPER_FANG) input.sides[input.turn ^ 1].hp = [1, 2, 3, 255, 256, 511, 999][next(7)];
|
||||||
|
cases.push(input);
|
||||||
|
}
|
||||||
|
// Psywave's rejection loop through a regenerated stream.
|
||||||
|
cases.push(make({ turn: 0, sides: [side({ level: 10, move: { anim: 149, effect: C.EFFECT_PSYWAVE, power: 1, type: 24, accuracy: 255 } }), side()], seeds: Array(10).fill(200) }));
|
||||||
|
await compare(page, ["BattleCommand_ConstantDamage"], cases);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OHKO matches level checks, accuracy bonus and one-hit KO damage", async ({ page }) => {
|
||||||
|
const { make, next } = generator(0x0b0e);
|
||||||
|
const cases: Case[] = [];
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const input = make({ typeModifier: next(4) ? C.EFFECTIVE : 0 });
|
||||||
|
const user = input.sides[input.turn];
|
||||||
|
user.move = { ...user.move, effect: C.EFFECT_OHKO, power: 1, accuracy: [0, 76, 178, 255][next(4)] };
|
||||||
|
if (next(3)) input.sides[input.turn ^ 1].level = Math.max(1, user.level - next(100));
|
||||||
|
cases.push(input);
|
||||||
|
}
|
||||||
|
await compare(page, ["BattleCommand_OHKO"], cases);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Counter and Mirror Coat match their move, type, order and damage checks", async ({ page }) => {
|
||||||
|
const { make, next } = generator(0xc0de);
|
||||||
|
const cases: Case[] = [];
|
||||||
|
// Physical, special, status, Counter, Mirror Coat, no move.
|
||||||
|
const lastMoves = [33, 52, 45, 68, 243, 0, 89, 94, 149];
|
||||||
|
for (const [routine, effect, type] of [
|
||||||
|
["BattleCommand_Counter", C.EFFECT_COUNTER, FIGHTING],
|
||||||
|
["BattleCommand_MirrorCoat", C.EFFECT_MIRROR_COAT, PSYCHIC_TYPE],
|
||||||
|
] as const) {
|
||||||
|
const batch: Case[] = [];
|
||||||
|
for (let i = 0; i < 400; i++) {
|
||||||
|
const input = make({ curDamage: [0, 1, 55, 997, 0x7fff, 0x8000, 0xffff][next(7)] });
|
||||||
|
const user = input.sides[input.turn],
|
||||||
|
target = input.sides[input.turn ^ 1];
|
||||||
|
user.move = { ...user.move, effect, type, power: 1 };
|
||||||
|
target.lastCounterMove = lastMoves[next(lastMoves.length)];
|
||||||
|
target.types = [[C.NORMAL, GHOST, DARK, PSYCHIC_TYPE][next(4)], [C.NORMAL, GHOST, DARK][next(3)]] as [number, number];
|
||||||
|
if (next(4) === 0) target.sub[0] = 1 << C.SUBSTATUS_IDENTIFIED;
|
||||||
|
batch.push(input);
|
||||||
|
}
|
||||||
|
await compare(page, [routine], batch);
|
||||||
|
cases.push(...batch);
|
||||||
|
}
|
||||||
|
expect(cases.length).toBe(800);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("EndLoop matches the hit count draws, Beat Up counts and the loop back", async ({ page }) => {
|
||||||
|
const { make, next } = generator(0x100b);
|
||||||
|
const cases: Case[] = [];
|
||||||
|
// checkhit, critical, damagestats, damagecalc, endloop | raisesub, endmove
|
||||||
|
const script = [COMMAND.checkhit, COMMAND.critical, COMMAND.damagestats, COMMAND.damagecalc, COMMAND.endloop, COMMAND.raisesub, COMMAND.endmove, 0, 0];
|
||||||
|
const effects = [C.EFFECT_MULTI_HIT, C.EFFECT_DOUBLE_HIT, C.EFFECT_POISON_MULTI_HIT, C.EFFECT_TRIPLE_KICK, C.EFFECT_BEAT_UP];
|
||||||
|
for (const effect of effects)
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const input = make({ script, scriptIndex: 5, battleMode: next(3) ? C.TRAINER_BATTLE : C.WILD_BATTLE });
|
||||||
|
const user = input.sides[input.turn];
|
||||||
|
user.move = { ...user.move, effect };
|
||||||
|
user.damageTaken = next(0x10000);
|
||||||
|
if (i % 2) {
|
||||||
|
// A later pass that loops back again.
|
||||||
|
user.sub[2] = 1 << C.SUBSTATUS_IN_LOOP;
|
||||||
|
user.rollout = 2 + next(4);
|
||||||
|
}
|
||||||
|
// A single Triple Kick, or a final pass, prints "Hit n times!"; the
|
||||||
|
// fixture cannot answer texts, so those draws are moved on.
|
||||||
|
if (effect === C.EFFECT_TRIPLE_KICK && !(i % 2)) {
|
||||||
|
let p = input.count;
|
||||||
|
while (!(input.seeds[p] & 3) && p < 8) p++;
|
||||||
|
if ((input.seeds[p] & 3) === 1) input.seeds[p] |= 2;
|
||||||
|
if (p === 8 && !(input.seeds[p] & 3)) input.seeds[p] = 2;
|
||||||
|
}
|
||||||
|
cases.push(input);
|
||||||
|
}
|
||||||
|
await compare(page, ["BattleCommand_EndLoop"], cases);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Triple Kick, Hidden Power and False Swipe match their damage changes", async ({ page }) => {
|
||||||
|
const { make, next } = generator(0x3ce);
|
||||||
|
const kicks: Case[] = [];
|
||||||
|
for (let i = 0; i < 200; i++)
|
||||||
|
kicks.push(make({ animParam: next(4), curDamage: [0, 1, 300, 997, 0x5555, 0x8000, 0xffff][next(7)] }));
|
||||||
|
await compare(page, ["BattleCommand_TripleKick"], kicks);
|
||||||
|
// Hidden Power's type and power, then the damage they give.
|
||||||
|
const hidden: Case[] = [];
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
const input = make({ curDamage: 0 });
|
||||||
|
const user = input.sides[input.turn];
|
||||||
|
user.dvs = i < 256 ? (i << 8) | (i ^ 0x5a) : next(0x10000);
|
||||||
|
user.move = { ...user.move, effect: C.EFFECT_HIDDEN_POWER, power: 1, anim: 237 };
|
||||||
|
hidden.push(input);
|
||||||
|
}
|
||||||
|
await compare(page, ["BattleCommand_HiddenPower", "BattleCommand_DamageCalc"], hidden);
|
||||||
|
const swipes: Case[] = [];
|
||||||
|
for (let i = 0; i < 300; i++) {
|
||||||
|
const input = make({ criticalHit: [0, 1, 2][next(3)], curDamage: next(600) });
|
||||||
|
input.sides[input.turn ^ 1].hp = [1, 2, 255, 256, next(600)][next(5)];
|
||||||
|
swipes.push(input);
|
||||||
|
}
|
||||||
|
await compare(page, ["BattleCommand_FalseSwipe"], swipes);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user