Jofthomas's picture
Jofthomas HF staff
Upload 4781 files
5c2ed06 verified
/**
* @author mia-pi-git
*/
import { FS, Net, Utils } from '../../lib';
export const SEASONS_PER_YEAR = 4;
export const FORMATS_PER_SEASON = 4;
export const BADGE_THRESHOLDS: Record<string, number> = {
gold: 3,
silver: 30,
bronze: 100,
};
export const FIXED_FORMATS = ['randombattle', 'ou'];
export const FORMAT_POOL = ['ubers', 'uu', 'ru', 'nu', 'pu', 'lc', 'doublesou', 'monotype'];
export const PUBLIC_PHASE_LENGTH = 3;
interface SeasonData {
current: { period: number, year: number, formatsGeneratedAt: number, season: number };
badgeholders: { [period: string]: { [format: string]: { [badgeType: string]: string[] } } };
formatSchedule: Record<string, string[]>;
}
export let data: SeasonData;
try {
data = JSON.parse(FS('config/chat-plugins/seasons.json').readSync());
} catch {
data = {
// force a reroll
current: { season: null!, year: null!, formatsGeneratedAt: null!, period: null! },
formatSchedule: {},
badgeholders: {},
};
}
export function getBadges(user: User, curFormat: string) {
let userBadges: { type: string, format: string }[] = [];
const season = data.current.season; // don't factor in old badges
for (const format in data.badgeholders[season]) {
const badges = data.badgeholders[season][format];
for (const type in badges) {
if (badges[type].includes(user.id)) {
// ex badge-bronze-gen9ou-250-1-2024
userBadges.push({ type, format });
}
}
}
// find which ones we should prioritize showing - badge of current tier/season, then top badges of other formats for this season
let curFormatBadge;
for (const [i, badge] of userBadges.entries()) {
if (badge.format === curFormat) {
userBadges.splice(i);
curFormatBadge = badge;
}
}
// now - sort by highest levels
userBadges = Utils.sortBy(userBadges, x => Object.keys(BADGE_THRESHOLDS).indexOf(x.type))
.slice(0, 2);
if (curFormatBadge) userBadges.unshift(curFormatBadge);
// format and return
return userBadges;
}
function getUserHTML(user: User, format: string) {
const buf = `<username>${user.name}</username>`;
const badgeType = getBadges(user, format).find(x => x.format === format)?.type;
if (badgeType) {
let formatType = format.split(/gen\d+/)[1];
if (!['ou', 'randombattle'].includes(formatType)) formatType = 'rotating';
return `<img src="https://${Config.routes.client}/sprites/misc/${formatType}_${badgeType}.png" />` + buf;
}
return buf;
}
export function setFormatSchedule() {
// guard heavily against this being overwritten
if (data.current.formatsGeneratedAt === getYear()) return;
data.current.formatsGeneratedAt = getYear();
const formats = generateFormatSchedule();
for (const [i, formatList] of formats.entries()) {
data.formatSchedule[i + 1] = FIXED_FORMATS.concat(formatList.slice());
}
saveData();
}
class ScheduleGenerator {
formats: string[][];
items = new Map<string, number>();
constructor() {
this.formats = new Array(SEASONS_PER_YEAR).fill(null).map(() => [] as string[]);
for (const format of FORMAT_POOL) this.items.set(format, 0);
}
generate() {
for (let i = 0; i < this.formats.length; i++) {
this.step([i, 0]);
}
for (let i = 1; i < SEASONS_PER_YEAR; i++) {
this.step([0, i]);
}
return this.formats;
}
swap(x: number, y: number) {
const item = this.formats[x][y];
for (let i = 0; i < SEASONS_PER_YEAR; i++) {
if (this.formats[i].includes(item)) continue;
for (const [j, cur] of this.formats[i].entries()) {
if (cur === item) continue;
if (this.formats[x].includes(cur)) continue;
this.formats[i][j] = item;
return cur;
}
}
throw new Error("Couldn't find swap target for " + item + ": " + JSON.stringify(this.formats));
}
select(x: number, y: number): string {
const items = Array.from(this.items).filter(entry => entry[1] < 2);
const item = Utils.randomElement(items);
if (item[1] >= 2) {
this.items.delete(item[0]);
return this.select(x, y);
}
this.items.set(item[0], item[1] + 1);
if (item[0] && this.formats[x].includes(item[0])) {
this.formats[x][y] = item[0];
return this.swap(x, y);
}
return item[0];
}
step(start: [number, number]) {
let [x, y] = start;
while (x < this.formats.length && y < FORMATS_PER_SEASON) {
const item = this.select(x, y);
this.formats[x][y] = item;
x++;
y++;
}
}
}
export function generateFormatSchedule() {
return new ScheduleGenerator().generate();
}
export async function getLadderTop(format: string) {
try {
const results = await Net(`https://${Config.routes.root}/ladder/?format=${toID(format)}&json`).get();
const reply = JSON.parse(results);
return reply.toplist;
} catch (e) {
Monitor.crashlog(e, "A season ladder request");
return null;
}
}
export async function updateBadgeholders() {
rollSeason();
const period = `${data.current.season}`;
if (!data.badgeholders[period]) {
data.badgeholders[period] = {};
}
for (const formatName of data.formatSchedule[findPeriod()]) {
const formatid = `gen${Dex.gen}${formatName}`;
const response = await getLadderTop(formatid);
if (!response) continue; // ??
const newHolders: Record<string, string[]> = {};
for (const [i, row] of response.entries()) {
let badgeType = null;
for (const type in BADGE_THRESHOLDS) {
if ((i + 1) <= BADGE_THRESHOLDS[type]) {
badgeType = type;
break;
}
}
if (!badgeType) break;
if (!newHolders[badgeType]) newHolders[badgeType] = [];
newHolders[badgeType].push(row.userid);
}
data.badgeholders[period][formatid] = newHolders;
}
saveData();
}
function getYear() {
return new Date().getFullYear();
}
function findPeriod(modifier = 0) {
return Math.floor((new Date().getMonth() + modifier) / (SEASONS_PER_YEAR - 1)) + 1;
}
/** Are we in the last three days of the month (the public phase, where badged battles are public and the room is active?) */
function checkPublicPhase() {
const daysInCurrentMonth = new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate();
// last 3 days of the month, and next month is a new season
return new Date().getDate() >= (daysInCurrentMonth - PUBLIC_PHASE_LENGTH) && findPeriod() !== findPeriod(1);
}
export function saveData() {
FS('config/chat-plugins/seasons.json').writeUpdate(() => JSON.stringify(data));
}
export function rollSeason() {
const year = getYear();
if (data.current.year !== year) {
data.current.year = year;
setFormatSchedule();
}
if (findPeriod() !== data.current.period) {
data.current.season++;
data.badgeholders[data.current.season] = {};
for (const k of data.formatSchedule[findPeriod()]) {
data.badgeholders[data.current.season][`gen${Dex.gen}${k}`] = {};
}
data.current.period = findPeriod();
saveData();
}
}
export let updateTimeout: NodeJS.Timeout | true | null = null;
export function rollTimer() {
if (updateTimeout === true) return;
if (updateTimeout) {
clearTimeout(updateTimeout);
}
updateTimeout = true;
void updateBadgeholders();
const time = Date.now();
const next = new Date();
next.setHours(next.getHours() + 1, 0, 0, 0);
updateTimeout = setTimeout(() => rollTimer(), next.getTime() - time);
const discussionRoom = Rooms.search('seasondiscussion');
if (discussionRoom) {
if (checkPublicPhase() && discussionRoom.settings.isPrivate) {
discussionRoom.setPrivate(false);
discussionRoom.settings.modchat = 'autoconfirmed';
discussionRoom.add(
`|html|<div class="broadcast-blue"><strong>The public phase of the month has now started!</strong>` +
`<br /> Badged battles are now forced public, and this room is open for use.</div>`
).update();
} else if (!checkPublicPhase() && !discussionRoom.settings.isPrivate) {
discussionRoom.setPrivate('unlisted');
discussionRoom.add(
`|html|<div class="broadcast-blue">The public phase of the month has ended.</div>`
).update();
}
}
}
export function destroy() {
if (updateTimeout && typeof updateTimeout !== 'boolean') {
clearTimeout(updateTimeout);
}
}
rollTimer();
export const commands: Chat.ChatCommands = {
seasonschedule: 'seasons',
seasons() {
return this.parse(`/join view-seasonschedule`);
},
};
export const pages: Chat.PageTable = {
seasonschedule() {
this.checkCan('globalban');
let buf = `<div class="pad"><h2>Season schedule for ${getYear()}</h2><br />`;
buf += `<div class="ladder pad"><table><tr><th>Season #</th><th>Formats</th></tr>`;
for (const period in data.formatSchedule) {
const match = findPeriod() === Number(period);
const formatString = data.formatSchedule[period]
.sort()
.map(x => Dex.formats.get(x).name.replace(`[Gen ${Dex.gen}]`, ''))
.join(', ');
buf += `<tr><td>${match ? `<strong>${period}</strong>` : period}</td>`;
buf += `<td>${match ? `<strong>${formatString}</strong>` : formatString}</td></tr>`;
}
buf += `</tr></table></div>`;
return buf;
},
seasonladder(query, user) {
const format = toID(query.shift());
const season = toID(query.shift()) || `${data.current.season}`;
if (!data.badgeholders[season]) {
return this.errorReply(`Season ${season} not found.`);
}
this.title = `[Seasons]`;
let buf = '<div class="pad">';
if (!Object.keys(data.badgeholders[season]).includes(format)) {
// fall back to the master list so that people can still access this easily from the ladder page of other formats
this.title += ` All`;
buf += `<h2>Season Records</h2>`;
const seasonsDesc = Utils.sortBy(
Object.keys(data.badgeholders),
s => s.split('-').map(x => -Number(x))
);
for (const s of seasonsDesc) {
buf += `<h3>Season ${s}</h3><hr />`;
for (const f in data.badgeholders[s]) {
buf += `<a class="button" name="send" target="replace" href="/view-seasonladder-${f}-${s}">${Dex.formats.get(f).name}</a>`;
}
buf += `<br />`;
}
return buf;
}
this.title += ` ${format} [Season ${season}]`;
const uppercase = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
let formatName = Dex.formats.get(format).name;
// futureproofing for gen10/etc
const room = Rooms.search(Utils.splitFirst(format, /\d+/)[1] || '');
if (room) {
formatName = `<a href="/${room.roomid}">${formatName}</a>`;
}
buf += `<h2>Season results for ${formatName} [${season}]</h2>`;
buf += `<small><a target="replace" href="/view-seasonladder">View past seasons</a></small>`;
let i = 0;
for (const badgeType in data.badgeholders[season][format]) {
buf += `<div class="ladder pad"><table>`;
let formatType = format.split(/gen\d+/)[1];
if (!['ou', 'randombattle'].includes(formatType)) formatType = 'rotating';
buf += `<tr><h2><img src="https://${Config.routes.client}/sprites/misc/${formatType}_${badgeType}.png" /> ${uppercase(badgeType)}</h2></tr>`;
for (const userid of data.badgeholders[season][format][badgeType]) {
i++;
buf += `<tr><td>${i}</td><td><a href="https://${Config.routes.root}/users/${userid}">${userid}</a></td></tr>`;
}
buf += `</table></div>`;
}
return buf;
},
};
export const handlers: Chat.Handlers = {
onBattleStart(user, room) {
if (!room.battle) return; // should never happen, just sating TS
// now first verify they have a badge
const badges = getBadges(user, room.battle.format);
if (!badges.length) return;
const slot = room.battle.playerTable[user.id]?.slot;
if (!slot) return; // not in battle fsr? wack
for (const badge of badges) {
room.add(`|badge|${slot}|${badge.type}|${badge.format}|${BADGE_THRESHOLDS[badge.type]}-${data.current.season}`);
}
if (
checkPublicPhase() && !room.battle.forcedSettings.privacy &&
badges.filter(x => x.format === room.battle!.format).length && room.battle.rated
) {
room.battle.forcedSettings.privacy = 'medal';
room.add(
`|html|<div class="broadcast-red"><strong>This battle is required to be public due to one or more player having a season medal.</strong><br />` +
`During the public phase, you can discuss the state of the ladder <a href="/seasondiscussion">in a special chatroom.</a></div>`
);
room.setPrivate(false);
const seasonRoom = Rooms.search('seasondiscussion');
if (seasonRoom) {
const p1html = getUserHTML(user, room.battle.format);
const otherPlayer = user.id === room.battle.p1.id ? room.battle.p2 : room.battle.p1;
const otherUser = otherPlayer.getUser();
const p2html = otherUser ? getUserHTML(otherUser, room.battle.format) : `<username>${otherPlayer.name}</username>`;
const formatName = Dex.formats.get(room.battle.format).name;
seasonRoom.add(
`|raw|<a href="/${room.roomid}" class="ilink">${formatName} battle started between ` +
`${p1html} and ${p2html}. (rating: ${Math.floor(room.battle.rated)})</a>`
).update();
}
}
room.add(
`|uhtml|medal-msg|<div class="broadcast-blue">Curious what those medals under the avatar are? PS now has Ladder Seasons!` +
` For more information, check out the <a href="https://www.smogon.com/forums/threads/3740067/">thread on Smogon.</a></div>`
);
room.update();
},
};