From b0d29eb232c5887669d8a687053535693b65de60 Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Sat, 25 Jan 2025 12:33:09 -0500 Subject: [PATCH 1/9] Create new turnData field for tracking damageResults, check for HitResult in Reviver Seed modifier --- src/field/pokemon.ts | 8 ++++++++ src/modifier/modifier.ts | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 731d5c8fbe7..31ab2c44e1e 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3060,6 +3060,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * @returns integer of damage done */ damageAndUpdate(damage: number, result?: DamageResult, critical: boolean = false, ignoreSegments: boolean = false, preventEndure: boolean = false, ignoreFaintPhase: boolean = false, source?: Pokemon): number { + // When damage is done from any source (Move or Indirect damage, e.g. weather), store latest occurrence in damageSources[0] + if (result !== undefined) { + this.turnData.damageSources.unshift(result); + } const damagePhase = new DamageAnimPhase(this.getBattlerIndex(), damage, result as DamageResult, critical); globalScene.unshiftPhase(damagePhase); if (this.switchOutStatus && source) { @@ -5365,6 +5369,10 @@ export class PokemonTurnData { * forced to act again in the same turn */ public extraTurns: number = 0; + /** + * Used to track damage sources from HitResult.OTHER + */ + public damageSources: DamageResult[] = []; } export enum AiType { diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index c51fa129efe..975f9d07d05 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -5,7 +5,7 @@ import { allMoves } from "#app/data/move"; import { MAX_PER_TYPE_POKEBALLS } from "#app/data/pokeball"; import { type FormChangeItem, SpeciesFormChangeItemTrigger, SpeciesFormChangeLapseTeraTrigger, SpeciesFormChangeTeraTrigger } from "#app/data/pokemon-forms"; import { getStatusEffectHealText } from "#app/data/status-effect"; -import Pokemon, { type PlayerPokemon } from "#app/field/pokemon"; +import Pokemon, { HitResult, type PlayerPokemon } from "#app/field/pokemon"; import { getPokemonNameWithAffix } from "#app/messages"; import Overrides from "#app/overrides"; import { EvolutionPhase } from "#app/phases/evolution-phase"; @@ -1927,6 +1927,10 @@ export class PokemonInstantReviveModifier extends PokemonHeldItemModifier { * @returns always `true` */ override apply(pokemon: Pokemon): boolean { + // Do not revive if damage is indirect + if (pokemon.turnData?.damageSources?.at(0) === HitResult.OTHER) { + return false; + } // Restore the Pokemon to half HP globalScene.unshiftPhase(new PokemonHealPhase(pokemon.getBattlerIndex(), toDmgValue(pokemon.getMaxHp() / 2), i18next.t("modifier:pokemonInstantReviveApply", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), typeName: this.type.name }), false, false, true)); From 3027cd863024780beabd104c38db9fa5cb775758 Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Sat, 25 Jan 2025 13:01:10 -0500 Subject: [PATCH 2/9] Optional chaining for cases like stealth rock --- src/field/pokemon.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 31ab2c44e1e..d778944ce06 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3062,7 +3062,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { damageAndUpdate(damage: number, result?: DamageResult, critical: boolean = false, ignoreSegments: boolean = false, preventEndure: boolean = false, ignoreFaintPhase: boolean = false, source?: Pokemon): number { // When damage is done from any source (Move or Indirect damage, e.g. weather), store latest occurrence in damageSources[0] if (result !== undefined) { - this.turnData.damageSources.unshift(result); + this.turnData?.damageSources?.unshift(result); } const damagePhase = new DamageAnimPhase(this.getBattlerIndex(), damage, result as DamageResult, critical); globalScene.unshiftPhase(damagePhase); From 21add68ed745347c4fe0a15d36957a0e2118be3f Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Sat, 25 Jan 2025 19:54:20 -0500 Subject: [PATCH 3/9] Adds HitResult.SELF for confusion to distinguish from indirect damage --- src/data/battler-tags.ts | 2 +- src/field/pokemon.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 4c68de5abc5..3e9a86b4640 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -665,7 +665,7 @@ export class ConfusedTag extends BattlerTag { const def = pokemon.getEffectiveStat(Stat.DEF); const damage = toDmgValue(((((2 * pokemon.level / 5 + 2) * 40 * atk / def) / 50) + 2) * (pokemon.randSeedIntRange(85, 100) / 100)); globalScene.queueMessage(i18next.t("battlerTags:confusedLapseHurtItself")); - pokemon.damageAndUpdate(damage); + pokemon.damageAndUpdate(damage, HitResult.SELF); pokemon.battleData.hitCount++; (globalScene.getCurrentPhase() as MovePhase).cancel(); } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d778944ce06..d211ffbe04e 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5400,10 +5400,11 @@ export enum HitResult { FAIL, MISS, OTHER, - IMMUNE + IMMUNE, + SELF } -export type DamageResult = HitResult.EFFECTIVE | HitResult.SUPER_EFFECTIVE | HitResult.NOT_VERY_EFFECTIVE | HitResult.ONE_HIT_KO | HitResult.OTHER; +export type DamageResult = HitResult.EFFECTIVE | HitResult.SUPER_EFFECTIVE | HitResult.NOT_VERY_EFFECTIVE | HitResult.ONE_HIT_KO | HitResult.SELF | HitResult.OTHER; /** Interface containing the results of a damage calculation for a given move */ export interface DamageCalculationResult { From ae4e94254fa1e2b3c5ee19deedb90315b1389154 Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Sat, 25 Jan 2025 20:59:37 -0500 Subject: [PATCH 4/9] Adds HitResult.SELF to damage sound effect switch --- src/phases/damage-anim-phase.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/phases/damage-anim-phase.ts b/src/phases/damage-anim-phase.ts index 2983d6b2de0..0479573e11a 100644 --- a/src/phases/damage-anim-phase.ts +++ b/src/phases/damage-anim-phase.ts @@ -42,6 +42,7 @@ export class DamageAnimPhase extends PokemonPhase { applyDamage() { switch (this.damageResult) { case HitResult.EFFECTIVE: + case HitResult.SELF: globalScene.playSound("se/hit"); break; case HitResult.SUPER_EFFECTIVE: From cce05a12449bd4d5e437481757a8bc03cf29be52 Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Sun, 26 Jan 2025 12:20:31 -0500 Subject: [PATCH 5/9] Cover edge case of salt cure, insert HitResult for ALL damage regardless of optional variable --- src/field/pokemon.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index d211ffbe04e..08c699c1fbd 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3063,6 +3063,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { // When damage is done from any source (Move or Indirect damage, e.g. weather), store latest occurrence in damageSources[0] if (result !== undefined) { this.turnData?.damageSources?.unshift(result); + } else { + this.turnData?.damageSources?.unshift(HitResult.OTHER); } const damagePhase = new DamageAnimPhase(this.getBattlerIndex(), damage, result as DamageResult, critical); globalScene.unshiftPhase(damagePhase); From f6dac8bade06db00bfb49b0183d75a6b679c923d Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Sun, 26 Jan 2025 13:00:29 -0500 Subject: [PATCH 6/9] Change Liquid Ooze HitResult to OTHER from HEAL --- src/phases/pokemon-heal-phase.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/phases/pokemon-heal-phase.ts b/src/phases/pokemon-heal-phase.ts index 268794ce97c..b7a43dde7a0 100644 --- a/src/phases/pokemon-heal-phase.ts +++ b/src/phases/pokemon-heal-phase.ts @@ -68,7 +68,7 @@ export class PokemonHealPhase extends CommonAnimPhase { } const healAmount = new Utils.NumberHolder(Math.floor(this.hpHealed * hpRestoreMultiplier.value)); if (healAmount.value < 0) { - pokemon.damageAndUpdate(healAmount.value * -1, HitResult.HEAL as DamageResult); + pokemon.damageAndUpdate(healAmount.value * -1, HitResult.OTHER as DamageResult); healAmount.value = 0; } // Prevent healing to full if specified (in case of healing tokens so Sturdy doesn't cause a softlock) From c3a918f6b969e3b280771c502ecc3ec3c4330e77 Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Sun, 26 Jan 2025 20:36:55 -0500 Subject: [PATCH 7/9] Adjust OHKO moves to not bypass endure or RSeed --- src/field/pokemon.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 08c699c1fbd..90e2a1eaa17 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -2945,7 +2945,7 @@ 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. */ - const damage = this.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg, result as DamageResult, isCritical, isOneHitKo, isOneHitKo, true, source); + const damage = this.damageAndUpdate(isBlockedBySubstitute ? 0 : dmg, result as DamageResult, isCritical, isOneHitKo, false, true, source); if (damage > 0) { if (source.isPlayer()) { @@ -2989,7 +2989,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { if (this.isFainted()) { // set splice index here, so future scene queues happen before FaintedPhase globalScene.setPhaseQueueSplice(); - globalScene.unshiftPhase(new FaintPhase(this.getBattlerIndex(), isOneHitKo, destinyTag, grudgeTag, source)); + globalScene.unshiftPhase(new FaintPhase(this.getBattlerIndex(), false, destinyTag, grudgeTag, source)); this.destroySubstitute(); this.lapseTag(BattlerTagType.COMMANDED); From 73538df5d07d1729c3f2e5dd55407767f21c5d50 Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Sun, 26 Jan 2025 22:25:48 -0500 Subject: [PATCH 8/9] Add tests for reviver seed --- src/test/items/reviver_seed.test.ts | 117 ++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/test/items/reviver_seed.test.ts diff --git a/src/test/items/reviver_seed.test.ts b/src/test/items/reviver_seed.test.ts new file mode 100644 index 00000000000..da32799132e --- /dev/null +++ b/src/test/items/reviver_seed.test.ts @@ -0,0 +1,117 @@ +import { allMoves } from "#app/data/move"; +import { BattlerTagType } from "#app/enums/battler-tag-type"; +import type { PokemonInstantReviveModifier } from "#app/modifier/modifier"; +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, expect, it, vi } from "vitest"; + +describe("Items - Reviver Seed", () => { + 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 + .moveset([ Moves.SPLASH, Moves.TACKLE, Moves.ENDURE ]) + .ability(Abilities.BALL_FETCH) + .battleType("single") + .disableCrits() + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .startingHeldItems([{ name: "REVIVER_SEED" }]) + .enemyHeldItems([{ name: "REVIVER_SEED" }]) + .enemyMoveset(Moves.SPLASH); + vi.spyOn(allMoves[Moves.SHEER_COLD], "accuracy", "get").mockReturnValue(100); + vi.spyOn(allMoves[Moves.LEECH_SEED], "accuracy", "get").mockReturnValue(100); + vi.spyOn(allMoves[Moves.WHIRLPOOL], "accuracy", "get").mockReturnValue(100); + vi.spyOn(allMoves[Moves.WILL_O_WISP], "accuracy", "get").mockReturnValue(100); + }); + + it.each([ + { moveType: "Special Move", move: Moves.WATER_GUN }, + { moveType: "Physical Move", move: Moves.TACKLE }, + { moveType: "Fixed Damage Move", move: Moves.SEISMIC_TOSS }, + { moveType: "Final Gambit", move: Moves.FINAL_GAMBIT }, + { moveType: "Counter", move: Moves.COUNTER }, + { moveType: "OHKO", move: Moves.SHEER_COLD } + ])("should activate the holder's reviver seed from a $moveType", async ({ move }) => { + game.override + .enemyLevel(100) + .startingLevel(1) + .enemyMoveset(move); + await game.classicMode.startBattle([ Species.MAGIKARP, Species.FEEBAS ]); + const player = game.scene.getPlayerPokemon()!; + player.damageAndUpdate(player.hp - 1); + + const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; + vi.spyOn(reviverSeed, "apply"); + + game.move.select(Moves.TACKLE); + await game.phaseInterceptor.to("BerryPhase"); + + expect(reviverSeed.apply).toHaveReturnedWith(true); // Reviver Seed triggers + expect(player.isFainted()).toBeFalsy(); + }); + + it("should activate the holder's reviver seed from confusion self-hit", async () => { + game.override + .enemyLevel(1) + .startingLevel(100) + .enemyMoveset(Moves.SPLASH); + await game.classicMode.startBattle([ Species.MAGIKARP, Species.FEEBAS ]); + const player = game.scene.getPlayerPokemon()!; + player.damageAndUpdate(player.hp - 1); + player.addTag(BattlerTagType.CONFUSED, 3); + + const reviverSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; + vi.spyOn(reviverSeed, "apply"); + + vi.spyOn(player, "randSeedInt").mockReturnValue(0); // Force confusion self-hit + game.move.select(Moves.TACKLE); + await game.phaseInterceptor.to("BerryPhase"); + + expect(reviverSeed.apply).toHaveReturnedWith(true); // Reviver Seed triggers + expect(player.isFainted()).toBeFalsy(); + }); + + + //Need to fix some of tests, something wrong with the enemy fainting, wrong phase being chosen.. not sure + it.each([ + //{ moveType: "Damaging Move Chip Damage", move: Moves.SALT_CURE }, + //{ moveType: "Chip Damage", move: Moves.LEECH_SEED }, + //{ moveType: "Trapping Chip Damage", move: Moves.WHIRLPOOL }, + { moveType: "Status Effect Damage", move: Moves.WILL_O_WISP }, + { moveType: "Weather", move: Moves.SANDSTORM } + ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { + game.override + .enemyLevel(1) + .startingLevel(100) + .enemySpecies(Species.MAGIKARP) + .moveset(move) + .enemyMoveset(Moves.SPLASH); + await game.classicMode.startBattle([ Species.MAGIKARP, Species.FEEBAS ]); + const enemy = game.scene.getEnemyPokemon()!; + enemy.damageAndUpdate(enemy.hp - 1); + + const enemySeed = enemy.getHeldItems()[0] as PokemonInstantReviveModifier; + vi.spyOn(enemySeed, "apply"); + + game.move.select(move); + await game.phaseInterceptor.to("BerryPhase"); + + expect(enemy.isFainted()).toBeTruthy(); + }); +}); From 0ce30aad5f8fcc49e840fcc101891598e77f96d0 Mon Sep 17 00:00:00 2001 From: Christopher Schmidt Date: Tue, 28 Jan 2025 22:32:00 -0500 Subject: [PATCH 9/9] Fixes endure to no longer block indirect damage, updates weather damage to be HitResult.OTHER, adds/fixes unit test --- src/field/pokemon.ts | 4 +-- src/phases/weather-effect-phase.ts | 2 +- src/test/items/reviver_seed.test.ts | 43 +++++++++++++++++++++++------ 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 90e2a1eaa17..c910be18c8e 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -3014,7 +3014,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } const surviveDamage = new Utils.BooleanHolder(false); - if (!preventEndure && this.hp - damage <= 0) { + if (!preventEndure && this.hp - damage <= 0 && this.turnData?.damageSources?.at(0) !== HitResult.OTHER) { if (this.hp >= 1 && this.getTag(BattlerTagType.ENDURING)) { surviveDamage.value = this.lapseTag(BattlerTagType.ENDURING); } else if (this.hp > 1 && this.getTag(BattlerTagType.STURDY)) { @@ -3041,7 +3041,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * Once the MoveEffectPhase is over (and calls it's .end() function, shiftPhase() will reset the PhaseQueueSplice via clearPhaseQueueSplice() ) */ globalScene.setPhaseQueueSplice(); - globalScene.unshiftPhase(new FaintPhase(this.getBattlerIndex(), preventEndure)); + globalScene.unshiftPhase(new FaintPhase(this.getBattlerIndex())); this.destroySubstitute(); this.lapseTag(BattlerTagType.COMMANDED); this.resetSummonData(); diff --git a/src/phases/weather-effect-phase.ts b/src/phases/weather-effect-phase.ts index aa09f8a850d..ae5fd07b56a 100644 --- a/src/phases/weather-effect-phase.ts +++ b/src/phases/weather-effect-phase.ts @@ -49,7 +49,7 @@ export class WeatherEffectPhase extends CommonAnimPhase { const damage = Utils.toDmgValue(pokemon.getMaxHp() / 16); globalScene.queueMessage(getWeatherDamageMessage(this.weather?.weatherType!, pokemon)!); // TODO: are those bangs correct? - pokemon.damageAndUpdate(damage, HitResult.EFFECTIVE, false, false, true); + pokemon.damageAndUpdate(damage, HitResult.OTHER, false, false, true); }; this.executeForAll((pokemon: Pokemon) => { diff --git a/src/test/items/reviver_seed.test.ts b/src/test/items/reviver_seed.test.ts index da32799132e..651cd07c3ab 100644 --- a/src/test/items/reviver_seed.test.ts +++ b/src/test/items/reviver_seed.test.ts @@ -87,21 +87,20 @@ describe("Items - Reviver Seed", () => { expect(player.isFainted()).toBeFalsy(); }); - - //Need to fix some of tests, something wrong with the enemy fainting, wrong phase being chosen.. not sure + // Damaging opponents tests it.each([ - //{ moveType: "Damaging Move Chip Damage", move: Moves.SALT_CURE }, - //{ moveType: "Chip Damage", move: Moves.LEECH_SEED }, - //{ moveType: "Trapping Chip Damage", move: Moves.WHIRLPOOL }, + { moveType: "Damaging Move Chip Damage", move: Moves.SALT_CURE }, + { moveType: "Chip Damage", move: Moves.LEECH_SEED }, + { moveType: "Trapping Chip Damage", move: Moves.WHIRLPOOL }, { moveType: "Status Effect Damage", move: Moves.WILL_O_WISP }, - { moveType: "Weather", move: Moves.SANDSTORM } + { moveType: "Weather", move: Moves.SANDSTORM }, ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { game.override .enemyLevel(1) .startingLevel(100) .enemySpecies(Species.MAGIKARP) .moveset(move) - .enemyMoveset(Moves.SPLASH); + .enemyMoveset(Moves.ENDURE); await game.classicMode.startBattle([ Species.MAGIKARP, Species.FEEBAS ]); const enemy = game.scene.getEnemyPokemon()!; enemy.damageAndUpdate(enemy.hp - 1); @@ -110,8 +109,36 @@ describe("Items - Reviver Seed", () => { vi.spyOn(enemySeed, "apply"); game.move.select(move); - await game.phaseInterceptor.to("BerryPhase"); + await game.phaseInterceptor.to("TurnEndPhase"); expect(enemy.isFainted()).toBeTruthy(); }); + + // Self-damage tests + it.each([ + { moveType: "Recoil", move: Moves.DOUBLE_EDGE }, + { moveType: "Self-KO", move: Moves.EXPLOSION }, + { moveType: "Self-Deduction", move: Moves.CURSE }, + { moveType: "Liquid Ooze", move: Moves.GIGA_DRAIN }, + ])("should not activate the holder's reviver seed from $moveType", async ({ move }) => { + game.override + .enemyLevel(100) + .startingLevel(1) + .enemySpecies(Species.MAGIKARP) + .moveset(move) + .enemyAbility(Abilities.LIQUID_OOZE) + .enemyMoveset(Moves.SPLASH); + await game.classicMode.startBattle([ Species.GASTLY, Species.FEEBAS ]); + const player = game.scene.getPlayerPokemon()!; + player.damageAndUpdate(player.hp - 1); + + const playerSeed = player.getHeldItems()[0] as PokemonInstantReviveModifier; + vi.spyOn(playerSeed, "apply"); + + game.move.select(move); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(playerSeed.apply).toHaveReturnedWith(false); // Reviver Seed triggers + expect(player.isFainted()).toBeTruthy(); + }); });