From c7bdfe7ed8e7a4f3dc8244dd9a68af4ee91f7ee0 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Thu, 18 Dec 2025 22:07:48 -0500 Subject: [PATCH] [Bug] Reset hit-related turn data inside `MoveEndPhase` (#6637) * Reset hit-related turn data inside `MoveEndPhase` and remove `extraTurns` field * Fixed FS edge case * Fixed test hit count checks going past move end phase * fixed PB tests * Put `default` switch case last again --- src/data/abilities/ability.ts | 1 - src/data/moves/move.ts | 17 ++++---- src/data/pokemon/pokemon-data.ts | 29 +++++++------ src/data/positional-tags/positional-tag.ts | 6 +-- src/phases/move-effect-phase.ts | 9 +---- src/phases/move-end-phase.ts | 8 ++++ src/phases/move-phase.ts | 7 ---- test/abilities/parental-bond.test.ts | 47 ++++++++-------------- test/abilities/wimp-out.test.ts | 9 +++-- test/items/multi-lens.test.ts | 8 ++-- test/moves/beak-blast.test.ts | 13 ------ test/moves/electro-shot.test.ts | 2 +- test/moves/protect.test.ts | 3 +- 13 files changed, 67 insertions(+), 92 deletions(-) diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 7adce995dd8..348764d4659 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -5100,7 +5100,6 @@ export class PostDancingMoveAbAttr extends PostMoveUsedAbAttr { */ override apply({ source, pokemon, move, targets, simulated }: PostMoveUsedAbAttrParams): void { if (!simulated) { - pokemon.turnData.extraTurns++; // If the move is an AttackMove or a StatusMove the Dancer must replicate the move on the source of the Dance if (move.getMove().is("AttackMove") || move.getMove().is("StatusMove")) { const target = this.getTarget(pokemon, source, targets); diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 1073473b48f..3376dc26e0a 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -1860,7 +1860,9 @@ export class TargetHalfHpDamageAttr extends FixedDamageAttr { super(0); } - apply(user: Pokemon, target: Pokemon, _move: Move, args: any[]): boolean { + apply(user: Pokemon, target: Pokemon, _move: Move, args: [NumberHolder, ...any[]]): boolean { + const [dmg] = args; + // first, determine if the hit is coming from multi lens or not const lensCount = user @@ -1869,23 +1871,23 @@ export class TargetHalfHpDamageAttr extends FixedDamageAttr { ?.getStackCount() ?? 0; if (lensCount <= 0) { // no multi lenses; we can just halve the target's hp and call it a day - (args[0] as NumberHolder).value = toDmgValue(target.hp / 2); + dmg.value = toDmgValue(target.hp / 2); return true; } // figure out what hit # we're on switch (user.turnData.hitCount - user.turnData.hitsLeft) { case lensCount + 1: - // parental bond added hit; calc damage as normal - (args[0] as NumberHolder).value = toDmgValue(target.hp / 2); + // parental bond added hit; halve target's hp as normal + dmg.value = toDmgValue(target.hp / 2); return true; - // biome-ignore lint/suspicious/noFallthroughSwitchClause: intentional? + // biome-ignore lint/suspicious/noFallthroughSwitchClause: intentional case 0: - // first hit of move; update initialHp tracker + // first hit of move; update initialHp tracker for first hit this.initialHp = target.hp; default: // multi lens added hit; use initialHp tracker to ensure correct damage - (args[0] as NumberHolder).value = toDmgValue(this.initialHp / 2); + dmg.value = toDmgValue(this.initialHp / 2); return true; } } @@ -7861,7 +7863,6 @@ export class RepeatMoveAttr extends MoveEffectAttr { targetPokemonName: getPokemonNameWithAffix(target), }), ); - target.turnData.extraTurns++; globalScene.phaseManager.unshiftNew( "MovePhase", target, diff --git a/src/data/pokemon/pokemon-data.ts b/src/data/pokemon/pokemon-data.ts index f0a0fc88c59..40b03da12b6 100644 --- a/src/data/pokemon/pokemon-data.ts +++ b/src/data/pokemon/pokemon-data.ts @@ -124,12 +124,17 @@ export class PokemonSummonData { public stats: number[] = [0, 0, 0, 0, 0, 0]; public moveset: PokemonMove[] | null; - // If not initialized this value will not be populated from save data. public types: PokemonType[] = []; public addedType: PokemonType | null = null; - /** Data pertaining to this pokemon's illusion. */ + /** Data pertaining to this pokemon's Illusion, if it has one. */ public illusion: IllusionData | null = null; + /** + * Whether this Pokemon's illusion has been broken since switching out. + * @defaultValue `false` + */ + // TODO: Since Illusion applies on switch in, and this entire class is reset on switch-in, + // this may be replaceable with a check for `pokemon.summonData.illusionData !== null` public illusionBroken = false; /** Array containing all berries eaten in the last turn; used by {@linkcode AbilityId.CUD_CHEW} */ @@ -139,6 +144,7 @@ export class PokemonSummonData { * An array of all moves this pokemon has used since entering the battle. * Used for most moves and abilities that check prior move usage or copy already-used moves. */ + // TODO: Rework this into a sort of "global move history" that also allows checking execution order (for Fusion Bolt/Flare) public moveHistory: TurnMove[] = []; constructor(source?: PokemonSummonData | SerializedPokemonSummonData) { @@ -302,8 +308,10 @@ export class PokemonTurnData { /** How many times the current move should hit the target(s) */ public hitCount = 0; /** - * - `-1` = Calculate how many hits are left - * - `0` = Move is finished + * - `-1`: Calculate how many hits are left + * - `0`: Move is finished + * - `>0`: Move is in process of hitting targets + * @defaultValue `-1` */ public hitsLeft = -1; public totalDamageDealt = 0; @@ -320,20 +328,17 @@ export class PokemonTurnData { public summonedThisTurn = false; public failedRunAway = false; public joinedRound = false; - /** Tracker for a pending status effect + /** + * Tracker for a pending status effect. * * @remarks * Set whenever {@linkcode Pokemon#trySetStatus} succeeds in order to prevent subsequent status effects - * from being applied. Necessary because the status is not actually set until the {@linkcode ObtainStatusEffectPhase} runs, + * from being applied. \ + * Necessary because the status is not actually set until the {@linkcode ObtainStatusEffectPhase} runs, * which may not happen before another status effect is attempted to be applied. + * @defaultValue `StatusEffect.NONE` */ public pendingStatus: StatusEffect = StatusEffect.NONE; - /** - * The amount of times this Pokemon has acted again and used a move in the current turn. - * Used to make sure multi-hits occur properly when the user is - * forced to act again in the same turn, and **must be incremented** by any effects that grant extra actions. - */ - public extraTurns = 0; /** * All berries eaten by this pokemon in this turn. * Saved into {@linkcode PokemonSummonData | SummonData} by {@linkcode AbilityId.CUD_CHEW} on turn end. diff --git a/src/data/positional-tags/positional-tag.ts b/src/data/positional-tags/positional-tag.ts index e6d5cb245c5..f1fc29f59d4 100644 --- a/src/data/positional-tags/positional-tag.ts +++ b/src/data/positional-tags/positional-tag.ts @@ -99,10 +99,8 @@ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs public override trigger(): void { // Bangs are justified as the `shouldTrigger` method will queue the tag for removal // if the source or target no longer exist - const source = globalScene.getPokemonById(this.sourceId)!; const target = this.getTarget()!; - source.turnData.extraTurns++; globalScene.phaseManager.queueMessage( i18next.t("moveTriggers:tookMoveAttack", { pokemonName: getPokemonNameWithAffix(target), @@ -112,7 +110,9 @@ export class DelayedAttackTag extends PositionalTag implements DelayedAttackArgs globalScene.phaseManager.unshiftNew( "MoveEffectPhase", - this.sourceId, // TODO: Find an alternate method of passing the source pokemon without a source ID + // TODO: Find an alternate method of passing the (currently off-field) source pokemon + // instead of relying on pokemon getter jank + this.sourceId, [this.targetIndex], allMoves[this.sourceMove], MoveUseMode.DELAYED_ATTACK, diff --git a/src/phases/move-effect-phase.ts b/src/phases/move-effect-phase.ts index 3209298a265..f73ee2ae0ec 100644 --- a/src/phases/move-effect-phase.ts +++ b/src/phases/move-effect-phase.ts @@ -133,6 +133,7 @@ export class MoveEffectPhase extends PokemonPhase { if (anySuccess) { this.moveHistoryEntry.result = MoveResult.SUCCESS; } else { + // If the move failed to impact all targets, disable all subsequent multi-hits user.turnData.hitCount = 1; user.turnData.hitsLeft = 1; this.moveHistoryEntry.result = allMiss ? MoveResult.MISS : MoveResult.FAIL; @@ -258,14 +259,6 @@ export class MoveEffectPhase extends PokemonPhase { // Lapse `MOVE_EFFECT` effects (i.e. semi-invulnerability) when applicable user.lapseTags(BattlerTagLapseType.MOVE_EFFECT); - // If the user is acting again (such as due to Instruct or Dancer), reset hitsLeft/hitCount and - // recalculate hit count for multi-hit moves. - if (user.turnData.hitsLeft === 0 && user.turnData.hitCount > 0 && user.turnData.extraTurns > 0) { - user.turnData.hitsLeft = -1; - user.turnData.hitCount = 0; - user.turnData.extraTurns--; - } - /** * If this phase is for the first hit of the invoked move, * resolve the move's total hit count. This block combines the diff --git a/src/phases/move-end-phase.ts b/src/phases/move-end-phase.ts index fd893c445ff..02e6750f1aa 100644 --- a/src/phases/move-end-phase.ts +++ b/src/phases/move-end-phase.ts @@ -22,6 +22,14 @@ export class MoveEndPhase extends PokemonPhase { super.start(); const pokemon = this.getPokemon(); + + // Reset hit-related temporary data. + // TODO: These properties should be stored inside a "move in flight" object, + // which this Phase would promptly destroy + if (pokemon) { + pokemon.turnData.hitsLeft = -1; + } + if (!this.wasFollowUp && pokemon?.isActive(true)) { pokemon.lapseTags(BattlerTagLapseType.AFTER_MOVE); } diff --git a/src/phases/move-phase.ts b/src/phases/move-phase.ts index 50dd16a4d3f..fa4bf293ba4 100644 --- a/src/phases/move-phase.ts +++ b/src/phases/move-phase.ts @@ -422,13 +422,6 @@ export class MovePhase extends PokemonPhase { this.doThawCheck(); } - // Reset hit-related turn data when starting follow-up moves (e.g. Metronomed moves, Dancer repeats) - if (isVirtual(useMode)) { - const turnData = user.turnData; - turnData.hitsLeft = -1; - turnData.hitCount = 0; - } - const pokemonMove = this.move; // Check move to see if arena.ignoreAbilities should be true. diff --git a/test/abilities/parental-bond.test.ts b/test/abilities/parental-bond.test.ts index 95f0e8d4159..0fd199c0075 100644 --- a/test/abilities/parental-bond.test.ts +++ b/test/abilities/parental-bond.test.ts @@ -37,6 +37,8 @@ describe("Abilities - Parental Bond", () => { .enemyLevel(100); }); + // TODO: Review how many of these tests are duplicated in other files + // and/or in Multi Lens' suite it("should add second strike to attack move", async () => { game.override.moveset([MoveId.TACKLE]); @@ -53,7 +55,7 @@ describe("Abilities - Parental Bond", () => { const firstStrikeDamage = enemyStartingHp - enemyPokemon.hp; enemyStartingHp = enemyPokemon.hp; - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); const secondStrikeDamage = enemyStartingHp - enemyPokemon.hp; @@ -70,7 +72,7 @@ describe("Abilities - Parental Bond", () => { game.move.select(MoveId.POWER_UP_PUNCH); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(leadPokemon.turnData.hitCount).toBe(2); expect(leadPokemon.getStatStage(Stat.ATK)).toBe(2); @@ -85,7 +87,7 @@ describe("Abilities - Parental Bond", () => { game.move.select(MoveId.BABY_DOLL_EYES); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(enemyPokemon.getStatStage(Stat.ATK)).toBe(-1); }); @@ -100,7 +102,7 @@ describe("Abilities - Parental Bond", () => { game.move.select(MoveId.DOUBLE_HIT); await game.move.forceHit(); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(leadPokemon.turnData.hitCount).toBe(2); }); @@ -142,7 +144,7 @@ describe("Abilities - Parental Bond", () => { const enemyPokemon = game.field.getEnemyPokemon(); game.move.select(MoveId.DRAGON_RAGE); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - 80); }); @@ -156,11 +158,11 @@ describe("Abilities - Parental Bond", () => { const enemyPokemon = game.field.getEnemyPokemon(); game.move.select(MoveId.COUNTER); - await game.phaseInterceptor.to("DamageAnimPhase"); + await game.phaseInterceptor.to("MoveEndPhase"); const playerDamage = leadPokemon.getMaxHp() - leadPokemon.hp; - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(enemyPokemon.hp).toBe(enemyPokemon.getMaxHp() - 4 * playerDamage); }); @@ -168,16 +170,13 @@ describe("Abilities - Parental Bond", () => { it("should not apply to multi-target moves", async () => { game.override.battleStyle("double").moveset([MoveId.EARTHQUAKE]).passiveAbility(AbilityId.LEVITATE); - await game.classicMode.startBattle([SpeciesId.MAGIKARP, SpeciesId.FEEBAS]); - - const playerPokemon = game.scene.getPlayerField(); + await game.classicMode.startBattle([SpeciesId.MAGIKARP]); game.move.select(MoveId.EARTHQUAKE); - game.move.select(MoveId.EARTHQUAKE, 1); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); - playerPokemon.forEach(p => expect(p.turnData.hitCount).toBe(1)); + expect(game.field.getPlayerPokemon().turnData.hitCount).toBe(1); }); it("should apply to multi-target moves when hitting only one target", async () => { @@ -208,7 +207,7 @@ describe("Abilities - Parental Bond", () => { expect(leadPokemon.turnData.hitCount).toBe(2); // This test will time out if the user faints - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(leadPokemon.hp).toBe(Math.ceil(leadPokemon.getMaxHp() / 2)); }); @@ -229,7 +228,7 @@ describe("Abilities - Parental Bond", () => { expect(enemyPokemon.hp).toBeGreaterThan(0); expect(leadPokemon.isOfType(PokemonType.FIRE)).toBe(true); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(leadPokemon.isOfType(PokemonType.FIRE)).toBe(false); }); @@ -332,25 +331,11 @@ describe("Abilities - Parental Bond", () => { expect(leadPokemon.turnData.hitCount).toBe(2); expect(enemyPokemon.status?.effect).toBe(StatusEffect.SLEEP); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(enemyPokemon.status?.effect).toBeUndefined(); }); - it("should not cause user to hit into King's Shield more than once", async () => { - game.override.moveset([MoveId.TACKLE]).enemyMoveset([MoveId.KINGS_SHIELD]); - - await game.classicMode.startBattle([SpeciesId.MAGIKARP]); - - const leadPokemon = game.field.getPlayerPokemon(); - - game.move.select(MoveId.TACKLE); - - await game.phaseInterceptor.to("BerryPhase", false); - - expect(leadPokemon.getStatStage(Stat.ATK)).toBe(-1); - }); - it("should not cause user to hit into Storm Drain more than once", async () => { game.override.moveset([MoveId.WATER_GUN]).enemyAbility(AbilityId.STORM_DRAIN); @@ -360,7 +345,7 @@ describe("Abilities - Parental Bond", () => { game.move.select(MoveId.WATER_GUN); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(enemyPokemon.getStatStage(Stat.SPATK)).toBe(1); }); diff --git a/test/abilities/wimp-out.test.ts b/test/abilities/wimp-out.test.ts index 1caf9bf7701..75c5edf5566 100644 --- a/test/abilities/wimp-out.test.ts +++ b/test/abilities/wimp-out.test.ts @@ -417,7 +417,8 @@ describe("Abilities - Wimp Out", () => { game.move.select(MoveId.ENDURE); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); const enemyPokemon = game.field.getEnemyPokemon(); expect(enemyPokemon.turnData.hitsLeft).toBe(0); @@ -433,7 +434,8 @@ describe("Abilities - Wimp Out", () => { game.move.select(MoveId.ENDURE); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); const enemyPokemon = game.field.getEnemyPokemon(); expect(enemyPokemon.turnData.hitsLeft).toBe(0); @@ -448,7 +450,8 @@ describe("Abilities - Wimp Out", () => { game.move.select(MoveId.ENDURE); game.doSelectPartyPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); const enemyPokemon = game.field.getEnemyPokemon(); expect(enemyPokemon.turnData.hitsLeft).toBe(0); diff --git a/test/items/multi-lens.test.ts b/test/items/multi-lens.test.ts index aff9cc148f6..ee2f7629ceb 100644 --- a/test/items/multi-lens.test.ts +++ b/test/items/multi-lens.test.ts @@ -55,7 +55,7 @@ describe("Items - Multi Lens", () => { game.move.select(MoveId.TACKLE); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); const damageResults = spy.mock.results.map(result => result.value?.damage); expect(damageResults).toHaveLength(1 + stackCount); @@ -74,7 +74,7 @@ describe("Items - Multi Lens", () => { game.move.select(MoveId.TACKLE); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(playerPokemon.turnData.hitCount).toBe(3); }); @@ -112,7 +112,7 @@ describe("Items - Multi Lens", () => { await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.PLAYER_2, BattlerIndex.ENEMY, BattlerIndex.ENEMY_2]); - await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(magikarp.turnData.hitCount).toBe(2); }); @@ -129,7 +129,7 @@ describe("Items - Multi Lens", () => { game.move.select(MoveId.SEISMIC_TOSS); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); - await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); const damageResults = spy.mock.results.map(result => result.value?.damage); expect(damageResults).toHaveLength(2); diff --git a/test/moves/beak-blast.test.ts b/test/moves/beak-blast.test.ts index 4d28e7fd0ab..374c983ed9f 100644 --- a/test/moves/beak-blast.test.ts +++ b/test/moves/beak-blast.test.ts @@ -86,19 +86,6 @@ describe("Moves - Beak Blast", () => { expect(enemyPokemon.status?.effect).not.toBe(StatusEffect.BURN); }); - it("should only hit twice with Multi-Lens", async () => { - game.override.startingHeldItems([{ name: "MULTI_LENS", count: 1 }]); - - await game.classicMode.startBattle([SpeciesId.BLASTOISE]); - - const leadPokemon = game.field.getPlayerPokemon(); - - game.move.select(MoveId.BEAK_BLAST); - - await game.phaseInterceptor.to(BerryPhase, false); - expect(leadPokemon.turnData.hitCount).toBe(2); - }); - it("should be blocked by Protect", async () => { game.override.enemyMoveset([MoveId.PROTECT]); diff --git a/test/moves/electro-shot.test.ts b/test/moves/electro-shot.test.ts index 4b1303fc930..edaaf3e365c 100644 --- a/test/moves/electro-shot.test.ts +++ b/test/moves/electro-shot.test.ts @@ -95,7 +95,7 @@ describe("Moves - Electro Shot", () => { game.move.select(MoveId.ELECTRO_SHOT); - await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(playerPokemon.turnData.hitCount).toBe(1); expect(playerPokemon.getStatStage(Stat.SPATK)).toBe(1); }); diff --git a/test/moves/protect.test.ts b/test/moves/protect.test.ts index 7fe29cd7568..1c9fdb87558 100644 --- a/test/moves/protect.test.ts +++ b/test/moves/protect.test.ts @@ -179,7 +179,8 @@ describe("Moves - Protect", () => { const enemyPokemon = game.field.getEnemyPokemon(); game.move.select(MoveId.PROTECT); - await game.phaseInterceptor.to("BerryPhase", false); + await game.phaseInterceptor.to("MoveEndPhase"); + await game.phaseInterceptor.to("MoveEndPhase", false); expect(charizard.hp).toBe(charizard.getMaxHp()); expect(enemyPokemon.turnData.hitCount).toBe(1);