// ==UserScript== // @name Pokémon Showdown Teambuilder QOL // @author jl // @namespace https://github.com/Jake18236/showdown-teambuilder-mod // @version 19.2.0 // @description Adds Teambuilder and damage calculator support for some OMs // @match https://play.pokemonshowdown.com/* // @match https://smogtours.psim.us/* // @match https://calc.pokemonshowdown.com/* // @grant none // @run-at document-start // @updateURL https://github.com/Jake18236/Showdown-Teambuilder-Mod/releases/latest/download/showdown-teambuilder.user.js // @downloadURL https://github.com/Jake18236/Showdown-Teambuilder-Mod/releases/latest/download/showdown-teambuilder.user.js // ==/UserScript== // comments by claude bc documentation is too much work :C // // FILE LAYOUT // Core: constants and mod metadata, room / patch helpers, server banlists, // stat and type transforms, search providers and filters, teambuilder // patches, battle patches, DOM decorators, and boot. // Features: self-challenge, custom format import, usage stats, and calculator OMs. (function () { 'use strict'; const LOG = '[Teambuilder QOL]'; const IS_DAMAGE_CALC = location.hostname === 'calc.pokemonshowdown.com'; // ==================================================================== // 1. CONSTANTS & UTILITIES // ==================================================================== const STATS = ['hp', 'atk', 'def', 'spa', 'spd', 'spe']; const BOOSTABLE_STATS = STATS.slice(1); // everything but hp const sumStats = (s) => STATS.reduce((n, st) => n + s[st], 0); const copy = (obj) => Object.assign({}, obj); const clamp255 = (n) => Math.max(1, Math.min(255, n)); const clampBaseStats = (stats) => { if (!stats) return stats; const bounded = copy(stats); for (const stat of STATS) bounded[stat] = clamp255(bounded[stat]); return bounded; }; const listOf = (s) => s.split(/\s+/).filter(Boolean); const setOf = (s) => new Set(listOf(s)); const capitalize = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1).toLowerCase(); const toSearchId = (text) => String(text || '').toLowerCase().replace(/[^a-z0-9]+/g, ''); const dropEmptyHeaders = (rows) => rows.filter((row, i) => row[0] !== 'header' || (rows[i + 1] && rows[i + 1][0] !== 'header')); const MOD = { TIER_SHIFT: 'tierShift', MIX_AND_MEGA: 'mixAndMega', BAD_N_BOOSTED: 'badNBoosted', GODLY_GIFT: 'godlyGift', CROSS_EVOLUTION: 'crossEvolution', SCALEMONS: 'scalemons', FRANTIC_FUSIONS: 'franticFusions', INHERITANCE: 'inheritance', REEVOLUTION: 'reevolution', FLIPPED: 'flipped', THREE_FIFTY_CUP: 'threeFiftyCup', NATURE_SWAP: 'natureSwap', CAMOMONS: 'camomons', BONUS_TYPE: 'bonusType', FORTEMONS: 'fortemons', SKETCHMONS: 'sketchmons', TYPEMONS: 'typemons', STABMONS: 'stabmons', // Movepool / ability-list mods (no stat changes of their own) CONVERGENCE: 'convergence', ALPHABET_CUP: 'alphabetCup', ALL_MOVES: 'allMoves', // Camove Chaos: every move is legal ALMOST_ANY_ABILITY: 'almostAnyAbility', }; // Format matching is keyword based, so prefixes/suffixes such as // "nationaldex" (gen9nationaldexmixandmega) don't break detection. const fmtId = (f) => String(f || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const fmtHas = (f, ...keys) => { const id = fmtId(f); return keys.some((k) => id.includes(k)); }; const stripGen = (id) => String(id || '').replace(/^gen\d+/, ''); // Complete challenge-rule catalog supplied with this version. Presence here means // we can parse/name the rule; simulator-only hooks continue to run server-side. const SHOWDOWN_RULE_NAMES = Object.freeze([ "Standard AG", "Standard", "Standard NEXT", "Flat Rules", "Limit Two Restricted", "Limit One Restricted", "Standard Doubles", "Standard OMs", "Standard NatDex", "NatDex Mod", "Standard Draft", "Obtainable", "Obtainable Moves", "Obtainable Abilities", "Obtainable Formes", "Obtainable Misc", "Hoenn Pokedex", "Sinnoh Pokedex", "Old Unova Pokedex", "New Unova Pokedex", "Kalos Pokedex", "Old Alola Pokedex", "New Alola Pokedex", "Galar Pokedex", "Isle of Armor Pokedex", "Crown Tundra Pokedex", "Galar Expansion Pokedex", "Paldea Pokedex", "Kitakami Pokedex", "Blueberry Pokedex", "PotD", "Team Preview", "Tera Type Preview", "One vs One", "Two vs Two", "Little Cup", "DC Timer", "DC Timer Bank", "Timeout Auto Choose", "Timer Accelerate", "Blitz", "VGC Timer", "Species Clause", "Nickname Clause", "Beat Up Nicknames Mod", "OHKO Clause", "Evasion Clause", "Evasion Abilities Clause", "Evasion Items Clause", "Evasion Moves Clause", "Accuracy Moves Clause", "Accuracy Trap Clause", "Sleep Moves Clause", "Gravity Sleep Clause", "Endless Battle Clause", "DryPass Clause", "Baton Pass Clause", "One Baton Pass Clause", "One Boost Passer Clause", "Baton Pass Stat Clause", "Speed Pass Clause", "Baton Pass Trap Clause", "Baton Pass Stat Trap Clause", "CFZ Clause", "Z-Move Clause", "Not Fully Evolved", "HP Percentage Mod", "Exact HP Mod", "Cancel Mod", "Sleep Clause Mod", "Stadium Sleep Clause", "Switch Priority Clause Mod", "Desync Clause Mod", "Deoxys Camouflage Clause Mod", "Freeze Clause Mod", "No Freeze Mod", "Same Type Clause", "Enforce Same Tera Type", "Same Color Clause", "Same Letter Clause", "Mega Rayquaza Clause", "Dynamax Clause", "Terastal Clause", "Full Arceus Clause", "Inverse Mod", "OM Unobtainable Moves", "STABmons Move Legality", "Alphabet Cup Move Legality", "Sketchmons Move Legality", "Camomons Mod", "Allow Tradeback", "LGPE Normal Rules", "NFE Clause", "Gems Clause", "Sketch Post-Gen 7 Moves", "Mimic Glitch Clause", "Pomeg Glitch Clause", "Overflow Stat Mod", "Forme Clause", "350 Cup Mod", "Flipped Mod", "Scalemons Mod", "Team Type Preview", "Open Team Sheets", "Force Open Team Sheets", "AAA Restricted Abilities", "Event Moves Clause", "Stadium Items Clause", "NC 2000 Move Legality", "APT Clause", "NC 1997 Move Legality", "Stadium Poke Cup Rentals", "No Switching", "Guaranteed Secondary Mod", "Chimera 1v1 Rule", "Bonus Type Mod", "First Blood Rule", "Tier Shift Mod", "Revelationmons Mod", "Re-Evolution Mod", "Broken Record Mod", "Force of the Fallen Mod", "Category Swap Mod", "Godly Gift Mod", "VoltTurn Mayhem Mod", "Convergence Legality", "Hackmons Forme Legality", "Species Reveal Clause", "Frantic Fusions Mod", "Protean Palace Mod", "Illusion Level Mod", "Fervent Impersonation Mod", "Twisted Dimension Mod", "Mix and Mega Old Aggronite", "Bad 'n Boosted Mod", "Data Preview", "Rebalance Levels" ]); const SHOWDOWN_VALUE_RULE_NAMES = Object.freeze([ "Force Monotype", "Force Monocolor", "Force Tera Type", "Force Select", "EV Limits", "Timer Starting", "Timer Grace", "Timer Add Per Turn", "Timer Max Per Turn", "Timer Max First Turn", "Item Clause", "Ability Clause", "Min Source Gen", "Picked Team Size", "Min Team Size", "EV Limit", "Max Team Size", "Max Move Count", "Max Total Level", "Min Level", "Max Level", "Default Level", "Adjust Level", "Adjust Level Down", "Best Of", "Allowed Pokemoves", "Unique Pokemoves" ]); const RULE_CATALOG = new Map([...SHOWDOWN_RULE_NAMES, ...SHOWDOWN_VALUE_RULE_NAMES] .map((name) => [toSearchId(name), {name, valueRule: SHOWDOWN_VALUE_RULE_NAMES.includes(name)}])); const formatGeneration = (format, fallback = null) => { const base = String(format || '').split('@@@')[0].trim(); const match = /^(?:\[\s*gen\s*(\d+)\s*\]|gen\s*(\d+)(?=[^0-9]|$))/i.exec(base); return match ? Number(match[1] || match[2]) : fallback; }; // Return structured tokens: never confuse a removal (!Rule), replacement // (!!Value Rule = x), ban (-X), or unban (+X) with an added game mod. function parseChallengeRule(token) { const raw = String(token || '').trim(); if (!raw) return null; const match = /^(!!|!|\+|-|\*)\s*(.*)$/.exec(raw); const sign = match ? match[1] : ''; const text = (match ? match[2] : raw).trim(); const eq = /^([^=]+?)(?:\s*=\s*(.*))?$/.exec(text); const name = eq?.[1]?.trim() || text; const value = eq?.[2] === undefined ? null : eq[2].trim(); const id = toSearchId(name); const known = RULE_CATALOG.get(id) || null; const fromFormat = window.BattleFormats?.[id] || null; const kind = sign === '-' ? 'ban' : sign === '+' ? 'unban' : sign === '*' ? 'restrict' : sign === '!' ? 'remove' : sign === '!!' ? 'replace' : value !== null ? 'value' : 'rule'; return {raw, sign, kind, name: known?.name || name, id, value, known: !!known || !!fromFormat || !!MOD_DEFINITIONS.find((d) => d.challengeRule && toSearchId(d.challengeRule) === id), valueRule: !!known?.valueRule}; } function formatRuleTokens(format) { return String(format || '').split('@@@').slice(1).join('@@@') .split(',').map(parseChallengeRule).filter(Boolean); } // Effective *explicit* custom rules in input order (base-format inherited // clauses are handled by the server). Last explicit action wins. function explicitRuleState(format) { const active = new Map(); const removed = new Set(); const operations = formatRuleTokens(format); for (const op of operations) { if (op.kind === 'remove') {active.delete(op.id); removed.add(op.id);} else if (op.kind === 'rule' || op.kind === 'value' || op.kind === 'replace') { active.set(op.id, op); removed.delete(op.id); } } return {active, removed, operations}; } // Order matters: first match wins. const isNationalDexFormat = (format) => { const base = String(format || '').split('@@@')[0]; let enabled = fmtHas(base, 'nationaldex', 'natdex'); for (const op of formatRuleTokens(format)) { if (op.id === 'natdexmod' || op.id === 'standardnatdex') { if (op.kind === 'remove') enabled = false; else if (['rule', 'replace'].includes(op.kind)) enabled = true; } } return enabled; }; const isNationalDexMixAndMegaFormat = (format) => isNationalDexFormat(format) && fmtHas(format, 'mixandmega'); const isStandardMixAndMegaFormat = (format) => fmtHas(format, 'mixandmega') && !isNationalDexFormat(format); // `modifySpeciesPriority` mirrors Showdown's onModifySpeciesPriority. Within a priority // bracket, modifiers retain their order in the challenge format. const MOD_DEFINITIONS = [ { id: MOD.TIER_SHIFT, formatKeywords: ['tiershift'], label: 'Tier Shift', challengeRule: 'tiershiftmod', }, { id: MOD.MIX_AND_MEGA, formatKeywords: ['mixandmega'], label: 'Mix and Mega', challengeRule: 'mixandmegamod', }, { id: MOD.BAD_N_BOOSTED, formatKeywords: ['badnboosted', 'staaabnb'], label: 'Bad ’n Boosted', challengeRule: 'badnboostedmod', modifySpeciesPriority: 2, calcOm: 'badnboosted', }, {id: MOD.GODLY_GIFT, formatKeywords: ['godlygift'], label: 'Godly Gift', challengeRule: 'godlygiftmod', modifySpeciesPriority: 3}, { id: MOD.CROSS_EVOLUTION, formatKeywords: ['crossevolution'], label: 'Cross Evolution', challengeRule: 'crossevolutionmod', exclusiveGroup: 'fusion', }, { id: MOD.SCALEMONS, formatKeywords: ['aaaubers', 'scalemons'], label: 'Scalemons', challengeRule: 'scalemonsmod', modifySpeciesPriority: 1, calcOm: 'scalemons', }, { id: MOD.FRANTIC_FUSIONS, formatKeywords: ['franticfusions'], label: 'Frantic Fusions', challengeRule: 'franticfusionsmod', exclusiveGroup: 'fusion', }, { id: MOD.INHERITANCE, formatKeywords: ['inheritance'], label: 'Inheritance', challengeRule: 'inheritancemod', exclusiveGroup: 'fusion', }, { id: MOD.REEVOLUTION, formatKeywords: ['reevolution', 'reevolutionmod'], label: 'Re-Evolution', challengeRule: 'reevolutionmod', }, { id: MOD.FLIPPED, formatKeywords: ['flipped'], label: 'Flipped', challengeRule: 'flippedmod', modifySpeciesPriority: 2, calcOm: 'flipped', }, { id: MOD.THREE_FIFTY_CUP, formatKeywords: ['350cup'], label: '350 Cup', challengeRule: '350cupmod', modifySpeciesPriority: 2, calcOm: '350cup', }, { id: MOD.NATURE_SWAP, formatKeywords: ['natureswap'], label: 'Nature Swap', challengeRule: 'natureswapmod', }, { id: MOD.CAMOMONS, formatKeywords: ['camomons', 'camove'], label: 'Camomons', challengeRule: 'camomonsmod', modifySpeciesPriority: 2, }, { id: MOD.FORTEMONS, formatKeywords: ['fortemons'], label: 'Fortemons', challengeRule: 'fortemonsmod', }, { id: MOD.SKETCHMONS, formatKeywords: ['sketchmons'], label: 'Sketchmons', challengeRule: 'sketchmonsmod', }, {id: MOD.BONUS_TYPE, formatKeywords: ['bonustype'], label: 'Bonus Type', challengeRule: 'Bonus Type Mod'}, {id: MOD.TYPEMONS, providerKeywords: ['typemons'], label: 'Typemons'}, {id: MOD.STABMONS, providerKeywords: ['stabmons', 'staaab'], label: 'STABmons', challengeRule: 'STABmons Move Legality'}, {id: MOD.CONVERGENCE, providerKeywords: ['convergence'], label: 'Convergence', challengeRule: 'Convergence Legality'}, {id: MOD.ALPHABET_CUP, providerKeywords: ['alphabetcup'], label: 'Alphabet Cup'}, {id: MOD.ALL_MOVES, providerKeywords: ['camove'], label: 'Every Move'}, { id: MOD.ALMOST_ANY_ABILITY, providerKeywords: ['pokebilitiesaaa', 'pokeaaa', 'almostanyability', 'aaarestrictedabilities', 'staaab'], label: 'Almost Any Ability', }, ]; const MOD_DEFINITION_BY_ID = new Map(MOD_DEFINITIONS.map((definition) => [definition.id, definition])); const MOD_BY_CALC_OM = new Map(MOD_DEFINITIONS .filter((definition) => definition.calcOm) .map((definition) => [definition.calcOm, definition.id])); const FORMAT_MOD_KEYWORDS = MOD_DEFINITIONS.flatMap((definition) => (definition.formatKeywords || []).map((keyword) => [keyword, definition.id]) ); function modFromFormat(format) { const id = fmtId(format); for (const [keyword, mod] of FORMAT_MOD_KEYWORDS) { if (id.includes(keyword)) return mod; } return null; } const isFusionMod = (mod) => MOD_DEFINITION_BY_ID.get(mod)?.exclusiveGroup === 'fusion'; const FUSION_FORMAT_KEYWORDS = [...new Set(MOD_DEFINITIONS .filter((definition) => definition.exclusiveGroup === 'fusion') .flatMap((definition) => definition.formatKeywords || []))]; const isCrossFormat = (format) => fmtHas(format, ...FUSION_FORMAT_KEYWORDS); // Provider mods can coexist with the primary format mod (for example, // Camove is both Camomons and an all-moves format). const FORMAT_PROVIDER_KEYWORDS = MOD_DEFINITIONS.flatMap((definition) => (definition.providerKeywords || []).map((keyword) => [keyword, definition.id]) ); // ==================================================================== // 2. ROOM HELPERS // ==================================================================== const getTeambuilderRoom = () => window.app?.rooms?.teambuilder || null; const getActiveTeambuilderRoom = () => getTeambuilderRoom() || (window.room?.curTeam ? window.room : null); const getEngine = () => getTeambuilderRoom()?.search?.engine; // ---------- format stacking ---------- // // A team's format can carry custom rules ("gen9ou@@@badnboostedmod, flippedmod"), which is how // OMs are stacked. const asMods = (m) => (Array.isArray(m) ? m : m ? [m] : []); // Mods that fight over the same thing: only the first one in the list is used. const EXCLUSIVE_MOD_GROUPS = [...MOD_DEFINITIONS.reduce((groups, definition) => { if (!definition.exclusiveGroup) return groups; if (!groups.has(definition.exclusiveGroup)) groups.set(definition.exclusiveGroup, []); groups.get(definition.exclusiveGroup).push(definition.id); return groups; }, new Map()).values()]; function resolveExclusive(mods) { const out = []; for (const mod of mods) { const group = EXCLUSIVE_MOD_GROUPS.find((g) => g.includes(mod)); if (group && out.some((m) => group.includes(m))) continue; out.push(mod); } return out; } // ModifySpecies event handlers run by priority, high to low. Stable source // order within a priority bracket follows the challenge code's rule order. I do that bc its random in showdowns code lol. function orderModsByShowdownPriority(mods) { return asMods(mods).map((mod, index) => ({ mod, index, priority: MOD_DEFINITION_BY_ID.get(mod)?.modifySpeciesPriority || 0, })).sort((a, b) => b.priority - a.priority || a.index - b.index ).map(({mod}) => mod); } // Mods a format string turns on by itself: its own mod, provider mods, and "@@@" rule mods. function modsFromFormatString(format) { const mods = []; const add = (mod) => {if (mod && !mods.includes(mod)) mods.push(mod);}; const remove = (mod) => {const idx = mods.indexOf(mod); if (idx >= 0) mods.splice(idx, 1);}; const matchBase = (text) => { add(modFromFormat(text)); const id = fmtId(text); for (const [keyword, mod] of FORMAT_PROVIDER_KEYWORDS) if (id.includes(keyword)) add(mod); }; const base = String(format || '').split('@@@')[0].trim(); matchBase(base); if (/^(gen\d+)?(nationaldex|natdex)?aaa$/.test(fmtId(base))) add(MOD.ALMOST_ANY_ABILITY); // Do NOT run substring matching on signed rules (a removed rule must not // activate its mod). Rules are applied in their source order. let unrestrictedAbilities = false; let unrestrictedMoves = false; for (const op of formatRuleTokens(format)) { if (!['rule', 'value', 'replace', 'remove'].includes(op.kind)) continue; const on = op.kind !== 'remove'; const id = op.id; if (id === 'obtainable') { unrestrictedAbilities = !on; unrestrictedMoves = !on; } else if (id === 'obtainableabilities') unrestrictedAbilities = !on; else if (id === 'obtainablemoves') unrestrictedMoves = !on; let matching = MOD_DEFINITIONS.filter((d) => toSearchId(d.challengeRule || '') === id || (d.formatKeywords || []).some((keyword) => id === keyword || id === keyword + 'mod') || (d.providerKeywords || []).some((keyword) => id === keyword || id === keyword + 'mod' || id === keyword + 'movelegality') ).map((d) => d.id); // Showdown's current ruleset uses these precise legality names. if (id === 'convergencelegality') matching.push(MOD.CONVERGENCE); if (id === 'aaarestrictedabilities') matching.push(MOD.ALMOST_ANY_ABILITY); if (id === 'godlygiftmod') matching.push(MOD.GODLY_GIFT); if (id === 'sketchmonsmovelegality') matching.push(MOD.SKETCHMONS); for (const mod of new Set(matching)) on ? add(mod) : remove(mod); } if (unrestrictedAbilities) add(MOD.ALMOST_ANY_ABILITY); if (unrestrictedMoves) add(MOD.ALL_MOVES); return mods; } let modsMemo = null; // Every ordered mod that applies to the team being edited. The array is shared (memoized): // never mutate it. function getActiveMods(room = getActiveTeambuilderRoom()) { const team = room?.curTeam; isGodlyGiftFormat(room); isTierShiftAAAFormat(room); // side effects: start the banlist fetches const m = modsMemo; if (m && m.team === team && m.format === team?.format) return m.mods; const result = Object.freeze(resolveExclusive(modsFromFormatString(team?.format))); modsMemo = {team, format: team?.format, mods: result}; return result; } // The team's first mod (kept for callers that only care about "the" mod). const getActiveMod = (room = getActiveTeambuilderRoom()) => getActiveMods(room)[0] || null; // Mods for a search object (move / ability / pokemon / item search). Uses the team being // edited when the search belongs to its format, so the team's extras apply too. const searchFormatId = (f) => stripGen(fmtId(String(f || '').split('@@@')[0])); function modsForSearch(search) { if (search?.__qolProviderMods) return search.__qolProviderMods; const room = getActiveTeambuilderRoom(); const team = room?.curTeam; const engine = room?.search?.engine; // PS strips generation, National Dex, doubles, etc. from search.format. // Prefer ownership; comparing the raw format strings loses custom rules. const owned = search && [engine?.typedSearch, engine?.moveSearch, engine?.abilitySearch].includes(search); if (team && search && (owned || searchFormatId(team.format) === searchFormatId(search.format))) { return getActiveMods(room); } return modsFromFormatString(search?.format); } // Redraws everything that depends on the team's format / mod list. function refreshAfterFormatChange(room) { if (!room) return; const engine = room.search?.engine; if (engine?.filters && !fmtHas(room.curTeam?.format, 'monocolor', 'monocolour')) { engine.filters = engine.filters.filter(([type]) => baseFilterType(type) !== 'color'); if (room.search) room.search.filters = engine.filters; } for (const search of [engine?.typedSearch, engine?.moveSearch, engine?.abilitySearch]) { if (!search) continue; search.__qolFusionKey = null; clearSearchCache(search); } try { if (room.curSet) { refreshTypeIcons(room); room.updateStatForm(); room.updateStatGraph(); } else { room.update?.(); // team view: re-render the set rows and the format button } } catch (e) { console.warn(LOG, 'format refresh failed:', e); } refreshSearch(room, {clearBase: true}); // Showdown's native editor may create the EV controls only after its // update finishes; recompute once on the next frame, not per mutation. if (room.curSet && !room.__qolFormatStatRefreshPending) { room.__qolFormatStatRefreshPending = true; requestAnimationFrame(() => { room.__qolFormatStatRefreshPending = false; if (getActiveTeambuilderRoom() !== room || !room.curSet) return; try { room.updateStatForm?.(); room.updateStatGraph?.(); } catch (e) { console.warn(LOG, 'Deferred stat refresh failed:', e); } }); } } // ==================================================================== // 3. PATCH HELPERS // ==================================================================== function hasPatchTag(fn, tag) { while (typeof fn === 'function') { if (fn[tag]) return true; fn = fn.__original; } return false; } // Wraps target[key] exactly once; `wrap(original)` returns the replacement. function patchMethod(target, key, tag, wrap) { if (!target || typeof target[key] !== 'function') return false; if (hasPatchTag(target[key], tag)) return true; const original = target[key]; const wrapped = wrap(original); wrapped[tag] = true; wrapped.__original = original; target[key] = wrapped; return true; } function findPrototypeWithMethod(obj, methodName) { let proto = obj; while (proto) { if (Object.prototype.hasOwnProperty.call(proto, methodName)) return proto; proto = Object.getPrototypeOf(proto); } return null; } // Patches a method on whatever prototype owns it for the live search engine. function patchEngineMethod(key, tag, wrap) { return patchMethod(findPrototypeWithMethod(getEngine(), key), key, tag, wrap); } function patchNativeFormatRefresh() { const proto = window.TeambuilderRoom?.prototype; const change = patchMethod(proto, 'changeFormat', '__qolNativeFormatChangeRefresh', (original) => function (...args) { const before = this.curTeam?.format; const result = original.apply(this, args); if (this.curTeam?.format !== before) refreshAfterFormatChange(this); return result; }); const open = patchMethod(proto, 'edit', '__qolNativeTeamOpenRefresh', (original) => function (...args) { const result = original.apply(this, args); if (this.curTeam) refreshAfterFormatChange(this); return result; }); return change && open; } // Temporarily makes `dex.species.get` return `targetSpecies` with fields // overridden. This is how every mod fakes a stat change without touching // Showdown's real dex data. function withSpeciesOverrides(dex, targetSpecies, overrides, fn) { if (!dex?.species?.get || !targetSpecies) return fn(); const originalGet = dex.species.get; const modified = Object.assign({}, targetSpecies, overrides); dex.species.get = function (name) { const result = originalGet.call(this, name); return result === targetSpecies ? modified : result; }; try { return fn(); } finally { dex.species.get = originalGet; } } // A native stat render is recursive: updateStatForm -> getStat/updateStatGraph // -> guesser/optimizer -> getStat. All those paths share a Dex instance. // Applying a species transform twice is incorrect (35 -> 70 -> 140 in BnB). const activeStatOverrides = new WeakMap(); const isStatOverrideActive = (dex, speciesId) => { const active = dex && activeStatOverrides.get(dex); return !!active && active.has(toID(speciesId)); }; function withSpeciesBaseStats(dex, speciesId, baseStats, fn) { if (!dex?.species?.get || !baseStats) return fn(); const id = toID(speciesId); let active = activeStatOverrides.get(dex); if (active?.has(id)) return fn(); // already transformed by an outer wrapper const species = dex.species.get(speciesId); if (!species?.exists) return fn(); if (!active) { active = new Set(); activeStatOverrides.set(dex, active); } active.add(id); try { return withSpeciesOverrides(dex, species, {baseStats: copy(baseStats)}, fn); } finally { active.delete(id); if (!active.size) activeStatOverrides.delete(dex); } } // Same idea for call sites that read `pokemon.getSpecies()`. function withOverriddenGetSpecies(pokemon, shiftedSpecies, fn) { if (!pokemon?.getSpecies) return fn(); const original = pokemon.getSpecies; pokemon.getSpecies = () => shiftedSpecies; try { return fn(); } finally { pokemon.getSpecies = original; } } // ==================================================================== // 4. SEARCH-RESULT HELPERS // ==================================================================== function refreshSearchUi(room) { const ui = room?.search; if (!ui) return; if (typeof ui.update === 'function') ui.update(); else if (typeof ui.updateResults === 'function') ui.updateResults(); } const clearSearchCache = (search) => { search.__qolProviderPools = null; search.baseResults = null; search.baseIllegalResults = null; search.illegalReasons = null; }; // Throws away the engine's results (and optionally the typed search's // cached base results) and redraws the open search list. function refreshSearch(room, {clearBase = false} = {}) { const engine = room?.search?.engine; if (!engine) return; if (clearBase && engine.typedSearch) clearSearchCache(engine.typedSearch); engine.results = null; refreshSearchUi(room); } // Runs fn with the search temporarily pointed at a different species. function withSearchSpecies(search, speciesName, fn) { const savedSpecies = search.species; const savedSet = search.set; search.species = toID(speciesName); search.set = Object.assign({}, savedSet, {species: speciesName, name: ''}); try { return fn(); } finally { search.species = savedSpecies; search.set = savedSet; } } // Flat result list -> [{header, rows}]. function splitIntoSections(rows) { const sections = []; let cur = {header: null, rows: []}; sections.push(cur); for (const row of rows) { if (row[0] === 'header') { cur = {header: row, rows: []}; sections.push(cur); } else { cur.rows.push(row); } } return sections; } // Adds each move from `extra` that `base` lacks into the section with the // same header, so the movepools read as one list. function mergeMoveResults(base, extra) { const sections = splitIntoSections(base); const sectionKey = (s) => (s.header ? String(s.header[1]) : ''); const byKey = new Map(sections.map((s) => [sectionKey(s), s])); const have = new Set(base.filter((r) => r[0] === 'move').map((r) => r[1])); const wasSorted = new Map( sections.map((s) => { const ids = s.rows.map((r) => String(r[1])); return [s, ids.every((id, i) => i === 0 || ids[i - 1] <= id)]; }) ); const touched = new Set(); for (const section of splitIntoSections(extra)) { for (const row of section.rows) { if (row[0] !== 'move' || have.has(row[1])) continue; have.add(row[1]); let target = byKey.get(sectionKey(section)); if (!target) { target = {header: section.header, rows: []}; sections.push(target); byKey.set(sectionKey(section), target); wasSorted.set(target, true); } target.rows.push(row); touched.add(target); } } // Keep alphabetical order in any section that already had it. for (const s of touched) { if (wasSorted.get(s)) s.rows.sort((a, b) => String(a[1]).localeCompare(String(b[1]))); } return sections.flatMap((s) => (s.header ? [s.header, ...s.rows] : s.rows)); } // Adds brand-new move ids (Alphabet Cup letters, National Dex movepool...) // under the native "Moves" / "Usually useless moves" headers. function addMovesToResults(search, results, species, newIds) { let usableHeader = null; let uselessHeader = null; let lastHeader = null; let seenMove = false; for (const r of results) { if (r[0] === 'header') { lastHeader = r; const text = String(r[1]); if (!uselessHeader && /useless/i.test(text) && !/z-move/i.test(text)) uselessHeader = r; } else if (r[0] === 'move' && !seenMove) { seenMove = true; usableHeader = lastHeader; } } if (usableHeader && usableHeader === uselessHeader) usableHeader = ['header', 'Moves']; // Sort the new moves with the client's own "is this useless" check. const allIds = results.filter((r) => r[0] === 'move').map((r) => r[1]).concat(newIds); const isUsable = (id) => { if (typeof search.moveIsNotUseless !== 'function') return true; try { return !!search.moveIsNotUseless(id, species, allIds, search.set); } catch (e) { return true; } }; const usable = []; const useless = []; for (const id of newIds) (isUsable(id) ? usable : useless).push(['move', id]); const extra = []; if (usable.length) extra.push(...(usableHeader ? [usableHeader] : []), ...usable); if (useless.length) extra.push(uselessHeader || ['header', 'Usually useless moves'], ...useless); return extra.length ? mergeMoveResults(results, extra) : results; } function pokemonIconHtml(speciesId) { const icon = Dex.getPokemonIcon(speciesId); if (typeof icon !== 'string') return ''; // Some client versions return a full , others just the CSS. return icon.trim().startsWith('<') ? icon : ``; } // ==================================================================== // 5. SERVER BANLISTS // // Banlists live server-side only, so we ask via a silent `/tier` command // and parse the `/raw` reply (which is then hidden from chat). // - Named lists: Tier Shift AAA, Godly Gift, Convergence // - Generic: every other format (species / tier bans only) // ==================================================================== const BL = { tsa: {command: '/tier tiershiftaaa', loaded: false, sent: false, silent: false}, gg: {command: '/tier godly gift', loaded: false, sent: false, silent: false}, conv: {command: '/tier convergence', loaded: false, sent: false, silent: false}, }; let tsaBanlist = new Set(); let godlyGiftRestricted = new Set(); let convergenceBans = null; const BAN_TIER_IDS = new Set([ 'ag', 'uber', 'ou', 'uubl', 'uu', 'rubl', 'ru', 'nubl', 'nu', 'publ', 'pu', 'zubl', 'zu', 'nfe', 'lc', ]); // "Uber" -> 'uber'; National Dex's "ND Uber" -> 'uber' as well (judged on National Dex tiers, see // natdexTierMap). Null if the token isn't a tier. const banTierId = (id) => BAN_TIER_IDS.has(id) ? id : (/^nd/.test(id) && BAN_TIER_IDS.has(id.slice(2)) ? id.slice(2) : null); function requestBanlist(key) { const b = BL[key]; if (b.loaded || b.sent) return; if (!window.app || typeof app.send !== 'function') return; // Install the receiver before sending: a fast server response must not // arrive before the suppression hook has been attached. patchServerReceive(); b.sent = true; b.silent = true; b.sentAt = Date.now(); app.send(b.command); } // Checking "is this format active" also kicks off the fetch the first time. function formatActive(room, keyword, key) { const active = fmtHas(room?.curTeam?.format, keyword); if (active) requestBanlist(key); return active; } // The restricted list comes from the generic base banlist (plus custom rules), so nothing // is requested here: getMergedBans() asks the server once per format. const isGodlyGiftFormat = (room) => modsFromFormatString(room?.curTeam?.format).includes(MOD.GODLY_GIFT); const isTierShiftAAAFormat = (room) => formatActive(room, 'tiershiftaaa', 'tsa'); // The Convergence Megamons challenge code is based on Anything Goes and adds // Convergence Legality, !Obtainable Formes and individual +Mega entries. // It is NOT Mix and Mega: the Mega formes are starting species, not stone effects. // Keep the same path for the eventual standalone Showdown format. function isConvergenceMegamons(format) { const name = String(format || '').split('@@@')[0]; if (fmtHas(name, 'convergencemegamons', 'megamonsconvergence')) return true; // A removed Convergence rule is not an active Convergence format. if (!modsFromFormatString(format).includes(MOD.CONVERGENCE)) return false; const ops = formatRuleTokens(format); const allowedMega = ops.some((op) => op.kind === 'unban' && /-mega(?:-[a-z0-9]+)?$/i.test(op.name || '')); let unblockedFormes = false; for (const op of ops) if (op.id === 'obtainableformes') { if (op.kind === 'remove') unblockedFormes = true; else if (['rule', 'replace'].includes(op.kind)) unblockedFormes = false; } return allowedMega || unblockedFormes; } function isConvergenceEnabled(format) { return modsFromFormatString(format).includes(MOD.CONVERGENCE) || isConvergenceMegamons(format); } function explicitlyUnbannedForms(format) { const ids = new Set(); // Follow Showdown custom-rule ordering. A later ban cancels an earlier // unban, and a later unban re-enables the form. for (const op of formatRuleTokens(format)) { if (op.kind !== 'unban' && op.kind !== 'ban') continue; const name = String(op.name || '').trim(); if (!name || /^(?:ability|move|item|type|teratype)\s*:/i.test(name)) continue; const id = toID(name); if (op.kind === 'unban') ids.add(id); else ids.delete(id); } return ids; } const isConvergenceFormat = (room) => { const format = room?.curTeam?.format; if (isConvergenceMegamons(format)) return true; const active = isConvergenceEnabled(format); if (active) requestBanlist('conv'); return active; }; // ---------- parsing ---------- // Splits the `/raw` blob into {Bans: [...], Unbans: [...], ...}. // `header` is optional: pass null to skip the "is this the right format" check. function parseFormatSectionTokens(html, header) { if (!html || (header && !html.includes(header))) return null; const doc = new DOMParser().parseFromString(html, 'text/html'); // The official /formathelp HTML places Banlist/Ruleset // directly against Ruleset. textContent otherwise becomes // 'Banlist/RulesetRuleset - ...', so the old regexp misses EVERY rule. doc.querySelectorAll('br').forEach((br) => br.replaceWith('\n')); doc.querySelectorAll('summary, h2, hr, b').forEach((node) => node.before(doc.createTextNode('\n'))); const text = doc.body.textContent || ''; const re = /(^|[^A-Za-z])(Bans|Unbans|Restricted|Restrictions|Unrestricted|Ruleset|Rules|Custom Rules)\s+-\s+/g; const marks = []; let m; while ((m = re.exec(text))) { marks.push({ label: m[2], labelStart: m.index + m[1].length, bodyStart: m.index + m[0].length, }); } const sections = {}; marks.forEach((mark, i) => { const end = i + 1 < marks.length ? marks[i + 1].labelStart : text.length; const tokens = text.slice(mark.bodyStart, end).split(',').map((t) => t.trim()).filter(Boolean); sections[mark.label] = (sections[mark.label] || []).concat(tokens); }); return sections; } // Species ids from a "