Randbats: Add a chat plugin to track winrates (#9321)

This commit is contained in:
Mia 2023-01-14 19:08:12 -06:00 committed by GitHub
parent c3cef6c794
commit 3de84dd8bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 213 additions and 4 deletions

View File

@ -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 {

View File

@ -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<string, FormatData>;
}
interface FormatData {
mons: Record<string, {timesGenerated: number, numWins: number}>;
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 = `<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);
if (await FS(STATS_PATH.replace('{{MONTH}}', prevMonth)).exists()) {
buf += `<a class="button" style="float: left" href="/view-winrates-${format}--${prevMonth}>Previous month</button>`;
}
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()) {
buf += `<a class="button" style="float: right" href="/view-winrates-${format}--${nextMonth}>Next month</button>`;
}
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}`;
const mons = Utils.sortBy(Object.entries(formatData.mons),
([_, data]) => [data.numWins, data.timesGenerated]);
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>${(2 * Math.sqrt(timesGenerated) * (numWins / timesGenerated - 0.5)).toFixed(3)}</td>`;
buf += `<td>${numWins}</td><td>${timesGenerated}</td>`;
buf += `</tr>`;
}
buf += `</table></div></div>`;
return buf;
},
};

View File

@ -1305,8 +1305,9 @@ export class RoomBattle extends RoomGames.RoomGame<RoomBattlePlayer> {
}
}
}
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}`);