diff --git a/stats/src/classifier.ts b/stats/src/classifier.ts index ad1527d..6126f5b 100644 --- a/stats/src/classifier.ts +++ b/stats/src/classifier.ts @@ -1,4 +1,4 @@ -import {Generation, ID, PokemonSet, StatID, TypeName, toID} from '@pkmn/data'; +import {Generation, ID, Move, PokemonSet, StatID, TypeName, toID} from '@pkmn/data'; import * as util from './util'; @@ -6,78 +6,120 @@ import * as util from './util'; const LOG3_LOG2 = Math.log(3) / Math.log(2); export const Classifier = new class { + caches: {[gen: number]: {[name: string]: Set}} = {}; + classifyTeam(gen: Generation, team: Array>, legacy = false) { + const tables = legacy ? { + greaterSetup: GREATER_SETUP_MOVES, + lesserSetup: LESSER_SETUP_MOVES, + batonPass: SETUP_MOVES, + gravity: GRAVITY_MOVES, + recovery: RECOVERY_MOVES, + protection: PROTECT_MOVES, + phazing: PHAZING_MOVES, + paralysis: PARALYSIS_MOVES, + confusion: CONFUSION_MOVES, + sleep: SLEEP_MOVES, + ohko: OHKO_MOVES, + greaterOffensive: GREATER_OFFENSIVE_MOVES, + lesserOffensive: LESSER_OFFENSIVE_MOVES, + } : this.caches[gen.num] || (this.caches[gen.num] = { + greaterSetup: computeGreaterSetupMoves(gen), + lesserSetup: computeLesserSetupMoves(gen), + batonPass: computeBatonPassMoves(gen), + gravity: computeGravityMoves(gen), + recovery: computeRecoveryMoves(gen), + protection: computeProtectionMoves(gen), + phazing: computePhazingMoves(gen), + paralysis: computeParalysisMoves(gen), + confusion: computeConfusionMoves(gen), + sleep: computeSleepMoves(gen), + ohko: computeOHKOMoves(gen), + greaterOffensive: computeGreaterOffensiveMoves(gen), + lesserOffensive: computeLesserOffensiveMoves(gen), + }); + let teamBias = 0; const teamStalliness = []; for (const pokemon of team) { - const {bias, stalliness} = this.classifyPokemon(gen, pokemon, legacy); + const {bias, stalliness} = classifyPokemon(gen, pokemon, tables, legacy); teamBias += bias; teamStalliness.push(stalliness); } const stalliness = teamStalliness.reduce((a, b) => a + b) / teamStalliness.length; - const tags = tag(gen, team, stalliness, legacy); + const tags = tag(gen, team, stalliness, tables, legacy); return {bias: teamBias, stalliness, tags}; } +}; - // For stats and moveset purposes we're now counting Mega Pokemon seperately, - // but for team analysis we still want to consider the base (which presumably - // breaks for Hackmons, but we're OK with that). - classifyPokemon(gen: Generation, pokemon: PokemonSet, legacy = false) { - const originalSpecies = pokemon.species; - const originalAbility = pokemon.ability; - - const species = util.getSpecies(gen, pokemon.species, legacy); - let mega: {species: ID; ability: ID} | undefined; - if (util.isMega(species, legacy)) { - mega = { - species: toID(species.name), - ability: toID(species.abilities['0']), - }; - pokemon.species = toID(species.baseSpecies); - } +// For stats and moveset purposes we're now counting Mega Pokemon separately, +// but for team analysis we still want to consider the base (which presumably +// breaks for Hackmons, but we're OK with that). +function classifyPokemon( + gen: Generation, + pokemon: PokemonSet, + tables: {[name: string]: Set}, + legacy: boolean, +) { + const originalSpecies = pokemon.species; + const originalAbility = pokemon.ability; - let {bias, stalliness} = classifyForme(gen, pokemon, legacy); - if (!legacy) { - if (pokemon.species === 'meloetta' && pokemon.moves.includes('relicsong' as ID)) { - pokemon.species = 'meloettapirouette' as ID; - stalliness = (stalliness + classifyForme(gen, pokemon, legacy).stalliness) / 2; - } else if ( - pokemon.species === 'darmanitan' && pokemon.ability === 'zenmode') { - pokemon.species = 'darmanitanzen' as ID; - stalliness = (stalliness + classifyForme(gen, pokemon, legacy).stalliness) / 2; - } else if ( - pokemon.species === 'rayquaza' && - pokemon.moves.includes('dragonascent' as ID)) { - pokemon.species = 'rayquazamega' as ID; - pokemon.ability = 'deltastream' as ID; - stalliness = (stalliness + classifyForme(gen, pokemon, legacy).stalliness) / 2; - } - } - if (mega) { - if (!legacy) pokemon.species = mega.species; - pokemon.ability = mega.ability; - stalliness = (stalliness + classifyForme(gen, pokemon, legacy).stalliness) / 2; + const species = util.getSpecies(gen, pokemon.species, legacy); + let mega: {species: ID; ability: ID} | undefined; + if (util.isMega(species, legacy)) { + mega = { + species: toID(species.name), + ability: toID(species.abilities['0']), + }; + pokemon.species = toID(species.baseSpecies); + } + + let {bias, stalliness} = classifyForme(gen, pokemon, tables, legacy); + if (!legacy) { + if (pokemon.species === 'meloetta' && pokemon.moves.includes('relicsong' as ID)) { + pokemon.species = 'meloettapirouette' as ID; + stalliness = (stalliness + classifyForme(gen, pokemon, tables, legacy).stalliness) / 2; + } else if ( + pokemon.species === 'darmanitan' && pokemon.ability === 'zenmode') { + pokemon.species = 'darmanitanzen' as ID; + stalliness = (stalliness + classifyForme(gen, pokemon, tables, legacy).stalliness) / 2; + } else if ( + pokemon.species === 'rayquaza' && + pokemon.moves.includes('dragonascent' as ID)) { + pokemon.species = 'rayquazamega' as ID; + pokemon.ability = 'deltastream' as ID; + stalliness = (stalliness + classifyForme(gen, pokemon, tables, legacy).stalliness) / 2; } + } + if (mega) { + if (!legacy) pokemon.species = mega.species; + pokemon.ability = mega.ability; + stalliness = (stalliness + classifyForme(gen, pokemon, tables, legacy).stalliness) / 2; + } - // Make sure to revert back to the original values - pokemon.species = originalSpecies; - pokemon.ability = originalAbility; + // Make sure to revert back to the original values + pokemon.species = originalSpecies; + pokemon.ability = originalAbility; - return {bias, stalliness}; - } -}; + return {bias, stalliness}; +} const TRAPPING_ABILITIES = new Set(['arenatrap', 'magnetpull', 'shadowtag']); const TRAPPING_MOVES = new Set(['block', 'meanlook', 'spiderweb', 'pursuit']); -function classifyForme(gen: Generation, pokemon: PokemonSet, legacy: boolean) { +function classifyForme( + gen: Generation, + pokemon: PokemonSet, + tables: {[name: string]: Set}, + legacy: boolean, +) { let stalliness = baseStalliness(gen, pokemon, legacy); stalliness += abilityStallinessModifier(pokemon); stalliness += itemStallinessModifier(pokemon); - stalliness += movesStallinessModifier(pokemon); + stalliness += movesStallinessModifier(pokemon, tables); // These depend on a combination of moves/abilities and thus don't belong in either // abilityStallinessModifier or moveStallinessModifier, so we calculate them here. @@ -149,20 +191,8 @@ function calcFormeStats(gen: Generation, pokemon: PokemonSet, legacy: boolea return stats; } -// FIXME: Update all of these sets to be more comprehensive. -const SETUP_MOVES = new Set([ - 'acupressure', 'bellydrum', 'bulkup', 'coil', 'curse', 'dragondance', 'growth', 'honeclaws', - 'howl', 'meditate', 'sharpen', 'shellsmash', 'shiftgear', 'swordsdance', 'workup', 'calmmind', - 'chargebeam', 'fierydance', 'nastyplot', 'tailglow', 'quiverdance', 'agility', 'autotomize', - 'flamecharge', 'rockpolish', 'doubleteam', 'minimize', 'substitute', 'acidarmor', 'barrier', - 'cosmicpower', 'cottonguard', 'defendorder', 'defensecurl', 'harden', 'irondefense', 'stockpile', - 'withdraw', 'amnesia', 'charge', 'ingrain', -]); - const SETUP_ABILITIES = new Set(['angerpoint', 'contrary', 'moody', 'moxie', 'speedboost']); -// FIXME: This is missing the latest a number of dragons (Kommo-o?) and should instead be -// generated by iterating over all Species in Dex and looking for Dragon-typed Pokemon. const DRAGONS = new Set([ 'dratini', 'dragonair', 'bagon', 'shelgon', 'axew', 'fraxure', 'haxorus', 'druddigon', 'dragonite', 'altaria', 'salamence', 'latias', 'latios', 'rayquaza', 'gible', 'gabite', @@ -170,17 +200,13 @@ const DRAGONS = new Set([ 'flygon', 'dialga', 'palkia', 'giratina', 'giratinaorigin', 'deino', 'zweilous', 'hydreigon', ]); -const GRAVITY_MOVES = new Set([ - 'guillotine', 'fissure', 'sheercold', 'dynamicpunch', 'inferno', 'zapcannon', 'grasswhistle', - 'sing', 'supersonic', 'hypnosis', 'blizzard', 'focusblast', 'gunkshot', 'hurricane', 'smog', - 'thunder', 'clamp', 'dragonrush', 'eggbomb', 'irontail', 'lovelykiss', 'magmastorm', 'megakick', - 'poisonpowder', 'slam', 'sleeppowder', 'stunspore', 'sweetkiss', 'willowisp', 'crosschop', - 'darkvoid', 'furyswipes', 'headsmash', 'hydropump', 'kinesis', 'psywave', 'rocktomb', 'stoneedge', - 'submission', 'boneclub', 'bonerush', 'bonemerang', 'bulldoze', 'dig', 'drillrun', 'earthpower', - 'earthquake', 'magnitude', 'mudbomb', 'mudshot', 'mudslap', 'sandattack', 'spikes', 'toxicspikes', -]); - -function tag(gen: Generation, team: Array>, stalliness: number, legacy: boolean) { +function tag( + gen: Generation, + team: Array>, + stalliness: number, + tables: {[name: string]: Set}, + legacy: boolean, +) { const weather = {rain: 0, sun: 0, sand: 0, hail: 0}; const style = { batonpass: 0, tailwind: 0, trickroom: 0, slow: 0, gravityMoves: 0, gravity: 0, voltturn: 0, @@ -225,7 +251,8 @@ function tag(gen: Generation, team: Array>, stalliness: number, l } if (style.batonpass < 2 && moves.has('batonpass') && - (SETUP_ABILITIES.has(pokemon.ability) || pokemon.moves.some((m: ID) => SETUP_MOVES.has(m)))) { + (SETUP_ABILITIES.has(pokemon.ability) || + pokemon.moves.some((m: ID) => tables.batonPass.has(m)))) { style.batonpass++; } if (style.tailwind < 2 && moves.has('tailwind')) { @@ -243,7 +270,7 @@ function tag(gen: Generation, team: Array>, stalliness: number, l if (style.gravity < 2 && moves.has('gravity')) { style.gravity++; } - if (pokemon.moves.some((m: ID) => GRAVITY_MOVES.has(m))) { + if (pokemon.moves.some((m: ID) => tables.gravity.has(m))) { style.gravityMoves++; } if ((style.voltturn < 3 && pokemon.item === 'ejectbutton') || @@ -448,50 +475,7 @@ function itemStallinessModifier(pokemon: PokemonSet) { return 0; } -const RECOVERY_MOVES = new Set([ - 'recover', 'slackoff', 'healorder', 'milkdrink', 'roost', 'moonlight', 'morningsun', - 'synthesis', 'wish', 'aquaring', 'rest', 'softboiled', 'swallow', 'leechseed', -]); - -const PROTECT_MOVES = new Set(['protect', 'detect', 'kingsshield', 'matblock', 'spikyshield']); - -const PHAZING_MOVES = new Set(['whirlwind', 'roar', 'circlethrow', 'dragontail']); - -const PARALYSIS_MOVES = new Set(['thunderwave', 'stunspore', 'glare', 'nuzzle']); - -const CONFUSION_MOVES = new Set([ - 'supersonic', 'confuseray', 'swagger', 'flatter', 'teeterdance', 'yawn', -]); - -const SLEEP_MOVES = new Set([ - 'darkvoid', 'grasswhistle', 'hypnosis', 'lovelykiss', 'sing', 'sleeppowder', 'spore', -]); - -const LESSER_OFFENSIVE_MOVES = new Set([ - 'jumpkick', 'doubleedge', 'submission', 'petaldance', 'hijumpkick', 'outrage', - 'volttackle', 'closecombat', 'flareblitz', 'bravebird', 'woodhammer', 'headsmash', - 'headcharge', 'wildcharge', 'takedown', 'dragonascent', -]); - -const GREATER_OFFENSIVE_MOVES = new Set([ - 'selfdestruct', 'explosion', 'destinybond', 'perishsong', - 'memento', 'healingwish', 'lunardance', 'finalgambit', -]); - -const OHKO_MOVES = new Set(['guillotine', 'fissure', 'sheercold']); - -const GREATER_SETUP_MOVES = new Set([ - 'curse', 'dragondance', 'growth', 'shiftgear', 'swordsdance', - 'fierydance', 'nastyplot', 'tailglow', 'quiverdance', 'geomancy', -]); - -const LESSER_SETUP_MOVES = new Set([ - 'acupressure', 'bulkup', 'coil', 'howl', 'workup', 'meditate', 'sharpen', 'calmmind', - 'chargebeam', 'agility', 'autotomize', 'flamecharge', 'rockpolish', 'doubleteam', - 'minimize', 'tailwind', 'poweruppunch', 'rototiller', -]); - -function movesStallinessModifier(pokemon: PokemonSet) { +function movesStallinessModifier(pokemon: PokemonSet, tables: {[name: string]: Set}) { const moves = new Set(pokemon.moves as string[]); let mod = 0; @@ -507,25 +491,304 @@ function movesStallinessModifier(pokemon: PokemonSet) { if (moves.has('trick')) mod -= 0.5; if (moves.has('endeavor')) mod -= 1.0; - if (pokemon.moves.some((m: ID) => RECOVERY_MOVES.has(m))) mod += 1.0; - if (pokemon.moves.some((m: ID) => PROTECT_MOVES.has(m))) mod += 1.0; - if (pokemon.moves.some((m: ID) => PHAZING_MOVES.has(m))) mod += 0.5; - if (pokemon.moves.some((m: ID) => PARALYSIS_MOVES.has(m))) mod += 0.5; - if (pokemon.moves.some((m: ID) => CONFUSION_MOVES.has(m))) mod += 0.5; - if (pokemon.moves.some((m: ID) => SLEEP_MOVES.has(m))) mod -= 0.5; - if (pokemon.moves.some((m: ID) => LESSER_OFFENSIVE_MOVES.has(m))) mod -= 0.5; - if (pokemon.moves.some((m: ID) => GREATER_OFFENSIVE_MOVES.has(m))) mod -= 1.0; - if (pokemon.moves.some((m: ID) => OHKO_MOVES.has(m))) mod -= 1.0; + if (pokemon.moves.some((m: ID) => tables.recovery.has(m))) mod += 1.0; + if (pokemon.moves.some((m: ID) => tables.protection.has(m))) mod += 1.0; + if (pokemon.moves.some((m: ID) => tables.phazing.has(m))) mod += 0.5; + if (pokemon.moves.some((m: ID) => tables.paralysis.has(m))) mod += 0.5; + if (pokemon.moves.some((m: ID) => tables.confusion.has(m))) mod += 0.5; + if (pokemon.moves.some((m: ID) => tables.sleep.has(m))) mod -= 0.5; + if (pokemon.moves.some((m: ID) => tables.lesserOffensive.has(m))) mod -= 0.5; + if (pokemon.moves.some((m: ID) => tables.greaterOffensive.has(m))) mod -= 1.0; + if (pokemon.moves.some((m: ID) => tables.ohko.has(m))) mod -= 1.0; if (moves.has('bellydrum')) { mod -= 2.0; } else if (moves.has('shellsmash')) { mod -= 1.5; - } else if (pokemon.moves.some((m: ID) => GREATER_SETUP_MOVES.has(m))) { + } else if (pokemon.moves.some((m: ID) => tables.greaterSetup.has(m))) { mod -= 1.0; - } else if (pokemon.moves.some((m: ID) => LESSER_SETUP_MOVES.has(m))) { + } else if (pokemon.moves.some((m: ID) => tables.lesserSetup.has(m))) { mod -= 0.5; } return mod; } + +export const GREATER_SETUP_MOVES = new Set([ + 'curse', 'dragondance', 'growth', 'shiftgear', 'swordsdance', + 'fierydance', 'nastyplot', 'tailglow', 'quiverdance', 'geomancy', +] as ID[]); + +// https://pokemetrics.wordpress.com/2012/09/13/revisions-revisions/ +export function computeGreaterSetupMoves(gen: Generation) { + const moves = Array.from(gen.moves); + // Moves that either boost an attacking stat by multiple stages + // or boosts Speed and an attacking stat + const multiple = moves.filter(m => m.boosts && !targetsFoes(m) && ( + (m.boosts.atk && + ((m.boosts.atk >= 1 && m.boosts.spe && m.boosts.spe >= 1) || m.boosts.atk >= 2)) || + (m.boosts.spa && + ((m.boosts.spa >= 1 && m.boosts.spe && m.boosts.spe >= 1) || m.boosts.spa >= 2)) + // Shell Smash is intentionally left off this list + ) && m.id !== 'shellsmash').map(m => m.id); + // Attacking moves that have a high Base Power (80 or above) and have a high + // likelihood of boosting (at least 50%) + const attacking = moves .filter(m => m.basePower >= 80 && m.secondary?.self?.boosts && + m.secondary?.chance && m.secondary?.chance >= 50).map(m => m.id); + return new Set([...multiple, ...attacking, + // Due to implementation details, Tidy Up gets excluded from 'multiple' + ...(gen.num >= 9 ? ['tidyup'] : []) as ID[], + // These two moves in particular + ...(gen.num >= 2 ? ['curse', 'growth'] : []) as ID[], + ]); +} + +export const LESSER_SETUP_MOVES = new Set([ + 'acupressure', 'bulkup', 'coil', 'howl', 'workup', 'meditate', 'sharpen', 'calmmind', + 'chargebeam', 'agility', 'autotomize', 'flamecharge', 'rockpolish', 'doubleteam', + 'minimize', 'tailwind', 'poweruppunch', 'rototiller', +] as ID[]); + +export function computeLesserSetupMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + // Moves that only boost attacking stats by one stage + const single = moves.filter(m => !targetsFoes(m) && m.boosts && ( + (m.boosts.atk && m.boosts.atk === 1) || + (m.boosts.spa && m.boosts.spa === 1) + ) && !m.boosts.spe && m.id !== 'growth').map(m => m.id); + // Weaker attacking moves that boost stats + const attacking = moves.filter(m => m.basePower > 0 && m.basePower < 80 && + m.secondary?.self?.boosts && m.secondary?.chance && m.secondary?.chance >= 50).map(m => m.id); + // Moves that only boost speed + const speed = moves.filter(m => !targetsFoes(m) && + m.boosts?.spe && !m.boosts.atk && !m.boosts.spa).map(m => m.id); + // Moves that boost evasion + const evasion = moves.filter(m => !targetsFoes(m) && m.boosts?.evasion).map(m => m.id); + + return new Set([...single, ...attacking, ...speed, ...evasion, + // These moves in particular + ...(gen.num >= 6 ? ['rototiller'] : []) as ID[], + ...(gen.num >= 4 ? ['acupressure', 'tailwind'] : []) as ID[], + ]); +} + +export const SETUP_MOVES = new Set([ + 'acupressure', 'bellydrum', 'bulkup', 'coil', 'curse', 'dragondance', 'growth', 'honeclaws', + 'howl', 'meditate', 'sharpen', 'shellsmash', 'shiftgear', 'swordsdance', 'workup', 'calmmind', + 'chargebeam', 'fierydance', 'nastyplot', 'tailglow', 'quiverdance', 'agility', 'autotomize', + 'flamecharge', 'rockpolish', 'doubleteam', 'minimize', 'substitute', 'acidarmor', 'barrier', + 'cosmicpower', 'cottonguard', 'defendorder', 'defensecurl', 'harden', 'irondefense', 'stockpile', + 'withdraw', 'amnesia', 'charge', 'ingrain', +] as ID[]); + +export function computeBatonPassMoves(gen: Generation) { + const moves = Array.from(gen.moves); + // Any move that boosts your own stats + const self = moves.filter(m => m.boosts && + (['self', 'adjacentAllyOrSelf', 'allies'].includes(m.target))).map(m => m.id); + // Any attacking move that can increase your own stats + const attacking = moves.filter(m => m.basePower > 0 && m.secondary?.self?.boosts && + m.secondary?.chance && m.secondary?.chance >= 50).map(m => m.id); + return new Set([...self, ...attacking, + // Other moves that have effects that can be Baton Passed + ...(gen.num >= 4 ? ['acupressure'] as ID[] : []), + ...(gen.num >= 3 ? ['charge', 'ingrain', 'stockpile'] as ID[] : []), + ...(gen.num >= 2 ? ['bellydrum', 'curse'] as ID[] : []), + 'substitute' as ID, + ]); +} + +export const GRAVITY_MOVES = new Set([ + 'guillotine', 'fissure', 'sheercold', 'dynamicpunch', 'inferno', 'zapcannon', 'grasswhistle', + 'sing', 'supersonic', 'hypnosis', 'blizzard', 'focusblast', 'gunkshot', 'hurricane', 'smog', + 'thunder', 'clamp', 'dragonrush', 'eggbomb', 'irontail', 'lovelykiss', 'magmastorm', 'megakick', + 'poisonpowder', 'slam', 'sleeppowder', 'stunspore', 'sweetkiss', 'willowisp', 'crosschop', + 'darkvoid', 'furyswipes', 'headsmash', 'hydropump', 'kinesis', 'psywave', 'rocktomb', 'stoneedge', + 'submission', 'boneclub', 'bonerush', 'bonemerang', 'bulldoze', 'dig', 'drillrun', 'earthpower', + 'earthquake', 'magnitude', 'mudbomb', 'mudshot', 'mudslap', 'sandattack', 'spikes', 'toxicspikes', +] as ID[]); + +export function computeGravityMoves(gen: Generation) { + const moves = Array.from(gen.moves); + // Moves that have 80% accuracy or worse + const accuracy = moves.filter(m => ['normal', 'allAdjacentFoes', 'any'].includes(m.target) && + m.accuracy !== true && m.accuracy > 0 && m.accuracy <= 80).map(m => m.id); + // Ground-type moves + const ground = moves.filter(m => m.type === 'Ground' && + m.id !== 'hiddenpower' && m.target !== 'all').map(m => m.id); + return new Set([...accuracy, ...ground, + // Grounded hazards + ...(gen.num >= 6 ? ['stickyweb'] as ID[] : []), + ...(gen.num >= 4 ? ['toxicspikes'] as ID[] : []), + ...(gen.num >= 2 ? ['spikes'] as ID[] : []), + ]); +} + +export const RECOVERY_MOVES = new Set([ + 'recover', 'slackoff', 'healorder', 'milkdrink', 'roost', 'moonlight', 'morningsun', + 'synthesis', 'wish', 'aquaring', 'rest', 'softboiled', 'swallow', 'leechseed', +] as ID[]); + +export function computeRecoveryMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + // Moves that heal yourself + const healing = moves + .filter(m => m.flags.heal && !m.selfdestruct && (m.target === 'self' || m.target === 'allies')) + .map(m => m.id); + + return new Set([ + ...healing, + ...(gen.num >= 4 ? ['aquaring'] as ID[] : []), + 'leechseed' as ID, + ]); +} + +export const PROTECT_MOVES = new Set([ + 'protect', 'detect', 'kingsshield', 'matblock', 'spikyshield', +] as ID[]); + +export function computeProtectionMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + return new Set( + moves + .filter(m => m.stallingMove && !['endure', 'quickguard', 'wideguard'].includes(m.id)) + .map(m => m.id) + ); +} + +export const PHAZING_MOVES = new Set(['whirlwind', 'roar', 'circlethrow', 'dragontail'] as ID[]); + +export function computePhazingMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + return new Set( + moves.filter(m => m.forceSwitch).map(m => m.id) + ); +} + +export const PARALYSIS_MOVES = new Set(['thunderwave', 'stunspore', 'glare', 'nuzzle'] as ID[]); + +export function computeParalysisMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + // Non-damaging moves that induce paralysis + const paralysisMoves = moves.filter(m => m.status === 'par').map(m => m.id); + + // Attacking moves that are guaranteed to induce paralysis + const paralysisAttacks = moves.filter(m => m.secondary && m.secondary.status === 'par' && + m.secondary.chance === 100 && m.accuracy === 100).map(m => m.id); + + return new Set([ + ...paralysisMoves, + ...paralysisAttacks, + ]); +} + +export const CONFUSION_MOVES = new Set([ + 'supersonic', 'confuseray', 'swagger', 'flatter', 'teeterdance', 'yawn', +] as ID[]); + +export function computeConfusionMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + // Non-damaging moves that induce confusion + const confusionMoves = moves.filter(m => m.volatileStatus === 'confusion').map(m => m.id); + + // Attacking moves that are guaranteed to induce confusion + const confusionAttacks = moves.filter(m => m.secondary && + m.secondary.volatileStatus === 'confusion' && m.secondary.chance === 100 && + m.accuracy === 100).map(m => m.id); + + return new Set([ + ...confusionMoves, + ...confusionAttacks, + // Yawn is treated as a confusion move + ...(gen.num >= 3 ? ['yawn'] as ID[] : []), + ]); +} + +export const SLEEP_MOVES = new Set([ + 'darkvoid', 'grasswhistle', 'hypnosis', 'lovelykiss', 'sing', 'sleeppowder', 'spore', +] as ID[]); + +export function computeSleepMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + // Non-damaging moves that induce sleep + const sleepMoves = moves.filter(m => m.status === 'slp').map(m => m.id); + + // Attacking moves that are guaranteed to induce sleep + const sleepAttacks = moves.filter(m => m.secondary && m.secondary.status === 'slp' && + m.secondary.chance === 100 && m.accuracy === 100).map(m => m.id); + + return new Set([ + ...sleepMoves, + ...sleepAttacks, + ]); +} + +export const OHKO_MOVES = new Set(['guillotine', 'fissure', 'sheercold'] as ID[]); + +export function computeOHKOMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + return new Set( + moves.filter(m => m.ohko).map(m => m.id) + ); +} + +export const GREATER_OFFENSIVE_MOVES = new Set([ + 'selfdestruct', 'explosion', 'destinybond', 'perishsong', + 'memento', 'healingwish', 'lunardance', 'finalgambit', +] as ID[]); + +export function computeGreaterOffensiveMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + const selfKOMoves = moves.filter(m => m.selfdestruct).map(m => m.id); + + return new Set([ + ...selfKOMoves, + // These are moves that also deal with the user getting KOed + ...(gen.num >= 2 ? ['destinybond', 'perishsong'] as ID[] : []), + ]); +} + +export const LESSER_OFFENSIVE_MOVES = new Set([ + 'jumpkick', 'doubleedge', 'submission', 'petaldance', 'hijumpkick', 'outrage', + 'volttackle', 'closecombat', 'flareblitz', 'bravebird', 'woodhammer', 'headsmash', + 'headcharge', 'wildcharge', 'takedown', 'dragonascent', +] as ID[]); + +export function computeLesserOffensiveMoves(gen: Generation) { + const moves = Array.from(gen.moves); + + // Moves that inflict recoil + const recoil = moves.filter(m => m.recoil).map(m => m.id); + // Moves that inflict damage to the user if they miss + const crashDamage = moves.filter(m => m.hasCrashDamage).map(m => m.id); + // Moves that lock the user into using the move for multiple turns + const lockedMove = moves.filter(m => m.self && m.self.volatileStatus === 'lockedmove') + .map(m => m.id); + // Moves that drop the user's defenses (but not their attack or speed) + const dropDefenses = moves.filter(m => m.self?.boosts && + ((m.self.boosts.def && m.self.boosts.def < 0) || + (m.self.boosts.spd && m.self.boosts.spd < 0)) && + !((m.self.boosts.atk && m.self.boosts.atk < 0) || + (m.self.boosts.spa && m.self.boosts.spa < 0) || + (m.self.boosts.spe && m.self.boosts.spe < 0))).map(m => m.id); + + return new Set([ + ...recoil, + ...crashDamage, + ...lockedMove, + ...dropDefenses, + ]); +} + +function targetsFoes(move: Move) { + return ['normal', 'adjacentFoe', 'allAdjacentFoes', 'foeSide'].includes(move.target); +} diff --git a/stats/src/test/classifier.test.ts b/stats/src/test/classifier.test.ts new file mode 100644 index 0000000..145e2f0 --- /dev/null +++ b/stats/src/test/classifier.test.ts @@ -0,0 +1,126 @@ +import {Generations, ID} from '@pkmn/data'; +import {Dex} from '@pkmn/dex'; + +import * as classifier from '../classifier'; + +const GEN = new Generations(Dex).get(6); + +/** + * Returns set of items in set `a` that are not in set `b`. + */ +function getDifference(a: Set, b: Set) { + return new Set( + [...a].filter((value) => !b.has(value)) + ); +} + +describe('Classifier', () => { + test('GREATER_SETUP_MOVES', () => { + const COMPUTED_GREATER_MOVES = classifier.computeGreaterSetupMoves(GEN); + + // Moves that Antar incorrectly included (nothing) + expect(getDifference(classifier.GREATER_SETUP_MOVES, COMPUTED_GREATER_MOVES)) + .toEqual(new Set([])); + + // Moves that Antar forgot to include + expect(getDifference(COMPUTED_GREATER_MOVES, classifier.GREATER_SETUP_MOVES)) + .toEqual(new Set(['diamondstorm'])); + }); + test('LESSER_SETUP_MOVES', () => { + const COMPUTED_LESSER_SETUP_MOVES = classifier.computeLesserSetupMoves(GEN); + + // Moves that Antar incorrectly included (nothing) + expect(getDifference(classifier.LESSER_SETUP_MOVES, COMPUTED_LESSER_SETUP_MOVES)) + .toEqual(new Set([])); + + // Moves that Antar forgot to include + expect(getDifference(COMPUTED_LESSER_SETUP_MOVES, classifier.LESSER_SETUP_MOVES)) + .toEqual(new Set(['honeclaws'])); + }); + test('SETUP_MOVES', () => { + const COMPUTED_SETUP_MOVES = classifier.computeBatonPassMoves(GEN); + + // Moves that Antar incorrectly included (nothing) + expect(getDifference(classifier.SETUP_MOVES, COMPUTED_SETUP_MOVES)) + .toEqual(new Set([])); + + // Moves that Antar forgot to include + expect(getDifference(COMPUTED_SETUP_MOVES, classifier.SETUP_MOVES)) + .toEqual(new Set(['geomancy', 'diamondstorm', 'poweruppunch'])); + }); + test('GRAVITY_MOVES', () => { + const COMPUTED_GRAVITY_MOVES = classifier.computeGravityMoves(GEN); + + // Moves that Antar incorrectly included despite having high accuracy + expect(getDifference(classifier.GRAVITY_MOVES, COMPUTED_GRAVITY_MOVES)) + .toEqual(new Set(['clamp', 'willowisp', 'psywave', 'rocktomb'])); + + // Moves that Antar forgot to include + expect(getDifference(COMPUTED_GRAVITY_MOVES, classifier.GRAVITY_MOVES)) + .toEqual(new Set(['horndrill', 'precipiceblades', 'sandtomb', 'stickyweb', 'landswrath'])); + }); + test('RECOVERY_MOVES', () => { + const COMPUTED_RECOVERY_MOVES = classifier.computeRecoveryMoves(GEN); + + expect(COMPUTED_RECOVERY_MOVES).toEqual(classifier.RECOVERY_MOVES); + }); + test('PROTECT_MOVES', () => { + const COMPUTED_PROTECT_MOVES = classifier.computeProtectionMoves(GEN); + + expect(COMPUTED_PROTECT_MOVES).toEqual(classifier.PROTECT_MOVES); + }); + test('PHAZING_MOVES', () => { + const COMPUTED_PHAZING_MOVES = classifier.computePhazingMoves(GEN); + + expect(COMPUTED_PHAZING_MOVES).toEqual(classifier.PHAZING_MOVES); + }); + + test('PARALYSIS_MOVES', () => { + const COMPUTED_PARALYSIS_MOVES = classifier.computeParalysisMoves(GEN); + + expect(COMPUTED_PARALYSIS_MOVES).toEqual(classifier.PARALYSIS_MOVES); + }); + test('CONFUSION_MOVES', () => { + const COMPUTED_CONFUSION_MOVES = classifier.computeConfusionMoves(GEN); + + // Moves that Antar incorrectly included (nothing) + expect(getDifference(classifier.CONFUSION_MOVES, COMPUTED_CONFUSION_MOVES)) + .toEqual(new Set([])); + + // Confusion moves that Antar forgot to include + expect(getDifference(COMPUTED_CONFUSION_MOVES, classifier.CONFUSION_MOVES)) + .toEqual(new Set(['chatter', 'sweetkiss'])); + }); + test('SLEEP_MOVES', () => { + const COMPUTED_SLEEP_MOVES = classifier.computeSleepMoves(GEN); + + expect(COMPUTED_SLEEP_MOVES).toEqual(classifier.SLEEP_MOVES); + }); + test('OHKO_MOVES', () => { + const COMPUTED_OHKO_MOVES = classifier.computeOHKOMoves(GEN); + + // OHKO moves that Antar incorrectly included (nothing) + expect(getDifference(classifier.OHKO_MOVES, COMPUTED_OHKO_MOVES)).toEqual(new Set([])); + + // OHKO moves that Antar forgot to include + expect(getDifference(COMPUTED_OHKO_MOVES, classifier.OHKO_MOVES)) + .toEqual(new Set(['horndrill'])); + }); + test('GREATER_OFFENSIVE_MOVES', () => { + const COMPUTED_GREATER_OFFENSIVE_MOVES = classifier.computeGreaterOffensiveMoves(GEN); + + expect(COMPUTED_GREATER_OFFENSIVE_MOVES).toEqual(classifier.GREATER_OFFENSIVE_MOVES); + }); + test('LESSER_OFFENSIVE_MOVES', () => { + const COMPUTED_LESSER_OFFENSIVE_MOVES = classifier.computeLesserOffensiveMoves(GEN); + + // Lesser offensive that Antar incorrectly included (nothing) + // "High" Jump Kick used to be spelled as "Hi" and that spelling was used + expect(getDifference(classifier.LESSER_OFFENSIVE_MOVES, COMPUTED_LESSER_OFFENSIVE_MOVES)) + .toEqual(new Set(['hijumpkick'])); + + // Lesser offensive moves that Antar forgot to include + expect(getDifference(COMPUTED_LESSER_OFFENSIVE_MOVES, classifier.LESSER_OFFENSIVE_MOVES)) + .toEqual(new Set(['hyperspacefury', 'highjumpkick', 'thrash'])); + }); +});