From 7d3f732e1fb065345a295ac1b60f895fb5e80b2f Mon Sep 17 00:00:00 2001 From: Harry McKenzie Date: Fri, 13 Mar 2026 21:05:20 +0100 Subject: [PATCH] Support shared abilities in battle tooltips Use sharedAbilities from request data and -start volatiles to show correct stat modifiers, move tags, and ability text for Shared Power formats. Also fixes baseAbility display being clobbered when a shared ability's -start fires first. Co-Authored-By: Claude Opus 4.6 --- .../src/battle-tooltips.ts | 164 ++++++++++++------ play.pokemonshowdown.com/src/battle.ts | 6 +- 2 files changed, 116 insertions(+), 54 deletions(-) diff --git a/play.pokemonshowdown.com/src/battle-tooltips.ts b/play.pokemonshowdown.com/src/battle-tooltips.ts index 4bc1eb5ec..cf90427b0 100644 --- a/play.pokemonshowdown.com/src/battle-tooltips.ts +++ b/play.pokemonshowdown.com/src/battle-tooltips.ts @@ -15,6 +15,10 @@ import { BattleLog } from "./battle-log"; import { Move, BattleNatures } from "./battle-dex-data"; import { BattleTextParser } from "./battle-text-parser"; +function serverPokemonHasSharedAbility(serverPokemon: ServerPokemon | null | undefined, abilityId: ID) { + return !!serverPokemon?.sharedAbilities?.includes(abilityId); +} + export class ModifiableValue { value = 0; maxValue = 0; @@ -33,7 +37,11 @@ export class ModifiableValue { this.serverPokemon = serverPokemon; this.itemName = this.battle.dex.items.get(serverPokemon.item).name; - const ability = serverPokemon.ability || pokemon?.ability || serverPokemon.baseAbility; + const ability = ( + pokemon?.ability && pokemon.ability !== '(suppressed)' ? + pokemon.ability : + (serverPokemon.ability || serverPokemon.baseAbility) + ); this.abilityName = this.battle.dex.abilities.get(ability).name; this.weatherName = this.battle.dex.moves.get(battle.weather).exists ? this.battle.dex.moves.get(battle.weather).name : this.battle.dex.abilities.get(battle.weather).name; @@ -64,7 +72,12 @@ export class ModifiableValue { return true; } tryAbility(abilityName: string) { - if (abilityName !== this.abilityName) return false; + const abilityId = toID(abilityName); + if ( + abilityId !== toID(this.abilityName) && + !this.pokemon?.volatiles[abilityId] && + !serverPokemonHasSharedAbility(this.serverPokemon, abilityId) + ) return false; if (this.pokemon?.volatiles['gastroacid']) { this.comment.push(` (${abilityName} suppressed by Gastro Acid)`); return false; @@ -560,7 +573,16 @@ export class BattleTooltips { } // TODO: move this somewhere it makes more sense if (pokemon.ability === '(suppressed)') serverPokemon.ability = '(suppressed)'; - let ability = toID(serverPokemon.ability || pokemon.ability || serverPokemon.baseAbility); + let ability = this.getPokemonAbilityID(pokemon, serverPokemon); + const hasAbility = (id: string) => { + const abilityId = toID(id); + if ( + ability !== abilityId && + !pokemon.volatiles[abilityId] && + !serverPokemonHasSharedAbility(serverPokemon, abilityId) + ) return false; + return !!pokemon.effectiveAbility(serverPokemon); + }; let item = this.battle.dex.items.get(serverPokemon.item); let value = new ModifiableValue(this.battle, pokemon, serverPokemon); @@ -761,16 +783,16 @@ export class BattleTooltips { if (move.flags.powder && this.battle.gen > 5) { text += `

