From 89a1ff7b5b73202ab9e75d23b7bfb7c154af2315 Mon Sep 17 00:00:00 2001 From: MokaStitcher <54149968+MokaStitcher@users.noreply.github.com> Date: Tue, 3 Sep 2024 00:29:15 +0200 Subject: [PATCH] [Bug] Fix Inconsistency with stat boost when breaking through boss shields + tests (#3785) * fix boss shield stats up calculation and add tests * update test to remove usage of deprecated startBattle --- src/battle-scene.ts | 16 +- src/field/pokemon.ts | 20 +- src/overrides.ts | 8 + src/phases/encounter-phase.ts | 8 +- src/test/boss-pokemon.test.ts | 220 ++++++++++++++++++++++ src/test/moves/fusion_flare.test.ts | 2 +- src/test/utils/helpers/overridesHelper.ts | 14 ++ 7 files changed, 268 insertions(+), 20 deletions(-) create mode 100644 src/test/boss-pokemon.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index e761d8fca39..70f214f57c6 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -841,12 +841,13 @@ export default class BattleScene extends SceneBase { } addEnemyPokemon(species: PokemonSpecies, level: integer, trainerSlot: TrainerSlot, boss: boolean = false, dataSource?: PokemonData, postProcess?: (enemyPokemon: EnemyPokemon) => void): EnemyPokemon { + if (Overrides.OPP_LEVEL_OVERRIDE > 0) { + level = Overrides.OPP_LEVEL_OVERRIDE; + } if (Overrides.OPP_SPECIES_OVERRIDE) { species = getPokemonSpecies(Overrides.OPP_SPECIES_OVERRIDE); - } - - if (Overrides.OPP_LEVEL_OVERRIDE !== 0) { - level = Overrides.OPP_LEVEL_OVERRIDE; + // The fact that a Pokemon is a boss or not can change based on its Species and level + boss = this.getEncounterBossSegments(this.currentBattle.waveIndex, level, species) > 1; } const pokemon = new EnemyPokemon(this, species, level, trainerSlot, boss, dataSource); @@ -1327,6 +1328,13 @@ export default class BattleScene extends SceneBase { } getEncounterBossSegments(waveIndex: integer, level: integer, species?: PokemonSpecies, forceBoss: boolean = false): integer { + if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1) { + return Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE; + } else if (Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE === 1) { + // The rest of the code expects to be returned 0 and not 1 if the enemy is not a boss + return 0; + } + if (this.gameMode.isDaily && this.gameMode.isWaveFinal(waveIndex)) { return 5; } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 8594d5b769b..cce613f1ec4 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -4176,7 +4176,7 @@ export class EnemyPokemon extends Pokemon { //console.log('damage', damage, 'segment', segmentsBypassed + 1, 'segment size', segmentSize, 'damage needed', Math.round(segmentSize * Math.pow(2, segmentsBypassed + 1))); } - damage = hpRemainder + Math.round(segmentSize * segmentsBypassed); + damage = Utils.toDmgValue(this.hp - hpThreshold + segmentSize * segmentsBypassed); clearedBossSegmentIndex = s - segmentsBypassed; } break; @@ -4241,17 +4241,13 @@ export class EnemyPokemon extends Pokemon { let statLevels = 1; - switch (segmentIndex) { - case 1: - if (this.bossSegments >= 3) { - statLevels++; - } - break; - case 2: - if (this.bossSegments >= 5) { - statLevels++; - } - break; + // increase the boost if the boss has at least 3 segments and we passed last shield + if (this.bossSegments >= 3 && this.bossSegmentIndex === 1) { + statLevels++; + } + // increase the boost if the boss has at least 5 segments and we passed the second to last shield + if (this.bossSegments >= 5 && this.bossSegmentIndex === 2) { + statLevels++; } this.scene.unshiftPhase(new StatChangePhase(this.scene, this.getBattlerIndex(), true, [ boostedStat ], statLevels, true, true)); diff --git a/src/overrides.ts b/src/overrides.ts index 32ff116f41d..48c118b55bc 100644 --- a/src/overrides.ts +++ b/src/overrides.ts @@ -116,6 +116,14 @@ class DefaultOverrides { readonly OPP_VARIANT_OVERRIDE: Variant = 0; readonly OPP_IVS_OVERRIDE: number | number[] = []; readonly OPP_FORM_OVERRIDES: Partial> = {}; + /** + * Override to give the enemy Pokemon a given amount of health segments + * + * 0 (default): the health segments will be handled normally based on wave, level and species + * 1: the Pokemon will have a single health segment and therefore will not be a boss + * 2+: the Pokemon will be a boss with the given number of health segments + */ + readonly OPP_HEALTH_SEGMENTS_OVERRIDE: number = 0; // ------------- // EGG OVERRIDES diff --git a/src/phases/encounter-phase.ts b/src/phases/encounter-phase.ts index 07bfd72a8bf..6e0658d4ccb 100644 --- a/src/phases/encounter-phase.ts +++ b/src/phases/encounter-phase.ts @@ -26,6 +26,7 @@ import { ScanIvsPhase } from "./scan-ivs-phase"; import { ShinySparklePhase } from "./shiny-sparkle-phase"; import { SummonPhase } from "./summon-phase"; import { ToggleDoublePositionPhase } from "./toggle-double-position-phase"; +import Overrides from "#app/overrides"; export class EncounterPhase extends BattlePhase { private loaded: boolean; @@ -112,10 +113,11 @@ export class EncounterPhase extends BattlePhase { if (battle.battleType === BattleType.TRAINER) { loadEnemyAssets.push(battle.trainer?.loadAssets().then(() => battle.trainer?.initSprite())!); // TODO: is this bang correct? } else { - // This block only applies for double battles to init the boss segments (idk why it's split up like this) - if (battle.enemyParty.filter(p => p.isBoss()).length > 1) { + const overridedBossSegments = Overrides.OPP_HEALTH_SEGMENTS_OVERRIDE > 1; + // for double battles, reduce the health segments for boss Pokemon unless there is an override + if (!overridedBossSegments && battle.enemyParty.filter(p => p.isBoss()).length > 1) { for (const enemyPokemon of battle.enemyParty) { - // If the enemy pokemon is a boss and wasn't populated from data source, then set it up + // If the enemy pokemon is a boss and wasn't populated from data source, then update the number of segments if (enemyPokemon.isBoss() && !enemyPokemon.isPopulatedFromDataSource) { enemyPokemon.setBoss(true, Math.ceil(enemyPokemon.bossSegments * (enemyPokemon.getSpeciesForm().baseTotal / totalBst))); enemyPokemon.initBattleInfo(); diff --git a/src/test/boss-pokemon.test.ts b/src/test/boss-pokemon.test.ts new file mode 100644 index 00000000000..3e6701c7e4f --- /dev/null +++ b/src/test/boss-pokemon.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import GameManager from "./utils/gameManager"; +import { Species } from "#app/enums/species"; +import { getPokemonSpecies } from "#app/data/pokemon-species"; +import { SPLASH_ONLY } from "./utils/testUtils"; +import { Abilities } from "#app/enums/abilities"; +import { Moves } from "#app/enums/moves"; +import { BattleStat } from "#app/data/battle-stat"; +import { EnemyPokemon } from "#app/field/pokemon"; +import { toDmgValue } from "#app/utils"; + +describe("Boss Pokemon / Shields", () => { + const TIMEOUT = 2500; + + let phaserGame: Phaser.Game; + let game: GameManager; + + beforeAll(() => { + phaserGame = new Phaser.Game({ + type: Phaser.HEADLESS, + }); + }); + + afterEach(() => { + game.phaseInterceptor.restoreOg(); + }); + + beforeEach(() => { + game = new GameManager(phaserGame); + + game.override + .battleType("single") + .disableTrainerWaves() + .disableCrits() + .enemySpecies(Species.RATTATA) + .enemyMoveset(SPLASH_ONLY) + .enemyHeldItems([]) + .startingLevel(1000) + .moveset([Moves.FALSE_SWIPE, Moves.SUPER_FANG, Moves.SPLASH]) + .ability(Abilities.NO_GUARD); + }); + + it("Pokemon should get shields based on their Species and level and the current wave", async () => { + let level = 50; + let wave = 5; + + // On normal waves, no shields... + expect(game.scene.getEncounterBossSegments(wave, level, getPokemonSpecies(Species.RATTATA))).toBe(0); + // ... expect (sub)-legendary and mythical Pokemon who always get shields + expect(game.scene.getEncounterBossSegments(wave, level, getPokemonSpecies(Species.MEW))).toBe(2); + // Pokemon with 670+ BST get an extra shield + expect(game.scene.getEncounterBossSegments(wave, level, getPokemonSpecies(Species.MEWTWO))).toBe(3); + + // Every 10 waves will always be a boss Pokemon with shield(s) + wave = 50; + expect(game.scene.getEncounterBossSegments(wave, level, getPokemonSpecies(Species.RATTATA))).toBe(2); + // Every extra 250 waves adds a shield + wave += 250; + expect(game.scene.getEncounterBossSegments(wave, level, getPokemonSpecies(Species.RATTATA))).toBe(3); + wave += 750; + expect(game.scene.getEncounterBossSegments(wave, level, getPokemonSpecies(Species.RATTATA))).toBe(6); + + // Pokemon above level 100 get an extra shield + level = 100; + expect(game.scene.getEncounterBossSegments(wave, level, getPokemonSpecies(Species.RATTATA))).toBe(7); + }, TIMEOUT); + + it("should reduce the number of shields if we are in a double battle", async () => { + game.override + .battleType("double") + .startingWave(150); // Floor 150 > 2 shields / 3 health segments + + await game.classicMode.startBattle([ Species.MEWTWO ]); + + const boss1: EnemyPokemon = game.scene.getEnemyParty()[0]!; + const boss2: EnemyPokemon = game.scene.getEnemyParty()[1]!; + expect(boss1.isBoss()).toBe(true); + expect(boss1.bossSegments).toBe(2); + expect(boss2.isBoss()).toBe(true); + expect(boss2.bossSegments).toBe(2); + }, TIMEOUT); + + it("shields should stop overflow damage and give stat boosts when broken", async () => { + game.override.startingWave(150); // Floor 150 > 2 shields / 3 health segments + + await game.classicMode.startBattle([ Species.MEWTWO ]); + + const enemyPokemon = game.scene.getEnemyPokemon()!; + const segmentHp = enemyPokemon.getMaxHp() / enemyPokemon.bossSegments; + expect(enemyPokemon.isBoss()).toBe(true); + expect(enemyPokemon.bossSegments).toBe(3); + expect(getTotalStatBoosts(enemyPokemon)).toBe(0); + + game.move.select(Moves.SUPER_FANG); // Enough to break the first shield + await game.toNextTurn(); + + // Broke 1st of 2 shields, health at 2/3rd + expect(enemyPokemon.bossSegmentIndex).toBe(1); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - toDmgValue(segmentHp)); + // Breaking the shield gives a +1 boost to ATK, DEF, SP ATK, SP DEF or SPD + expect(getTotalStatBoosts(enemyPokemon)).toBe(1); + + game.move.select(Moves.FALSE_SWIPE); // Enough to break last shield but not kill + await game.toNextTurn(); + + expect(enemyPokemon.bossSegmentIndex).toBe(0); + expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - toDmgValue(2 * segmentHp)); + // Breaking the last shield gives a +2 boost to ATK, DEF, SP ATK, SP DEF or SPD + expect(getTotalStatBoosts(enemyPokemon)).toBe(3); + + }, TIMEOUT); + + it("breaking multiple shields at once requires extra damage", async () => { + game.override + .battleType("double") + .enemyHealthSegments(5); + + await game.classicMode.startBattle([ Species.MEWTWO ]); + + // In this test we want to break through 3 shields at once + const brokenShields = 3; + + const boss1: EnemyPokemon = game.scene.getEnemyParty()[0]!; + const boss1SegmentHp = boss1.getMaxHp() / boss1.bossSegments; + const requiredDamageBoss1 = boss1SegmentHp * (1 + Math.pow(2, brokenShields)); + expect(boss1.isBoss()).toBe(true); + expect(boss1.bossSegments).toBe(5); + expect(boss1.bossSegmentIndex).toBe(4); + + // Not enough damage to break through all shields + boss1.damageAndUpdate(Math.floor(requiredDamageBoss1 - 5)); + expect(boss1.bossSegmentIndex).toBe(1); + expect(boss1.hp).toBe(boss1.getMaxHp() - toDmgValue(boss1SegmentHp * 3)); + + const boss2: EnemyPokemon = game.scene.getEnemyParty()[1]!; + const boss2SegmentHp = boss2.getMaxHp() / boss2.bossSegments; + const requiredDamageBoss2 = boss2SegmentHp * (1 + Math.pow(2, brokenShields)); + + expect(boss2.isBoss()).toBe(true); + expect(boss2.bossSegments).toBe(5); + + // Enough damage to break through all shields + boss2.damageAndUpdate(Math.ceil(requiredDamageBoss2)); + expect(boss2.bossSegmentIndex).toBe(0); + expect(boss2.hp).toBe(boss2.getMaxHp() - toDmgValue(boss2SegmentHp * 4)); + + }, TIMEOUT); + + it("the number of stats boosts is consistent when several shields are broken at once", async () => { + const shieldsToBreak = 4; + + game.override + .battleType("double") + .enemyHealthSegments(shieldsToBreak + 1); + + await game.classicMode.startBattle([ Species.MEWTWO ]); + + const boss1: EnemyPokemon = game.scene.getEnemyParty()[0]!; + const boss1SegmentHp = boss1.getMaxHp() / boss1.bossSegments; + const singleShieldDamage = Math.ceil(boss1SegmentHp); + expect(boss1.isBoss()).toBe(true); + expect(boss1.bossSegments).toBe(shieldsToBreak + 1); + expect(boss1.bossSegmentIndex).toBe(shieldsToBreak); + expect(getTotalStatBoosts(boss1)).toBe(0); + + + let totalStats = 0; + + // Break the shields one by one + for (let i = 1; i <= shieldsToBreak; i++) { + boss1.damageAndUpdate(singleShieldDamage); + expect(boss1.bossSegmentIndex).toBe(shieldsToBreak - i); + expect(boss1.hp).toBe(boss1.getMaxHp() - toDmgValue(boss1SegmentHp * i)); + // Do nothing and go to next turn so that the StatChangePhase gets applied + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + // All broken shields give +1 stat boost, except the last two that gives +2 + totalStats += i >= shieldsToBreak -1? 2 : 1; + expect(getTotalStatBoosts(boss1)).toBe(totalStats); + } + + const boss2: EnemyPokemon = game.scene.getEnemyParty()[1]!; + const boss2SegmentHp = boss2.getMaxHp() / boss2.bossSegments; + const requiredDamage = boss2SegmentHp * (1 + Math.pow(2, shieldsToBreak - 1)); + + expect(boss2.isBoss()).toBe(true); + expect(boss2.bossSegments).toBe(shieldsToBreak + 1); + expect(boss2.bossSegmentIndex).toBe(shieldsToBreak); + expect(getTotalStatBoosts(boss2)).toBe(0); + + // Enough damage to break all shields at once + boss2.damageAndUpdate(Math.ceil(requiredDamage)); + expect(boss2.bossSegmentIndex).toBe(0); + expect(boss2.hp).toBe(boss2.getMaxHp() - toDmgValue(boss2SegmentHp * shieldsToBreak)); + // Do nothing and go to next turn so that the StatChangePhase gets applied + game.move.select(Moves.SPLASH); + await game.toNextTurn(); + expect(getTotalStatBoosts(boss2)).toBe(totalStats); + + }, TIMEOUT); + + /** + * Gets the sum of the ATK, DEF, SP ATK, SP DEF and SPD boosts for the given Pokemon + * @param enemyPokemon the pokemon to get stats from + * @returns the total stats boosts + */ + function getTotalStatBoosts(enemyPokemon: EnemyPokemon): number { + const enemyBattleStats = enemyPokemon.summonData.battleStats; + return enemyBattleStats?.reduce(statsSum, 0); + } + + function statsSum(total: number, value: number, index: number) { + if (index <= BattleStat.SPD) { + return total + value; + } + return total; + } + +}); + diff --git a/src/test/moves/fusion_flare.test.ts b/src/test/moves/fusion_flare.test.ts index 471f6a2ac7b..0a8f6f9115d 100644 --- a/src/test/moves/fusion_flare.test.ts +++ b/src/test/moves/fusion_flare.test.ts @@ -27,7 +27,7 @@ describe("Moves - Fusion Flare", () => { game.override.moveset([fusionFlare]); game.override.startingLevel(1); - game.override.enemySpecies(Species.RESHIRAM); + game.override.enemySpecies(Species.RATTATA); game.override.enemyMoveset([Moves.REST, Moves.REST, Moves.REST, Moves.REST]); game.override.battleType("single"); diff --git a/src/test/utils/helpers/overridesHelper.ts b/src/test/utils/helpers/overridesHelper.ts index d5eaee003db..6451155cf17 100644 --- a/src/test/utils/helpers/overridesHelper.ts +++ b/src/test/utils/helpers/overridesHelper.ts @@ -281,6 +281,20 @@ export class OverridesHelper extends GameManagerHelper { return this; } + /** + * Override the enemy (Pokemon) to have the given amount of health segments + * @param healthSegments the number of segments to give + * default: 0, the health segments will be handled like in the game based on wave, level and species + * 1: the Pokemon will not be a boss + * 2+: the Pokemon will be a boss with the given number of health segments + * @returns this + */ + enemyHealthSegments(healthSegments: number) { + vi.spyOn(Overrides, "OPP_HEALTH_SEGMENTS_OVERRIDE", "get").mockReturnValue(healthSegments); + this.log("Enemy Pokemon health segments set to:", healthSegments); + return this; + } + private log(...params: any[]) { console.log("Overrides:", ...params); }