From 8797d4628c6d7fa26732442a0ffc82716e3358f8 Mon Sep 17 00:00:00 2001 From: Kris Johnson <11083252+KrisXV@users.noreply.github.com> Date: Sun, 31 Aug 2025 23:57:15 -0600 Subject: [PATCH] Remove inactive ladders --- CODEOWNERS | 3 +- config/formats.ts | 13 +- data/aliases.ts | 6 +- data/cg-team-data.ts | 78 -- data/cg-teams.ts | 1098 ---------------------- data/mods/gen9ssb/moves.ts | 7 +- data/rulesets.ts | 5 - databases/schemas/battlestats.sql | 22 - server/chat-plugins/cg-teams-leveling.ts | 163 ---- sim/teams.ts | 4 +- test/server/cg-teams.js | 61 -- 11 files changed, 9 insertions(+), 1451 deletions(-) delete mode 100644 data/cg-team-data.ts delete mode 100644 data/cg-teams.ts delete mode 100644 databases/schemas/battlestats.sql delete mode 100644 server/chat-plugins/cg-teams-leveling.ts delete mode 100644 test/server/cg-teams.js diff --git a/CODEOWNERS b/CODEOWNERS index 60436be276..32c4da4566 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,8 +1,7 @@ config/formats.ts @KrisXV @Marty-D data/mods/gen9ssb/ @HoeenCoder @HisuianZoroark @KrisXV -data/random-battles/ @AnnikaCodes @KrisXV @MathyFurret @ACakeWearingAHat @livid-washed @adrivrie +data/random-battles/ @KrisXV @MathyFurret @ACakeWearingAHat @livid-washed @adrivrie data/text/ @Marty-D -data/cg-team*.ts @KrisXV @pyuk databases/ @monsanto lib/sql.ts @mia-pi-git server/artemis/* @mia-pi-git diff --git a/config/formats.ts b/config/formats.ts index d1c22ca934..9ac42a75bf 100644 --- a/config/formats.ts +++ b/config/formats.ts @@ -327,6 +327,7 @@ export const Formats: import('../sim/dex-formats').FormatList = [ { name: "[Gen 9] Ubers UU", mod: 'gen9', + searchShow: false, ruleset: ['[Gen 9] Ubers'], banlist: [ // Ubers OU @@ -3175,6 +3176,7 @@ export const Formats: import('../sim/dex-formats').FormatList = [ name: "[Gen 9] BSS Factory", desc: `Randomized 3v3 Singles featuring Pokémon and movesets popular in Battle Stadium Singles.`, mod: 'gen9', + searchShow: false, team: 'randomBSSFactory', bestOfDefault: true, ruleset: ['Flat Rules', 'VGC Timer'], @@ -3183,6 +3185,7 @@ export const Formats: import('../sim/dex-formats').FormatList = [ name: "[Gen 9] Draft Factory", desc: `Randomized matchups sourced from various 6v6 singles draft leagues.`, mod: 'gen9', + searchShow: false, team: 'randomDraftFactory', bestOfDefault: true, ruleset: ['Standard Draft', '!Team Preview'], @@ -3218,16 +3221,6 @@ export const Formats: import('../sim/dex-formats').FormatList = [ bestOfDefault: true, ruleset: ['Obtainable', 'Species Clause', 'HP Percentage Mod', 'Cancel Mod', 'Sleep Clause Mod', 'Illusion Level Mod'], }, - { - name: "[Gen 9] Computer-Generated Teams", - desc: `Teams generated automatically based on heuristics (rules), with levels based on previous success/failure in battle. ` + - `Not affiliated with Random Battles formats. Some sets will by nature be worse than others, but you can report egregiously bad sets ` + - `with this form.`, - mod: 'gen9', - team: 'computerGenerated', - bestOfDefault: true, - ruleset: ['Obtainable', 'Species Clause', 'HP Percentage Mod', 'Cancel Mod', 'Sleep Clause Mod', 'Illusion Level Mod'], - }, { name: "[Gen 9] Hackmons Cup", desc: `Randomized teams of level-balanced Pokémon with absolutely any ability, moves, and item.`, diff --git a/data/aliases.ts b/data/aliases.ts index 7a675be9a0..97f851987f 100644 --- a/data/aliases.ts +++ b/data/aliases.ts @@ -62,8 +62,6 @@ export const Aliases: import('../sim/dex').AliasesTable = { gen6hackmons: "[Gen 6] Pure Hackmons", cc1v1: "[Gen 9] Challenge Cup 1v1", cc2v2: "[Gen 9] Challenge Cup 2v2", - cgt: "[Gen 9] Computer-Generated Teams", - compgen: "[Gen 9] Computer-Generated Teams", hc: "[Gen 9] Hackmons Cup", bf: "[Gen 8] Battle Factory", bssf: "[Gen 9] BSS Factory", @@ -90,8 +88,8 @@ export const Aliases: import('../sim/dex').AliasesTable = { gen6ag: "[Gen 6] Anything Goes", crossevo: "[Gen 9] Cross Evolution", mayhem: "[Gen 9] Random Battle Mayhem", - omotm: "[Gen 9] Alphabet Cup", - lcotm: "[Gen 9] Inheritance", + omotm: "[Gen 9] Bad 'n Boosted", + lcotm: "[Gen 9] Revelationmons", // mega evos --- 1st ordered alphabetically by species, 2nd by alias megasnow: "Abomasnow-Mega", diff --git a/data/cg-team-data.ts b/data/cg-team-data.ts deleted file mode 100644 index 4bbd0685b7..0000000000 --- a/data/cg-team-data.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Data for computer-generated teams - -export const MOVE_PAIRINGS: { [moveID: IDEntry]: IDEntry } = { - rest: 'sleeptalk', - sleeptalk: 'rest', -}; - -// Bonuses to move ratings by ability -export const ABILITY_MOVE_BONUSES: { [abilityID: IDEntry]: { [moveID: IDEntry]: number } } = { - contrary: { terablast: 2 }, - drought: { sunnyday: 0.2, solarbeam: 2 }, - drizzle: { raindance: 0.2, solarbeam: 0.2, hurricane: 2 }, -}; -// Bonuses to move ratings by move type -export const ABILITY_MOVE_TYPE_BONUSES: { [abilityID: IDEntry]: { [typeName: string]: number } } = { - darkaura: { Dark: 1.33 }, - dragonsmaw: { Dragon: 1.5 }, - fairyaura: { Fairy: 1.33 }, - steelworker: { Steel: 1.5 }, - steelyspirit: { Steel: 1.5 }, - transistor: { Electric: 1.3 }, - - // -ate moves - pixilate: { Normal: 1.5 * 1.2 }, - refrigerate: { Normal: 1.5 * 1.2 }, - aerilate: { Normal: 1.5 * 1.2 }, - normalize: { Normal: 1.2 }, - - // weather - drizzle: { Water: 1.4, Fire: 0.6 }, - drought: { Fire: 1.4, Water: 0.6 }, -}; -// For moves whose quality isn't obvious from data -// USE SPARINGLY! -export const HARDCODED_MOVE_WEIGHTS: { [moveID: IDEntry]: number } = { - // Fails unless user is asleep - snore: 0, - // Hard to use - lastresort: 0.1, dreameater: 0.1, - // Useless without Berry + sucks even then - belch: 0.2, - - // Power increases in conditions out of our control that may occur - avalanche: 1.2, - ficklebeam: 1.3, - hex: 1.2, - stompingtantrum: 1.2, - temperflare: 1.2, - - // Attacks that set hazards on hit - // We REALLY like hazards - stoneaxe: 16, - ceaselessedge: 16, - - // screens - lightscreen: 3, reflect: 3, auroraveil: 3, // TODO: make sure AVeil always gets Snow? - tailwind: 2, - - // mess with the opponent - taunt: 2, disable: 2, encore: 3, - - // healing moves - // TODO: should healing moves be more common on bulkier pokemon? - // 25% - junglehealing: 3, lifedew: 3, - // 50% - milkdrink: 5, moonlight: 5, morningsun: 5, recover: 5, roost: 5, - shoreup: 5, slackoff: 5, softboiled: 5, synthesis: 5, - // delayed/consequence - rest: 3, // has sleeptalk potential - wish: 2, - - // requires terrain - steelroller: 0.1, -}; - -export const WEIGHT_BASED_MOVES = ['heatcrash', 'heavyslam', 'lowkick', 'grassknot']; -export const TARGET_HP_BASED_MOVES = ['crushgrip', 'hardpress']; diff --git a/data/cg-teams.ts b/data/cg-teams.ts deleted file mode 100644 index f87d334e4e..0000000000 --- a/data/cg-teams.ts +++ /dev/null @@ -1,1098 +0,0 @@ -/** - * Computer-Generated Teams - * - * Generates teams based on heuristics, most of which carry over across generations. - * Teams generated will not always be competitively great, but they will have variety - * and be fun to play (i.e., tries to avoid awful sets). - * - * The [Gen 9] Computer-Generated Teams format is personally maintained by Annika, - * and is not part of any official Smogon or PS format selection. If you enjoy playing - * with teams you didn't make yourself, you may want to check out Random Battles, Battle Factory, - * and/or the sample teams for usage-based formats like OU. - * - * The core of the generator is the weightedRandomPick function, which chooses from an array - * of options based on a weight associated with each option. This way, better/stronger/more useful options - * are more likely to be chosen, but there's still an opportunity for weaker, more situational, - * or higher-risk/higher-reward options to be chosen. However, for moves, the 'worst' moves are excluded - * altogether, both to reduce the likelihood of a bad moveset and improve generator performance. - * - * Certain less-relevant aspects of the set are not randomized at all, such as: - * - IVs (all 31s, with 0 Attack IV if the Pokémon has no Physical moves in case of Confusion) - * - EVs (84 per stat, for +21 to each) - * - Nature (always Quirky, which has no effect) - * - Happiness (there are no Happiness-based moves in Gen IX) - * - * Currently, leveling is based on a Pokémon's position within Smogon's usage-based tiers, - * but an automatic leveling system is planned for the future. This would involve storing win and loss - * data by Pokémon species in a database, and increasing and decreasing the levels of Pokémon species - * each day based on their win/loss ratio. For example, if 60% of matches with a Pokémon species are wins, - * the species is probably overleveled! - * - * Other aspects of the team generator that may be worth implementing in the future include: - * - Explicit support for weather-oriented teams (boosting moves and typings that synergize with that weather) - * - Tracking type coverage to make it more likely that a moveset can hit every type - */ - -import type { SQLDatabaseManager } from '../lib/sql'; -import { Dex, PRNG, SQL } from '../sim'; -import type { EventMethods } from '../sim/dex-conditions'; -import { - ABILITY_MOVE_BONUSES, - ABILITY_MOVE_TYPE_BONUSES, - HARDCODED_MOVE_WEIGHTS, - MOVE_PAIRINGS, - TARGET_HP_BASED_MOVES, - WEIGHT_BASED_MOVES, -} from './cg-team-data'; - -interface TeamStats { - hazardSetters: { [moveid: string]: number }; - typeWeaknesses: { [type: string]: number }; - hazardRemovers: number; -} -interface MovesStats { - attackTypes: { [type: string]: number }; - setup: { atk: number, def: number, spa: number, spd: number, spe: number }; - noSleepTalk: number; - hazards: number; - stallingMoves: number; - nonStatusMoves: number; - healing: number; -} - -// We put a limit on the number of Pokémon on a team that can be weak to a given type. -const MAX_WEAK_TO_SAME_TYPE = 3; -/** An estimate of the highest raw speed in the metagame */ -const TOP_SPEED = 300; - -const levelOverride: { [speciesID: string]: number } = {}; -export let levelUpdateInterval: NodeJS.Timeout | null = null; - -// can't import the function cg-teams-leveling.ts uses to this context for some reason -const useBaseSpecies = [ - 'Pikachu', - 'Gastrodon', - 'Magearna', - 'Dudunsparce', - 'Maushold', - 'Keldeo', - 'Zarude', - 'Polteageist', - 'Sinistcha', - 'Sawsbuck', - 'Vivillon', - 'Florges', - 'Minior', - 'Toxtricity', - 'Tatsugiri', - 'Alcremie', -]; - -async function updateLevels(database: SQL.DatabaseManager) { - const updateSpecies = await database.prepare( - 'UPDATE gen9computergeneratedteams SET wins = 0, losses = 0, level = ? WHERE species_id = ?' - ); - const updateHistory = await database.prepare( - `INSERT INTO gen9_historical_levels (level, species_id, timestamp) VALUES (?, ?, ${Date.now()})` - ); - const data: { species_id: ID, wins: number, losses: number, level: number }[] = await database.all( - 'SELECT species_id, wins, losses, level FROM gen9computergeneratedteams' - ); - for (let { species_id, wins, losses, level } of data) { - const total = wins + losses; - - if (total > 10) { - if (wins / total >= 0.55) level--; - if (wins / total <= 0.45) level++; - level = Math.max(1, Math.min(100, level)); - await updateSpecies?.run([level, species_id]); - await updateHistory?.run([level, species_id]); - } - - levelOverride[species_id] = level; - } -} - -export let cgtDatabase: SQLDatabaseManager; -if (global.Config && Config.usesqlite && Config.usesqliteleveling) { - cgtDatabase = SQL(module, { file: './databases/battlestats.db' }); - - // update every 2 hours - void updateLevels(cgtDatabase); - levelUpdateInterval = setInterval(() => void updateLevels(cgtDatabase), 1000 * 60 * 60 * 2); -} - -export default class TeamGenerator { - dex: ModdedDex; - format: Format; - teamSize: number; - forceLevel?: number; - prng: PRNG; - itemPool: Item[]; - specialItems: { [pokemon: string]: string }; - - constructor(format: Format | string, seed: PRNG | PRNGSeed | null) { - this.dex = Dex.forFormat(format); - this.format = Dex.formats.get(format); - this.teamSize = this.format.ruleTable?.maxTeamSize || 6; - this.prng = PRNG.get(seed); - this.itemPool = this.dex.items.all().filter(i => i.exists && i.isNonstandard !== 'Past' && !i.isPokeball); - this.specialItems = {}; - for (const i of this.itemPool) { - if (i.itemUser && !i.isNonstandard) { - for (const user of i.itemUser) { - if (Dex.species.get(user).requiredItems?.[0] !== i.name) this.specialItems[user] = i.id; - } - } - } - - const rules = Dex.formats.getRuleTable(this.format); - if (rules.adjustLevel) this.forceLevel = rules.adjustLevel; - } - - getTeam(): PokemonSet[] { - let speciesPool = this.dex.species.all().filter(s => { - if (!s.exists) return false; - if (s.isNonstandard || s.isNonstandard === 'Unobtainable') return false; - if (s.nfe) return false; - if (s.battleOnly && (!s.requiredItems?.length || s.name.endsWith('-Tera'))) return false; - - return true; - }); - const teamStats: TeamStats = { - hazardSetters: {}, - typeWeaknesses: {}, - hazardRemovers: 0, - }; - - const team: PokemonSet[] = []; - while (team.length < this.teamSize && speciesPool.length) { - const species = this.prng.sample(speciesPool); - - const haveRoomToReject = speciesPool.length >= (this.teamSize - team.length); - const isGoodFit = this.speciesIsGoodFit(species, teamStats); - if (haveRoomToReject && !isGoodFit) continue; - - speciesPool = speciesPool.filter(s => s.baseSpecies !== species.baseSpecies); - team.push(this.makeSet(species, teamStats)); - } - - return team; - } - - protected makeSet(species: Species, teamStats: TeamStats): PokemonSet { - const abilityPool: string[] = Object.values(species.abilities); - const abilityWeights = abilityPool.map(a => this.getAbilityWeight(this.dex.abilities.get(a))); - const ability = this.weightedRandomPick(abilityPool, abilityWeights); - const level = this.forceLevel || TeamGenerator.getLevel(species); - - const moves: Move[] = []; - let movesStats: MovesStats = { - setup: { atk: 0, def: 0, spa: 0, spd: 0, spe: 0 }, - attackTypes: {}, - noSleepTalk: 0, - hazards: 0, - stallingMoves: 0, - healing: 0, - nonStatusMoves: 0, - }; - - let movePool: IDEntry[] = [...this.dex.species.getMovePool(species.id)]; - if (!movePool.length) throw new Error(`No moves for ${species.id}`); - - // Consider either the top 15 moves or top 30% of moves, whichever is greater. - const numberOfMovesToConsider = Math.min(movePool.length, Math.max(15, Math.trunc(movePool.length * 0.3))); - let movePoolIsTrimmed = false; - // Many moves' weights, such as Swords Dance, are dependent on having other moves in the moveset already - // and end up very low when calculated with no moves chosen. This makes it difficult to add these moves without - // weighing every move 4 times, and trimming once after the initial weighing makes them impossible for most Pokemon. - // To get around this, after weighing against an empty moveset, trimming, and adding three moves, we weigh ALL - // moves again against the populated moveset, then put the chosen 3 moves back into the pool with their - // original empty-set weights, trim the pool again, and start over. This process results in about 15% fewer calls - // to getMoveWeight than considering every move every time does. - let isRound2 = false; - // this is just a second reference the array because movePool gets set to point to a new array before the old one - // gets mutated - const movePoolCopy = movePool; - let interimMovePool: { move: IDEntry, weight: number }[] = []; - while (moves.length < 4 && movePool.length) { - let weights; - if (!movePoolIsTrimmed) { - if (!isRound2) { - for (const moveID of movePool) { - const move = this.dex.moves.get(moveID); - const weight = this.getMoveWeight(move, teamStats, species, moves, movesStats, ability, level); - interimMovePool.push({ move: moveID, weight }); - } - - interimMovePool.sort((a, b) => b.weight - a.weight); - } else { - const originalWeights: typeof interimMovePool = []; - for (const move of moves) { - originalWeights.push(interimMovePool.find(m => m.move === move.id)!); - } - interimMovePool = originalWeights; - - for (const moveID of movePoolCopy) { - const move = this.dex.moves.get(moveID); - if (moves.includes(move)) continue; - const weight = this.getMoveWeight(move, teamStats, species, moves, movesStats, ability, level); - interimMovePool.push({ move: moveID, weight }); - } - - interimMovePool.sort((a, b) => b.weight - a.weight); - moves.splice(0); - movesStats = { - setup: { atk: 0, def: 0, spa: 0, spd: 0, spe: 0 }, - attackTypes: {}, - noSleepTalk: 0, - hazards: 0, - stallingMoves: 0, - healing: 0, - nonStatusMoves: 0, - }; - } - movePool = []; - weights = []; - - for (let i = 0; i < numberOfMovesToConsider; i++) { - movePool.push(interimMovePool[i].move); - weights.push(interimMovePool[i].weight); - } - movePoolIsTrimmed = true; - } else { - weights = movePool.map( - m => this.getMoveWeight(this.dex.moves.get(m), teamStats, species, moves, movesStats, ability, level) - ); - } - - const moveID = this.weightedRandomPick(movePool, weights, { remove: true }); - - const move = this.dex.moves.get(moveID); - moves.push(move); - if (TeamGenerator.moveIsHazard(moves[moves.length - 1])) { - teamStats.hazardSetters[moveID] = (teamStats.hazardSetters[moveID] || 0) + 1; - movesStats.hazards++; - } - if (['defog', 'courtchange', 'tidyup', 'rapidspin', 'mortalspin'].includes(moveID)) teamStats.hazardRemovers++; - const boosts = move.boosts || move.self?.boosts || move.selfBoost?.boosts || - ability !== 'Sheer Force' && move.secondary?.self?.boosts; - if (move.category === 'Status') { - if (boosts) { - for (const stat in boosts) { - const chance = Math.min(100, move.secondary?.chance || 100 * (ability === 'Serene Grace' ? 2 : 1)); - const boost = (boosts[stat as StatIDExceptHP] || 0) * chance / 100; - if (boost) { - if (movesStats.setup[stat as StatIDExceptHP] < 0 && boost > 0) { - movesStats.setup[stat as StatIDExceptHP] = boost; - } else { - movesStats.setup[stat as StatIDExceptHP] += boost; - } - if (boost > 1) movesStats.noSleepTalk++; - } - } - } else { - movesStats.noSleepTalk++; - } - if (move.heal) movesStats.healing++; - if (move.stallingMove) movesStats.stallingMoves++; - } else { - movesStats.nonStatusMoves++; - const bp = +move.basePower; - const moveType = TeamGenerator.moveType(move, species); - if ((movesStats.attackTypes[moveType] || 0) < bp) movesStats.attackTypes[moveType] = bp; - } - - if (!isRound2 && moves.length === 3) { - isRound2 = true; - movePoolIsTrimmed = false; - continue; - } - - // add paired moves, like RestTalk - const pairedMove = MOVE_PAIRINGS[moveID]; - const alreadyHavePairedMove = moves.some(m => m.id === pairedMove); - if ( - moves.length < 4 && - pairedMove && - !(pairedMove === 'sleeptalk' && movesStats.noSleepTalk) && - !alreadyHavePairedMove && - // We don't check movePool because sometimes paired moves are bad. - this.dex.species.getLearnsetData(species.id).learnset?.[pairedMove] - ) { - moves.push(this.dex.moves.get(pairedMove)); - const pairedMoveIndex = movePool.indexOf(pairedMove); - if (pairedMoveIndex > -1) movePool.splice(pairedMoveIndex, 1); - } - } - - let item = ''; - const nonStatusMoves = moves.filter(m => this.dex.moves.get(m).category !== 'Status'); - if (species.requiredItem) { - item = species.requiredItem; - } else if (species.requiredItems) { - item = this.prng.sample(species.requiredItems.filter(i => !this.dex.items.get(i).isNonstandard)); - } else if (this.specialItems[species.name] && nonStatusMoves.length) { - // If the species has a special item, we should use it. - item = this.specialItems[species.name]; - } else if (moves.every(m => m.id !== 'acrobatics')) { // Don't assign an item if the set includes Acrobatics... - const weights = []; - const items = []; - for (const i of this.itemPool) { - const weight = this.getItemWeight(i, teamStats, species, moves, ability, level); - if (weight !== 0) { - weights.push(weight); - items.push(i.name); - } - } - if (!item) item = this.weightedRandomPick(items, weights); - } else if (['Quark Drive', 'Protosynthesis'].includes(ability)) { - // ...unless the Pokemon can use Booster Energy - item = 'Booster Energy'; - } - - const ivs: PokemonSet['ivs'] = { - hp: 31, - atk: moves.some(move => this.dex.moves.get(move).category === 'Physical') ? 31 : 0, - def: 31, - spa: 31, - spd: 31, - spe: 31, - }; - - // For Tera Type, we just pick a random type if it's got Tera Blast, Revelation Dance, or no attacking moves - // In the latter case, we avoid picking a type the Pokemon already is, and in the other two we avoid picking a - // type that matches the Pokemon's other moves - // Otherwise, we pick the type of one of its attacking moves - // Pokemon with 3 or more attack types and Pokemon with both Tera Blast and Contrary can also get Stellar type - // but Pokemon with Adaptability never get Stellar because Tera Stellar makes Adaptability have no effect - // Ogerpon's formes are forced to the Tera type that matches their forme - // Terapagos is forced to Stellar type - // Pokemon with Black Sludge don't generally want to tera to a type other than Poison - const hasTeraBlast = moves.some(m => m.id === 'terablast'); - const hasRevelationDance = moves.some(m => m.id === 'revelationdance'); - let attackingTypes = nonStatusMoves.map(m => TeamGenerator.moveType(this.dex.moves.get(m), species)); - let teraType; - if (species.requiredTeraType) { - teraType = species.requiredTeraType; - } else if (item === 'blacksludge' && this.prng.randomChance(2, 3)) { - teraType = 'Poison'; - } else if (hasTeraBlast && ability === 'Contrary' && this.prng.randomChance(2, 3)) { - teraType = 'Stellar'; - } else { - const noStellar = ability === 'Adaptability' || new Set(attackingTypes).size < 3; - const noAttacks = !nonStatusMoves.length; - if (hasTeraBlast || hasRevelationDance || noAttacks) { - attackingTypes = this.dex.types.names().filter(t => !(noAttacks ? species.types : attackingTypes).includes(t)); - if (noStellar) attackingTypes.splice(attackingTypes.indexOf('Stellar')); - } else { - if (!noStellar) attackingTypes.push('Stellar'); - } - teraType = this.prng.sample(attackingTypes); - } - - return { - name: species.name, - species: species.name, - item, - ability, - moves: moves.map(m => m.name), - nature: 'Quirky', - gender: species.gender, - evs: { hp: 84, atk: 84, def: 84, spa: 84, spd: 84, spe: 84 }, - ivs, - level, - teraType, - shiny: this.prng.randomChance(1, 1024), - happiness: 255, - }; - } - - /** - * @returns true if the Pokémon is a good fit for the team so far, and no otherwise - */ - protected speciesIsGoodFit(species: Species, stats: TeamStats): boolean { - // type check - for (const typeName of this.dex.types.names()) { - const effectiveness = this.dex.getEffectiveness(typeName, species.types); - if (effectiveness === 1) { // WEAKNESS! - if (stats.typeWeaknesses[typeName] === undefined) { - stats.typeWeaknesses[typeName] = 0; - } - if (stats.typeWeaknesses[typeName] >= MAX_WEAK_TO_SAME_TYPE) { - // too many weaknesses to this type - return false; - } - } - } - // species passes; increment counters - for (const typeName of this.dex.types.names()) { - const effectiveness = this.dex.getEffectiveness(typeName, species.types); - if (effectiveness === 1) { - stats.typeWeaknesses[typeName]++; - } - } - return true; - } - - /** - * @returns A weighting for the Pokémon's ability. - */ - protected getAbilityWeight(ability: Ability): number { - return ability.rating + 1; // Some ability ratings are -1 - } - - protected static moveIsHazard(move: Move): boolean { - return !!(move.sideCondition && move.target === 'foeSide') || ['stoneaxe', 'ceaselessedge'].includes(move.id); - } - - /** - * @returns A weight for a given move on a given Pokémon. - */ - protected getMoveWeight( - move: Move, - teamStats: TeamStats, - species: Species, - movesSoFar: Move[], - movesStats: MovesStats, - ability: string, - level: number, - ): number { - if (!move.exists) return 0; - // this is NOT doubles, so there will be no adjacent ally - if (move.target === 'adjacentAlly') return 0; - - // There's an argument to be made for using Terapagos-Stellar's stats instead - // but the important thing is to not use Terapagos-Base's stats since it never battles in that forme - if (ability === 'Tera Shift') species = this.dex.species.get('Terapagos-Terastal'); - - // Attack and Special Attack are scaled by level^2 because in addition to stats themselves being scaled by level, - // damage dealt by attacks is also scaled by the user's level - const adjustedStats: StatsTable = { - hp: species.baseStats.hp * level / 100 + level, - atk: species.baseStats.atk * level * level / 10000, - def: species.baseStats.def * level / 100, - spa: species.baseStats.spa * level * level / 10000, - spd: species.baseStats.spd * level / 100, - spe: species.baseStats.spe * level / 100, - }; - - if (move.category === 'Status') { - // The initial value of this weight determines how valuable status moves are vs. attacking moves. - // You can raise it to make random status moves more valuable or lower it and increase multipliers - // to make only CERTAIN status moves valuable. - let weight = 2400; - - // inflicts status - if (move.status) weight *= TeamGenerator.statusWeight(move.status) * 2; - - // hazard setters: very important, but we don't need 2 pokemon to set the same hazard on a team - if (TeamGenerator.moveIsHazard(move) && (teamStats.hazardSetters[move.id] || 0) < 1) { - weight *= move.id === 'spikes' ? 12 : 16; - - // if we are ALREADY setting hazards, setting MORE is really good - if (movesStats.hazards) weight *= 2; - } - - // hazard removers: even more important than hazard setters, since they remove everything at once - // we still don't need too many on one team, though - if (['defog', 'courtchange', 'tidyup'].includes(move.id) && !teamStats.hazardRemovers) { - weight *= 32; - - // these moves can also lessen the effectiveness of the user's team's own hazards - weight *= 0.8 ** Object.values(teamStats.hazardSetters).reduce((total, num) => total + num, 0); - } - - // boosts - weight *= this.boostWeight(move, movesSoFar, species, ability, level); - weight *= this.opponentDebuffWeight(move); - - // nonstandard boosting moves - if (move.id === 'focusenergy' && ability !== 'Super Luck') { - const highCritMoves = movesSoFar.filter(m => m.critRatio && m.critRatio > 1); - weight *= 1 + highCritMoves.length * (ability === 'Sniper' ? 2 : 1); - } else if (move.id === 'tailwind' && ability === 'Wind Rider' && movesSoFar.some(m => m.category === 'Physical')) { - weight *= 2.5; // grants +1 attack, but isn't spammable - } - - // protection moves - useful for bulky/stally pokemon - if (!movesStats.stallingMoves) { - if (adjustedStats.def >= 80 || adjustedStats.spd >= 80 || adjustedStats.hp >= 80) { - switch (move.volatileStatus) { - case 'endure': - weight *= 2; - break; - case 'protect': - weight *= 3; - break; - case 'kingsshield': case 'silktrap': - weight *= 4; - break; - case 'banefulbunker': case 'burningbulwark': case 'spikyshield': - weight *= 5; - break; - default: - break; - } - } - } - - // Hardcoded boosts - if (move.id in HARDCODED_MOVE_WEIGHTS) weight *= HARDCODED_MOVE_WEIGHTS[move.id]; - - // Rest and Sleep Talk are pretty bad on Pokemon that can't fall asleep - const sleepImmunities = [ - 'Comatose', - 'Purifying Salt', - 'Shields Down', - 'Insomnia', - 'Vital Spirit', - 'Sweet Veil', - 'Misty Surge', - 'Electric Surge', - 'Hadron Engine', - ]; - if (['sleeptalk', 'rest'].includes(move.id) && sleepImmunities.includes(ability)) return 0; - - // Sleep Talk is bad with moves that can't be used repeatedly, a.k.a. most status moves - // the exceptions allowed here are moves which boost a stat by exactly 1 and moves that wake the user up - if (move.id === 'sleeptalk') { - if (movesStats.noSleepTalk) weight *= 0.1; - } else if (movesSoFar.some(m => m.id === 'sleeptalk')) { - let sleepTalkSpammable = ['takeheart', 'junglehealing', 'healbell'].includes(move.id); - if (move.boosts) { - for (const stat in move.boosts) { - if (move.boosts[stat as StatIDExceptHP] === 1) { - sleepTalkSpammable = true; - break; - } - } - } - if (!sleepTalkSpammable) weight *= 0.1; - } - - // Pokémon with high Attack and Special Attack stats shouldn't have too many status moves, - // but on bulkier Pokémon it's more likely to be worth it. - const goodAttacker = adjustedStats.atk > 65 || adjustedStats.spa > 65; - if (goodAttacker && movesStats.nonStatusMoves < 2) { - weight *= 0.3; - } - - if (movesSoFar.length === 3 && movesStats.nonStatusMoves === 0) { - // uh oh - weight *= 0.6; - for (const stat in movesStats.setup) { - if (movesStats.setup[stat as StatIDExceptHP] > 0) { - // having no attacks is bad; having setup but no attacks is REALLY bad - weight *= 0.6; - } - } - } - - // don't need 2 healing moves - if (move.heal && movesStats.healing) weight *= 0.5; - - return weight; - } - - let basePower = move.basePower; - // For Grass Knot and friends, let's just assume they average out to around 60 base power. - // Same with Crush Grip and Hard Press - if (WEIGHT_BASED_MOVES.includes(move.id) || TARGET_HP_BASED_MOVES.includes(move.id)) basePower = 60; - /** A value from 0 to 1, where 0 is the fastest and 1 is the slowest */ - const slownessRating = Math.max(0, TOP_SPEED - adjustedStats.spe) / TOP_SPEED; - // not how this calc works but it should be close enough - if (move.id === 'gyroball') basePower = 150 * slownessRating * slownessRating; - if (move.id === 'electroball') basePower = 150 * (1 - slownessRating) * (1 - slownessRating); - - let baseStat = move.category === 'Physical' ? adjustedStats.atk : adjustedStats.spa; - if (move.id === 'foulplay') baseStat = adjustedStats.spe * level / 100; - if (move.id === 'bodypress') baseStat = adjustedStats.def * level / 100; - // 10% bonus for never-miss moves - let accuracy = move.accuracy === true || ability === 'No Guard' ? 110 : move.accuracy; - if (accuracy < 100) { - if (ability === 'Compound Eyes') accuracy = Math.min(100, Math.round(accuracy * 1.3)); - if (ability === 'Victory Star') accuracy = Math.min(100, Math.round(accuracy * 1.1)); - } - accuracy /= 100; - - const moveType = TeamGenerator.moveType(move, species); - - let powerEstimate = basePower * baseStat * accuracy; - // STAB - if (species.types.includes(moveType)) powerEstimate *= ability === 'Adaptability' ? 2 : 1.5; - if (ability === 'Technician' && move.basePower <= 60) powerEstimate *= 1.5; - if (ability === 'Sheer Force' && (move.secondary || move.secondaries)) powerEstimate *= 1.3; - const numberOfHits = Array.isArray(move.multihit) ? - (ability === 'Skill Link' ? move.multihit[1] : (move.multihit[0] + move.multihit[1]) / 2) : - move.multihit || 1; - powerEstimate *= numberOfHits; - - if (species.requiredItems) { - const item: Item & EventMethods = this.dex.items.get(this.specialItems[species.name]); - if (item.onBasePower && (species.types.includes(moveType) || item.name.endsWith('Mask'))) powerEstimate *= 1.2; - } else if (this.specialItems[species.name]) { - const item: Item & EventMethods = this.dex.items.get(this.specialItems[species.name]); - if (item.onBasePower && species.types.includes(moveType)) powerEstimate *= 1.2; - if (item.id === 'lightball') powerEstimate *= 2; - } - - // If it uses the attacking stat that we don't boost, it's less useful! - const specialSetup = movesStats.setup.spa; - const physicalSetup = movesStats.setup.atk; - if (move.category === 'Physical' && !['bodypress', 'foulplay'].includes(move.id)) { - powerEstimate *= Math.max(0.5, 1 + physicalSetup) / Math.max(0.5, 1 + specialSetup); - } - if (move.category === 'Special') powerEstimate *= Math.max(0.5, 1 + specialSetup) / Math.max(0.5, 1 + physicalSetup); - - let abilityBonus = ( - (ABILITY_MOVE_BONUSES[this.dex.toID(ability)]?.[move.id] || 1) * - (ABILITY_MOVE_TYPE_BONUSES[this.dex.toID(ability)]?.[moveType] || 1) - ); - - const missilePrimers = ['surf', 'dive']; - if (ability === 'Gulp Missile' && missilePrimers.includes(move.id)) { - // we want exactly one move that activates gulp missile - if (!movesSoFar.find(m => m.id === (missilePrimers.find(p => p !== move.id)))) { - abilityBonus = 3; - } else { - abilityBonus = 0.75; - } - } - - let weight = powerEstimate * abilityBonus; - if (move.id in HARDCODED_MOVE_WEIGHTS) weight *= HARDCODED_MOVE_WEIGHTS[move.id]; - // semi-hardcoded move weights that depend on having control over the item - if (!this.specialItems[species.name] && !species.requiredItem) { - if (move.id === 'acrobatics') weight *= 1.75; - if (move.id === 'facade') { - if (!['Comatose', 'Purifying Salt', 'Shields Down', 'Natural Cure', 'Misty Surge'].includes(ability)) weight *= 1.5; - } - } - - // priority is more useful when you're slower - // except Upper Hand, which is anti-priority and thus better on faster Pokemon - // TODO: make weight scale with priority - if (move.priority > 0 && move.id !== 'upperhand') weight *= (Math.max(105 - adjustedStats.spe, 0) / 105) * 0.5 + 1; - if (move.priority < 0 || move.id === 'upperhand') weight *= Math.min((1 / adjustedStats.spe) * 25, 1); - - // flags - if (move.flags.charge || (move.flags.recharge && ability !== 'Truant')) weight *= 0.5; - if (move.flags.contact) { - if (ability === 'Tough Claws') weight *= 1.3; - if (ability === 'Unseen Fist') weight *= 1.1; - if (ability === 'Poison Touch') weight *= TeamGenerator.statusWeight('psn', 1 - (0.7 ** numberOfHits)); - } - if (move.flags.bite && ability === 'Strong Jaw') weight *= 1.5; - // 5% boost for ability to bypass subs - if (move.flags.bypasssub) weight *= 1.05; - if (move.flags.pulse && ability === 'Mega Launcher') weight *= 1.5; - if (move.flags.punch && ability === 'Iron Fist') weight *= 1.2; - if (!move.flags.protect) weight *= 1.05; - if (move.flags.slicing && ability === 'Sharpness') weight *= 1.5; - if (move.flags.sound && ability === 'Punk Rock') weight *= 1.3; - - // boosts/secondaries - // TODO: consider more possible secondaries - weight *= this.boostWeight(move, movesSoFar, species, ability, level); - const secondaryChance = Math.min((move.secondary?.chance || 100) * (ability === 'Serene Grace' ? 2 : 1) / 100, 100); - if (move.secondary || move.secondaries) { - if (ability === 'Sheer Force') { - weight *= 1.3; - } else { - const secondaries = move.secondaries || [move.secondary!]; - for (const secondary of secondaries) { - if (secondary.status) { - weight *= TeamGenerator.statusWeight(secondary.status, secondaryChance, slownessRating); - if (ability === 'Poison Puppeteer' && ['psn', 'tox'].includes(secondary.status)) { - weight *= TeamGenerator.statusWeight('confusion', secondaryChance); - } - } - if (secondary.volatileStatus) { - weight *= TeamGenerator.statusWeight(secondary.volatileStatus, secondaryChance, slownessRating); - } - } - } - } - if (ability === 'Toxic Chain') weight *= TeamGenerator.statusWeight('tox', 1 - (0.7 ** numberOfHits)); - - // Special effect if something special happened earlier in the turn - // More useful on slower Pokemon - if (move.id === 'lashout') weight *= 1 + 0.2 * slownessRating; - if (move.id === 'burningjealousy') weight *= TeamGenerator.statusWeight('brn', 0.2 * slownessRating); - if (move.id === 'alluringvoice') weight *= TeamGenerator.statusWeight('confusion', 0.2 * slownessRating); - - // self-inflicted confusion or locking yourself in - if (move.self?.volatileStatus) weight *= 0.8; - - // downweight moves if we already have an attacking move of the same type - if ((movesStats.attackTypes[moveType] || 0) > 60) weight *= 0.5; - - if (move.selfdestruct) weight *= 0.3; - if (move.recoil && ability !== 'Rock Head' && ability !== 'Magic Guard') { - weight *= 1 - (move.recoil[0] / move.recoil[1]); - if (ability === 'Reckless') weight *= 1.2; - } - if (move.hasCrashDamage && ability !== 'Magic Guard') { - weight *= 1 - 0.75 * (1.2 - accuracy); - if (ability === 'Reckless') weight *= 1.2; - } - if (move.mindBlownRecoil) weight *= 0.25; - if (move.flags['futuremove']) weight *= 0.3; - - let critRate = move.willCrit ? 4 : move.critRatio || 1; - if (ability === 'Super Luck') critRate++; - if (movesSoFar.some(m => m.id === 'focusenergy')) { - critRate += 2; - weight *= 0.9; // a penalty the extra turn of setup - } - if (critRate > 4) critRate = 4; - weight *= 1 + [0, 1 / 24, 1 / 8, 1 / 2, 1][critRate] * (ability === 'Sniper' ? 1 : 0.5); - - // these two hazard removers don't clear hazards on the opponent's field, but can be blocked by type immunities - if (['rapidspin', 'mortalspin'].includes(move.id)) { - weight *= 1 + 20 * (0.25 ** teamStats.hazardRemovers); - } - - // these moves have a hard-coded 16x bonus - if (move.id === 'stoneaxe' && teamStats.hazardSetters.stealthrock) weight /= 4; - if (move.id === 'ceaselessedge' && teamStats.hazardSetters.spikes) weight /= 2; - - if (move.drain) { - const drainedFraction = move.drain[0] / move.drain[1]; - weight *= 1 + (drainedFraction * 0.5); - } - - // Oricorio should rarely get Tera Blast, as Revelation Dance is strictly better - // Tera Blast is also bad on species with forced Tera types, a.k.a. Ogerpon and Terapagos - if (move.id === 'terablast' && (species.baseSpecies === 'Oricorio' || species.requiredTeraType)) weight *= 0.5; - - return weight; - } - - /** - * @returns The effective type of moves with variable types such as Judgment - */ - protected static moveType(move: Move, species: Species) { - switch (move.id) { - case 'ivycudgel': - case 'ragingbull': - if (species.types.length > 1) return species.types[1]; - // falls through for Ogerpon and Tauros's respective base formes - case 'judgment': - case 'revelationdance': - return species.types[0]; - } - return move.type; - } - - protected static moveIsPhysical(move: Move, species: Species) { - if (move.category === 'Physical') { - return !(move.damageCallback || move.damage); - } else if (['terablast', 'terastarstorm', 'photongeyser', 'shellsidearm'].includes(move.id)) { - return species.baseStats.atk > species.baseStats.spa; - } else { - return false; - } - } - - protected static moveIsSpecial(move: Move, species: Species) { - if (move.category === 'Special') { - return !(move.damageCallback || move.damage); - } else if (['terablast', 'terastarstorm', 'photongeyser', 'shellsidearm'].includes(move.id)) { - return species.baseStats.atk <= species.baseStats.spa; - } else { - return false; - } - } - - /** - * @returns A multiplier to a move weighting based on the status it inflicts. - */ - protected static statusWeight(status: string, chance = 1, slownessRating?: number): number { - if (chance !== 1) return 1 + (TeamGenerator.statusWeight(status) - 1) * chance; - - switch (status) { - case 'brn': return 2; - case 'frz': return 5; - // paralysis is especially valuable on slow pokemon that can become faster than an opponent by paralyzing it - // but some pokemon are so slow that most paralyzed pokemon would still outspeed them anyway - case 'par': return slownessRating && slownessRating > 0.25 ? 2 + slownessRating : 2; - case 'psn': return 1.75; - case 'tox': return 4; - case 'slp': return 4; - case 'confusion': return 1.5; - case 'healblock': return 1.75; - case 'flinch': return slownessRating ? slownessRating * 3 : 1; - case 'saltcure': return 2; - case 'sparklingaria': return 0.95; - case 'syrupbomb': return 1.5; - } - return 1; - } - - /** - * @returns A multiplier to a move weighting based on the boosts it produces for the user. - */ - protected boostWeight(move: Move, movesSoFar: Move[], species: Species, ability: string, level: number): number { - const physicalIsRelevant = ( - TeamGenerator.moveIsPhysical(move, species) || - movesSoFar.some( - m => TeamGenerator.moveIsPhysical(m, species) && !m.overrideOffensiveStat && !m.overrideOffensivePokemon - ) - ); - const specialIsRelevant = ( - TeamGenerator.moveIsSpecial(move, species) || - movesSoFar.some(m => TeamGenerator.moveIsSpecial(m, species)) - ); - - const adjustedStats: StatsTable = { - hp: species.baseStats.hp * level / 100 + level, - atk: species.baseStats.atk * level * level / 10000, - def: species.baseStats.def * level / 100, - spa: species.baseStats.spa * level * level / 10000, - spd: species.baseStats.spd * level / 100, - spe: species.baseStats.spe * level / 100, - }; - - let weight = 0; - const accuracy = move.accuracy === true ? 100 : move.accuracy / 100; - const secondaryChance = move.secondary && ability !== 'Sheer Force' ? - Math.min(((move.secondary.chance || 100) * (ability === 'Serene Grace' ? 2 : 1) / 100), 100) * accuracy : 0; - const abilityMod = ability === 'Simple' ? 2 : ability === 'Contrary' ? -1 : 1; - const bodyPressMod = movesSoFar.some(m => m.id === 'bodyPress') ? 2 : 1; - const electroBallMod = movesSoFar.some(m => m.id === 'electroball') ? 2 : 1; - for (const { chance, boosts } of [ - { chance: 1, boosts: move.boosts }, - { chance: 1, boosts: move.self?.boosts }, - { chance: 1, boosts: move.selfBoost?.boosts }, - { - chance: secondaryChance, - boosts: move.secondary?.self?.boosts, - }, - ]) { - if (!boosts || chance === 0) continue; - const statusMod = move.category === 'Status' ? 1 : 0.5; - - if (boosts.atk && physicalIsRelevant) weight += chance * boosts.atk * abilityMod * 2 * statusMod; - if (boosts.spa && specialIsRelevant) weight += chance * boosts.spa * abilityMod * 2 * statusMod; - - // TODO: should these scale by base stat magnitude instead of using ternaries? - // defense/special defense boost is less useful if we have some bulk to start with - if (boosts.def) { - weight += chance * boosts.def * abilityMod * bodyPressMod * (adjustedStats.def > 60 ? 0.5 : 1) * statusMod; - } - if (boosts.spd) weight += chance * boosts.spd * abilityMod * (adjustedStats.spd > 60 ? 0.5 : 1) * statusMod; - - // speed boost is less useful for fast pokemon - if (boosts.spe) { - weight += chance * boosts.spe * abilityMod * electroBallMod * (adjustedStats.spe > 95 ? 0.5 : 1) * statusMod; - } - } - - return weight >= 0 ? 1 + weight : 1 / (1 - weight); - } - - /** - * @returns A weight for a move based on how much it will reduce the opponent's stats. - */ - protected opponentDebuffWeight(move: Move): number { - if (!['allAdjacentFoes', 'allAdjacent', 'foeSide', 'normal'].includes(move.target)) return 1; - - let averageNumberOfDebuffs = 0; - for (const { chance, boosts } of [ - { chance: 1, boosts: move.boosts }, - { - chance: move.secondary ? ((move.secondary.chance || 100) / 100) : 0, - boosts: move.secondary?.boosts, - }, - ]) { - if (!boosts || chance === 0) continue; - - const numBoosts = Object.values(boosts).filter(x => x < 0).length; - averageNumberOfDebuffs += chance * numBoosts; - } - - return 1 + (0.5 * averageNumberOfDebuffs); - } - - /** - * @returns A weight for an item. - */ - protected getItemWeight( - item: Item, teamStats: TeamStats, species: Species, moves: Move[], ability: string, level: number - ): number { - const adjustedStats: StatsTable = { - hp: species.baseStats.hp * level / 100 + level, - atk: species.baseStats.atk * level * level / 10000, - def: species.baseStats.def * level / 100, - spa: species.baseStats.spa * level * level / 10000, - spd: species.baseStats.spd * level / 100, - spe: species.baseStats.spe * level / 100, - }; - const statusImmunities = ['Comatose', 'Purifying Salt', 'Shields Down', 'Natural Cure', 'Misty Surge']; - - let weight; - switch (item.id) { - // Choice Items - case 'choiceband': - return moves.every(x => TeamGenerator.moveIsPhysical(x, species) && x.priority < 3) ? 50 : 0; - case 'choicespecs': - return moves.every(x => TeamGenerator.moveIsSpecial(x, species)) ? 50 : 0; - case 'choicescarf': - if (moves.some(x => x.category === 'Status' || x.secondary?.self?.boosts?.spe || x.priority > 1)) return 0; - if (adjustedStats.spe > 50 && adjustedStats.spe < 120) return 50; - return 10; - - // Generally Decent Items - case 'lifeorb': - return moves.filter(x => x.category !== 'Status' && !x.damage && !x.damageCallback).length * 8; - case 'focussash': - if (ability === 'Sturdy') return 0; - // frail - if (adjustedStats.hp < 65 && adjustedStats.def < 65 && adjustedStats.spd < 65) return 35; - return 10; - case 'heavydutyboots': - switch (this.dex.getEffectiveness('Rock', species)) { - case 2: return 50; // double super effective - case 1: return 30; // super effective - case 0: return 10; // neutral - } - return 5; // not very effective/other - case 'assaultvest': - if (moves.some(x => x.category === 'Status')) return 0; - return 30; - case 'scopelens': - const attacks = moves.filter(x => x.category !== 'Status' && !x.damage && !x.damageCallback && !x.willCrit); - if (moves.some(m => m.id === 'focusenergy')) { - if (ability === 'Super Luck') return 0; // we're already lucky enough, thank you - return attacks.length * (ability === 'Sniper' ? 16 : 12); - } else if (attacks.filter(x => (x.critRatio || 1) > 1).length || ability === 'Super Luck') { - return attacks.reduce((total, x) => { - let ratio = ability === 'Super Luck' ? 2 : 1; - if ((x.critRatio || 1) > 1) ratio++; - return total + [0, 3, 6, 12][ratio] * (ability === 'Sniper' ? 4 / 3 : 1); - }, 0); - } - return 0; - case 'eviolite': - return species.nfe || species.id === 'dipplin' ? 100 : 0; - - // status - case 'flameorb': - if (species.types.includes('Fire')) return 0; - if (statusImmunities.includes(ability)) return 0; - if (['Thermal Exchange', 'Water Bubble', 'Water Veil'].includes(ability)) return 0; - weight = ['Guts', 'Flare Boost'].includes(ability) ? 30 : 0; - if (moves.some(m => m.id === 'facade')) { - if (!weight && !moves.some(m => TeamGenerator.moveIsPhysical(m, species) && m.id !== 'facade')) { - weight = 30; - } else { - weight *= 2; - } - } - return weight; - case 'toxicorb': - if (species.types.includes('Poison') || species.types.includes('Steel')) return 0; - if (statusImmunities.includes(ability)) return 0; - if (ability === 'Immunity') return 0; - // If facade is our only physical attack, Flame Orb is preferred - if (!moves.some(m => TeamGenerator.moveIsPhysical(m, species) && m.id !== 'facade') && - !species.types.includes('Fire') && ['Thermal Exchange', 'Water Bubble', 'Water Veil'].includes(ability) - ) return 0; - - weight = 0; - if (['Poison Heal', 'Toxic Boost'].includes('ability')) weight += 25; - if (moves.some(m => m.id === 'facade')) weight += 25; - - return weight; - - // Healing - case 'leftovers': - return moves.some(m => m.stallingMove) ? 40 : 20; - case 'blacksludge': - // Even poison types don't really like Black Sludge in Gen 9 because it discourages them from terastallizing - // to a type other than Poison, and thus reveals their Tera type when it activates - return species.types.includes('Poison') ? moves.some(m => m.stallingMove) ? 20 : 10 : 0; - - // berries - case 'sitrusberry': case 'magoberry': - return 20; - - case 'throatspray': - if (moves.some(m => m.flags.sound) && moves.some(m => m.category === 'Special')) return 30; - return 0; - - default: - // probably not a very good item - return 0; - } - } - - /** - * @returns The level a Pokémon should be. - */ - protected static getLevel(species: Species): number { - if (['Zacian', 'Zamazenta'].includes(species.name)) { - species = Dex.species.get(species.otherFormes![0]); - } else if (species.baseSpecies === 'Squawkabilly') { - if (['Yellow', 'White'].includes(species.forme)) { - species = Dex.species.get('Squawkabilly-Yellow'); - } else { - species = Dex.species.get('Squawkabilly'); - } - } else if (useBaseSpecies.includes(species.baseSpecies)) { - species = Dex.species.get(species.baseSpecies); - } - if (levelOverride[species.id]) return levelOverride[species.id]; - - switch (species.tier) { - case 'AG': return 60; - case 'Uber': return 70; - case 'OU': case 'Unreleased': return 80; - case 'UU': return 90; - case 'LC': case 'NFE': return 100; - } - - return 100; - } - - /** - * Picks a choice from `choices` based on the weights in `weights`. - * `weights` must be the same length as `choices`. - */ - weightedRandomPick( - choices: T[], - weights: number[], - options?: { remove?: boolean } - ) { - if (!choices.length) throw new Error(`Can't pick from an empty list`); - if (choices.length !== weights.length) throw new Error(`Choices and weights must be the same length`); - - /* console.log(choices.reduce((acc, element, index) => { - return { - ...acc, - [element as string]: weights[index], - }; - }, {})) */ - - const totalWeight = weights.reduce((a, b) => a + b, 0); - - let randomWeight = this.prng.random(0, totalWeight); - for (let i = 0; i < choices.length; i++) { - randomWeight -= weights[i]; - if (randomWeight < 0) { - const choice = choices[i]; - if (options?.remove) choices.splice(i, 1); - return choice; - } - } - - if (options?.remove && choices.length) return choices.pop()!; - return choices[choices.length - 1]; - } - - setSeed(seed: PRNGSeed) { - this.prng.setSeed(seed); - } -} diff --git a/data/mods/gen9ssb/moves.ts b/data/mods/gen9ssb/moves.ts index f858fb74ad..69b1c307e2 100644 --- a/data/mods/gen9ssb/moves.ts +++ b/data/mods/gen9ssb/moves.ts @@ -2696,7 +2696,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = { basePower: 0, category: "Status", shortDesc: "Changes target to a Randbats set.", - desc: "Z-Move requiring Irpatuzinium Z. Nearly always moves first. Permanently transforms the target into a randomized Pokemon that would be generated in one of the following formats: Gen 9 Random Battle, Gen 9 Hackmons Cup, Gen 9 Challenge Cup, or Computer-Generated Teams. In the vast majority of circumstances, this also prevents the target from acting this turn.", + desc: "Z-Move requiring Irpatuzinium Z. Nearly always moves first. Permanently transforms the target into a randomized Pokemon that would be generated in one of the following formats: Gen 9 Random Battle, Gen 9 Hackmons Cup, or Gen 9 Challenge Cup. In the vast majority of circumstances, this also prevents the target from acting this turn.", name: "Bibbidi-Bobbidi-Rands", gen: 9, pp: 1, @@ -2706,7 +2706,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = { this.attrLastMove('[anim] Doom Desire'); }, onHit(target, source) { - const formats = ['gen9randombattle', 'gen9hackmonscup', 'gen9challengecup1v1', 'gen9computergeneratedteams']; + const formats = ['gen9randombattle', 'gen9hackmonscup', 'gen9challengecup1v1']; const randFormat = this.sample(formats); let msg; switch (randFormat) { @@ -2719,9 +2719,6 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = { case 'gen9challengecup1v1': msg = "The only difference between a Challenge Cup Pokémon and my in-game one is that the former actually surpassed lvl. 60, enjoy n.n"; break; - case 'gen9computergeneratedteams': - msg = "We asked an AI to make a randbats set. YOU WON'T BELIEVE WHAT IT CAME UP WITH N.N"; - break; } let team = [] as PokemonSet[]; const unModdedDex = Dex.mod('base'); diff --git a/data/rulesets.ts b/data/rulesets.ts index ffeaf79b40..92946094d0 100644 --- a/data/rulesets.ts +++ b/data/rulesets.ts @@ -2016,11 +2016,6 @@ export const Rulesets: import('../sim/dex-formats').FormatDataTable = { desc: "Maximum team size (number of pokemon) that can be brought into Team Preview (or into the battle, in formats without Team Preview)", hasValue: 'positive-integer', // hardcoded in sim/team-validator - onValidateRule(value) { - if (this.format.id.endsWith('computergeneratedteams')) { - throw new Error(`${this.format.name} does not support Max Team Size.`); - } - }, }, maxmovecount: { effectType: 'ValidatorRule', diff --git a/databases/schemas/battlestats.sql b/databases/schemas/battlestats.sql deleted file mode 100644 index 9a6c482fee..0000000000 --- a/databases/schemas/battlestats.sql +++ /dev/null @@ -1,22 +0,0 @@ -CREATE TABLE IF NOT EXISTS gen9computergeneratedteams ( - species_id TEXT PRIMARY KEY, - wins NUMBER NOT NULL, - losses NUMBER NOT NULL, - level NUMBER NOT NULL -); - -CREATE INDEX IF NOT EXISTS gen9computergeneratedteams_species_id_level ON gen9computergeneratedteams(species_id, level); - -CREATE TABLE IF NOT EXISTS gen9_historical_levels ( - species_id TEXT NOT NULL, - level NUMBER NOT NULL, - timestamp NUMBER NOT NULL -); - -CREATE TABLE IF NOT EXISTS db_info ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -INSERT INTO db_info (key, value) VALUES ('version', '1') ON CONFLICT DO NOTHING; -PRAGMA journal_mode=WAL; diff --git a/server/chat-plugins/cg-teams-leveling.ts b/server/chat-plugins/cg-teams-leveling.ts deleted file mode 100644 index edddaeb2b3..0000000000 --- a/server/chat-plugins/cg-teams-leveling.ts +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Not a chat plugin. - * - * Handles updating the level database for [Gen 9] Computer-Generated Teams. - */ - -import { SQL, Utils } from "../../lib"; -import { getSpeciesName } from "./randombattles/winrates"; -import { cgtDatabase } from "../../data/cg-teams"; - -export let addPokemon: SQL.Statement | null = null; -export let incrementWins: SQL.Statement | null = null; -export let incrementLosses: SQL.Statement | null = null; -export let dbSetupPromise: Promise | null = null; - -async function setupDatabase(database: SQL.DatabaseManager) { - await database.runFile('./databases/schemas/battlestats.sql'); - addPokemon = await database.prepare( - 'INSERT OR IGNORE INTO gen9computergeneratedteams (species_id, wins, losses, level) VALUES (?, 0, 0, ?)' - ); - incrementWins = await database.prepare( - 'UPDATE gen9computergeneratedteams SET wins = wins + 1 WHERE species_id = ?' - ); - incrementLosses = await database.prepare( - 'UPDATE gen9computergeneratedteams SET losses = losses + 1 WHERE species_id = ?' - ); -} - -if (Config.usesqlite && Config.usesqliteleveling) { - const database = SQL(module, { - file: './databases/battlestats.db', - }); - dbSetupPromise = setupDatabase(database); -} - -function getLevelSpeciesID(set: PokemonSet, format?: Format) { - if (['Basculin', 'Greninja'].includes(set.name)) return toID(set.species); - return toID(getSpeciesName(set, format || Dex.formats.get('gen9computergeneratedteams'))); -} - -async function updateStats(battle: RoomBattle, winner: ID) { - if (!incrementWins || !incrementLosses) await dbSetupPromise; - if (toID(battle.format) !== 'gen9computergeneratedteams') return; - // if the game is rated or part of a tournament hosted by a public room, it counts - if (battle.rated <= 1 && battle.room.parent?.game) { - let parent = battle.room.parent; - if (parent.game!.gameid === 'bestof' && parent.parent?.game) parent = parent.parent; - if (parent.game!.gameid !== 'tournament' || parent.settings.isPrivate) return; - } else if (battle.rated < 1000) { - return; - } - - for (const player of battle.players) { - const team = await battle.getPlayerTeam(player); - if (!team) return; - const increment = (player.id === winner ? incrementWins : incrementLosses); - - for (const set of team) { - const statsSpecies = getLevelSpeciesID(set, Dex.formats.get(battle.format)); - await addPokemon?.run([statsSpecies, set.level || 100]); - await increment?.run([statsSpecies]); - } - } -} - -export const handlers: Chat.Handlers = { - onBattleEnd(battle, winner) { - if (!Config.usesqlite || !Config.usesqliteleveling) return; - void updateStats(battle, winner); - }, -}; - -export const commands: Chat.ChatCommands = { - cgtwr: 'cgtwinrates', - cgtwinrates(target, room, user) { - return this.parse(`/j view-cgtwinrates-${target ? 'history--' + target : 'current'}`); - }, - cgtwinrateshelp: [ - '/cgtwinrates OR /cgtwr - Get a list of the current win rate data for all Pokemon in [Gen 9] Computer Generated Teams.', - ], - - // Add maintenance commands here -}; - -interface MonCurrent { species_id: ID; wins: number; losses: number; level: number } -interface MonHistory { level: number; species_id: ID; timestamp: number } - -export const pages: Chat.PageTable = { - async cgtwinrates(query, user) { - if (!user.named) return Rooms.RETRY_AFTER_LOGIN; - if (!cgtDatabase) { - throw new Chat.ErrorMessage(`CGT win rates are not being tracked due to the server's SQL settings.`); - } - query = query.join('-').split('--'); - const mode = query.shift(); - if (mode === 'current') { - let buf = `

