Spaces:
Paused
Paused
| /** | |
| * @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(); | |
| }, | |
| }; | |