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