import { Elimination } from './generator-elimination'; import { RoundRobin } from './generator-round-robin'; import { Utils } from '../../lib'; import { PRNG } from '../../sim/prng'; import type { BestOfGame } from '../room-battle-bestof'; export interface TournamentRoomSettings { allowModjoin?: boolean; allowScouting?: boolean; announcements?: boolean; autoconfirmedOnly?: boolean; autodq?: number; autostart?: number | boolean; forcePublic?: boolean; forceTimer?: boolean; playerCap?: number; recentToursLength?: number; recentTours?: { name: string, baseFormat: string, time: number }[]; blockRecents?: boolean; } type Generator = RoundRobin | Elimination; const BRACKET_MINIMUM_UPDATE_INTERVAL = 2 * 1000; const AUTO_DISQUALIFY_WARNING_TIMEOUT = 30 * 1000; const MAX_AUTO_DISQUALIFY_TIMEOUT = 60 * 60 * 1000; const AUTO_START_MINIMUM_TIMEOUT = 30 * 1000; const MAX_REASON_LENGTH = 300; const MAX_CUSTOM_NAME_LENGTH = 100; const TOURBAN_DURATION = 14 * 24 * 60 * 60 * 1000; Punishments.addRoomPunishmentType({ type: 'TOURBAN', desc: 'banned from tournaments', }); const TournamentGenerators = { __proto__: null, roundrobin: RoundRobin, elimination: Elimination, }; function usersToNames(users: TournamentPlayer[]) { return users.map(user => user.name); } export class TournamentPlayer extends Rooms.RoomGamePlayer { readonly availableMatches: Set; isBusy: boolean; inProgressMatch: { to: TournamentPlayer, room: GameRoom } | null; pendingChallenge: { from?: TournamentPlayer, to?: TournamentPlayer, team: string, hidden: boolean, inviteOnly: boolean, } | null; isDisqualified: boolean; isEliminated: boolean; autoDisqualifyWarned: boolean; lastActionTime: number; wins: number; losses: number; games: number; score: number; constructor(user: User | string | null, game: Tournament, num: number) { super(user, game, num); this.availableMatches = new Set(); this.isBusy = false; this.inProgressMatch = null; this.pendingChallenge = null; this.isDisqualified = false; this.isEliminated = false; this.autoDisqualifyWarned = false; this.lastActionTime = 0; this.wins = 0; this.losses = 0; this.games = 0; this.score = 0; } } export class Tournament extends Rooms.RoomGame { override readonly gameid = 'tournament' as ID; readonly isTournament: true; readonly completedMatches: Set; /** Format ID not including custom rules */ readonly baseFormat: ID; /** * Full format specifier, including custom rules (such as 'gen7challengecup1v1@@@speciesclause') */ fullFormat: string; name: string; customRules: string[]; generator: Generator; isRated: boolean; allowScouting: boolean; allowModjoin: boolean; autoconfirmedOnly: boolean; forceTimer: boolean; autostartcap: boolean; forcePublic: boolean; isTournamentStarted: boolean; isBracketInvalidated: boolean; lastBracketUpdate: number; bracketUpdateTimer: NodeJS.Timeout | null; bracketCache: AnyObject | null; isAvailableMatchesInvalidated: boolean; availableMatchesCache: { challenges: Map, challengeBys: Map, }; autoDisqualifyTimeout: number; autoDisqualifyTimer: NodeJS.Timeout | null; autoStartTimeout: number; autoStartTimer: NodeJS.Timeout | null; constructor( room: ChatRoom, format: Format, generator: Generator, playerCap: string | undefined, isRated: boolean, name: string | undefined ) { super(room); const formatId = toID(format); this.title = format.name + ' tournament'; this.isTournament = true; this.completedMatches = new Set(); this.allowRenames = false; this.playerCap = (playerCap ? parseInt(playerCap) : Config.tourdefaultplayercap) || 0; this.baseFormat = formatId; this.fullFormat = formatId; // This will sometimes be sent alone in updates as "format", if the tour doesn't have a custom name this.name = name || formatId; this.customRules = []; this.generator = generator; this.isRated = isRated; this.allowScouting = true; this.allowModjoin = false; this.autoconfirmedOnly = false; this.forceTimer = false; this.autostartcap = false; this.forcePublic = false; if (Config.tourdefaultplayercap && this.playerCap > Config.tourdefaultplayercap) { Monitor.log(`[TourMonitor] Room ${room.roomid} starting a tour over default cap (${this.playerCap})`); } this.isTournamentStarted = false; this.isBracketInvalidated = true; this.lastBracketUpdate = 0; this.bracketUpdateTimer = null; this.bracketCache = null; this.isAvailableMatchesInvalidated = true; this.availableMatchesCache = { challenges: new Map(), challengeBys: new Map() }; this.autoDisqualifyTimeout = Infinity; this.autoDisqualifyTimer = null; this.autoStartTimeout = Infinity; this.autoStartTimer = null; room.add(`|tournament|create|${this.baseFormat}|${generator.name}|${this.playerCap}${this.name === this.baseFormat ? `` : `|${this.name}`}`); const update: { format: string, teambuilderFormat?: string, generator: string, playerCap: number, isStarted: boolean, isJoined: boolean, } = { format: this.name, generator: generator.name, playerCap: this.playerCap, isStarted: false, isJoined: false, }; if (this.name !== this.baseFormat) update.teambuilderFormat = this.baseFormat; room.send(`|tournament|update|${JSON.stringify(update)}`); this.update(); } destroy() { this.forceEnd(); } remove() { if (this.autoStartTimer) clearTimeout(this.autoStartTimer); if (this.autoDisqualifyTimer) clearTimeout(this.autoDisqualifyTimer); for (const roomid of this.completedMatches) { const room = Rooms.get(roomid) as GameRoom; if (room) room.tour = null; } this.setEnded(); this.room.game = null; } getRemainingPlayers() { return this.players.filter(player => !player.isDisqualified && !player.isEliminated); } setGenerator(generator: Generator, output: Chat.CommandContext) { if (this.isTournamentStarted) { output.sendReply('|tournament|error|BracketFrozen'); return; } this.generator = generator; this.room.send(`|tournament|update|${JSON.stringify({ generator: generator.name })}`); this.isBracketInvalidated = true; this.update(); return true; } setCustomRules(rules: string) { let format; try { const tryFormat = Dex.formats.validate(`${this.baseFormat}@@@${rules}`); format = Dex.formats.get(tryFormat, true); // In tours of formats with generated teams, custom rule errors should be checked for here, // since users can't edit their teams to avoid them at matching time if (format.team) { const testTeamSeed = PRNG.generateSeed(); const testTeamGenerator = Teams.getGenerator(format, testTeamSeed); testTeamGenerator.getTeam(); // Throws error if generation fails } this.fullFormat = tryFormat; } catch (e: any) { throw new Chat.ErrorMessage(`Custom rule error: ${e.message}`); } const customRules = format.customRules; if (!customRules) { throw new Chat.ErrorMessage(`Invalid rules.`); } this.customRules = customRules; if (this.name === this.baseFormat) { this.name = this.getDefaultCustomName(); this.room.send(`|tournament|update|${JSON.stringify({ format: this.name })}`); this.update(); } return true; } getCustomRules() { const bans = []; const unbans = []; const restrictions = []; const addedRules = []; const removedRules = []; for (const ban of this.customRules) { const charAt0 = ban.charAt(0); if (charAt0 === '+') { unbans.push(ban.substr(1)); } else if (charAt0 === '-') { bans.push(ban.substr(1)); } else if (charAt0 === '*') { restrictions.push(ban.substr(1)); } else if (charAt0 === '!') { removedRules.push(ban.substr(1)); } else { addedRules.push(ban); } } const html = []; if (bans.length) html.push(Utils.html`Added bans - ${bans.join(', ')}`); if (unbans.length) html.push(Utils.html`Removed bans - ${unbans.join(', ')}`); if (restrictions.length) html.push(Utils.html`Added restrictions - ${restrictions.join(', ')}`); if (addedRules.length) html.push(Utils.html`Added rules - ${addedRules.join(', ')}`); if (removedRules.length) html.push(Utils.html`Removed rules - ${removedRules.join(', ')}`); return html.join(`
`); } forceEnd() { if (this.isTournamentStarted) { if (this.autoDisqualifyTimer) clearTimeout(this.autoDisqualifyTimer); for (const player of this.players) { const match = player.inProgressMatch; if (match) { match.room.tour = null; match.room.setParent(null); match.room.addRaw(`
The tournament was forcefully ended.
You can finish playing, but this battle is no longer considered a tournament battle.
`); } } } this.room.add('|tournament|forceend'); this.remove(); } updateFor(targetUser: User, connection?: Connection | User) { if (!connection) connection = targetUser; if (this.ended) return; if ((!this.bracketUpdateTimer && this.isBracketInvalidated) || (this.isTournamentStarted && this.isAvailableMatchesInvalidated)) { this.room.add( "Error: update() called with a target user when data invalidated: " + `${!this.bracketUpdateTimer && this.isBracketInvalidated}, ` + `${this.isTournamentStarted && this.isAvailableMatchesInvalidated}` + "; Please report this to an admin." ); return; } const possiblePlayer = this.playerTable[targetUser.id]; let isJoined = false; if (possiblePlayer) { if (this.generator.name.includes("Elimination")) { isJoined = !possiblePlayer.isEliminated && !possiblePlayer.isDisqualified; } else if (this.generator.name.includes("Round Robin")) { if (possiblePlayer.isDisqualified) { isJoined = !possiblePlayer.isDisqualified; } else if ((this.generator as RoundRobin)?.matchesPerPlayer) { isJoined = possiblePlayer.games !== (this.generator as RoundRobin).matchesPerPlayer; } else if (!this.isTournamentStarted) { isJoined = true; } } else { isJoined = true; } } const update: { format: string, teambuilderFormat?: string, generator: string, isStarted: boolean, isJoined: boolean, bracketData: AnyObject, } = { format: this.name, generator: this.generator.name, isStarted: this.isTournamentStarted, isJoined, bracketData: this.bracketCache!, }; if (this.name !== this.baseFormat) update.teambuilderFormat = this.baseFormat; connection.sendTo(this.room, `|tournament|update|${JSON.stringify(update)}`); if (this.isTournamentStarted && isJoined) { const update2 = { challenges: usersToNames(this.availableMatchesCache.challenges.get(this.playerTable[targetUser.id])!), challengeBys: usersToNames(this.availableMatchesCache.challengeBys.get(this.playerTable[targetUser.id])!), }; connection.sendTo(this.room, `|tournament|update|${JSON.stringify(update2)}`); const pendingChallenge = this.playerTable[targetUser.id].pendingChallenge; if (pendingChallenge) { if (pendingChallenge.to) { connection.sendTo(this.room, `|tournament|update|${JSON.stringify({ challenging: pendingChallenge.to.name })}`); } else if (pendingChallenge.from) { connection.sendTo(this.room, `|tournament|update|${JSON.stringify({ challenged: pendingChallenge.from.name })}`); } } } connection.sendTo(this.room, '|tournament|updateEnd'); } update() { if (this.ended) return; if (this.isBracketInvalidated) { if (Date.now() < this.lastBracketUpdate + BRACKET_MINIMUM_UPDATE_INTERVAL) { if (this.bracketUpdateTimer) clearTimeout(this.bracketUpdateTimer); this.bracketUpdateTimer = setTimeout(() => { this.bracketUpdateTimer = null; this.update(); }, BRACKET_MINIMUM_UPDATE_INTERVAL); } else { this.lastBracketUpdate = Date.now(); this.bracketCache = this.getBracketData(); this.isBracketInvalidated = false; this.room.send(`|tournament|update|${JSON.stringify({ bracketData: this.bracketCache })}`); } } if (this.isTournamentStarted && this.isAvailableMatchesInvalidated) { this.availableMatchesCache = this.getAvailableMatches(); this.isAvailableMatchesInvalidated = false; for (const [player, opponents] of this.availableMatchesCache.challenges) { player.sendRoom(`|tournament|update|${JSON.stringify({ challenges: usersToNames(opponents) })}`); } for (const [player, opponents] of this.availableMatchesCache.challengeBys) { player.sendRoom(`|tournament|update|${JSON.stringify({ challengeBys: usersToNames(opponents) })}`); } } this.room.send('|tournament|updateEnd'); } static checkBanned(room: Room, user: User | string) { return Punishments.hasRoomPunishType(room, toID(user), 'TOURBAN'); } removeBannedUser(userid: User | ID) { userid = toID(userid); if (!(userid in this.playerTable)) return; if (this.isTournamentStarted) { const player = this.playerTable[userid]; if (!player.isDisqualified) { this.disqualifyUser(userid); } } else { this.removeUser(userid); } this.room.update(); } addUser(user: User, output: Chat.CommandContext) { if (!user.named) { output.sendReply('|tournament|error|UserNotNamed'); return; } if (user.id in this.playerTable) { output.sendReply('|tournament|error|UserAlreadyAdded'); return; } if (this.playerCap && this.playerCount >= this.playerCap) { output.sendReply('|tournament|error|Full'); return; } if (Tournament.checkBanned(this.room, user) || Punishments.isBattleBanned(user) || user.namelocked) { output.sendReply('|tournament|error|Banned'); return; } if ( (this.room.settings.tournaments?.autoconfirmedOnly || this.autoconfirmedOnly) && !user.autoconfirmed && !user.trusted ) { user.popup("Signups for tournaments are only available for autoconfirmed users in this room."); return; } const gameCount = user.games.size; if (gameCount > 4) { output.errorReply("Due to high load, you are limited to 4 games at the same time."); return; } if (!Config.noipchecks) { for (const otherPlayer of this.players) { if (!otherPlayer) continue; const otherUser = Users.get(otherPlayer.id); if (otherUser && otherUser.latestIp === user.latestIp) { output.sendReply('|tournament|error|AltUserAlreadyAdded'); return; } } } if (this.isTournamentStarted) { output.sendReply(`|tournament|error|BracketFrozen`); return; } const player = this.addPlayer(user); if (!player) throw new Error("Failed to add player."); this.playerTable[user.id] = player; this.room.add(`|tournament|join|${user.name}`); user.sendTo(this.room, '|tournament|update|{"isJoined":true}'); this.isBracketInvalidated = true; this.update(); if (this.playerCount === this.playerCap) { if (this.autostartcap === true) { this.startTournament(output); } else { this.room.add("The tournament is now full."); } } } makePlayer(user: User | string | null) { const num = this.players.length ? this.players[this.players.length - 1].num : 1; return new TournamentPlayer(user, this, num); } removeUser(userid: ID, output?: Chat.CommandContext) { const player = this.playerTable[userid]; if (!player) { if (output) output.sendReply('|tournament|error|UserNotAdded'); return; } this.removePlayer(player); const user = Users.get(userid); this.room.add(`|tournament|leave|${user ? user.name : userid}`); if (user) user.sendTo(this.room, '|tournament|update|{"isJoined":false}'); this.isBracketInvalidated = true; this.update(); } replaceUser(user: User, replacementUser: User, output: Chat.CommandContext) { if (!this.isTournamentStarted) { output.sendReply('|tournament|error|NotStarted'); return; } if (!(user.id in this.playerTable)) { output.errorReply(`${user.name} isn't in the tournament.`); return; } if (!replacementUser.named) { output.errorReply(`${replacementUser.name} must be named to join the tournament.`); return; } if (replacementUser.id in this.playerTable) { output.errorReply(`${replacementUser.name} is already in the tournament.`); return; } if (Tournament.checkBanned(this.room, replacementUser) || Punishments.isBattleBanned(replacementUser) || replacementUser.namelocked) { output.errorReply(`${replacementUser.name} is banned from joining tournaments.`); return; } if ((this.room.settings.tournaments?.autoconfirmedOnly || this.autoconfirmedOnly) && !user.autoconfirmed) { user.popup("Signups for tournaments are only available for autoconfirmed users in this room."); return; } if (!Config.noipchecks) { for (const otherPlayer of this.players) { if (!otherPlayer) continue; const otherUser = Users.get(otherPlayer.id); if (otherUser && otherUser.latestIp === replacementUser.latestIp && replacementUser.latestIp !== user.latestIp) { output.errorReply(`${replacementUser.name} already has an alt in the tournament.`); return; } } } if (!(replacementUser.id in this.room.users)) { output.errorReply(`${replacementUser.name} is not in this room (${this.room.title}).`); return; } const player = this.playerTable[user.id]; if (player.pendingChallenge) { this.cancelChallenge(user, output); } // Replace the player this.setPlayerUser(player, replacementUser); // Reset and invalidate any in progress battles let matchPlayer = null; if (player.inProgressMatch) { matchPlayer = player; } else { for (const p of this.players) { if (p.inProgressMatch && p.inProgressMatch.to === player) { matchPlayer = p; break; } } } if (matchPlayer?.inProgressMatch) { matchPlayer.inProgressMatch.to.isBusy = false; matchPlayer.isBusy = false; matchPlayer.inProgressMatch.room.addRaw( Utils.html`
${user.name} is no longer in the tournament.
` + `You can finish playing, but this battle is no longer considered a tournament battle.
` ).update(); matchPlayer.inProgressMatch.room.setParent(null); this.completedMatches.add(matchPlayer.inProgressMatch.room.roomid); matchPlayer.inProgressMatch = null; } this.isAvailableMatchesInvalidated = true; this.isBracketInvalidated = true; // Update the bracket this.update(); this.updateFor(user); this.updateFor(replacementUser); const challengePlayer = player.pendingChallenge && (player.pendingChallenge.from || player.pendingChallenge.to); if (challengePlayer) { const challengeUser = Users.getExact(challengePlayer.id); if (challengeUser) this.updateFor(challengeUser); } this.room.add(`|tournament|replace|${user.name}|${replacementUser.name}`); return true; } getBracketData() { let data: any; if (!this.isTournamentStarted) { data = this.generator.getPendingBracketData(this.players); } else { data = this.generator.getBracketData(); } if (data.type === 'tree') { if (!data.rootNode) { data.users = usersToNames(this.players.sort()); return data; } const queue = [data.rootNode]; while (queue.length > 0) { const node = queue.shift(); if (node.state === 'available') { const pendingChallenge = node.children[0].team.pendingChallenge; if (pendingChallenge && node.children[1].team === pendingChallenge.to) { node.state = 'challenging'; } const inProgressMatch = node.children[0].team.inProgressMatch; if (inProgressMatch && node.children[1].team === inProgressMatch.to) { node.state = 'inprogress'; node.room = inProgressMatch.room.roomid; } } if (node.team && typeof node.team !== 'string') { node.team = node.team.name; } if (node.children) { for (const child of node.children) { queue.push(child); } } } } else if (data.type === 'table') { if (this.isTournamentStarted) { for (const [r, row] of data.tableContents.entries()) { const pendingChallenge = data.tableHeaders.rows[r].pendingChallenge; const inProgressMatch = data.tableHeaders.rows[r].inProgressMatch; if (pendingChallenge || inProgressMatch) { for (const [c, cell] of row.entries()) { if (!cell) continue; if (pendingChallenge && data.tableHeaders.cols[c] === pendingChallenge.to) { cell.state = 'challenging'; } if (inProgressMatch && data.tableHeaders.cols[c] === inProgressMatch.to) { cell.state = 'inprogress'; cell.room = inProgressMatch.room.roomid; } } } } } data.tableHeaders.cols = usersToNames(data.tableHeaders.cols); data.tableHeaders.rows = usersToNames(data.tableHeaders.rows); } return data; } startTournament(output: Chat.CommandContext, isAutostart?: boolean) { if (this.isTournamentStarted) { output.sendReply('|tournament|error|AlreadyStarted'); return false; } if (this.players.length < 2) { if (isAutostart) { this.room.send('|tournament|error|NotEnoughUsers'); this.forceEnd(); this.room.update(); output.modlog('TOUR END'); } else { // manual tour start without enough users output.sendReply('|tournament|error|NotEnoughUsers'); } return false; } this.generator.freezeBracket(this.players); const now = Date.now(); for (const user of this.players) { user.lastActionTime = now; } this.isTournamentStarted = true; if (this.autoStartTimer) clearTimeout(this.autoStartTimer); if (this.autoDisqualifyTimeout !== Infinity) { this.autoDisqualifyTimer = setTimeout(() => this.runAutoDisqualify(), this.autoDisqualifyTimeout); } this.isBracketInvalidated = true; this.room.add(`|tournament|start|${this.players.length}`); output.modlog('TOUR START', null, `${this.players.length} players`); this.room.send('|tournament|update|{"isStarted":true}'); this.update(); return true; } getAvailableMatches() { const matches = this.generator.getAvailableMatches() as [TournamentPlayer, TournamentPlayer][]; if (typeof matches === 'string') throw new Error(`Error from getAvailableMatches(): ${matches}`); const challenges = new Map(); const challengeBys = new Map(); const oldAvailableMatches = new Map(); for (const user of this.players) { challenges.set(user, []); challengeBys.set(user, []); let oldAvailableMatch = false; const availableMatches = user.availableMatches; if (availableMatches.size) { oldAvailableMatch = true; availableMatches.clear(); } oldAvailableMatches.set(user, oldAvailableMatch); } for (const match of matches) { challenges.get(match[0])!.push(match[1]); challengeBys.get(match[1])!.push(match[0]); match[0].availableMatches.add(match[1]); } const now = Date.now(); for (const player of this.players) { if (oldAvailableMatches.get(player)) continue; if (player.availableMatches.size) player.lastActionTime = now; } return { challenges, challengeBys, }; } disqualifyUser(userid: ID, output: Chat.CommandContext | null = null, reason: string | null = null, isSelfDQ = false) { const user = Users.get(userid); let sendReply: (msg: string) => void; if (output) { sendReply = msg => output.sendReply(msg); } else if (user) { sendReply = msg => user.sendTo(this.roomid, msg); } else { sendReply = () => {}; } if (!this.isTournamentStarted) { sendReply('|tournament|error|NotStarted'); return false; } if (!(userid in this.playerTable)) { sendReply(`|tournament|error|UserNotAdded|${userid}`); return false; } const player = this.playerTable[userid]; if (player.isDisqualified) { sendReply(`|tournament|error|AlreadyDisqualified|${userid}`); return false; } player.isDisqualified = true; const error = this.generator.disqualifyUser(player); if (error) { sendReply(`|tournament|error|${error}`); return false; } player.isBusy = false; const challenge = player.pendingChallenge; if (challenge) { player.pendingChallenge = null; if (challenge.to) { challenge.to.isBusy = false; challenge.to.pendingChallenge = null; challenge.to.sendRoom('|tournament|update|{"challenged":null}'); } else if (challenge.from) { challenge.from.isBusy = false; challenge.from.pendingChallenge = null; challenge.from.sendRoom('|tournament|update|{"challenging":null}'); } } const matchFrom = player.inProgressMatch; if (matchFrom) { matchFrom.to.isBusy = false; player.inProgressMatch = null; matchFrom.room.setParent(null); this.completedMatches.add(matchFrom.room.roomid); matchFrom.room.game?.forfeit?.(player.name); } let matchTo = null; for (const playerFrom of this.players) { const match = playerFrom.inProgressMatch; if (match && match.to === player) matchTo = playerFrom; } if (matchTo) { matchTo.isBusy = false; const matchRoom = matchTo.inProgressMatch!.room; matchRoom.setParent(null); this.completedMatches.add(matchRoom.roomid); if (matchRoom.game) matchRoom.game.forfeit?.(player.id); matchTo.inProgressMatch = null; } if (isSelfDQ) { this.room.add(`|tournament|leave|${player.name}`); } else { this.room.add(`|tournament|disqualify|${player.name}`); } if (user) { user.sendTo(this.room, '|tournament|update|{"isJoined":false}'); user.popup(`|modal|You have been disqualified from the tournament in ${this.room.title}${reason ? `:\n\n${reason}` : `.`}`); } this.isBracketInvalidated = true; this.isAvailableMatchesInvalidated = true; if (this.generator.isTournamentEnded()) { this.onTournamentEnd(); } else { this.update(); } return true; } setAutoStartTimeout(timeout: number, output: Chat.CommandContext) { if (this.isTournamentStarted) { output.sendReply('|tournament|error|AlreadyStarted'); return false; } if (timeout < AUTO_START_MINIMUM_TIMEOUT || isNaN(timeout)) { output.sendReply('|tournament|error|InvalidAutoStartTimeout'); return false; } if (this.autoStartTimer) clearTimeout(this.autoStartTimer); if (timeout === Infinity) { this.room.add('|tournament|autostart|off'); } else { this.autoStartTimer = setTimeout(() => this.startTournament(output, true), timeout); this.room.add(`|tournament|autostart|on|${timeout}`); } this.autoStartTimeout = timeout; return true; } setAutoDisqualifyTimeout(timeout: number, output: Chat.CommandContext) { if ( isNaN(timeout) || timeout < AUTO_DISQUALIFY_WARNING_TIMEOUT || (timeout > MAX_AUTO_DISQUALIFY_TIMEOUT && timeout !== Infinity) ) { output.sendReply('|tournament|error|InvalidAutoDisqualifyTimeout'); return false; } this.autoDisqualifyTimeout = timeout; if (this.autoDisqualifyTimeout === Infinity) { this.room.add('|tournament|autodq|off'); if (this.autoDisqualifyTimer) clearTimeout(this.autoDisqualifyTimer); for (const player of this.players) player.autoDisqualifyWarned = false; } else { this.room.add(`|tournament|autodq|on|${this.autoDisqualifyTimeout}`); if (this.isTournamentStarted) this.runAutoDisqualify(); } return true; } runAutoDisqualify(output?: Chat.CommandContext) { if (!this.isTournamentStarted) { if (output) output.sendReply('|tournament|error|NotStarted'); return false; } if (this.autoDisqualifyTimer) clearTimeout(this.autoDisqualifyTimer); const now = Date.now(); for (const player of this.players) { const time = player.lastActionTime; let availableMatches = false; if (player.availableMatches.size) availableMatches = true; const pendingChallenge = player.pendingChallenge; if (!availableMatches && !pendingChallenge) { player.autoDisqualifyWarned = false; continue; } if (pendingChallenge?.to) continue; if (now > time + this.autoDisqualifyTimeout && player.autoDisqualifyWarned) { let reason; if (pendingChallenge?.from) { reason = "You failed to accept your opponent's challenge in time."; } else { reason = "You failed to challenge your opponent in time."; } this.disqualifyUser(player.id, output, reason); this.room.update(); } else if (now > time + this.autoDisqualifyTimeout - AUTO_DISQUALIFY_WARNING_TIMEOUT) { if (player.autoDisqualifyWarned) continue; let remainingTime = this.autoDisqualifyTimeout - now + time; if (remainingTime <= 0) { remainingTime = AUTO_DISQUALIFY_WARNING_TIMEOUT; player.lastActionTime = now - this.autoDisqualifyTimeout + AUTO_DISQUALIFY_WARNING_TIMEOUT; } player.autoDisqualifyWarned = true; player.sendRoom(`|tournament|autodq|target|${remainingTime}`); } else { player.autoDisqualifyWarned = false; } } if (!this.ended) this.autoDisqualifyTimer = setTimeout(() => this.runAutoDisqualify(), this.autoDisqualifyTimeout); if (output) output.sendReply("All available matches were checked for automatic disqualification."); } setScouting(allowed: boolean) { this.allowScouting = allowed; this.allowModjoin = !allowed; this.room.add(`|tournament|scouting|${this.allowScouting ? 'allow' : 'disallow'}`); } setModjoin(allowed: boolean) { this.allowModjoin = allowed; this.room.add(`Modjoining is now ${allowed ? 'allowed' : 'banned'} (Players can${allowed ? '' : 'not'} modjoin their tournament battles).`); } setAutoconfirmedOnly(acOnly: boolean) { this.autoconfirmedOnly = acOnly; this.room.add(`This tournament is now ${acOnly ? 'dis' : ''}allowing non-autoconfirmed users' joining.`); } setForceTimer(force: boolean) { this.forceTimer = force; this.room.add(`Forcetimer is now ${force ? 'on' : 'off'} for the tournament.`); } setForcePublic(force: boolean) { this.forcePublic = force; this.room.add(`Tournament battles forced public: ${force ? 'ON' : 'OFF'}`); } setAutostartAtCap(autostart: boolean) { this.autostartcap = true; this.room.add(`The tournament will start once ${this.playerCap} players have joined.`); } async challenge(user: User, targetUserid: ID, output: Chat.CommandContext) { if (!this.isTournamentStarted) { output.sendReply('|tournament|error|NotStarted'); return; } if (!(user.id in this.playerTable)) { output.sendReply('|tournament|error|UserNotAdded'); return; } if (!(targetUserid in this.playerTable)) { output.sendReply('|tournament|error|InvalidMatch'); return; } const from = this.playerTable[user.id]; const to = this.playerTable[targetUserid]; const availableMatches = from.availableMatches; if (!availableMatches?.has(to)) { output.sendReply('|tournament|error|InvalidMatch'); return; } if (from.isBusy || to.isBusy) { this.room.add("Tournament backend breaks specifications. Please report this to an admin."); return; } from.isBusy = true; to.isBusy = true; this.isAvailableMatchesInvalidated = true; this.update(); const ready = await Ladders(this.fullFormat).prepBattle(output.connection, 'tour'); if (!ready) { from.isBusy = false; to.isBusy = false; this.isAvailableMatchesInvalidated = true; this.update(); return; } to.lastActionTime = Date.now(); from.pendingChallenge = { to, team: ready.settings.team, hidden: ready.settings.hidden, inviteOnly: ready.settings.inviteOnly, }; to.pendingChallenge = { from, team: ready.settings.team, hidden: ready.settings.hidden, inviteOnly: ready.settings.inviteOnly, }; from.sendRoom(`|tournament|update|${JSON.stringify({ challenging: to.name })}`); to.sendRoom(`|tournament|update|${JSON.stringify({ challenged: from.name })}`); this.isBracketInvalidated = true; this.update(); } cancelChallenge(user: User, output: Chat.CommandContext) { if (!this.isTournamentStarted) { if (output) output.sendReply('|tournament|error|NotStarted'); return; } if (!(user.id in this.playerTable)) { if (output) output.sendReply('|tournament|error|UserNotAdded'); return; } const player = this.playerTable[user.id]; const challenge = player.pendingChallenge; if (!challenge?.to) return; player.isBusy = false; challenge.to.isBusy = false; player.pendingChallenge = null; challenge.to.pendingChallenge = null; user.sendTo(this.room, '|tournament|update|{"challenging":null}'); challenge.to.sendRoom('|tournament|update|{"challenged":null}'); this.isBracketInvalidated = true; this.isAvailableMatchesInvalidated = true; this.update(); } async acceptChallenge(user: User, output: Chat.CommandContext) { if (!this.isTournamentStarted) { output.sendReply('|tournament|error|NotStarted'); return; } if (!(user.id in this.playerTable)) { output.sendReply('|tournament|error|UserNotAdded'); return; } const player = this.playerTable[user.id]; const challenge = player.pendingChallenge; if (!challenge?.from) return; const ready = await Ladders(this.fullFormat).prepBattle(output.connection, 'tour'); if (!ready) return; // Prevent battles between offline users from starting const from = Users.get(challenge.from.id); if (!from?.connected || !user.connected) return; // Prevent double accepts and users that have been disqualified while between these two functions if (!challenge.from.pendingChallenge) return; if (!player.pendingChallenge) return; const room = Rooms.createBattle({ format: this.fullFormat, isPrivate: this.room.settings.isPrivate, players: [{ user: from, team: challenge.team, hidden: challenge.hidden, inviteOnly: challenge.inviteOnly, }, { user, team: ready.settings.team, hidden: ready.settings.hidden, inviteOnly: ready.settings.inviteOnly, }], rated: !Ladders.disabled && this.isRated, challengeType: ready.challengeType, tour: this, parentid: this.roomid, }); challenge.from.pendingChallenge = null; player.pendingChallenge = null; from.sendTo(this.room, '|tournament|update|{"challenging":null}'); user.sendTo(this.room, '|tournament|update|{"challenged":null}'); // server lockdown if (!room) return; challenge.from.inProgressMatch = { to: player, room }; this.room.add(`|tournament|battlestart|${from.name}|${user.name}|${room.roomid}`).update(); this.isBracketInvalidated = true; if (this.autoDisqualifyTimeout !== Infinity) this.runAutoDisqualify(); if (this.forceTimer) room.game.startTimer(); this.update(); } getDefaultCustomName() { return Dex.formats.get(this.fullFormat).name + " (with custom rules)"; } forfeit(user: User) { return this.disqualifyUser(user.id, null, "You left the tournament", true); } onConnect(user: User, connection: Connection) { this.updateFor(user, connection); } onUpdateConnection(user: User, connection: Connection) { this.updateFor(user, connection); } onRename(user: User, oldUserid: ID) { if (oldUserid in this.playerTable) { this.renamePlayer(user, oldUserid); } this.updateFor(user); } onBattleJoin(room: GameRoom, user: User) { if (!room.p1 || !room.p2) return; if (this.allowScouting || this.ended || user.latestIp === room.p1.latestIp || user.latestIp === room.p2.latestIp) { return; } if (user.can('makeroom')) return; for (const otherPlayer of this.getRemainingPlayers()) { const otherUser = Users.get(otherPlayer.id); if (otherUser && otherUser.latestIp === user.latestIp) { return "Scouting is banned: tournament players can't watch other tournament battles."; } } } onBattleWin(room: GameRoom, winnerid: ID) { if (this.completedMatches.has(room.roomid)) return; this.completedMatches.add(room.roomid); room.setParent(null); if (!room.game) throw new Error("onBattleWin called without a battle"); if (!room.p1 || !room.p2) throw new Error("onBattleWin called with missing players"); const p1 = this.playerTable[room.p1.id]; const p2 = this.playerTable[room.p2.id]; const winner = this.playerTable[winnerid]; const score = (room.game as RoomBattle | BestOfGame).score || [0, 0]; let result: 'win' | 'loss' | 'draw' = 'draw'; if (p1 === winner) { p1.score += 1; p1.wins += 1; p2.losses += 1; result = 'win'; } else if (p2 === winner) { p2.score += 1; p2.wins += 1; p1.losses += 1; result = 'loss'; } p1.isBusy = false; p2.isBusy = false; p1.inProgressMatch = null; this.isBracketInvalidated = true; this.isAvailableMatchesInvalidated = true; if (result === 'draw' && !this.generator.isDrawingSupported) { this.room.add(`|tournament|battleend|${p1.name}|${p2.name}|${result}|${score.join(',')}|fail|${room.roomid}`); if (this.autoDisqualifyTimeout !== Infinity) this.runAutoDisqualify(); this.update(); return this.room.update(); } if (result === 'draw') { p1.score += 0.5; p2.score += 0.5; } p1.games += 1; p2.games += 1; if (!(p1.isDisqualified || p2.isDisqualified)) { // If a player was disqualified, handle the results there const error = this.generator.setMatchResult([p1, p2], result as 'win' | 'loss', score); if (error) { // Should never happen return this.room.add(`Unexpected ${error} from setMatchResult([${room.p1.id}, ${room.p2.id}], ${result}, ${score}) in onBattleWin(${room.roomid}, ${winnerid}). Please report this to an admin.`).update(); } } this.room.add(`|tournament|battleend|${p1.name}|${p2.name}|${result}|${score.join(',')}|success|${room.roomid}`); if (this.generator.isTournamentEnded()) { if (!this.room.settings.isPrivate && this.generator.name.includes('Elimination') && !Config.autosavereplays) { const uploader = Users.get(winnerid); if (uploader?.connections[0]) { void Chat.parse('/savereplay', room, uploader, uploader.connections[0]); } } this.onTournamentEnd(); } else { if (this.autoDisqualifyTimeout !== Infinity) this.runAutoDisqualify(); this.update(); } this.room.update(); } onTournamentEnd() { const update = { results: (this.generator.getResults() as TournamentPlayer[][]).map(usersToNames), format: this.name, generator: this.generator.name, bracketData: this.getBracketData(), }; this.room.add(`|tournament|end|${JSON.stringify(update)}`); const settings = this.room.settings.tournaments; if (settings?.recentToursLength) { if (!settings.recentTours) settings.recentTours = []; const name = Dex.formats.get(this.name).exists ? Dex.formats.get(this.name).name : `${this.name} (${Dex.formats.get(this.baseFormat).name})`; settings.recentTours.unshift({ name, baseFormat: this.baseFormat, time: Date.now() }); // Use a while loop here in case the threshold gets lowered with /tour settings recenttours // to trim down multiple at once while (settings.recentTours.length > settings.recentToursLength) { settings.recentTours.pop(); } this.room.saveSettings(); } this.remove(); } } function getGenerator(generator: string | undefined) { generator = toID(generator); switch (generator) { case 'elim': generator = 'elimination'; break; case 'rr': generator = 'roundrobin'; break; } return TournamentGenerators[generator as 'elimination' | 'roundrobin']; } function createTournamentGenerator( generatorName: string | undefined, modifier: string | undefined, output: Chat.CommandContext ) { const TourGenerator = getGenerator(generatorName); if (!TourGenerator) { output.errorReply(`${generatorName} is not a valid type.`); const generatorNames = Object.keys(TournamentGenerators).join(', '); output.errorReply(`Valid types: ${generatorNames}`); return; } return new TourGenerator(modifier || ''); } function createTournament( room: Room, formatId: string | undefined, generator: string | undefined, playerCap: string | undefined, isRated: boolean, generatorMod: string | undefined, name: string | undefined, output: Chat.CommandContext ) { if (room.type !== 'chat') { output.errorReply("Tournaments can only be created in chat rooms."); return; } if (room.game) { output.errorReply(`You cannot have a tournament until the current room activity is over: ${room.game.title}`); return; } if (Rooms.global.lockdown) { output.errorReply("The server is restarting soon, so a tournament cannot be created."); return; } const format = Dex.formats.get(formatId); if (format.effectType !== 'Format' || !format.tournamentShow) { output.errorReply(`${format.id} is not a valid tournament format.`); void output.parse(`/tour formats`); return; } const settings = room.settings.tournaments; if (settings?.blockRecents && settings.recentTours && settings.recentToursLength) { const recentTours = settings.recentTours.map(x => x.baseFormat); if (recentTours.includes(format.id)) { output.errorReply(`A ${format.name} tournament was made too recently.`); return; } } if (!getGenerator(generator)) { output.errorReply(`${generator} is not a valid type.`); const generators = Object.keys(TournamentGenerators).join(', '); output.errorReply(`Valid types: ${generators}`); return; } if (playerCap && parseInt(playerCap) < 2) { output.errorReply("You cannot have a player cap that is less than 2."); return; } if (name?.trim().length) { if (output.checkChat(name) !== name) { throw new Chat.ErrorMessage(`You cannot use filtered words in tour names.`); } if (name.length > MAX_CUSTOM_NAME_LENGTH) { throw new Chat.ErrorMessage(`The tournament's name cannot exceed ${MAX_CUSTOM_NAME_LENGTH} characters.`); } if (name.includes('|')) throw new Chat.ErrorMessage("The tournament's name cannot include the | symbol."); } const tour = room.game = new Tournament( room, format, createTournamentGenerator(generator, generatorMod, output)!, playerCap, isRated, name ); if (settings) { if (typeof settings.autostart === 'number') tour.setAutoStartTimeout(settings.autostart, output); if (settings.playerCap) { tour.playerCap = settings.playerCap; if (settings.autostart === true) tour.setAutostartAtCap(true); } if (settings.autodq) tour.setAutoDisqualifyTimeout(settings.autodq, output); if (settings.forcePublic) tour.setForcePublic(true); if (settings.forceTimer) tour.setForceTimer(true); if (settings.allowModjoin === false) tour.setModjoin(false); if (settings.allowScouting === false) tour.setScouting(false); } return tour; } const commands: Chat.ChatCommands = { pasttours: 'recenttours', recenttours(target, room, user) { this.runBroadcast(); room = this.requireRoom(); if (!room.settings.tournaments?.recentToursLength) { throw new Chat.ErrorMessage(`Recent tournaments aren't documented in this room.`); } if (!room.settings.tournaments?.recentTours?.length) { throw new Chat.ErrorMessage(`There haven't been any documented tournaments in this room recently.`); } // Shorten array if the recentToursLength gets adjusted const array = room.settings.tournaments.recentTours; const { name, time } = array[0]; let buf = `The last tournament ended ${Chat.toDurationString(Date.now() - time)} ago - ${name}`; if (array.length > 1) { buf += `
Previous tournaments: `; buf += array.filter((x, i) => i !== 0).map(x => x.name).join(', '); } this.sendReplyBox(buf); }, recenttourshelp: [`/recenttours - Displays the n most recent tour(s), where n represents the number defined by staff (i.e. the 6 most recent tours).`], tour: 'tournament', tours: 'tournament', tournaments: 'tournament', tournament: { ''(target, room, user) { room = this.requireRoom(); if (!this.runBroadcast()) return; const update = []; for (const tourRoom of Rooms.rooms.values()) { const tournament = tourRoom.getGame(Tournament); if (!tournament) continue; if (tourRoom.settings.isPrivate || tourRoom.settings.isPersonal || tourRoom.settings.staffRoom) continue; update.push({ room: tourRoom.roomid, title: room.title, format: tournament.name, generator: tournament.generator.name, isStarted: tournament.isTournamentStarted, }); } this.sendReply(`|tournaments|info|${JSON.stringify(update)}`); }, help() { return this.parse('/help tournament'); }, enable: 'toggle', disable: 'toggle', toggle(target, room, user, connection, cmd) { throw new Chat.ErrorMessage(`${this.cmdToken}${this.fullCmd} has been deprecated. Instead, use "${this.cmdToken}permissions set tournaments, [rank symbol]".`); }, announcements: 'announce', announce(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('gamemanagement', null, room); if (!target) { if (room.settings.tournaments?.announcements) { return this.sendReply("Tournament announcements are enabled."); } else { return this.sendReply("Tournament announcements are disabled."); } } const option = target.toLowerCase(); if (this.meansYes(option)) { if (room.settings.tournaments?.announcements) return this.errorReply("Tournament announcements are already enabled."); if (!room.settings.tournaments) room.settings.tournaments = {}; room.settings.tournaments.announcements = true; room.saveSettings(); this.privateModAction(`Tournament announcements were enabled by ${user.name}`); this.modlog('TOUR ANNOUNCEMENTS', null, 'ON'); } else if (this.meansNo(option)) { if (!room.settings.tournaments?.announcements) return this.errorReply("Tournament announcements are already disabled."); if (!room.settings.tournaments) room.settings.tournaments = {}; room.settings.tournaments.announcements = false; room.saveSettings(); this.privateModAction(`Tournament announcements were disabled by ${user.name}`); this.modlog('TOUR ANNOUNCEMENTS', null, 'OFF'); } else { return this.sendReply(`Usage: /tour ${cmd} `); } room.saveSettings(); }, new: 'create', create(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const [format, generator, cap, mod, name] = target.split(',').map(item => item.trim()); if (!target || !format || !generator) { return this.sendReply(`Usage: /tour ${cmd} , [, ]`); } const tour: Tournament | undefined = createTournament(room, format, generator, cap, Config.ratedtours, mod, name, this); if (tour) { this.privateModAction(`${user.name} created a tournament in ${tour.baseFormat} format.`); this.modlog('TOUR CREATE', null, tour.baseFormat); if (room.settings.tournaments?.announcements) { const tourRoom = Rooms.search(Config.tourroom || 'tournaments'); if (tourRoom && tourRoom !== room) { tourRoom.addRaw( Utils.html`` ).update(); } } } }, formats(target, room, user) { if (!this.runBroadcast()) return; let buf = ``; let section = undefined; for (const format of Dex.formats.all()) { if (!format.tournamentShow) continue; const name = format.name.startsWith(`[Gen ${Dex.gen}] `) ? format.name.slice(8) : format.name; if (format.section !== section) { section = format.section; buf += Utils.html`
${section}:
• ${name}`; } else { buf += Utils.html`
• ${name}`; } } this.sendReplyBox(`
Valid Formats: ${buf}
`); }, banuser(target, room, user) { room = this.requireRoom(); const [userid, ...reasonsArray] = target.split(',').map(item => item.trim()); if (!target) { return this.sendReply(`Usage: /tour banuser , `); } const reason = reasonsArray.join(','); const targetUser = Users.get(userid); this.checkCan('gamemoderation', targetUser, room); const targetUserid = targetUser ? targetUser.id : toID(userid); if (!targetUser) return false; if (reason?.length > MAX_REASON_LENGTH) { return this.errorReply(`The reason is too long. It cannot exceed ${MAX_REASON_LENGTH} characters.`); } if (Tournament.checkBanned(room, targetUser)) return this.errorReply("This user is already banned from tournaments."); const punishment = { type: 'TOURBAN', id: targetUserid, expireTime: Date.now() + TOURBAN_DURATION, reason, }; if (targetUser) { Punishments.roomPunish(room, targetUser, punishment); } else { Punishments.roomPunishName(room, targetUserid, punishment); } room.getGame(Tournament)?.removeBannedUser(targetUserid); this.modlog('TOURBAN', targetUser, reason); this.privateModAction( `${targetUser ? targetUser.name : targetUserid} was banned from joining tournaments by ${user.name}. (${reason})` ); }, unbanuser(target, room, user) { room = this.requireRoom(); target = target.trim(); if (!target) { return this.sendReply(`Usage: /tour unbanuser `); } const targetUser = Users.get(toID(target)); this.checkCan('gamemoderation', targetUser, room); const targetUserid = toID(targetUser || toID(target)); if (!Tournament.checkBanned(room, targetUserid)) return this.errorReply("This user isn't banned from tournaments."); if (targetUser) { Punishments.roomUnpunish(room, targetUserid, 'TOURBAN', false); } this.privateModAction(`${targetUser ? targetUser.name : targetUserid} was unbanned from joining tournaments by ${user.name}.`); this.modlog('TOUR UNBAN', targetUser, null, { noip: 1, noalts: 1 }); }, j: 'join', in: 'join', join(target, room, user) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); tournament.addUser(user, this); }, l: 'leave', out: 'leave', leave(target, room, user) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); if (tournament.isTournamentStarted) { if (tournament.getRemainingPlayers().some(player => player.id === user.id)) { tournament.disqualifyUser(user.id, this, null, true); } else { this.errorReply("You have already been eliminated from this tournament."); } } else { tournament.removeUser(user.id, this); } }, getusers(target, room) { if (!this.runBroadcast()) return; room = this.requireRoom(); const tournament = this.requireGame(Tournament); const users = usersToNames(tournament.getRemainingPlayers().sort()); this.sendReplyBox( `${users.length}/${tournament.players.length}` + Utils.html` users remain in this tournament:
${users.join(', ')}` ); }, getupdate(target, room, user) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); tournament.updateFor(user); this.sendReply("Your tournament bracket has been updated."); }, challenge(target, room, user, connection, cmd) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); if (!target) { return this.sendReply(`Usage: /tour ${cmd} `); } void tournament.challenge(user, toID(target), this); }, cancelchallenge(target, room, user) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); tournament.cancelChallenge(user, this); }, acceptchallenge(target, room, user) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); void tournament.acceptChallenge(user, this); }, async vtm(target, room, user, connection) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); if (Monitor.countPrepBattle(connection.ip, connection)) { return; } const result = await TeamValidatorAsync.get(tournament.fullFormat).validateTeam(user.battleSettings.team); if (result.startsWith('1')) { connection.popup("Your team is valid for this tournament."); } else { const formatName = Dex.formats.get(tournament.baseFormat).name; // split/join is the easiest way to do a find/replace with an untrusted string, sadly const reasons = result.slice(1).split(formatName).join('this tournament'); connection.popup(`Your team was rejected for the following reasons:\n\n- ${reasons.replace(/\n/g, '\n- ')}`); } }, viewruleset: 'viewcustomrules', viewbanlist: 'viewcustomrules', viewrules: 'viewcustomrules', viewcustomrules(target, room) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); if (!this.runBroadcast()) return; if (tournament.customRules.length < 1) { return this.errorReply("The tournament does not have any custom rules."); } this.sendReply(`|html|
This tournament includes:
${tournament.getCustomRules()}
`); }, settype(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); if (!target) { return this.sendReply(`Usage: /tour ${cmd} [, ]`); } const [generatorType, cap, modifier] = target.split(',').map(item => item.trim()); const playerCap = parseInt(cap); const generator = createTournamentGenerator(generatorType, modifier, this); if (generator && tournament.setGenerator(generator, this)) { if (playerCap && playerCap >= 2) { tournament.playerCap = playerCap; if (Config.tourdefaultplayercap && tournament.playerCap > Config.tourdefaultplayercap) { Monitor.log(`[TourMonitor] Room ${tournament.room.roomid} starting a tour over default cap (${tournament.playerCap})`); } room.send(`|tournament|update|{"playerCap": "${playerCap}"}`); } else if (tournament.playerCap && !playerCap) { tournament.playerCap = 0; room.send(`|tournament|update|{"playerCap": "${playerCap}"}`); } const capNote = (tournament.playerCap ? ` with a player cap of ${tournament.playerCap}` : ''); this.privateModAction(`${user.name} set tournament type to ${generator.name}${capNote}.`); this.modlog('TOUR SETTYPE', null, `${generator.name}${capNote}`); this.sendReply(`Tournament set to ${generator.name}${capNote}.`); } }, cap: 'setplayercap', playercap: 'setplayercap', setcap: 'setplayercap', setplayercap(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); target = target.trim(); if (!target) { if (tournament.playerCap) { return this.sendReply(`Usage: /tour ${cmd} ; The current player cap is ${tournament.playerCap}`); } else { return this.sendReply(`Usage: /tour ${cmd} `); } } if (tournament.isTournamentStarted) { return this.errorReply("The player cap cannot be changed once the tournament has started."); } const option = target.toLowerCase(); if (['0', 'infinity', 'off', 'false', 'stop', 'remove'].includes(option)) { if (!tournament.playerCap) return this.errorReply("The tournament does not have a player cap."); target = '0'; } const playerCap = parseInt(target); if (playerCap === 0) { tournament.playerCap = 0; this.privateModAction(`${user.name} removed the tournament's player cap.`); this.modlog('TOUR PLAYERCAP', null, 'removed'); this.sendReply("Tournament cap removed."); } else { if (isNaN(playerCap) || playerCap < 2) { return this.errorReply("The tournament cannot have a player cap less than 2."); } if (playerCap === tournament.playerCap) { return this.errorReply(`The tournament's player cap is already ${playerCap}.`); } tournament.playerCap = playerCap; if (Config.tourdefaultplayercap && tournament.playerCap > Config.tourdefaultplayercap) { Monitor.log(`[TourMonitor] Room ${tournament.room.roomid} starting a tour over default cap (${tournament.playerCap})`); } this.privateModAction(`${user.name} set the tournament's player cap to ${tournament.playerCap}.`); this.modlog('TOUR PLAYERCAP', null, tournament.playerCap.toString()); this.sendReply(`Tournament cap set to ${tournament.playerCap}.`); } room.send(`|tournament|update|{"playerCap": "${tournament.playerCap}"}`); }, end: 'delete', stop: 'delete', delete(target, room, user) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); tournament.forceEnd(); this.privateModAction(`${user.name} forcibly ended a tournament.`); this.modlog('TOUR END'); }, ruleset: 'customrules', banlist: 'customrules', rules: 'customrules', customrules(target, room, user, connection, cmd) { room = this.requireRoom(); const tournament = this.requireGame(Tournament); if (cmd === 'banlist') { return this.errorReply('The new syntax is: /tour rules -bannedthing, +un[banned|restricted]thing, *restrictedthing, !removedrule, addedrule'); } if (!target) { this.sendReply("Usage: /tour rules "); this.sendReply("Rules can be: -bannedthing, +un[banned|restricted]thing, *restrictedthing, !removedrule, addedrule"); this.parse('/tour viewrules'); if (tournament.customRules.length) { return this.sendReplyBox(`
Source/tour rules ${tournament.customRules}
`); } return; } this.checkCan('tournaments', null, room); if (tournament.isTournamentStarted) { return this.errorReply("The custom rules cannot be changed once the tournament has started."); } if (tournament.setCustomRules(target)) { room.addRaw( `
This tournament includes:
${tournament.getCustomRules()}
` ); this.privateModAction(`${user.name} updated the tournament's custom rules.`); this.modlog('TOUR RULES', null, tournament.customRules.join(', ')); this.sendReplyBox(`
Source/tour rules ${tournament.customRules}
`); } }, clearruleset: 'clearcustomrules', clearbanlist: 'clearcustomrules', clearrules: 'clearcustomrules', clearcustomrules(target, room, user) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); if (tournament.isTournamentStarted) { return this.errorReply("The custom rules cannot be changed once the tournament has started."); } if (tournament.customRules.length < 1) { return this.errorReply("The tournament does not have any custom rules."); } tournament.customRules = []; tournament.fullFormat = tournament.baseFormat; if (tournament.name === tournament.getDefaultCustomName()) { tournament.name = tournament.baseFormat; room.send(`|tournament|update|${JSON.stringify({ format: tournament.name })}`); tournament.update(); } room.addRaw(`The tournament's custom rules were cleared.`); this.privateModAction(`${user.name} cleared the tournament's custom rules.`); this.modlog('TOUR CLEARRULES'); }, name: 'setname', customname: 'setname', setname(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); const name = target.trim(); if (!name) { return this.sendReply(`Usage: /tour ${cmd} `); } this.checkChat(name); if (!name || typeof name !== 'string') return; if (name.length > MAX_CUSTOM_NAME_LENGTH) { return this.errorReply(`The tournament's name cannot exceed ${MAX_CUSTOM_NAME_LENGTH} characters.`); } if (name.includes('|')) return this.errorReply("The tournament's name cannot include the | symbol."); tournament.name = name; room.send(`|tournament|update|${JSON.stringify({ format: tournament.name })}`); this.privateModAction(`${user.name} set the tournament's name to ${tournament.name}.`); this.modlog('TOUR NAME', null, tournament.name); tournament.update(); }, resetname: 'clearname', clearname(target, room, user) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); if (tournament.name === tournament.baseFormat) return this.errorReply("The tournament does not have a name."); tournament.name = tournament.baseFormat; room.send(`|tournament|update|${JSON.stringify({ format: tournament.name })}`); this.privateModAction(`${user.name} cleared the tournament's name.`); this.modlog('TOUR CLEARNAME'); tournament.update(); }, begin: 'start', start(target, room, user) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); if (tournament.startTournament(this)) { room.sendMods(`(${user.name} started the tournament.)`); } }, dq: 'disqualify', disqualify(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); if (!target) { return this.sendReply(`Usage: /tour ${cmd} `); } const [userid, reason] = target.split(',').map(item => item.trim()); const targetUser = Users.get(userid); const targetUserid = toID(targetUser || userid); if (reason?.length > MAX_REASON_LENGTH) { return this.errorReply(`The reason is too long. It cannot exceed ${MAX_REASON_LENGTH} characters.`); } if (tournament.disqualifyUser(targetUserid, this, reason)) { this.privateModAction(`${(targetUser ? targetUser.name : targetUserid)} was disqualified from the tournament by ${user.name}${(reason ? ' (' + reason + ')' : '')}`); this.modlog('TOUR DQ', targetUserid, reason); } }, sub: 'replace', replace(target, room, user) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); const [oldUser, newUser] = target.split(',').map(item => Users.get(item.trim())); if (!oldUser) return this.errorReply(`User ${oldUser} not found.`); if (!newUser) return this.errorReply(`User ${newUser} not found.`); tournament.replaceUser(oldUser, newUser, this); }, autostart: 'setautostart', setautostart(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); target = target.trim(); if (!target) { return this.sendReply(`Usage: /tour ${cmd} `); } const option = target.toLowerCase(); if ((this.meansYes(option) && option !== '1') || option === 'start') { if (tournament.isTournamentStarted) { return this.errorReply("The tournament has already started."); } else if (!tournament.playerCap) { return this.errorReply("The tournament does not have a player cap set."); } else { if (tournament.autostartcap) { return this.errorReply("The tournament is already set to autostart when the player cap is reached."); } tournament.setAutostartAtCap(true); this.privateModAction(`The tournament was set to autostart when the player cap is reached by ${user.name}`); this.modlog('TOUR AUTOSTART', null, 'when playercap is reached'); } } else { if (option === '0' || option === 'infinity' || this.meansNo(option) || option === 'stop' || option === 'remove') { if (!tournament.autostartcap && tournament.autoStartTimeout === Infinity) { return this.errorReply("The automatic tournament start timer is already off."); } target = 'off'; tournament.autostartcap = false; } const timeout = target.toLowerCase() === 'off' ? Infinity : Number(target) * 60 * 1000; if (timeout <= 0 || (timeout !== Infinity && timeout > Chat.MAX_TIMEOUT_DURATION)) { return this.errorReply(`The automatic tournament start timer must be set to a positive number.`); } if (tournament.setAutoStartTimeout(timeout, this)) { this.privateModAction(`The tournament auto start timer was set to ${target} by ${user.name}`); this.modlog('TOUR AUTOSTART', null, timeout === Infinity ? 'off' : target); } } }, autodq: 'setautodq', setautodq(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); target = target.trim(); if (!target) { if (tournament.autoDisqualifyTimeout !== Infinity) { return this.sendReply(`Usage: /tour ${cmd} ; The current automatic disqualify timer is set to ${(tournament.autoDisqualifyTimeout / 1000 / 60)} minute(s)`); } else { return this.sendReply(`Usage: /tour ${cmd} `); } } if (target.toLowerCase() === 'infinity' || target === '0') target = 'off'; const timeout = target.toLowerCase() === 'off' ? Infinity : Number(target) * 60 * 1000; if (timeout <= 0 || (timeout !== Infinity && timeout > Chat.MAX_TIMEOUT_DURATION)) { return this.errorReply(`The automatic disqualification timer must be set to a positive number.`); } if (timeout === tournament.autoDisqualifyTimeout) { return this.errorReply(`The automatic tournament disqualify timer is already set to ${target} minute(s).`); } if (tournament.setAutoDisqualifyTimeout(timeout, this)) { this.privateModAction(`The tournament auto disqualify timer was set to ${target} by ${user.name}`); this.modlog('TOUR AUTODQ', null, timeout === Infinity ? 'off' : target); } }, runautodq(target, room, user) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); if (tournament.autoDisqualifyTimeout === Infinity) { return this.errorReply("The automatic tournament disqualify timer is not set."); } tournament.runAutoDisqualify(this); this.roomlog(`${user.name} used /tour runautodq`); }, scout: 'setscouting', scouting: 'setscouting', setscout: 'setscouting', setscouting(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); target = target.trim(); if (!target) { if (tournament.allowScouting) { return this.sendReply("This tournament allows spectating other battles while in a tournament."); } else { return this.sendReply("This tournament disallows spectating other battles while in a tournament."); } } const option = target.toLowerCase(); if (this.meansYes(option) || option === 'allow' || option === 'allowed') { if (tournament.allowScouting) return this.errorReply("Scouting for this tournament is already set to allowed."); tournament.setScouting(true); this.privateModAction(`The tournament was set to allow scouting by ${user.name}`); this.modlog('TOUR SCOUT', null, 'allow'); } else if (this.meansNo(option) || option === 'disallow' || option === 'disallowed') { if (!tournament.allowScouting) return this.errorReply("Scouting for this tournament is already disabled."); tournament.setScouting(false); this.privateModAction(`The tournament was set to disallow scouting by ${user.name}`); this.modlog('TOUR SCOUT', null, 'disallow'); } else { return this.sendReply(`Usage: /tour ${cmd}`); } }, modjoin: 'setmodjoin', setmodjoin(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); target = target.trim(); if (!target) { if (tournament.allowModjoin) { return this.sendReply("This tournament allows players to modjoin their battles."); } else { return this.sendReply("This tournament does not allow players to modjoin their battles."); } } const option = target.toLowerCase(); if (this.meansYes(option) || option === 'allowed') { if (tournament.allowModjoin) return this.errorReply("Modjoining is already allowed for this tournament."); tournament.setModjoin(true); this.privateModAction(`The tournament was set to allow modjoin by ${user.name}`); this.modlog('TOUR MODJOIN', null, option); } else if (this.meansNo(option) || option === 'disallowed') { if (!tournament.allowModjoin) return this.errorReply("Modjoining is already not allowed for this tournament."); tournament.setModjoin(false); this.privateModAction(`The tournament was set to disallow modjoin by ${user.name}`); this.modlog('TOUR MODJOIN', null, option); } else { return this.sendReply(`Usage: /tour ${cmd} `); } }, aconly: 'autoconfirmedonly', onlyac: 'autoconfirmedonly', onlyautoconfirmed: 'autoconfirmedonly', autoconfirmedonly(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); target = target.trim(); if (!target) { return this.sendReply( `This tournament ${tournament.autoconfirmedOnly ? 'does not allow' : 'allows'} non-autoconfirmed users to join a tournament.` ); } const value = this.meansYes(target) ? true : this.meansNo(target) ? false : null; target = value ? 'ON' : 'OFF'; if (value === null || !toID(target)) { return this.parse(`/help tour`); } if (tournament.autoconfirmedOnly === value) { return this.errorReply(`This tournament is already set to ${value ? 'disallow' : 'allow'} non-autoconfirmed users.`); } tournament.setAutoconfirmedOnly(value); this.privateModAction(`${user.name} set this tournament to ${value ? 'disallow' : 'allow'} non-autoconfirmed users.`); this.modlog('TOUR AUTOCONFIRMEDONLY', null, target); }, forcepublic(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); target = target.trim(); const option = target || 'on'; if (this.meansYes(option)) { if (tournament.forcePublic) { throw new Chat.ErrorMessage(`Tournament battles are already being forced public.`); } tournament.setForcePublic(true); this.privateModAction(`Tournament public battles were turned ON by ${user.name}`); this.modlog('TOUR FORCEPUBLIC', null, 'ON'); } else if (this.meansNo(option) || option === 'stop') { if (!tournament.forcePublic) { throw new Chat.ErrorMessage(`Tournament battles are not being forced public.`); } tournament.setForcePublic(false); this.privateModAction(`Tournament public battles were turned OFF by ${user.name}`); this.modlog('TOUR FORCEPUBLIC', null, 'OFF'); } else { return this.sendReply(`Usage: /tour ${cmd} `); } }, forcetimer(target, room, user, connection, cmd) { room = this.requireRoom(); this.checkCan('tournaments', null, room); const tournament = this.requireGame(Tournament); target = target.trim(); const option = target ? target.toLowerCase() : 'on'; if (this.meansYes(option)) { tournament.setForceTimer(true); for (const player of tournament.players) { player.inProgressMatch?.room.game?.startTimer(); } this.privateModAction(`The timer was turned on for the tournament by ${user.name}`); this.modlog('TOUR FORCETIMER', null, 'ON'); } else if (this.meansNo(option) || option === 'stop') { tournament.setForceTimer(false); this.privateModAction(`The timer was turned off for the tournament by ${user.name}`); this.modlog('TOUR FORCETIMER', null, 'OFF'); } else { return this.sendReply(`Usage: /tour ${cmd} `); } }, settings: { modjoin(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); if (!target || (!this.meansYes(target) && !this.meansNo(target))) { return this.parse(`/help tour settings`); } const tour = room.getGame(Tournament); room.settings.tournaments ||= {}; if (this.meansYes(target)) { if (room.settings.tournaments.allowModjoin) { throw new Chat.ErrorMessage(`Modjoin is already enabled for every tournament.`); } if (tour && !tour.allowModjoin) this.parse(`/tour modjoin allow`); room.settings.tournaments.allowModjoin = true; room.saveSettings(); this.privateModAction(`Modjoin was enabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'modjoin: ALLOW'); } else { if (!room.settings.tournaments.allowModjoin) { throw new Chat.ErrorMessage(`Modjoin is already disabled for every tournament.`); } if (tour?.allowModjoin) this.parse(`/tour modjoin disallow`); room.settings.tournaments.allowModjoin = false; room.saveSettings(); this.privateModAction(`Modjoin was disabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'modjoin: DISALLOW'); } }, scouting(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); if (!target || (!this.meansYes(target) && !this.meansNo(target))) return this.parse(`/help tour settings`); const tour = room.getGame(Tournament); room.settings.tournaments ||= {}; if (this.meansYes(target)) { if (room.settings.tournaments.allowScouting) { throw new Chat.ErrorMessage(`Scouting is already enabled for every tournament.`); } if (tour && !tour.allowScouting) this.parse(`/tour scouting allow`); room.settings.tournaments.allowScouting = true; room.saveSettings(); this.privateModAction(`Scouting was enabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'scouting: ALLOW'); } else { if (!room.settings.tournaments.allowScouting) { throw new Chat.ErrorMessage(`Scouting is already disabled for every tournament.`); } if (tour?.allowScouting) this.parse(`/tour scouting disallow`); room.settings.tournaments.allowScouting = false; room.saveSettings(); this.privateModAction(`Scouting was disabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'scouting: DISALLOW'); } }, aconly: 'autoconfirmedonly', onlyac: 'autoconfirmedonly', onlyautoconfirmed: 'autoconfirmedonly', autoconfirmedonly(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); const tour = room.getGame(Tournament); room.settings.tournaments ||= {}; const value = this.meansYes(target) ? true : this.meansNo(target) ? false : null; if (!target || value === null) return this.parse(`/help tour settings`); if (room.settings.tournaments.autoconfirmedOnly === value) { return this.errorReply(`All tournaments are already set to ${value ? 'disallow' : 'allow'} non-autoconfimed users.`); } room.settings.tournaments.autoconfirmedOnly = value; room.saveSettings(); target = value ? 'ON' : 'OFF'; this.modlog('TOUR SETTINGS', null, `autoconfirmed only: ${target}`); if (tour) this.parse(`/tour autoconfirmedonly ${target}`); this.privateModAction(`${user.name} set all tournaments to ${value ? 'disallow' : 'allow'} non-autoconfirmed users.`); }, forcepublic(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); if (!target || (!this.meansNo(target) && !this.meansYes(target))) return this.parse(`/help tour settings`); const tour = room.getGame(Tournament); room.settings.tournaments ||= {}; if (this.meansNo(target)) { if (!room.settings.tournaments.forcePublic) { throw new Chat.ErrorMessage(`Forced public battles are already disabled for every tournament.`); } if (tour?.forcePublic) this.parse(`/tour forcepublic off`); room.settings.tournaments.forcePublic = false; room.saveSettings(); this.privateModAction(`Forced public battles were disabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'forcepublic: DISABLE'); } else { if (room.settings.tournaments.forcePublic) { throw new Chat.ErrorMessage(`Forced public battles are already enabled for every tournament.`); } if (tour && !tour.forcePublic) this.parse(`/tour forcepublic on`); room.settings.tournaments.forcePublic = true; room.saveSettings(); this.privateModAction(`Forced public battles were enabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'forcepublic: ENABLE'); } }, forcetimer(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); if (!target || (!this.meansNo(target) && !this.meansYes(target))) return this.parse(`/help tour settings`); const tour = room.getGame(Tournament); room.settings.tournaments ||= {}; if (this.meansNo(target)) { if (!room.settings.tournaments.forceTimer) { throw new Chat.ErrorMessage(`Forced timer is already disabled for every tournament.`); } if (tour?.forceTimer) this.parse(`/tour forcetimer off`); room.settings.tournaments.forceTimer = false; room.saveSettings(); this.privateModAction(`Forced timer was disabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'forcetimer: DISABLE'); } else { if (room.settings.tournaments.forceTimer) { throw new Chat.ErrorMessage(`Forced timer is already enabled for every tournament.`); } if (tour && !tour.forceTimer) this.parse(`/tour forcetimer on`); room.settings.tournaments.forceTimer = true; room.saveSettings(); this.privateModAction(`Forced timer was enabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'forcetimer: ENABLE'); } }, autostart(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); const num = Number(target); if (!target || (!this.meansYes(target) && !this.meansNo(target) && isNaN(num))) { return this.parse(`/help tour settings`); } const tour = room.getGame(Tournament); room.settings.tournaments ||= {}; if (this.meansNo(target)) { if (!room.settings.tournaments.autostart) { throw new Chat.ErrorMessage(`Autostart is already disabled for every tournament.`); } if (tour && !tour.isTournamentStarted && tour.autoDisqualifyTimeout !== Infinity) { this.parse(`/tour setautojoin off`); } room.settings.tournaments.autostart = false; room.saveSettings(); this.privateModAction(`Autostart was disabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'autostart: DISABLE'); } else if (this.meansYes(target) && target !== '1') { if (room.settings.tournaments.autostart === true) { throw new Chat.ErrorMessage(`Autostart for every tournament is already set to true.`); } room.settings.tournaments.autostart = true; if (tour && !tour.isTournamentStarted && tour.playerCap) this.parse(`/tour setautostart on`); room.saveSettings(); this.privateModAction(`Autostart was set to true for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, `autostart: ON`); } else if (!isNaN(num)) { const timeout = num * 60 * 1000; if (timeout < 0.5 * 60 * 1000 || timeout > Chat.MAX_TIMEOUT_DURATION) { throw new Chat.ErrorMessage(`The autostart must be set to at least 0.5.`); } if (room.settings.tournaments.autostart === timeout) { throw new Chat.ErrorMessage(`Autostart for every tournament is already set to ${num}.`); } room.settings.tournaments.autostart = timeout; if (tour && !tour.isTournamentStarted && tour.autoStartTimeout === Infinity) { this.parse(`/tour setautostart ${num}`); } room.saveSettings(); this.privateModAction(`Autostart was set to ${num} minute(s) for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, `autostart: ${num} minute(s)`); } }, autodq(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); const num = Number(target); if (!target || (!this.meansNo(target) && isNaN(num))) return this.parse(`/help tour settings`); const tour = room.getGame(Tournament); room.settings.tournaments ||= {}; if (this.meansNo(target)) { if (!room.settings.tournaments.autodq) { throw new Chat.ErrorMessage(`Automatic disqualification is already disabled for every tournament.`); } if (tour && !tour.isTournamentStarted && tour.autoDisqualifyTimeout !== Infinity) { this.parse(`/tour autodq off`); } delete room.settings.tournaments.autodq; room.saveSettings(); this.privateModAction(`Automatic disqualification was disabled for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'autodq: DISABLE'); } else if (!isNaN(num)) { const timeout = num * 60 * 1000; if (timeout < 0.5 * 60 * 1000 || timeout > Chat.MAX_TIMEOUT_DURATION) { throw new Chat.ErrorMessage(`The autodq must be set to a number greater than 1.`); } if (room.settings.tournaments.autodq === timeout) { throw new Chat.ErrorMessage(`Automatic disqualification for every tournament is already set to ${num}.`); } room.settings.tournaments.autodq = timeout; if (tour?.autoDisqualifyTimeout === Infinity) { this.parse(`/tour autodq ${num}`); } room.saveSettings(); this.privateModAction(`Automatic disqualification was set to ${num} minute(s) for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, `autodq: ${num} minute(s)`); } }, playercap(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); const num = parseInt(target); if (!target || (!this.meansNo(target) && isNaN(num))) return this.parse(`/help tour settings`); const tour = room.getGame(Tournament); room.settings.tournaments ||= {}; if (this.meansNo(target)) { if (!room.settings.tournaments.playerCap) { throw new Chat.ErrorMessage(`Player Cap is already removed for every tournament.`); } if (tour && !tour.isTournamentStarted && tour.playerCap) { this.parse(`/tour setplayercap off`); } delete room.settings.tournaments.playerCap; room.saveSettings(); this.privateModAction(`Player Cap was removed for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, 'playercap: REMOVE'); } else if (!isNaN(num)) { if (num < 2) { throw new Chat.ErrorMessage(`The Player Cap must be at least 2.`); } if (room.settings.tournaments.playerCap === num) { throw new Chat.ErrorMessage(`Player Cap for every tournament is already set to ${num}.`); } room.settings.tournaments.playerCap = num; if (tour && !tour.isTournamentStarted && !tour.playerCap) { this.parse(`/tour setplayercap ${num}`); if (room.settings.tournaments.autostart === true) this.parse(`/tour autostart on`); } room.saveSettings(); this.privateModAction(`Player Cap was set to ${num} for every tournament by ${user.name}`); this.modlog('TOUR SETTINGS', null, `playercap: ${num}`); if (Config.tourdefaultplayercap && room.settings.tournaments.playerCap > Config.tourdefaultplayercap) { Monitor.log(`[TourMonitor] Room ${room.roomid} setting cap for every tour over default cap (${room.settings.tournaments.playerCap})`); } } else { return this.sendReply(`Usage: ${this.cmdToken}${this.fullCmd} `); } }, recenttours(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); let num = parseInt(target); const forcedelete = toID(target) === 'forcedelete'; if (this.meansNo(target) || forcedelete) num = 0; if (isNaN(num) || num > 15 || num < 0) { return this.parse(`/help tour settings`); } room.settings.tournaments ||= {}; if (num >= 1) { if (room.settings.tournaments.recentToursLength === num) { throw new Chat.ErrorMessage(`Number of recent tournaments to record is already set to ${num}.`); } room.settings.tournaments.recentToursLength = num; if (room.settings.tournaments.recentTours) { while (room.settings.tournaments.recentTours.length > num) { room.settings.tournaments.recentTours.pop(); } } room.saveSettings(); this.privateModAction(`Number of recent tournaments to record was set to ${num} by ${user.name}.`); this.modlog('TOUR SETTINGS', null, `recent tours: ${num} most recent`); } else { if (forcedelete && room.settings.tournaments.recentTours) { delete room.settings.tournaments.recentTours; this.privateModAction(`Recent tournaments list was deleted by ${user.name}.`); this.modlog('TOUR SETTINGS', null, `recent tours: delete`); } if (!room.settings.tournaments.recentToursLength) { throw new Chat.ErrorMessage(`Number of recent tournaments to record is already disabled.`); } delete room.settings.tournaments.recentToursLength; room.saveSettings(); this.privateModAction(`Number of recent tournaments to record was turned off by ${user.name}.`); this.modlog('TOUR SETTINGS', null, `recent tours: off`); } }, blockrecents(target, room, user) { room = this.requireRoom(); this.checkCan('declare', null, room); target = toID(target); if (!target || (!this.meansYes(target) && !this.meansNo(target))) { if (room.settings.tournaments?.blockRecents) { this.sendReply(`Recent tournaments are currently ${room.settings.tournaments.blockRecents ? '' : 'NOT '} blocked from being made.`); } return this.parse(`/help tour settings`); } room.settings.tournaments ||= {}; if (this.meansYes(target)) { if (room.settings.tournaments.blockRecents) { throw new Chat.ErrorMessage(`Recent tournaments are already blocked from being made.`); } room.settings.tournaments.blockRecents = true; room.saveSettings(); this.privateModAction(`Recent tournaments were blocked from being made by ${user.name}.`); this.modlog('TOUR SETTINGS', null, `recent tour block: on`); } else { if (!room.settings.tournaments.blockRecents) { throw new Chat.ErrorMessage(`Recent tournaments are already allowed to be remade.`); } delete room.settings.tournaments.blockRecents; room.saveSettings(); this.privateModAction(`Recent tournaments were allowed to be remade by ${user.name}.`); this.modlog('TOUR SETTINGS', null, `recent tour block: off`); } }, '': 'help', help() { this.parse(`${this.cmdToken}help tour settings`); }, }, }, tournamenthelp(target) { if (!this.runBroadcast()) return; if (target.endsWith('settings')) { return this.sendReplyBox( `/tour settings autodq <minutes|off> - Sets the automatic disqualification timeout for every tournament.
` + `/tour settings autostart <on|minutes|off> - Sets the automatic start timeout for every tournament.
` + `/tour settings forcepublic <on|off> - Specifies whether users can hide their battles for every tournament.
` + `/tour settings forcetimer <on|off> - Specifies whether users can toggle the timer for every tournament.
` + `/tour settings modjoin <on|off> - Specifies whether users can modjoin their battles for every tournament.
` + `/tour settings autoconfirmedonly<on|off> - Set requirement for signups for this tournament. If this is on, only autoconfirmed users can join a tournament.
` + `/tour settings playercap <number> - Sets the playercap for every tournament.
` + `/tour settings scouting <on|off> - Specifies whether users can spectate other participants for every tournament.
` + `/tour settings recenttours <number|off|forcedelete> - Specifies the amount of recent tournaments to list in /recenttours.
` + `/tour settings blockrecents <on|off> - Toggles blocking tours in /recenttours from being made.
` + `Requires: # ~`, ); } this.sendReplyBox( `Tournament Commands
` + `- create/new <format>, <type>, [ <comma-separated arguments>]: Creates a new tournament in the current room.
` + `- rules <comma-separated arguments>: Sets the custom rules for the tournament before it has started. Custom rules help/list
` + `- end/stop/delete: Forcibly ends the tournament in the current room.
` + `- begin/start: Starts the tournament in the current room.

` + `
Configuration Commands` + `- settype <type> [, <comma-separated arguments>]: Modifies the type of tournament after it's been created, but before it has started.
` + `- cap/playercap <cap>: Sets the player cap of the tournament before it has started.
` + `- viewrules/viewbanlist: Shows the custom rules for the tournament.
` + `- clearrules/clearbanlist: Clears the custom rules for the tournament before it has started.
` + `- name <name>: Sets a custom name for the tournament.
` + `- clearname: Clears the custom name of the tournament.
` + `- autostart/setautostart <on|minutes|off>: Sets the automatic start timeout.
` + `- dq/disqualify <user>: Disqualifies a user.
` + `- autodq/setautodq <minutes|off>: Sets the automatic disqualification timeout.
` + `- runautodq: Manually run the automatic disqualifier.
` + `- autoconfirmedonly/onlyautoconfirmed/aconly/onlyac <on|off>: Set requirement for signups for this tournament. If this is on, only autoconfirmed users can join a tournament.
` + `- scouting <allow|disallow>: Specifies whether joining tournament matches while in a tournament is allowed.
` + `- modjoin <allow|disallow>: Specifies whether players can modjoin their battles.
` + `- forcetimer <on|off>: Turn on the timer for tournament battles.
` + `- forcepublic <on|off>: Forces tournament battles and their replays to be public.
` + `- getusers: Lists the users in the current tournament.
` + `- announce/announcements <on|off>: Enables/disables tournament announcements for the current room.
` + `- banuser/unbanuser <user>: Bans/unbans a user from joining tournaments in this room. Lasts 2 weeks.
` + `- sub/replace <olduser>, <newuser>: Substitutes a new user for an old one
` + `- settings: Do /help tour settings for more information
` + `
` + `
` + `You can also consult more detailed help.` ); }, }; const roomSettings: Chat.SettingsHandler[] = [ room => ({ label: "Tournament Forced Public Battles", permission: "editroom", options: [ ['on', room.settings.tournaments?.forcePublic || 'tour settings forcepublic on'], ['off', !room.settings.tournaments?.forcePublic || 'tour settings forcepublic off'], ], }), room => ({ label: "Tournament Forced Timer", permission: "editroom", options: [ ['on', room.settings.tournaments?.forceTimer || 'tour settings forcetimer on'], ['off', !room.settings.tournaments?.forceTimer || 'tour settings forcetimer off'], ], }), room => ({ label: "Tournament Modjoin", permission: "editroom", options: [ ['allow', room.settings.tournaments?.allowModjoin || 'tour settings modjoin allow'], ['disallow', !room.settings.tournaments?.allowModjoin || 'tour settings modjoin disallow'], ], }), room => ({ label: "Tournament Autoconfirmed Only", permission: "editroom", options: [ ['on', room.settings.tournaments?.autoconfirmedOnly || 'tour settings aconly on'], ['off', !room.settings.tournaments?.autoconfirmedOnly || 'tour settings aconly off'], ], }), room => ({ label: "Tournament Scouting", permission: "editroom", options: [ ['allow', room.settings.tournaments?.allowScouting || 'tour settings scouting allow'], ['disallow', !room.settings.tournaments?.allowScouting || 'tour settings scouting disallow'], ], }), room => ({ label: "Tournament Recent Tours", permission: "editroom", options: ['off', 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map( setting => ( [ `${setting}`, setting === (room.settings.tournaments?.recentToursLength || 'off') || `tour settings recenttours ${setting}`, ] ) ), }), room => ({ label: "Tournament Block Recent Tours", permission: "editroom", options: [ ['on', room.settings.tournaments?.blockRecents || 'tour settings blockrecents on'], ['off', !room.settings.tournaments?.blockRecents || 'tour settings blockrecents off'], ], }), ]; export const Tournaments = { TournamentGenerators, TournamentPlayer, Tournament, createTournament, commands, roomSettings, }; for (const room of Rooms.rooms.values()) { const announcements = (room.settings as any).tourAnnouncements; delete (room.settings as any).tourAnnouncements; if (!announcements) { room.saveSettings(); continue; } if (!room.settings.tournaments) room.settings.tournaments = {}; room.settings.tournaments.announcements = announcements; room.saveSettings(); }