/** * Integration for Smogon tournaments. * @author mia-pi-git */ import { FS, Utils } from '../../lib'; type Image = [string, number, number]; interface TourEvent { title: string; url: string; desc: string; image?: Image; /** If there's an image, there needs to be credit to wherever they got it */ artistCredit?: { url: string, name: string }; id: string; shortDesc: string; date: number; // make this required later ends?: number; } interface TourTable { title: string; tours: TourEvent[]; whitelist?: string[]; icon?: Image; desc: string; } export const tours: Record = { official: { title: "Smogon Officials", // cap this one's dimensions icon: ['https://www.smogon.com/media/zracknel-beta.svg', 178, 200], tours: [], desc: "Tournaments run by Smogon staff.", }, smogon: { title: "Open Sign-Ups", tours: [], desc: "Tournaments run by Smogon staff and regular users alike.", }, ps: { title: "Pokémon Showdown!", icon: ['https://play.pokemonshowdown.com/pokemonshowdownbeta.png', 146, 44], tours: [], desc: "Tournaments run by the rooms of Pokemon Showdown.", }, }; try { const data = JSON.parse(FS('config/chat-plugins/smogtours.json').readSync()); // settings should prioritize hardcoded values for these keys const PRIO = ['title', 'icon']; for (const key in data) { const section = (tours[key] ||= data[key]) as any; for (const k in data[key]) { if (PRIO.includes(k)) { if (!section[k]) section[k] = data[key][k]; } else { section[k] = data[key][k]; } } } } catch {} function saveTours() { FS('config/chat-plugins/smogtours.json').writeUpdate(() => JSON.stringify(tours)); } function getTour(categoryID: ID, id: string) { id = toID(id); if (!tours[categoryID]) return null; const idx = tours[categoryID].tours.findIndex(f => f.id === id) ?? -1; const tour = tours[categoryID].tours[idx]; if (!tour) { return null; } if (tour.ends && Date.now() > tour.ends) { tours[categoryID].tours.splice(idx, 1); return null; } return tour; } function checkWhitelisted(category: ID, user: User) { return category ? tours[category].whitelist?.includes(user.id) : Object.values(tours).some(f => f.whitelist?.includes(user.id)); } function checkCanEdit(user: User, context: Chat.PageContext | Chat.CommandContext, category?: ID) { category = toID(category); if (!checkWhitelisted(category, user)) { context.checkCan('rangeban'); } } export const commands: Chat.ChatCommands = { smogtours: { ''() { return this.parse('/j view-tournaments-all'); }, edit: 'add', async add(target, room, user, connection, cmd) { if (!toID(target).length) { return this.parse(`/help smogtours`); } const targets = target.split('|'); const isEdit = cmd === 'edit'; const tourID = isEdit ? toID(targets.shift()) : null; // {title}|{category}|{url}|{end date}|{img}|{credit}|{artist}{shortDesc}|{desc} console.log(targets); const [ title, rawSection, url, rawEnds, rawImg, rawCredit, rawArtistName, rawShort, rawDesc, ] = Utils.splitFirst(targets.join('|'), '|', 8).map(f => f.trim()); const sectionID = toID(rawSection); if (!toID(title)) { return this.popupReply(`Invalid title. Must have at least one alphanumeric character.`); } const section = tours[sectionID]; if (!section) { return this.errorReply(`Invalid section ID: "${sectionID}"`); } if (!isEdit && section.tours.find(f => toID(title) === f.id)) { return this.popupReply(`A tour with that ID already exists. Please choose another.`); } checkCanEdit(user, this, sectionID); if (!Chat.isLink(url)) { return this.popupReply(`Invalid info URL: "${url}"`); } if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(rawEnds)) { return this.popupReply(`Invalid ending date: ${rawEnds}.`); } const ends = new Date(rawEnds).getTime(); if (isNaN(ends)) { return this.popupReply(`Invalid ending date: ${rawEnds}.`); } let image, artistCredit; if (rawImg) { if (!Chat.isLink(rawImg)) { return this.popupReply(`Invalid image URL: ${rawImg}`); } try { const dimensions = await Chat.fitImage(rawImg, 300, 300); image = [rawImg, ...dimensions.slice(0, -1)] as Image; } catch { return this.popupReply(`Invalid image URL: ${rawImg}`); } } if (image && !(toID(rawCredit) && toID(rawArtistName))) { return this.popupReply(`All images must have the artist named and a link to the profile of the user who created them.`); } if (rawCredit || rawArtistName) { // if one exists, both should, as verified above const artistUrl = (Chat.linkRegex.exec(rawCredit))?.[0]; if (!artistUrl) { return this.errorReply(`Invalid artist credit URL.`); } artistCredit = { url: artistUrl, name: rawArtistName.trim() }; } if (!rawShort?.length || !rawDesc?.length) { return this.popupReply(`Must provide both a short description and a full description.`); } const tour: TourEvent = { title: Utils.escapeHTML(title), url, image, artistCredit, shortDesc: rawShort.replace(/ /g, '\n'), desc: rawDesc.replace(/ /g, '\n'), id: tourID || toID(title), date: Date.now(), ends, }; if (isEdit) { const index = section.tours.findIndex(t => t.id === tour.id); if (index < 0) { return this.popupReply(`Tour not found. Create one first.`); } section.tours.splice(index, 1); } section.tours.push(tour); saveTours(); this.refreshPage(`tournaments-add`); }, end(target, room, user, connection) { const [sectionID, tourID] = target.split(',').map(toID).filter(Boolean); if (!sectionID || !tourID) { return this.parse(`/help smogtours`); } const section = tours[sectionID]; if (!section) return this.popupReply(`Invalid section: "${sectionID}"`); const idx = section.tours.findIndex(t => t.id === tourID); const title = section.tours[idx].title; if (idx < 0) { return this.popupReply(`Tour with ID "${tourID}" not found.`); } section.tours.splice(idx, 1); this.refreshPage(`tournaments-view-${sectionID}-${tourID}`); this.popupReply(`Tour "${title}" ended.`); }, whitelist(target, room, user) { this.checkCan('rangeban'); const [sectionID, targetID] = target.split(',').map(toID).filter(Boolean); if (!sectionID || !targetID) { return this.parse(`/help smogtours`); } const section = tours[sectionID]; if (!section) { return this.errorReply(`Invalid section ID: "${sectionID}". Valid IDs: ${Object.keys(tours).join(', ')}`); } if (section.whitelist?.includes(targetID)) { return this.errorReply(`That user is already whitelisted on that section.`); } if (!section.whitelist) section.whitelist = []; section.whitelist.push(targetID); this.privateGlobalModAction( `${user.name} whitelisted ${targetID} to manage tours for the ${section.title} section` ); this.globalModlog('TOUR WHITELIST', targetID); saveTours(); }, unwhitelist(target, room, user) { this.checkCan('rangeban'); const [sectionID, targetID] = target.split(',').map(toID).filter(Boolean); if (!sectionID || !targetID) { return this.parse(`/help smogtours`); } const section = tours[sectionID]; if (!section) { return this.errorReply(`Invalid section ID: "${sectionID}". Valid IDs: ${Object.keys(tours).join(', ')}`); } const idx = section.whitelist?.indexOf(targetID) ?? -1; if (!section.whitelist || idx < 0) { return this.errorReply(`${targetID} is not whitelisted in that section.`); } section.whitelist.splice(idx, 1); if (!section.whitelist.length) { delete section.whitelist; } this.privateGlobalModAction( `${user.name} removed ${targetID} from the tour management whitelist for the ${section.title} section` ); this.globalModlog('TOUR UNWHITELIST', targetID); saveTours(); }, view() { return this.parse(`/join view-tournaments-all`); }, }, smogtourshelp: [ `/smogtours view - View a list of ongoing forum tournaments.`, `/smogtours whitelist [section], [user] - Whitelists the given [user] to manage tournaments for the given [section].`, `Requires: ~`, `/smogtours unwhitelist [section], [user] - Removes the given [user] from the [section]'s management whitelist.`, `Requires: ~`, ], }; /** Modifies `inner` in-place to wrap it in the necessary HTML to show a tab on the sidebar. */ function renderTab(inner: string, isTitle?: boolean, isCur?: boolean) { isTitle = false; let buf = ''; if (isCur) { // the CSS breaks entirely without the folderhacks. buf += `
`; buf += `
`; buf += `
${inner}
`; } else { if (!isTitle) { inner = `
${inner}
`; } buf += `
${inner}
`; } return buf; } const refresh = (pageid: string) => ( `` ); const back = (section?: string) => ( `` + ` Back` ); export function renderPageChooser(curPage: string, buffer: string, user?: User) { let buf = `
`; buf += `
`; buf += `
`; const keys = Object.keys(tours); buf += keys.map(cat => { let innerBuf = ''; const tourData = tours[cat]; innerBuf += renderTab( `${tourData.title}`, true, curPage === cat ); if (tourData.tours.length) { Utils.sortBy(tourData.tours, t => -t.date); innerBuf += tourData.tours.map(t => ( renderTab( `${t.title}`, false, curPage === `${cat}-${t.id}` ) )).join(''); } else { innerBuf += renderTab(`None`, false); } return innerBuf; }).join('
'); if (user && (checkWhitelisted('', user) || user?.can('rangeban'))) { buf += `
`; buf += renderTab( `Manage`, true, curPage === 'manage' ); buf += renderTab( `Start new`, false, curPage === 'start', ); buf += renderTab( `Edit existing`, false, curPage === 'edit', ); if (user.can('rangeban')) { buf += renderTab( `Whitelist`, false, curPage === 'whitelist', ); } } buf += `
`; buf += `${buffer}
`; return buf; } function error(page: string, message: string, user: User) { return renderPageChooser(page, `
${message}
`, user); } export const pages: Chat.PageTable = { tournaments: { all(query, user) { let buf = `${refresh(this.pageid)}
`; buf += `

Welcome!

`; const icon = tours.official.icon; if (icon) buf += `
`; buf += `
`; this.title = '[Tournaments] All'; buf += `

Smogon runs official tournaments across their metagames where the strongest and most `; buf += `experienced competitors duke it out for prizes and recognition!

`; buf += `You can see a listing of current official tournaments here; `; buf += `by clicking any hyperlink, you will be directed to the forum for any given tournament!

`; buf += `Be sure to sign up if you are eager to participate or `; buf += `check it out if you want to spectate the most hyped games out there.

`; buf += `For information on tournament rules and etiquette, check out this information thread.`; buf += `

`; buf += Object.keys(tours).map(catID => ( `` + ` ${tours[catID].title}` )).join(' '); buf += `
`; return renderPageChooser('', buf, user); }, view(query, user) { const [categoryID, tourID] = query.map(toID); if (!categoryID || !tourID) { return error('', 'You must specify a tour category and a tour ID.', user); } this.title = `[Tournaments] [${categoryID}] `; if (!tours[categoryID]) { return error('', `Invalid tour section: '${categoryID}'.`, user); } const tour = getTour(categoryID, tourID); if (!tour) { return error(categoryID, `Tour '${tourID}' not found.`, user); } // unescaping since it's escaped on client this.title += `${tour.title}` .replace(/"/g, '"') .replace(/>/g, '>') .replace(/</g, '<') .replace(/&/g, '&'); // stuff! let buf = `${back(categoryID)}${refresh(this.pageid)}
`; buf += `

${tour.title}

`; if (tour.image) { buf += ``; if (tour.artistCredit) { buf += `
The creator of this image, ${tour.artistCredit.name}, `; buf += `can be found here.`; } } buf += `
`; if (tour.ends) { buf += `
Signups end: ${Chat.toTimestamp(new Date(tour.ends)).split(' ')[0]}`; } buf += `
`; buf += Utils.escapeHTML(tour.desc).replace(/\n/ig, '
'); buf += `

View information and signups`; try { checkCanEdit(user, this, categoryID); buf += `

Manage`; buf += ``; buf += `
`; } catch {} return renderPageChooser(query.join('-'), buf, user); }, section(query, user) { const categoryID = toID(query.shift()); if (!categoryID) { return error('', `No section specified.`, user); } this.title = '[Tournaments] ' + categoryID; const category = tours[categoryID]; if (!category) { return error('', Utils.html`Invalid section specified: '${categoryID}'`, user); } let buf = `${back()}${refresh(this.pageid)}

${category.title}

`; if (category.icon) { buf += `
`; } buf += `
${category.desc}
`; let needsSave = false; for (const [i, tour] of category.tours.entries()) { if (tour.ends && (tour.ends < Date.now())) { category.tours.splice(i, 1); needsSave = true; } } if (needsSave) saveTours(); if (!category.tours.length) { buf += `

There are currently no tournaments in this section with open signups.

`; buf += `

Check back later for new tours.

`; } else { buf += category.tours.map(tour => { let innerBuf = `
`; innerBuf += `${tour.title}
`; innerBuf += Utils.escapeHTML(tour.shortDesc); innerBuf += `
`; return innerBuf; }).join('
'); } return renderPageChooser(categoryID, buf, user); }, start(query, user) { checkCanEdit(user, this); // broad check first let buf = `${refresh(this.pageid)}
`; this.title = '[Tournaments] Add'; buf += `

Add new tournament


`; buf += `
`; let possibleCategory = Object.keys(tours)[0]; for (const k in tours) { if (tours[k].whitelist?.includes(user.id)) { // favor first one where user is whitelisted where applicable possibleCategory = k; break; } } buf += `Title:
`; buf += `Category:
`; buf += `Info link:
`; buf += `End date:
`; buf += `Image link (optional):
`; buf += `Artist name (required if image provided):
`; buf += `Image credit URL (required if image provided, must be a link to the creator's Smogon profile): `; buf += `
`; buf += `Short description:

`; buf += `Full description:

`; buf += `
`; return renderPageChooser('start', buf, user); }, // edit single edit(query, user) { this.title = '[Tournaments] Edit '; const [sectionID, tourID] = query.map(toID); if (!sectionID || !tourID) { return Chat.resolvePage(`view-tournaments-manage`, user, this.connection); } const section = tours[sectionID]; if (!section) return error('edit', `Invalid section: "${sectionID}"`, user); const tour = section.tours.find(t => t.id === tourID); if (!tour) return error('edit', `Tour with ID "${tourID}" not found.`, user); let buf = `${refresh(this.pageid)}

Edit tournament "${tour.title}"


`; buf += `
`; buf += `Title:
`; buf += `Info link:
`; const curEndDay = Chat.toTimestamp(new Date(tour.ends || Date.now())).split(' ')[0]; buf += `End date:
`; buf += `Image link (optional):
`; buf += `Artist name (required if image provided):
`; buf += `Image credit (required if image provided, must be a link to the creator's Smogon profile): `; buf += `
`; buf += `Short description:
`; buf += `
`; const desc = Utils.escapeHTML(tour.desc).replace(/
/g, ' '); buf += `Full description:

`; buf += ``; return renderPageChooser('edit', buf, user); }, // panel for all you have perms to edit manage(query, user) { checkCanEdit(user, this); this.title = '[Tournaments] Manage'; let buf = `${refresh(this.pageid)}

Manage ongoing tournaments


`; buf += Object.keys(tours).map(cat => { let innerBuf = ''; try { checkCanEdit(user, this, toID(cat)); } catch { return ''; } const section = tours[cat]; innerBuf += `${section.title}:
`; for (const [i, tour] of section.tours.entries()) { if (tour.ends && Date.now() > tour.ends) { section.tours.splice(i, 1); saveTours(); } } innerBuf += section.tours.map( t => `• ${t.title}` ).join('
') || "None active."; return innerBuf; }).filter(Boolean).join('
'); return renderPageChooser('manage', buf, user); }, whitelists(query, user) { this.checkCan('rangeban'); let buf = `${refresh(this.pageid)}

Section whitelists
`; for (const k in tours) { buf += `${tours[k].title}
`; const whitelist = tours[k].whitelist || []; if (!whitelist.length) { buf += `None.
`; continue; } buf += Utils.sortBy(whitelist).map(f => `
  • ${f}
  • `).join(''); buf += `
    `; } return renderPageChooser('whitelist', buf, user); }, }, }; process.nextTick(() => { Chat.multiLinePattern.register('/smogtours (add|edit)'); });