mirror of
https://github.com/smogon/pokemon-showdown.git
synced 2026-06-02 22:08:36 -05:00
Randbats: Add a chat plugin to track winrates (#9321)
This commit is contained in:
parent
c3cef6c794
commit
3de84dd8bc
|
|
@ -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 {
|
||||
208
server/chat-plugins/randombattles/winrates.ts
Normal file
208
server/chat-plugins/randombattles/winrates.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user