[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
This commit is contained in:
MokaStitcher 2024-09-03 00:29:15 +02:00 committed by GitHub
parent 9c30e5b213
commit 89a1ff7b5b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 268 additions and 20 deletions

View File

@ -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;
}

View File

@ -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));

View File

@ -116,6 +116,14 @@ class DefaultOverrides {
readonly OPP_VARIANT_OVERRIDE: Variant = 0;
readonly OPP_IVS_OVERRIDE: number | number[] = [];
readonly OPP_FORM_OVERRIDES: Partial<Record<Species, number>> = {};
/**
* 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

View File

@ -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();

View File

@ -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;
}
});

View File

@ -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");

View File

@ -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);
}