[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
This commit is contained in:
Bertie690 2025-12-18 22:07:48 -05:00 committed by GitHub
parent a6554acfe3
commit c7bdfe7ed8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 67 additions and 92 deletions

View File

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

View File

@ -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,

View File

@ -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.

View File

@ -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,

View File

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

View File

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

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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