From f82d3529ad9f4a8e0472a0f0f71028b5f0ea382c Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Tue, 29 Apr 2025 10:04:38 -0400 Subject: [PATCH] Applied review comments, cleaned up code a bit --- src/battle-scene.ts | 4 +- src/data/abilities/ab-attrs/ab-attr.ts | 4 + src/data/abilities/ability.ts | 99 +++++++++---------- src/data/battle-anims.ts | 2 +- src/data/moves/move.ts | 99 +++++++++++-------- .../the-winstrate-challenge-encounter.ts | 1 + src/field/pokemon.ts | 24 ++--- src/modifier/modifier-type.ts | 1 + src/phases/battle-end-phase.ts | 4 +- src/phases/berry-phase.ts | 13 ++- src/phases/new-biome-encounter-phase.ts | 3 +- src/phases/next-encounter-phase.ts | 8 +- src/phases/switch-summon-phase.ts | 1 + src/system/game-data.ts | 2 - src/utils/data.ts | 26 +++-- test/abilities/cud_chew.test.ts | 14 ++- 16 files changed, 168 insertions(+), 137 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 02034674c4a..9f3e8909d53 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -874,8 +874,8 @@ export default class BattleScene extends SceneBase { /** * Returns an array of Pokemon on both sides of the battle - player first, then enemy. * Does not actually check if the pokemon are on the field or not, and always has length 4 regardless of battle type. - * @param activeOnly Whether to consider only active pokemon - * @returns array of {@linkcode Pokemon} + * @param activeOnly - Whether to consider only active pokemon; default `false` + * @returns An array of {@linkcode Pokemon}, as described above. */ public getField(activeOnly = false): Pokemon[] { const ret = new Array(4).fill(null); diff --git a/src/data/abilities/ab-attrs/ab-attr.ts b/src/data/abilities/ab-attrs/ab-attr.ts index a653c3f372d..24fbb6dc338 100644 --- a/src/data/abilities/ab-attrs/ab-attr.ts +++ b/src/data/abilities/ab-attrs/ab-attr.ts @@ -6,6 +6,10 @@ export abstract class AbAttr { public showAbility: boolean; private extraCondition: AbAttrCondition; + /** + * @param showAbility - Whether to show this ability as a flyout during battle; default `true`. + * Should be kept in parity with mainline where possible. + */ constructor(showAbility = true) { this.showAbility = showAbility; } diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index c65c858472c..65ace1cb48c 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -4045,7 +4045,13 @@ export class PostTurnResetStatusAbAttr extends PostTurnAbAttr { */ export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr { /** - * @param procChance - Chance to create an item + * Array containing all {@linkcode BerryType | BerryTypes} that are under cap and able to be restored. + * Stored inside the class for a minor performance boost + */ + private berriesUnderCap: BerryType[] + + /** + * @param procChance - function providing chance to restore an item * @see {@linkcode createEatenBerry()} */ constructor( @@ -4054,19 +4060,19 @@ export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr { super(); } - override canApplyPostTurn(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean { - // check if we have at least 1 recoverable berry (at least 1 berry in berriesEaten is not capped) + override canApplyPostTurn(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): boolean { + // Ensure we have at least 1 recoverable berry (at least 1 berry in berriesEaten is not capped) const cappedBerries = new Set( globalScene.getModifiers(BerryModifier, pokemon.isPlayer()).filter( - (bm) => bm.pokemonId === pokemon.id && bm.getCountUnderMax() < 1 + bm => bm.pokemonId === pokemon.id && bm.getCountUnderMax() < 1 ).map(bm => bm.berryType) ); - const hasBerryUnderCap = pokemon.battleData.berriesEaten.some( - (bt) => !cappedBerries.has(bt) + this.berriesUnderCap = pokemon.battleData.berriesEaten.filter( + bt => !cappedBerries.has(bt) ); - if (!hasBerryUnderCap) { + if (!this.berriesUnderCap.length) { return false; } @@ -4076,41 +4082,22 @@ export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr { } override applyPostTurn(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): void { - this.createEatenBerry(pokemon, simulated); + if (!simulated) { + this.createEatenBerry(pokemon); + } } /** - * Create a new berry chosen randomly from the berries the pokemon ate this battle - * @param pokemon The pokemon with this ability - * @param simulated whether the associated ability call is simulated + * Create a new berry chosen randomly from all berries the pokemon ate this battle + * @param pokemon - The {@linkcode Pokemon} with this ability * @returns `true` if a new berry was created */ - createEatenBerry(pokemon: Pokemon, simulated: boolean): boolean { - // get all berries we just ate that are under cap - const cappedBerries = new Set( - globalScene.getModifiers(BerryModifier, pokemon.isPlayer()).filter( - (bm) => bm.pokemonId === pokemon.id && bm.getCountUnderMax() < 1 - ).map((bm) => bm.berryType) - ); - - const berriesEaten = pokemon.battleData.berriesEaten.filter( - (bt) => !cappedBerries.has(bt) - ); - - if (!berriesEaten.length) { - return false; - } - - if (simulated) { - return true; - } - - // Pick a random berry to yoink - const randomIdx = randSeedInt(berriesEaten.length); - const chosenBerryType = berriesEaten[randomIdx]; + createEatenBerry(pokemon: Pokemon): boolean { + // Pick a random available berry to yoink + const randomIdx = randSeedInt(this.berriesUnderCap.length); + const chosenBerryType = this.berriesUnderCap[randomIdx]; pokemon.battleData.berriesEaten.splice(randomIdx, 1); // Remove berry from memory const chosenBerry = new BerryModifierType(chosenBerryType); - chosenBerry.id = "BERRY" // needed to prevent item deletion; remove after modifier rework // Add the randomly chosen berry or update the existing one const berryModifier = globalScene.findModifier( @@ -4121,7 +4108,6 @@ export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr { if (berryModifier) { berryModifier.stackCount++ } else { - // make new modifier const newBerry = new BerryModifier(chosenBerry, pokemon.id, chosenBerryType, 1); if (pokemon.isPlayer()) { globalScene.addModifier(newBerry); @@ -4141,19 +4127,19 @@ export class PostTurnRestoreBerryAbAttr extends PostTurnAbAttr { * Used by {@linkcode Abilities.CUD_CHEW}. */ export class RepeatBerryNextTurnAbAttr extends PostTurnAbAttr { - // no need for constructor; all it does is set `showAbility` which we override before triggering anyways - /** * @returns `true` if the pokemon ate anything last turn */ override canApply(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): boolean { - this.showAbility = true; // force ability popup if ability triggers + // force ability popup for ability triggers on normal turns. + // Still not used if ability doesn't proc + this.showAbility = true; return !!pokemon.summonData.berriesEatenLast.length; } /** - * Cause this {@linkcode Pokemon} to regurgitate and eat all berries - * inside its `berriesEatenLast` array. + * Cause this {@linkcode Pokemon} to regurgitate and eat all berries inside its `berriesEatenLast` array. + * Triggers a berry use animation, but does *not* count for other berry or item-related abilities. * @param pokemon - The {@linkcode Pokemon} having a bad tummy ache * @param _passive - N/A * @param _simulated - N/A @@ -4161,13 +4147,12 @@ export class RepeatBerryNextTurnAbAttr extends PostTurnAbAttr { * @param _args - N/A */ override apply(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _cancelled: BooleanHolder | null, _args: any[]): void { - // play berry animation globalScene.unshiftPhase( new CommonAnimPhase(pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.USE_ITEM), ); // Re-apply effects of all berries previously scarfed. - // This technically doesn't count as "eating" a berry (for unnerve/stuff cheeks/unburden) + // This doesn't count as "eating" a berry (for unnerve/stuff cheeks/unburden) as no item is consumed. for (const berryType of pokemon.summonData.berriesEatenLast) { getBerryEffectFunc(berryType)(pokemon); const bMod = new BerryModifier(new BerryModifierType(berryType), pokemon.id, berryType, 1); @@ -4179,7 +4164,7 @@ export class RepeatBerryNextTurnAbAttr extends PostTurnAbAttr { } /** - * @returns always `true` + * @returns always `true` as we always want to move berries into summon data */ override canApplyPostTurn(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): boolean { this.showAbility = false; // don't show popup for turn end berry moving (should ideally be hidden) @@ -4188,11 +4173,12 @@ export class RepeatBerryNextTurnAbAttr extends PostTurnAbAttr { /** * Move this {@linkcode Pokemon}'s `berriesEaten` array from `PokemonTurnData` - * into `PokemonSummonData`. - * @param pokemon The {@linkcode Pokemon} having a nice snack - * @param _passive N/A - * @param _simulated N/A - * @param _args N/A + * into `PokemonSummonData` on turn end. + * Both arrays are cleared on switch. + * @param pokemon - The {@linkcode Pokemon} having a nice snack + * @param _passive - N/A + * @param _simulated - N/A + * @param _args - N/A */ override applyPostTurn(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): void { pokemon.summonData.berriesEatenLast = pokemon.turnData.berriesEaten; @@ -4565,8 +4551,19 @@ export class DoubleBerryEffectAbAttr extends AbAttr { } } +/** + * Attribute to prevent opposing berry use while on the field. + * Used by {@linkcode Abilities.UNNERVE}, {@linkcode Abilities.AS_ONE_GLASTRIER} and {@linkcode Abilities.AS_ONE_SPECTRIER} + */ export class PreventBerryUseAbAttr extends AbAttr { - override apply(pokemon: Pokemon, passive: boolean, simulated: boolean, cancelled: BooleanHolder, args: any[]): void { + /** + * Prevent use of opposing berries. + * @param _pokemon - Unused + * @param _passive - Unused + * @param _simulated - Unused + * @param cancelled - {@linkcode BooleanHolder} containing whether to block berry use + */ + override apply(_pokemon: Pokemon, _passive: boolean, _simulated: boolean, cancelled: BooleanHolder): void { cancelled.value = true; } } @@ -5156,7 +5153,7 @@ export class PostSummonStatStageChangeOnArenaAbAttr extends PostSummonStatStageC /** * Takes no damage from the first hit of a damaging move. * This is used in the Disguise and Ice Face abilities. - * + * * Does not apply to a user's substitute * @extends ReceivedMoveDamageMultiplierAbAttr */ diff --git a/src/data/battle-anims.ts b/src/data/battle-anims.ts index 0999e9db6ff..5bc8d01b7cd 100644 --- a/src/data/battle-anims.ts +++ b/src/data/battle-anims.ts @@ -1132,7 +1132,7 @@ export abstract class BattleAnim { if (priority === 0) { // Place the sprite in front of the pokemon on the field. targetSprite = globalScene.getEnemyField().find(p => p) ?? globalScene.getPlayerField().find(p => p); - console.log(typeof targetSprite); + // console.log(typeof targetSprite); moveFunc = globalScene.field.moveBelow; } else if (priority === 2 && this.bgSprite) { moveFunc = globalScene.field.moveAbove; diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index a74937ab91f..c791c363e3b 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -2535,10 +2535,10 @@ export class PsychoShiftEffectAttr extends MoveEffectAttr { return !target.status && target.canSetStatus(user.status?.effect, true, false, user) ? -10 : 0; } } + /** - * The following needs to be implemented for Thief - * "If the user faints due to the target's Ability (Rough Skin or Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item." - * "If Knock Off causes a Pokémon with the Sticky Hold Ability to faint, it can now remove that Pokémon's held item." + * Attribute to steal items upon this move's use. + * Used for {@linkcode Moves.THIEF} and {@linkcode Moves.COVET}. */ export class StealHeldItemChanceAttr extends MoveEffectAttr { private chance: number; @@ -2553,18 +2553,22 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr { if (rand > this.chance) { return false; } + const heldItems = this.getTargetHeldItems(target).filter((i) => i.isTransferable); - if (heldItems.length) { - const poolType = target.isPlayer() ? ModifierPoolType.PLAYER : target.hasTrainer() ? ModifierPoolType.TRAINER : ModifierPoolType.WILD; - const highestItemTier = heldItems.map((m) => m.type.getOrInferTier(poolType)).reduce((highestTier, tier) => Math.max(tier!, highestTier), 0); // TODO: is the bang after tier correct? - const tierHeldItems = heldItems.filter((m) => m.type.getOrInferTier(poolType) === highestItemTier); - const stolenItem = tierHeldItems[user.randSeedInt(tierHeldItems.length)]; - if (globalScene.tryTransferHeldItemModifier(stolenItem, user, false)) { - globalScene.queueMessage(i18next.t("moveTriggers:stoleItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: stolenItem.type.name })); - return true; - } + if (!heldItems.length) { + return false; } - return false; + + const poolType = target.isPlayer() ? ModifierPoolType.PLAYER : target.hasTrainer() ? ModifierPoolType.TRAINER : ModifierPoolType.WILD; + const highestItemTier = heldItems.map((m) => m.type.getOrInferTier(poolType)).reduce((highestTier, tier) => Math.max(tier!, highestTier), 0); // TODO: is the bang after tier correct? + const tierHeldItems = heldItems.filter((m) => m.type.getOrInferTier(poolType) === highestItemTier); + const stolenItem = tierHeldItems[user.randSeedInt(tierHeldItems.length)]; + if (!globalScene.tryTransferHeldItemModifier(stolenItem, user, false)) { + return false; + } + + globalScene.queueMessage(i18next.t("moveTriggers:stoleItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: stolenItem.type.name })); + return true; } getTargetHeldItems(target: Pokemon): PokemonHeldItemModifier[] { @@ -2588,58 +2592,62 @@ export class StealHeldItemChanceAttr extends MoveEffectAttr { * Used for Incinerate and Knock Off. * Not Implemented Cases: (Same applies for Thief) * "If the user faints due to the target's Ability (Rough Skin or Iron Barbs) or held Rocky Helmet, it cannot remove the target's held item." - * "If Knock Off causes a Pokémon with the Sticky Hold Ability to faint, it can now remove that Pokémon's held item." + * "If the Pokémon is knocked out by the attack, Sticky Hold does not protect the held item."" */ export class RemoveHeldItemAttr extends MoveEffectAttr { - /** Optional restriction for item pool to berries only i.e. Differentiating Incinerate and Knock Off */ + /** Optional restriction for item pool to berries only; i.e. Incinerate */ private berriesOnly: boolean; - constructor(berriesOnly: boolean) { + constructor(berriesOnly: boolean = false) { super(false); this.berriesOnly = berriesOnly; } /** - * - * @param user {@linkcode Pokemon} that used the move - * @param target Target {@linkcode Pokemon} that the moves applies to - * @param move {@linkcode Move} that is used + * Attempt to permanently remove a held + * @param user - The {@linkcode Pokemon} that used the move + * @param target - The {@linkcode Pokemon} targeted by the move + * @param move - N/A * @param args N/A - * @returns True if an item was removed + * @returns `true` if an item was able to be removed */ apply(user: Pokemon, target: Pokemon, move: Move, args: any[]): boolean { if (!this.berriesOnly && target.isPlayer()) { // "Wild Pokemon cannot knock off Player Pokemon's held items" (See Bulbapedia) return false; } + // Check for abilities that block item theft + // TODO: This should not trigger if the target would faint beforehand const cancelled = new BooleanHolder(false); - applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); // Check for abilities that block item theft + applyAbAttrs(BlockItemTheftAbAttr, target, cancelled); - if (cancelled.value === true) { + if (cancelled.value) { return false; } - // Considers entire transferrable item pool by default (Knock Off). Otherwise berries only if specified (Incinerate). + // Considers entire transferrable item pool by default (Knock Off). + // Otherwise only consider berries (Incinerate). let heldItems = this.getTargetHeldItems(target).filter(i => i.isTransferable); if (this.berriesOnly) { heldItems = heldItems.filter(m => m instanceof BerryModifier && m.pokemonId === target.id, target.isPlayer()); } - if (heldItems.length) { - const removedItem = heldItems[user.randSeedInt(heldItems.length)]; + if (!heldItems.length) { + return false; + } - // Decrease item amount and update icon - target.loseHeldItem(removedItem); - globalScene.updateModifiers(target.isPlayer()); + const removedItem = heldItems[user.randSeedInt(heldItems.length)]; + // Decrease item amount and update icon + target.loseHeldItem(removedItem); + globalScene.updateModifiers(target.isPlayer()); - if (this.berriesOnly) { - globalScene.queueMessage(i18next.t("moveTriggers:incineratedItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name })); - } else { - globalScene.queueMessage(i18next.t("moveTriggers:knockedOffItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name })); - } + if (this.berriesOnly) { + globalScene.queueMessage(i18next.t("moveTriggers:incineratedItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name })); + } else { + globalScene.queueMessage(i18next.t("moveTriggers:knockedOffItem", { pokemonName: getPokemonNameWithAffix(user), targetName: getPokemonNameWithAffix(target), itemName: removedItem.type.name })); } return true; @@ -6349,11 +6357,11 @@ export class ForceSwitchOutAttr extends MoveEffectAttr { if (!allyPokemon?.isActive(true) && switchOutTarget.hp) { globalScene.pushPhase(new BattleEndPhase(false)); - + if (globalScene.gameMode.hasRandomBiomes || globalScene.isNewBiome()) { globalScene.pushPhase(new SelectBiomePhase()); } - + globalScene.pushPhase(new NewBattlePhase()); } } @@ -8711,7 +8719,10 @@ export function initMoves() { .attr(MultiHitPowerIncrementAttr, 3) .checkAllHits(), new AttackMove(Moves.THIEF, PokemonType.DARK, MoveCategory.PHYSICAL, 60, 100, 25, -1, 0, 2) - .attr(StealHeldItemChanceAttr, 0.3), + .attr(StealHeldItemChanceAttr, 0.3) + .edgeCase(), + // Should not be able to steal held item if user faints due to Rough Skin, Iron Barbs, etc. + // Should be able to steal items from pokemon with Sticky Hold if the damage causes them to faint new StatusMove(Moves.SPIDER_WEB, PokemonType.BUG, -1, 10, -1, 0, 2) .condition(failIfGhostTypeCondition) .attr(AddBattlerTagAttr, BattlerTagType.TRAPPED, false, true, 1) @@ -9098,7 +9109,10 @@ export function initMoves() { .reflectable(), new AttackMove(Moves.KNOCK_OFF, PokemonType.DARK, MoveCategory.PHYSICAL, 65, 100, 20, -1, 0, 3) .attr(MovePowerMultiplierAttr, (user, target, move) => target.getHeldItems().filter(i => i.isTransferable).length > 0 ? 1.5 : 1) - .attr(RemoveHeldItemAttr, false), + .attr(RemoveHeldItemAttr, false) + .edgeCase(), + // Should not be able to remove held item if user faints due to Rough Skin, Iron Barbs, etc. + // Should be able to remove items from pokemon with Sticky Hold if the damage causes them to faint new AttackMove(Moves.ENDEAVOR, PokemonType.NORMAL, MoveCategory.PHYSICAL, -1, 100, 5, -1, 0, 3) .attr(MatchHpAttr) .condition(failOnBossCondition), @@ -9286,7 +9300,10 @@ export function initMoves() { .attr(HighCritAttr) .attr(StatusEffectAttr, StatusEffect.POISON), new AttackMove(Moves.COVET, PokemonType.NORMAL, MoveCategory.PHYSICAL, 60, 100, 25, -1, 0, 3) - .attr(StealHeldItemChanceAttr, 0.3), + .attr(StealHeldItemChanceAttr, 0.3) + .edgeCase(), + // Should not be able to steal held item if user faints due to Rough Skin, Iron Barbs, etc. + // Should be able to steal items from pokemon with Sticky Hold if the damage causes them to faint new AttackMove(Moves.VOLT_TACKLE, PokemonType.ELECTRIC, MoveCategory.PHYSICAL, 120, 100, 15, 10, 0, 3) .attr(RecoilAttr, false, 0.33) .attr(StatusEffectAttr, StatusEffect.PARALYSIS) @@ -9797,7 +9814,9 @@ export function initMoves() { .hidesTarget(), new AttackMove(Moves.INCINERATE, PokemonType.FIRE, MoveCategory.SPECIAL, 60, 100, 15, -1, 0, 5) .target(MoveTarget.ALL_NEAR_ENEMIES) - .attr(RemoveHeldItemAttr, true), + .attr(RemoveHeldItemAttr, true) + .edgeCase(), + // Should be able to remove items from pokemon with Sticky Hold if the damage causes them to faint new StatusMove(Moves.QUASH, PokemonType.DARK, 100, 15, -1, 0, 5) .condition(failIfSingleBattle) .condition((user, target, move) => !target.turnData.acted) diff --git a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts index c235dde37d0..8fa6680a3e6 100644 --- a/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts +++ b/src/data/mystery-encounters/encounters/the-winstrate-challenge-encounter.ts @@ -222,6 +222,7 @@ function endTrainerBattleAndShowDialogue(): Promise { globalScene.triggerPokemonFormChange(pokemon, SpeciesFormChangeAbilityTrigger); } + // Each trainer battle is "supposed" to be a new fight, so reset all per-battle activation effects pokemon.resetBattleAndWaveData(); applyPostBattleInitAbAttrs(PostBattleInitAbAttr, pokemon); } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index e6f0fac77e6..6bfaf58846c 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -676,9 +676,10 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** - * Checks if the pokemon is allowed in battle (ie: not fainted, and allowed under any active challenges). - * @param onField `true` to also check if the pokemon is currently on the field, defaults to `false` - * @returns `true` if the pokemon is "active". Returns `false` if there is no active {@linkcode BattleScene} + * Checks if this {@linkcode Pokemon} is allowed in battle (ie: not fainted, and allowed under any active challenges). + * @param onField `true` to also check if the pokemon is currently on the field; default `false` + * @returns `true` if the pokemon is "active", as described above. + * Returns `false` if there is no active {@linkcode BattleScene} or the pokemon is disallowed. */ public isActive(onField = false): boolean { if (!globalScene) { @@ -5696,9 +5697,9 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } /** - Reset a {@linkcode Pokemon}'s per-battle {@linkcode PokemonBattleData | battleData}, - as well as any transient {@linkcode PokemonWaveData | waveData} for the current wave. - Called before a new battle starts. + * Reset a {@linkcode Pokemon}'s per-battle {@linkcode PokemonBattleData | battleData}, + * as well as any transient {@linkcode PokemonWaveData | waveData} for the current wave. + * Should be called once per arena transition (new biome/trainer battle/Mystery Encounter). */ resetBattleAndWaveData(): void { this.battleData = new PokemonBattleData(); @@ -5707,7 +5708,8 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { /** * Reset a {@linkcode Pokemon}'s {@linkcode PokemonWaveData | waveData}. - * Called once per new wave start as well as by {@linkcode resetBattleAndWaveData}. + * Should be called upon starting a new wave in addition to whenever an arena transition occurs. + * @see {@linkcode resetBattleAndWaveData()} */ resetWaveData(): void { this.waveData = new PokemonWaveData(); @@ -6046,7 +6048,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { let fusionPaletteColors: Map; const originalRandom = Math.random; - Math.random = randSeedFloat; + Math.random = () => randSeedFloat(); globalScene.executeWithSeedOffset( () => { @@ -7788,11 +7790,11 @@ export class PokemonSummonData { * Resets at the start of a new battle (but not on switch). */ export class PokemonBattleData { - /** counts the hits the pokemon received during this battle; used for {@linkcode Moves.RAGE_FIST} */ + /** Counter tracking direct hits this Pokemon has received during this battle; used for {@linkcode Moves.RAGE_FIST} */ public hitCount = 0; - /** Whether this has eaten a berry this battle; used for {@linkcode Moves.BELCH} */ + /** Whether this Pokemon has eaten a berry this battle; used for {@linkcode Moves.BELCH} */ public hasEatenBerry: boolean = false; - /** A list of all berries eaten in this current battle; used by {@linkcode Abilities.HARVEST} */ + /** Array containing all berries eaten in this current battle; used by {@linkcode Abilities.HARVEST} */ public berriesEaten: BerryType[] = []; constructor(source?: PokemonBattleData | Partial) { diff --git a/src/modifier/modifier-type.ts b/src/modifier/modifier-type.ts index 219a6b6344b..c03f2526267 100644 --- a/src/modifier/modifier-type.ts +++ b/src/modifier/modifier-type.ts @@ -789,6 +789,7 @@ export class BerryModifierType extends PokemonHeldItemModifierType implements Ge ); this.berryType = berryType; + this.id = "BERRY"; // needed to prevent harvest item deletion; remove after modifier rework } get name(): string { diff --git a/src/phases/battle-end-phase.ts b/src/phases/battle-end-phase.ts index b2f7867466c..5ccef2c71eb 100644 --- a/src/phases/battle-end-phase.ts +++ b/src/phases/battle-end-phase.ts @@ -58,9 +58,8 @@ export class BattleEndPhase extends BattlePhase { globalScene.unshiftPhase(new GameOverPhase(true)); } - // reset pokemon wave turn count, apply post battle effects, etc etc. for (const pokemon of globalScene.getField()) { - if (pokemon?.summonData) { + if (pokemon) { pokemon.summonData.waveTurnCount = 1; } } @@ -82,7 +81,6 @@ export class BattleEndPhase extends BattlePhase { } } - // lapse all post battle modifiers that should lapse const lapsingModifiers = globalScene.findModifiers( m => m instanceof LapsingPersistentModifier || m instanceof LapsingPokemonHeldItemModifier, ) as (LapsingPersistentModifier | LapsingPokemonHeldItemModifier)[]; diff --git a/src/phases/berry-phase.ts b/src/phases/berry-phase.ts index 576f6f52419..b964783460c 100644 --- a/src/phases/berry-phase.ts +++ b/src/phases/berry-phase.ts @@ -15,7 +15,10 @@ import { CommonAnimPhase } from "./common-anim-phase"; import { globalScene } from "#app/global-scene"; import type Pokemon from "#app/field/pokemon"; -/** The phase after attacks where the pokemon eat berries */ +/** + * The phase after attacks where the pokemon eat berries. + * Also triggers Cud Chew's "repeat berry use" effects + */ export class BerryPhase extends FieldPhase { start() { super.start(); @@ -30,18 +33,17 @@ export class BerryPhase extends FieldPhase { /** * Attempt to eat all of a given {@linkcode Pokemon}'s berries once. - * @param pokemon The {@linkcode Pokemon} to check + * @param pokemon - The {@linkcode Pokemon} to check */ eatBerries(pokemon: Pokemon): void { - // check if we even have anything to eat const hasUsableBerry = !!globalScene.findModifier(m => { return m instanceof BerryModifier && m.shouldApply(pokemon); }, pokemon.isPlayer()); + if (!hasUsableBerry) { return; } - // Check if any opponents have unnerve to block us from eating berries const cancelled = new BooleanHolder(false); pokemon.getOpponents().map(opp => applyAbAttrs(PreventBerryUseAbAttr, opp, cancelled)); if (cancelled.value) { @@ -57,7 +59,6 @@ export class BerryPhase extends FieldPhase { new CommonAnimPhase(pokemon.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.USE_ITEM), ); - // try to apply all berry modifiers for this pokemon for (const berryModifier of globalScene.applyModifiers(BerryModifier, pokemon.isPlayer(), pokemon)) { if (berryModifier.consumed) { berryModifier.consumed = false; @@ -66,8 +67,6 @@ export class BerryPhase extends FieldPhase { // No need to track berries being eaten; already done inside applyModifiers globalScene.eventTarget.dispatchEvent(new BerryUsedEvent(berryModifier)); } - - // update held modifiers and such globalScene.updateModifiers(pokemon.isPlayer()); // Abilities.CHEEK_POUCH only works once per round of nom noms diff --git a/src/phases/new-biome-encounter-phase.ts b/src/phases/new-biome-encounter-phase.ts index 55ab8701183..ef027bfd77a 100644 --- a/src/phases/new-biome-encounter-phase.ts +++ b/src/phases/new-biome-encounter-phase.ts @@ -7,7 +7,8 @@ export class NewBiomeEncounterPhase extends NextEncounterPhase { doEncounter(): void { globalScene.playBgm(undefined, true); - // reset all battle data, perform form changes, etc. + // Reset all battle and wave data, perform form changes, etc. + // We do this because new biomes are considered "arena transitions" akin to MEs and trainer battles for (const pokemon of globalScene.getPlayerParty()) { if (pokemon) { pokemon.resetBattleAndWaveData(); diff --git a/src/phases/next-encounter-phase.ts b/src/phases/next-encounter-phase.ts index 4bfe86ad672..30b4004363c 100644 --- a/src/phases/next-encounter-phase.ts +++ b/src/phases/next-encounter-phase.ts @@ -1,6 +1,10 @@ import { globalScene } from "#app/global-scene"; import { EncounterPhase } from "./encounter-phase"; +/** + * The phase between defeating an encounter and starting another wild wave. + * Handles generating, loading and preparing for it. + */ export class NextEncounterPhase extends EncounterPhase { start() { super.start(); @@ -9,7 +13,9 @@ export class NextEncounterPhase extends EncounterPhase { doEncounter(): void { globalScene.playBgm(undefined, true); - // Reset all player transient wave data/intel. + // Reset all player transient wave data/intel before starting a new wild encounter. + // We exclusively reset wave data here as wild waves are considered one continuous "battle" + // for lack of an arena transition. for (const pokemon of globalScene.getPlayerParty()) { if (pokemon) { pokemon.resetWaveData(); diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index c96b617c18c..35d83ac3922 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -240,6 +240,7 @@ export class SwitchSummonPhase extends SummonPhase { } } + // No need (or particular use) resetting turn data here on initial send in if (this.switchType !== SwitchType.INITIAL_SWITCH) { pokemon.resetTurnData(); pokemon.turnData.switchedInThisTurn = true; diff --git a/src/system/game-data.ts b/src/system/game-data.ts index 751a794ae20..e200fa6b3c7 100644 --- a/src/system/game-data.ts +++ b/src/system/game-data.ts @@ -1198,8 +1198,6 @@ export class GameData { } } - // load modifier data - if (globalScene.modifiers.length) { console.warn("Existing modifiers not cleared on session load, deleting..."); globalScene.modifiers = []; diff --git a/src/utils/data.ts b/src/utils/data.ts index abf5327b7cb..33623dc5e40 100644 --- a/src/utils/data.ts +++ b/src/utils/data.ts @@ -13,27 +13,23 @@ export function deepCopy(values: object): object { * This copies all values from `source` that match properties inside `dest`, * checking recursively for non-null nested objects. - * If a property in `src` does not exist in `dest` or its `typeof` evaluates differently, it is skipped. + * If a property in `source` does not exist in `dest` or its `typeof` evaluates differently, it is skipped. * If it is a non-array object, its properties are recursed into and checked in turn. * All other values are copied verbatim. - * @param dest The object to merge values into - * @param source The object to source merged values from + * @param dest - The object to merge values into + * @param source - The object to source merged values from * @remarks Do not use for regular objects; this is specifically made for JSON copying. - * @see deepMergeObjects */ export function deepMergeSpriteData(dest: object, source: object) { - // Grab all the keys present in both with similar types - const matchingKeys = Object.keys(source).filter(key => { - const destVal = dest[key]; - const sourceVal = source[key]; + for (const key of Object.keys(source)) { + if ( + !(key in dest) || + typeof source[key] !== typeof dest[key] || + Array.isArray(source[key]) !== Array.isArray(dest[key]) + ) { + continue; + } - return ( - // 1st part somewhat redundant, but makes it clear that we're explicitly interested in properties that exist in both - key in source && Array.isArray(sourceVal) === Array.isArray(destVal) && typeof sourceVal === typeof destVal - ); - }); - - for (const key of matchingKeys) { // Pure objects get recursed into; everything else gets overwritten if (typeof source[key] !== "object" || source[key] === null || Array.isArray(source[key])) { dest[key] = source[key]; diff --git a/test/abilities/cud_chew.test.ts b/test/abilities/cud_chew.test.ts index 56b503003e4..f99060cb744 100644 --- a/test/abilities/cud_chew.test.ts +++ b/test/abilities/cud_chew.test.ts @@ -126,7 +126,7 @@ describe("Abilities - Cud Chew", () => { game.move.select(Moves.STUFF_CHEEKS); await game.toNextTurn(); - // Ate 2 petayas from moves + 1 of each at turn end; all 4 get moved on turn end + // Ate 2 petayas from moves + 1 of each at turn end; all 4 get tallied on turn end expect(farigiraf.summonData.berriesEatenLast).toEqual([ BerryType.PETAYA, BerryType.PETAYA, @@ -145,7 +145,7 @@ describe("Abilities - Cud Chew", () => { expect(farigiraf.getStatStage(Stat.ATK)).toBe(4); // 1+2+1 }); - it("resets array on switch", async () => { + it("should reset both arrays on switch", async () => { await game.classicMode.startBattle([Species.FARIGIRAF, Species.GIRAFARIG]); const farigiraf = game.scene.getPlayerPokemon()!; @@ -157,12 +157,20 @@ describe("Abilities - Cud Chew", () => { const turn1Hp = farigiraf.hp; game.doSwitchPokemon(1); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toNextTurn(); // summonData got cleared due to switch, turnData got cleared due to turn end expect(farigiraf.summonData.berriesEatenLast).toEqual([]); expect(farigiraf.turnData.berriesEaten).toEqual([]); expect(farigiraf.hp).toEqual(turn1Hp); + + game.doSwitchPokemon(1); + await game.toNextTurn(); + + // TurnData gets cleared while switching in + expect(farigiraf.summonData.berriesEatenLast).toEqual([]); + expect(farigiraf.turnData.berriesEaten).toEqual([]); + expect(farigiraf.hp).toEqual(turn1Hp); }); it("clears array if disabled", async () => {