Winrates for [Gen 9] Computer Generated Teams

`; - const sorter = toID(query.shift() || 'alphabetical'); - if (!['alphabetical', 'level'].includes(sorter)) { - throw new Chat.ErrorMessage(`Invalid sorting method. Must be either 'alphabetical' or 'level'.`); - } - const otherSort = sorter === 'alphabetical' ? 'Level' : 'Alphabetical'; - buf += ``; - buf += `Sort by ${otherSort} descending`; - buf += `
`; - const statData: MonCurrent[] = await cgtDatabase.all( - 'SELECT species_id, wins, losses, level FROM gen9computergeneratedteams' - ); - this.title = `[Winrates] [Gen 9] Computer Generated Teams`; - let sortFn: (val: MonCurrent) => Utils.Comparable; - - if (sorter === 'alphabetical') { - sortFn = data => [data.species_id]; - } else { - sortFn = data => [-data.level]; - } - const mons = Utils.sortBy(statData, sortFn); - buf += `
`; - for (const mon of mons) { - buf += ``; - buf += ``; - } - buf += `
PokemonLevelWinsLosses
${Dex.species.get(mon.species_id).name}${mon.level}${mon.wins}${mon.losses}
`; - return buf; - } else if (mode === 'history') { - // Restricted because this is a potentially very slow command - this.checkCan('modlog', null, Rooms.get('development')!); // stinky non-null assertion - - let speciesID = query.shift(); - let buf; - if (speciesID) { - speciesID = getLevelSpeciesID({ species: speciesID || '' } as PokemonSet); - const species = Dex.species.get(speciesID); - if (!species.exists || - species.isNonstandard || species.isNonstandard === 'Unobtainable' || - species.nfe || - species.battleOnly && (!species.requiredItems?.length || species.name.endsWith('-Tera')) - ) { - this.errorReply('Species has no data in [Gen 9] Computer Generated Teams'); - } - buf = `

