[Enhancement][EnemyAI] Add support for simulated damage calculations and "Search for KO" move filtering (#3975)

* Create getAttackDamage function

* Add ignoreAbility params to getBattleStat

* Rewrite Pokemon.apply

* renamed damage variables

* Add `ignoreSourceAbility` arg to `getAttackDamage`

* Enemy AI now searches for KO moves

* Add probabilistic test for KO search

* Add tests to `damage_calculation`

* "killMoves" --> "koMoves"

* Clean up `randomMultiplier`

* Clean up damage calculation test

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>

* Fix stabMultiplier using base type for Tera bonus

* Restore simulation capabilities for Unaware

* move sourceTeraType closer to where it's used

* Add base damage test

* Exclude counter moves from KO search

---------

Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com>
This commit is contained in:
innerthunder 2024-09-13 22:54:22 -07:00 committed by GitHub
parent 9d3681cf31
commit 77f0fe6e4b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 568 additions and 315 deletions

View File

@ -3977,7 +3977,7 @@ export class StatusCategoryOnAllyAttr extends VariableMoveCategoryAttr {
export class ShellSideArmCategoryAttr extends VariableMoveCategoryAttr {
apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean {
const category = (args[0] as Utils.IntegerHolder);
const category = (args[0] as Utils.NumberHolder);
const atkRatio = user.getEffectiveStat(Stat.ATK, target, move) / target.getEffectiveStat(Stat.DEF, user, move);
const specialRatio = user.getEffectiveStat(Stat.SPATK, target, move) / target.getEffectiveStat(Stat.SPDEF, user, move);

View File

@ -3,7 +3,7 @@ import BattleScene, { AnySound } from "../battle-scene";
import { Variant, VariantSet, variantColorCache } from "#app/data/variant";
import { variantData } from "#app/data/variant";
import BattleInfo, { PlayerBattleInfo, EnemyBattleInfo } from "../ui/battle-info";
import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr } from "../data/move";
import Move, { HighCritAttr, HitsTagAttr, applyMoveAttrs, FixedDamageAttr, VariableAtkAttr, allMoves, MoveCategory, TypelessAttr, CritOnlyAttr, getMoveTargets, OneHitKOAttr, VariableMoveTypeAttr, VariableDefAttr, AttackMove, ModifiedDamageAttr, VariableMoveTypeMultiplierAttr, IgnoreOpponentStatStagesAttr, SacrificialAttr, VariableMoveCategoryAttr, CounterDamageAttr, StatStageChangeAttr, RechargeAttr, ChargeAttr, IgnoreWeatherTypeDebuffAttr, BypassBurnDamageReductionAttr, SacrificialAttrOnHit, OneHitKOAccuracyAttr, RespectAttackTypeImmunityAttr, MoveTarget } from "../data/move";
import { default as PokemonSpecies, PokemonSpeciesForm, SpeciesFormKey, getFusedSpeciesName, getPokemonSpecies, getPokemonSpeciesForm, getStarterValueFriendshipCap, speciesStarters, starterPassiveAbilities } from "../data/pokemon-species";
import { Constructor, isNullOrUndefined, randSeedInt } from "#app/utils";
import * as Utils from "../utils";
@ -870,22 +870,29 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @param stat the desired {@linkcode EffectiveStat}
* @param opponent the target {@linkcode Pokemon}
* @param move the {@linkcode Move} being used
* @param ignoreAbility determines whether this Pokemon's abilities should be ignored during the stat calculation
* @param ignoreOppAbility during an attack, determines whether the opposing Pokemon's abilities should be ignored during the stat calculation.
* @param isCritical determines whether a critical hit has occurred or not (`false` by default)
* @param simulated if `true`, nullifies any effects that produce any changes to game state from triggering
* @returns the final in-battle value of a stat
*/
getEffectiveStat(stat: EffectiveStat, opponent?: Pokemon, move?: Move, isCritical: boolean = false): integer {
getEffectiveStat(stat: EffectiveStat, opponent?: Pokemon, move?: Move, ignoreAbility: boolean = false, ignoreOppAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): integer {
const statValue = new Utils.NumberHolder(this.getStat(stat, false));
this.scene.applyModifiers(StatBoosterModifier, this.isPlayer(), this, stat, statValue);
// The Ruin abilities here are never ignored, but they reveal themselves on summon anyway
const fieldApplied = new Utils.BooleanHolder(false);
for (const pokemon of this.scene.getField(true)) {
applyFieldStatMultiplierAbAttrs(FieldMultiplyStatAbAttr, pokemon, stat, statValue, this, fieldApplied);
applyFieldStatMultiplierAbAttrs(FieldMultiplyStatAbAttr, pokemon, stat, statValue, this, fieldApplied, simulated);
if (fieldApplied.value) {
break;
}
}
applyStatMultiplierAbAttrs(StatMultiplierAbAttr, this, stat, statValue);
let ret = statValue.value * this.getStatStageMultiplier(stat, opponent, move, isCritical);
if (!ignoreAbility) {
applyStatMultiplierAbAttrs(StatMultiplierAbAttr, this, stat, statValue, simulated);
}
let ret = statValue.value * this.getStatStageMultiplier(stat, opponent, move, ignoreOppAbility, isCritical, simulated);
switch (stat) {
case Stat.ATK:
@ -2223,10 +2230,12 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* @param stat the desired {@linkcode EffectiveStat}
* @param opponent the target {@linkcode Pokemon}
* @param move the {@linkcode Move} being used
* @param ignoreOppAbility determines whether the effects of the opponent's abilities (i.e. Unaware) should be ignored (`false` by default)
* @param isCritical determines whether a critical hit has occurred or not (`false` by default)
* @param simulated determines whether effects are applied without altering game state (`true` by default)
* @return the stat stage multiplier to be used for effective stat calculation
*/
getStatStageMultiplier(stat: EffectiveStat, opponent?: Pokemon, move?: Move, isCritical: boolean = false): number {
getStatStageMultiplier(stat: EffectiveStat, opponent?: Pokemon, move?: Move, ignoreOppAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): number {
const statStage = new Utils.IntegerHolder(this.getStatStage(stat));
const ignoreStatStage = new Utils.BooleanHolder(false);
@ -2243,7 +2252,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
break;
}
}
applyAbAttrs(IgnoreOpponentStatStagesAbAttr, opponent, null, false, stat, ignoreStatStage);
if (!ignoreOppAbility) {
applyAbAttrs(IgnoreOpponentStatStagesAbAttr, opponent, null, simulated, stat, ignoreStatStage);
}
if (move) {
applyMoveAttrs(IgnoreOpponentStatStagesAttr, this, opponent, move, ignoreStatStage);
}
@ -2308,13 +2319,18 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
}
/**
* Apply the results of a move to this pokemon
* @param {Pokemon} source The pokemon using the move
* @param {PokemonMove} battlerMove The move being used
* @returns {HitResult} The result of the attack
* Calculates the damage of an attack made by another Pokemon against this Pokemon
* @param source {@linkcode Pokemon} the attacking Pokemon
* @param move {@linkcode Pokemon} the move used in the attack
* @param ignoreAbility If `true`, ignores this Pokemon's defensive ability effects
* @param isCritical If `true`, calculates damage for a critical hit.
* @param simulated If `true`, suppresses changes to game state during the calculation.
* @returns a {@linkcode DamageCalculationResult} object with three fields:
* - `cancelled`: `true` if the move was cancelled by another effect.
* - `result`: {@linkcode HitResult} indicates the attack's type effectiveness.
* - `damage`: `number` the attack's final damage output.
*/
apply(source: Pokemon, move: Move): HitResult {
let result: HitResult;
getAttackDamage(source: Pokemon, move: Move, ignoreAbility: boolean = false, ignoreSourceAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): DamageCalculationResult {
const damage = new Utils.NumberHolder(0);
const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
@ -2332,57 +2348,240 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* The effectiveness of the move being used. Along with type matchups, this
* accounts for changes in effectiveness from the move's attributes and the
* abilities of both the source and this Pokemon.
*
* Note that the source's abilities are not ignored here
*/
const typeMultiplier = this.getMoveEffectiveness(source, move, false, false, cancelled);
const typeMultiplier = this.getMoveEffectiveness(source, move, ignoreAbility, simulated, cancelled);
switch (moveCategory) {
case MoveCategory.PHYSICAL:
case MoveCategory.SPECIAL:
const isPhysical = moveCategory === MoveCategory.PHYSICAL;
const sourceTeraType = source.getTeraType();
const power = move.calculateBattlePower(source, this);
if (cancelled.value) {
// Cancelled moves fail silently
source.stopMultiHit(this);
return HitResult.NO_EFFECT;
} else {
const typeBoost = source.findTag(t => t instanceof TypeBoostTag && t.boostedType === moveType) as TypeBoostTag;
if (typeBoost?.oneUse) {
source.removeTag(typeBoost.tagType);
}
/** Combined damage multiplier from field effects such as weather, terrain, etc. */
const arenaAttackTypeMultiplier = new Utils.NumberHolder(this.scene.arena.getAttackTypeMultiplier(moveType, source.isGrounded()));
applyMoveAttrs(IgnoreWeatherTypeDebuffAttr, source, this, move, arenaAttackTypeMultiplier);
/**
* Whether or not this Pokemon is immune to the incoming move.
* Note that this isn't fully resolved in `getMoveEffectiveness` because
* of possible type-suppressing field effects (e.g. Desolate Land's effect on Water-type attacks).
*/
const isTypeImmune = (typeMultiplier * arenaAttackTypeMultiplier.value) === 0;
if (isTypeImmune) {
// Moves with no effect that were not cancelled queue a "no effect" message before failing
source.stopMultiHit(this);
result = (move.id === Moves.SHEER_COLD)
? HitResult.IMMUNE
: HitResult.NO_EFFECT;
if (result === HitResult.IMMUNE) {
this.scene.queueMessage(i18next.t("battle:hitResultImmune", { pokemonName: this.name }));
if (cancelled.value || isTypeImmune) {
return {
cancelled: cancelled.value,
result: move.id === Moves.SHEER_COLD ? HitResult.IMMUNE : HitResult.NO_EFFECT,
damage: 0
};
}
// If the attack deals fixed damaged, return a result with that much damage
const fixedDamage = new Utils.IntegerHolder(0);
applyMoveAttrs(FixedDamageAttr, source, this, move, fixedDamage);
if (fixedDamage.value) {
return {
cancelled: false,
result: HitResult.EFFECTIVE,
damage: fixedDamage.value
};
}
// If the attack is a one-hit KO move, return a result with damage equal to this Pokemon's HP
const isOneHitKo = new Utils.BooleanHolder(false);
applyMoveAttrs(OneHitKOAttr, source, this, move, isOneHitKo);
if (isOneHitKo.value) {
return {
cancelled: false,
result: HitResult.ONE_HIT_KO,
damage: this.hp
};
}
// ----- BEGIN BASE DAMAGE MULTIPLIERS -----
/** A base damage multiplier based on the source's level */
const levelMultiplier = (2 * source.level / 5 + 2);
/** The power of the move after power boosts from abilities, etc. have applied */
const power = move.calculateBattlePower(source, this, simulated);
/**
* The attacker's offensive stat for the given move's category.
* Critical hits ignore negative stat stages.
*/
const sourceAtk = new Utils.NumberHolder(source.getEffectiveStat(isPhysical ? Stat.ATK : Stat.SPATK, this, undefined, ignoreSourceAbility, ignoreAbility, isCritical, simulated));
applyMoveAttrs(VariableAtkAttr, source, this, move, sourceAtk);
/**
* This Pokemon's defensive stat for the given move's category.
* Critical hits ignore positive stat stages.
*/
const targetDef = new Utils.NumberHolder(this.getEffectiveStat(isPhysical ? Stat.DEF : Stat.SPDEF, source, move, ignoreAbility, ignoreSourceAbility, isCritical, simulated));
applyMoveAttrs(VariableDefAttr, source, this, move, targetDef);
/**
* The attack's base damage, as determined by the source's level, move power
* and Attack stat as well as this Pokemon's Defense stat
*/
const baseDamage = ((levelMultiplier * power * sourceAtk.value / targetDef.value) / 50) + 2;
// ------ END BASE DAMAGE MULTIPLIERS ------
/** 25% damage debuff on moves hitting more than one non-fainted target (regardless of immunities) */
const { targets, multiple } = getMoveTargets(source, move.id);
const numTargets = multiple ? targets.length : 1;
const targetMultiplier = (numTargets > 1) ? 0.75 : 1;
/** 0.25x multiplier if this is an added strike from the attacker's Parental Bond */
const parentalBondMultiplier = new Utils.NumberHolder(1);
if (!ignoreSourceAbility) {
applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, simulated, numTargets, new Utils.IntegerHolder(0), parentalBondMultiplier);
}
/** Doubles damage if this Pokemon's last move was Glaive Rush */
const glaiveRushMultiplier = new Utils.IntegerHolder(1);
if (this.getTag(BattlerTagType.RECEIVE_DOUBLE_DAMAGE)) {
glaiveRushMultiplier.value = 2;
}
/** The damage multiplier when the given move critically hits */
const criticalMultiplier = new Utils.NumberHolder(isCritical ? 1.5 : 1);
applyAbAttrs(MultCritAbAttr, source, null, simulated, criticalMultiplier);
/**
* A multiplier for random damage spread in the range [0.85, 1]
* This is always 1 for simulated calls.
*/
const randomMultiplier = simulated ? 1 : ((this.randSeedIntRange(85, 100)) / 100);
const sourceTypes = source.getTypes();
const sourceTeraType = source.getTeraType();
const matchesSourceType = sourceTypes.includes(moveType);
/** A damage multiplier for when the attack is of the attacker's type and/or Tera type. */
const stabMultiplier = new Utils.NumberHolder(1);
if (matchesSourceType) {
stabMultiplier.value += 0.5;
}
if (sourceTeraType !== Type.UNKNOWN && sourceTeraType === moveType) {
stabMultiplier.value += 0.5;
}
if (!ignoreSourceAbility) {
applyAbAttrs(StabBoostAbAttr, source, null, simulated, stabMultiplier);
}
stabMultiplier.value = Math.min(stabMultiplier.value, 2.25);
/** Halves damage if the attacker is using a physical attack while burned */
const burnMultiplier = new Utils.NumberHolder(1);
if (isPhysical && source.status && source.status.effect === StatusEffect.BURN) {
if (!move.hasAttr(BypassBurnDamageReductionAttr)) {
const burnDamageReductionCancelled = new Utils.BooleanHolder(false);
if (!ignoreSourceAbility) {
applyAbAttrs(BypassBurnDamageReductionAbAttr, source, burnDamageReductionCancelled, simulated);
}
if (!burnDamageReductionCancelled.value) {
burnMultiplier.value = 0.5;
}
}
}
/** Reduces damage if this Pokemon has a relevant screen (e.g. Light Screen for special attacks) */
const screenMultiplier = new Utils.NumberHolder(1);
this.scene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, move.category, this.scene.currentBattle.double, screenMultiplier);
/**
* For each {@linkcode HitsTagAttr} the move has, doubles the damage of the move if:
* The target has a {@linkcode BattlerTagType} that this move interacts with
* AND
* The move doubles damage when used against that tag
*/
const hitsTagMultiplier = new Utils.NumberHolder(1);
move.getAttrs(HitsTagAttr).filter(hta => hta.doubleDamage).forEach(hta => {
if (this.getTag(hta.tagType)) {
hitsTagMultiplier.value *= 2;
}
});
/** Halves damage if this Pokemon is grounded in Misty Terrain against a Dragon-type attack */
const mistyTerrainMultiplier = (this.scene.arena.terrain?.terrainType === TerrainType.MISTY && this.isGrounded() && moveType === Type.DRAGON)
? 0.5
: 1;
damage.value = Utils.toDmgValue(
baseDamage
* targetMultiplier
* parentalBondMultiplier.value
* arenaAttackTypeMultiplier.value
* glaiveRushMultiplier.value
* criticalMultiplier.value
* randomMultiplier
* stabMultiplier.value
* typeMultiplier
* burnMultiplier.value
* screenMultiplier.value
* hitsTagMultiplier.value
* mistyTerrainMultiplier
);
/** Doubles damage if the attacker has Tinted Lens and is using a resisted move */
if (!ignoreSourceAbility) {
applyPreAttackAbAttrs(DamageBoostAbAttr, source, this, move, simulated, damage);
}
/** Apply the enemy's Damage and Resistance tokens */
if (!source.isPlayer()) {
this.scene.applyModifiers(EnemyDamageBoosterModifier, false, damage);
}
if (!this.isPlayer()) {
this.scene.applyModifiers(EnemyDamageReducerModifier, false, damage);
}
/** Apply this Pokemon's post-calc defensive modifiers (e.g. Fur Coat) */
if (!ignoreAbility) {
applyPreDefendAbAttrs(ReceivedMoveDamageMultiplierAbAttr, this, source, move, cancelled, simulated, damage);
}
// This attribute may modify damage arbitrarily, so be careful about changing its order of application.
applyMoveAttrs(ModifiedDamageAttr, source, this, move, damage);
if (this.isFullHp() && !ignoreAbility) {
applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, false, damage);
}
// debug message for when damage is applied (i.e. not simulated)
if (!simulated) {
console.log("damage", damage.value, move.name, power, sourceAtk, targetDef);
}
let hitResult: HitResult;
if (typeMultiplier < 1) {
hitResult = HitResult.NOT_VERY_EFFECTIVE;
} else if (typeMultiplier > 1) {
hitResult = HitResult.SUPER_EFFECTIVE;
} else {
hitResult = HitResult.EFFECTIVE;
}
return {
cancelled: cancelled.value,
result: hitResult,
damage: damage.value
};
}
/**
* Applies the results of a move to this pokemon
* @param source The {@linkcode Pokemon} using the move
* @param move The {@linkcode Move} being used
* @returns The {@linkcode HitResult} of the attack
*/
apply(source: Pokemon, move: Move): HitResult {
const defendingSide = this.isPlayer() ? ArenaTagSide.PLAYER : ArenaTagSide.ENEMY;
if (move.category === MoveCategory.STATUS) {
const cancelled = new Utils.BooleanHolder(false);
const typeMultiplier = this.getMoveEffectiveness(source, move, false, false, cancelled);
if (!cancelled.value && typeMultiplier === 0) {
this.scene.queueMessage(i18next.t("battle:hitResultNoEffect", { pokemonName: getPokemonNameWithAffix(this) }));
}
return result;
}
const glaiveRushModifier = new Utils.IntegerHolder(1);
if (this.getTag(BattlerTagType.RECEIVE_DOUBLE_DAMAGE)) {
glaiveRushModifier.value = 2;
}
return (typeMultiplier === 0) ? HitResult.NO_EFFECT : HitResult.STATUS;
} else {
/** Determines whether the attack critically hits */
let isCritical: boolean;
const critOnly = new Utils.BooleanHolder(false);
const critAlways = source.getTag(BattlerTagType.ALWAYS_CRIT);
@ -2393,156 +2592,53 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
} else {
const critChance = [24, 8, 2, 1][Math.max(0, Math.min(this.getCritStage(source, move), 3))];
isCritical = critChance === 1 || !this.scene.randBattleSeedInt(critChance);
if (Overrides.NEVER_CRIT_OVERRIDE) {
isCritical = false;
}
}
if (isCritical) {
const noCritTag = this.scene.arena.getTagOnSide(NoCritTag, defendingSide);
const blockCrit = new Utils.BooleanHolder(false);
applyAbAttrs(BlockCritAbAttr, this, null, false, blockCrit);
if (noCritTag || blockCrit.value) {
if (noCritTag || blockCrit.value || Overrides.NEVER_CRIT_OVERRIDE) {
isCritical = false;
}
}
const sourceAtk = new Utils.IntegerHolder(source.getEffectiveStat(isPhysical ? Stat.ATK : Stat.SPATK, this, undefined, isCritical));
const targetDef = new Utils.IntegerHolder(this.getEffectiveStat(isPhysical ? Stat.DEF : Stat.SPDEF, source, move, isCritical));
const criticalMultiplier = new Utils.NumberHolder(isCritical ? 1.5 : 1);
applyAbAttrs(MultCritAbAttr, source, null, false, criticalMultiplier);
const screenMultiplier = new Utils.NumberHolder(1);
if (!isCritical) {
this.scene.arena.applyTagsForSide(WeakenMoveScreenTag, defendingSide, move.category, this.scene.currentBattle.double, screenMultiplier);
}
const sourceTypes = source.getTypes();
const matchesSourceType = sourceTypes[0] === moveType || (sourceTypes.length > 1 && sourceTypes[1] === moveType);
const stabMultiplier = new Utils.NumberHolder(1);
if (sourceTeraType === Type.UNKNOWN && matchesSourceType) {
stabMultiplier.value += 0.5;
} else if (sourceTeraType !== Type.UNKNOWN && sourceTeraType === moveType) {
stabMultiplier.value += 0.5;
const { cancelled, result, damage: dmg } = this.getAttackDamage(source, move, false, false, isCritical, false);
const typeBoost = source.findTag(t => t instanceof TypeBoostTag && t.boostedType === source.getMoveType(move)) as TypeBoostTag;
if (typeBoost?.oneUse) {
source.removeTag(typeBoost.tagType);
}
applyAbAttrs(StabBoostAbAttr, source, null, false, stabMultiplier);
if (cancelled || result === HitResult.IMMUNE || result === HitResult.NO_EFFECT) {
source.stopMultiHit(this);
if (sourceTeraType !== Type.UNKNOWN && matchesSourceType) {
stabMultiplier.value = Math.min(stabMultiplier.value + 0.5, 2.25);
}
// 25% damage debuff on moves hitting more than one non-fainted target (regardless of immunities)
const { targets, multiple } = getMoveTargets(source, move.id);
const targetMultiplier = (multiple && targets.length > 1) ? 0.75 : 1;
applyMoveAttrs(VariableAtkAttr, source, this, move, sourceAtk);
applyMoveAttrs(VariableDefAttr, source, this, move, targetDef);
const effectPhase = this.scene.getCurrentPhase();
let numTargets = 1;
if (effectPhase instanceof MoveEffectPhase) {
numTargets = effectPhase.getTargets().length;
}
const twoStrikeMultiplier = new Utils.NumberHolder(1);
applyPreAttackAbAttrs(AddSecondStrikeAbAttr, source, this, move, false, numTargets, new Utils.IntegerHolder(0), twoStrikeMultiplier);
if (!isTypeImmune) {
const levelMultiplier = (2 * source.level / 5 + 2);
const randomMultiplier = (this.randSeedIntRange(85, 100) / 100);
damage.value = Utils.toDmgValue((((levelMultiplier * power * sourceAtk.value / targetDef.value) / 50) + 2)
* stabMultiplier.value
* typeMultiplier
* arenaAttackTypeMultiplier.value
* screenMultiplier.value
* twoStrikeMultiplier.value
* targetMultiplier
* criticalMultiplier.value
* glaiveRushModifier.value
* randomMultiplier);
if (isPhysical && source.status && source.status.effect === StatusEffect.BURN) {
if (!move.hasAttr(BypassBurnDamageReductionAttr)) {
const burnDamageReductionCancelled = new Utils.BooleanHolder(false);
applyAbAttrs(BypassBurnDamageReductionAbAttr, source, burnDamageReductionCancelled, false);
if (!burnDamageReductionCancelled.value) {
damage.value = Utils.toDmgValue(damage.value / 2);
}
}
}
applyPreAttackAbAttrs(DamageBoostAbAttr, source, this, move, false, damage);
/**
* For each {@link HitsTagAttr} the move has, doubles the damage of the move if:
* The target has a {@link BattlerTagType} that this move interacts with
* AND
* The move doubles damage when used against that tag
*/
move.getAttrs(HitsTagAttr).filter(hta => hta.doubleDamage).forEach(hta => {
if (this.getTag(hta.tagType)) {
damage.value *= 2;
}
});
}
if (this.scene.arena.terrain?.terrainType === TerrainType.MISTY && this.isGrounded() && moveType === Type.DRAGON) {
damage.value = Utils.toDmgValue(damage.value / 2);
}
const fixedDamage = new Utils.IntegerHolder(0);
applyMoveAttrs(FixedDamageAttr, source, this, move, fixedDamage);
if (!isTypeImmune && fixedDamage.value) {
damage.value = fixedDamage.value;
isCritical = false;
result = HitResult.EFFECTIVE;
}
result = result!; // telling TS compiler that result is defined!
if (!result) {
const isOneHitKo = new Utils.BooleanHolder(false);
applyMoveAttrs(OneHitKOAttr, source, this, move, isOneHitKo);
if (isOneHitKo.value) {
result = HitResult.ONE_HIT_KO;
isCritical = false;
damage.value = this.hp;
} else if (typeMultiplier >= 2) {
result = HitResult.SUPER_EFFECTIVE;
} else if (typeMultiplier >= 1) {
result = HitResult.EFFECTIVE;
if (!cancelled) {
if (result === HitResult.IMMUNE) {
this.scene.queueMessage(i18next.t("battle:hitResultImmune", { pokemonName: getPokemonNameWithAffix(this) }));
} else {
result = HitResult.NOT_VERY_EFFECTIVE;
this.scene.queueMessage(i18next.t("battle:hitResultNoEffect"));
}
}
const isOneHitKo = result === HitResult.ONE_HIT_KO;
if (!fixedDamage.value && !isOneHitKo) {
if (!source.isPlayer()) {
this.scene.applyModifiers(EnemyDamageBoosterModifier, false, damage);
}
if (!this.isPlayer()) {
this.scene.applyModifiers(EnemyDamageReducerModifier, false, damage);
return result;
}
applyPreDefendAbAttrs(ReceivedMoveDamageMultiplierAbAttr, this, source, move, cancelled, false, damage);
if (isCritical) {
this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit"));
}
// This attribute may modify damage arbitrarily, so be careful about changing its order of application.
applyMoveAttrs(ModifiedDamageAttr, source, this, move, damage);
console.log("damage", damage.value, move.name, power, sourceAtk, targetDef);
// In case of fatal damage, this tag would have gotten cleared before we could lapse it.
const destinyTag = this.getTag(BattlerTagType.DESTINY_BOND);
if (damage.value) {
const isOneHitKo = result === HitResult.ONE_HIT_KO;
if (dmg) {
this.lapseTags(BattlerTagLapseType.HIT);
const substitute = this.getTag(SubstituteTag);
if (substitute && move.hitsSubstitute(source, this)) {
substitute.hp -= damage.value;
damage.value = 0;
const isBlockedBySubstitute = !!substitute && move.hitsSubstitute(source, this);
if (isBlockedBySubstitute) {
substitute.hp -= dmg;
}
if (this.isFullHp()) {
applyPreDefendAbAttrs(PreDefendFullHpEndureAbAttr, this, source, move, cancelled, false, damage);
} else if (!this.isPlayer() && damage.value >= this.hp) {
if (!this.isPlayer() && dmg >= this.hp) {
this.scene.applyModifiers(EnemyEndureChanceModifier, false, this);
}
@ -2550,26 +2646,21 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
* We explicitly require to ignore the faint phase here, as we want to show the messages
* about the critical hit and the super effective/not very effective messages before the faint phase.
*/
damage.value = this.damageAndUpdate(damage.value, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true);
this.turnData.damageTaken += damage.value;
const damage = this.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true);
if (isCritical) {
this.scene.queueMessage(i18next.t("battle:hitResultCriticalHit"));
}
if (damage > 0) {
if (source.isPlayer()) {
this.scene.validateAchvs(DamageAchv, damage);
if (damage.value > this.scene.gameData.gameStats.highestDamage) {
this.scene.gameData.gameStats.highestDamage = damage.value;
if (damage > this.scene.gameData.gameStats.highestDamage) {
this.scene.gameData.gameStats.highestDamage = damage;
}
}
if (damage.value > 0) {
source.turnData.damageDealt += damage.value;
source.turnData.currDamageDealt = damage.value;
source.turnData.damageDealt += damage;
source.turnData.currDamageDealt = damage;
this.turnData.damageTaken += damage;
this.battleData.hitCount++;
const attackResult = { move: move.id, result: result as DamageResult, damage: damage.value, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() };
const attackResult = { move: move.id, result: result as DamageResult, damage: damage, critical: isCritical, sourceId: source.id, sourceBattlerIndex: source.getBattlerIndex() };
this.turnData.attacksReceived.unshift(attackResult);
if (source.isPlayer() && !this.isPlayer()) {
this.scene.applyModifiers(DamageMoneyRewardModifier, true, source, damage);
}
@ -2588,10 +2679,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
case HitResult.ONE_HIT_KO:
this.scene.queueMessage(i18next.t("battle:hitResultOneHitKO"));
break;
case HitResult.IMMUNE:
case HitResult.NO_EFFECT:
console.error("Unhandled move immunity!");
break;
}
}
@ -2603,21 +2690,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
this.resetSummonData();
}
if (damage) {
if (dmg) {
destinyTag?.lapse(source, BattlerTagLapseType.CUSTOM);
}
}
break;
case MoveCategory.STATUS:
if (!cancelled.value && typeMultiplier === 0) {
this.scene.queueMessage(i18next.t("battle:hitResultNoEffect", { pokemonName: getPokemonNameWithAffix(this) }));
}
result = (cancelled.value || typeMultiplier === 0) ? HitResult.NO_EFFECT : HitResult.STATUS;
break;
}
return result;
}
}
/**
* Called by damageAndUpdate()
@ -4287,7 +4366,7 @@ export class EnemyPokemon extends Pokemon {
}
// Filter out any moves this Pokemon cannot use
const movePool = this.getMoveset().filter(m => m?.isUsable(this));
let movePool = this.getMoveset().filter(m => m?.isUsable(this));
// If no moves are left, use Struggle. Otherwise, continue with move selection
if (movePool.length) {
// If there's only 1 move in the move pool, use it.
@ -4308,6 +4387,36 @@ export class EnemyPokemon extends Pokemon {
return { move: moveId, targets: this.getNextTargets(moveId) };
case AiType.SMART_RANDOM:
case AiType.SMART:
/**
* Search this Pokemon's move pool for moves that will KO an opposing target.
* If there are any moves that can KO an opponent (i.e. a player Pokemon),
* those moves are the only ones considered for selection on this turn.
*/
const koMoves = movePool.filter(pkmnMove => {
if (!pkmnMove) {
return false;
}
const move = pkmnMove.getMove()!;
if (move.moveTarget === MoveTarget.ATTACKER) {
return false;
}
const fieldPokemon = this.scene.getField();
const moveTargets = getMoveTargets(this, move.id).targets
.map(ind => fieldPokemon[ind])
.filter(p => this.isPlayer() !== p.isPlayer());
// Only considers critical hits for crit-only moves or when this Pokemon is under the effect of Laser Focus
const isCritical = move.hasAttr(CritOnlyAttr) || !!this.getTag(BattlerTagType.ALWAYS_CRIT);
return move.category !== MoveCategory.STATUS
&& moveTargets.some(p => p.getAttackDamage(this, move, !p.battleData.abilityRevealed, false, isCritical).damage >= p.hp);
}, this);
if (koMoves.length > 0) {
movePool = koMoves;
}
/**
* Move selection is based on the move's calculated "benefit score" against the
* best possible target(s) (as determined by {@linkcode getNextTargets}).
@ -4779,6 +4888,16 @@ export enum HitResult {
export type DamageResult = HitResult.EFFECTIVE | HitResult.SUPER_EFFECTIVE | HitResult.NOT_VERY_EFFECTIVE | HitResult.ONE_HIT_KO | HitResult.OTHER;
/** Interface containing the results of a damage calculation for a given move */
export interface DamageCalculationResult {
/** `true` if the move was cancelled (thus suppressing "No Effect" messages) */
cancelled: boolean;
/** The effectiveness of the move */
result: HitResult;
/** The damage dealt by the move */
damage: number;
}
/**
* Wrapper class for the {@linkcode Move} class for Pokemon to interact with.
* These are the moves assigned to a {@linkcode Pokemon} object.

View File

@ -1,14 +1,13 @@
import { DamagePhase } from "#app/phases/damage-phase";
import { toDmgValue } from "#app/utils";
import { allMoves } from "#app/data/move";
import { Abilities } from "#enums/abilities";
import { ArenaTagType } from "#enums/arena-tag-type";
import { Moves } from "#enums/moves";
import { Species } from "#enums/species";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
describe("Round Down and Minimun 1 test in Damage Calculation", () => {
describe("Battle Mechanics - Damage Calculation", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
@ -24,24 +23,86 @@ describe("Round Down and Minimun 1 test in Damage Calculation", () => {
beforeEach(() => {
game = new GameManager(phaserGame);
game.override.battleType("single");
game.override.startingLevel(10);
game.override
.battleType("single")
.enemySpecies(Species.SNORLAX)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH)
.startingLevel(100)
.enemyLevel(100)
.disableCrits()
.moveset([Moves.TACKLE, Moves.DRAGON_RAGE, Moves.FISSURE, Moves.JUMP_KICK]);
});
it("Tackle deals expected base damage", async () => {
await game.classicMode.startBattle([Species.CHARIZARD]);
const playerPokemon = game.scene.getPlayerPokemon()!;
vi.spyOn(playerPokemon, "getEffectiveStat").mockReturnValue(80);
const enemyPokemon = game.scene.getEnemyPokemon()!;
vi.spyOn(enemyPokemon, "getEffectiveStat").mockReturnValue(90);
// expected base damage = [(2*level/5 + 2) * power * playerATK / enemyDEF / 50] + 2
// = 31.8666...
expect(enemyPokemon.getAttackDamage(playerPokemon, allMoves[Moves.TACKLE]).damage).toBeCloseTo(31);
});
it("Attacks deal 1 damage at minimum", async () => {
game.override
.startingLevel(1)
.enemySpecies(Species.AGGRON);
await game.classicMode.startBattle([Species.MAGIKARP]);
const aggron = game.scene.getEnemyPokemon()!;
game.move.select(Moves.TACKLE);
await game.phaseInterceptor.to("BerryPhase", false);
// Lvl 1 0 Atk Magikarp Tackle vs. 0 HP / 0 Def Aggron: 1-1 (0.3 - 0.3%) -- possibly the worst move ever
expect(aggron.hp).toBe(aggron.getMaxHp() - 1);
});
it("Fixed-damage moves ignore damage multipliers", async () => {
game.override
.enemySpecies(Species.DRAGONITE)
.enemyAbility(Abilities.MULTISCALE);
await game.classicMode.startBattle([Species.MAGIKARP]);
const magikarp = game.scene.getPlayerPokemon()!;
const dragonite = game.scene.getEnemyPokemon()!;
expect(dragonite.getAttackDamage(magikarp, allMoves[Moves.DRAGON_RAGE]).damage).toBe(40);
});
it("One-hit KO moves ignore damage multipliers", async () => {
game.override
.enemySpecies(Species.AGGRON)
.enemyAbility(Abilities.MULTISCALE);
await game.classicMode.startBattle([Species.MAGIKARP]);
const magikarp = game.scene.getPlayerPokemon()!;
const aggron = game.scene.getEnemyPokemon()!;
expect(aggron.getAttackDamage(magikarp, allMoves[Moves.FISSURE]).damage).toBe(aggron.hp);
});
it("When the user fails to use Jump Kick with Wonder Guard ability, the damage should be 1.", async () => {
game.override.enemySpecies(Species.GASTLY);
game.override.enemyMoveset(Moves.SPLASH);
game.override.starterSpecies(Species.SHEDINJA);
game.override.moveset([Moves.JUMP_KICK]);
game.override.ability(Abilities.WONDER_GUARD);
game.override
.enemySpecies(Species.GASTLY)
.ability(Abilities.WONDER_GUARD);
await game.startBattle();
await game.classicMode.startBattle([Species.SHEDINJA]);
const shedinja = game.scene.getPlayerPokemon()!;
game.move.select(Moves.JUMP_KICK);
await game.phaseInterceptor.to(DamagePhase);
await game.phaseInterceptor.to("DamagePhase");
expect(shedinja.hp).toBe(shedinja.getMaxHp() - 1);
});
@ -49,21 +110,19 @@ describe("Round Down and Minimun 1 test in Damage Calculation", () => {
it("Charizard with odd HP survives Stealth Rock damage twice", async () => {
game.scene.arena.addTag(ArenaTagType.STEALTH_ROCK, 1, Moves.STEALTH_ROCK, 0);
game.override.seed("Charizard Stealth Rock test");
game.override.enemySpecies(Species.CHARIZARD);
game.override.enemyAbility(Abilities.BLAZE);
game.override.starterSpecies(Species.PIKACHU);
game.override.enemyLevel(100);
game.override
.seed("Charizard Stealth Rock test")
.enemySpecies(Species.CHARIZARD)
.enemyAbility(Abilities.BLAZE);
await game.startBattle();
await game.classicMode.startBattle([Species.PIKACHU]);
const charizard = game.scene.getEnemyPokemon()!;
const maxHp = charizard.getMaxHp();
const damage_prediction = toDmgValue(charizard.getMaxHp() / 2);
const currentHp = charizard.hp;
const expectedHP = maxHp - damage_prediction;
expect(currentHp).toBe(expectedHP);
if (charizard.getMaxHp() % 2 === 1) {
expect(charizard.hp).toBeGreaterThan(charizard.getMaxHp() / 2);
} else {
expect(charizard.hp).toBe(charizard.getMaxHp() / 2);
}
});
});

View File

@ -0,0 +1,75 @@
import { allMoves, MoveCategory } from "#app/data/move";
import { Abilities } from "#app/enums/abilities";
import { Moves } from "#app/enums/moves";
import { Species } from "#app/enums/species";
import { AiType, EnemyPokemon } from "#app/field/pokemon";
import { randSeedInt } from "#app/utils";
import GameManager from "#test/utils/gameManager";
import Phaser from "phaser";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
const TIMEOUT = 20 * 1000;
const NUM_TRIALS = 300;
type MoveChoiceSet = { [key: number]: number };
function getEnemyMoveChoices(pokemon: EnemyPokemon, moveChoices: MoveChoiceSet): void {
// Use an unseeded random number generator in place of the mocked-out randBattleSeedInt
vi.spyOn(pokemon.scene, "randBattleSeedInt").mockImplementation((range, min?) => {
return randSeedInt(range, min);
});
for (let i = 0; i < NUM_TRIALS; i++) {
const queuedMove = pokemon.getNextMove();
moveChoices[queuedMove.move]++;
}
for (const [moveId, count] of Object.entries(moveChoices)) {
console.log(`Move: ${allMoves[moveId].name} Count: ${count} (${count / NUM_TRIALS * 100}%)`);
}
}
describe("Enemy Commands - Move Selection", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
game = new GameManager(phaserGame);
game.override.ability(Abilities.BALL_FETCH);
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
it(
"should never use Status moves if an attack can KO",
async () => {
game.override
.enemySpecies(Species.ETERNATUS)
.enemyMoveset([Moves.ETERNABEAM, Moves.SLUDGE_BOMB, Moves.DRAGON_DANCE, Moves.COSMIC_POWER])
.enemyAbility(Abilities.BALL_FETCH)
.ability(Abilities.BALL_FETCH)
.startingLevel(1)
.enemyLevel(100);
await game.classicMode.startBattle([Species.MAGIKARP]);
const enemyPokemon = game.scene.getEnemyPokemon()!;
enemyPokemon.aiType = AiType.SMART_RANDOM;
const moveChoices: MoveChoiceSet = {};
const enemyMoveset = enemyPokemon.getMoveset();
enemyMoveset.forEach(mv => moveChoices[mv!.moveId] = 0);
getEnemyMoveChoices(enemyPokemon, moveChoices);
enemyMoveset.forEach(mv => {
if (mv?.getMove().category === MoveCategory.STATUS) {
expect(moveChoices[mv.moveId]).toBe(0);
}
});
}, TIMEOUT
);
});