diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c90dc3ec2b2e..c369b59a7b2d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,6 +9,15 @@ At the highest level, PS is split into three parts: All three communicate directly with each other. +A user starts by visiting `https://play.pokemonshowdown.com/`. This is handled by an Apache server (in the Client), which serves mostly static files but uses some PHP (legacy, intended to be migrated to Loginserver). + +The user's web browser (running Client code) will then communicate with the Login server (mounted at `https://play.pokemonshowdown.com/api/` to handle logins mostly, or otherwise interface with the Client databases one way or another). + +The user's web browser will also connect to the Game server, through SockJS. The Game server handles the chat rooms, matchmaking, and actual battle simulation. + +The Game server also communicates with the Login server, to handle replay uploads (and, for the main server, ladder updates). + + Game server ----------- @@ -26,6 +35,7 @@ Its entry point is [server/index.ts](./server/index.ts), which launches several `Rooms` also includes support for battle rooms, which is where the server connects to the game simulator itself. Game simulation code is in [sim/](./sim/). + Client ------ @@ -35,9 +45,12 @@ Its entry point is [index.template.html](https://github.com/smogon/pokemon-showd It was written long ago, so instead of a single JS entry point, it includes a lot of JS files. Everything important is launched from [js/client.js](https://github.com/smogon/pokemon-showdown-client/blob/master/play.pokemonshowdown.com/js/client.js) + Login server ------------ -The client’s login server, which handles logins and most database interaction, is written in TypeScript in progress. The backend is split between a MySQL InnoDB database (for most things) and a Postgres database (for Replays). +The client’s login server, which handles logins and most database interaction, is written in TypeScript. The backend is currently split between a MySQL InnoDB database (for users, ladder, and most other things) and a Postgres (technically Cockroach) database (for Replays). Its entry point is [server.ts](https://github.com/smogon/pokemon-showdown-loginserver/blob/master/src/server.ts). + +It's intended to replace all of the old PHP code in the Client, but that migration is only halfway done at the moment. diff --git a/config/formats.ts b/config/formats.ts index d4664ca96341..b028c8dc69df 100644 --- a/config/formats.ts +++ b/config/formats.ts @@ -241,12 +241,6 @@ export const Formats: import('../sim/dex-formats').FormatList = [ ruleset: ['[Gen 3] OU', 'Pre RU Tier Shift Mod'], searchShow: true, }, - { - name: "[Gen 3] RU", - mod: 'gen3', - ruleset: ['[Gen 3] UU'], - banlist: ['UU', 'RUBL'], - }, { name: "[Gen 3] SU", mod: 'gen3', @@ -940,13 +934,13 @@ export const Formats: import('../sim/dex-formats').FormatList = [ desc: `All forms of passive damage deal type-based damage based on the primary type of the Pokémon that inflicted the passive damage against the target Pokémon.`, mod: 'passiveaggressive', // searchShow: false, - ruleset: ['Standard OMs', 'Sleep Moves Clause', 'Evasion Items Clause'], + ruleset: ['Standard OMs', 'Sleep Moves Clause', 'Evasion Items Clause', 'Terastal Clause'], banlist: [ - 'Arceus', 'Baxcalibur', 'Calyrex-Ice', 'Calyrex-Shadow', 'Chi-Yu', 'Chien-Pao', 'Deoxys-Attack', 'Deoxys-Normal', 'Dialga', 'Dialga-Origin', 'Eternatus', 'Flutter Mane', - 'Gholdengo', 'Giratina', 'Giratina-Origin', 'Gouging Fire', 'Groudon', 'Ho-Oh', 'Iron Bundle', 'Koraidon', 'Kyogre', 'Kyurem', 'Kyurem-Black', 'Kyurem-White', 'Lugia', - 'Lunala', 'Magearna', 'Mewtwo', 'Miraidon', 'Necrozma-Dawn-Wings', 'Necrozma-Dusk-Mane', 'Ogerpon-Hearthflame', 'Palafin', 'Palkia', 'Palkia-Origin', 'Raging Bolt', - 'Rayquaza', 'Reshiram', 'Shaymin-Sky', 'Sneasler', 'Solgaleo', 'Zacian', 'Zacian-Crowned', 'Zamazenta-Crowned', 'Zekrom', 'Arena Trap', 'Moody', 'Shadow Tag', 'Speed Boost', - 'Heat Rock', 'King\'s Rock', 'Razor Fang', 'Quick Claw', 'Baton Pass', 'Last Respects', 'Shed Tail', + 'Annihilape', 'Arceus', 'Baxcalibur', 'Calyrex-Ice', 'Calyrex-Shadow', 'Chi-Yu', 'Chien-Pao', 'Deoxys-Attack', 'Deoxys-Normal', 'Dialga', 'Dialga-Origin', 'Eternatus', 'Flutter Mane', + 'Gholdengo', 'Giratina', 'Giratina-Origin', 'Gouging Fire', 'Groudon', 'Ho-Oh', 'Iron Bundle', 'Koraidon', 'Kyogre', 'Kyurem', 'Kyurem-Black', 'Kyurem-White', 'Landorus-Incarnate', + 'Lugia', 'Lunala', 'Magearna', 'Mewtwo', 'Miraidon', 'Necrozma-Dawn-Wings', 'Necrozma-Dusk-Mane', 'Ogerpon-Hearthflame', 'Palafin', 'Palkia', 'Palkia-Origin', 'Raging Bolt', 'Rayquaza', + 'Reshiram', 'Shaymin-Sky', 'Sneasler', 'Solgaleo', 'Spectrier', 'Ursaluna-Bloodmoon', 'Zacian', 'Zacian-Crowned', 'Zamazenta-Crowned', 'Zekrom', 'Arena Trap', 'Moody', 'Shadow Tag', + 'Speed Boost', 'Heat Rock', 'King\'s Rock', 'Razor Fang', 'Quick Claw', 'Baton Pass', 'Last Respects', 'Shed Tail', ], }, { @@ -1660,7 +1654,7 @@ export const Formats: import('../sim/dex-formats').FormatList = [ 'Sand Veil', 'Snow Cloak', 'King\'s Rock', 'Razor Fang', 'Baton Pass', 'Dire Claw', 'Last Respects', 'Shed Tail', ], restricted: [ - 'Beat Up', 'Belly Drum', 'Clangorous Soul', 'Dragon Dance', 'Endeavor', 'Quiver Dance', 'Shell Smash', 'Shift Gear', 'Tail Glow', 'Tidy Up', 'Victory Dance', + 'Belly Drum', 'Clangorous Soul', 'Dragon Dance', 'Endeavor', 'Quiver Dance', 'Shell Smash', 'Shift Gear', 'Tail Glow', 'Tidy Up', 'Victory Dance', ], onValidateSet(set) { const fsMove = this.dex.moves.get(set.moves[0]); @@ -1680,6 +1674,7 @@ export const Formats: import('../sim/dex-formats').FormatList = [ const moveData = this.dex.getActiveMove(move.id); moveData.flags['futuremove'] = 1; delete moveData.flags['protect']; + if (moveData.id === 'beatup') this.singleEvent('ModifyMove', moveData, null, pokemon, null, null, moveData); Object.assign(t.side.slotConditions[t.position]['futuremove'], { duration: 3, move: moveData.id, @@ -2673,13 +2668,13 @@ export const Formats: import('../sim/dex-formats').FormatList = [ 'Sleep Clause Mod', 'Forme Clause', 'Z-Move Clause', 'Terastal Clause', 'Mega Rayquaza Clause', ], banlist: [ - 'ND Uber', 'ND AG', 'ND OU', 'ND UUBL', 'ND UU', 'ND RUBL', 'ND RU', 'ND NFE', ' ND LC', + 'ND Uber', 'ND AG', 'ND OU', 'ND UUBL', 'ND UU', 'ND RUBL', 'ND RU', 'ND NFE', 'ND LC', 'Battle Bond', 'Moody', 'Shadow Tag', 'Berserk Gene', 'King\'s Rock', 'Quick Claw', 'Razor Fang', 'Acupressure', 'Last Respects', ], unbanlist: [ - 'Ampharos-Base', 'Appletun', 'Araquanid', 'Arbok', 'Centiskorch', 'Drampa', 'Dusknoir', 'Exeggutor-Alola', 'Exeggutor-Base', 'Frosmoth', 'Gabite', 'Glaceon', - 'Golduck', 'Gourgeist-Average', 'Gourgeist-Super', 'Granbull', 'Gumshoos', 'Guzzlord', 'Kecleon', 'Ledian', 'Lurantis', 'Oricorio-Baile', 'Overqwil', - 'Pinsir-Base', 'Qwilfish-Base', 'Reuniclus', 'Shedinja', 'Shelgon', 'Spiritomb', 'Trapinch', 'Tsareena', 'Turtonator', 'Unown', 'Wigglytuff', 'Wishiwashi', + 'Ampharos-Base', 'Appletun', 'Araquanid', 'Arbok', 'Centiskorch', 'Drampa', 'Dusknoir', 'Exeggutor-Alola', 'Exeggutor-Base', 'Frosmoth', 'Gabite', 'Galvantula', + 'Glaceon', 'Golduck', 'Gorebyss', 'Gourgeist-Average', 'Gourgeist-Super', 'Granbull', 'Gumshoos', 'Guzzlord', 'Kecleon', 'Ledian', 'Lurantis', 'Pinsir-Base', + 'Qwilfish-Base', 'Reuniclus', 'Shedinja', 'Shelgon', 'Spiritomb', 'Trapinch', 'Tsareena', 'Turtonator', 'Unown', 'Wigglytuff', 'Wishiwashi', ], // Stupid hardcode onValidateSet(set, format, setHas, teamHas) { @@ -4547,18 +4542,13 @@ export const Formats: import('../sim/dex-formats').FormatList = [ ruleset: ['Standard'], banlist: ['Uber', 'OU', 'UUBL', 'Smeargle + Ingrain', 'Arena Trap', 'Baton Pass', 'Swagger'], }, - // { - // name: "[Gen 3] RU", - // mod: 'gen3', - // searchShow: false, - // ruleset: ['[Gen 3] UU'], - // banlist: [ - // 'Altaria', 'Ampharos', 'Arcanine', 'Blastoise', 'Clefable', 'Cradily', 'Electabuzz', 'Electrode', 'Fearow', 'Feraligatr', 'Gligar', 'Golduck', - // 'Golem', 'Gorebyss', 'Granbull', 'Grumpig', 'Hitmonlee', 'Hitmontop', 'Jumpluff', 'Kangaskhan', 'Lanturn', 'Lunatone', 'Manectric', 'Misdreavus', - // 'Muk', 'Nidoking', 'Nidoqueen', 'Ninjask', 'Omastar', 'Pinsir', 'Qwilfish', 'Sandslash', 'Scyther', 'Slowking', 'Solrock', 'Tentacruel', 'Vileplume', - // 'Walrein', 'Xatu', - // ], - // }, + { + name: "[Gen 3] RU", + mod: 'gen3', + searchShow: false, + ruleset: ['Standard'], + banlist: ['Uber', 'OU', 'UUBL', 'UU', 'RUBL', 'Smeargle + Ingrain', 'Arena Trap', 'Baton Pass', 'Swagger'], + }, { name: "[Gen 3] NU", mod: 'gen3', @@ -4571,7 +4561,7 @@ export const Formats: import('../sim/dex-formats').FormatList = [ mod: 'gen3', searchShow: false, ruleset: ['Standard', 'Baton Pass Stat Clause'], - banlist: ['Uber', 'OU', 'UUBL', 'UU', 'NUBL', 'NU', 'PUBL'], + banlist: ['Uber', 'OU', 'UUBL', 'UU', 'RUBL', 'RU', 'NUBL', 'NU', 'PUBL'], }, { name: "[Gen 3] LC", @@ -4600,7 +4590,7 @@ export const Formats: import('../sim/dex-formats').FormatList = [ mod: 'gen3', searchShow: false, ruleset: ['Standard', 'Sleep Moves Clause', 'Baton Pass Stat Trap Clause', 'Swagger Clause'], - banlist: ['Uber', 'OU', 'UUBL', 'UU', 'NUBL', 'NU', 'PUBL', 'PU', 'ZUBL', 'Baton Pass + Substitute'], + banlist: ['Uber', 'OU', 'UUBL', 'UU', 'RUBL', 'RU', 'NUBL', 'NU', 'PUBL', 'PU', 'ZUBL', 'Baton Pass + Substitute'], }, { name: "[Gen 3] Custom Game", diff --git a/data/cg-teams.ts b/data/cg-teams.ts index 5748bbbc2033..2de0d5b345a2 100644 --- a/data/cg-teams.ts +++ b/data/cg-teams.ts @@ -297,7 +297,7 @@ export default class TeamGenerator { let types = nonStatusMoves.map(move => TeamGenerator.moveType(this.dex.moves.get(move), species)); const noStellar = ability === 'Adaptability' || new Set(types).size < 3; if (hasTeraBlast || hasRevelationDance || !nonStatusMoves.length) { - types = [...this.dex.types.all().map(t => t.name)]; + types = [...this.dex.types.names()]; if (noStellar) types.splice(types.indexOf('Stellar')); } else { if (!noStellar) types.push('Stellar'); @@ -327,23 +327,23 @@ export default class TeamGenerator { */ protected speciesIsGoodFit(species: Species, stats: TeamStats): boolean { // type check - for (const type of this.dex.types.all()) { - const effectiveness = this.dex.getEffectiveness(type.name, species.types); + for (const typeName of this.dex.types.names()) { + const effectiveness = this.dex.getEffectiveness(typeName, species.types); if (effectiveness === 1) { // WEAKNESS! - if (stats.typeWeaknesses[type.name] === undefined) { - stats.typeWeaknesses[type.name] = 0; + if (stats.typeWeaknesses[typeName] === undefined) { + stats.typeWeaknesses[typeName] = 0; } - if (stats.typeWeaknesses[type.name] >= MAX_WEAK_TO_SAME_TYPE) { + if (stats.typeWeaknesses[typeName] >= MAX_WEAK_TO_SAME_TYPE) { // too many weaknesses to this type return false; } } } // species passes; increment counters - for (const type of this.dex.types.all()) { - const effectiveness = this.dex.getEffectiveness(type.name, species.types); + for (const typeName of this.dex.types.names()) { + const effectiveness = this.dex.getEffectiveness(typeName, species.types); if (effectiveness === 1) { - stats.typeWeaknesses[type.name]++; + stats.typeWeaknesses[typeName]++; } } return true; diff --git a/data/formats-data.ts b/data/formats-data.ts index eedf824c717a..76ffe6e72c10 100644 --- a/data/formats-data.ts +++ b/data/formats-data.ts @@ -5605,7 +5605,7 @@ export const FormatsData: import('../sim/dex-species').SpeciesFormatsDataTable = natDexTier: "RU", }, okidogi: { - tier: "UU", + tier: "UUBL", doublesTier: "(DUU)", natDexTier: "RU", }, diff --git a/data/learnsets.ts b/data/learnsets.ts index b4d0c64d0bed..4473fb447a5f 100644 --- a/data/learnsets.ts +++ b/data/learnsets.ts @@ -67280,7 +67280,7 @@ export const Learnsets: import('../sim/dex-species').LearnsetDataTable = { drainpunch: ["9M", "7T", "6T", "5T"], dreameater: ["7M", "6M", "5M"], dualchop: ["7T", "6T", "5T"], - echoedvoice: ["9L36", "7M", "7L36", "6M", "6L36", "5M", "5L36"], + echoedvoice: ["9L36", "9S5", "7M", "7L36", "6M", "6L36", "5M", "5L36"], embargo: ["7M", "6M", "5M"], endure: ["9M"], energyball: ["9M", "7M", "6M", "5M"], @@ -67316,14 +67316,14 @@ export const Learnsets: import('../sim/dex-species').LearnsetDataTable = { playrough: ["9M"], poweruppunch: ["6M"], protect: ["9M", "7M", "6M", "5M"], - psybeam: ["9M", "9L31", "7L31", "6L31", "5L31"], + psybeam: ["9M", "9L31", "9S5", "7L31", "6L31", "5L31"], psychic: ["9M", "9L57", "9S4", "7M", "7L57", "7S2", "6M", "6L57", "5M", "5L57", "5S1"], psychup: ["9M", "7M", "6M", "5M"], psyshock: ["9M", "7M", "6M", "5M"], quickattack: ["9L1", "7L1", "6L6", "5L6", "5S0"], raindance: ["9M", "7M", "6M", "5M"], recycle: ["7T", "6T", "5T"], - relicsong: ["9L50", "9S4", "7T", "7S3", "6T", "5T"], + relicsong: ["9L50", "9S4", "9S5", "7T", "7S3", "6T", "5T"], rest: ["9M", "7M", "6M", "5M"], retaliate: ["6M", "5M"], return: ["7M", "6M", "5M"], @@ -67337,7 +67337,7 @@ export const Learnsets: import('../sim/dex-species').LearnsetDataTable = { shadowclaw: ["9M", "7M", "6M", "5M"], shockwave: ["7T", "6T"], signalbeam: ["7T", "6T", "5T"], - sing: ["9L1", "9S4", "7L1", "7S2", "7S3", "6L16", "5L16"], + sing: ["9L1", "9S4", "9S5", "7L1", "7S2", "7S3", "6L16", "5L16"], skillswap: ["9M", "7T", "6T", "5T"], sleeptalk: ["9M", "7M", "6M", "5T"], snatch: ["7T", "6T", "5T"], @@ -67373,6 +67373,7 @@ export const Learnsets: import('../sim/dex-species').LearnsetDataTable = { {generation: 7, level: 15, moves: ["sing", "psychic", "closecombat"], pokeball: "cherishball"}, {generation: 7, level: 50, moves: ["sing", "celebrate", "round", "relicsong"], pokeball: "cherishball"}, {generation: 9, level: 70, moves: ["relicsong", "hypervoice", "sing", "psychic"]}, + {generation: 9, level: 50, shiny: true, nature: "Modest", ivs: {hp: 20, atk: 20, def: 20, spa: 31, spd: 31, spe: 31}, moves: ["relicsong", "echoedvoice", "psybeam", "sing"], pokeball: "cherishball"}, ], eventOnly: true, }, diff --git a/data/mods/gen1rbycap/moves.ts b/data/mods/gen1rbycap/moves.ts index 14e95c12f70a..7be673a05905 100644 --- a/data/mods/gen1rbycap/moves.ts +++ b/data/mods/gen1rbycap/moves.ts @@ -74,7 +74,7 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = { icicle: { accuracy: 100, basePower: 70, - category: "Physical", + category: "Special", shortDesc: "High critical hit ratio.", name: "Icicle", pp: 15, diff --git a/data/mods/gen3/formats-data.ts b/data/mods/gen3/formats-data.ts index 3cce6eac8c5b..7069a5a73680 100644 --- a/data/mods/gen3/formats-data.ts +++ b/data/mods/gen3/formats-data.ts @@ -111,7 +111,7 @@ export const FormatsData: import('../../../sim/dex-species').ModdedSpeciesFormat tier: "SU", }, clefable: { - tier: "UU", + tier: "RU", }, vulpix: { tier: "IU", @@ -576,7 +576,7 @@ export const FormatsData: import('../../../sim/dex-species').ModdedSpeciesFormat tier: "SU", }, xatu: { - tier: "UU", + tier: "RU", }, mareep: { tier: "IU", diff --git a/data/mods/gen8linked/moves.ts b/data/mods/gen8linked/moves.ts index 8e2427e4aef2..db0878bba43a 100644 --- a/data/mods/gen8linked/moves.ts +++ b/data/mods/gen8linked/moves.ts @@ -384,11 +384,11 @@ export const Moves: import('../../../sim/dex-moves').ModdedMoveDataTable = { if (!lastMove) return false; const possibleTypes = []; const attackType = lastMove.type; - for (const type of this.dex.types.all()) { - if (source.hasType(type.name)) continue; - const typeCheck = type.damageTaken[attackType]; + for (const typeName of this.dex.types.names()) { + if (source.hasType(typeName)) continue; + const typeCheck = this.dex.types.get(typeName).damageTaken[attackType]; if (typeCheck === 2 || typeCheck === 3) { - possibleTypes.push(type.name); + possibleTypes.push(typeName); } } if (!possibleTypes.length) { diff --git a/data/mods/gen9ssb/scripts.ts b/data/mods/gen9ssb/scripts.ts index 428c94ab672c..3fe0e55a73b8 100644 --- a/data/mods/gen9ssb/scripts.ts +++ b/data/mods/gen9ssb/scripts.ts @@ -85,12 +85,9 @@ export function changeSet(context: Battle, pokemon: Pokemon, newSet: SSBSet, cha if (newSet.species === 'Shedinja') percent = 1; pokemon.formeChange(newSet.species, context.effect, true); if (!pokemon.terastallized && newSet.teraType) { - const allTypes = context.dex.types.all().map(x => x.name); - pokemon.teraType = newSet.teraType === 'Any' ? - allTypes[Math.floor(Math.random() * allTypes.length)] : - Array.isArray(newSet.teraType) ? - newSet.teraType[Math.floor(Math.random() * newSet.teraType.length)] : - newSet.teraType; + const allTypes = context.dex.types.names(); + pokemon.teraType = newSet.teraType === 'Any' ? context.sample(allTypes) : + Array.isArray(newSet.teraType) ? context.sample(newSet.teraType) : newSet.teraType; } const details = pokemon.species.name + (pokemon.level === 100 ? '' : ', L' + pokemon.level) + (pokemon.gender === '' ? '' : ', ' + pokemon.gender) + (pokemon.set.shiny ? ', shiny' : ''); diff --git a/data/moves.ts b/data/moves.ts index 1f4bd3143341..e7fa04db92b7 100644 --- a/data/moves.ts +++ b/data/moves.ts @@ -2908,11 +2908,11 @@ export const Moves: import('../sim/dex-moves').MoveDataTable = { } const possibleTypes = []; const attackType = target.lastMoveUsed.type; - for (const type of this.dex.types.all()) { - if (source.hasType(type.name)) continue; - const typeCheck = type.damageTaken[attackType]; + for (const typeName of this.dex.types.names()) { + if (source.hasType(typeName)) continue; + const typeCheck = this.dex.types.get(typeName).damageTaken[attackType]; if (typeCheck === 2 || typeCheck === 3) { - possibleTypes.push(type.name); + possibleTypes.push(typeName); } } if (!possibleTypes.length) { diff --git a/data/random-battles/gen3/teams.ts b/data/random-battles/gen3/teams.ts index 3570009cf673..511fd1377296 100644 --- a/data/random-battles/gen3/teams.ts +++ b/data/random-battles/gen3/teams.ts @@ -129,9 +129,7 @@ export class RandomGen3Teams extends RandomGen4Teams { // Develop additional move lists const badWithSetup = ['knockoff', 'rapidspin', 'toxic']; - const statusMoves = this.dex.moves.all() - .filter(move => move.category === 'Status') - .map(move => move.id); + const statusMoves = this.cachedStatusMoves; // General incompatibilities const incompatiblePairs = [ diff --git a/data/random-battles/gen4/teams.ts b/data/random-battles/gen4/teams.ts index 1b84d3bdeae6..1ce2723fe08e 100644 --- a/data/random-battles/gen4/teams.ts +++ b/data/random-battles/gen4/teams.ts @@ -76,6 +76,9 @@ export class RandomGen4Teams extends RandomGen5Teams { Steel: (movePool, moves, abilities, types, counter, species) => (!counter.get('Steel') && species.id === 'metagross'), Water: (movePool, moves, abilities, types, counter) => !counter.get('Water'), }; + this.cachedStatusMoves = this.dex.moves.all() + .filter(move => move.category === 'Status') + .map(move => move.id); } cullMovePool( @@ -164,9 +167,7 @@ export class RandomGen4Teams extends RandomGen5Teams { // Develop additional move lists const badWithSetup = ['pursuit', 'toxic']; - const statusMoves = this.dex.moves.all() - .filter(move => move.category === 'Status') - .map(move => move.id); + const statusMoves = this.cachedStatusMoves; // General incompatibilities const incompatiblePairs = [ diff --git a/data/random-battles/gen5/teams.ts b/data/random-battles/gen5/teams.ts index 4cc13067b4a2..14636cc136c3 100644 --- a/data/random-battles/gen5/teams.ts +++ b/data/random-battles/gen5/teams.ts @@ -89,6 +89,10 @@ export class RandomGen5Teams extends RandomGen6Teams { ), Water: (movePool, moves, abilities, types, counter) => !counter.get('Water'), }; + // Nature Power is Earthquake this gen + this.cachedStatusMoves = this.dex.moves.all() + .filter(move => move.category === 'Status' && move.id !== 'naturepower') + .map(move => move.id); } cullMovePool( @@ -177,10 +181,7 @@ export class RandomGen5Teams extends RandomGen6Teams { // Develop additional move lists const badWithSetup = ['healbell', 'pursuit', 'toxic']; - // Nature Power is Earthquake this gen - const statusMoves = this.dex.moves.all() - .filter(move => move.category === 'Status' && move.id !== 'naturepower') - .map(move => move.id); + const statusMoves = this.cachedStatusMoves; // General incompatibilities const incompatiblePairs = [ diff --git a/data/random-battles/gen6/teams.ts b/data/random-battles/gen6/teams.ts index e54839e0ce74..b989b40c62fe 100644 --- a/data/random-battles/gen6/teams.ts +++ b/data/random-battles/gen6/teams.ts @@ -103,6 +103,9 @@ export class RandomGen6Teams extends RandomGen7Teams { Steel: (movePool, moves, abilities, types, counter, species) => (!counter.get('Steel') && species.baseStats.atk >= 100), Water: (movePool, moves, abilities, types, counter) => !counter.get('Water'), }; + this.cachedStatusMoves = this.dex.moves.all() + .filter(move => move.category === 'Status') + .map(move => move.id); } cullMovePool( @@ -196,9 +199,7 @@ export class RandomGen6Teams extends RandomGen7Teams { // Develop additional move lists const badWithSetup = ['defog', 'dragontail', 'haze', 'healbell', 'nuzzle', 'pursuit', 'rapidspin', 'toxic']; - const statusMoves = this.dex.moves.all() - .filter(move => move.category === 'Status') - .map(move => move.id); + const statusMoves = this.cachedStatusMoves; // General incompatibilities const incompatiblePairs = [ diff --git a/data/random-battles/gen7/teams.ts b/data/random-battles/gen7/teams.ts index fbd939b601a8..3ed12defd32f 100644 --- a/data/random-battles/gen7/teams.ts +++ b/data/random-battles/gen7/teams.ts @@ -99,6 +99,7 @@ function sereneGraceBenefits(move: Move) { export class RandomGen7Teams extends RandomGen8Teams { randomSets: {[species: string]: RandomTeamsTypes.RandomSpeciesData} = require('./sets.json'); + protected cachedStatusMoves: ID[]; constructor(format: Format | string, prng: PRNG | PRNGSeed | null) { super(format, prng); @@ -141,6 +142,10 @@ export class RandomGen7Teams extends RandomGen8Teams { Steel: (movePool, moves, abilities, types, counter, species) => (!counter.get('Steel') && species.baseStats.atk >= 100), Water: (movePool, moves, abilities, types, counter) => !counter.get('Water'), }; + // Nature Power is Tri Attack this gen + this.cachedStatusMoves = this.dex.moves.all() + .filter(move => move.category === 'Status' && move.id !== 'naturepower') + .map(move => move.id); } newQueryMoves( @@ -311,10 +316,7 @@ export class RandomGen7Teams extends RandomGen8Teams { // Develop additional move lists const badWithSetup = ['defog', 'dragontail', 'haze', 'healbell', 'nuzzle', 'pursuit', 'rapidspin', 'toxic']; - // Nature Power is Tri Attack this gen - const statusMoves = this.dex.moves.all() - .filter(move => move.category === 'Status' && move.id !== 'naturepower') - .map(move => move.id); + const statusMoves = this.cachedStatusMoves; // General incompatibilities const incompatiblePairs = [ diff --git a/data/random-battles/gen8/teams.ts b/data/random-battles/gen8/teams.ts index e8e68b1dc84e..210968ed3df2 100644 --- a/data/random-battles/gen8/teams.ts +++ b/data/random-battles/gen8/teams.ts @@ -95,7 +95,7 @@ function sereneGraceBenefits(move: Move) { } export class RandomGen8Teams { - dex: ModdedDex; + readonly dex: ModdedDex; gen: number; factoryTier: string; format: Format; @@ -116,6 +116,11 @@ export class RandomGen8Teams { */ moveEnforcementCheckers: {[k: string]: MoveEnforcementChecker}; + /** Used by .getPools() */ + private poolsCacheKey: [string | undefined, number | undefined, RuleTable | undefined, boolean] | undefined; + private cachedPool: number[] | undefined; + private cachedSpeciesPool: Species[] | undefined; + constructor(format: Format | string, prng: PRNG | PRNGSeed | null) { format = Dex.formats.get(format); this.dex = Dex.forFormat(format); @@ -232,6 +237,9 @@ export class RandomGen8Teams { return abilities.includes('Huge Power') && movePool.includes('aquajet'); }, }; + this.poolsCacheKey = undefined; + this.cachedPool = undefined; + this.cachedSpeciesPool = undefined; } setSeed(prng?: PRNG | PRNGSeed) { @@ -518,7 +526,7 @@ export class RandomGen8Teams { }; if (this.gen === 9) { // Tera type - set.teraType = this.sample(this.dex.types.all()).name; + set.teraType = this.sample(this.dex.types.names()); } team.push(set); } @@ -526,19 +534,18 @@ export class RandomGen8Teams { return team; } - randomNPokemon(n: number, requiredType?: string, minSourceGen?: number, ruleTable?: RuleTable, requireMoves = false) { - // Picks `n` random pokemon--no repeats, even among formes - // Also need to either normalize for formes or select formes at random - // Unreleased are okay but no CAP - if (requiredType && !this.dex.types.get(requiredType).exists) { - throw new Error(`"${requiredType}" is not a valid type.`); - } - + private getPools(requiredType?: string, minSourceGen?: number, ruleTable?: RuleTable, requireMoves = false) { + // Memoize pool and speciesPool because, at least during tests, they are constructed with the same parameters + // hundreds of times and are expensive to compute. const isNotCustom = !ruleTable; - - const pool: number[] = []; + let pool: number[] = []; let speciesPool: Species[] = []; - if (isNotCustom) { + const ck = this.poolsCacheKey; + if (ck && this.cachedPool && this.cachedSpeciesPool && + ck[0] === requiredType && ck[1] === minSourceGen && ck[2] === ruleTable && ck[3] === requireMoves) { + speciesPool = this.cachedSpeciesPool.slice(); + pool = this.cachedPool.slice(); + } else if (isNotCustom) { speciesPool = [...this.dex.species.all()]; for (const species of speciesPool) { if (species.isNonstandard && species.isNonstandard !== 'Unobtainable') continue; @@ -552,6 +559,9 @@ export class RandomGen8Teams { if (num <= 0 || pool.includes(num)) continue; pool.push(num); } + this.poolsCacheKey = [requiredType, minSourceGen, ruleTable, requireMoves]; + this.cachedPool = pool.slice(); + this.cachedSpeciesPool = speciesPool.slice(); } else { const EXISTENCE_TAG = ['past', 'future', 'lgpe', 'unobtainable', 'cap', 'custom', 'nonexistent']; const nonexistentBanReason = ruleTable.check('nonexistent'); @@ -596,7 +606,23 @@ export class RandomGen8Teams { if (pool.includes(num)) continue; pool.push(num); } + this.poolsCacheKey = [requiredType, minSourceGen, ruleTable, requireMoves]; + this.cachedPool = pool.slice(); + this.cachedSpeciesPool = speciesPool.slice(); } + return {pool, speciesPool}; + } + + randomNPokemon(n: number, requiredType?: string, minSourceGen?: number, ruleTable?: RuleTable, requireMoves = false) { + // Picks `n` random pokemon--no repeats, even among formes + // Also need to either normalize for formes or select formes at random + // Unreleased are okay but no CAP + if (requiredType && !this.dex.types.get(requiredType).exists) { + throw new Error(`"${requiredType}" is not a valid type.`); + } + + const {pool, speciesPool} = this.getPools(requiredType, minSourceGen, ruleTable, requireMoves); + const isNotCustom = !ruleTable; const hasDexNumber: {[k: string]: number} = {}; for (let i = 0; i < n; i++) { @@ -867,7 +893,7 @@ export class RandomGen8Teams { }; if (this.gen === 9) { // Random Tera type - set.teraType = this.sample(this.dex.types.all()).name; + set.teraType = this.sample(this.dex.types.names()); } team.push(set); } diff --git a/data/random-battles/gen9/teams.ts b/data/random-battles/gen9/teams.ts index cbab9c31e234..0f858d935e9b 100644 --- a/data/random-battles/gen9/teams.ts +++ b/data/random-battles/gen9/teams.ts @@ -145,7 +145,7 @@ function sereneGraceBenefits(move: Move) { } export class RandomTeams { - dex: ModdedDex; + readonly dex: ModdedDex; gen: number; factoryTier: string; format: Format; @@ -164,6 +164,12 @@ export class RandomTeams { */ moveEnforcementCheckers: {[k: string]: MoveEnforcementChecker}; + /** Used by .getPools() */ + private poolsCacheKey: [string | undefined, number | undefined, RuleTable | undefined, boolean] | undefined; + private cachedPool: number[] | undefined; + private cachedSpeciesPool: Species[] | undefined; + protected cachedStatusMoves: ID[]; + constructor(format: Format | string, prng: PRNG | PRNGSeed | null) { format = Dex.formats.get(format); this.dex = Dex.forFormat(format); @@ -233,6 +239,10 @@ export class RandomTeams { ), Water: (movePool, moves, abilities, types, counter) => (!counter.get('Water') && !types.includes('Ground')), }; + this.poolsCacheKey = undefined; + this.cachedPool = undefined; + this.cachedSpeciesPool = undefined; + this.cachedStatusMoves = this.dex.moves.all().filter(move => move.category === 'Status').map(move => move.id); } setSeed(prng?: PRNG | PRNGSeed) { @@ -476,9 +486,7 @@ export class RandomTeams { } // Develop additional move lists - const statusMoves = this.dex.moves.all() - .filter(move => move.category === 'Status') - .map(move => move.id); + const statusMoves = this.cachedStatusMoves; // Team-based move culls if (teamDetails.screens) { @@ -1963,7 +1971,7 @@ export class RandomTeams { if (this.forceTeraType) { set.teraType = this.forceTeraType; } else { - set.teraType = this.sample(this.dex.types.all()).name; + set.teraType = this.sample(this.dex.types.names()); } } team.push(set); @@ -1972,19 +1980,18 @@ export class RandomTeams { return team; } - randomNPokemon(n: number, requiredType?: string, minSourceGen?: number, ruleTable?: RuleTable, requireMoves = false) { - // Picks `n` random pokemon--no repeats, even among formes - // Also need to either normalize for formes or select formes at random - // Unreleased are okay but no CAP - if (requiredType && !this.dex.types.get(requiredType).exists) { - throw new Error(`"${requiredType}" is not a valid type.`); - } - + private getPools(requiredType?: string, minSourceGen?: number, ruleTable?: RuleTable, requireMoves = false) { + // Memoize pool and speciesPool because, at least during tests, they are constructed with the same parameters + // hundreds of times and are expensive to compute. const isNotCustom = !ruleTable; - - const pool: number[] = []; + let pool: number[] = []; let speciesPool: Species[] = []; - if (isNotCustom) { + const ck = this.poolsCacheKey; + if (ck && this.cachedPool && this.cachedSpeciesPool && + ck[0] === requiredType && ck[1] === minSourceGen && ck[2] === ruleTable && ck[3] === requireMoves) { + speciesPool = this.cachedSpeciesPool.slice(); + pool = this.cachedPool.slice(); + } else if (isNotCustom) { speciesPool = [...this.dex.species.all()]; for (const species of speciesPool) { if (species.isNonstandard && species.isNonstandard !== 'Unobtainable') continue; @@ -1998,6 +2005,9 @@ export class RandomTeams { if (num <= 0 || pool.includes(num)) continue; pool.push(num); } + this.poolsCacheKey = [requiredType, minSourceGen, ruleTable, requireMoves]; + this.cachedPool = pool.slice(); + this.cachedSpeciesPool = speciesPool.slice(); } else { const EXISTENCE_TAG = ['past', 'future', 'lgpe', 'unobtainable', 'cap', 'custom', 'nonexistent']; const nonexistentBanReason = ruleTable.check('nonexistent'); @@ -2042,7 +2052,23 @@ export class RandomTeams { if (pool.includes(num)) continue; pool.push(num); } + this.poolsCacheKey = [requiredType, minSourceGen, ruleTable, requireMoves]; + this.cachedPool = pool.slice(); + this.cachedSpeciesPool = speciesPool.slice(); } + return {pool, speciesPool}; + } + + randomNPokemon(n: number, requiredType?: string, minSourceGen?: number, ruleTable?: RuleTable, requireMoves = false) { + // Picks `n` random pokemon--no repeats, even among formes + // Also need to either normalize for formes or select formes at random + // Unreleased are okay but no CAP + if (requiredType && !this.dex.types.get(requiredType).exists) { + throw new Error(`"${requiredType}" is not a valid type.`); + } + + const {pool, speciesPool} = this.getPools(requiredType, minSourceGen, ruleTable, requireMoves); + const isNotCustom = !ruleTable; const hasDexNumber: {[k: string]: number} = {}; for (let i = 0; i < n; i++) { @@ -2319,7 +2345,7 @@ export class RandomTeams { if (this.forceTeraType) { set.teraType = this.forceTeraType; } else { - set.teraType = this.sample(this.dex.types.all()).name; + set.teraType = this.sample(this.dex.types.names()); } } team.push(set); diff --git a/data/random-battles/gen9baby/teams.ts b/data/random-battles/gen9baby/teams.ts index 18836784c297..da9977951a17 100644 --- a/data/random-battles/gen9baby/teams.ts +++ b/data/random-battles/gen9baby/teams.ts @@ -102,9 +102,7 @@ export class RandomBabyTeams extends RandomTeams { } // Create list of all status moves to be used later - const statusMoves = this.dex.moves.all() - .filter(move => move.category === 'Status') - .map(move => move.id); + const statusMoves = this.cachedStatusMoves; // Team-based move culls if (teamDetails.screens && movePool.length >= this.maxMoveCount + 2) { diff --git a/data/text/items.ts b/data/text/items.ts index bc1d3b8d64a7..0fcbda76f457 100644 --- a/data/text/items.ts +++ b/data/text/items.ts @@ -2270,8 +2270,11 @@ export const ItemsText: {[id: IDEntry]: ItemText} = { }, utilityumbrella: { name: "Utility Umbrella", - desc: "The holder ignores rain- and sun-based effects. Damage and accuracy calculations from attacks used by the holder are affected by rain and sun, but not attacks used against the holder.", + desc: "The holder ignores rain- and sun-based effects, including those of its Ability unless it is Orichalcum Pulse or Protosynthesis. Damage and accuracy calculations from attacks used by the holder are affected by rain and sun, but not attacks used against the holder.", shortDesc: "The holder ignores rain- and sun-based effects.", + gen8: { + desc: "The holder ignores rain- and sun-based effects, including those of its Ability. Damage and accuracy calculations from attacks used by the holder are affected by rain and sun, but not attacks used against the holder.", + }, }, venusaurite: { name: "Venusaurite", diff --git a/databases/schemas/teams.sql b/databases/schemas/teams.sql index 128599dabedb..f16c05408210 100644 --- a/databases/schemas/teams.sql +++ b/databases/schemas/teams.sql @@ -10,5 +10,5 @@ CREATE TABLE teams ( ); CREATE INDEX owner_idx ON teams(ownerid); -CREATE INDEX format_idx ON teams(ownerid); +CREATE INDEX format_idx ON teams(format); CREATE INDEX owner_fmt_idx ON teams(ownerid, format); diff --git a/package-lock.json b/package-lock.json index 059c0f423b8d..25a94b4dbe76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,7 +37,7 @@ "typescript": "^5.0.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" }, "optionalDependencies": { "better-sqlite3": "^7.6.2", diff --git a/package.json b/package.json index 7c30ade6fbad..97613739752e 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "node-oom-heapdump": "^1.2.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0" }, "scripts": { "start": "node pokemon-showdown start", diff --git a/server/README.md b/server/README.md index b9d670b02c07..ed1635869466 100644 --- a/server/README.md +++ b/server/README.md @@ -11,7 +11,7 @@ Installing ./pokemon-showdown -(Requires Node.js v14+) +(Requires Node.js v16+) If your distro package manager has an old Node.js version, the simplest way to upgrade is `n` – usually no root necessary: diff --git a/server/chat-commands/admin.ts b/server/chat-commands/admin.ts index 20afaaa63a5a..3f11448f1466 100644 --- a/server/chat-commands/admin.ts +++ b/server/chat-commands/admin.ts @@ -368,7 +368,8 @@ export const commands: Chat.ChatCommands = { const closeHtmlPage = cmd === 'closehtmlpage'; - const {targetUser, rest} = this.requireUser(target); + const [targetStr, rest] = this.splitOne(target).map(str => str.trim()); + const targets = targetStr.split('|').map(u => u.trim()); let [pageid, content] = this.splitOne(rest); let selector: string | undefined; if (cmd === 'changehtmlpageselector') { @@ -381,47 +382,70 @@ export const commands: Chat.ChatCommands = { pageid = `${user.id}-${toID(pageid)}`; - if (targetUser.locked && !this.user.can('lock')) { - this.errorReply("This user is currently locked, so you cannot send them HTML."); - return false; - } + const successes: string[] = [], errors: string[] = []; - let targetConnections = []; - // find if a connection has specifically requested this page - for (const c of targetUser.connections) { - if (c.lastRequestedPage === pageid) { - targetConnections.push(c); + content = this.checkHTML(content); + + targets.forEach(targetUsername => { + const targetUser = Users.get(targetUsername); + if (!targetUser) return errors.push(`${targetUsername} [offline/misspelled]`); + + if (targetUser.locked && !this.user.can('lock')) { + return errors.push(`${targetUser.name} [locked]`); } - } - if (!targetConnections.length) { - // no connection has requested it - verify that we share a room - this.checkPMHTML(targetUser); - targetConnections = targetUser.connections; - } - content = this.checkHTML(content); + let targetConnections = []; + // find if a connection has specifically requested this page + for (const c of targetUser.connections) { + if (c.lastRequestedPage === pageid) { + targetConnections.push(c); + } + } + if (!targetConnections.length) { + // no connection has requested it - verify that we share a room + try { + this.checkPMHTML(targetUser); + } catch { + return errors.push(`${targetUser.name} [not in room / blocking PMs]`); + } + targetConnections = targetUser.connections; + } - for (const targetConnection of targetConnections) { - const context = new Chat.PageContext({ - user: targetUser, - connection: targetConnection, - pageid: `view-bot-${pageid}`, - }); - if (closeHtmlPage) { - context.send(`|deinit|`); - } else if (selector) { - context.send(`|selectorhtml|${selector}|${content}`); - } else { - context.title = `[${user.name}] ${pageid}`; - context.setHTML(content); + for (const targetConnection of targetConnections) { + const context = new Chat.PageContext({ + user: targetUser, + connection: targetConnection, + pageid: `view-bot-${pageid}`, + }); + if (closeHtmlPage) { + context.send(`|deinit|`); + } else if (selector) { + context.send(`|selectorhtml|${selector}|${content}`); + } else { + context.title = `[${user.name}] ${pageid}`; + context.setHTML(content); + } } - } + successes.push(targetUser.name); + }); if (closeHtmlPage) { - this.sendReply(`Closed the bot page ${pageid} for ${targetUser.name}.`); + if (successes.length) { + this.sendReply(`Closed the bot page ${pageid} for ${Chat.toListString(successes)}.`); + } + if (errors.length) { + this.errorReply(`Unable to close the bot page for ${Chat.toListString(errors)}.`); + } } else { - this.sendReply(`Sent ${targetUser.name}${selector ? ` the selector ${selector} on` : ''} the bot page ${pageid}.`); + if (successes.length) { + this.sendReply(`Sent ${Chat.toListString(successes)}${selector ? ` the selector ${selector} on` : ''} the bot page ${pageid}.`); + } + if (errors.length) { + this.errorReply(`Unable to send the bot page ${pageid} to ${Chat.toListString(errors)}.`); + } } + + if (!successes.length) return false; }, sendhtmlpagehelp: [ `/sendhtmlpage [userid], [pageid], [html] - Sends [userid] the bot page [pageid] with the content [html]. Requires: * # ~`, @@ -473,15 +497,8 @@ export const commands: Chat.ChatCommands = { room = this.requireRoom(); this.checkCan('addhtml', null, room); - const {targetUser, rest} = this.requireUser(target); - - if (targetUser.locked && !this.user.can('lock')) { - throw new Chat.ErrorMessage("This user is currently locked, so you cannot send them private HTML."); - } - - if (!(targetUser.id in room.users)) { - throw new Chat.ErrorMessage("You cannot send private HTML to users who are not in this room."); - } + const [targetStr, rest] = this.splitOne(target).map(str => str.trim()); + const targets = targetStr.split('|').map(u => u.trim()); let html: string; let messageType: string; @@ -499,13 +516,33 @@ export const commands: Chat.ChatCommands = { html = this.checkHTML(html); if (!html) return this.parse('/help sendprivatehtmlbox'); - html = `${Utils.html`
[Private from ${user.name}]
`}${Chat.collapseLineBreaksHTML(html)}`; if (plainHtml) html = `
${html}
`; - targetUser.sendTo(room, `|${messageType}|${html}`); + const successes: string[] = [], errors: string[] = []; + + targets.forEach(targetUsername => { + const targetUser = Users.get(targetUsername); + + if (!targetUser) return errors.push(`${targetUsername} [offline/misspelled]`); + + if (targetUser.locked && !this.user.can('lock')) { + return errors.push(`${targetUser.name} [locked]`); + } + + if (!(targetUser.id in room!.users)) { + return errors.push(`${targetUser.name} [not in room]`); + } + + successes.push(targetUser.name); + targetUser.sendTo(room, `|${messageType}|${html}`); + }); + + + if (successes.length) this.sendReply(`Sent private HTML to ${Chat.toListString(successes)}.`); + if (errors.length) this.errorReply(`Unable to send private HTML to ${Chat.toListString(errors)}.`); - this.sendReply(`Sent private HTML to ${targetUser.name}.`); + if (!successes.length) return false; }, sendprivatehtmlboxhelp: [ `/sendprivatehtmlbox [userid], [html] - Sends [userid] the private [html]. Requires: * # ~`, diff --git a/server/chat-commands/core.ts b/server/chat-commands/core.ts index d1aa0cdcfd11..0764c30b42bc 100644 --- a/server/chat-commands/core.ts +++ b/server/chat-commands/core.ts @@ -290,7 +290,9 @@ export const commands: Chat.ChatCommands = { if (response[1] || !name) { throw new Chat.ErrorMessage("Error while verifying username: " + (response[1]?.message || "malformed name received")); } - user.send(`|openpage|https://smogon.com/tools/connect-ps-account/${user.id}/${name}`); + const link = `https://www.smogon.com/tools/connect-ps-account/${user.id}/${name}`; + user.send(`|openpage|${link}`); + this.sendReply(`|html|If the page failed to open, you may link your Smogon and PS accounts by clicking this link.`); }, async msgroom(target, room, user, connection) { diff --git a/server/chat-commands/info.ts b/server/chat-commands/info.ts index 0d2573f4ca32..70fbe95b98e0 100644 --- a/server/chat-commands/info.ts +++ b/server/chat-commands/info.ts @@ -822,7 +822,7 @@ export const commands: Chat.ChatCommands = { allAdjacentFoes: "All Adjacent Opponents", foeSide: "Opposing Side", allySide: "User's Side", - allyTeam: "User's Side", + allyTeam: "User's Team", allAdjacent: "All Adjacent Pok\u00e9mon", any: "Any Pok\u00e9mon", all: "All Pok\u00e9mon", diff --git a/server/chat-commands/moderation.ts b/server/chat-commands/moderation.ts index 0531489a768e..5023ee007aae 100644 --- a/server/chat-commands/moderation.ts +++ b/server/chat-commands/moderation.ts @@ -1427,7 +1427,10 @@ export const commands: Chat.ChatCommands = { this.privateModAction(`${user.name} notes: ${target}`); }, - modnotehelp: [`/modnote [note] - Adds a moderator note that can be read through modlog. Requires: % @ # ~`], + modnotehelp: [ + `/modnote - Adds a moderator note that can be read through modlog. Requires: % @ # ~`, + `/modnote [] - Adds a moderator note to a user's modlog that can be read through modlog. Requires: % @ # ~`, + ], globalpromote: 'promote', promote(target, room, user, connection, cmd) { diff --git a/server/chat-plugins/daily-spotlight.ts b/server/chat-plugins/daily-spotlight.ts index 0d797d8e2063..380b6c82360a 100644 --- a/server/chat-plugins/daily-spotlight.ts +++ b/server/chat-plugins/daily-spotlight.ts @@ -52,7 +52,7 @@ function nextDaily() { const midnight = new Date(); midnight.setHours(24, 0, 0, 0); -let timeout = setTimeout(nextDaily, midnight.valueOf() - Date.now()); +let timeout = setTimeout(nextDaily, midnight.getTime() - Date.now()); export async function renderSpotlight(roomid: RoomID, key: string, index: number) { let imgHTML = ''; diff --git a/server/chat-plugins/seasons.ts b/server/chat-plugins/seasons.ts index 06a20e427145..6e79e7fe89fb 100644 --- a/server/chat-plugins/seasons.ts +++ b/server/chat-plugins/seasons.ts @@ -228,8 +228,7 @@ export function rollTimer() { void updateBadgeholders(); const time = Date.now(); const next = new Date(); - next.setHours(next.getHours() + 1); - next.setMinutes(0, 0, 0); + next.setHours(next.getHours() + 1, 0, 0, 0); updateTimeout = setTimeout(() => rollTimer(), next.getTime() - time); const discussionRoom = Rooms.search('seasondiscussion'); diff --git a/server/chat-plugins/suspect-tests.ts b/server/chat-plugins/suspect-tests.ts index 0100fd43ac5f..09340ad25636 100644 --- a/server/chat-plugins/suspect-tests.ts +++ b/server/chat-plugins/suspect-tests.ts @@ -120,7 +120,7 @@ export const commands: Chat.ChatCommands = { }; saveSuspectTests(); this.sendReply(`Added a suspect test notice for ${suspectString} in ${format.name}.`); - if (reqData.coil) this.sendReply('Remember to add a B value for your test\'s COIL setting with /suspects setcoil.'); + if (reqData.coil) this.sendReply('Remember to add a B value for your test\'s COIL setting with /suspects setbvalue.'); }, end: 'remove', @@ -219,28 +219,32 @@ export const commands: Chat.ChatCommands = { return this.parse('/help suspects'); }, - deletecoil: 'setcoil', - sc: 'setcoil', - dc: 'setcoil', - async setcoil(target, room, user, connection, cmd) { + deletebvalue: 'setbvalue', + deletecoil: 'setbvalue', + sbv: 'setbvalue', + dbv: 'setbvalue', + sc: 'setbvalue', + dc: 'setbvalue', + setcoil: 'setbvalue', + async setbvalue(target, room, user, connection, cmd) { checkPermissions(this); if (!toID(target)) { return this.parse(`/help ${cmd}`); } - const [format, source] = this.splitOne(target); - const formatid = toID(format); + const [formatStr, source] = this.splitOne(target); + const format = Dex.formats.get(formatStr); let bVal: number | undefined = parseFloat(source); if (cmd.startsWith('d')) { bVal = undefined; } else if (!source || isNaN(bVal) || bVal < 1) { return this.errorReply(`Specify a valid COIL B value.`); } - if (!formatid || !Dex.formats.get(formatid).exists) { - return this.errorReply(`Specify a valid format to set COIL for.`); + if (!toID(formatStr) || !format.exists) { + return this.errorReply(`Specify a valid format to set a COIL B value for. Check spelling?`); } this.sendReply(`Updating...`); const [res, error] = await LoginServer.request('updatecoil', { - format: formatid, + format: format.id, coil_b: bVal, }); if (error) { @@ -249,18 +253,18 @@ export const commands: Chat.ChatCommands = { if (!res || res.actionerror) { return this.errorReply(res?.actionerror || "The loginserver is currently disabled."); } - this.globalModlog(`${source ? 'SET' : 'REMOVE'}COIL`, null, `${formatid}${bVal ? ` to ${bVal}` : ""}`); + this.globalModlog(`${source ? 'SET' : 'REMOVE'}BVALUE`, null, `${format.id}${bVal ? ` to ${bVal}` : ""}`); this.addGlobalModAction( - `${user.name} ${bVal ? `set COIL for ${formatid} to ${bVal}` : `removed COIL values for ${formatid}`}` + `${user.name} ${bVal ? `set B value for ${format.name} to ${bVal}` : `removed B value for ${format.name}`}.` ); if (source) { - return this.sendReply(`COIL B value for ${formatid} set to ${bVal}`); + return this.sendReply(`COIL B value for ${format.name} set to ${bVal}.`); } else { - return this.sendReply(`Removed COIL for ${formatid}.`); + return this.sendReply(`Removed COIL B value for ${format.name}.`); } }, - setcoilhelp: [ - `/suspects setcoil OR /suspects sc [formatid], [B value] - Activate COIL ranking for the given [formatid] with the given [B value].`, + setbvaluehelp: [ + `/suspects setbvalue OR /suspects sbv [formatid], [B value] - Activate COIL ranking for the given [formatid] with the given [B value].`, `Requires: suspect whitelist ~`, ], }, @@ -275,7 +279,7 @@ export const commands: Chat.ChatCommands = { `/suspects remove [tier]: deletes a suspect test. Requires: ~
` + `/suspects whitelist [username]: allows [username] to add suspect tests. Requires: ~
` + `/suspects unwhitelist [username]: disallows [username] from adding suspect tests. Requires: ~
` + - `/suspects setcoil OR /suspects sc [formatid], [B value]: Activate COIL ranking for the given [formatid] with the given [B value].` + + `/suspects setbvalue OR /suspects sbv [formatid], [B value]: Activate COIL ranking for the given [formatid] with the given [B value].` + `Requires: suspect whitelist ~` ); }, diff --git a/server/private-messages/index.ts b/server/private-messages/index.ts index 330f91a424f1..1c48b49a3492 100644 --- a/server/private-messages/index.ts +++ b/server/private-messages/index.ts @@ -140,8 +140,8 @@ export const PrivateMessages = new class { if (!PM.isParentProcess) return null!; const time = Date.now(); // even though we expire once a week atm, we check once a day - const nextMidnight = new Date(time + 24 * 60 * 60 * 1000); - nextMidnight.setHours(0, 0, 1); + const nextMidnight = new Date(); + nextMidnight.setHours(24, 0, 0, 0); if (this.clearInterval) clearTimeout(this.clearInterval); this.clearInterval = setTimeout(() => { void this.clearOffline(); diff --git a/server/room-battle.ts b/server/room-battle.ts index c1cc67c70ab1..23b390a07544 100644 --- a/server/room-battle.ts +++ b/server/room-battle.ts @@ -1116,7 +1116,7 @@ export class RoomBattle extends RoomGame { checkForcedUserSettings(user: User) { this.forcedSettings = { modchat: this.forcedSettings.modchat || RoomBattle.battleForcedSetting(user, 'modchat'), - privacy: this.forcedSettings.privacy || RoomBattle.battleForcedSetting(user, 'privacy'), + privacy: !!this.options.rated && (this.forcedSettings.privacy || RoomBattle.battleForcedSetting(user, 'privacy')), }; if ( this.players.some(p => p.getUser()?.battleSettings.special) || diff --git a/server/roomlogs.ts b/server/roomlogs.ts index 15c99dae71b2..841a4663392c 100644 --- a/server/roomlogs.ts +++ b/server/roomlogs.ts @@ -327,8 +327,8 @@ export class Roomlog { log.setupRoomlogStream(); } const time = Date.now(); - const nextMidnight = new Date(time + 24 * 60 * 60 * 1000); - nextMidnight.setHours(0, 0, 1); + const nextMidnight = new Date(); + nextMidnight.setHours(24, 0, 0, 0); Roomlogs.rollLogTimer = setTimeout(() => Roomlog.rollLogs(), nextMidnight.getTime() - time); } truncate() { diff --git a/sim/battle-actions.ts b/sim/battle-actions.ts index 406496a3f42f..b6e89572529c 100644 --- a/sim/battle-actions.ts +++ b/sim/battle-actions.ts @@ -2066,6 +2066,10 @@ export class BattleActions { pokemon.maxhp = newMaxHP; this.battle.add('-heal', pokemon, pokemon.getHealth, '[silent]'); } + if (pokemon.species.baseSpecies === 'Morpeko') { + pokemon.baseSpecies = pokemon.species; + pokemon.details = pokemon.details.replace('Morpeko', pokemon.species.name); + } this.battle.runEvent('AfterTerastallization', pokemon); } diff --git a/sim/dex-data.ts b/sim/dex-data.ts index c52b0e90d3b8..ce4df1661bd3 100644 --- a/sim/dex-data.ts +++ b/sim/dex-data.ts @@ -20,18 +20,12 @@ import {Utils} from '../lib'; * commonly it's used. */ export function toID(text: any): ID { - // The sucrase transformation of optional chaining is too expensive to be used in a hot function like this. - /* eslint-disable @typescript-eslint/prefer-optional-chain */ - if (text && text.id) { - text = text.id; - } else if (text && text.userid) { - text = text.userid; - } else if (text && text.roomid) { - text = text.roomid; + if (typeof text !== 'string') { + if (text) text = text.id || text.userid || text.roomid || text; + if (typeof text === 'number') text = '' + text; + else if (typeof text !== 'string') return ''; } - if (typeof text !== 'string' && typeof text !== 'number') return ''; - return ('' + text).toLowerCase().replace(/[^a-z0-9]+/g, '') as ID; - /* eslint-enable @typescript-eslint/prefer-optional-chain */ + return text.toLowerCase().replace(/[^a-z0-9]+/g, '') as ID; } /** diff --git a/sim/dex-moves.ts b/sim/dex-moves.ts index 7853c5e2135d..370efb45589e 100644 --- a/sim/dex-moves.ts +++ b/sim/dex-moves.ts @@ -655,6 +655,18 @@ export class DexMoves { if (move.gen > this.dex.gen) { (move as any).isNonstandard = 'Future'; } + if (this.dex.parentMod) { + // If move is exactly identical to parentMod's move, reuse parentMod's copy + const parentMod = this.dex.mod(this.dex.parentMod); + if (moveData === parentMod.data.Moves[id]) { + const parentMove = parentMod.moves.getByID(id); + if (move.isNonstandard === parentMove.isNonstandard && + move.desc === parentMove.desc && + move.shortDesc === parentMove.shortDesc) { + move = parentMove; + } + } + } } else { move = new DataMove({ name: id, exists: false, diff --git a/sim/teams.ts b/sim/teams.ts index 11d635443671..cd9fa1a15d4a 100644 --- a/sim/teams.ts +++ b/sim/teams.ts @@ -618,15 +618,16 @@ export const Teams = new class Teams { getGenerator(format: Format | string, seed: PRNG | PRNGSeed | null = null) { let TeamGenerator; format = Dex.formats.get(format); - if (toID(format).includes('gen9computergeneratedteams')) { + const formatID = toID(format); + if (formatID.includes('gen9computergeneratedteams')) { TeamGenerator = require(Dex.forFormat(format).dataDir + '/cg-teams').default; - } else if (toID(format).includes('gen9vgcpride')) { + } else if (formatID.includes('gen9vgcpride')) { TeamGenerator = require(`../data/mods/gen9vgcgay/random-teams`).default; - } else if (toID(format).includes('gen9superstaffbrosultimate')) { + } else if (formatID.includes('gen9superstaffbrosultimate')) { TeamGenerator = require(`../data/mods/gen9ssb/random-teams`).default; - } else if (toID(format).includes('gen9babyrandombattle')) { + } else if (formatID.includes('gen9babyrandombattle')) { TeamGenerator = require(`../data/random-battles/gen9baby/teams`).default; - } else if (toID(format).includes('gen9randombattle') && format.ruleTable?.has('+pokemontag:cap')) { + } else if (formatID.includes('gen9randombattle') && format.ruleTable?.has('+pokemontag:cap')) { TeamGenerator = require(`../data/random-battles/gen9cap/teams`).default; } else { TeamGenerator = require(`../data/random-battles/${format.mod}/teams`).default; diff --git a/test/random-battles/tools.js b/test/random-battles/tools.js index bb0c23e182ad..4c0d97ba9f2a 100644 --- a/test/random-battles/tools.js +++ b/test/random-battles/tools.js @@ -22,10 +22,11 @@ function testSet(pokemon, options, test) { const isDoubles = options.isDoubles || (options.format && options.format.includes('doubles')); const isDynamax = options.isDynamax || !(options.format && options.format.includes('nodmax')); + const generator = Teams.getGenerator(options.format, [0, 0, 0, 0]); for (let i = 0; i < rounds; i++) { // If undefined, test lead 1/6 of the time const isLead = options.isLead === undefined ? i % 6 === 2 : options.isLead; - const generator = Teams.getGenerator(options.format, options.seed || [i, i, i, i]); + generator.setSeed(options.seed || [i, i, i, i]); const set = generator.randomSet(pokemon, {}, isLead, isDoubles, isDynamax); test(set); } @@ -107,8 +108,9 @@ function testAlwaysHasMove(pokemon, options, move) { function testTeam(options, test) { const rounds = options.rounds || 1000; + const generator = Teams.getGenerator(options.format, [0, 0, 0, 0]); for (let i = 0; i < rounds; i++) { - const generator = Teams.getGenerator(options.format, options.seed || [i, i, i, i]); + generator.setSeed(options.seed || [i, i, i, i]); const team = generator.getTeam(); test(team); } diff --git a/test/sim/abilities/hungerswitch.js b/test/sim/abilities/hungerswitch.js index 119974e07239..e28cb207ee5b 100644 --- a/test/sim/abilities/hungerswitch.js +++ b/test/sim/abilities/hungerswitch.js @@ -23,4 +23,50 @@ describe("Hunger Switch", function () { battle.makeChoices(); assert.species(peko, 'Morpeko'); }); + + it("should revert back to the base form when switched out", function () { + battle = common.createBattle([[ + {species: 'Morpeko', ability: 'hungerswitch', moves: ['rest']}, + {species: 'Furret', ability: 'Run Away', moves: ['sleeptalk']}, + ], [ + {species: 'Magikarp', ability: 'Swift Swim', moves: ['splash']}, + {species: 'Koffing', ability: 'neutralizinggas', moves: ['sleeptalk']}, + ]]); + const peko = battle.p1.active[0]; + battle.makeChoices(); + assert.species(peko, 'Morpeko-Hangry'); + battle.makeChoices('switch 2', 'auto'); + battle.makeChoices('switch 2', 'switch 2'); + assert.species(peko, 'Morpeko'); + }); + + it("should stop activating when Morpeko Terastallizes", function () { + battle = common.createBattle([[ + {species: 'Morpeko', ability: 'hungerswitch', moves: ['rest']}, + ], [ + {species: 'Magikarp', ability: 'Swift Swim', moves: ['splash']}, + ]]); + const peko = battle.p1.active[0]; + battle.makeChoices(); + assert.species(peko, 'Morpeko-Hangry'); + battle.makeChoices('move 1 terastallize', 'auto'); + assert.species(peko, 'Morpeko-Hangry'); + }); + + it("should maintain its form when Terastallized, even when switched out", function () { + battle = common.createBattle([[ + {species: 'Morpeko', ability: 'hungerswitch', moves: ['rest']}, + {species: 'Furret', ability: 'Run Away', moves: ['sleeptalk']}, + ], [ + {species: 'Magikarp', ability: 'Swift Swim', moves: ['splash']}, + {species: 'Koffing', ability: 'neutralizinggas', moves: ['sleeptalk']}, + ]]); + const peko = battle.p1.active[0]; + battle.makeChoices(); + battle.makeChoices('move 1 terastallize', 'auto'); + assert.species(peko, 'Morpeko-Hangry'); + battle.makeChoices('switch 2', 'auto'); + battle.makeChoices('switch 2', 'switch 2'); + assert.species(peko, 'Morpeko-Hangry'); + }); });