[Move] Improved damage forecasting for Shell Side Arm (#4310)

This commit is contained in:
innerthunder 2024-09-18 16:03:01 -07:00 committed by GitHub
parent 81ea1296b3
commit 605ae9e1c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 145 additions and 33 deletions

View File

@ -3974,18 +3974,17 @@ 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.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);
// Shell Side Arm is much more complicated than it looks, this is a partial implementation to try to achieve something similar to the games
if (atkRatio > specialRatio) {
const predictedPhysDmg = target.getBaseDamage(user, move, MoveCategory.PHYSICAL, true, true);
const predictedSpecDmg = target.getBaseDamage(user, move, MoveCategory.SPECIAL, true, true);
if (predictedPhysDmg > predictedSpecDmg) {
category.value = MoveCategory.PHYSICAL;
return true;
} else if (atkRatio === specialRatio && user.randSeedInt(2) === 0) {
} else if (predictedPhysDmg === predictedSpecDmg && user.randSeedInt(2) === 0) {
category.value = MoveCategory.PHYSICAL;
return true;
}
return false;
}
}
@ -9106,7 +9105,7 @@ export function initMoves() {
new AttackMove(Moves.SHELL_SIDE_ARM, Type.POISON, MoveCategory.SPECIAL, 90, 100, 10, 20, 0, 8)
.attr(ShellSideArmCategoryAttr)
.attr(StatusEffectAttr, StatusEffect.POISON)
.partial(),
.partial(), // Physical version of the move does not make contact
new AttackMove(Moves.MISTY_EXPLOSION, Type.FAIRY, MoveCategory.SPECIAL, 100, 100, 5, -1, 0, 8)
.attr(SacrificialAttr)
.target(MoveTarget.ALL_NEAR_OTHERS)

View File

@ -2322,11 +2322,61 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
return accuracyMultiplier.value / evasionMultiplier.value;
}
/**
* Calculates the base damage of the given move against this Pokemon when attacked by the given source.
* Used during damage calculation and for Shell Side Arm's forecasting effect.
* @param source the attacking {@linkcode Pokemon}.
* @param move the {@linkcode Move} used in the attack.
* @param moveCategory the move's {@linkcode MoveCategory} after variable-category effects are applied.
* @param ignoreAbility if `true`, ignores this Pokemon's defensive ability effects (defaults to `false`).
* @param ignoreSourceAbility if `true`, ignore's the attacking Pokemon's ability effects (defaults to `false`).
* @param isCritical if `true`, calculates effective stats as if the hit were critical (defaults to `false`).
* @param simulated if `true`, suppresses changes to game state during calculation (defaults to `true`).
* @returns The move's base damage against this Pokemon when used by the source Pokemon.
*/
getBaseDamage(source: Pokemon, move: Move, moveCategory: MoveCategory, ignoreAbility: boolean = false, ignoreSourceAbility: boolean = false, isCritical: boolean = false, simulated: boolean = true): number {
const isPhysical = moveCategory === MoveCategory.PHYSICAL;
/** 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 cause negative stat stages to be ignored.
*/
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 cause positive stat stages to be ignored.
*/
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;
/** Debug message for non-simulated calls (i.e. when damage is actually dealt) */
if (!simulated) {
console.log("base damage", baseDamage, move.name, power, sourceAtk.value, targetDef.value);
}
return baseDamage;
}
/**
* 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 ignoreSourceAbility If `true`, ignores the attacking Pokemon's 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:
@ -2395,35 +2445,11 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
};
}
// ----- 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 ------
const baseDamage = this.getBaseDamage(source, move, moveCategory, ignoreAbility, ignoreSourceAbility, isCritical, simulated);
/** 25% damage debuff on moves hitting more than one non-fainted target (regardless of immunities) */
const { targets, multiple } = getMoveTargets(source, move.id);
@ -2549,7 +2575,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container {
// debug message for when damage is applied (i.e. not simulated)
if (!simulated) {
console.log("damage", damage.value, move.name, power, sourceAtk, targetDef);
console.log("damage", damage.value, move.name);
}
let hitResult: HitResult;

View File

@ -0,0 +1,87 @@
import { BattlerIndex } from "#app/battle";
import { allMoves, ShellSideArmCategoryAttr } from "#app/data/move";
import { Abilities } from "#enums/abilities";
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, it, expect, vi } from "vitest";
describe("Moves - Shell Side Arm", () => {
let phaserGame: Phaser.Game;
let game: GameManager;
const TIMEOUT = 20 * 1000;
beforeAll(() => {
phaserGame = new Phaser.Game({
type: Phaser.HEADLESS,
});
});
afterEach(() => {
game.phaseInterceptor.restoreOg();
});
beforeEach(() => {
game = new GameManager(phaserGame);
game.override
.moveset([Moves.SHELL_SIDE_ARM])
.battleType("single")
.startingLevel(100)
.enemyLevel(100)
.enemyAbility(Abilities.BALL_FETCH)
.enemyMoveset(Moves.SPLASH);
});
it("becomes a physical attack if forecasted to deal more damage as physical", async () => {
game.override.enemySpecies(Species.SNORLAX);
await game.classicMode.startBattle([Species.MANAPHY]);
const shellSideArm = allMoves[Moves.SHELL_SIDE_ARM];
const shellSideArmAttr = shellSideArm.getAttrs(ShellSideArmCategoryAttr)[0];
vi.spyOn(shellSideArmAttr, "apply");
game.move.select(Moves.SHELL_SIDE_ARM);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(shellSideArmAttr.apply).toHaveLastReturnedWith(true);
}, TIMEOUT);
it("remains a special attack if forecasted to deal more damage as special", async () => {
game.override.enemySpecies(Species.SLOWBRO);
await game.classicMode.startBattle([Species.MANAPHY]);
const shellSideArm = allMoves[Moves.SHELL_SIDE_ARM];
const shellSideArmAttr = shellSideArm.getAttrs(ShellSideArmCategoryAttr)[0];
vi.spyOn(shellSideArmAttr, "apply");
game.move.select(Moves.SHELL_SIDE_ARM);
await game.phaseInterceptor.to("MoveEffectPhase");
expect(shellSideArmAttr.apply).toHaveLastReturnedWith(false);
}, TIMEOUT);
it("respects stat stage changes when forecasting base damage", async () => {
game.override
.enemySpecies(Species.SNORLAX)
.enemyMoveset(Moves.COTTON_GUARD);
await game.classicMode.startBattle([Species.MANAPHY]);
const shellSideArm = allMoves[Moves.SHELL_SIDE_ARM];
const shellSideArmAttr = shellSideArm.getAttrs(ShellSideArmCategoryAttr)[0];
vi.spyOn(shellSideArmAttr, "apply");
game.move.select(Moves.SHELL_SIDE_ARM);
await game.setTurnOrder([BattlerIndex.ENEMY, BattlerIndex.PLAYER]);
await game.phaseInterceptor.to("BerryPhase", false);
expect(shellSideArmAttr.apply).toHaveLastReturnedWith(false);
}, TIMEOUT);
});