';
if (hasThousandArrows) {
buffer += " Thousand Arrows has neutral type effectiveness on Flying-type Pok\u00e9mon if not already smacked down.";
}
this.sendReplyBox('Coverage for ' + sources.join(' + ') + ': ' + buffer);
}
},
coveragehelp: [
`/coverage [move 1], [move 2] ... - Provides the best effectiveness match-up against all defending types for given moves or attacking types`,
`!coverage [move 1], [move 2] ... - Shows this information to everyone.`,
`Adding the parameter 'all' or 'table' will display the information with a table of all type combinations.`,
],
'!statcalc': true,
statcalc(target, room, user) {
if (!target) return this.parse("/help statcalc");
if (!this.runBroadcast()) return;
let targets = target.split(' ');
let lvlSet, natureSet, ivSet, evSet, baseSet, modSet, realSet = false;
let pokemon;
let useStat = '';
let level = 100;
let calcHP = false;
let nature = 1.0;
let iv = 31;
let ev = 252;
let baseStat = -1;
let modifier = 0;
let positiveMod = true;
let realStat;
for (const arg of targets) {
let lowercase = arg.toLowerCase();
if (!lvlSet) {
if (lowercase === 'lc') {
level = 5;
lvlSet = true;
continue;
} else if (lowercase === 'vgc') {
level = 50;
lvlSet = true;
continue;
} else if (lowercase.startsWith('lv') || lowercase.startsWith('level')) {
level = parseInt(arg.replace(/\D/g, ''));
lvlSet = true;
if (level < 1 || level > 9999) {
return this.sendReplyBox('Invalid value for level: ' + level);
}
continue;
}
}
if (!useStat) {
switch (lowercase) {
case 'hp':
case 'hitpoints':
calcHP = true;
useStat = 'hp';
continue;
case 'atk':
case 'attack':
useStat = 'atk';
continue;
case 'def':
case 'defense':
useStat = 'def';
continue;
case 'spa':
useStat = 'spa';
continue;
case 'spd':
case 'sdef':
useStat = 'spd';
continue;
case 'spe':
case 'speed':
useStat = 'spe';
continue;
}
}
if (!natureSet) {
if (lowercase === 'boosting' || lowercase === 'positive') {
nature = 1.1;
natureSet = true;
continue;
} else if (lowercase === 'negative' || lowercase === 'inhibiting') {
nature = 0.9;
natureSet = true;
continue;
} else if (lowercase === 'neutral') {
continue;
}
}
if (!ivSet) {
if (lowercase.endsWith('iv') || lowercase.endsWith('ivs')) {
iv = parseInt(arg);
ivSet = true;
if (isNaN(iv)) {
return this.sendReplyBox('Invalid value for IVs: ' + Chat.escapeHTML(arg));
}
continue;
}
}
if (!evSet) {
if (lowercase === 'invested' || lowercase === 'max') {
evSet = true;
if (lowercase === 'max' && !natureSet) {
nature = 1.1;
natureSet = true;
}
} else if (lowercase === 'uninvested') {
ev = 0;
evSet = true;
} else if (lowercase.endsWith('ev') || lowercase.endsWith('evs') || lowercase.endsWith('+') || lowercase.endsWith('-')) {
ev = parseInt(arg);
evSet = true;
if (isNaN(ev)) {
return this.sendReplyBox('Invalid value for EVs: ' + Chat.escapeHTML(arg));
}
if (ev > 255 || ev < 0) {
return this.sendReplyBox('The amount of EVs should be between 0 and 255.');
}
if (!natureSet) {
if (arg.includes('+')) {
nature = 1.1;
natureSet = true;
} else if (arg.includes('-')) {
nature = 0.9;
natureSet = true;
}
}
continue;
}
}
if (!modSet) {
if (['band', 'scarf', 'specs'].includes(arg)) {
modifier = 1;
modSet = true;
} else if (arg.charAt(0) === '+') {
modifier = parseInt(arg.charAt(1));
modSet = true;
} else if (arg.charAt(0) === '-') {
positiveMod = false;
modifier = parseInt(arg.charAt(1));
modSet = true;
}
if (isNaN(modifier)) {
return this.sendReplyBox('Invalid value for modifier: ' + Chat.escapeHTML(modifier));
}
if (modifier > 6) {
return this.sendReplyBox('Modifier should be a number between -6 and +6');
}
if (modSet) continue;
}
if (!pokemon) {
let testPoke = Dex.getTemplate(arg);
if (testPoke.baseStats) {
pokemon = testPoke.baseStats;
baseSet = true;
continue;
}
}
let tempStat = parseInt(arg);
if (!realSet) {
if (lowercase.endsWith('real')) {
realStat = tempStat;
realSet = true;
if (isNaN(realStat)) {
return this.sendReplyBox('Invalid value for target real stat: ' + Chat.escapeHTML(arg));
}
if (realStat < 0) {
return this.sendReplyBox('The target real stat must be greater than 0.');
}
continue;
}
}
if (!isNaN(tempStat) && !baseSet && tempStat > 0 && tempStat < 256) {
baseStat = tempStat;
baseSet = true;
}
}
if (pokemon) {
if (useStat) {
baseStat = pokemon[useStat];
} else {
return this.sendReplyBox('No stat found.');
}
}
if (realSet) {
if (!baseSet) {
if (calcHP) {
baseStat = Math.ceil((100 * realStat - 10 - level * (ev / 4 + iv + 100)) / (2 * level));
} else {
if (!positiveMod) {
realStat *= (2 + modifier) / 2;
} else {
realStat *= 2 / (2 + modifier);
}
baseStat = Math.ceil((100 * Math.ceil(realStat) - nature * (level * (ev / 4 + iv) + 500)) / (2 * level * nature));
}
if (baseStat < 0) {
return this.sendReplyBox('No valid value for base stat possible with given parameters.');
}
} else if (!evSet) {
if (calcHP) {
ev = Math.ceil(100 * (realStat - 10) / level - 2 * (baseStat + 50));
} else {
if (!positiveMod) {
realStat *= (2 + modifier) / 2;
} else {
realStat *= 2 / (2 + modifier);
}
ev = Math.ceil(-1 * (2 * (nature * (baseStat * level + 250) - 50 * Math.ceil(realStat))) / (level * nature));
}
ev -= 31;
if (ev < 0) iv += ev;
ev *= 4;
if (iv < 0 || ev > 255) {
return this.sendReplyBox('No valid EV/IV combination possible with given parameters. Maybe try a different nature?' + ev);
}
} else {
return this.sendReplyBox('Too many parameters given; nothing to calculate.');
}
} else if (baseStat < 0) {
return this.sendReplyBox('No valid value for base stat found.');
}
let output;
if (calcHP) {
output = (((iv + (2 * baseStat) + (ev / 4) + 100) * level) / 100) + 10;
} else {
output = Math.floor(nature * Math.floor((((iv + (2 * baseStat) + (ev / 4)) * level) / 100) + 5));
if (positiveMod) {
output *= (2 + modifier) / 2;
} else {
output *= 2 / (2 + modifier);
}
}
return this.sendReplyBox('Base ' + baseStat + (calcHP ? ' HP ' : ' ') + 'at level ' + level + ' with ' + iv + ' IVs, ' + ev + (nature === 1.1 ? '+' : (nature === 0.9 ? '-' : '')) + ' EVs' + (modifier > 0 && !calcHP ? ' at ' + (positiveMod ? '+' : '-') + modifier : '') + ': ' + Math.floor(output) + '.');
},
statcalchelp: [
`/statcalc [level] [base stat] [IVs] [nature] [EVs] [modifier] (only base stat is required) - Calculates what the actual stat of a Pokémon is with the given parameters. For example, '/statcalc lv50 100 30iv positive 252ev scarf' calculates the speed of a base 100 scarfer with HP Ice in Battle Spot, and '/statcalc uninvested 90 neutral' calculates the attack of an uninvested Crobat.`,
`!statcalc [level] [base stat] [IVs] [nature] [EVs] [modifier] (only base stat is required) - Shows this information to everyone.`,
`Inputing 'hp' as an argument makes it use the formula for HP. Instead of giving nature, '+' and '-' can be appended to the EV amount (e.g. 252+ev) to signify a boosting or inhibiting nature.`,
`An actual stat can be given in place of a base stat or EVs. In this case, the minumum base stat or EVs necessary to have that real stat with the given parameters will be determined. For example, '/statcalc 502real 252+ +1' calculates the minimum base speed necessary for a positive natured fully invested scarfer to outspeed`,
],
/*********************************************************
* Informational commands
*********************************************************/
'!uptime': true,
uptime(target, room, user) {
if (!this.can('broadcast')) return false;
if (!this.runBroadcast()) return;
let uptime = process.uptime();
let uptimeText;
if (uptime > 24 * 60 * 60) {
let uptimeDays = Math.floor(uptime / (24 * 60 * 60));
uptimeText = uptimeDays + " " + (uptimeDays === 1 ? "day" : "days");
let uptimeHours = Math.floor(uptime / (60 * 60)) - uptimeDays * 24;
if (uptimeHours) uptimeText += ", " + uptimeHours + " " + (uptimeHours === 1 ? "hour" : "hours");
} else {
uptimeText = Chat.toDurationString(uptime * 1000);
}
this.sendReplyBox("Uptime: " + uptimeText + "");
},
'!servertime': true,
servertime(target, room, user) {
if (!this.runBroadcast()) return;
let servertime = new Date();
this.sendReplyBox(`Server time: ${servertime.toLocaleString()}`);
},
'!groups': true,
groups(target, room, user) {
if (!this.runBroadcast()) return;
const showRoom = (target !== 'global');
const showGlobal = (target !== 'room' && target !== 'rooms');
const roomRanks = [
`Room ranks`,
`+ Voice - They can use ! commands like !groups`,
`% Driver - The above, and they can mute and warn`,
`@ Moderator - The above, and they can room ban users`,
`* Bot - Like Moderator, but makes it clear that this user is a bot`,
`# Room Owner - They are leaders of the room and can almost totally control it`,
];
const globalRanks = [
`Global ranks`,
`+ Global Voice - They can use ! commands like !groups`,
`% Global Driver - The above, and they can also lock users and check for alts`,
`@ Global Moderator - The above, and they can globally ban users`,
`* Global Bot - Like Moderator, but makes it clear that this user is a bot`,
`& Global Leader - The above, and they can promote to global moderator and force ties`,
`~ Global Administrator - They can do anything, like change what this message says`,
];
this.sendReplyBox(
(showRoom ? roomRanks.map(str => this.tr(str)).join(' ') : ``) +
(showRoom && showGlobal ? `
` : ``) +
(showGlobal ? globalRanks.map(str => this.tr(str)).join(' ') : ``)
);
},
groupshelp: [
`/groups - Explains what the symbols (like % and @) before people's names mean.`,
`/groups [global|room] - Explains only global or room symbols.`,
`!groups - Shows everyone that information. Requires: + % @ # & ~`,
],
'!punishments': true,
punishments(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(
`Room punishments: ` +
`warn - Displays a popup with the rules. ` +
`mute - Mutes a user (makes them unable to talk) for 7 minutes. ` +
`hourmute - Mutes a user for 60 minutes. ` +
`ban - Bans a user (makes them unable to join the room) for 2 days. ` +
`blacklist - Bans a user for a year. ` +
` ` +
`Global punishments: ` +
`lock - Locks a user (makes them unable to talk in any rooms or PM non-staff) for 2 days. ` +
`weeklock - Locks a user for a week. ` +
`namelock - Locks a user and prevents them from having a username for 2 days. ` +
`globalban - Globally bans (makes them unable to connect and play games) for a week.`
);
},
punishmentshelp: [
`/punishments - Explains punishments.`,
`!punishments - Show everyone that information. Requires: + % @ # & ~`,
],
'!opensource': true,
repo: 'opensource',
repository: 'opensource',
git: 'opensource',
opensource(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(
`Pokémon Showdown is open source: ` +
`- Language: JavaScript (Node.js) ` +
`- What's new? ` +
`- Server source code ` +
`- Client source code ` +
`- Dex source code`
);
},
opensourcehelp: [
`/opensource - Links to PS's source code repository.`,
`!opensource - Show everyone that information. Requires: + % @ # & ~`,
],
'!staff': true,
staff(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(`Pokémon Showdown Staff List`);
},
'!forums': true,
forums(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(`Pokémon Showdown Forums`);
},
'!privacypolicy': true,
privacypolicy(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(
`- We log PMs so you can report them - staff can't look at them without permission unless there's a law enforcement reason. ` +
`- We log IPs to enforce bans and mutes. ` +
`- We use cookies to save your login info and teams, and for Google Analytics and AdSense. ` +
`- For more information, you can read our full privacy policy.`
);
},
'!suggestions': true,
suggestions(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(`Make a suggestion for Pokémon Showdown`);
},
'!bugs': true,
bugreport: 'bugs',
bugreports: 'bugs',
bugs(target, room, user) {
if (!this.runBroadcast()) return;
if (room && room.battle) {
this.sendReplyBox(`
`);
} else {
this.sendReplyBox(
`Have a replay showcasing a bug on Pokémon Showdown? ` +
`- Questions ` +
`- Bug Reports (ask in Help before posting in the thread if you're unsure)`
);
}
},
'!avatars': true,
avatars(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(`You can by clicking on it in the menu in the upper right. Custom avatars are only obtainable by staff.`);
},
avatarshelp: [
`/avatars - Explains how to change avatars.`,
`!avatars - Show everyone that information. Requires: + % @ # & ~`,
],
'!optionsbutton': true,
optionbutton: 'optionsbutton',
optionsbutton(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(` (The Sound and Options buttons are at the top right, next to your username)`);
},
'!soundbutton': true,
soundsbutton: 'soundbutton',
volumebutton: 'soundbutton',
soundbutton(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(` (The Sound and Options buttons are at the top right, next to your username)`);
},
'!intro': true,
introduction: 'intro',
intro(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(
`New to competitive Pokémon? ` +
`- Beginner's Guide to Pokémon Showdown ` +
`- An introduction to competitive Pokémon ` +
`- What do 'OU', 'UU', etc mean? ` +
`- What are the rules for each format? What is 'Sleep Clause'?`
);
},
introhelp: [
`/intro - Provides an introduction to competitive Pok\u00e9mon.`,
`!intro - Show everyone that information. Requires: + % @ # & ~`,
],
'!smogintro': true,
mentoring: 'smogintro',
smogonintro: 'smogintro',
smogintro(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(
`Welcome to Smogon's official simulator! The Smogon Info / Intro Hub can help you get integrated into the community. ` +
`- Useful Smogon Info ` +
`- Tiering FAQ `
);
},
'!calc': true,
calculator: 'calc',
damagecalculator: 'calc',
damagecalc: 'calc',
randomscalc: 'calc',
randbatscalc: 'calc',
rcalc: 'calc',
calc(target, room, user, connection, cmd) {
if (!this.runBroadcast()) return;
let isRandomBattle = (room && room.battle && room.battle.format === 'gen7randombattle');
if (['randomscalc', 'randbatscalc', 'rcalc'].includes(cmd) || isRandomBattle) {
return this.sendReplyBox(
`Random Battles damage calculator. (Courtesy of LegoFigure11 & Smoochyena) ` +
`- Random Battles Damage Calcuator`
);
}
this.sendReplyBox(
`Pokémon Showdown! damage calculator. (Courtesy of Honko) ` +
`- Damage Calculator`
);
},
calchelp: [
`/calc - Provides a link to a damage calculator`,
`/rcalc - Provides a link to the random battles damage calculator`,
`!calc - Shows everyone a link to a damage calculator. Requires: + % @ # & ~`,
],
'!cap': true,
capintro: 'cap',
cap(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(
`An introduction to the Create-A-Pokémon project: ` +
`- CAP project website and description ` +
`- What Pokémon have been made? ` +
`- Talk about the metagame here ` +
`- Sample SM CAP teams`
);
},
caphelp: [
`/cap - Provides an introduction to the Create-A-Pok\u00e9mon project.`,
`!cap - Show everyone that information. Requires: + % @ # & ~`,
],
'!gennext': true,
gennext(target, room, user) {
if (!this.runBroadcast()) return;
this.sendReplyBox(
"NEXT (also called Gen-NEXT) is a mod that makes changes to the game: " +
`- README: overview of NEXT ` +
"Example replays: " +
`- Zergo vs Mr Weegle Snarf ` +
`- NickMP vs Khalogie`
);
},
'!formathelp': true,
banlists: 'formathelp',
tier: 'formathelp',
tiers: 'formathelp',
formats: 'formathelp',
tiershelp: 'formathelp',
formatshelp: 'formathelp',
viewbanlist: 'formathelp',
formathelp(target, room, user, connection, cmd) {
if (!this.runBroadcast()) return;
if (!target) {
return this.sendReplyBox(
`- Smogon Tiers ` +
`- Tiering FAQ ` +
`- The banlists for each tier ` +
" Type /formatshelp [format|section] to get details about an available format or group of formats."
);
}
const isOMSearch = (cmd === 'om' || cmd === 'othermetas');
let targetId = toId(target);
if (targetId === 'ladder') targetId = 'search';
if (targetId === 'all') targetId = '';
let formatList;
let format = Dex.getFormat(targetId);
if (format.effectType === 'Format' || format.effectType === 'ValidatorRule' || format.effectType === 'Rule') formatList = [targetId];
if (!formatList) {
formatList = Object.keys(Dex.formats);
}
// Filter formats and group by section
let exactMatch = '';
let sections = {};
let totalMatches = 0;
for (const mode of formatList) {
let format = Dex.getFormat(mode);
let sectionId = toId(format.section);
let formatId = format.id;
if (!/^gen\d+/.test(targetId)) formatId = formatId.replace(/^gen\d+/, ''); // skip generation prefix if it wasn't provided
if (targetId && !format[targetId + 'Show'] && sectionId !== targetId && format.id === mode && !formatId.startsWith(targetId)) continue;
if (isOMSearch && format.id.startsWith('gen') && ['ou', 'uu', 'ru', 'ubers', 'lc', 'customgame', 'doublescustomgame', 'gbusingles', 'gbudoubles'].includes(format.id.slice(4))) continue;
if (isOMSearch && (format.id === 'gen5nu')) continue;
totalMatches++;
if (!sections[sectionId]) sections[sectionId] = {name: format.section, formats: []};
sections[sectionId].formats.push(format.id);
if (format.id !== targetId) continue;
exactMatch = sectionId;
break;
}
if (!totalMatches) return this.errorReply("No matched formats found.");
if (totalMatches === 1) {
let rules = [];
let rulesetHtml = '';
let format = Dex.getFormat(Object.values(sections)[0].formats[0]);
if (format.effectType === 'ValidatorRule' || format.effectType === 'Rule' || format.effectType === 'Format') {
if (format.ruleset && format.ruleset.length) rules.push("Ruleset - " + Chat.escapeHTML(format.ruleset.join(", ")));
if (format.removedRules && format.removedRules.length) rules.push("Removed rules - " + Chat.escapeHTML(format.removedRules.join(", ")));
if (format.banlist && format.banlist.length) rules.push("Bans - " + Chat.escapeHTML(format.banlist.join(", ")));
if (format.unbanlist && format.unbanlist.length) rules.push("Unbans - " + Chat.escapeHTML(format.unbanlist.join(", ")));
if (format.restrictedStones && format.restrictedStones.length) rules.push("Restricted Mega Stones - " + Chat.escapeHTML(format.restrictedStones.join(", ")));
if (format.cannotMega && format.cannotMega.length) rules.push("Can't Mega Evolve non-natively - " + Chat.escapeHTML(format.cannotMega.join(", ")));
if (format.restrictedAbilities && format.restrictedAbilities.length) rules.push("Restricted abilities - " + Chat.escapeHTML(format.restrictedAbilities.join(", ")));
if (format.restrictedMoves && format.restrictedMoves.length) rules.push("Restricted moves - " + Chat.escapeHTML(format.restrictedMoves.join(", ")));
if (rules.length > 0) {
rulesetHtml = `Banlist/Ruleset${rules.join(" ")}`;
} else {
rulesetHtml = "No ruleset found for " + format.name;
}
}
let formatType = (format.gameType || "singles");
formatType = formatType.charAt(0).toUpperCase() + formatType.slice(1).toLowerCase();
if (!format.desc && !format.threads) {
if (format.effectType === 'Format') {
return this.sendReplyBox("No description found for this " + formatType + " " + format.section + " format." + " " + rulesetHtml);
} else {
return this.sendReplyBox("No description found for this rule." + " " + rulesetHtml);
}
}
let descHtml = format.desc ? [format.desc] : [];
if (format.threads) descHtml = descHtml.concat(format.threads);
return this.sendReplyBox(descHtml.join(" ") + " " + rulesetHtml);
}
let tableStyle = `border:1px solid gray; border-collapse:collapse`;
if (this.broadcasting) {
tableStyle += `; display:inline-block; max-height:240px;" class="scrollable`;
}
// Build tables
let buf = [`
`];
for (let sectionId in sections) {
if (exactMatch && sectionId !== exactMatch) continue;
buf.push(Chat.html`
${sections[sectionId].name}
`);
for (const section of sections[sectionId].formats) {
let format = Dex.getFormat(section);
let nameHTML = Chat.escapeHTML(format.name);
let desc = format.desc ? [format.desc] : [];
if (format.threads) desc = desc.concat(format.threads);
let descHTML = desc.length ? desc.join(" ") : "—";
buf.push(`
${nameHTML}
${descHTML}
`);
}
}
buf.push(`
`);
return this.sendReply("|raw|" + buf.join("") + "");
},
'!roomhelp': true,
roomhelp(target, room, user) {
if (!this.canBroadcast(false, '!htmlbox')) return;
if (this.broadcastMessage && !this.can('declare', null, room)) return false;
if (!this.runBroadcast(false, '!htmlbox')) return;
this.sendReplyBox(
`Room drivers (%) can use: ` +
`- /warn OR /k username: warn a user and show the Pokémon Showdown rules ` +
`- /mute OR /m username: 7 minute mute ` +
`- /hourmute OR /hm username: 60 minute mute ` +
`- /unmute username: unmute ` +
`- /announce OR /wall message: make an announcement ` +
`- /modlog username: search the moderator log of the room ` +
`- /modnote note: add a moderator note that can be read through modlog ` +
` ` +
`Room moderators (@) can also use: ` +
`- /roomban OR /rb username: ban user from the room ` +
`- /roomunban username: unban user from the room ` +
`- /roomvoice username: appoint a room voice ` +
`- /roomdevoice username: remove a room voice ` +
`- /staffintro intro: set the staff introduction that will be displayed for all staff joining the room ` +
`- /roomsettings: change a variety of room settings, namely modchat ` +
` ` +
`Room owners (#) can also use: ` +
`- /roomintro intro: set the room introduction that will be displayed for all users joining the room ` +
`- /rules rules link: set the room rules link seen when using /rules ` +
`- /roommod, /roomdriver username: appoint a room moderator/driver ` +
`- /roomdemod, /roomdedriver username: remove a room moderator/driver ` +
`- /roomdeauth username: remove all room auth from a user ` +
`- /declare message: make a large blue declaration to the room ` +
`- !htmlbox HTML code: broadcast a box of HTML code to the room ` +
`- !showimage [url], [width], [height]: show an image to the room ` +
`- /roomsettings: change a variety of room settings, including modchat, capsfilter, etc ` +
` ` +
`More detailed help can be found in the roomauth guide ` +
` ` +
`Tournament Help: ` +
`- /tour create format, elimination: create a new single elimination tournament in the current room. ` +
`- /tour create format, roundrobin: create a new round robin tournament in the current room. ` +
`- /tour end: forcibly end the tournament in the current room ` +
`- /tour start: start the tournament in the current room ` +
`- /tour banlist [pokemon], [talent], [...]: ban moves, abilities, Pokémon or items from being used in a tournament (it must be created first) ` +
` ` +
`More detailed help can be found in the tournaments guide ` +
``
);
},
'!restarthelp': true,
restarthelp(target, room, user) {
if (!Rooms.global.lockdown && !this.can('lockdown')) return false;
if (!this.runBroadcast()) return;
this.sendReplyBox(
`The server is restarting. Things to know: ` +
`- We wait a few minutes before restarting so people can finish up their battles ` +
`- The restart itself will take around 0.6 seconds ` +
`- Your ladder ranking and teams will not change ` +
`- We are restarting to update Pokémon Showdown to a newer version`
);
},
'!processes': true,
processes(target, room, user) {
if (!this.can('lockdown')) return false;
let buf = `${process.pid} - Main `;
for (const worker of Sockets.workers.values()) {
buf += `${worker.pid || worker.process.pid} - Sockets ${worker.id} `;
}
/** @type {typeof import('../../lib/process-manager').processManagers} */
const processManagers = require(/** @type {any} */('../../.lib-dist/process-manager')).processManagers;
for (const manager of processManagers) {
for (const [i, process] of manager.processes.entries()) {
buf += `${process.process.pid} - ${manager.basename} ${i} (load ${process.load}) `;
}
for (const [i, process] of manager.releasingProcesses.entries()) {
buf += `${process.process.pid} - PENDING RELEASE ${manager.basename} ${i} (load ${process.load}) `;
}
}
this.sendReplyBox(buf);
},
'!rules': true,
rule: 'rules',
rules(target, room, user) {
if (!target) {
if (!this.runBroadcast()) return;
this.sendReplyBox(
`${room ? this.tr("Please follow the rules:") + ' ' : ``}` +
(room && room.rulesLink ? Chat.html`- ${this.tr `${room.title} room rules`} ` : ``) +
`- ${this.tr("Global Rules")}`
);
return;
}
if (!room) {
return this.errorReply(`This is not a room you can set the rules of.`);
}
if (!this.can('editroom', null, room)) return;
if (target.length > 150) {
return this.errorReply(`Error: Room rules link is too long (must be under 150 characters). You can use a URL shortener to shorten the link.`);
}
target = target.trim();
if (target === 'delete' || target === 'remove') {
if (!room.rulesLink) return this.errorReply(`This room does not have rules set to remove.`);
delete room.rulesLink;
this.privateModAction(`(${user.name} has removed the room rules link.)`);
this.modlog('RULES', null, `removed room rules link`);
} else {
room.rulesLink = target;
this.privateModAction(`(${user.name} changed the room rules link to: ${target})`);
this.modlog('RULES', null, `changed link to: ${target}`);
}
if (room.chatRoomData) {
room.chatRoomData.rulesLink = room.rulesLink;
Rooms.global.writeChatRoomData();
}
},
ruleshelp: [
`/rules - Show links to room rules and global rules.`,
`!rules - Show everyone links to room rules and global rules. Requires: + % @ # & ~`,
`/rules [url] - Change the room rules URL. Requires: # & ~`,
`/rules remove - Removes a room rules URL. Requires: # & ~`,
],
'!faq': true,
faq(target, room, user) {
if (!this.runBroadcast()) return;
target = target.toLowerCase().trim();
let showAll = target === 'all';
if (showAll && this.broadcasting) {
return this.sendReplyBox("You cannot broadcast all FAQs at once.");
}
let buffer = [];
if (showAll || target === 'staff') {
buffer.push(`Staff FAQ`);
}
if (showAll || target === 'autoconfirmed' || target === 'ac') {
buffer.push(`A user is autoconfirmed when they have won at least one rated battle and have been registered for one week or longer.`);
}
if (showAll || target === 'coil') {
buffer.push(`What is COIL?`);
}
if (showAll || target === 'ladder' || target === 'ladderhelp' || target === 'decay') {
buffer.push(`How the ladder works`);
}
if (showAll || target === 'tiering' || target === 'tiers' || target === 'tier') {
buffer.push(`Tiering FAQ`);
}
if (showAll || target === 'badge' || target === 'badges') {
buffer.push(`Badge FAQ`);
}
if (showAll || target === 'rng') {
buffer.push(`How Pokémon Showdown's RNG works`);
}
if (showAll || !buffer.length) {
buffer.unshift(`Frequently Asked Questions`);
}
this.sendReplyBox(buffer.join(` `));
},
faqhelp: [
`/faq [theme] - Provides a link to the FAQ. Add autoconfirmed, badges, coil, ladder, staff, or tiers for a link to these questions. Add all for all of them.`,
`!faq [theme] - Shows everyone a link to the FAQ. Add autoconfirmed, badges, coil, ladder, staff, or tiers for a link to these questions. Add all for all of them. Requires: + % @ # & ~`,
],
'!smogdex': true,
analysis: 'smogdex',
strategy: 'smogdex',
smogdex(target, room, user) {
if (!target) return this.parse('/help smogdex');
if (!this.runBroadcast()) return;
let targets = target.split(',');
let pokemon = Dex.getTemplate(targets[0]);
let item = Dex.getItem(targets[0]);
let move = Dex.getMove(targets[0]);
let ability = Dex.getAbility(targets[0]);
let format = Dex.getFormat(targets[0]);
let atLeastOne = false;
let generation = (targets[1] || 'sm').trim().toLowerCase();
let genNumber = 7;
let extraFormat = Dex.getFormat(targets[2]);
if (['7', 'gen7', 'seven', 'sm', 'sumo', 'usm', 'usum'].includes(generation)) {
generation = 'sm';
} else if (['6', 'gen6', 'oras', 'six', 'xy'].includes(generation)) {
generation = 'xy';
genNumber = 6;
} else if (['5', 'b2w2', 'bw', 'bw2', 'five', 'gen5'].includes(generation)) {
generation = 'bw';
genNumber = 5;
} else if (['4', 'dp', 'dpp', 'four', 'gen4', 'hgss'].includes(generation)) {
generation = 'dp';
genNumber = 4;
} else if (['3', 'adv', 'frlg', 'gen3', 'rs', 'rse', 'three'].includes(generation)) {
generation = 'rs';
genNumber = 3;
} else if (['2', 'gen2', 'gs', 'gsc', 'two'].includes(generation)) {
generation = 'gs';
genNumber = 2;
} else if (['1', 'gen1', 'one', 'rb', 'rby', 'rgy'].includes(generation)) {
generation = 'rb';
genNumber = 1;
} else {
generation = 'sm';
}
// Pokemon
if (pokemon.exists) {
atLeastOne = true;
if (genNumber < pokemon.gen) {
return this.sendReplyBox(`${pokemon.name} did not exist in ${generation.toUpperCase()}!`);
}
if ((pokemon.battleOnly && pokemon.baseSpecies !== 'Greninja') || pokemon.baseSpecies === 'Keldeo' || pokemon.baseSpecies === 'Genesect') {
pokemon = Dex.getTemplate(pokemon.baseSpecies);
}
let formatName = extraFormat.name;
let formatId = extraFormat.id;
if (formatName.startsWith('[Gen ')) {
formatName = formatName.replace('[Gen ' + formatName[formatName.indexOf('[') + 5] + '] ', '');
formatId = toId(formatName);
}
if (formatId === 'battlespotdoubles') {
formatId = 'battle_spot_doubles';
} else if (formatId === 'battlespottriples') {
formatId = 'battle_spot_triples';
if (genNumber > 6) {
return this.sendReplyBox(`Triples formats are not an available format in Pokémon generation ${generation.toUpperCase()}.`);
}
} else if (formatId === 'doublesou') {
formatId = 'doubles';
} else if (formatId === 'balancedhackmons') {
formatId = 'bh';
} else if (formatId === 'battlespotsingles') {
formatId = 'battle_spot_singles';
} else if (formatId === 'ubers') {
formatId = 'uber';
} else if (formatId.includes('vgc')) {
formatId = 'vgc' + formatId.slice(-2);
formatName = 'VGC20' + formatId.slice(-2);
} else if (extraFormat.effectType !== 'Format') {
formatName = formatId = '';
}
const supportedLanguages = {
spanish: 'es',
french: 'fr',
italian: 'it',
german: 'de',
portuguese: 'pt',
};
let speciesid = pokemon.speciesid;
// Special case for Meowstic-M
if (speciesid === 'meowstic') speciesid = 'meowsticm';
if (['ou', 'uu'].includes(formatId) && generation === 'sm' && room && room.language in supportedLanguages) {
// Limited support for translated analysis
// Translated analysis do not support automatic redirects from a speciesid to the proper page
this.sendReplyBox(`${generation.toUpperCase()} ${Chat.escapeHTML(formatName)} ${pokemon.name} analysis, brought to you by Smogon University`);
} else if (['ou', 'uu'].includes(formatId) && generation === 'sm') {
this.sendReplyBox(`${generation.toUpperCase()} ${Chat.escapeHTML(formatName)} ${pokemon.name} analysis, brought to you by Smogon University ` +
`Other languages: Español, Français, Italiano, ` +
`Deutsch, Português`
);
} else {
this.sendReplyBox(`${generation.toUpperCase()} ${Chat.escapeHTML(formatName)} ${pokemon.name} analysis, brought to you by Smogon University`);
}
}
// Item
if (item.exists && genNumber > 1 && item.gen <= genNumber) {
atLeastOne = true;
this.sendReplyBox(`${generation.toUpperCase()} ${item.name} item analysis, brought to you by Smogon University`);
}
// Ability
if (ability.exists && genNumber > 2 && ability.gen <= genNumber) {
atLeastOne = true;
this.sendReplyBox(`${generation.toUpperCase()} ${ability.name} ability analysis, brought to you by Smogon University`);
}
// Move
if (move.exists && move.gen <= genNumber) {
atLeastOne = true;
this.sendReplyBox(`${generation.toUpperCase()} ${move.name} move analysis, brought to you by Smogon University`);
}
// Format
if (format.id) {
let formatName = format.name;
let formatId = format.id;
if (formatId === 'battlespotdoubles') {
formatId = 'battle_spot_doubles';
} else if (formatId === 'battlespottriples') {
formatId = 'battle_spot_triples';
if (genNumber > 6) {
return this.sendReplyBox(`Triples formats are not an available format in Pokémon generation ${generation.toUpperCase()}.`);
}
} else if (formatId === 'doublesou') {
formatId = 'doubles';
} else if (formatId === 'balancedhackmons') {
formatId = 'bh';
} else if (formatId === 'battlespotsingles') {
formatId = 'battle_spot_singles';
} else if (formatId === 'ubers') {
formatId = 'uber';
} else if (formatId.includes('vgc')) {
formatId = 'vgc' + formatId.slice(-2);
formatName = 'VGC20' + formatId.slice(-2);
} else if (format.effectType !== 'Format') {
formatName = formatId = '';
}
if (formatName) {
atLeastOne = true;
this.sendReplyBox(`${generation.toUpperCase()} ${Chat.escapeHTML(formatName)} format analysis, brought to you by Smogon University`);
}
}
if (!atLeastOne) {
return this.sendReplyBox(`Pokémon, item, move, ability, or format not found for generation ${generation.toUpperCase()}.`);
}
},
smogdexhelp: [
`/analysis [pokemon], [generation], [format] - Links to the Smogon University analysis for this Pok\u00e9mon in the given generation.`,
`!analysis [pokemon], [generation], [format] - Shows everyone this link. Requires: + % @ # & ~`,
],
'!veekun': true,
veekun(target, broadcast, user) {
if (!target) return this.parse('/help veekun');
if (!this.runBroadcast()) return;
let baseLink = 'http://veekun.com/dex/';
let pokemon = Dex.getTemplate(target);
let item = Dex.getItem(target);
let move = Dex.getMove(target);
let ability = Dex.getAbility(target);
let nature = Dex.getNature(target);
let atLeastOne = false;
// Pokemon
if (pokemon.exists) {
atLeastOne = true;
if (pokemon.isNonstandard) return this.errorReply(`${pokemon.species} is not a real Pok\u00e9mon.`);
let baseSpecies = pokemon.baseSpecies || pokemon.species;
let forme = pokemon.forme;
// Showdown and Veekun have different names for various formes
if (baseSpecies === 'Meowstic' && forme === 'F') forme = 'Female';
if (baseSpecies === 'Zygarde' && forme === '10%') forme = '10';
if (baseSpecies === 'Necrozma' && !Dex.getTemplate(baseSpecies + forme).battleOnly) forme = forme.substr(0, 4);
if (baseSpecies === 'Pikachu' && Dex.getTemplate(baseSpecies + forme).gen === 7) forme += '-Cap';
if (forme.endsWith('Totem')) {
if (baseSpecies === 'Raticate') forme = 'Totem-Alola';
if (baseSpecies === 'Marowak') forme = 'Totem';
if (baseSpecies === 'Mimikyu') forme += forme === 'Busted-Totem' ? '-Busted' : '-Disguised';
}
let link = baseLink + 'pokemon/' + baseSpecies.toLowerCase();
if (forme) {
if (baseSpecies === 'Arceus' || baseSpecies === 'Silvally') link += '/flavor';
link += '?form=' + forme.toLowerCase();
}
this.sendReplyBox(`${pokemon.species} description by Veekun`);
}
// Item
if (item.exists) {
atLeastOne = true;
if (item.isNonstandard) return this.errorReply(`${item.name} is not a real item.`);
let link = baseLink + 'items/' + item.name.toLowerCase();
this.sendReplyBox(`${item.name} item description by Veekun`);
}
// Ability
if (ability.exists) {
atLeastOne = true;
if (ability.isNonstandard) return this.errorReply(`${ability.name} is not a real ability.`);
let link = baseLink + 'abilities/' + ability.name.toLowerCase();
this.sendReplyBox(`${ability.name} ability description by Veekun`);
}
// Move
if (move.exists) {
atLeastOne = true;
if (move.isNonstandard) return this.errorReply(`${move.name} is not a real move.`);
let link = baseLink + 'moves/' + move.name.toLowerCase();
this.sendReplyBox(`${move.name} move description by Veekun`);
}
// Nature
if (nature.exists) {
atLeastOne = true;
let link = baseLink + 'natures/' + nature.name.toLowerCase();
this.sendReplyBox(`${nature.name} nature description by Veekun`);
}
if (!atLeastOne) {
return this.sendReplyBox(`Pokémon, item, move, ability, or nature not found.`);
}
},
veekunhelp: [
`/veekun [pokemon] - Links to Veekun website for this pokemon/item/move/ability/nature.`,
`!veekun [pokemon] - Shows everyone this link. Requires: + % @ # & ~`,
],
'!register': true,
register() {
if (!this.runBroadcast()) return;
this.sendReplyBox(`You will be prompted to register upon winning a rated battle. Alternatively, there is a register button in the menu in the upper right.`);
},
/*********************************************************
* Miscellaneous commands
*********************************************************/
potd(target, room, user) {
if (!this.can('potd')) return false;
Config.potd = target;
// TODO: support eval in new PM
Rooms.PM.eval('Config.potd = \'' + toId(target) + '\'');
if (target) {
if (Rooms.lobby) Rooms.lobby.addRaw(`
The Pokémon of the Day is now ${target}! This Pokemon will be guaranteed to show up in random battles.
`);
this.modlog('POTD', null, target);
} else {
if (Rooms.lobby) Rooms.lobby.addRaw(`
The Pokémon of the Day was removed! No pokemon will be guaranteed in random battles.
`);
this.modlog('POTD', null, 'removed');
}
},
'!dice': true,
roll: 'dice',
dice(target, room, user) {
if (!target || target.match(/[^\d\sdHL+-]/i)) return this.parse('/help dice');
if (!this.runBroadcast(true)) return;
// ~30 is widely regarded as the sample size required for sum to be a Gaussian distribution.
// This also sets a computation time constraint for safety.
let maxDice = 40;
let diceQuantity = 1;
let diceDataStart = target.indexOf('d');
if (diceDataStart >= 0) {
if (diceDataStart) diceQuantity = Number(target.slice(0, diceDataStart));
target = target.slice(diceDataStart + 1);
if (!Number.isInteger(diceQuantity) || diceQuantity <= 0 || diceQuantity > maxDice) return this.sendReply(`The amount of dice rolled should be a natural number up to ${maxDice}.`);
}
let offset = 0;
let removeOutlier = 0;
let modifierData = target.match(/[+-]/);
if (modifierData) {
switch (target.slice(modifierData.index).trim().toLowerCase()) {
case '-l':
removeOutlier = -1;
break;
case '-h':
removeOutlier = +1;
break;
default:
offset = Number(target.slice(modifierData.index));
if (isNaN(offset)) return this.parse('/help dice');
if (!Number.isSafeInteger(offset)) return this.errorReply(`The specified offset must be an integer up to ${Number.MAX_SAFE_INTEGER}.`);
}
if (removeOutlier && diceQuantity <= 1) return this.errorReply(`More than one dice should be rolled before removing outliers.`);
target = target.slice(0, modifierData.index);
}
let diceFaces = 6;
if (target.length) {
diceFaces = Number(target);
if (!Number.isSafeInteger(diceFaces) || diceFaces <= 0) {
return this.errorReply(`Rolled dice must have a natural amount of faces up to ${Number.MAX_SAFE_INTEGER}.`);
}
}
if (diceQuantity > 1) {
// Make sure that we can deal with high rolls
if (!Number.isSafeInteger(offset < 0 ? diceQuantity * diceFaces : diceQuantity * diceFaces + offset)) {
return this.errorReply(`The maximum sum of rolled dice must be lower or equal than ${Number.MAX_SAFE_INTEGER}.`);
}
}
let maxRoll = 0;
let minRoll = Number.MAX_SAFE_INTEGER;
let trackRolls = diceQuantity * (('' + diceFaces).length + 1) <= 60;
let rolls = [];
let rollSum = 0;
for (let i = 0; i < diceQuantity; ++i) {
let curRoll = Math.floor(Math.random() * diceFaces) + 1;
rollSum += curRoll;
if (curRoll > maxRoll) maxRoll = curRoll;
if (curRoll < minRoll) minRoll = curRoll;
if (trackRolls) rolls.push(curRoll);
}
// Apply modifiers
if (removeOutlier > 0) {
rollSum -= maxRoll;
} else if (removeOutlier < 0) {
rollSum -= minRoll;
}
if (offset) rollSum += offset;
// Reply with relevant information
let offsetFragment = "";
if (offset) offsetFragment += (offset > 0 ? " + " + offset : offset);
if (diceQuantity === 1) return this.sendReplyBox(`Rolling (1 to ${diceFaces})${offsetFragment}: ${rollSum}`);
const outlierFragment = removeOutlier ? ` except ${removeOutlier > 0 ? "highest" : "lowest"}` : ``;
const rollsFragment = trackRolls ? ": " + rolls.join(", ") : "";
return this.sendReplyBox(
`${diceQuantity} rolls (1 to ${diceFaces})${rollsFragment} ` +
`Sum${offsetFragment}${outlierFragment}: ${rollSum}`
);
},
dicehelp: [
`/dice [max number] - Randomly picks a number between 1 and the number you choose.`,
`/dice [number of dice]d[number of sides] - Simulates rolling a number of dice, e.g., /dice 2d4 simulates rolling two 4-sided dice.`,
`/dice [number of dice]d[number of sides][+/-][offset] - Simulates rolling a number of dice and adding an offset to the sum, e.g., /dice 2d6+10: two standard dice are rolled; the result lies between 12 and 22.`,
`/dice [number of dice]d[number of sides]-[H/L] - Simulates rolling a number of dice with removal of extreme values, e.g., /dice 3d8-L: rolls three 8-sided dice; the result ignores the lowest value.`,
],
'!pickrandom': true,
pr: 'pickrandom',
pick: 'pickrandom',
pickrandom(target, room, user) {
if (!target) return false;
if (!target.includes(',')) return this.parse('/help pick');
if (!this.runBroadcast(true)) return false;
if (this.broadcasting) {
[, target] = Chat.splitFirst(this.message, ' ');
}
let options = target.split(',');
const pickedOption = options[Math.floor(Math.random() * options.length)].trim();
return this.sendReplyBox(Chat.html`We randomly picked: ${pickedOption}`);
},
pickrandomhelp: [`/pick [option], [option], ... - Randomly selects an item from a list containing 2 or more elements.`],
showimage(target, room, user) {
if (!target) return this.parse('/help showimage');
if (!this.can('declare', null, room)) return false;
if (!this.runBroadcast()) return;
if (this.room.isPersonal && !this.user.can('announce')) {
return this.errorReply(`Images are not allowed in personal rooms.`);
}
let targets = target.split(',');
if (targets.length !== 1 && targets.length !== 3) {
return this.parse('/help showimage');
}
let image = targets[0].trim();
if (!image) return this.errorReply(`No image URL was provided!`);
image = this.canEmbedURI(image);
if (!image) return false;
if (targets.length === 3) {
let width = targets[1].trim();
if (!width) return this.errorReply(`No width for the image was provided!`);
if (!isNaN(width)) width += `px`;
let height = targets[2].trim();
if (!height) return this.errorReply(`No height for the image was provided!`);
if (!isNaN(height)) height += `px`;
let unitRegex = /^\d+(?:p[xtc]|%|[ecm]m|ex|in)$/;
if (!unitRegex.test(width)) {
return this.errorReply(`"${width}" is not a valid width value!`);
}
if (!unitRegex.test(height)) {
return this.errorReply(`"${height}" is not a valid height value!`);
}
return this.sendReply(Chat.html`|raw|`);
}
Chat.fitImage(image).then(([width, height]) => {
this.sendReply(Chat.html`|raw|`);
room.update();
});
},
showimagehelp: [`/showimage [url], [width], [height] - Show an image. Any CSS units may be used for the width or height (default: px). If width and height aren't provided, automatically scale the image to fit in chat. Requires: # & ~`],
htmlbox(target, room, user) {
if (!target) return this.parse('/help htmlbox');
target = this.canHTML(target);
if (!target) return;
target = Chat.collapseLineBreaksHTML(target);
if (!this.canBroadcast(true, '!htmlbox')) return;
if (this.broadcastMessage && !this.can('declare', null, room)) return false;
if (!this.runBroadcast(true, '!htmlbox')) return;
this.sendReplyBox(target);
},
htmlboxhelp: [
`/htmlbox [message] - Displays a message, parsing HTML code contained.`,
`!htmlbox [message] - Shows everyone a message, parsing HTML code contained. Requires: * # & ~`,
],
addhtmlbox(target, room, user, connection, cmd) {
if (!target) return this.parse('/help ' + cmd);
if (!this.canTalk()) return;
target = this.canHTML(target);
if (!target) return;
if (!this.can('addhtml', null, room)) return;
target = Chat.collapseLineBreaksHTML(target);
if (!user.can('addhtml')) {
target += Chat.html`
[${user.name}]
`;
}
this.addBox(target);
},
addhtmlboxhelp: [
`/addhtmlbox [message] - Shows everyone a message, parsing HTML code contained. Requires: * & ~`,
],
addrankhtmlbox(target, room, user, connection, cmd) {
if (!target) return this.parse('/help ' + cmd);
if (!this.canTalk()) return;
let [rank, html] = this.splitOne(target);
if (!(rank in Config.groups)) return this.errorReply(`Group '${rank}' does not exist.`);
html = this.canHTML(html);
if (!html) return;
if (!this.can('addhtml', null, room)) return;
html = Chat.collapseLineBreaksHTML(html);
if (!user.can('addhtml')) {
html += Chat.html`
[${user.name}]
`;
}
this.room.sendRankedUsers(`|html|
${html}
`, rank);
},
addrankhtmlboxhelp: [
`/addrankhtmlbox [rank], [message] - Shows everyone with the specified rank or higher a message, parsing HTML code contained. Requires: * & ~`,
],
changeuhtml: 'adduhtml',
adduhtml(target, room, user, connection, cmd) {
if (!target) return this.parse('/help ' + cmd);
if (!this.canTalk()) return;
let [name, html] = this.splitOne(target);
name = toId(name);
html = this.canHTML(html);
if (!html) return;
if (!this.can('addhtml', null, room)) return;
html = Chat.collapseLineBreaksHTML(html);
if (!user.can('addhtml')) {
html += Chat.html`
[${user.name}]
`;
}
html = `|uhtml${(cmd === 'changeuhtml' ? 'change' : '')}|${name}|${html}`;
this.add(html);
},
adduhtmlhelp: [
`/adduhtml [name], [message] - Shows everyone a message that can change, parsing HTML code contained. Requires: * & ~`,
],
changeuhtmlhelp: [
`/changeuhtml [name], [message] - Changes the message previously shown with /adduhtml [name]. Requires: * & ~`,
],
changerankuhtml: 'addrankuhtml',
addrankuhtml(target, room, user, connection, cmd) {
if (!target) return this.parse('/help ' + cmd);
if (!this.canTalk()) return;
let [rank, uhtml] = this.splitOne(target);
if (!(rank in Config.groups)) return this.errorReply(`Group '${rank}' does not exist.`);
let [name, html] = this.splitOne(uhtml);
name = toId(name);
html = this.canHTML(html);
if (!html) return;
if (!this.can('addhtml', null, room)) return;
html = Chat.collapseLineBreaksHTML(html);
if (!user.can('addhtml')) {
html += Chat.html`
[${user.name}]
`;
}
html = `|uhtml${(cmd === 'changerankuhtml' ? 'change' : '')}|${name}|${html}`;
this.room.sendRankedUsers(html, rank);
},
addrankuhtmlhelp: [
`/addrankuhtml [rank], [name], [message] - Shows everyone with the specified rank or higher a message that can change, parsing HTML code contained. Requires: * & ~`,
],
changerankuhtmlhelp: [
`/changerankuhtml [rank], [name], [message] - Changes the message previously shown with /addrankuhtml [rank], [name]. Requires: * & ~`,
],
};
/** @type {PageTable} */
const pages = {
punishments(query, user, connection) {
this.title = 'Punishments';
let buf = "";
this.extractRoom();
if (!user.named) return Rooms.RETRY_AFTER_LOGIN;
buf += `
List of active punishments:
`;
if (!this.can('mute', null, this.room)) return;
if (!this.room.chatRoomData) {
return buf + `
This page is unavailable in temporary rooms / non-existent rooms.
`;
}
const store = new Map();
const possessive = (word) => {
const suffix = word.endsWith('s') ? `'` : `'s`;
return `${word}${suffix}`;
};
if (Punishments.roomUserids.get(this.room.id)) {
for (let [key, value] of Punishments.roomUserids.get(this.room.id)) {
if (!store.has(value)) store.set(value, [new Set([value.id]), new Set()]);
store.get(value)[0].add(key);
}
}
if (Punishments.roomIps.get(this.room.id)) {
for (let [key, value] of Punishments.roomIps.get(this.room.id)) {
if (!store.has(value)) store.set(value, [new Set([value.id]), new Set()]);
store.get(value)[1].add(key);
}
}
for (const [punishment, data] of store) {
let [punishType, id, expireTime, reason] = punishment;
let alts = [...data[0]].filter(user => user !== id);
let ip = [...data[1]];
let expiresIn = new Date(expireTime).getTime() - Date.now();
let expireString = Chat.toDurationString(expiresIn, {precision: 1});
let punishDesc = "";
if (reason) punishDesc += ` Reason: ${reason}.`;
if (alts.length) punishDesc += ` Alts: ${alts.join(", ")}.`;
if (user.can('ban') && ip.length) {
punishDesc += ` IPs: ${ip.join(", ")}.`;
}
buf += `
- ${possessive(id)} ${punishType.toLowerCase()} expires in ${expireString}.${punishDesc}
`;
}
if (this.room.muteQueue) {
for (const entry of this.room.muteQueue) {
let expiresIn = new Date(entry.time).getTime() - Date.now();
if (expiresIn < 0) continue;
let expireString = Chat.toDurationString(expiresIn, {precision: 1});
buf += `
- ${possessive(entry.userid)} mute expires in ${expireString}.