Level history for ${species.name} in [Gen 9] CGT

`; - } else { - buf = `

Level history for [Gen 9] Computer Generated Teams

`; - } - const history: MonHistory[] = await cgtDatabase.all( - 'SELECT level, species_id, timestamp FROM gen9_historical_levels' - ); - this.title = `[History] [Gen 9] Computer Generated Teams`; - - const MAX_LINES = 100; - buf += `
`; - for (let i = history.length - 1; history.length - i <= MAX_LINES; i--) { - const entry = history[i]; - if (speciesID && entry.species_id !== speciesID) continue; - buf += ``; - const timestamp = new Date(entry.timestamp); - buf += ``; - } - buf += `
PokemonLevelTimestamp
${entry.species_id}${entry.level}${timestamp.toLocaleDateString()}, ${timestamp.toLocaleTimeString()}
`; - return buf; - } - }, -}; diff --git a/sim/teams.ts b/sim/teams.ts index 6573141950..2dbc797d5b 100644 --- a/sim/teams.ts +++ b/sim/teams.ts @@ -630,9 +630,7 @@ export const Teams = new class Teams { let mod = format.mod; if (format.mod === 'monkeyspaw') mod = 'gen9'; const formatID = toID(format); - if (formatID.includes('gen9computergeneratedteams')) { - TeamGenerator = require(Dex.forFormat(format).dataDir + '/cg-teams').default; - } else if (mod === 'gen9ssb') { + if (mod === 'gen9ssb') { TeamGenerator = require(`../data/mods/gen9ssb/random-teams`).default; } else if (formatID.includes('gen9donotuserandombattle')) { TeamGenerator = require(`../data/random-battles/donotuse/teams`).default; diff --git a/test/server/cg-teams.js b/test/server/cg-teams.js deleted file mode 100644 index 4ec36257bc..0000000000 --- a/test/server/cg-teams.js +++ /dev/null @@ -1,61 +0,0 @@ -'use strict'; - -const assert = require('assert').strict; -const TeamGenerator = require('../../dist/data/cg-teams').default; - -describe('[Gen 9] Computer-Generated Teams', () => { - it.skip('should give all species 4 or fewer moves', () => { - const generator = new TeamGenerator(); - const pool = generator.dex.species - .all() - .filter(s => s.exists && !(s.isNonstandard || s.isNonstandard === 'Unobtainable') && !s.nfe); - for (const species of pool) { - const set = generator.makeSet(species, { hazardSetters: {} }); - assert(set.moves.length <= 4, `Species ${species.name} has more than 4 moves (set=${JSON.stringify(set)})`); - assert(new Set(set.moves).size === set.moves.length, `Species ${species.name} has duplicate moves (set=${JSON.stringify(set)})`); - } - }); - - // Skipped since it includes randomness; useful for debugging though - it.skip('should have an accurate weighted picker', () => { - const generator = new TeamGenerator(); - const numTrials = 100000; - let error = 0; - let trials = 0; - - for (const choices of [ - [{ choice: 'a', weight: 1 }, { choice: 'b', weight: 2 }], - [{ choice: 'a', weight: 1 }, { choice: 'b', weight: 1 }], - [{ choice: 'a', weight: 30 }, { choice: 'b', weight: 2000 }, { choice: 'c', weight: 7 }], - // a big test case with lots of different weight values - [ - { choice: 'a', weight: 1345 }, { choice: 'b', weight: 2013 }, { choice: 'c', weight: 3411 }, { choice: 'd', weight: 940 }, - { choice: 'e', weight: 505 }, { choice: 'f', weight: 10148 }, { choice: 'g', weight: 7342 }, { choice: 'h', weight: 8403 }, - { choice: 'i', weight: 9859 }, { choice: 'j', weight: 1042 }, { choice: 'k', weight: 1132 }, { choice: 'l', weight: 1200 }, - ], - ]) { - const results = {}; - for (let i = 0; i < numTrials; i++) { - const res = generator.weightedRandomPick(choices.map(x => x.choice), c => choices.find(x => x.choice === c)?.weight || 0); - // console.log(`"${res}"`); - if (!results[res]) results[res] = 0; - results[res]++; - } - - let totalWeight = 0; - for (const choice of choices) { - totalWeight += choice.weight; - } - - for (const [choice, count] of Object.entries(results)) { - const c = choices.find(x => x.choice === choice); - const expected = (c.weight / totalWeight) * numTrials; - error += Math.abs(count - expected) / expected; - trials++; - } - } - - const percentError = (error / trials) * 100; - assert(percentError < 3, `Weighted picker error is too high: ${percentError.toFixed(1)}%`); - }); -});