From 3de84dd8bc8dd84bb60e4f16a1ed8ad8c5418d0c Mon Sep 17 00:00:00 2001 From: Mia <49593536+mia-pi-git@users.noreply.github.com> Date: Sat, 14 Jan 2023 19:08:12 -0600 Subject: [PATCH] Randbats: Add a chat plugin to track winrates (#9321) --- .../index.ts} | 4 +- server/chat-plugins/randombattles/winrates.ts | 208 ++++++++++++++++++ server/room-battle.ts | 5 +- 3 files changed, 213 insertions(+), 4 deletions(-) rename server/chat-plugins/{random-battles.ts => randombattles/index.ts} (99%) create mode 100644 server/chat-plugins/randombattles/winrates.ts diff --git a/server/chat-plugins/random-battles.ts b/server/chat-plugins/randombattles/index.ts similarity index 99% rename from server/chat-plugins/random-battles.ts rename to server/chat-plugins/randombattles/index.ts index f8d90f0f20..99c80348fb 100644 --- a/server/chat-plugins/random-battles.ts +++ b/server/chat-plugins/randombattles/index.ts @@ -5,8 +5,8 @@ * Set probability code written by Annika */ -import {FS, Utils} from '../../lib'; -import {SSBSet, ssbSets} from '../../data/mods/ssb/random-teams'; +import {FS, Utils} from '../../../lib'; +import {SSBSet, ssbSets} from '../../../data/mods/ssb/random-teams'; interface SetCriteria { diff --git a/server/chat-plugins/randombattles/winrates.ts b/server/chat-plugins/randombattles/winrates.ts new file mode 100644 index 0000000000..c8e51a812d --- /dev/null +++ b/server/chat-plugins/randombattles/winrates.ts @@ -0,0 +1,208 @@ +/** + * A chat plugin to store, calculate, and view winrates in random battle formats. + * @author mia-pi-git + */ + +import {FS, Utils} from '../../../lib'; + +interface Stats { + elo: number; + month: string; + formats: Record; +} + +interface FormatData { + mons: Record; + period?: number; // how often it resets - defaults to 1mo +} + +const STATS_PATH = 'logs/randbats/{{MONTH}}-winrates.json'; +export let stats: Stats; + +try { + const path = STATS_PATH.replace('{{MONTH}}', getMonth()); + if (!FS('logs/randbats/').existsSync()) { + FS('logs/randbats/').mkdirSync(); + } + stats = JSON.parse(FS(path).readSync()); +} catch { + stats = getDefaultStats(); +} + +function getDefaultStats() { + return { + elo: 1500, + month: getMonth(), + formats: { + // all of these requested by rands staff. they don't anticipate it being changed much + // so i'm not spending the time to add commands to toggle this + gen9randombattle: {mons: {}}, + gen7randombattle: {mons: {}}, + gen6randombattle: {mons: {}}, + gen5randombattle: {mons: {}}, + gen4randombattle: {mons: {}}, + gen3randombattle: {mons: {}}, + gen2randombattle: {mons: {}}, + }, + } as Stats; +} + +export function saveStats(month = getMonth()) { + // clone to avoid race conditions with the data getting deleted later (on month rollover) + const curStats = {...stats}; + FS(STATS_PATH.replace('{{MONTH}}', month)).writeUpdate(() => JSON.stringify(curStats)); +} + +function getMonth() { + return Chat.toTimestamp(new Date()).split(' ')[0].slice(0, -3); +} + +// no, this cannot be baseSpecies - some formes matter, ex arceus formes +// no, there is no better way to do this. +// yes, i tried. +function getSpeciesName(species: string) { + if (species.startsWith("Pikachu-")) { + return 'Pikachu'; + } else if (species.startsWith("Unown-")) { + return 'Unown'; + } else if (species === "Gastrodon-East") { + return 'Gastrodon'; + } else if (species === "Magearna-Original") { + return "Magearna"; + } else if (species === "Genesect-Douse") { + return "Genesect"; + } else if (species.startsWith("Basculin-")) { + return "Basculin"; + } else if (species.startsWith("Sawsbuck-")) { + return "Sawsbuck"; + } else if (species.startsWith("Vivillon-")) { + return "Vivillon"; + } else if (species.startsWith("Florges-")) { + return "Florges"; + } else if (species.startsWith("Furfrou-")) { + return "Furfrou"; + } else if (species.startsWith("Minior-")) { + return "Minior"; + } else if (species.startsWith("Gourgeist-")) { + return "Gourgeist"; + } else if (species.startsWith("Toxtricity-")) { + return 'Toxtricity'; + } else { + return species; + } +} + +function checkRollover() { + if (stats.month !== getMonth()) { + saveStats(stats.month); + Object.assign(stats, getDefaultStats()); + saveStats(); + } +} + +export const handlers: Chat.Handlers = { + onBattleEnd(battle, winner, players) { + void collectStats(battle, winner, players); + }, +}; + +async function collectStats(battle: RoomBattle, winner: ID, players: ID[]) { + const formatData = stats.formats[battle.format]; + let eloFloor = stats.elo; + if (Dex.gen !== Dex.formats.get(battle.format).gen) { + eloFloor = 1300; + } + if (!formatData || battle.rated < eloFloor) return; + checkRollover(); + for (const p of players) { + const team = await battle.getTeam(p); + if (!team) return; // ??? + const mons = team.map(f => getSpeciesName(f.species)); + for (const mon of mons) { + if (!formatData.mons[mon]) formatData.mons[mon] = {timesGenerated: 0, numWins: 0}; + formatData.mons[mon].timesGenerated++; + if (toID(winner) === toID(p)) { + formatData.mons[mon].numWins++; + } + } + } + saveStats(); +} + +export const commands: Chat.ChatCommands = { + rwr: 'randswinrates', + randswinrates(target, room, user) { + return this.parse(`/j view-winrates-${toID(target) || `gen${Dex.gen}randombattle`}`); + }, + randswinrateshelp: [ + '/randswinrates OR /rwr [format] - Get a list of the win rates for all Pokemon in the given Random Battles format.', + ], + + async removewinrates(target, room, user) { + this.checkCan('rangeban'); + if (!/^[0-9]{4}-[0-9]{2}$/.test(target) || target === getMonth()) { + return this.errorReply(`Invalid month: ${target}`); + } + const path = STATS_PATH.replace('{{MON}}', target); + if (!(await FS(path).exists())) { + return this.errorReply(`No stats for the month ${target}.`); + } + await FS(path).unlinkIfExists(); + this.globalModlog('REMOVEWINRATES', null, target); + this.privateGlobalModAction(`${user.name} removed Random Battle winrates for the month of ${target}`); + }, +}; + +export const pages: Chat.PageTable = { + async winrates(query, user) { + if (!user.named) return Rooms.RETRY_AFTER_LOGIN; + query = query.join('-').split('--'); + const format = toID(query.shift()); + if (!format) return this.errorReply(`Specify a format to view winrates for.`); + if (!stats.formats[format]) { + return this.errorReply(`That format does not have winrates tracked.`); + } + checkRollover(); + const month = query.shift() || getMonth(); + if (!/^[0-9]{4}-[0-9]{2}$/.test(month)) { + return this.errorReply(`Invalid month: ${month}`); + } + const isOldMonth = month !== getMonth(); + if (isOldMonth && !(await FS(STATS_PATH.replace('{{MONTH}}', month)).exists())) { + return this.errorReply(`There are no winrates for that month.`); + } + const formatTitle = Dex.formats.get(format).name; + let buf = `

Winrates for ${formatTitle} (${month})

`; + const prevMonth = new Date(new Date(`${month}-15`).getTime() - (30 * 24 * 60 * 60 * 1000)).toISOString().slice(0, 7); + if (await FS(STATS_PATH.replace('{{MONTH}}', prevMonth)).exists()) { + buf += `No stats for that format found on that month.
`; + return buf; + } + this.title = `[Winrates] [${format}] ${month}`; + const mons = Utils.sortBy(Object.entries(formatData.mons), + ([_, data]) => [data.numWins, data.timesGenerated]); + buf += `
`; + buf += ``; + for (const [mon, data] of mons) { + buf += ``; + const {timesGenerated, numWins} = data; + buf += ``; + buf += ``; + buf += ``; + buf += ``; + } + buf += `
PokemonWin %Z-ScoreRaw winsTimes generated
${Dex.species.get(mon).name}${((numWins / timesGenerated) * 100).toFixed(2)}%${(2 * Math.sqrt(timesGenerated) * (numWins / timesGenerated - 0.5)).toFixed(3)}${numWins}${timesGenerated}
`; + return buf; + }, +}; diff --git a/server/room-battle.ts b/server/room-battle.ts index 87145325ed..326a5ba8ec 100644 --- a/server/room-battle.ts +++ b/server/room-battle.ts @@ -1305,8 +1305,9 @@ export class RoomBattle extends RoomGames.RoomGame { } } } - async getTeam(user: User | ID) { - const id = typeof user === 'object' ? user.id : user; + async getTeam(user: User | string) { + // toID extracts user.id + const id = toID(user); const player = this.playerTable[id]; if (!player) return; void this.stream.write(`>requestteam ${player.slot}`);