Spaces:
Running
Running
/** | |
* 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<string, FormatData>; | |
} | |
interface MonEntry { | |
timesGenerated: number; | |
numWins: number; | |
} | |
interface FormatData { | |
mons: Record<string, MonEntry>; | |
period?: number; // how often it resets - defaults to 1mo | |
} | |
const STATS_PATH = Monitor.logPath('randbats/{{MONTH}}-winrates.json').path; | |
export const stats: Stats = getDefaultStats(); | |
try { | |
const path = STATS_PATH.replace('{{MONTH}}', getMonth()); | |
if (!Monitor.logPath('randbats/').existsSync()) { | |
Monitor.logPath('randbats/').mkdirSync(); | |
} | |
const savedStats = JSON.parse(FS(path).readSync()); | |
stats.elo = savedStats.elo; | |
stats.month = savedStats.month; | |
for (const k in stats.formats) { | |
stats.formats[k] = savedStats.formats[k] || stats.formats[k]; | |
} | |
} catch {} | |
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: {} }, | |
gen9randomdoublesbattle: { mons: {} }, | |
gen9babyrandombattle: { mons: {} }, | |
gen9superstaffbrosultimate: { mons: {} }, | |
gen8randombattle: { mons: {} }, | |
gen7randombattle: { mons: {} }, | |
gen6randombattle: { mons: {} }, | |
gen5randombattle: { mons: {} }, | |
gen4randombattle: { mons: {} }, | |
gen3randombattle: { mons: {} }, | |
gen2randombattle: { mons: {} }, | |
gen1randombattle: { 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. | |
export function getSpeciesName(set: PokemonSet, format: Format) { | |
const species = set.species; | |
const item = Dex.items.get(set.item); | |
const moves = set.moves; | |
const megaRayquazaPossible = ['gen6', 'gen7'].includes(format.mod) && !format.ruleset.includes('Mega Rayquaza Clause'); | |
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 === "Dudunsparce-Three-Segment") { | |
return 'Dudunsparce'; | |
} else if (species === "Maushold-Four") { | |
return 'Maushold'; | |
} else if (species === "Greninja-Bond") { | |
return 'Greninja'; | |
} else if (species === "Keldeo-Resolute") { | |
return 'Keldeo'; | |
} else if (species === "Zarude-Dada") { | |
return 'Zarude'; | |
} else if (species === 'Polteageist-Antique') { | |
return 'Polteageist'; | |
} else if (species === 'Sinistcha-Masterpiece') { | |
return 'Sinistcha'; | |
} else if (species === "Squawkabilly-Blue") { | |
return "Squawkabilly"; | |
} else if (species === "Squawkabilly-White") { | |
return "Squawkabilly-Yellow"; | |
} 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("Toxtricity-")) { | |
return 'Toxtricity'; | |
} else if (species.startsWith("Tatsugiri-")) { | |
return 'Tatsugiri'; | |
} else if (species.startsWith("Alcremie-")) { | |
return 'Alcremie'; | |
} else if (species === "Zacian" && item.name === "Rusted Sword") { | |
return 'Zacian-Crowned'; | |
} else if (species === "Zamazenta" && item.name === "Rusted Shield") { | |
return "Zamazenta-Crowned"; | |
} else if (species === "Kyogre" && item.name === "Blue Orb") { | |
return "Kyogre-Primal"; | |
} else if (species === "Groudon" && item.name === "Red Orb") { | |
return "Groudon-Primal"; | |
} else if (item.megaStone) { | |
return item.megaStone; | |
} else if (species === "Rayquaza" && moves.includes('Dragon Ascent') && !item.zMove && megaRayquazaPossible) { | |
return "Rayquaza-Mega"; | |
} else if (species === "Poltchageist-Artisan") { // Babymons from here on out | |
return "Poltchageist"; | |
} else if (species === "Shellos-East") { | |
return "Shellos"; | |
} else if (species === "Sinistea-Antique") { | |
return "Sinistea"; | |
} else if (species.startsWith("Deerling-")) { | |
return "Deerling"; | |
} else if (species.startsWith("Flabe\u0301be\u0301-")) { | |
return "Flabe\u0301be\u0301"; | |
} else { | |
return species; | |
} | |
} | |
function checkRollover() { | |
if (stats.month !== getMonth()) { | |
saveStats(stats.month); | |
Object.assign(stats, getDefaultStats()); | |
saveStats(); | |
} | |
} | |
const getZScore = (data: MonEntry) => ( | |
2 * Math.sqrt(data.timesGenerated) * (data.numWins / data.timesGenerated - 0.5) | |
); | |
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; | |
const format = Dex.formats.get(battle.format); | |
if (format.mod === 'gen2' || format.team === 'randomBaby') { | |
// ladders are inactive, so use a lower threshold | |
eloFloor = 1150; | |
} else if (format.mod !== `gen${Dex.gen}`) { | |
eloFloor = 1300; | |
} else if (format.gameType === 'doubles') { | |
// may need to be raised again if ladder takes off further | |
eloFloor = 1400; | |
} | |
if (!formatData || (format.mod !== 'gen9ssb' && battle.rated < eloFloor) || !winner) return; | |
checkRollover(); | |
for (const p of battle.players) { | |
const team = await battle.getPlayerTeam(p); | |
if (!team) return; // ??? | |
const mons = team.map(f => getSpeciesName(f, format)); | |
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.name)) { | |
formatData.mons[mon].numWins++; | |
} | |
} | |
} | |
saveStats(); | |
} | |
export const commands: Chat.ChatCommands = { | |
rwr: 'randswinrates', | |
randswinrates(target, room, user) { | |
target = toID(target); | |
if (/^(gen|)[0-9]+$/.test(target)) { | |
if (target.startsWith('gen')) target = target.slice(3); | |
target = `gen${target}randombattle`; | |
} | |
return this.parse(`/j view-winrates-${target ? Dex.formats.get(target).id : `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 sorter = toID(query.shift() || 'zscore'); | |
if (!['zscore', 'raw'].includes(sorter)) { | |
return this.errorReply(`Invalid sorting method. Must be either 'zscore' or 'raw'.`); | |
} | |
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 = `<div class="pad"><h2>Winrates for ${formatTitle} (${month})</h2>`; | |
const prevMonth = new Date(new Date(`${month}-15`).getTime() - (30 * 24 * 60 * 60 * 1000)).toISOString().slice(0, 7); | |
let hasButton = false; | |
if (await FS(STATS_PATH.replace('{{MONTH}}', prevMonth)).exists()) { | |
buf += `<a class="button" href="/view-winrates-${format}--${sorter}--${prevMonth}">Previous month</a>`; | |
hasButton = true; | |
} | |
const nextMonth = new Date(new Date(`${month}-15`).getTime() + (30 * 24 * 60 * 60 * 1000)).toISOString().slice(0, 7); | |
if (await FS(STATS_PATH.replace('{{MONTH}}', nextMonth)).exists()) { | |
if (hasButton) buf += ` | `; | |
buf += `<a class="button" href="/view-winrates-${format}--${sorter}--${nextMonth}">Next month</a>`; | |
hasButton = true; | |
} | |
buf += hasButton ? ` | ` : ''; | |
const otherSort = sorter === 'zscore' ? 'Raw' : 'Z-Score'; | |
buf += `<a class="button" target="replace" href="/view-winrates-${format}--${toID(otherSort)}--${month}">`; | |
buf += `Sort by ${otherSort} descending</a>`; | |
buf += `<hr />`; | |
const statData: Stats = month === stats.month ? | |
stats : JSON.parse(await FS(STATS_PATH.replace('{{MONTH}}', month)).read()); | |
const formatData = statData.formats[format]; | |
if (!formatData) { | |
buf += `<div class="message-error">No stats for that format found on that month.</div>`; | |
return buf; | |
} | |
this.title = `[Winrates] [${format}] ${month}`; | |
let sortFn: (val: [string, MonEntry]) => Utils.Comparable; | |
if (sorter === 'zscore') { | |
sortFn = ([_, data]) => [-getZScore(data), -data.timesGenerated]; | |
} else { | |
sortFn = ([_, data]) => [ | |
-(data.numWins / data.timesGenerated), -data.numWins, -data.timesGenerated, | |
]; | |
} | |
const mons = Utils.sortBy(Object.entries(formatData.mons), sortFn); | |
buf += `<div class="ladder pad"><table><tr><th>Pokemon</th><th>Win %</th><th>Z-Score</th>`; | |
buf += `<th>Raw wins</th><th>Times generated</th></tr>`; | |
for (const [mon, data] of mons) { | |
buf += `<tr><td>${Dex.species.get(mon).name}</td>`; | |
const { timesGenerated, numWins } = data; | |
buf += `<td>${((numWins / timesGenerated) * 100).toFixed(2)}%</td>`; | |
buf += `<td>${getZScore(data).toFixed(3)}</td>`; | |
buf += `<td>${numWins}</td><td>${timesGenerated}</td>`; | |
buf += `</tr>`; | |
} | |
buf += `</table></div></div>`; | |
return buf; | |
}, | |
}; | |