From 4dfcca150131bae383228044c58a582660f5e11a Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 21 May 2025 13:54:58 -0400 Subject: [PATCH 01/18] Fixed modifier code, removed instances of "0 ID = no mon" --- src/battle-scene.ts | 242 +++++++++------- src/data/abilities/ability.ts | 52 ++-- src/data/arena-tag.ts | 227 +++++++++------ src/data/battler-tags.ts | 267 ++++++++++++------ src/data/moves/move.ts | 8 +- .../encounters/dancing-lessons-encounter.ts | 2 +- src/field/pokemon.ts | 5 +- src/modifier/modifier.ts | 2 +- test/abilities/unburden.test.ts | 7 +- test/field/pokemon_id_checks.test.ts | 104 +++++++ 10 files changed, 601 insertions(+), 315 deletions(-) create mode 100644 test/field/pokemon_id_checks.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index cbaf07d579c..0e0df19bed9 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -932,7 +932,16 @@ export default class BattleScene extends SceneBase { return activeOnly ? this.infoToggles.filter(t => t?.isActive()) : this.infoToggles; } - getPokemonById(pokemonId: number): Pokemon | null { + /** + * Return the {@linkcode Pokemon} associated with a given ID. + * @param pokemonId - The ID whose Pokemon will be retrieved. + * @returns The {@linkcode Pokemon} associated with the given id. + * Returns `null` if the ID is `undefined` or not present in either party. + */ + getPokemonById(pokemonId: number | undefined): Pokemon | null { + if (isNullOrUndefined(pokemonId)) { + return null; + } const findInParty = (party: Pokemon[]) => party.find(p => p.id === pokemonId); return (findInParty(this.getPlayerParty()) || findInParty(this.getEnemyParty())) ?? null; } @@ -3033,14 +3042,14 @@ export default class BattleScene extends SceneBase { * If the recepient already has the maximum amount allowed for this item, the transfer is cancelled. * The quantity to transfer is automatically capped at how much the recepient can take before reaching the maximum stack size for the item. * A transfer that moves a quantity smaller than what is specified in the transferQuantity parameter is still considered successful. - * @param itemModifier {@linkcode PokemonHeldItemModifier} item to transfer (represents the whole stack) - * @param target {@linkcode Pokemon} recepient in this transfer - * @param playSound `true` to play a sound when transferring the item - * @param transferQuantity How many items of the stack to transfer. Optional, defaults to `1` - * @param instant ??? (Optional) - * @param ignoreUpdate ??? (Optional) - * @param itemLost If `true`, treat the item's current holder as losing the item (for now, this simply enables Unburden). Default is `true`. - * @returns `true` if the transfer was successful + * @param itemModifier - {@linkcode PokemonHeldItemModifier} to transfer (represents whole stack) + * @param target - Recipient {@linkcode Pokemon} recieving items + * @param playSound - Whether to play a sound when transferring the item + * @param transferQuantity - How many items of the stack to transfer. Optional, defaults to `1` + * @param instant - ??? (Optional) + * @param ignoreUpdate - ??? (Optional) + * @param itemLost - Whether to treat the item's current holder as losing the item (for now, this simply enables Unburden). Default: `true`. + * @returns Whether the transfer was successful */ tryTransferHeldItemModifier( itemModifier: PokemonHeldItemModifier, @@ -3051,106 +3060,107 @@ export default class BattleScene extends SceneBase { ignoreUpdate?: boolean, itemLost = true, ): boolean { - const source = itemModifier.pokemonId ? itemModifier.getPokemon() : null; - const cancelled = new BooleanHolder(false); + const source = itemModifier.getPokemon(); + // Check if source even exists and error if not. + // Almost certainly redundant due to checking inside condition, but better log twice than not at all + if (isNullOrUndefined(source)) { + console.error( + `Pokemon ${target.getNameToRender()} tried to transfer %d items from nonexistent source; item: `, + transferQuantity, + itemModifier, + ); + return false; + } + // Check for effects that might block us from stealing + const cancelled = new BooleanHolder(false); if (source && source.isPlayer() !== target.isPlayer()) { applyAbAttrs(BlockItemTheftAbAttr, source, cancelled); } - if (cancelled.value) { return false; } - const newItemModifier = itemModifier.clone() as PokemonHeldItemModifier; - newItemModifier.pokemonId = target.id; + // check if we have an item already and calc how much to transfer const matchingModifier = this.findModifier( m => m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, target.isPlayer(), - ) as PokemonHeldItemModifier; + ) as PokemonHeldItemModifier | undefined; + const countTaken = Math.min( + transferQuantity, + itemModifier.stackCount, + matchingModifier?.getCountUnderMax() ?? Number.MAX_SAFE_INTEGER, + ); + if (countTaken <= 0) { + // Can't transfer negative items + return false; + } + itemModifier.stackCount -= countTaken; + + // If the old modifier is at 0 stacks, try to remove it + if (itemModifier.stackCount <= 0 && !this.removeModifier(itemModifier, !source.isPlayer())) { + return false; + } + + // TODO: what does this do and why is it here + if (source.isPlayer() !== target.isPlayer() && !ignoreUpdate) { + this.updateModifiers(source.isPlayer(), instant); + } + + // Add however much we took to the recieving pokemon, creating a new modifier if the target lacked one prio if (matchingModifier) { - const maxStackCount = matchingModifier.getMaxStackCount(); - if (matchingModifier.stackCount >= maxStackCount) { - return false; - } - const countTaken = Math.min( - transferQuantity, - itemModifier.stackCount, - maxStackCount - matchingModifier.stackCount, - ); - itemModifier.stackCount -= countTaken; - newItemModifier.stackCount = matchingModifier.stackCount + countTaken; + matchingModifier.stackCount += countTaken; } else { - const countTaken = Math.min(transferQuantity, itemModifier.stackCount); - itemModifier.stackCount -= countTaken; + const newItemModifier = itemModifier.clone() as PokemonHeldItemModifier; + newItemModifier.pokemonId = target.id; newItemModifier.stackCount = countTaken; - } - - const removeOld = itemModifier.stackCount === 0; - - if (!removeOld || !source || this.removeModifier(itemModifier, !source.isPlayer())) { - const addModifier = () => { - if (!matchingModifier || this.removeModifier(matchingModifier, !target.isPlayer())) { - if (target.isPlayer()) { - this.addModifier(newItemModifier, ignoreUpdate, playSound, false, instant); - if (source && itemLost) { - applyPostItemLostAbAttrs(PostItemLostAbAttr, source, false); - } - return true; - } - this.addEnemyModifier(newItemModifier, ignoreUpdate, instant); - if (source && itemLost) { - applyPostItemLostAbAttrs(PostItemLostAbAttr, source, false); - } - return true; - } - return false; - }; - if (source && source.isPlayer() !== target.isPlayer() && !ignoreUpdate) { - this.updateModifiers(source.isPlayer(), instant); - addModifier(); + if (target.isPlayer()) { + this.addModifier(newItemModifier, ignoreUpdate, playSound, false, instant); } else { - addModifier(); + this.addEnemyModifier(newItemModifier, ignoreUpdate, instant); } - return true; } - return false; + + if (itemLost) { + applyPostItemLostAbAttrs(PostItemLostAbAttr, source, false); + } + + return true; } canTransferHeldItemModifier(itemModifier: PokemonHeldItemModifier, target: Pokemon, transferQuantity = 1): boolean { - const mod = itemModifier.clone() as PokemonHeldItemModifier; - const source = mod.pokemonId ? mod.getPokemon() : null; - const cancelled = new BooleanHolder(false); - - if (source && source.isPlayer() !== target.isPlayer()) { - applyAbAttrs(BlockItemTheftAbAttr, source, cancelled); + const source = itemModifier.getPokemon(); + if (!source) { + console.error( + `Pokemon ${target.getNameToRender()} tried to transfer %d items from nonexistent source; item: `, + transferQuantity, + itemModifier, + ); + return false; } + // Check theft prevention + // TODO: Verify whether sticky hold procs on friendly fire ally theft + const cancelled = new BooleanHolder(false); + if (source.isPlayer() !== target.isPlayer()) { + applyAbAttrs(BlockItemTheftAbAttr, source, cancelled); + } if (cancelled.value) { return false; } + // figure out if we can take anything const matchingModifier = this.findModifier( - m => m instanceof PokemonHeldItemModifier && m.matchType(mod) && m.pokemonId === target.id, + m => m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, target.isPlayer(), - ) as PokemonHeldItemModifier; - - if (matchingModifier) { - const maxStackCount = matchingModifier.getMaxStackCount(); - if (matchingModifier.stackCount >= maxStackCount) { - return false; - } - const countTaken = Math.min(transferQuantity, mod.stackCount, maxStackCount - matchingModifier.stackCount); - mod.stackCount -= countTaken; - } else { - const countTaken = Math.min(transferQuantity, mod.stackCount); - mod.stackCount -= countTaken; - } - - const removeOld = mod.stackCount === 0; - - return !removeOld || !source || this.hasModifier(itemModifier, !source.isPlayer()); + ) as PokemonHeldItemModifier | undefined; + const countTaken = Math.min( + transferQuantity, + itemModifier.stackCount, + matchingModifier?.getCountUnderMax() ?? Number.MAX_SAFE_INTEGER, + ); + return countTaken > 0 && this.hasModifier(itemModifier, !source.isPlayer()); } removePartyMemberModifiers(partyMemberIndex: number): Promise { @@ -3286,6 +3296,7 @@ export default class BattleScene extends SceneBase { } } + // Why do we silently delete missing modifiers? const modifiersClone = modifiers.slice(0); for (const modifier of modifiersClone) { if (!modifier.getStackCount()) { @@ -3311,44 +3322,71 @@ export default class BattleScene extends SceneBase { }); } + /** + * Check whether a given {@linkcode PersistentModifier} exists on a given side of the field. + * @param modifier - The {@linkcode PersistentModifier} to check the existence of. + * @param enemy - Whether to check the enemy (`true`) or player (`false`) party. Default is `false`. + * @returns Whether the specified modifier exists on the given side of the field. + * @remarks This also compares `pokemonId`s to confirm a match (and therefore owners). + */ hasModifier(modifier: PersistentModifier, enemy = false): boolean { - const modifiers = !enemy ? this.modifiers : this.enemyModifiers; - return modifiers.indexOf(modifier) > -1; + return (!enemy ? this.modifiers : this.enemyModifiers).includes(modifier); } /** - * Removes a currently owned item. If the item is stacked, the entire item stack + * Remove a currently owned item. If the item is stacked, the entire item stack * gets removed. This function does NOT apply in-battle effects, such as Unburden. * If in-battle effects are needed, use {@linkcode Pokemon.loseHeldItem} instead. - * @param modifier The item to be removed. - * @param enemy `true` to remove an item owned by the enemy rather than the player; default `false`. - * @returns `true` if the item exists and was successfully removed, `false` otherwise + * @param modifier - The item to be removed. + * @param enemy - Whether to remove from the enemy (`true`) or player (`false`) party; default `false`. + * @returns Whether the item exists and was successfully removed */ removeModifier(modifier: PersistentModifier, enemy = false): boolean { const modifiers = !enemy ? this.modifiers : this.enemyModifiers; const modifierIndex = modifiers.indexOf(modifier); - if (modifierIndex > -1) { - modifiers.splice(modifierIndex, 1); - if (modifier instanceof PokemonFormChangeItemModifier) { - const pokemon = this.getPokemonById(modifier.pokemonId); - if (pokemon) { - modifier.apply(pokemon, false); - } - } - return true; + if (modifierIndex === -1) { + return false; } - return false; + modifiers.splice(modifierIndex, 1); + if (modifier instanceof PokemonFormChangeItemModifier) { + const pokemon = this.getPokemonById(modifier.pokemonId); + if (pokemon) { + modifier.apply(pokemon, false); + } + } + return true; } /** - * Get all of the modifiers that match `modifierType` - * @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier} - * @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true` - * @returns the list of all modifiers that matched `modifierType`. + * Get all modifiers of all {@linkcode Pokemon} in the given party, + * optionally filtering based on `modifierType` if provided. + * @param player Whether to search the player (`true`) or enemy (`false`) party; Defaults to `true` + * @returns An array containing all {@linkcode PersistentModifier}s on the given side of the field. + * @overload */ - getModifiers(modifierType: Constructor, player = true): T[] { - return (player ? this.modifiers : this.enemyModifiers).filter((m): m is T => m instanceof modifierType); + getModifiers(player?: boolean): PersistentModifier[]; + + /** + * Get all modifiers of all {@linkcode Pokemon} in the given party, + * optionally filtering based on `modifierType` if provided. + * @param modifierType The type of modifier to check against; must extend {@linkcode PersistentModifier}. + * If omitted, will return all {@linkcode PersistentModifier}s regardless of type. + * @param player Whether to search the player (`true`) or enemy (`false`) party; Defaults to `true` + * @returns An array containing all modifiers matching `modifierType` on the given side of the field. + * @overload + */ + getModifiers(modifierType: Constructor, player?: boolean): T[]; + + // NOTE: Boolean typing on 1st parameter needed to satisfy "bool only" overload + getModifiers(modifierType?: Constructor | boolean, player?: boolean) { + const usePlayer: boolean = player ?? (typeof modifierType !== "boolean" || modifierType); // non-bool in 1st position = true by default + const mods = usePlayer ? this.modifiers : this.enemyModifiers; + + if (typeof modifierType === "undefined" || typeof modifierType === "boolean") { + return mods; + } + return mods.filter((m): m is T => m instanceof modifierType); } /** diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index f49863639f0..eca16c51618 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -1246,7 +1246,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { /** * Determine if the move type change attribute can be applied - * + * * Can be applied if: * - The ability's condition is met, e.g. pixilate only boosts normal moves, * - The move is not forbidden from having its type changed by an ability, e.g. {@linkcode Moves.MULTI_ATTACK} @@ -1262,7 +1262,7 @@ export class MoveTypeChangeAbAttr extends PreAttackAbAttr { */ override canApplyPreAttack(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _defender: Pokemon | null, move: Move, _args: [NumberHolder?, NumberHolder?, ...any]): boolean { return (!this.condition || this.condition(pokemon, _defender, move)) && - !noAbilityTypeOverrideMoves.has(move.id) && + !noAbilityTypeOverrideMoves.has(move.id) && (!pokemon.isTerastallized || (move.id !== Moves.TERA_BLAST && (move.id !== Moves.TERA_STARSTORM || pokemon.getTeraType() !== PokemonType.STELLAR || !pokemon.hasSpecies(Species.TERAPAGOS)))); @@ -1716,7 +1716,7 @@ export class GorillaTacticsAbAttr extends PostAttackAbAttr { export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr { private stealCondition: PokemonAttackCondition | null; - private stolenItem?: PokemonHeldItemModifier; + private stolenItem: PokemonHeldItemModifier; constructor(stealCondition?: PokemonAttackCondition) { super(); @@ -1731,39 +1731,37 @@ export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr { defender: Pokemon, move: Move, hitResult: HitResult, - args: any[]): boolean { + args: any[] + ): boolean { + // Check which items to steal + const heldItems = this.getTargetHeldItems(defender).filter((i) => i.isTransferable); if ( - super.canApplyPostAttack(pokemon, passive, simulated, defender, move, hitResult, args) && - !simulated && - hitResult < HitResult.NO_EFFECT && - (!this.stealCondition || this.stealCondition(pokemon, defender, move)) + !super.canApplyPostAttack(pokemon, passive, simulated, defender, move, hitResult, args) || + heldItems.length === 0 || // no items to steal + hitResult >= HitResult.NO_EFFECT || // move was ineffective/protected against + (this.stealCondition && !this.stealCondition(pokemon, defender, move)) // no condition = pass ) { - const heldItems = this.getTargetHeldItems(defender).filter((i) => i.isTransferable); - if (heldItems.length) { - // Ensure that the stolen item in testing is the same as when the effect is applied - this.stolenItem = heldItems[pokemon.randBattleSeedInt(heldItems.length)]; - if (globalScene.canTransferHeldItemModifier(this.stolenItem, pokemon)) { - return true; - } - } + return false; } - this.stolenItem = undefined; - return false; + + // pick random item and check if we can steal it + this.stolenItem = heldItems[pokemon.randBattleSeedInt(heldItems.length)]; + return globalScene.canTransferHeldItemModifier(this.stolenItem, pokemon) } override applyPostAttack( pokemon: Pokemon, - passive: boolean, + _passive: boolean, simulated: boolean, defender: Pokemon, - move: Move, - hitResult: HitResult, - args: any[], + _move: Move, + _hitResult: HitResult, + _args: any[], ): void { - const heldItems = this.getTargetHeldItems(defender).filter((i) => i.isTransferable); - if (!this.stolenItem) { - this.stolenItem = heldItems[pokemon.randBattleSeedInt(heldItems.length)]; + if (simulated) { + return; } + if (globalScene.tryTransferHeldItemModifier(this.stolenItem, pokemon, false)) { globalScene.queueMessage( i18next.t("abilityTriggers:postAttackStealHeldItem", { @@ -1773,7 +1771,6 @@ export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr { }), ); } - this.stolenItem = undefined; } getTargetHeldItems(target: Pokemon): PokemonHeldItemModifier[] { @@ -6629,7 +6626,8 @@ export function initAbilities() { new Ability(Abilities.STICKY_HOLD, 3) .attr(BlockItemTheftAbAttr) .bypassFaint() - .ignorable(), + .ignorable() + .edgeCase(), // may or may not proc incorrectly on user's allies new Ability(Abilities.SHED_SKIN, 3) .conditionalAttr(pokemon => !randSeedInt(3), PostTurnResetStatusAbAttr), new Ability(Abilities.GUTS, 3) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 1955b51e8e0..f2e293f17fc 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -87,10 +87,11 @@ export abstract class ArenaTag { /** * Helper function that retrieves the source Pokemon - * @returns The source {@linkcode Pokemon} or `null` if none is found + * @returns - The source {@linkcode Pokemon} for this tag. + * Returns `null` if `this.sourceId` is `undefined` */ public getSourcePokemon(): Pokemon | null { - return this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; + return globalScene.getPokemonById(this.sourceId); } /** @@ -122,19 +123,22 @@ export class MistTag extends ArenaTag { onAdd(arena: Arena, quiet = false): void { super.onAdd(arena); - if (this.sourceId) { - const source = globalScene.getPokemonById(this.sourceId); - - if (!quiet && source) { - globalScene.queueMessage( - i18next.t("arenaTag:mistOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); - } else if (!quiet) { - console.warn("Failed to get source for MistTag onAdd"); - } + // We assume `quiet=true` means "just add the bloody tag no questions asked" + if (quiet) { + return; } + + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for MistTag on add message; id: ${this.sourceId}`); + return; + } + + globalScene.queueMessage( + i18next.t("arenaTag:mistOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(source), + }), + ); } /** @@ -455,18 +459,18 @@ class MatBlockTag extends ConditionalProtectTag { } onAdd(_arena: Arena) { - if (this.sourceId) { - const source = globalScene.getPokemonById(this.sourceId); - if (source) { - globalScene.queueMessage( - i18next.t("arenaTag:matBlockOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); - } else { - console.warn("Failed to get source for MatBlockTag onAdd"); - } + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for Mat Block message; id: ${this.sourceId}`); + return; } + + super.onAdd(_arena); + globalScene.queueMessage( + i18next.t("arenaTag:matBlockOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(source), + }), + ); } } @@ -526,7 +530,12 @@ export class NoCritTag extends ArenaTag { /** Queues a message upon removing this effect from the field */ onRemove(_arena: Arena): void { - const source = globalScene.getPokemonById(this.sourceId!); // TODO: is this bang correct? + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for NoCritTag on remove message; id: ${this.sourceId}`); + return; + } + globalScene.queueMessage( i18next.t("arenaTag:noCritOnRemove", { pokemonNameWithAffix: getPokemonNameWithAffix(source ?? undefined), @@ -537,7 +546,7 @@ export class NoCritTag extends ArenaTag { } /** - * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Wish_(move) Wish}. + * Arena Tag class for {@link https://bulbapedia.bulbagarden.net/wiki/Wish_(move) | Wish}. * Heals the Pokémon in the user's position the turn after Wish is used. */ class WishTag extends ArenaTag { @@ -550,18 +559,20 @@ class WishTag extends ArenaTag { } onAdd(_arena: Arena): void { - if (this.sourceId) { - const user = globalScene.getPokemonById(this.sourceId); - if (user) { - this.battlerIndex = user.getBattlerIndex(); - this.triggerMessage = i18next.t("arenaTag:wishTagOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(user), - }); - this.healHp = toDmgValue(user.getMaxHp() / 2); - } else { - console.warn("Failed to get source for WishTag onAdd"); - } + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for WishTag on add message; id: ${this.sourceId}`); + return; } + + super.onAdd(_arena); + this.healHp = toDmgValue(source.getMaxHp() / 2); + + globalScene.queueMessage( + i18next.t("arenaTag:wishTagOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(source), + }), + ); } onRemove(_arena: Arena): void { @@ -756,15 +767,23 @@ class SpikesTag extends ArenaTrapTag { onAdd(arena: Arena, quiet = false): void { super.onAdd(arena); - const source = this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; - if (!quiet && source) { - globalScene.queueMessage( - i18next.t("arenaTag:spikesOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }), - ); + // We assume `quiet=true` means "just add the bloody tag no questions asked" + if (quiet) { + return; } + + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`); + return; + } + + globalScene.queueMessage( + i18next.t("arenaTag:spikesOnAdd", { + moveName: this.getMoveName(), + opponentDesc: source.getOpponentDescriptor(), + }), + ); } override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { @@ -809,15 +828,23 @@ class ToxicSpikesTag extends ArenaTrapTag { onAdd(arena: Arena, quiet = false): void { super.onAdd(arena); - const source = this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; - if (!quiet && source) { - globalScene.queueMessage( - i18next.t("arenaTag:toxicSpikesOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }), - ); + if (quiet) { + // We assume `quiet=true` means "just add the bloody tag no questions asked" + return; } + + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for ToxicSpikesTag on add message; id: ${this.sourceId}`); + return; + } + + globalScene.queueMessage( + i18next.t("arenaTag:toxicSpikesOnAdd", { + moveName: this.getMoveName(), + opponentDesc: source.getOpponentDescriptor(), + }), + ); } onRemove(arena: Arena): void { @@ -915,7 +942,11 @@ class StealthRockTag extends ArenaTrapTag { onAdd(arena: Arena, quiet = false): void { super.onAdd(arena); - const source = this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; + if (quiet) { + return; + } + + const source = this.getSourcePokemon(); if (!quiet && source) { globalScene.queueMessage( i18next.t("arenaTag:stealthRockOnAdd", { @@ -999,15 +1030,24 @@ class StickyWebTag extends ArenaTrapTag { onAdd(arena: Arena, quiet = false): void { super.onAdd(arena); - const source = this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; - if (!quiet && source) { - globalScene.queueMessage( - i18next.t("arenaTag:stickyWebOnAdd", { - moveName: this.getMoveName(), - opponentDesc: source.getOpponentDescriptor(), - }), - ); + + // We assume `quiet=true` means "just add the bloody tag no questions asked" + if (quiet) { + return; } + + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for SpikesTag on add message; id: ${this.sourceId}`); + return; + } + + globalScene.queueMessage( + i18next.t("arenaTag:spikesOnAdd", { + moveName: this.getMoveName(), + opponentDesc: source.getOpponentDescriptor(), + }), + ); } override activateTrap(pokemon: Pokemon, simulated: boolean): boolean { @@ -1072,14 +1112,20 @@ export class TrickRoomTag extends ArenaTag { } onAdd(_arena: Arena): void { - const source = this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; - if (source) { - globalScene.queueMessage( - i18next.t("arenaTag:trickRoomOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); + super.onAdd(_arena); + + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for TrickRoomTag on add message; id: ${this.sourceId}`); + return; } + + globalScene.queueMessage( + i18next.t("arenaTag:spikesOnAdd", { + moveName: this.getMoveName(), + opponentDesc: source.getOpponentDescriptor(), + }), + ); } onRemove(_arena: Arena): void { @@ -1126,6 +1172,13 @@ class TailwindTag extends ArenaTag { } onAdd(_arena: Arena, quiet = false): void { + const source = this.getSourcePokemon(); + if (!source) { + return; + } + + super.onAdd(_arena, quiet); + if (!quiet) { globalScene.queueMessage( i18next.t( @@ -1134,10 +1187,9 @@ class TailwindTag extends ArenaTag { ); } - const source = globalScene.getPokemonById(this.sourceId!); //TODO: this bang is questionable! - const party = (source?.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField()) ?? []; + const field = source.isPlayer() ? globalScene.getPlayerField() : globalScene.getEnemyField(); - for (const pokemon of party) { + for (const pokemon of field) { // Apply the CHARGED tag to party members with the WIND_POWER ability if (pokemon.hasAbility(Abilities.WIND_POWER) && !pokemon.getTag(BattlerTagType.CHARGED)) { pokemon.addTag(BattlerTagType.CHARGED); @@ -1225,24 +1277,25 @@ class ImprisonTag extends ArenaTrapTag { } /** - * This function applies the effects of Imprison to the opposing Pokemon already present on the field. - * @param arena + * Apply the effects of Imprison to all opposing on-field Pokemon. */ override onAdd() { const source = this.getSourcePokemon(); - if (source) { - const party = this.getAffectedPokemon(); - party?.forEach((p: Pokemon) => { - if (p.isAllowedInBattle()) { - p.addTag(BattlerTagType.IMPRISON, 1, Moves.IMPRISON, this.sourceId); - } - }); - globalScene.queueMessage( - i18next.t("battlerTags:imprisonOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(source), - }), - ); + if (!source) { + return; } + + const party = this.getAffectedPokemon(); + party.forEach((p: Pokemon) => { + if (p.isAllowedInBattle()) { + p.addTag(BattlerTagType.IMPRISON, 1, Moves.IMPRISON, this.sourceId); + } + }); + globalScene.queueMessage( + i18next.t("battlerTags:imprisonOnAdd", { + pokemonNameWithAffix: getPokemonNameWithAffix(source), + }), + ); } /** diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index c284fcd5130..6bea9060499 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -129,7 +129,7 @@ export class BattlerTag { * @returns The source {@linkcode Pokemon}, or `null` if none is found */ public getSourcePokemon(): Pokemon | null { - return this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; + return globalScene.getPokemonById(this.sourceId); } } @@ -585,9 +585,13 @@ export class TrappedTag extends BattlerTag { } canAdd(pokemon: Pokemon): boolean { - const source = globalScene.getPokemonById(this.sourceId!)!; - const move = allMoves[this.sourceMove]; + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for TrappedTag canAdd; id: ${this.sourceId}`); + return false; + } + const move = allMoves[this.sourceMove]; const isGhost = pokemon.isOfType(PokemonType.GHOST); const isTrapped = pokemon.getTag(TrappedTag); const hasSubstitute = move.hitsSubstitute(source, pokemon); @@ -809,12 +813,20 @@ export class DestinyBondTag extends BattlerTag { if (lapseType !== BattlerTagLapseType.CUSTOM) { return super.lapse(pokemon, lapseType); } - const source = this.sourceId ? globalScene.getPokemonById(this.sourceId) : null; - if (!source?.isFainted()) { + + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for DestinyBondTag lapse; id: ${this.sourceId}`); + return false; + } + + // Destiny bond stays active until the user faints + if (!source.isFainted()) { return true; } - if (source?.getAlly() === pokemon) { + // Don't kill allies + if (source.getAlly() === pokemon) { return false; } @@ -827,6 +839,7 @@ export class DestinyBondTag extends BattlerTag { return false; } + // Drag the foe down with the user globalScene.queueMessage( i18next.t("battlerTags:destinyBondLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(source), @@ -844,17 +857,13 @@ export class InfatuatedTag extends BattlerTag { } canAdd(pokemon: Pokemon): boolean { - if (this.sourceId) { - const pkm = globalScene.getPokemonById(this.sourceId); - - if (pkm) { - return pokemon.isOppositeGender(pkm); - } - console.warn("canAdd: this.sourceId is not a valid pokemon id!", this.sourceId); + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for InfatuatedTag canAdd; id: ${this.sourceId}`); return false; } - console.warn("canAdd: this.sourceId is undefined"); - return false; + + return pokemon.isOppositeGender(source); } onAdd(pokemon: Pokemon): void { @@ -863,7 +872,7 @@ export class InfatuatedTag extends BattlerTag { globalScene.queueMessage( i18next.t("battlerTags:infatuatedOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - sourcePokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? + sourcePokemonName: getPokemonNameWithAffix(this.getSourcePokemon()!), // Tag not added + console warns if no source }), ); } @@ -881,26 +890,35 @@ export class InfatuatedTag extends BattlerTag { lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { const ret = lapseType !== BattlerTagLapseType.CUSTOM || super.lapse(pokemon, lapseType); - if (ret) { - globalScene.queueMessage( - i18next.t("battlerTags:infatuatedLapse", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - sourcePokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? - }), - ); - globalScene.unshiftPhase(new CommonAnimPhase(pokemon.getBattlerIndex(), undefined, CommonAnim.ATTRACT)); - - if (pokemon.randBattleSeedInt(2)) { - globalScene.queueMessage( - i18next.t("battlerTags:infatuatedLapseImmobilize", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }), - ); - (globalScene.getCurrentPhase() as MovePhase).cancel(); - } + if (!ret) { + return false; } - return ret; + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for InfatuatedTag lapse; id: ${this.sourceId}`); + return false; + } + + globalScene.queueMessage( + i18next.t("battlerTags:infatuatedLapse", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + sourcePokemonName: getPokemonNameWithAffix(source), + }), + ); + globalScene.unshiftPhase(new CommonAnimPhase(pokemon.getBattlerIndex(), undefined, CommonAnim.ATTRACT)); + + // 50% chance to disrupt the target's action + if (pokemon.randBattleSeedInt(2) === 0) { + globalScene.queueMessage( + i18next.t("battlerTags:infatuatedLapseImmobilize", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }), + ); + (globalScene.getCurrentPhase() as MovePhase).cancel(); + } + + return true; } onRemove(pokemon: Pokemon): void { @@ -943,6 +961,12 @@ export class SeedTag extends BattlerTag { } onAdd(pokemon: Pokemon): void { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for SeedTag onAdd; id: ${this.sourceId}`); + return; + } + super.onAdd(pokemon); globalScene.queueMessage( @@ -950,45 +974,52 @@ export class SeedTag extends BattlerTag { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }), ); - this.sourceIndex = globalScene.getPokemonById(this.sourceId!)!.getBattlerIndex(); // TODO: are those bangs correct? + this.sourceIndex = source.getBattlerIndex(); } lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { const ret = lapseType !== BattlerTagLapseType.CUSTOM || super.lapse(pokemon, lapseType); - if (ret) { - const source = pokemon.getOpponents().find(o => o.getBattlerIndex() === this.sourceIndex); - if (source) { - const cancelled = new BooleanHolder(false); - applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled); - - if (!cancelled.value) { - globalScene.unshiftPhase( - new CommonAnimPhase(source.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.LEECH_SEED), - ); - - const damage = pokemon.damageAndUpdate(toDmgValue(pokemon.getMaxHp() / 8), { result: HitResult.INDIRECT }); - const reverseDrain = pokemon.hasAbilityWithAttr(ReverseDrainAbAttr, false); - globalScene.unshiftPhase( - new PokemonHealPhase( - source.getBattlerIndex(), - !reverseDrain ? damage : damage * -1, - !reverseDrain - ? i18next.t("battlerTags:seededLapse", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }) - : i18next.t("battlerTags:seededLapseShed", { - pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - }), - false, - true, - ), - ); - } - } + if (!ret) { + return false; } - return ret; + // Check which opponent to restore HP to + const source = pokemon.getOpponents().find(o => o.getBattlerIndex() === this.sourceIndex); + if (!source) { + console.warn(`Failed to get source Pokemon for SeedTag lapse; id: ${this.sourceId}`); + return false; + } + + const cancelled = new BooleanHolder(false); + applyAbAttrs(BlockNonDirectDamageAbAttr, pokemon, cancelled); + + if (cancelled.value) { + return true; + } + + globalScene.unshiftPhase( + new CommonAnimPhase(source.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.LEECH_SEED), + ); + + const damage = pokemon.damageAndUpdate(toDmgValue(pokemon.getMaxHp() / 8), { result: HitResult.INDIRECT }); + const reverseDrain = pokemon.hasAbilityWithAttr(ReverseDrainAbAttr, false); + globalScene.unshiftPhase( + new PokemonHealPhase( + source.getBattlerIndex(), + !reverseDrain ? damage : damage * -1, + !reverseDrain + ? i18next.t("battlerTags:seededLapse", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }) + : i18next.t("battlerTags:seededLapseShed", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + }), + false, + true, + ), + ); + return true; } getDescriptor(): string { @@ -1245,9 +1276,15 @@ export class HelpingHandTag extends BattlerTag { } onAdd(pokemon: Pokemon): void { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for HelpingHandTag onAdd; id: ${this.sourceId}`); + return; + } + globalScene.queueMessage( i18next.t("battlerTags:helpingHandOnAdd", { - pokemonNameWithAffix: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? + pokemonNameWithAffix: getPokemonNameWithAffix(source), pokemonName: getPokemonNameWithAffix(pokemon), }), ); @@ -1459,15 +1496,22 @@ export abstract class DamagingTrapTag extends TrappedTag { } } +// TODO: Condense all these tags into 1 singular tag with a modified message func export class BindTag extends DamagingTrapTag { constructor(turnCount: number, sourceId: number) { super(BattlerTagType.BIND, CommonAnim.BIND, turnCount, Moves.BIND, sourceId); } getTrapMessage(pokemon: Pokemon): string { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for BindTag getTrapMessage; id: ${this.sourceId}`); + return "ERROR - CHECK CONSOLE AND REPORT"; + } + return i18next.t("battlerTags:bindOnTrap", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - sourcePokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? + sourcePokemonName: getPokemonNameWithAffix(source), moveName: this.getMoveName(), }); } @@ -1479,9 +1523,16 @@ export class WrapTag extends DamagingTrapTag { } getTrapMessage(pokemon: Pokemon): string { - return i18next.t("battlerTags:wrapOnTrap", { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for ClampTag getTrapMessage; id: ${this.sourceId}`); + return "ERROR - CHECK CONSOLE AND REPORT"; + } + + return i18next.t("battlerTags:clampOnTrap", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - sourcePokemonName: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? + sourcePokemonName: getPokemonNameWithAffix(source), + moveName: this.getMoveName(), }); } } @@ -1516,8 +1567,14 @@ export class ClampTag extends DamagingTrapTag { } getTrapMessage(pokemon: Pokemon): string { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for ClampTag getTrapMessage; id: ${this.sourceId}`); + return "ERROR - CHECK CONSOLE AND REPORT ASAP"; + } + return i18next.t("battlerTags:clampOnTrap", { - sourcePokemonNameWithAffix: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? + sourcePokemonNameWithAffix: getPokemonNameWithAffix(source), pokemonName: getPokemonNameWithAffix(pokemon), }); } @@ -1566,9 +1623,15 @@ export class ThunderCageTag extends DamagingTrapTag { } getTrapMessage(pokemon: Pokemon): string { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for ThunderCageTag getTrapMessage; id: ${this.sourceId}`); + return "ERROR - PLEASE REPORT ASAP"; + } + return i18next.t("battlerTags:thunderCageOnTrap", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - sourcePokemonNameWithAffix: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? + sourcePokemonNameWithAffix: getPokemonNameWithAffix(source), }); } } @@ -1579,9 +1642,15 @@ export class InfestationTag extends DamagingTrapTag { } getTrapMessage(pokemon: Pokemon): string { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for InfestationTag getTrapMessage; id: ${this.sourceId}`); + return "ERROR - CHECK CONSOLE AND REPORT"; + } + return i18next.t("battlerTags:infestationOnTrap", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), - sourcePokemonNameWithAffix: getPokemonNameWithAffix(globalScene.getPokemonById(this.sourceId!) ?? undefined), // TODO: is that bang correct? + sourcePokemonNameWithAffix: getPokemonNameWithAffix(source), }); } } @@ -2253,14 +2322,19 @@ export class SaltCuredTag extends BattlerTag { } onAdd(pokemon: Pokemon): void { - super.onAdd(pokemon); + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for SaltCureTag onAdd; id: ${this.sourceId}`); + return; + } + super.onAdd(pokemon); globalScene.queueMessage( i18next.t("battlerTags:saltCuredOnAdd", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }), ); - this.sourceIndex = globalScene.getPokemonById(this.sourceId!)!.getBattlerIndex(); // TODO: are those bangs correct? + this.sourceIndex = source.getBattlerIndex(); } lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { @@ -2310,8 +2384,14 @@ export class CursedTag extends BattlerTag { } onAdd(pokemon: Pokemon): void { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for CursedTag onAdd; id: ${this.sourceId}`); + return; + } + super.onAdd(pokemon); - this.sourceIndex = globalScene.getPokemonById(this.sourceId!)!.getBattlerIndex(); // TODO: are those bangs correct? + this.sourceIndex = source.getBattlerIndex(); } lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { @@ -2922,7 +3002,13 @@ export class SubstituteTag extends BattlerTag { /** Sets the Substitute's HP and queues an on-add battle animation that initializes the Substitute's sprite. */ onAdd(pokemon: Pokemon): void { - this.hp = Math.floor(globalScene.getPokemonById(this.sourceId!)!.getMaxHp() / 4); + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for SubstituteTag onAdd; id: ${this.sourceId}`); + return; + } + + this.hp = Math.floor(source.getMaxHp() / 4); this.sourceInFocus = false; // Queue battle animation and message @@ -3205,13 +3291,14 @@ export class ImprisonTag extends MoveRestrictionBattlerTag { */ public override lapse(pokemon: Pokemon, lapseType: BattlerTagLapseType): boolean { const source = this.getSourcePokemon(); - if (source) { - if (lapseType === BattlerTagLapseType.PRE_MOVE) { - return super.lapse(pokemon, lapseType) && source.isActive(true); - } - return source.isActive(true); + if (!source) { + console.warn(`Failed to get source Pokemon for ImprisonTag lapse; id: ${this.sourceId}`); + return false; } - return false; + if (lapseType === BattlerTagLapseType.PRE_MOVE) { + return super.lapse(pokemon, lapseType) && source.isActive(true); + } + return source.isActive(true); } /** @@ -3271,12 +3358,20 @@ export class SyrupBombTag extends BattlerTag { * Applies the single-stage speed down to the target Pokemon and decrements the tag's turn count * @param pokemon - The target {@linkcode Pokemon} * @param _lapseType - N/A - * @returns `true` if the `turnCount` is still greater than `0`; `false` if the `turnCount` is `0` or the target or source Pokemon has been removed from the field + * @returns Whether the tag should persist (`turnsRemaining > 0` and source still on field) */ override lapse(pokemon: Pokemon, _lapseType: BattlerTagLapseType): boolean { - if (this.sourceId && !globalScene.getPokemonById(this.sourceId)?.isActive(true)) { + const source = this.getSourcePokemon(); + if (!source) { + console.warn(`Failed to get source Pokemon for SyrupBombTag lapse; id: ${this.sourceId}`); return false; } + + // Syrup bomb clears immediately if source leaves field/faints + if (!source.isActive(true)) { + return false; + } + // Custom message in lieu of an animation in mainline globalScene.queueMessage( i18next.t("battlerTags:syrupBombLapse", { @@ -3286,7 +3381,7 @@ export class SyrupBombTag extends BattlerTag { globalScene.unshiftPhase( new StatStageChangePhase(pokemon.getBattlerIndex(), true, [Stat.SPD], -1, true, false, true), ); - return --this.turnCount > 0; + return super.lapse(pokemon, _lapseType); } } diff --git a/src/data/moves/move.ts b/src/data/moves/move.ts index 8a0da5f35c2..0db7b513eb3 100644 --- a/src/data/moves/move.ts +++ b/src/data/moves/move.ts @@ -6822,17 +6822,17 @@ export class RandomMovesetMoveAttr extends CallMoveAttr { // includeParty will be true for Assist, false for Sleep Talk let allies: Pokemon[]; if (this.includeParty) { - allies = user.isPlayer() ? globalScene.getPlayerParty().filter(p => p !== user) : globalScene.getEnemyParty().filter(p => p !== user); + allies = (user.isPlayer() ? globalScene.getPlayerParty() : globalScene.getEnemyParty()).filter(p => p !== user); } else { allies = [ user ]; } - const partyMoveset = allies.map(p => p.moveset).flat(); - const moves = partyMoveset.filter(m => !this.invalidMoves.has(m!.moveId) && !m!.getMove().name.endsWith(" (N)")); + const partyMoveset = allies.flatMap(p => p.moveset); + const moves = partyMoveset.filter(m => !this.invalidMoves.has(m.moveId) && !m.getMove().name.endsWith(" (N)")); if (moves.length === 0) { return false; } - this.moveId = moves[user.randBattleSeedInt(moves.length)]!.moveId; + this.moveId = moves[user.randBattleSeedInt(moves.length)].moveId; return true; }; } diff --git a/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts b/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts index bdd4bfaacaa..c0d5c5dbe1d 100644 --- a/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts +++ b/src/data/mystery-encounters/encounters/dancing-lessons-encounter.ts @@ -327,7 +327,7 @@ export const DancingLessonsEncounter: MysteryEncounter = MysteryEncounterBuilder .withOptionPhase(async () => { // Show the Oricorio a dance, and recruit it const encounter = globalScene.currentBattle.mysteryEncounter!; - const oricorio = encounter.misc.oricorioData.toPokemon(); + const oricorio = encounter.misc.oricorioData.toPokemon() as EnemyPokemon; oricorio.passive = true; // Ensure the Oricorio's moveset gains the Dance move the player used diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 329ba06fd09..985a544dc2d 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -311,6 +311,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * TODO: Stop treating this like a unique ID and stop treating 0 as no pokemon */ public id: number; + public pid: number; public name: string; public nickname: string; public species: PokemonSpecies; @@ -4172,7 +4173,7 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { getTag(tagType: Constructor): T | undefined; getTag(tagType: BattlerTagType | Constructor): BattlerTag | undefined { - return tagType instanceof Function + return typeof tagType === "function" ? this.summonData.tags.find(t => t instanceof tagType) : this.summonData.tags.find(t => t.tagType === tagType); } @@ -6702,7 +6703,7 @@ export class EnemyPokemon extends Pokemon { return ret; } - + /** * Show or hide the type effectiveness multiplier window * Passing undefined will hide the window diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 42e0155bdd8..c504e7d5942 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -738,7 +738,7 @@ export abstract class PokemonHeldItemModifier extends PersistentModifier { } getPokemon(): Pokemon | undefined { - return this.pokemonId ? (globalScene.getPokemonById(this.pokemonId) ?? undefined) : undefined; + return globalScene.getPokemonById(this.pokemonId) ?? undefined; } getScoreMultiplier(): number { diff --git a/test/abilities/unburden.test.ts b/test/abilities/unburden.test.ts index ea4f84545aa..e020ffb6255 100644 --- a/test/abilities/unburden.test.ts +++ b/test/abilities/unburden.test.ts @@ -23,10 +23,7 @@ describe("Abilities - Unburden", () => { */ function getHeldItemCount(pokemon: Pokemon): number { const stackCounts = pokemon.getHeldItems().map(m => m.getStackCount()); - if (stackCounts.length) { - return stackCounts.reduce((a, b) => a + b); - } - return 0; + return stackCounts.reduce((a, b) => a + b, 0); } beforeAll(() => { @@ -318,7 +315,7 @@ describe("Abilities - Unburden", () => { }); it("should activate when a reviver seed is used", async () => { - game.override.startingHeldItems([{ name: "REVIVER_SEED" }]).enemyMoveset([Moves.WING_ATTACK]); + game.override.startingHeldItems([{ name: "REVIVER_SEED" }]).enemyMoveset(Moves.WING_ATTACK); await game.classicMode.startBattle([Species.TREECKO]); const playerPokemon = game.scene.getPlayerPokemon()!; diff --git a/test/field/pokemon_id_checks.test.ts b/test/field/pokemon_id_checks.test.ts new file mode 100644 index 00000000000..e52ce9cbecb --- /dev/null +++ b/test/field/pokemon_id_checks.test.ts @@ -0,0 +1,104 @@ +import { allMoves } from "#app/data/moves/move"; +import type Pokemon from "#app/field/pokemon"; +import { BerryModifier } from "#app/modifier/modifier"; +import { Abilities } from "#enums/abilities"; +import { BattleType } from "#enums/battle-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { BerryType } from "#enums/berry-type"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { Stat } from "#enums/stat"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Field - Pokemon ID Checks", () => { + 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) + .ability(Abilities.NO_GUARD) + .battleStyle("single") + .disableCrits() + .enemyLevel(100) + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + function onlyUnique(array: T[]): T[] { + return [...new Set(array)]; + } + + // TODO: We currently generate IDs as a pure random integer; remove once unique UUIDs are added + it.todo("2 Pokemon should not be able to generate with the same ID during 1 encounter", async () => { + game.override.battleType(BattleType.TRAINER); // enemy generates 2 mons + await game.classicMode.startBattle([Species.FEEBAS, Species.ABRA]); + + const ids = (game.scene.getPlayerParty() as Pokemon[]).concat(game.scene.getEnemyParty()).map((p: Pokemon) => p.id); + const uniqueIds = onlyUnique(ids); + + expect(ids).toHaveLength(uniqueIds.length); + }); + + it("should not prevent item theft with PID of 0", async () => { + game.override + .moveset([Moves.THIEF, Moves.SPLASH]) + .enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.APICOT }]); + + vi.spyOn(allMoves[Moves.THIEF], "chance", "get").mockReturnValue(100); + + await game.classicMode.startBattle([Species.TREECKO]); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + // Override enemy pokemon PID to be 0 + enemy.id = 0; + game.scene.getModifiers(BerryModifier, false).forEach(modifier => { + modifier.pokemonId = enemy.id; + }); + + expect(enemy.getHeldItems()).toHaveLength(1); + expect(player.getHeldItems()).toHaveLength(0); + + // Player uses Thief and steals the opponent's item + game.move.select(Moves.THIEF); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(enemy.getHeldItems()).toHaveLength(0); + expect(player.getHeldItems()).toHaveLength(1); + }); + + it("should not prevent Syrup Bomb triggering if user has PID of 0", async () => { + game.override.moveset(Moves.SYRUP_BOMB); + await game.classicMode.startBattle([Species.TREECKO]); + + const player = game.scene.getPlayerPokemon()!; + // Override player pokemon PID to be 0 + player.id = 0; + + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy.getTag(BattlerTagType.SYRUP_BOMB)).toBeUndefined(); + + game.move.select(Moves.SYRUP_BOMB); + await game.phaseInterceptor.to("TurnEndPhase"); + + const syrupTag = enemy.getTag(BattlerTagType.SYRUP_BOMB)!; + expect(syrupTag).toBeDefined(); + expect(syrupTag.getSourcePokemon()).toBe(player); + expect(enemy.getStatStage(Stat.SPD)).toBe(-1); + }); +}); From cc6a13cce3fd920038d52456ec5ba94016a8322c Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 4 Jun 2025 13:57:45 -0400 Subject: [PATCH 02/18] corrected casing + dejanked seed tag --- src/data/battler-tags.ts | 9 +-- test/field/pokemon_id_checks.test.ts | 104 --------------------------- 2 files changed, 5 insertions(+), 108 deletions(-) delete mode 100644 test/field/pokemon_id_checks.test.ts diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 6bea9060499..9282fd807af 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1002,17 +1002,18 @@ export class SeedTag extends BattlerTag { new CommonAnimPhase(source.getBattlerIndex(), pokemon.getBattlerIndex(), CommonAnim.LEECH_SEED), ); + // Damage the target and restore our HP (or take damage in the case of liquid ooze) const damage = pokemon.damageAndUpdate(toDmgValue(pokemon.getMaxHp() / 8), { result: HitResult.INDIRECT }); const reverseDrain = pokemon.hasAbilityWithAttr(ReverseDrainAbAttr, false); globalScene.unshiftPhase( new PokemonHealPhase( source.getBattlerIndex(), - !reverseDrain ? damage : damage * -1, - !reverseDrain - ? i18next.t("battlerTags:seededLapse", { + reverseDrain ? -damage : damage, + reverseDrain + ? i18next.t("battlerTags:seededLapseShed", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }) - : i18next.t("battlerTags:seededLapseShed", { + : i18next.t("battlerTags:seededLapse", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), }), false, diff --git a/test/field/pokemon_id_checks.test.ts b/test/field/pokemon_id_checks.test.ts deleted file mode 100644 index e52ce9cbecb..00000000000 --- a/test/field/pokemon_id_checks.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { allMoves } from "#app/data/moves/move"; -import type Pokemon from "#app/field/pokemon"; -import { BerryModifier } from "#app/modifier/modifier"; -import { Abilities } from "#enums/abilities"; -import { BattleType } from "#enums/battle-type"; -import { BattlerTagType } from "#enums/battler-tag-type"; -import { BerryType } from "#enums/berry-type"; -import { Moves } from "#enums/moves"; -import { Species } from "#enums/species"; -import { Stat } from "#enums/stat"; -import GameManager from "#test/testUtils/gameManager"; -import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; - -describe("Field - Pokemon ID Checks", () => { - 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) - .ability(Abilities.NO_GUARD) - .battleStyle("single") - .disableCrits() - .enemyLevel(100) - .enemySpecies(Species.MAGIKARP) - .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset(Moves.SPLASH); - }); - - function onlyUnique(array: T[]): T[] { - return [...new Set(array)]; - } - - // TODO: We currently generate IDs as a pure random integer; remove once unique UUIDs are added - it.todo("2 Pokemon should not be able to generate with the same ID during 1 encounter", async () => { - game.override.battleType(BattleType.TRAINER); // enemy generates 2 mons - await game.classicMode.startBattle([Species.FEEBAS, Species.ABRA]); - - const ids = (game.scene.getPlayerParty() as Pokemon[]).concat(game.scene.getEnemyParty()).map((p: Pokemon) => p.id); - const uniqueIds = onlyUnique(ids); - - expect(ids).toHaveLength(uniqueIds.length); - }); - - it("should not prevent item theft with PID of 0", async () => { - game.override - .moveset([Moves.THIEF, Moves.SPLASH]) - .enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.APICOT }]); - - vi.spyOn(allMoves[Moves.THIEF], "chance", "get").mockReturnValue(100); - - await game.classicMode.startBattle([Species.TREECKO]); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - // Override enemy pokemon PID to be 0 - enemy.id = 0; - game.scene.getModifiers(BerryModifier, false).forEach(modifier => { - modifier.pokemonId = enemy.id; - }); - - expect(enemy.getHeldItems()).toHaveLength(1); - expect(player.getHeldItems()).toHaveLength(0); - - // Player uses Thief and steals the opponent's item - game.move.select(Moves.THIEF); - await game.phaseInterceptor.to("TurnEndPhase"); - - expect(enemy.getHeldItems()).toHaveLength(0); - expect(player.getHeldItems()).toHaveLength(1); - }); - - it("should not prevent Syrup Bomb triggering if user has PID of 0", async () => { - game.override.moveset(Moves.SYRUP_BOMB); - await game.classicMode.startBattle([Species.TREECKO]); - - const player = game.scene.getPlayerPokemon()!; - // Override player pokemon PID to be 0 - player.id = 0; - - const enemy = game.scene.getEnemyPokemon()!; - expect(enemy.getTag(BattlerTagType.SYRUP_BOMB)).toBeUndefined(); - - game.move.select(Moves.SYRUP_BOMB); - await game.phaseInterceptor.to("TurnEndPhase"); - - const syrupTag = enemy.getTag(BattlerTagType.SYRUP_BOMB)!; - expect(syrupTag).toBeDefined(); - expect(syrupTag.getSourcePokemon()).toBe(player); - expect(enemy.getStatStage(Stat.SPD)).toBe(-1); - }); -}); From 506f3931126266861c6f8ec10933142c34b14405 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Wed, 4 Jun 2025 16:24:04 -0400 Subject: [PATCH 03/18] Added test file, added overload to `findModifier` if given type predicate --- src/battle-scene.ts | 49 ++++++++----- src/field/pokemon.ts | 9 ++- src/modifier/modifier.ts | 1 + test/field/pokemon-id-checks.test.ts | 104 +++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 test/field/pokemon-id-checks.test.ts diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 0e0df19bed9..a072d721260 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -19,7 +19,12 @@ import { type Constructor, } from "#app/utils/common"; import { deepMergeSpriteData } from "#app/utils/data"; -import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "./modifier/modifier"; +import type { + Modifier, + ModifierIdentityPredicate, + ModifierPredicate, + TurnHeldItemTransferModifier, +} from "./modifier/modifier"; import { ConsumableModifier, ConsumablePokemonModifier, @@ -3074,7 +3079,7 @@ export default class BattleScene extends SceneBase { // Check for effects that might block us from stealing const cancelled = new BooleanHolder(false); - if (source && source.isPlayer() !== target.isPlayer()) { + if (source.isPlayer() !== target.isPlayer()) { applyAbAttrs(BlockItemTheftAbAttr, source, cancelled); } if (cancelled.value) { @@ -3083,9 +3088,10 @@ export default class BattleScene extends SceneBase { // check if we have an item already and calc how much to transfer const matchingModifier = this.findModifier( - m => m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, + (m): m is PokemonHeldItemModifier => + m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, target.isPlayer(), - ) as PokemonHeldItemModifier | undefined; + ); const countTaken = Math.min( transferQuantity, itemModifier.stackCount, @@ -3131,7 +3137,7 @@ export default class BattleScene extends SceneBase { canTransferHeldItemModifier(itemModifier: PokemonHeldItemModifier, target: Pokemon, transferQuantity = 1): boolean { const source = itemModifier.getPokemon(); - if (!source) { + if (isNullOrUndefined(source)) { console.error( `Pokemon ${target.getNameToRender()} tried to transfer %d items from nonexistent source; item: `, transferQuantity, @@ -3140,27 +3146,33 @@ export default class BattleScene extends SceneBase { return false; } - // Check theft prevention - // TODO: Verify whether sticky hold procs on friendly fire ally theft - const cancelled = new BooleanHolder(false); - if (source.isPlayer() !== target.isPlayer()) { - applyAbAttrs(BlockItemTheftAbAttr, source, cancelled); - } - if (cancelled.value) { + // If we somehow lack the item being transferred, skip + if (!this.hasModifier(itemModifier, !source.isPlayer())) { return false; } - // figure out if we can take anything + // Check enemy theft prevention + // TODO: Verify whether sticky hold procs on friendly fire ally theft + if (source.isPlayer() !== target.isPlayer()) { + const cancelled = new BooleanHolder(false); + applyAbAttrs(BlockItemTheftAbAttr, source, cancelled); + if (cancelled.value) { + return false; + } + } + + // Finally, ensure we can actually steal at least 1 item const matchingModifier = this.findModifier( - m => m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, + (m): m is PokemonHeldItemModifier => + m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, target.isPlayer(), - ) as PokemonHeldItemModifier | undefined; + ); const countTaken = Math.min( transferQuantity, itemModifier.stackCount, matchingModifier?.getCountUnderMax() ?? Number.MAX_SAFE_INTEGER, ); - return countTaken > 0 && this.hasModifier(itemModifier, !source.isPlayer()); + return countTaken > 0; } removePartyMemberModifiers(partyMemberIndex: number): Promise { @@ -3399,6 +3411,11 @@ export default class BattleScene extends SceneBase { return (isPlayer ? this.modifiers : this.enemyModifiers).filter(modifierFilter); } + findModifier( + modifierFilter: ModifierIdentityPredicate, + player?: boolean, + ): T | undefined; + findModifier(modifierFilter: ModifierPredicate, player?: boolean): PersistentModifier | undefined; /** * Find the first modifier that pass the `modifierFilter` function * @param modifierFilter The function used to filter a target's modifiers diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 985a544dc2d..462e03165e5 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -311,7 +311,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { * TODO: Stop treating this like a unique ID and stop treating 0 as no pokemon */ public id: number; - public pid: number; public name: string; public nickname: string; public species: PokemonSpecies; @@ -5133,6 +5132,13 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } }); + globalScene.queueMessage( + i18next.t("battleInfo:newKey2", { + // arguments for the locale key go here; + pokemonNameWithAffix: getPokemonNameWithAffix(this), + }), + ); + for (let f = 0; f < 2; f++) { const variantColors = variantColorCache[!f ? spriteKey : backSpriteKey]; const variantColorSet = new Map(); @@ -6703,7 +6709,6 @@ export class EnemyPokemon extends Pokemon { return ret; } - /** * Show or hide the type effectiveness multiplier window * Passing undefined will hide the window diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index c504e7d5942..03719f3b7f1 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -56,6 +56,7 @@ import { import { globalScene } from "#app/global-scene"; export type ModifierPredicate = (modifier: Modifier) => boolean; +export type ModifierIdentityPredicate = (modifier: Modifier) => modifier is T; const iconOverflowIndex = 24; diff --git a/test/field/pokemon-id-checks.test.ts b/test/field/pokemon-id-checks.test.ts new file mode 100644 index 00000000000..e52ce9cbecb --- /dev/null +++ b/test/field/pokemon-id-checks.test.ts @@ -0,0 +1,104 @@ +import { allMoves } from "#app/data/moves/move"; +import type Pokemon from "#app/field/pokemon"; +import { BerryModifier } from "#app/modifier/modifier"; +import { Abilities } from "#enums/abilities"; +import { BattleType } from "#enums/battle-type"; +import { BattlerTagType } from "#enums/battler-tag-type"; +import { BerryType } from "#enums/berry-type"; +import { Moves } from "#enums/moves"; +import { Species } from "#enums/species"; +import { Stat } from "#enums/stat"; +import GameManager from "#test/testUtils/gameManager"; +import Phaser from "phaser"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +describe("Field - Pokemon ID Checks", () => { + 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) + .ability(Abilities.NO_GUARD) + .battleStyle("single") + .disableCrits() + .enemyLevel(100) + .enemySpecies(Species.MAGIKARP) + .enemyAbility(Abilities.BALL_FETCH) + .enemyMoveset(Moves.SPLASH); + }); + + function onlyUnique(array: T[]): T[] { + return [...new Set(array)]; + } + + // TODO: We currently generate IDs as a pure random integer; remove once unique UUIDs are added + it.todo("2 Pokemon should not be able to generate with the same ID during 1 encounter", async () => { + game.override.battleType(BattleType.TRAINER); // enemy generates 2 mons + await game.classicMode.startBattle([Species.FEEBAS, Species.ABRA]); + + const ids = (game.scene.getPlayerParty() as Pokemon[]).concat(game.scene.getEnemyParty()).map((p: Pokemon) => p.id); + const uniqueIds = onlyUnique(ids); + + expect(ids).toHaveLength(uniqueIds.length); + }); + + it("should not prevent item theft with PID of 0", async () => { + game.override + .moveset([Moves.THIEF, Moves.SPLASH]) + .enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.APICOT }]); + + vi.spyOn(allMoves[Moves.THIEF], "chance", "get").mockReturnValue(100); + + await game.classicMode.startBattle([Species.TREECKO]); + + const player = game.scene.getPlayerPokemon()!; + const enemy = game.scene.getEnemyPokemon()!; + // Override enemy pokemon PID to be 0 + enemy.id = 0; + game.scene.getModifiers(BerryModifier, false).forEach(modifier => { + modifier.pokemonId = enemy.id; + }); + + expect(enemy.getHeldItems()).toHaveLength(1); + expect(player.getHeldItems()).toHaveLength(0); + + // Player uses Thief and steals the opponent's item + game.move.select(Moves.THIEF); + await game.phaseInterceptor.to("TurnEndPhase"); + + expect(enemy.getHeldItems()).toHaveLength(0); + expect(player.getHeldItems()).toHaveLength(1); + }); + + it("should not prevent Syrup Bomb triggering if user has PID of 0", async () => { + game.override.moveset(Moves.SYRUP_BOMB); + await game.classicMode.startBattle([Species.TREECKO]); + + const player = game.scene.getPlayerPokemon()!; + // Override player pokemon PID to be 0 + player.id = 0; + + const enemy = game.scene.getEnemyPokemon()!; + expect(enemy.getTag(BattlerTagType.SYRUP_BOMB)).toBeUndefined(); + + game.move.select(Moves.SYRUP_BOMB); + await game.phaseInterceptor.to("TurnEndPhase"); + + const syrupTag = enemy.getTag(BattlerTagType.SYRUP_BOMB)!; + expect(syrupTag).toBeDefined(); + expect(syrupTag.getSourcePokemon()).toBe(player); + expect(enemy.getStatStage(Stat.SPD)).toBe(-1); + }); +}); From 8c62ea2ab07d86942038977cd3b908262eefffac Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 5 Jun 2025 12:56:27 -0400 Subject: [PATCH 04/18] fixed test --- test/field/pokemon-id-checks.test.ts | 34 ++++++++++++++-------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/test/field/pokemon-id-checks.test.ts b/test/field/pokemon-id-checks.test.ts index e52ce9cbecb..67907acbb6d 100644 --- a/test/field/pokemon-id-checks.test.ts +++ b/test/field/pokemon-id-checks.test.ts @@ -1,12 +1,12 @@ -import { allMoves } from "#app/data/moves/move"; +import { allMoves } from "#app/data/data-lists"; import type Pokemon from "#app/field/pokemon"; +import { MoveId } from "#enums/move-id"; +import { AbilityId } from "#enums/ability-id"; +import { SpeciesId } from "#enums/species-id"; import { BerryModifier } from "#app/modifier/modifier"; -import { Abilities } from "#enums/abilities"; import { BattleType } from "#enums/battle-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; -import { Moves } from "#enums/moves"; -import { Species } from "#enums/species"; import { Stat } from "#enums/stat"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; @@ -29,14 +29,14 @@ describe("Field - Pokemon ID Checks", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset(Moves.SPLASH) - .ability(Abilities.NO_GUARD) + .moveset(MoveId.SPLASH) + .ability(AbilityId.NO_GUARD) .battleStyle("single") .disableCrits() .enemyLevel(100) - .enemySpecies(Species.MAGIKARP) - .enemyAbility(Abilities.BALL_FETCH) - .enemyMoveset(Moves.SPLASH); + .enemySpecies(SpeciesId.MAGIKARP) + .enemyAbility(AbilityId.BALL_FETCH) + .enemyMoveset(MoveId.SPLASH); }); function onlyUnique(array: T[]): T[] { @@ -46,7 +46,7 @@ describe("Field - Pokemon ID Checks", () => { // TODO: We currently generate IDs as a pure random integer; remove once unique UUIDs are added it.todo("2 Pokemon should not be able to generate with the same ID during 1 encounter", async () => { game.override.battleType(BattleType.TRAINER); // enemy generates 2 mons - await game.classicMode.startBattle([Species.FEEBAS, Species.ABRA]); + await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.ABRA]); const ids = (game.scene.getPlayerParty() as Pokemon[]).concat(game.scene.getEnemyParty()).map((p: Pokemon) => p.id); const uniqueIds = onlyUnique(ids); @@ -56,12 +56,12 @@ describe("Field - Pokemon ID Checks", () => { it("should not prevent item theft with PID of 0", async () => { game.override - .moveset([Moves.THIEF, Moves.SPLASH]) + .moveset([MoveId.THIEF, MoveId.SPLASH]) .enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.APICOT }]); - vi.spyOn(allMoves[Moves.THIEF], "chance", "get").mockReturnValue(100); + vi.spyOn(allMoves[MoveId.THIEF], "chance", "get").mockReturnValue(100); - await game.classicMode.startBattle([Species.TREECKO]); + await game.classicMode.startBattle([SpeciesId.TREECKO]); const player = game.scene.getPlayerPokemon()!; const enemy = game.scene.getEnemyPokemon()!; @@ -75,7 +75,7 @@ describe("Field - Pokemon ID Checks", () => { expect(player.getHeldItems()).toHaveLength(0); // Player uses Thief and steals the opponent's item - game.move.select(Moves.THIEF); + game.move.select(MoveId.THIEF); await game.phaseInterceptor.to("TurnEndPhase"); expect(enemy.getHeldItems()).toHaveLength(0); @@ -83,8 +83,8 @@ describe("Field - Pokemon ID Checks", () => { }); it("should not prevent Syrup Bomb triggering if user has PID of 0", async () => { - game.override.moveset(Moves.SYRUP_BOMB); - await game.classicMode.startBattle([Species.TREECKO]); + game.override.moveset(MoveId.SYRUP_BOMB); + await game.classicMode.startBattle([SpeciesId.TREECKO]); const player = game.scene.getPlayerPokemon()!; // Override player pokemon PID to be 0 @@ -93,7 +93,7 @@ describe("Field - Pokemon ID Checks", () => { const enemy = game.scene.getEnemyPokemon()!; expect(enemy.getTag(BattlerTagType.SYRUP_BOMB)).toBeUndefined(); - game.move.select(Moves.SYRUP_BOMB); + game.move.select(MoveId.SYRUP_BOMB); await game.phaseInterceptor.to("TurnEndPhase"); const syrupTag = enemy.getTag(BattlerTagType.SYRUP_BOMB)!; From 565e18fcb2213cc20151599dd83be59ea547a283 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Thu, 5 Jun 2025 21:22:21 -0400 Subject: [PATCH 05/18] Revert predicate stuff for now going in separate PR --- src/battle-scene.ts | 12 +----------- src/modifier/modifier.ts | 1 - 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 533740f89c0..e4222bc549e 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -19,12 +19,7 @@ import { type Constructor, } from "#app/utils/common"; import { deepMergeSpriteData } from "#app/utils/data"; -import type { - Modifier, - ModifierIdentityPredicate, - ModifierPredicate, - TurnHeldItemTransferModifier, -} from "./modifier/modifier"; +import type { Modifier, ModifierPredicate, TurnHeldItemTransferModifier } from "./modifier/modifier"; import { ConsumableModifier, ConsumablePokemonModifier, @@ -3411,11 +3406,6 @@ export default class BattleScene extends SceneBase { return (isPlayer ? this.modifiers : this.enemyModifiers).filter(modifierFilter); } - findModifier( - modifierFilter: ModifierIdentityPredicate, - player?: boolean, - ): T | undefined; - findModifier(modifierFilter: ModifierPredicate, player?: boolean): PersistentModifier | undefined; /** * Find the first modifier that pass the `modifierFilter` function * @param modifierFilter The function used to filter a target's modifiers diff --git a/src/modifier/modifier.ts b/src/modifier/modifier.ts index 3fe17c47f30..4f4b5dd5d41 100644 --- a/src/modifier/modifier.ts +++ b/src/modifier/modifier.ts @@ -56,7 +56,6 @@ import { import { globalScene } from "#app/global-scene"; export type ModifierPredicate = (modifier: Modifier) => boolean; -export type ModifierIdentityPredicate = (modifier: Modifier) => modifier is T; const iconOverflowIndex = 24; From 36d811e30216bbb41d29f4cdae4825daa09344cc Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Fri, 6 Jun 2025 11:39:16 -0400 Subject: [PATCH 06/18] Fix id check syrup bomb test Wasn't running phase due to being a turn end effect --- test/field/pokemon-id-checks.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/field/pokemon-id-checks.test.ts b/test/field/pokemon-id-checks.test.ts index 67907acbb6d..3335fb849bc 100644 --- a/test/field/pokemon-id-checks.test.ts +++ b/test/field/pokemon-id-checks.test.ts @@ -76,7 +76,7 @@ describe("Field - Pokemon ID Checks", () => { // Player uses Thief and steals the opponent's item game.move.select(MoveId.THIEF); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toNextTurn(); expect(enemy.getHeldItems()).toHaveLength(0); expect(player.getHeldItems()).toHaveLength(1); @@ -94,7 +94,7 @@ describe("Field - Pokemon ID Checks", () => { expect(enemy.getTag(BattlerTagType.SYRUP_BOMB)).toBeUndefined(); game.move.select(MoveId.SYRUP_BOMB); - await game.phaseInterceptor.to("TurnEndPhase"); + await game.toNextTurn(); const syrupTag = enemy.getTag(BattlerTagType.SYRUP_BOMB)!; expect(syrupTag).toBeDefined(); From 5da3991e54f7da2088ff0847ad6f47c0e99b0138 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Fri, 6 Jun 2025 20:12:42 -0400 Subject: [PATCH 07/18] [WIP] Changed test to use destiny bond as proper regression --- src/battle-scene.ts | 5 ++-- test/field/pokemon-id-checks.test.ts | 43 +++++++++++++++------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index e4222bc549e..7659a9e68bf 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -942,8 +942,9 @@ export default class BattleScene extends SceneBase { if (isNullOrUndefined(pokemonId)) { return null; } - const findInParty = (party: Pokemon[]) => party.find(p => p.id === pokemonId); - return (findInParty(this.getPlayerParty()) || findInParty(this.getEnemyParty())) ?? null; + + const party = (this.getPlayerParty() as Pokemon[]).concat(this.getEnemyParty()); + return party.find(p => p.id === pokemonId) ?? null; } addPlayerPokemon( diff --git a/test/field/pokemon-id-checks.test.ts b/test/field/pokemon-id-checks.test.ts index 3335fb849bc..d97b3461aa0 100644 --- a/test/field/pokemon-id-checks.test.ts +++ b/test/field/pokemon-id-checks.test.ts @@ -7,10 +7,10 @@ import { BerryModifier } from "#app/modifier/modifier"; import { BattleType } from "#enums/battle-type"; import { BattlerTagType } from "#enums/battler-tag-type"; import { BerryType } from "#enums/berry-type"; -import { Stat } from "#enums/stat"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { BattlerIndex } from "#app/battle"; describe("Field - Pokemon ID Checks", () => { let phaserGame: Phaser.Game; @@ -29,12 +29,12 @@ describe("Field - Pokemon ID Checks", () => { beforeEach(() => { game = new GameManager(phaserGame); game.override - .moveset(MoveId.SPLASH) .ability(AbilityId.NO_GUARD) .battleStyle("single") + .battleType(BattleType.TRAINER) .disableCrits() .enemyLevel(100) - .enemySpecies(SpeciesId.MAGIKARP) + .enemySpecies(SpeciesId.ARCANINE) .enemyAbility(AbilityId.BALL_FETCH) .enemyMoveset(MoveId.SPLASH); }); @@ -43,7 +43,7 @@ describe("Field - Pokemon ID Checks", () => { return [...new Set(array)]; } - // TODO: We currently generate IDs as a pure random integer; remove once unique UUIDs are added + // TODO: We currently generate IDs as a pure random integer; enable once unique UUIDs are added it.todo("2 Pokemon should not be able to generate with the same ID during 1 encounter", async () => { game.override.battleType(BattleType.TRAINER); // enemy generates 2 mons await game.classicMode.startBattle([SpeciesId.FEEBAS, SpeciesId.ABRA]); @@ -55,9 +55,7 @@ describe("Field - Pokemon ID Checks", () => { }); it("should not prevent item theft with PID of 0", async () => { - game.override - .moveset([MoveId.THIEF, MoveId.SPLASH]) - .enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.APICOT }]); + game.override.enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.APICOT }]); vi.spyOn(allMoves[MoveId.THIEF], "chance", "get").mockReturnValue(100); @@ -75,30 +73,35 @@ describe("Field - Pokemon ID Checks", () => { expect(player.getHeldItems()).toHaveLength(0); // Player uses Thief and steals the opponent's item - game.move.select(MoveId.THIEF); + game.move.use(MoveId.THIEF); await game.toNextTurn(); expect(enemy.getHeldItems()).toHaveLength(0); expect(player.getHeldItems()).toHaveLength(1); }); - it("should not prevent Syrup Bomb triggering if user has PID of 0", async () => { - game.override.moveset(MoveId.SYRUP_BOMB); - await game.classicMode.startBattle([SpeciesId.TREECKO]); + it("should not prevent Destiny Bond from triggering if user has PID of 0", async () => { + await game.classicMode.startBattle([SpeciesId.TREECKO, SpeciesId.AERODACTYL]); - const player = game.scene.getPlayerPokemon()!; + const player = game.field.getPlayerPokemon(); // Override player pokemon PID to be 0 player.id = 0; + expect(player.getTag(BattlerTagType.DESTINY_BOND)).toBeUndefined(); - const enemy = game.scene.getEnemyPokemon()!; - expect(enemy.getTag(BattlerTagType.SYRUP_BOMB)).toBeUndefined(); + game.move.use(MoveId.DESTINY_BOND); + await game.move.forceEnemyMove(MoveId.FLARE_BLITZ); + await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); + await game.phaseInterceptor.to("MoveEndPhase"); - game.move.select(MoveId.SYRUP_BOMB); - await game.toNextTurn(); + const dBondTag = player.getTag(BattlerTagType.DESTINY_BOND)!; + expect(dBondTag).toBeDefined(); + expect(dBondTag.sourceId).toBe(0); - const syrupTag = enemy.getTag(BattlerTagType.SYRUP_BOMB)!; - expect(syrupTag).toBeDefined(); - expect(syrupTag.getSourcePokemon()).toBe(player); - expect(enemy.getStatStage(Stat.SPD)).toBe(-1); + await game.phaseInterceptor.to("MoveEndPhase"); + + const enemy = game.scene.getEnemyPokemon(); + expect(player.isFainted()).toBe(true); + expect(enemy).toBeUndefined(); + expect(player.getTag(BattlerTagType.DESTINY_BOND)).toBeUndefined(); }); }); From b996624b038c603cfa564ad7b915507ab7277fcd Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 7 Jun 2025 12:08:21 -0400 Subject: [PATCH 08/18] Removed `instant` and `ignoreUpdate` parameters from `tryTransferHeldItemModifier`; fixed post-battle loot code to _not_ break type safety --- src/battle-scene.ts | 40 ++++++++++++----------- src/battle.ts | 17 +++------- src/data/abilities/ability.ts | 49 +++++++++++++++++++---------- src/field/pokemon.ts | 3 +- src/phases/select-modifier-phase.ts | 2 -- src/phases/switch-summon-phase.ts | 11 +------ src/ui/battle-info/battle-info.ts | 19 ++++++++--- 7 files changed, 76 insertions(+), 65 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index 7659a9e68bf..a5f94818efd 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -2925,10 +2925,10 @@ export default class BattleScene extends SceneBase { addModifier( modifier: Modifier | null, - ignoreUpdate?: boolean, - playSound?: boolean, - virtual?: boolean, - instant?: boolean, + ignoreUpdate = false, + playSound = false, + virtual = false, + instant = false, cost?: number, ): boolean { // We check against modifier.type to stop a bug related to loading in a pokemon that has a form change item, which prior to some patch @@ -2942,7 +2942,7 @@ export default class BattleScene extends SceneBase { this.validateAchvs(ModifierAchv, modifier); const modifiersToRemove: PersistentModifier[] = []; if (modifier instanceof PersistentModifier) { - if ((modifier as PersistentModifier).add(this.modifiers, !!virtual)) { + if ((modifier as PersistentModifier).add(this.modifiers, virtual)) { if (modifier instanceof PokemonFormChangeItemModifier) { const pokemon = this.getPokemonById(modifier.pokemonId); if (pokemon) { @@ -3015,7 +3015,7 @@ export default class BattleScene extends SceneBase { return success; } - addEnemyModifier(modifier: PersistentModifier, ignoreUpdate?: boolean, instant?: boolean): Promise { + addEnemyModifier(modifier: PersistentModifier, ignoreUpdate = false, instant = false): Promise { return new Promise(resolve => { const modifiersToRemove: PersistentModifier[] = []; if ((modifier as PersistentModifier).add(this.enemyModifiers, false)) { @@ -3046,9 +3046,7 @@ export default class BattleScene extends SceneBase { * @param itemModifier - {@linkcode PokemonHeldItemModifier} to transfer (represents whole stack) * @param target - Recipient {@linkcode Pokemon} recieving items * @param playSound - Whether to play a sound when transferring the item - * @param transferQuantity - How many items of the stack to transfer. Optional, defaults to `1` - * @param instant - ??? (Optional) - * @param ignoreUpdate - ??? (Optional) + * @param transferQuantity - How many items of the stack to transfer. Optional, default `1` * @param itemLost - Whether to treat the item's current holder as losing the item (for now, this simply enables Unburden). Default: `true`. * @returns Whether the transfer was successful */ @@ -3057,8 +3055,6 @@ export default class BattleScene extends SceneBase { target: Pokemon, playSound: boolean, transferQuantity = 1, - instant?: boolean, - ignoreUpdate?: boolean, itemLost = true, ): boolean { const source = itemModifier.getPokemon(); @@ -3106,8 +3102,8 @@ export default class BattleScene extends SceneBase { } // TODO: what does this do and why is it here - if (source.isPlayer() !== target.isPlayer() && !ignoreUpdate) { - this.updateModifiers(source.isPlayer(), instant); + if (source.isPlayer() !== target.isPlayer()) { + this.updateModifiers(source.isPlayer(), false); } // Add however much we took to the recieving pokemon, creating a new modifier if the target lacked one prio @@ -3118,9 +3114,9 @@ export default class BattleScene extends SceneBase { newItemModifier.pokemonId = target.id; newItemModifier.stackCount = countTaken; if (target.isPlayer()) { - this.addModifier(newItemModifier, ignoreUpdate, playSound, false, instant); + this.addModifier(newItemModifier, false, playSound); } else { - this.addEnemyModifier(newItemModifier, ignoreUpdate, instant); + this.addEnemyModifier(newItemModifier); } } @@ -3286,8 +3282,10 @@ export default class BattleScene extends SceneBase { [this.modifierBar, this.enemyModifierBar].map(m => m.setVisible(visible)); } - // TODO: Document this - updateModifiers(player = true, instant?: boolean): void { + /** + * @param instant - Whether to instantly update any changes to party members' HP bars; default `false` + */ + updateModifiers(player = true, instant = false): void { const modifiers = player ? this.modifiers : (this.enemyModifiers as PersistentModifier[]); for (let m = 0; m < modifiers.length; m++) { const modifier = modifiers[m]; @@ -3319,7 +3317,13 @@ export default class BattleScene extends SceneBase { } } - updatePartyForModifiers(party: Pokemon[], instant?: boolean): Promise { + /** + * Update one or more Pokemon's info containers after having recieved modifiers. + * @param party - An array of {@linkcode Pokemon} to update info. + * @param instant - Whether to instantly update any changes to the party's HP bars; default `false` + * @returns A Promise that resolves once all the info containers have been updated. + */ + updatePartyForModifiers(party: Pokemon[], instant = false): Promise { return new Promise(resolve => { Promise.allSettled( party.map(p => { diff --git a/src/battle.ts b/src/battle.ts index 8e63a680c06..920ea647b9c 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -175,18 +175,11 @@ export default class Battle { } addPostBattleLoot(enemyPokemon: EnemyPokemon): void { - this.postBattleLoot.push( - ...globalScene - .findModifiers( - m => m instanceof PokemonHeldItemModifier && m.pokemonId === enemyPokemon.id && m.isTransferable, - false, - ) - .map(i => { - const ret = i as PokemonHeldItemModifier; - //@ts-ignore - this is awful to fix/change - ret.pokemonId = null; - return ret; - }), + this.postBattleLoot.concat( + globalScene.findModifiers( + m => m instanceof PokemonHeldItemModifier && m.pokemonId === enemyPokemon.id && m.isTransferable, + false, + ) as PokemonHeldItemModifier[], ); } diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 8e574f11ed1..e9971c91be7 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -4724,31 +4724,46 @@ export class PostBattleAbAttr extends AbAttr { } export class PostBattleLootAbAttr extends PostBattleAbAttr { - private randItem?: PokemonHeldItemModifier; - - override canApplyPostBattle(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): boolean { - const postBattleLoot = globalScene.currentBattle.postBattleLoot; - if (!simulated && postBattleLoot.length && args[0]) { - this.randItem = randSeedItem(postBattleLoot); - return globalScene.canTransferHeldItemModifier(this.randItem, pokemon, 1); - } - return false; - } + /** The index of the random item to steal. */ + private randItemIndex: number = 0; /** * @param args - `[0]`: boolean for if the battle ended in a victory */ - override applyPostBattle(pokemon: Pokemon, passive: boolean, simulated: boolean, args: any[]): void { + override canApplyPostBattle(pokemon: Pokemon, passive: boolean, simulated: boolean, args: [boolean]): boolean { const postBattleLoot = globalScene.currentBattle.postBattleLoot; - if (!this.randItem) { - this.randItem = randSeedItem(postBattleLoot); + const wasVictory = args[0]; + if (simulated || postBattleLoot.length === 0 || !wasVictory) { + return false; } - if (globalScene.tryTransferHeldItemModifier(this.randItem, pokemon, true, 1, true, undefined, false)) { - postBattleLoot.splice(postBattleLoot.indexOf(this.randItem), 1); - globalScene.queueMessage(i18next.t("abilityTriggers:postBattleLoot", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), itemName: this.randItem.type.name })); + // Pick a random item and check if we are capped. + this.randItemIndex = randSeedInt(postBattleLoot.length); + const item = postBattleLoot[this.randItemIndex] + + // We can't use `canTransferItemModifier` as that assumes the Pokemon in question already exists (which it does not) + const existingItem = globalScene.findModifier( + (m): m is PokemonHeldItemModifier => + m instanceof PokemonHeldItemModifier && m.matchType(item) && m.pokemonId === pokemon.id, + pokemon.isPlayer(), + ) as PokemonHeldItemModifier | undefined; + + return (existingItem?.getCountUnderMax() ?? Number.MAX_SAFE_INTEGER) > 1 + } + + /** + * Attempt to give the previously selected random item to the ability holder at battle end. + * @param args - `[0]`: boolean for if the battle ended in a victory + */ + override applyPostBattle(pokemon: Pokemon, passive: boolean, simulated: boolean, args: [boolean]): void { + const postBattleLoot = globalScene.currentBattle.postBattleLoot; + const item = postBattleLoot[this.randItemIndex] + item.pokemonId = pokemon.id; + + if (globalScene.addModifier(item, false, true)) { + postBattleLoot.splice(this.randItemIndex, 1); + globalScene.queueMessage(i18next.t("abilityTriggers:postBattleLoot", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), itemName: item.type.name })); } - this.randItem = undefined; } } diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 6465acca0ac..9af6f9611b3 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -6005,9 +6005,8 @@ export class PlayerPokemon extends Pokemon { true, ) as PokemonHeldItemModifier[]; for (const modifier of fusedPartyMemberHeldModifiers) { - globalScene.tryTransferHeldItemModifier(modifier, this, false, modifier.getStackCount(), true, true, false); + globalScene.tryTransferHeldItemModifier(modifier, this, false, modifier.getStackCount(), false); } - globalScene.updateModifiers(true, true); globalScene.removePartyMemberModifiers(fusedPartyMemberIndex); globalScene.getPlayerParty().splice(fusedPartyMemberIndex, 1)[0]; const newPartyMemberIndex = globalScene.getPlayerParty().indexOf(this); diff --git a/src/phases/select-modifier-phase.ts b/src/phases/select-modifier-phase.ts index 5f11441333b..89496f66396 100644 --- a/src/phases/select-modifier-phase.ts +++ b/src/phases/select-modifier-phase.ts @@ -162,8 +162,6 @@ export class SelectModifierPhase extends BattlePhase { party[toSlotIndex], true, itemQuantity, - undefined, - undefined, false, ); } else { diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index 6bdbb66be14..aa051f0805a 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -138,7 +138,6 @@ export class SwitchSummonPhase extends SummonPhase { return; } - if (this.switchType === SwitchType.BATON_PASS) { // If switching via baton pass, update opposing tags coming from the prior pokemon (this.player ? globalScene.getEnemyField() : globalScene.getPlayerField()).forEach((enemyPokemon: Pokemon) => @@ -160,15 +159,7 @@ export class SwitchSummonPhase extends SummonPhase { ) as SwitchEffectTransferModifier; if (batonPassModifier) { - globalScene.tryTransferHeldItemModifier( - batonPassModifier, - switchedInPokemon, - false, - undefined, - undefined, - undefined, - false, - ); + globalScene.tryTransferHeldItemModifier(batonPassModifier, switchedInPokemon, false, 1, false); } } } diff --git a/src/ui/battle-info/battle-info.ts b/src/ui/battle-info/battle-info.ts index e67000bb243..f132ae2d8b9 100644 --- a/src/ui/battle-info/battle-info.ts +++ b/src/ui/battle-info/battle-info.ts @@ -538,9 +538,14 @@ export default abstract class BattleInfo extends Phaser.GameObjects.Container { this.updateHpFrame(); } - /** Update the pokemonHp bar */ - protected updatePokemonHp(pokemon: Pokemon, resolve: (r: void | PromiseLike) => void, instant?: boolean): void { - let duration = !instant ? Phaser.Math.Clamp(Math.abs(this.lastHp - pokemon.hp) * 5, 250, 5000) : 0; + /** + * Update a Pokemon's HP bar. + * @param pokemon - The {@linkcode Pokemon} to whom the HP bar belongs. + * @param resolve - A promise to which the HP bar will be chained unto. + * @param instant - Whether to instantly update the pokemon's HP bar; default `false` + */ + protected updatePokemonHp(pokemon: Pokemon, resolve: (r: void | PromiseLike) => void, instant = false): void { + let duration = instant ? 0 : Phaser.Math.Clamp(Math.abs(this.lastHp - pokemon.hp) * 5, 250, 5000); const speed = globalScene.hpBarSpeed; if (speed) { duration = speed >= 3 ? 0 : duration / Math.pow(2, speed); @@ -563,7 +568,13 @@ export default abstract class BattleInfo extends Phaser.GameObjects.Container { //#endregion - async updateInfo(pokemon: Pokemon, instant?: boolean): Promise { + /** + * Update a Pokemon's battle info, HP bar and other effects. + * @param pokemon - The {@linkcode} Pokemon to whom this BattleInfo belongs. + * @param instant - Whether to instantly update any changes to this Pokemon's HP bar; default `false` + * @returns A Promise that resolves once the Pokemon's info has been successfully updated. + */ + async updateInfo(pokemon: Pokemon, instant = false): Promise { let resolve: (r: void | PromiseLike) => void = () => {}; const promise = new Promise(r => (resolve = r)); if (!globalScene) { From 8a9ff2818b521f3b52922f516290f50e7745d6d8 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Sat, 7 Jun 2025 12:21:16 -0400 Subject: [PATCH 09/18] Fixed up tests --- src/battle.ts | 7 ++++--- test/field/pokemon-id-checks.test.ts | 13 +++++++------ 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/battle.ts b/src/battle.ts index 920ea647b9c..ef89fd72910 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -175,11 +175,12 @@ export default class Battle { } addPostBattleLoot(enemyPokemon: EnemyPokemon): void { - this.postBattleLoot.concat( - globalScene.findModifiers( + // Push used instead of concat to avoid extra allocation + this.postBattleLoot.push( + ...(globalScene.findModifiers( m => m instanceof PokemonHeldItemModifier && m.pokemonId === enemyPokemon.id && m.isTransferable, false, - ) as PokemonHeldItemModifier[], + ) as PokemonHeldItemModifier[]), ); } diff --git a/test/field/pokemon-id-checks.test.ts b/test/field/pokemon-id-checks.test.ts index d97b3461aa0..937f304cae8 100644 --- a/test/field/pokemon-id-checks.test.ts +++ b/test/field/pokemon-id-checks.test.ts @@ -11,6 +11,7 @@ import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { BattlerIndex } from "#app/battle"; +import { StealHeldItemChanceAttr } from "#app/data/moves/move"; describe("Field - Pokemon ID Checks", () => { let phaserGame: Phaser.Game; @@ -57,7 +58,8 @@ describe("Field - Pokemon ID Checks", () => { it("should not prevent item theft with PID of 0", async () => { game.override.enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.APICOT }]); - vi.spyOn(allMoves[MoveId.THIEF], "chance", "get").mockReturnValue(100); + // Mock thief's steal chance to be 100% guaranteed + vi.spyOn(allMoves[MoveId.THIEF], "attrs", "get").mockReturnValue([new StealHeldItemChanceAttr(1.0)]); await game.classicMode.startBattle([SpeciesId.TREECKO]); @@ -72,7 +74,6 @@ describe("Field - Pokemon ID Checks", () => { expect(enemy.getHeldItems()).toHaveLength(1); expect(player.getHeldItems()).toHaveLength(0); - // Player uses Thief and steals the opponent's item game.move.use(MoveId.THIEF); await game.toNextTurn(); @@ -84,12 +85,14 @@ describe("Field - Pokemon ID Checks", () => { await game.classicMode.startBattle([SpeciesId.TREECKO, SpeciesId.AERODACTYL]); const player = game.field.getPlayerPokemon(); + const enemy = game.field.getEnemyPokemon(); // Override player pokemon PID to be 0 player.id = 0; expect(player.getTag(BattlerTagType.DESTINY_BOND)).toBeUndefined(); game.move.use(MoveId.DESTINY_BOND); - await game.move.forceEnemyMove(MoveId.FLARE_BLITZ); + game.doSelectPartyPokemon(1); + await game.move.forceEnemyMove(MoveId.FLAME_WHEEL); await game.setTurnOrder([BattlerIndex.PLAYER, BattlerIndex.ENEMY]); await game.phaseInterceptor.to("MoveEndPhase"); @@ -99,9 +102,7 @@ describe("Field - Pokemon ID Checks", () => { await game.phaseInterceptor.to("MoveEndPhase"); - const enemy = game.scene.getEnemyPokemon(); expect(player.isFainted()).toBe(true); - expect(enemy).toBeUndefined(); - expect(player.getTag(BattlerTagType.DESTINY_BOND)).toBeUndefined(); + expect(enemy.isFainted()).toBe(true); }); }); From 69bcbdcaa1d1f1873353ff7f175afba2c79e4054 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 9 Jun 2025 18:29:21 -0400 Subject: [PATCH 10/18] Reverted unneeded changes --- src/battle-scene.ts | 274 +++++++++++++----------------- src/battle.ts | 16 +- src/data/abilities/ability.ts | 106 +++++------- src/field/pokemon.ts | 10 +- src/phases/switch-summon-phase.ts | 10 +- src/ui/battle-info/battle-info.ts | 19 +-- 6 files changed, 190 insertions(+), 245 deletions(-) diff --git a/src/battle-scene.ts b/src/battle-scene.ts index b65effd6fba..b408dad7802 100644 --- a/src/battle-scene.ts +++ b/src/battle-scene.ts @@ -2614,10 +2614,10 @@ export default class BattleScene extends SceneBase { addModifier( modifier: Modifier | null, - ignoreUpdate = false, - playSound = false, - virtual = false, - instant = false, + ignoreUpdate?: boolean, + playSound?: boolean, + virtual?: boolean, + instant?: boolean, cost?: number, ): boolean { // We check against modifier.type to stop a bug related to loading in a pokemon that has a form change item, which prior to some patch @@ -2631,7 +2631,7 @@ export default class BattleScene extends SceneBase { this.validateAchvs(ModifierAchv, modifier); const modifiersToRemove: PersistentModifier[] = []; if (modifier instanceof PersistentModifier) { - if ((modifier as PersistentModifier).add(this.modifiers, virtual)) { + if ((modifier as PersistentModifier).add(this.modifiers, !!virtual)) { if (modifier instanceof PokemonFormChangeItemModifier) { const pokemon = this.getPokemonById(modifier.pokemonId); if (pokemon) { @@ -2704,7 +2704,7 @@ export default class BattleScene extends SceneBase { return success; } - addEnemyModifier(modifier: PersistentModifier, ignoreUpdate = false, instant = false): Promise { + addEnemyModifier(modifier: PersistentModifier, ignoreUpdate?: boolean, instant?: boolean): Promise { return new Promise(resolve => { const modifiersToRemove: PersistentModifier[] = []; if ((modifier as PersistentModifier).add(this.enemyModifiers, false)) { @@ -2732,128 +2732,124 @@ export default class BattleScene extends SceneBase { * If the recepient already has the maximum amount allowed for this item, the transfer is cancelled. * The quantity to transfer is automatically capped at how much the recepient can take before reaching the maximum stack size for the item. * A transfer that moves a quantity smaller than what is specified in the transferQuantity parameter is still considered successful. - * @param itemModifier - {@linkcode PokemonHeldItemModifier} to transfer (represents whole stack) - * @param target - Recipient {@linkcode Pokemon} recieving items - * @param playSound - Whether to play a sound when transferring the item - * @param transferQuantity - How many items of the stack to transfer. Optional, default `1` - * @param itemLost - Whether to treat the item's current holder as losing the item (for now, this simply enables Unburden). Default: `true`. - * @returns Whether the transfer was successful + * @param itemModifier {@linkcode PokemonHeldItemModifier} item to transfer (represents the whole stack) + * @param target {@linkcode Pokemon} recepient in this transfer + * @param playSound `true` to play a sound when transferring the item + * @param transferQuantity How many items of the stack to transfer. Optional, defaults to `1` + * @param instant ??? (Optional) + * @param ignoreUpdate ??? (Optional) + * @param itemLost If `true`, treat the item's current holder as losing the item (for now, this simply enables Unburden). Default is `true`. + * @returns `true` if the transfer was successful */ tryTransferHeldItemModifier( itemModifier: PokemonHeldItemModifier, target: Pokemon, playSound: boolean, transferQuantity = 1, + instant?: boolean, + ignoreUpdate?: boolean, itemLost = true, ): boolean { - const source = itemModifier.getPokemon(); - // Check if source even exists and error if not. - // Almost certainly redundant due to checking inside condition, but better log twice than not at all - if (isNullOrUndefined(source)) { - console.error( - `Pokemon ${target.getNameToRender()} tried to transfer %d items from nonexistent source; item: `, - transferQuantity, - itemModifier, - ); - return false; - } - - // Check for effects that might block us from stealing + const source = itemModifier.pokemonId ? itemModifier.getPokemon() : null; const cancelled = new BooleanHolder(false); - if (source.isPlayer() !== target.isPlayer()) { + + if (source && source.isPlayer() !== target.isPlayer()) { applyAbAttrs(BlockItemTheftAbAttr, source, cancelled); } + if (cancelled.value) { return false; } - // check if we have an item already and calc how much to transfer + const newItemModifier = itemModifier.clone() as PokemonHeldItemModifier; + newItemModifier.pokemonId = target.id; const matchingModifier = this.findModifier( - (m): m is PokemonHeldItemModifier => - m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, + m => m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, target.isPlayer(), - ); - const countTaken = Math.min( - transferQuantity, - itemModifier.stackCount, - matchingModifier?.getCountUnderMax() ?? Number.MAX_SAFE_INTEGER, - ); - if (countTaken <= 0) { - // Can't transfer negative items - return false; - } + ) as PokemonHeldItemModifier; - itemModifier.stackCount -= countTaken; - - // If the old modifier is at 0 stacks, try to remove it - if (itemModifier.stackCount <= 0 && !this.removeModifier(itemModifier, !source.isPlayer())) { - return false; - } - - // TODO: what does this do and why is it here - if (source.isPlayer() !== target.isPlayer()) { - this.updateModifiers(source.isPlayer(), false); - } - - // Add however much we took to the recieving pokemon, creating a new modifier if the target lacked one prio if (matchingModifier) { - matchingModifier.stackCount += countTaken; - } else { - const newItemModifier = itemModifier.clone() as PokemonHeldItemModifier; - newItemModifier.pokemonId = target.id; - newItemModifier.stackCount = countTaken; - if (target.isPlayer()) { - this.addModifier(newItemModifier, false, playSound); - } else { - this.addEnemyModifier(newItemModifier); + const maxStackCount = matchingModifier.getMaxStackCount(); + if (matchingModifier.stackCount >= maxStackCount) { + return false; } + const countTaken = Math.min( + transferQuantity, + itemModifier.stackCount, + maxStackCount - matchingModifier.stackCount, + ); + itemModifier.stackCount -= countTaken; + newItemModifier.stackCount = matchingModifier.stackCount + countTaken; + } else { + const countTaken = Math.min(transferQuantity, itemModifier.stackCount); + itemModifier.stackCount -= countTaken; + newItemModifier.stackCount = countTaken; } - if (itemLost) { - applyPostItemLostAbAttrs(PostItemLostAbAttr, source, false); - } + const removeOld = itemModifier.stackCount === 0; - return true; + if (!removeOld || !source || this.removeModifier(itemModifier, source.isEnemy())) { + const addModifier = () => { + if (!matchingModifier || this.removeModifier(matchingModifier, target.isEnemy())) { + if (target.isPlayer()) { + this.addModifier(newItemModifier, ignoreUpdate, playSound, false, instant); + if (source && itemLost) { + applyPostItemLostAbAttrs(PostItemLostAbAttr, source, false); + } + return true; + } + this.addEnemyModifier(newItemModifier, ignoreUpdate, instant); + if (source && itemLost) { + applyPostItemLostAbAttrs(PostItemLostAbAttr, source, false); + } + return true; + } + return false; + }; + if (source && source.isPlayer() !== target.isPlayer() && !ignoreUpdate) { + this.updateModifiers(source.isPlayer(), instant); + addModifier(); + } else { + addModifier(); + } + return true; + } + return false; } canTransferHeldItemModifier(itemModifier: PokemonHeldItemModifier, target: Pokemon, transferQuantity = 1): boolean { - const source = itemModifier.getPokemon(); - if (isNullOrUndefined(source)) { - console.error( - `Pokemon ${target.getNameToRender()} tried to transfer %d items from nonexistent source; item: `, - transferQuantity, - itemModifier, - ); - return false; - } + const mod = itemModifier.clone() as PokemonHeldItemModifier; + const source = mod.pokemonId ? mod.getPokemon() : null; + const cancelled = new BooleanHolder(false); - // If we somehow lack the item being transferred, skip - if (!this.hasModifier(itemModifier, !source.isPlayer())) { - return false; - } - - // Check enemy theft prevention - // TODO: Verify whether sticky hold procs on friendly fire ally theft - if (source.isPlayer() !== target.isPlayer()) { - const cancelled = new BooleanHolder(false); + if (source && source.isPlayer() !== target.isPlayer()) { applyAbAttrs(BlockItemTheftAbAttr, source, cancelled); - if (cancelled.value) { + } + + if (cancelled.value) { + return false; + } + + const matchingModifier = this.findModifier( + m => m instanceof PokemonHeldItemModifier && m.matchType(mod) && m.pokemonId === target.id, + target.isPlayer(), + ) as PokemonHeldItemModifier; + + if (matchingModifier) { + const maxStackCount = matchingModifier.getMaxStackCount(); + if (matchingModifier.stackCount >= maxStackCount) { return false; } + const countTaken = Math.min(transferQuantity, mod.stackCount, maxStackCount - matchingModifier.stackCount); + mod.stackCount -= countTaken; + } else { + const countTaken = Math.min(transferQuantity, mod.stackCount); + mod.stackCount -= countTaken; } - // Finally, ensure we can actually steal at least 1 item - const matchingModifier = this.findModifier( - (m): m is PokemonHeldItemModifier => - m instanceof PokemonHeldItemModifier && m.matchType(itemModifier) && m.pokemonId === target.id, - target.isPlayer(), - ); - const countTaken = Math.min( - transferQuantity, - itemModifier.stackCount, - matchingModifier?.getCountUnderMax() ?? Number.MAX_SAFE_INTEGER, - ); - return countTaken > 0; + const removeOld = mod.stackCount === 0; + + return !removeOld || !source || this.hasModifier(itemModifier, !source.isPlayer()); } removePartyMemberModifiers(partyMemberIndex: number): Promise { @@ -2971,10 +2967,8 @@ export default class BattleScene extends SceneBase { [this.modifierBar, this.enemyModifierBar].map(m => m.setVisible(visible)); } - /** - * @param instant - Whether to instantly update any changes to party members' HP bars; default `false` - */ - updateModifiers(player = true, instant = false): void { + // TODO: Document this + updateModifiers(player = true, instant?: boolean): void { const modifiers = player ? this.modifiers : (this.enemyModifiers as PersistentModifier[]); for (let m = 0; m < modifiers.length; m++) { const modifier = modifiers[m]; @@ -2991,7 +2985,6 @@ export default class BattleScene extends SceneBase { } } - // Why do we silently delete missing modifiers? const modifiersClone = modifiers.slice(0); for (const modifier of modifiersClone) { if (!modifier.getStackCount()) { @@ -3006,13 +2999,7 @@ export default class BattleScene extends SceneBase { } } - /** - * Update one or more Pokemon's info containers after having recieved modifiers. - * @param party - An array of {@linkcode Pokemon} to update info. - * @param instant - Whether to instantly update any changes to the party's HP bars; default `false` - * @returns A Promise that resolves once all the info containers have been updated. - */ - updatePartyForModifiers(party: Pokemon[], instant = false): Promise { + updatePartyForModifiers(party: Pokemon[], instant?: boolean): Promise { return new Promise(resolve => { Promise.allSettled( party.map(p => { @@ -3023,71 +3010,44 @@ export default class BattleScene extends SceneBase { }); } - /** - * Check whether a given {@linkcode PersistentModifier} exists on a given side of the field. - * @param modifier - The {@linkcode PersistentModifier} to check the existence of. - * @param enemy - Whether to check the enemy (`true`) or player (`false`) party. Default is `false`. - * @returns Whether the specified modifier exists on the given side of the field. - * @remarks This also compares `pokemonId`s to confirm a match (and therefore owners). - */ hasModifier(modifier: PersistentModifier, enemy = false): boolean { - return (!enemy ? this.modifiers : this.enemyModifiers).includes(modifier); + const modifiers = !enemy ? this.modifiers : this.enemyModifiers; + return modifiers.indexOf(modifier) > -1; } /** - * Remove a currently owned item. If the item is stacked, the entire item stack + * Removes a currently owned item. If the item is stacked, the entire item stack * gets removed. This function does NOT apply in-battle effects, such as Unburden. * If in-battle effects are needed, use {@linkcode Pokemon.loseHeldItem} instead. - * @param modifier - The item to be removed. - * @param enemy - Whether to remove from the enemy (`true`) or player (`false`) party; default `false`. - * @returns Whether the item exists and was successfully removed + * @param modifier The item to be removed. + * @param enemy `true` to remove an item owned by the enemy rather than the player; default `false`. + * @returns `true` if the item exists and was successfully removed, `false` otherwise */ removeModifier(modifier: PersistentModifier, enemy = false): boolean { const modifiers = !enemy ? this.modifiers : this.enemyModifiers; const modifierIndex = modifiers.indexOf(modifier); - if (modifierIndex === -1) { - return false; + if (modifierIndex > -1) { + modifiers.splice(modifierIndex, 1); + if (modifier instanceof PokemonFormChangeItemModifier) { + const pokemon = this.getPokemonById(modifier.pokemonId); + if (pokemon) { + modifier.apply(pokemon, false); + } + } + return true; } - modifiers.splice(modifierIndex, 1); - if (modifier instanceof PokemonFormChangeItemModifier) { - const pokemon = this.getPokemonById(modifier.pokemonId); - if (pokemon) { - modifier.apply(pokemon, false); - } - } - return true; + return false; } /** - * Get all modifiers of all {@linkcode Pokemon} in the given party, - * optionally filtering based on `modifierType` if provided. - * @param player Whether to search the player (`true`) or enemy (`false`) party; Defaults to `true` - * @returns An array containing all {@linkcode PersistentModifier}s on the given side of the field. - * @overload + * Get all of the modifiers that match `modifierType` + * @param modifierType The type of modifier to apply; must extend {@linkcode PersistentModifier} + * @param player Whether to search the player (`true`) or the enemy (`false`); Defaults to `true` + * @returns the list of all modifiers that matched `modifierType`. */ - getModifiers(player?: boolean): PersistentModifier[]; - - /** - * Get all modifiers of all {@linkcode Pokemon} in the given party, - * optionally filtering based on `modifierType` if provided. - * @param modifierType The type of modifier to check against; must extend {@linkcode PersistentModifier}. - * If omitted, will return all {@linkcode PersistentModifier}s regardless of type. - * @param player Whether to search the player (`true`) or enemy (`false`) party; Defaults to `true` - * @returns An array containing all modifiers matching `modifierType` on the given side of the field. - * @overload - */ - getModifiers(modifierType: Constructor, player?: boolean): T[]; - - // NOTE: Boolean typing on 1st parameter needed to satisfy "bool only" overload - getModifiers(modifierType?: Constructor | boolean, player?: boolean) { - const usePlayer: boolean = player ?? (typeof modifierType !== "boolean" || modifierType); // non-bool in 1st position = true by default - const mods = usePlayer ? this.modifiers : this.enemyModifiers; - - if (typeof modifierType === "undefined" || typeof modifierType === "boolean") { - return mods; - } - return mods.filter((m): m is T => m instanceof modifierType); + getModifiers(modifierType: Constructor, player = true): T[] { + return (player ? this.modifiers : this.enemyModifiers).filter((m): m is T => m instanceof modifierType); } /** diff --git a/src/battle.ts b/src/battle.ts index 4585634120b..2ebfb634751 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -176,12 +176,18 @@ export default class Battle { } addPostBattleLoot(enemyPokemon: EnemyPokemon): void { - // Push used instead of concat to avoid extra allocation this.postBattleLoot.push( - ...(globalScene.findModifiers( - m => m instanceof PokemonHeldItemModifier && m.pokemonId === enemyPokemon.id && m.isTransferable, - false, - ) as PokemonHeldItemModifier[]), + ...globalScene + .findModifiers( + m => m instanceof PokemonHeldItemModifier && m.pokemonId === enemyPokemon.id && m.isTransferable, + false, + ) + .map(i => { + const ret = i as PokemonHeldItemModifier; + //@ts-ignore - this is awful to fix/change + ret.pokemonId = null; + return ret; + }), ); } diff --git a/src/data/abilities/ability.ts b/src/data/abilities/ability.ts index 8730c5e7a64..900782eaddb 100644 --- a/src/data/abilities/ability.ts +++ b/src/data/abilities/ability.ts @@ -2539,17 +2539,11 @@ export class AllyStatMultiplierAbAttr extends AbAttr { * @extends AbAttr */ export class ExecutedMoveAbAttr extends AbAttr { - canApplyExecutedMove( - _pokemon: Pokemon, - _simulated: boolean, - ): boolean { + canApplyExecutedMove(_pokemon: Pokemon, _simulated: boolean): boolean { return true; } - applyExecutedMove( - _pokemon: Pokemon, - _simulated: boolean, - ): void {} + applyExecutedMove(_pokemon: Pokemon, _simulated: boolean): void {} } /** @@ -2557,7 +2551,7 @@ export class ExecutedMoveAbAttr extends AbAttr { * @extends ExecutedMoveAbAttr */ export class GorillaTacticsAbAttr extends ExecutedMoveAbAttr { - constructor(showAbility: boolean = false) { + constructor(showAbility = false) { super(showAbility); } @@ -2574,7 +2568,7 @@ export class GorillaTacticsAbAttr extends ExecutedMoveAbAttr { export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr { private stealCondition: PokemonAttackCondition | null; - private stolenItem: PokemonHeldItemModifier; + private stolenItem?: PokemonHeldItemModifier; constructor(stealCondition?: PokemonAttackCondition) { super(); @@ -2591,35 +2585,38 @@ export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr { hitResult: HitResult, args: any[], ): boolean { - // Check which items to steal - const heldItems = this.getTargetHeldItems(defender).filter((i) => i.isTransferable); if ( - !super.canApplyPostAttack(pokemon, passive, simulated, defender, move, hitResult, args) || - heldItems.length === 0 || // no items to steal - hitResult >= HitResult.NO_EFFECT || // move was ineffective/protected against - (this.stealCondition && !this.stealCondition(pokemon, defender, move)) // no condition = pass + super.canApplyPostAttack(pokemon, passive, simulated, defender, move, hitResult, args) && + !simulated && + hitResult < HitResult.NO_EFFECT && + (!this.stealCondition || this.stealCondition(pokemon, defender, move)) ) { - return false; + const heldItems = this.getTargetHeldItems(defender).filter(i => i.isTransferable); + if (heldItems.length) { + // Ensure that the stolen item in testing is the same as when the effect is applied + this.stolenItem = heldItems[pokemon.randBattleSeedInt(heldItems.length)]; + if (globalScene.canTransferHeldItemModifier(this.stolenItem, pokemon)) { + return true; + } + } } - - // pick random item and check if we can steal it - this.stolenItem = heldItems[pokemon.randBattleSeedInt(heldItems.length)]; - return globalScene.canTransferHeldItemModifier(this.stolenItem, pokemon) + this.stolenItem = undefined; + return false; } override applyPostAttack( pokemon: Pokemon, _passive: boolean, - simulated: boolean, + _simulated: boolean, defender: Pokemon, _move: Move, _hitResult: HitResult, _args: any[], ): void { - if (simulated) { - return; + const heldItems = this.getTargetHeldItems(defender).filter(i => i.isTransferable); + if (!this.stolenItem) { + this.stolenItem = heldItems[pokemon.randBattleSeedInt(heldItems.length)]; } - if (globalScene.tryTransferHeldItemModifier(this.stolenItem, pokemon, false)) { globalScene.phaseManager.queueMessage( i18next.t("abilityTriggers:postAttackStealHeldItem", { @@ -2629,6 +2626,7 @@ export class PostAttackStealHeldItemAbAttr extends PostAttackAbAttr { }), ); } + this.stolenItem = undefined; } getTargetHeldItems(target: Pokemon): PokemonHeldItemModifier[] { @@ -6217,45 +6215,36 @@ export class PostBattleAbAttr extends AbAttr { } export class PostBattleLootAbAttr extends PostBattleAbAttr { - /** The index of the random item to steal. */ - private randItemIndex = 0; + private randItem?: PokemonHeldItemModifier; + + override canApplyPostBattle(pokemon: Pokemon, _passive: boolean, simulated: boolean, args: any[]): boolean { + const postBattleLoot = globalScene.currentBattle.postBattleLoot; + if (!simulated && postBattleLoot.length && args[0]) { + this.randItem = randSeedItem(postBattleLoot); + return globalScene.canTransferHeldItemModifier(this.randItem, pokemon, 1); + } + return false; + } /** * @param _args - `[0]`: boolean for if the battle ended in a victory */ - override canApplyPostBattle(pokemon: Pokemon, _passive: boolean, simulated: boolean, args: [boolean]): boolean { + override applyPostBattle(pokemon: Pokemon, _passive: boolean, _simulated: boolean, _args: any[]): void { const postBattleLoot = globalScene.currentBattle.postBattleLoot; - const wasVictory = args[0]; - if (simulated || postBattleLoot.length === 0 || !wasVictory) { - return false; + if (!this.randItem) { + this.randItem = randSeedItem(postBattleLoot); } - // Pick a random item and check if we are capped. - this.randItemIndex = randSeedInt(postBattleLoot.length); - const item = postBattleLoot[this.randItemIndex] - - // We can't use `canTransferItemModifier` as that assumes the Pokemon in question already exists (which it does not) - const existingItem = globalScene.findModifier( - (m): m is PokemonHeldItemModifier => - m instanceof PokemonHeldItemModifier && m.matchType(item) && m.pokemonId === pokemon.id, - pokemon.isPlayer(), - ) as PokemonHeldItemModifier | undefined; - - return (existingItem?.getCountUnderMax() ?? Number.MAX_SAFE_INTEGER) > 1 - } - - /** - * Attempt to give the previously selected random item to the ability holder at battle end. - */ - override applyPostBattle(pokemon: Pokemon): void { - const postBattleLoot = globalScene.currentBattle.postBattleLoot; - const item = postBattleLoot[this.randItemIndex] - item.pokemonId = pokemon.id; - - if (globalScene.addModifier(item, false, true)) { - postBattleLoot.splice(this.randItemIndex, 1); - globalScene.phaseManager.queueMessage(i18next.t("abilityTriggers:postBattleLoot", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), itemName: item.type.name })); + if (globalScene.tryTransferHeldItemModifier(this.randItem, pokemon, true, 1, true, undefined, false)) { + postBattleLoot.splice(postBattleLoot.indexOf(this.randItem), 1); + globalScene.phaseManager.queueMessage( + i18next.t("abilityTriggers:postBattleLoot", { + pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), + itemName: this.randItem.type.name, + }), + ); } + this.randItem = undefined; } } @@ -7790,7 +7779,7 @@ export function applyPreAttackAbAttrs( export function applyExecutedMoveAbAttrs( attrType: Constructor, pokemon: Pokemon, - simulated: boolean = false, + simulated = false, ...args: any[] ): void { applyAbAttrsInternal( @@ -8401,8 +8390,7 @@ export function initAbilities() { new Ability(AbilityId.STICKY_HOLD, 3) .attr(BlockItemTheftAbAttr) .bypassFaint() - .ignorable() - .edgeCase(), // may or may not proc incorrectly on user's allies + .ignorable(), new Ability(AbilityId.SHED_SKIN, 3) .conditionalAttr(_pokemon => !randSeedInt(3), PostTurnResetStatusAbAttr), new Ability(AbilityId.GUTS, 3) diff --git a/src/field/pokemon.ts b/src/field/pokemon.ts index 7321c11248d..86372d06c22 100644 --- a/src/field/pokemon.ts +++ b/src/field/pokemon.ts @@ -5131,13 +5131,6 @@ export default abstract class Pokemon extends Phaser.GameObjects.Container { } }); - globalScene.phaseManager.queueMessage( - i18next.t("battleInfo:newKey2", { - // arguments for the locale key go here; - pokemonNameWithAffix: getPokemonNameWithAffix(this), - }), - ); - for (let f = 0; f < 2; f++) { const variantColors = variantColorCache[!f ? spriteKey : backSpriteKey]; const variantColorSet = new Map(); @@ -6006,8 +5999,9 @@ export class PlayerPokemon extends Pokemon { true, ) as PokemonHeldItemModifier[]; for (const modifier of fusedPartyMemberHeldModifiers) { - globalScene.tryTransferHeldItemModifier(modifier, this, false, modifier.getStackCount(), false); + globalScene.tryTransferHeldItemModifier(modifier, this, false, modifier.getStackCount(), true, true, false); } + globalScene.updateModifiers(true, true); globalScene.removePartyMemberModifiers(fusedPartyMemberIndex); globalScene.getPlayerParty().splice(fusedPartyMemberIndex, 1)[0]; const newPartyMemberIndex = globalScene.getPlayerParty().indexOf(this); diff --git a/src/phases/switch-summon-phase.ts b/src/phases/switch-summon-phase.ts index d4777839339..103af3db275 100644 --- a/src/phases/switch-summon-phase.ts +++ b/src/phases/switch-summon-phase.ts @@ -159,7 +159,15 @@ export class SwitchSummonPhase extends SummonPhase { ) as SwitchEffectTransferModifier; if (batonPassModifier) { - globalScene.tryTransferHeldItemModifier(batonPassModifier, switchedInPokemon, false, 1, false); + globalScene.tryTransferHeldItemModifier( + batonPassModifier, + switchedInPokemon, + false, + undefined, + undefined, + undefined, + false, + ); } } } diff --git a/src/ui/battle-info/battle-info.ts b/src/ui/battle-info/battle-info.ts index f132ae2d8b9..e67000bb243 100644 --- a/src/ui/battle-info/battle-info.ts +++ b/src/ui/battle-info/battle-info.ts @@ -538,14 +538,9 @@ export default abstract class BattleInfo extends Phaser.GameObjects.Container { this.updateHpFrame(); } - /** - * Update a Pokemon's HP bar. - * @param pokemon - The {@linkcode Pokemon} to whom the HP bar belongs. - * @param resolve - A promise to which the HP bar will be chained unto. - * @param instant - Whether to instantly update the pokemon's HP bar; default `false` - */ - protected updatePokemonHp(pokemon: Pokemon, resolve: (r: void | PromiseLike) => void, instant = false): void { - let duration = instant ? 0 : Phaser.Math.Clamp(Math.abs(this.lastHp - pokemon.hp) * 5, 250, 5000); + /** Update the pokemonHp bar */ + protected updatePokemonHp(pokemon: Pokemon, resolve: (r: void | PromiseLike) => void, instant?: boolean): void { + let duration = !instant ? Phaser.Math.Clamp(Math.abs(this.lastHp - pokemon.hp) * 5, 250, 5000) : 0; const speed = globalScene.hpBarSpeed; if (speed) { duration = speed >= 3 ? 0 : duration / Math.pow(2, speed); @@ -568,13 +563,7 @@ export default abstract class BattleInfo extends Phaser.GameObjects.Container { //#endregion - /** - * Update a Pokemon's battle info, HP bar and other effects. - * @param pokemon - The {@linkcode} Pokemon to whom this BattleInfo belongs. - * @param instant - Whether to instantly update any changes to this Pokemon's HP bar; default `false` - * @returns A Promise that resolves once the Pokemon's info has been successfully updated. - */ - async updateInfo(pokemon: Pokemon, instant = false): Promise { + async updateInfo(pokemon: Pokemon, instant?: boolean): Promise { let resolve: (r: void | PromiseLike) => void = () => {}; const promise = new Promise(r => (resolve = r)); if (!globalScene) { From 1c2cefea511cb9e9b6b266383647e16345d1428c Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 9 Jun 2025 19:05:48 -0400 Subject: [PATCH 11/18] Removed outdated modifier test --- test/field/pokemon-id-checks.test.ts | 35 +++------------------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/test/field/pokemon-id-checks.test.ts b/test/field/pokemon-id-checks.test.ts index 937f304cae8..87b5b64d6ef 100644 --- a/test/field/pokemon-id-checks.test.ts +++ b/test/field/pokemon-id-checks.test.ts @@ -1,17 +1,13 @@ -import { allMoves } from "#app/data/data-lists"; import type Pokemon from "#app/field/pokemon"; import { MoveId } from "#enums/move-id"; import { AbilityId } from "#enums/ability-id"; import { SpeciesId } from "#enums/species-id"; -import { BerryModifier } from "#app/modifier/modifier"; import { BattleType } from "#enums/battle-type"; import { BattlerTagType } from "#enums/battler-tag-type"; -import { BerryType } from "#enums/berry-type"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { BattlerIndex } from "#app/battle"; -import { StealHeldItemChanceAttr } from "#app/data/moves/move"; describe("Field - Pokemon ID Checks", () => { let phaserGame: Phaser.Game; @@ -55,33 +51,7 @@ describe("Field - Pokemon ID Checks", () => { expect(ids).toHaveLength(uniqueIds.length); }); - it("should not prevent item theft with PID of 0", async () => { - game.override.enemyHeldItems([{ name: "BERRY", count: 1, type: BerryType.APICOT }]); - - // Mock thief's steal chance to be 100% guaranteed - vi.spyOn(allMoves[MoveId.THIEF], "attrs", "get").mockReturnValue([new StealHeldItemChanceAttr(1.0)]); - - await game.classicMode.startBattle([SpeciesId.TREECKO]); - - const player = game.scene.getPlayerPokemon()!; - const enemy = game.scene.getEnemyPokemon()!; - // Override enemy pokemon PID to be 0 - enemy.id = 0; - game.scene.getModifiers(BerryModifier, false).forEach(modifier => { - modifier.pokemonId = enemy.id; - }); - - expect(enemy.getHeldItems()).toHaveLength(1); - expect(player.getHeldItems()).toHaveLength(0); - - game.move.use(MoveId.THIEF); - await game.toNextTurn(); - - expect(enemy.getHeldItems()).toHaveLength(0); - expect(player.getHeldItems()).toHaveLength(1); - }); - - it("should not prevent Destiny Bond from triggering if user has PID of 0", async () => { + it("should not prevent Battler Tags from triggering if user has PID of 0", async () => { await game.classicMode.startBattle([SpeciesId.TREECKO, SpeciesId.AERODACTYL]); const player = game.field.getPlayerPokemon(); @@ -99,6 +69,7 @@ describe("Field - Pokemon ID Checks", () => { const dBondTag = player.getTag(BattlerTagType.DESTINY_BOND)!; expect(dBondTag).toBeDefined(); expect(dBondTag.sourceId).toBe(0); + expect(dBondTag.getSourcePokemon()).toBe(player); await game.phaseInterceptor.to("MoveEndPhase"); From a299a3be53e17ab6bb231c313a186aa5a1e5e718 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Tue, 10 Jun 2025 09:19:46 -0400 Subject: [PATCH 12/18] Fix impory --- test/field/pokemon-id-checks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/field/pokemon-id-checks.test.ts b/test/field/pokemon-id-checks.test.ts index 87b5b64d6ef..32d6b5f08dd 100644 --- a/test/field/pokemon-id-checks.test.ts +++ b/test/field/pokemon-id-checks.test.ts @@ -7,7 +7,7 @@ import { BattlerTagType } from "#enums/battler-tag-type"; import GameManager from "#test/testUtils/gameManager"; import Phaser from "phaser"; import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; -import { BattlerIndex } from "#app/battle"; +import { BattlerIndex } from "#enums/battler-index"; describe("Field - Pokemon ID Checks", () => { let phaserGame: Phaser.Game; From 6d7d9010b7c9df423a92cf81aaa275dfadd265ea Mon Sep 17 00:00:00 2001 From: NightKev <34855794+DayKev@users.noreply.github.com> Date: Sun, 15 Jun 2025 10:20:00 -0700 Subject: [PATCH 13/18] Apply Biome --- src/data/arena-tag.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index ba2b4dd09f0..c5f8278b4d4 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1194,7 +1194,14 @@ class TailwindTag extends ArenaTag { // TODO: Ability displays should be handled by the ability if (pokemon.hasAbility(AbilityId.WIND_RIDER)) { globalScene.phaseManager.queueAbilityDisplay(pokemon, false, true); - globalScene.phaseManager.unshiftNew("StatStageChangePhase", pokemon.getBattlerIndex(), true, [Stat.ATK], 1, true); + globalScene.phaseManager.unshiftNew( + "StatStageChangePhase", + pokemon.getBattlerIndex(), + true, + [Stat.ATK], + 1, + true, + ); globalScene.phaseManager.queueAbilityDisplay(pokemon, false, false); } } From c8308505a73b8baadaf9580d95d3edb143c375d2 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sun, 15 Jun 2025 13:38:29 -0400 Subject: [PATCH 14/18] Update battler-tags.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/battler-tags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 63b56f95a93..95620caa777 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1496,7 +1496,7 @@ export class WrapTag extends DamagingTrapTag { return "ERROR - CHECK CONSOLE AND REPORT"; } - return i18next.t("battlerTags:clampOnTrap", { + return i18next.t("battlerTags:wrapOnTrap", { pokemonNameWithAffix: getPokemonNameWithAffix(pokemon), sourcePokemonName: getPokemonNameWithAffix(source), moveName: this.getMoveName(), From a3da9c590b172cbe17f805c4ba0c35430ee105bd Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sun, 15 Jun 2025 13:38:40 -0400 Subject: [PATCH 15/18] Update battler-tags.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/battler-tags.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/battler-tags.ts b/src/data/battler-tags.ts index 95620caa777..4079a60fa4b 100644 --- a/src/data/battler-tags.ts +++ b/src/data/battler-tags.ts @@ -1492,7 +1492,7 @@ export class WrapTag extends DamagingTrapTag { getTrapMessage(pokemon: Pokemon): string { const source = this.getSourcePokemon(); if (!source) { - console.warn(`Failed to get source Pokemon for ClampTag getTrapMessage; id: ${this.sourceId}`); + console.warn(`Failed to get source Pokemon for WrapTag getTrapMessage; id: ${this.sourceId}`); return "ERROR - CHECK CONSOLE AND REPORT"; } From 124383cb04e80cbd2298268cbc8c09a4a2244d7a Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sun, 15 Jun 2025 13:38:53 -0400 Subject: [PATCH 16/18] Update arena-tag.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/arena-tag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index c5f8278b4d4..98a7f8d6c6e 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1032,7 +1032,7 @@ class StickyWebTag extends ArenaTrapTag { } globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:spikesOnAdd", { + i18next.t("arenaTag:stickyWebOnAdd", { moveName: this.getMoveName(), opponentDesc: source.getOpponentDescriptor(), }), From cf1292eb5b3c263567daa73f2292af2fe68bcf05 Mon Sep 17 00:00:00 2001 From: Bertie690 <136088738+Bertie690@users.noreply.github.com> Date: Sun, 15 Jun 2025 13:38:58 -0400 Subject: [PATCH 17/18] Update arena-tag.ts Co-authored-by: NightKev <34855794+DayKev@users.noreply.github.com> --- src/data/arena-tag.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/data/arena-tag.ts b/src/data/arena-tag.ts index 98a7f8d6c6e..741a68f2c98 100644 --- a/src/data/arena-tag.ts +++ b/src/data/arena-tag.ts @@ -1109,7 +1109,7 @@ export class TrickRoomTag extends ArenaTag { } globalScene.phaseManager.queueMessage( - i18next.t("arenaTag:spikesOnAdd", { + i18next.t("arenaTag:trickRoomOnAdd", { moveName: this.getMoveName(), opponentDesc: source.getOpponentDescriptor(), }), From 6359bef13d4654f1f7f73bcc4c712137a278dd33 Mon Sep 17 00:00:00 2001 From: Bertie690 Date: Mon, 16 Jun 2025 13:22:29 -0400 Subject: [PATCH 18/18] Fixde test --- test/field/pokemon-id-checks.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/field/pokemon-id-checks.test.ts b/test/field/pokemon-id-checks.test.ts index 32d6b5f08dd..4023b8d73ad 100644 --- a/test/field/pokemon-id-checks.test.ts +++ b/test/field/pokemon-id-checks.test.ts @@ -29,7 +29,7 @@ describe("Field - Pokemon ID Checks", () => { .ability(AbilityId.NO_GUARD) .battleStyle("single") .battleType(BattleType.TRAINER) - .disableCrits() + .criticalHits(false) .enemyLevel(100) .enemySpecies(SpeciesId.ARCANINE) .enemyAbility(AbilityId.BALL_FETCH)