✓ Powder (doesn't affect Grass, Overcoat, Safety Goggles)

`; } - if (move.flags.punch && ability === 'ironfist') { + if (move.flags.punch && hasAbility('ironfist')) { text += `

✓ Fist (boosted by Iron Fist)

`; } - if (move.flags.pulse && ability === 'megalauncher') { + if (move.flags.pulse && hasAbility('megalauncher')) { text += `

✓ Pulse (boosted by Mega Launcher)

`; } - if (move.flags.bite && ability === 'strongjaw') { + if (move.flags.bite && hasAbility('strongjaw')) { text += `

✓ Bite (boosted by Strong Jaw)

`; } - if ((move.recoil || move.hasCrashDamage) && ability === 'reckless') { + if ((move.recoil || move.hasCrashDamage) && hasAbility('reckless')) { text += `

✓ Recoil (boosted by Reckless)

`; } if (move.flags.bullet) { @@ -1059,13 +1081,20 @@ export class BattleTooltips { } if (statStagesOnly) return stats; - const ability = toID( - clientPokemon?.effectiveAbility(serverPokemon) ?? (serverPokemon.ability || serverPokemon.baseAbility) - ); + const ability = this.getPokemonAbilityID(clientPokemon, serverPokemon); + const hasAbility = (id: string) => { + const abilityId = toID(id); + if ( + ability !== abilityId && + !clientPokemon?.volatiles[abilityId] && + !serverPokemonHasSharedAbility(serverPokemon, abilityId) + ) return false; + return !clientPokemon || !!clientPokemon.effectiveAbility(serverPokemon); + }; // check for burn, paralysis, guts, quick feet if (pokemon.status) { - if (this.battle.gen > 2 && ability === 'guts') { + if (this.battle.gen > 2 && hasAbility('guts')) { stats.atk = Math.floor(stats.atk * 1.5); } else if (this.battle.gen < 2 && pokemon.status === 'brn') { stats.atk = Math.floor(stats.atk * 0.5); @@ -1090,7 +1119,7 @@ export class BattleTooltips { 'machobrace', 'poweranklet', 'powerband', 'powerbelt', 'powerbracer', 'powerlens', 'powerweight', ]; if ( - (ability === 'klutz' && !speedHalvingEVItems.includes(item)) || + (hasAbility('klutz') && !speedHalvingEVItems.includes(item)) || this.battle.hasPseudoWeather('Magic Room') || clientPokemon?.volatiles['embargo'] ) { @@ -1145,10 +1174,10 @@ export class BattleTooltips { if (item === 'choiceband' && !clientPokemon?.volatiles['dynamax']) { stats.atk = Math.floor(stats.atk * 1.5); } - if (ability === 'purepower' || ability === 'hugepower') { + if (hasAbility('purepower') || hasAbility('hugepower')) { stats.atk *= 2; } - if (ability === 'hustle' || (ability === 'gorillatactics' && !clientPokemon?.volatiles['dynamax'])) { + if (hasAbility('hustle') || (hasAbility('gorillatactics') && !clientPokemon?.volatiles['dynamax'])) { stats.atk = Math.floor(stats.atk * 1.5); } if (weather) { @@ -1158,21 +1187,21 @@ export class BattleTooltips { if (this.pokemonHasType(pokemon, 'Ice') && weather === 'snowscape') { stats.def = Math.floor(stats.def * 1.5); } - if (ability === 'sandrush' && weather === 'sandstorm') { + if (hasAbility('sandrush') && weather === 'sandstorm') { speedModifiers.push(2); } - if (ability === 'slushrush' && (weather === 'hail' || weather === 'snowscape')) { + if (hasAbility('slushrush') && (weather === 'hail' || weather === 'snowscape')) { speedModifiers.push(2); } if (item !== 'utilityumbrella') { if (weather === 'sunnyday' || weather === 'desolateland') { - if (ability === 'chlorophyll') { + if (hasAbility('chlorophyll')) { speedModifiers.push(2); } - if (ability === 'solarpower') { + if (hasAbility('solarpower')) { stats.spa = Math.floor(stats.spa * 1.5); } - if (ability === 'orichalcumpulse') { + if (hasAbility('orichalcumpulse')) { stats.atk = Math.floor(stats.atk * 1.3333); } let allyActive = clientPokemon?.side.active; @@ -1188,13 +1217,13 @@ export class BattleTooltips { } } if (weather === 'raindance' || weather === 'primordialsea') { - if (ability === 'swiftswim') { + if (hasAbility('swiftswim')) { speedModifiers.push(2); } } } } - if (ability === 'defeatist' && serverPokemon.hp <= serverPokemon.maxhp / 2) { + if (hasAbility('defeatist') && serverPokemon.hp <= serverPokemon.maxhp / 2) { stats.atk = Math.floor(stats.atk * 0.5); stats.spa = Math.floor(stats.spa * 0.5); } @@ -1203,7 +1232,7 @@ export class BattleTooltips { stats.atk = Math.floor(stats.atk * 0.5); speedModifiers.push(0.5); } - if (ability === 'unburden' && clientPokemon.volatiles['itemremoved'] && !item) { + if (hasAbility('unburden') && clientPokemon.volatiles['itemremoved'] && !item) { speedModifiers.push(2); } for (const statName of Dex.statNamesExceptHP) { @@ -1217,10 +1246,10 @@ export class BattleTooltips { } } if (pokemon.status) { - if (ability === 'marvelscale') { + if (hasAbility('marvelscale')) { stats.def = Math.floor(stats.def * 1.5); } - if (ability === 'quickfeet') { + if (hasAbility('quickfeet')) { speedModifiers.push(1.5); } } @@ -1228,14 +1257,14 @@ export class BattleTooltips { stats.def = Math.floor(stats.def * 1.5); stats.spd = Math.floor(stats.spd * 1.5); } - if (ability === 'grasspelt' && this.battle.hasPseudoWeather('Grassy Terrain')) { + if (hasAbility('grasspelt') && this.battle.hasPseudoWeather('Grassy Terrain')) { stats.def = Math.floor(stats.def * 1.5); } if (this.battle.hasPseudoWeather('Electric Terrain')) { - if (ability === 'surgesurfer') { + if (hasAbility('surgesurfer')) { speedModifiers.push(2); } - if (ability === 'hadronengine') { + if (hasAbility('hadronengine')) { stats.spa = Math.floor(stats.spa * 1.3333); } } @@ -1249,10 +1278,10 @@ export class BattleTooltips { stats.spa = Math.floor(stats.spa * 1.5); stats.spd = Math.floor(stats.spd * 1.5); } - if (clientPokemon && (ability === 'plus' || ability === 'minus')) { + if (clientPokemon && (hasAbility('plus') || hasAbility('minus'))) { let allyActive = clientPokemon.side.active; if (allyActive.length > 1) { - let abilityName = (ability === 'plus' ? 'Plus' : 'Minus'); + let abilityName = hasAbility('plus') ? 'Plus' : 'Minus'; for (const ally of allyActive) { if (!ally || ally === clientPokemon || ally.fainted) continue; let allyAbility = this.getAllyAbility(ally); @@ -1275,26 +1304,26 @@ export class BattleTooltips { if (item === 'ironball' || speedHalvingEVItems.includes(item)) { speedModifiers.push(0.5); } - if (ability === 'furcoat') { + if (hasAbility('furcoat')) { stats.def *= 2; } if (this.battle.abilityActive('Vessel of Ruin')) { - if (ability !== 'vesselofruin') { + if (!hasAbility('vesselofruin')) { stats.spa = Math.floor(stats.spa * 0.75); } } if (this.battle.abilityActive('Sword of Ruin')) { - if (ability !== 'swordofruin') { + if (!hasAbility('swordofruin')) { stats.def = Math.floor(stats.def * 0.75); } } if (this.battle.abilityActive('Tablets of Ruin')) { - if (ability !== 'tabletsofruin') { + if (!hasAbility('tabletsofruin')) { stats.atk = Math.floor(stats.atk * 0.75); } } if (this.battle.abilityActive('Beads of Ruin')) { - if (ability !== 'beadsofruin') { + if (!hasAbility('beadsofruin')) { stats.spd = Math.floor(stats.spd * 0.75); } } @@ -1352,17 +1381,17 @@ export class BattleTooltips { stats.spd = Math.floor(stats.spd * 1.5); } if (this.battle.abilityActive('quagofruin')) { - if (ability !== 'quagofruin') { + if (!hasAbility('quagofruin')) { stats.def = Math.floor(stats.def * 0.85); } } if (this.battle.abilityActive('clodofruin')) { - if (ability !== 'clodofruin') { + if (!hasAbility('clodofruin')) { stats.atk = Math.floor(stats.atk * 0.85); } } if (this.battle.abilityActive('blitzofruin')) { - if (ability !== 'blitzofruin') { + if (!hasAbility('blitzofruin')) { speedModifiers.push(0.75); } } @@ -1404,7 +1433,7 @@ export class BattleTooltips { stats.spe *= chainedSpeedModifier; stats.spe = stats.spe % 1 > 0.5 ? Math.ceil(stats.spe) : Math.floor(stats.spe); - if (pokemon.status === 'par' && ability !== 'quickfeet') { + if (pokemon.status === 'par' && !hasAbility('quickfeet')) { if (this.battle.gen > 6) { stats.spe = Math.floor(stats.spe * 0.5); } else { @@ -1805,8 +1834,7 @@ export class BattleTooltips { for (const active of pokemon.side.active) { if (!active || active.fainted) continue; - const ability = this.getAllyAbility(active); - if (ability === 'Victory Star') { + if (this.pokemonHasAbility(active, 'Victory Star')) { accuracyModifiers.push(4506); value.modify(1.1, "Victory Star"); } @@ -2177,18 +2205,17 @@ export class BattleTooltips { let auraBroken = false; for (const ally of pokemon.side.active) { if (!ally || ally.fainted) continue; - let allyAbility = this.getAllyAbility(ally); - if (moveType === 'Fairy' && allyAbility === 'Fairy Aura') { + if (moveType === 'Fairy' && this.pokemonHasAbility(ally, 'Fairy Aura')) { auraBoosted = 'Fairy Aura'; - } else if (moveType === 'Dark' && allyAbility === 'Dark Aura') { + } else if (moveType === 'Dark' && this.pokemonHasAbility(ally, 'Dark Aura')) { auraBoosted = 'Dark Aura'; - } else if (allyAbility === 'Aura Break') { + } else if (this.pokemonHasAbility(ally, 'Aura Break')) { auraBroken = true; - } else if (allyAbility === 'Battery' && ally !== pokemon && move.category === 'Special') { + } else if (this.pokemonHasAbility(ally, 'Battery') && ally !== pokemon && move.category === 'Special') { value.modify(1.3, 'Battery'); - } else if (allyAbility === 'Power Spot' && ally !== pokemon) { + } else if (this.pokemonHasAbility(ally, 'Power Spot') && ally !== pokemon) { value.modify(1.3, 'Power Spot'); - } else if (allyAbility === 'Steely Spirit' && moveType === 'Steel') { + } else if (this.pokemonHasAbility(ally, 'Steely Spirit') && moveType === 'Steel') { value.modify(1.5, 'Steely Spirit'); } } @@ -2483,14 +2510,31 @@ export class BattleTooltips { } return false; } - getAllyAbility(ally: Pokemon) { + getMyServerPokemon(pokemon: Pokemon): ServerPokemon | undefined { let serverPokemon; if (this.battle.myAllyPokemon) { - serverPokemon = this.battle.myAllyPokemon[ally.slot]; + serverPokemon = this.battle.myAllyPokemon[pokemon.slot]; } else if (this.battle.myPokemon) { - serverPokemon = this.battle.myPokemon[ally.slot]; + serverPokemon = this.battle.myPokemon[pokemon.slot]; } - return ally.effectiveAbility(serverPokemon); + return serverPokemon; + } + getAllyAbility(ally: Pokemon) { + return ally.effectiveAbility(this.getMyServerPokemon(ally)); + } + getPokemonAbilityID(pokemon: Pokemon | null | undefined, serverPokemon: ServerPokemon | null | undefined) { + if (pokemon?.ability && pokemon.ability !== '(suppressed)') return toID(pokemon.ability); + return toID(serverPokemon?.ability || serverPokemon?.baseAbility); + } + pokemonHasAbility(pokemon: Pokemon, abilityName: string) { + const serverPokemon = this.getMyServerPokemon(pokemon); + if (!pokemon.effectiveAbility(serverPokemon)) return false; + const abilityId = toID(abilityName); + return ( + pokemon.volatiles[abilityId] || + serverPokemonHasSharedAbility(serverPokemon, abilityId) || + this.getPokemonAbilityID(pokemon, serverPokemon) === abilityId + ); } getPokemonAbilityData(clientPokemon: Pokemon | null, serverPokemon: ServerPokemon | null | undefined) { const abilityData: { ability: string, baseAbility: string, possibilities: string[] } = { @@ -2521,9 +2565,14 @@ export class BattleTooltips { } if (serverPokemon) { if (!abilityData.ability) abilityData.ability = serverPokemon.ability || serverPokemon.baseAbility; - if (!abilityData.baseAbility && serverPokemon.baseAbility) { + if (serverPokemon.baseAbility) { abilityData.baseAbility = serverPokemon.baseAbility; } + // In Shared Power, -start messages for shared abilities can clobber + // clientPokemon.ability. Use server data as authoritative in that case. + if (serverPokemon.sharedAbilities?.includes(toID(abilityData.ability) as ID)) { + abilityData.ability = serverPokemon.ability || serverPokemon.baseAbility; + } } return abilityData; } @@ -2547,6 +2596,16 @@ export class BattleTooltips { if (baseAbilityName && baseAbilityName !== abilityName) text += ' (base: ' + baseAbilityName + ')'; } } + // Shared Power: show shared abilities from teammates + if (serverPokemon?.sharedAbilities?.length) { + const sharedNames = serverPokemon.sharedAbilities + .map(id => this.battle.dex.abilities.get(id).name) + .filter(name => !!name); + if (sharedNames.length) { + if (text) text += '
'; + text += 'Shared: ' + sharedNames.join(', '); + } + } const tier = this.battle.tier; if (!text && abilityData.possibilities.length && !hidePossible && !(tier.includes('Almost Any Ability') || tier.includes('Hackmons') || @@ -3310,6 +3369,7 @@ declare const require: any; declare const global: any; if (typeof require === 'function') { // in Node + global.BattleTooltips = BattleTooltips; global.BattleStatGuesser = BattleStatGuesser; global.BattleStatOptimizer = BattleStatOptimizer; } diff --git a/play.pokemonshowdown.com/src/battle.ts b/play.pokemonshowdown.com/src/battle.ts index 8d1075f2c..232f8280f 100644 --- a/play.pokemonshowdown.com/src/battle.ts +++ b/play.pokemonshowdown.com/src/battle.ts @@ -508,14 +508,14 @@ export class Pokemon implements PokemonDetails, PokemonHealth { let item = toID(serverPokemon ? serverPokemon.item : this.item); let ability = toID(this.effectiveAbility(serverPokemon)); - if (battle.hasPseudoWeather('Magic Room') || this.volatiles['embargo'] || ability === 'klutz') { + if (battle.hasPseudoWeather('Magic Room') || this.volatiles['embargo'] || ability === 'klutz' || this.volatiles['klutz']) { item = '' as ID; } if (item === 'ironball') { return true; } - if (ability === 'levitate') { + if (ability === 'levitate' || this.volatiles['levitate']) { return false; } if (this.volatiles['magnetrise'] || this.volatiles['telekinesis']) { @@ -1013,6 +1013,8 @@ export interface ServerPokemon extends PokemonDetails, PokemonHealth { baseAbility: string; /** currently an ID, will revise to name */ ability?: string; + /** currently an array of IDs for additional Shared Power-style abilities */ + sharedAbilities?: ID[]; /** currently an ID, will revise to name */ item: string; /** currently an ID, will revise to name */