This Pokemon Showdown battle has frozen! Don't worry, we're working on fixing it, so just carry on like you never saw this. (Do not report this, this is intended.)
`);
- },
- },
- vooper: {
- noCopy: true,
- onStart() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('vooper')}|${this.sample(['Paws out, claws out!', 'Ready for the prowl!'])}`);
- },
- onSwitchOut() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('vooper')}|Must... eat... bamboo...`);
- },
- onFaint() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('vooper')}|I guess Kung Fu isn't for everyone...`);
- },
- },
- yuki: {
- noCopy: true,
- onStart(target, pokemon) {
- let bst = 0;
- for (const stat of Object.values(pokemon.species.baseStats)) {
- bst += stat;
- }
- let targetBst = 0;
- for (const stat of Object.values(target.species.baseStats)) {
- targetBst += stat;
- }
- let message: string;
- if (bst > targetBst) {
- message = 'You dare challenge me!?';
- } else {
- message = 'Sometimes, you go for it';
- }
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('yuki')}|${message}`);
- },
- onSwitchOut() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('yuki')}|Catch me if you can!`);
- },
- onFaint() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('yuki')}|You'll never extinguish our hopes!`);
- },
- },
- zalm: {
- noCopy: true,
- onStart() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zalm')}|<(:O)000>`);
- },
- onSwitchOut() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zalm')}|Run for the hills!`);
- },
- onFaint() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zalm')}|Woah`);
- },
- },
- zarel: {
- noCopy: true,
- onStart() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zarel')}|the melo-p represents PS's battles, and the melo-a represents PS's chatrooms`);
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zarel')}|THIS melo-a represents kicking your ass, though`);
- },
- },
- zodiax: {
- noCopy: true,
- onStart(source) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zodiax')}|Zodiax is here to Zodihax`);
-
- // Easter Egg
- const activeMon = this.toID(
- source.side.foe.active[0].illusion ? source.side.foe.active[0].illusion.name : source.side.foe.active[0].name
- );
- if (activeMon === 'aeonic') {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zodiax')}|Happy Birthday Aeonic`);
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Aeonic')}|THIS JOKE IS AS BORING AS YOU ARE`);
- }
- },
- onSwitchOut() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zodiax')}|Don't worry I'll be back again`);
- },
- onFaint(pokemon) {
- const name = pokemon.side.foe.name;
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zodiax')}|${name}, Why would you hurt this poor little pompombirb :(`);
- },
- },
- zyguser: {
- noCopy: true,
- onStart() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zyg')}|Free Swirlyder.`);
- },
- onSwitchOut() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zyg')}|/me sighs... what is there to say?`);
- },
- onFaint() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Zyg')}|At least I have a tier.`);
- },
- },
- // Heavy Hailstorm status support for Alpha
- heavyhailstorm: {
- name: 'HeavyHailstorm',
- effectType: 'Weather',
- duration: 0,
- onTryMovePriority: 1,
- onTryMove(attacker, defender, move) {
- if (move.type === 'Steel' && move.category !== 'Status') {
- this.debug('Heavy Hailstorm Steel suppress');
- this.add('-message', 'The hail suppressed the move!');
- this.add('-fail', attacker, move, '[from] Heavy Hailstorm');
- this.attrLastMove('[still]');
- return null;
- }
- },
- onWeatherModifyDamage(damage, attacker, defender, move) {
- if (move.type === 'Ice') {
- this.debug('Heavy Hailstorm ice boost');
- return this.chainModify(1.5);
- }
- },
- onFieldStart(field, source, effect) {
- this.add('-weather', 'Hail', '[from] ability: ' + effect, '[of] ' + source);
- this.add('-message', 'The hail became extremely chilling!');
- },
- onModifyMove(move, pokemon, target) {
- if (!this.field.isWeather('heavyhailstorm')) return;
- if (move.category !== "Status") {
- this.debug('Adding Heavy Hailstorm freeze');
- if (!move.secondaries) move.secondaries = [];
- for (const secondary of move.secondaries) {
- if (secondary.status === 'frz') return;
- }
- move.secondaries.push({
- chance: 10,
- status: 'frz',
- });
- }
- },
- onFieldResidualOrder: 1,
- onFieldResidual() {
- this.add('-weather', 'Hail', '[upkeep]');
- if (this.field.isWeather('heavyhailstorm')) this.eachEvent('Weather');
- },
- onWeather(target, source, effect) {
- if (target.isAlly(this.effectState.source)) return;
- // Hail is stronger from Heavy Hailstorm
- if (!target.hasType('Ice')) this.damage(target.baseMaxhp / 8);
- },
- onFieldEnd() {
- this.add('-weather', 'none');
- },
- },
- // Forever Winter Hail support for piloswine gripado
- winterhail: {
- name: 'Winter Hail',
- effectType: 'Weather',
- duration: 0,
- onFieldStart(field, source, effect) {
- this.add('-weather', 'Hail', '[from] ability: ' + effect, '[of] ' + source);
- this.add('-message', 'It became winter!');
- },
- onModifySpe(spe, pokemon) {
- if (!pokemon.hasType('Ice')) return this.chainModify(0.5);
- },
- onFieldResidualOrder: 1,
- onFieldResidual() {
- this.add('-weather', 'Hail', '[upkeep]');
- if (this.field.isWeather('winterhail')) this.eachEvent('Weather');
- },
- onWeather(target) {
- if (target.hasType('Ice')) return;
- this.damage(target.baseMaxhp / 8);
- },
- onFieldEnd() {
- this.add('-weather', 'none');
- },
- },
- raindrop: {
- name: 'Raindrop',
- noCopy: true,
- onStart(target) {
- this.effectState.layers = 1;
- this.effectState.def = 0;
- this.effectState.spd = 0;
- this.add('-start', target, 'Raindrop');
- this.add('-message', `${target.name} has ${this.effectState.layers} raindrop(s)!`);
- const [curDef, curSpD] = [target.boosts.def, target.boosts.spd];
- this.boost({def: 1, spd: 1}, target, target);
- if (curDef !== target.boosts.def) this.effectState.def--;
- if (curSpD !== target.boosts.spd) this.effectState.spd--;
- },
- onRestart(target) {
- this.effectState.layers++;
- this.add('-start', target, 'Raindrop');
- this.add('-message', `${target.name} has ${this.effectState.layers} raindrop(s)!`);
- const curDef = target.boosts.def;
- const curSpD = target.boosts.spd;
- this.boost({def: 1, spd: 1}, target, target);
- if (curDef !== target.boosts.def) this.effectState.def--;
- if (curSpD !== target.boosts.spd) this.effectState.spd--;
- },
- onEnd(target) {
- if (this.effectState.def || this.effectState.spd) {
- const boosts: SparseBoostsTable = {};
- if (this.effectState.def) boosts.def = this.effectState.def;
- if (this.effectState.spd) boosts.spd = this.effectState.spd;
- this.boost(boosts, target, target);
- }
- this.add('-end', target, 'Raindrop');
- if (this.effectState.def !== this.effectState.layers * -1 || this.effectState.spd !== this.effectState.layers * -1) {
- this.hint("Raindrop keeps track of how many times it successfully altered each stat individually.");
- }
- },
- },
- // Brilliant Condition for Arcticblast
- brilliant: {
- name: 'Brilliant',
- duration: 5,
- onStart(pokemon) {
- this.add('-start', pokemon, 'Brilliant');
- },
- onModifyAtk() {
- return this.chainModify(1.5);
- },
- onModifyDef() {
- return this.chainModify(1.5);
- },
- onModifySpA() {
- return this.chainModify(1.5);
- },
- onModifySpD() {
- return this.chainModify(1.5);
- },
- onModifySpe() {
- return this.chainModify(1.5);
- },
- onUpdate(pokemon) {
- if (pokemon.volatiles['perishsong']) pokemon.removeVolatile('perishsong');
- },
- onTryAddVolatile(status) {
- if (status.id === 'perishsong') return null;
- },
- onResidualOrder: 7,
- onResidual(pokemon) {
- this.heal(pokemon.baseMaxhp / 16);
- },
- onTrapPokemon(pokemon) {
- pokemon.tryTrap();
- },
- onDragOut(pokemon) {
- this.add('-activate', pokemon, 'move: Ingrain');
- return null;
- },
- onEnd(pokemon) {
- this.add('-end', pokemon, 'Brilliant');
- },
- },
- // Custom status for HoeenHero's move
- stormsurge: {
- name: "Storm Surge",
- duration: 2,
- durationCallback(target, source, effect) {
- const windSpeeds = [65, 85, 95, 115, 140];
- return windSpeeds.indexOf((effect as ActiveMove).basePower) + 2;
- },
- onSideStart(targetSide) {
- this.add('-sidestart', targetSide, 'Storm Surge');
- this.add('-message', `Storm Surge flooded the afflicted side of the battlefield!`);
- },
- onEnd(targetSide) {
- this.add('-sideend', targetSide, 'Storm Surge');
- this.add('-message', 'The Storm Surge receded.');
- },
- onModifySpe() {
- return this.chainModify(0.75);
- },
- },
- // Kipkluif, needs to end in mod to not trigger aelita's effect
- degeneratormod: {
- onBeforeSwitchOut(pokemon) {
- let alreadyAdded = false;
- for (const source of this.effectState.sources) {
- if (!source.hp || source.volatiles['gastroacid']) continue;
- if (!alreadyAdded) {
- const foe = pokemon.side.foe.active[0];
- if (foe) this.add('-activate', foe, 'ability: Degenerator');
- alreadyAdded = true;
- }
- this.damage((pokemon.baseMaxhp * 33) / 100, pokemon);
- }
- },
- },
- // For ravioliqueen
- haunting: {
- name: 'Haunting',
- onTrapPokemon(pokemon) {
- pokemon.tryTrap();
- },
- onStart(target) {
- this.add('-start', target, 'Haunting');
- },
- onResidualOrder: 11,
- onResidual(pokemon) {
- this.damage(pokemon.baseMaxhp / 8);
- },
- onEnd(pokemon) {
- this.add('-end', pokemon, 'Haunting');
- },
- },
- // for pants' move
- givewistfulthinking: {
- duration: 1,
- onSwitchInPriority: 1,
- onSwitchIn(pokemon) {
- pokemon.addVolatile('wistfulthinking');
- },
- },
- // focus punch effect for litt's move
- nexthuntcheck: {
- duration: 1,
- onStart(pokemon) {
- this.add('-singleturn', pokemon, 'move: /nexthunt');
- },
- onHit(pokemon, source, move) {
- if (move.category !== 'Status') {
- pokemon.volatiles['nexthuntcheck'].lostFocus = true;
- }
- },
- },
- // For Gmars' Effects
- minior: {
- noCopy: true,
- name: 'Minior',
- // Special Forme Effects
- onBeforeMove(pokemon) {
- if (pokemon.set.shiny) return;
- if (pokemon.species.id === "miniorviolet") {
- this.add(`${getName("GMars")} is thinking...`);
- if (this.randomChance(1, 3)) {
- this.add('cant', pokemon, 'ability: Truant');
- return false;
- }
- }
- },
- onSwitchIn(pokemon) {
- if (pokemon.set.shiny) return;
- if (pokemon.species.id === 'miniorindigo') {
- this.boost({atk: 1, spa: 1}, pokemon.side.foe.active[0]);
- } else if (pokemon.species.id === 'miniorgreen') {
- this.boost({atk: 1}, pokemon);
- }
- },
- onTryBoost(boost, target, source, effect) {
- if (target.set.shiny) return;
- if (source && target === source) return;
- if (target.species.id !== 'miniorblue') return;
- let showMsg = false;
- let i: BoostID;
- for (i in boost) {
- if (boost[i]! < 0) {
- delete boost[i];
- showMsg = true;
- }
- }
- if (showMsg && !(effect as ActiveMove).secondaries && effect.id !== 'octolock') {
- this.add('message', 'Minior is translucent!');
- }
- },
- onFoeTryMove(target, source, move) {
- if (move.id === 'haze' && target.species.id === 'miniorblue' && !target.set.shiny) {
- move.onHitField = function (this: Battle) {
- this.add('-clearallboost');
- for (const pokemon of this.getAllActive()) {
- if (pokemon.species.id === 'miniorblue') continue;
- pokemon.clearBoosts();
- }
- }.bind(this);
- return;
- }
- const dazzlingHolder = this.effectState.target;
- if (!dazzlingHolder.set.shiny) return;
- if (dazzlingHolder.species.id !== 'minior') return;
- const targetAllExceptions = ['perishsong', 'flowershield', 'rototiller'];
- if (move.target === 'foeSide' || (move.target === 'all' && !targetAllExceptions.includes(move.id))) {
- return;
- }
-
- if ((source.isAlly(dazzlingHolder) || move.target === 'all') && move.priority > 0.1) {
- this.attrLastMove('[still]');
- this.add('message', 'Minior dazzles!');
- this.add('cant', target, move, '[of] ' + dazzlingHolder);
- return false;
- }
- },
- },
- // modified paralysis for Inversion Terrain
- par: {
- name: 'par',
- effectType: 'Status',
- onStart(target, source, sourceEffect) {
- if (sourceEffect && sourceEffect.effectType === 'Ability') {
- this.add('-status', target, 'par', '[from] ability: ' + sourceEffect.name, '[of] ' + source);
- } else {
- this.add('-status', target, 'par');
- }
- },
- onModifySpe(spe, pokemon) {
- if (pokemon.hasAbility('quickfeet')) return;
- if (this.field.isTerrain('inversionterrain') && pokemon.isGrounded()) {
- return this.chainModify(2);
- }
- return this.chainModify(0.5);
- },
- onBeforeMovePriority: 1,
- onBeforeMove(pokemon) {
- if (this.randomChance(1, 4)) {
- this.add('cant', pokemon, 'par');
- return false;
- }
- },
- },
- bigstormcomingmod: {
- name: "Big Storm Coming Mod",
- duration: 1,
- onBasePower() {
- return this.chainModify([1229, 4096]);
- },
- },
-
- // condition used for brouha's ability
- turbulence: {
- name: 'Turbulence',
- effectType: 'Weather',
- duration: 0,
- onFieldStart(field, source, effect) {
- this.add('-weather', 'DeltaStream', '[from] ability: ' + effect, '[of] ' + source);
- },
- onFieldResidualOrder: 1,
- onFieldResidual() {
- this.add('-weather', 'DeltaStream', '[upkeep]');
- this.eachEvent('Weather');
- },
- onWeather(target) {
- if (!target.hasType('Flying')) this.damage(target.baseMaxhp * 0.06);
- if (this.sides.some(side => Object.keys(side.sideConditions).length)) {
- this.add(`-message`, 'The Turbulence blew away the hazards on both sides!');
- }
- if (this.field.terrain) {
- this.add(`-message`, 'The Turbulence blew away the terrain!');
- }
- const silentRemove = ['reflect', 'lightscreen', 'auroraveil', 'safeguard', 'mist'];
- for (const side of this.sides) {
- const keys = Object.keys(side.sideConditions);
- for (const key of keys) {
- if (key.endsWith('mod') || key.endsWith('clause')) continue;
- side.removeSideCondition(key);
- if (!silentRemove.includes(key)) {
- this.add('-sideend', side, this.dex.conditions.get(key).name, '[from] ability: Turbulence');
- }
- }
- }
- this.field.clearTerrain();
- },
- onFieldEnd() {
- this.add('-weather', 'none');
- },
- },
- // Modded rain dance for Kev's ability
- raindance: {
- name: 'RainDance',
- effectType: 'Weather',
- duration: 5,
- durationCallback(source) {
- let newDuration = 5;
- let boostNum = 0;
- if (source?.hasItem('damprock')) {
- newDuration = 8;
- }
- if (source?.hasAbility('kingofatlantis')) {
- for (const teammate of source.side.pokemon) {
- if (teammate.hasType('Water') && teammate !== source) {
- boostNum++;
- }
- }
- }
- return newDuration + boostNum;
- },
- onWeatherModifyDamage(damage, attacker, defender, move) {
- if (defender.hasItem('utilityumbrella')) return;
- if (move.type === 'Water') {
- this.debug('rain water boost');
- return this.chainModify(1.5);
- }
- if (move.type === 'Fire') {
- this.debug('rain fire suppress');
- return this.chainModify(0.5);
- }
- },
- onFieldStart(field, source, effect) {
- if (effect?.effectType === 'Ability') {
- if (this.gen <= 5) this.effectState.duration = 0;
- this.add('-weather', 'RainDance', '[from] ability: ' + effect, '[of] ' + source);
- } else {
- this.add('-weather', 'RainDance');
- }
- },
- onFieldResidualOrder: 1,
- onFieldResidual() {
- this.add('-weather', 'RainDance', '[upkeep]');
- this.eachEvent('Weather');
- },
- onFieldEnd() {
- this.add('-weather', 'none');
- },
- },
- // Modded hazard moves to fail when Wave terrain is active
- auroraveil: {
- name: "Aurora Veil",
- duration: 5,
- durationCallback(target, source) {
- if (source?.hasItem('lightclay')) {
- return 8;
- }
- return 5;
- },
- onAnyModifyDamage(damage, source, target, move) {
- if (target !== source && this.effectState.target.hasAlly(target)) {
- if ((target.side.getSideCondition('reflect') && this.getCategory(move) === 'Physical') ||
- (target.side.getSideCondition('lightscreen') && this.getCategory(move) === 'Special')) {
- return;
- }
- if (!target.getMoveHitData(move).crit && !move.infiltrates) {
- this.debug('Aurora Veil weaken');
- if (this.activePerHalf > 1) return this.chainModify([2732, 4096]);
- return this.chainModify(0.5);
- }
- }
- },
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Aurora Veil from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'move: Aurora Veil');
- },
- onSideResidualOrder: 21,
- onSideResidualSubOrder: 1,
- onSideEnd(side) {
- this.add('-sideend', side, 'move: Aurora Veil');
- },
- },
- lightscreen: {
- name: "Light Screen",
- duration: 5,
- durationCallback(target, source) {
- if (source?.hasItem('lightclay')) {
- return 8;
- }
- return 5;
- },
- onAnyModifyDamage(damage, source, target, move) {
- if (target !== source && this.effectState.target.hasAlly(target) && this.getCategory(move) === 'Special') {
- if (!target.getMoveHitData(move).crit && !move.infiltrates) {
- this.debug('Light Screen weaken');
- if (this.activePerHalf > 1) return this.chainModify([2732, 4096]);
- return this.chainModify(0.5);
- }
- }
- },
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Light Screen from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'move: Light Screen');
- },
- onSideResidualOrder: 21,
- onSideResidualSubOrder: 1,
- onSideEnd(side) {
- this.add('-sideend', side, 'move: Light Screen');
- },
- },
- mist: {
- name: "Mist",
- duration: 5,
- onTryBoost(boost, target, source, effect) {
- if (effect.effectType === 'Move' && effect.infiltrates && !target.isAlly(source)) return;
- if (source && target !== source) {
- let showMsg = false;
- let i: BoostID;
- for (i in boost) {
- if (boost[i]! < 0) {
- delete boost[i];
- showMsg = true;
- }
- }
- if (showMsg && !(effect as ActiveMove).secondaries) {
- this.add('-activate', target, 'move: Mist');
- }
- }
- },
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Mist from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'move: Mist');
- },
- onSideResidualOrder: 21,
- onSideResidualSubOrder: 3,
- onSideEnd(side) {
- this.add('-sideend', side, 'Mist');
- },
- },
- reflect: {
- name: "Reflect",
- duration: 5,
- durationCallback(target, source) {
- if (source?.hasItem('lightclay')) {
- return 8;
- }
- return 5;
- },
- onAnyModifyDamage(damage, source, target, move) {
- if (target !== source && this.effectState.target.hasAlly(target) && this.getCategory(move) === 'Physical') {
- if (!target.getMoveHitData(move).crit && !move.infiltrates) {
- this.debug('Reflect weaken');
- if (this.activePerHalf > 1) return this.chainModify([2732, 4096]);
- return this.chainModify(0.5);
- }
- }
- },
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Reflect from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'Reflect');
- },
- onSideResidualOrder: 21,
- onSideEnd(side) {
- this.add('-sideend', side, 'Reflect');
- },
- },
- safeguard: {
- name: "Safeguard",
- duration: 5,
- durationCallback(target, source, effect) {
- if (source?.hasAbility('persistent')) {
- this.add('-activate', source, 'ability: Persistent', effect);
- return 7;
- }
- return 5;
- },
- onSetStatus(status, target, source, effect) {
- if (!effect || !source) return;
- if (effect.effectType === 'Move' && effect.infiltrates && !target.isAlly(source)) return;
- if (target !== source) {
- this.debug('interrupting setStatus');
- if (effect.id === 'synchronize' || (effect.effectType === 'Move' && !effect.secondaries)) {
- this.add('-activate', target, 'move: Safeguard');
- }
- return null;
- }
- },
- onTryAddVolatile(status, target, source, effect) {
- if (!effect || !source) return;
- if (effect.effectType === 'Move' && effect.infiltrates && !target.isAlly(source)) return;
- if ((status.id === 'confusion' || status.id === 'yawn') && target !== source) {
- if (effect.effectType === 'Move' && !effect.secondaries) this.add('-activate', target, 'move: Safeguard');
- return null;
- }
- },
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Safeguard from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'move: Safeguard');
- },
- onSideResidualOrder: 21,
- onSideResidualSubOrder: 2,
- onSideEnd(side) {
- this.add('-sideend', side, 'Safeguard');
- },
- },
- gmaxsteelsurge: {
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Steel Spikes from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'move: G-Max Steelsurge');
- },
- onEntryHazard(pokemon) {
- if (pokemon.hasItem('heavydutyboots')) return;
- // Ice Face and Disguise correctly get typed damage from Stealth Rock
- // because Stealth Rock bypasses Substitute.
- // They don't get typed damage from Steelsurge because Steelsurge doesn't,
- // so we're going to test the damage of a Steel-type Stealth Rock instead.
- const steelHazard = this.dex.getActiveMove('Stealth Rock');
- steelHazard.type = 'Steel';
- const typeMod = this.clampIntRange(pokemon.runEffectiveness(steelHazard), -6, 6);
- this.damage(pokemon.maxhp * Math.pow(2, typeMod) / 8);
- },
- },
- spikes: {
- name: "Spikes",
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Spikes from starting!`);
- return null;
- }
- this.effectState.layers = 1;
- this.add('-sidestart', side, 'move: Spikes');
- },
- onSideRestart(side) {
- if (this.effectState.layers >= 3) return false;
- this.add('-sidestart', side, 'Spikes');
- this.effectState.layers++;
- },
- onEntryHazard(pokemon) {
- if (!pokemon.isGrounded() || pokemon.hasItem('heavydutyboots')) return;
- const damageAmounts = [0, 3, 4, 6]; // 1/8, 1/6, 1/4
- this.damage(damageAmounts[this.effectState.layers] * pokemon.maxhp / 24);
- },
- },
- stealthrock: {
- name: "Stealth Rock",
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Stealth Rock from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'move: Stealth Rock');
- },
- onEntryHazard(pokemon) {
- if (pokemon.hasItem('heavydutyboots')) return;
- const typeMod = this.clampIntRange(pokemon.runEffectiveness(this.dex.getActiveMove('stealthrock')), -6, 6);
- this.damage(pokemon.maxhp * Math.pow(2, typeMod) / 8);
- },
- },
- stickyweb: {
- name: "Sticky Web",
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Sticky Web from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'move: Sticky Web');
- },
- onEntryHazard(pokemon) {
- if (!pokemon.isGrounded() || pokemon.hasItem('heavydutyboots')) return;
- this.add('-activate', pokemon, 'move: Sticky Web');
- this.boost({spe: -1}, pokemon, pokemon.side.foe.active[0], this.dex.getActiveMove('stickyweb'));
- },
- },
- toxicspikes: {
- name: "Toxic Spikes",
- onSideStart(side) {
- if (this.field.isTerrain('waveterrain')) {
- this.add('-message', `Wave Terrain prevented Toxic Spikes from starting!`);
- return null;
- }
- this.add('-sidestart', side, 'move: Toxic Spikes');
- this.effectState.layers = 1;
- },
- onSideRestart(side) {
- if (this.effectState.layers >= 2) return false;
- this.add('-sidestart', side, 'move: Toxic Spikes');
- this.effectState.layers++;
- },
- onEntryHazard(pokemon) {
- if (!pokemon.isGrounded()) return;
- if (pokemon.hasType('Poison')) {
- this.add('-sideend', pokemon.side, 'move: Toxic Spikes', '[of] ' + pokemon);
- pokemon.side.removeSideCondition('toxicspikes');
- } else if (pokemon.hasType('Steel') || pokemon.hasItem('heavydutyboots')) {
- return;
- } else if (this.effectState.layers >= 2) {
- pokemon.trySetStatus('tox', pokemon.side.foe.active[0]);
- } else {
- pokemon.trySetStatus('psn', pokemon.side.foe.active[0]);
- }
- },
- },
- frz: {
- inherit: true,
- onHit(target, source, move) {
- if (move.thawsTarget || move.type === 'Fire' && move.category !== 'Status') {
- target.cureStatus();
- if (move.id === 'randomscreaming') {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Gimmick')}|Give me some more paaain, baaaby`);
- }
- }
- },
- },
- // No, you're not dynamaxing.
- dynamax: {
- inherit: true,
- onStart(pokemon) {
- pokemon.removeVolatile('minimize');
- pokemon.removeVolatile('substitute');
- if (pokemon.volatiles['torment']) {
- delete pokemon.volatiles['torment'];
- this.add('-end', pokemon, 'Torment', '[silent]');
- }
- if (['cramorantgulping', 'cramorantgorging'].includes(pokemon.species.id) && !pokemon.transformed) {
- pokemon.formeChange('cramorant');
- }
- this.add('-start', pokemon, 'Dynamax');
- if (pokemon.gigantamax) this.add('-formechange', pokemon, pokemon.species.name + '-Gmax');
- if (pokemon.baseSpecies.name !== 'Shedinja') {
- // Changes based on dynamax level, 2 is max (at LVL 10)
- const ratio = this.format.id.startsWith('gen8doublesou') ? 1.5 : 2;
-
- pokemon.maxhp = Math.floor(pokemon.maxhp * ratio);
- pokemon.hp = Math.floor(pokemon.hp * ratio);
-
- this.add('-heal', pokemon, pokemon.getHealth, '[silent]');
- }
- this.add('-message', 'Ok. sure. Dynamax. Just abuse it and win the game already.');
- // This is just for fun, as dynamax cannot be in a rated battle.
- this.win(pokemon.side);
- },
- },
- echoedvoiceclone: {
- duration: 2,
- onFieldStart() {
- this.effectState.multiplier = 1;
- },
- onFieldRestart() {
- if (this.effectState.duration !== 2) {
- this.effectState.duration = 2;
- if (this.effectState.multiplier < 5) {
- this.effectState.multiplier++;
- }
- }
- },
- },
-};
diff --git a/data/mods/ssb/items.ts b/data/mods/ssb/items.ts
deleted file mode 100644
index 91b86e9baaaa..000000000000
--- a/data/mods/ssb/items.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-export const Items: {[k: string]: ModdedItemData} = {
- // Alpha
- caioniumz: {
- name: "Caionium Z",
- onTakeItem: false,
- zMove: "Blistering Ice Age",
- zMoveFrom: "Blizzard",
- itemUser: ["Aurorus"],
- gen: 8,
- desc: "If held by an Aurorus with Blizzard, it can use Blistering Ice Age.",
- },
-
- // A Quag To The Past
- quagniumz: {
- name: "Quagnium Z",
- onTakeItem: false,
- zMove: "Bounty Place",
- zMoveFrom: "Scorching Sands",
- itemUser: ["Quagsire"],
- gen: 8,
- desc: "If held by a Quagsire with Scorching Sands, it can use Bounty Place.",
- },
-
- // Kalalokki
- kalalokkiumz: {
- name: "Kalalokkium Z",
- onTakeItem: false,
- zMove: "Gaelstrom",
- zMoveFrom: "Blackbird",
- itemUser: ["Wingull"],
- gen: 8,
- desc: "If held by a Wingull with Blackbird, it can use Gaelstrom.",
- },
-
- // Robb576
- modium6z: {
- name: "Modium-6 Z",
- onTakeItem: false,
- zMove: "Integer Overflow",
- zMoveFrom: "Photon Geyser",
- itemUser: ["Necrozma-Ultra"],
- gen: 8,
- desc: "If held by a Robb576 with Photon Geyser, it can use Integer Overflow.",
- },
-};
diff --git a/data/mods/ssb/moves.ts b/data/mods/ssb/moves.ts
deleted file mode 100644
index d74fad078ea5..000000000000
--- a/data/mods/ssb/moves.ts
+++ /dev/null
@@ -1,5328 +0,0 @@
-import {getName} from './conditions';
-import {changeSet, changeMoves} from "./abilities";
-import {ssbSets} from "./random-teams";
-
-export const Moves: {[k: string]: ModdedMoveData} = {
- /*
- // Example
- moveid: {
- accuracy: 100, // a number or true for always hits
- basePower: 100, // Not used for Status moves, base power of the move, number
- category: "Physical", // "Physical", "Special", or "Status"
- desc: "", // long description
- shortDesc: "", // short description, shows up in /dt
- name: "Move Name",
- gen: 8,
- pp: 10, // unboosted PP count
- priority: 0, // move priority, -6 -> 6
- flags: {}, // Move flags https://github.com/smogon/pokemon-showdown/blob/master/data/moves.js#L1-L27
- onTryMove() {
- this.attrLastMove('[still]'); // For custom animations
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Move Name 1', source);
- this.add('-anim', source, 'Move Name 2', source);
- }, // For custom animations
- secondary: {
- status: "tox",
- chance: 20,
- }, // secondary, set to null to not use one. Exact usage varies, check data/moves.js for examples
- target: "normal", // What does this move hit?
- // normal = the targeted foe, self = the user, allySide = your side (eg light screen), foeSide = the foe's side (eg spikes), all = the field (eg raindance). More can be found in data/moves.js
- type: "Water", // The move's type
- // Other useful things
- noPPBoosts: true, // add this to not boost the PP of a move, not needed for Z moves, dont include it otherwise
- isZ: "crystalname", // marks a move as a z move, list the crystal name inside
- zMove: {effect: ''}, // for status moves, what happens when this is used as a Z move? check data/moves.js for examples
- zMove: {boost: {atk: 2}}, // for status moves, stat boost given when used as a z move
- critRatio: 2, // The higher the number (above 1) the higher the ratio, lowering it lowers the crit ratio
- drain: [1, 2], // recover first num / second num % of the damage dealt
- heal: [1, 2], // recover first num / second num % of the target's HP
- },
- */
- // Please keep sets organized alphabetically based on staff member name!
- // Abdelrahman
- thetownoutplay: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Sets Trick Room and has 10% chance to burn the opponent.",
- shortDesc: "Sets Trick Room. 10% chance to burn.",
- name: "The Town Outplay",
- gen: 8,
- pp: 5,
- priority: -5,
- flags: {},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Trick Room', target);
- },
- onHit(target, source, move) {
- if (this.randomChance(1, 10)) {
- for (const foe of source.foes()) {
- foe.trySetStatus('brn', source);
- }
- }
- },
- pseudoWeather: 'trickroom',
- secondary: null,
- target: "self",
- type: "Fire",
- },
-
- // Adri
- skystriker: {
- accuracy: 100,
- basePower: 50,
- category: "Special",
- desc: "If this move is successful and the user has not fainted, the effects of Leech Seed and binding moves end for the user, and all hazards are removed from the user's side of the field. Raises the user's Speed by 1 stage.",
- shortDesc: "Free user from hazards/bind/Leech Seed; +1 Spe.",
- name: "Skystriker",
- gen: 8,
- pp: 30,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Aerial Ace', target);
- },
- onAfterHit(target, pokemon) {
- if (pokemon.hp && pokemon.removeVolatile('leechseed')) {
- this.add('-end', pokemon, 'Leech Seed', '[from] move: Skystriker', '[of] ' + pokemon);
- }
- const sideConditions = [
- 'spikes', 'toxicspikes', 'stealthrock', 'stickyweb', 'gmaxsteelsurge',
- ];
- for (const condition of sideConditions) {
- if (pokemon.hp && pokemon.side.removeSideCondition(condition)) {
- this.add('-sideend', pokemon.side, this.dex.conditions.get(condition).name, '[from] move: Skystriker', '[of] ' + pokemon);
- }
- }
- if (pokemon.hp && pokemon.volatiles['partiallytrapped']) {
- pokemon.removeVolatile('partiallytrapped');
- }
- },
- onAfterSubDamage(damage, target, pokemon) {
- if (pokemon.hp && pokemon.removeVolatile('leechseed')) {
- this.add('-end', pokemon, 'Leech Seed', '[from] move: Skystriker', '[of] ' + pokemon);
- }
- const sideConditions = [
- 'spikes', 'toxicspikes', 'stealthrock', 'stickyweb', 'gmaxsteelsurge',
- ];
- for (const condition of sideConditions) {
- if (pokemon.hp && pokemon.side.removeSideCondition(condition)) {
- this.add('-sideend', pokemon.side, this.dex.conditions.get(condition).name, '[from] move: Skystriker', '[of] ' + pokemon);
- }
- }
- if (pokemon.hp && pokemon.volatiles['partiallytrapped']) {
- pokemon.removeVolatile('partiallytrapped');
- }
- },
- self: {
- boosts: {
- spe: 1,
- },
- },
- secondary: null,
- target: "normal",
- type: "Flying",
- },
-
- // aegii
- reset: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "This move acts as King's Shield for the purpose of Stance Change. The user is protected from most attacks this turn, but not status moves. Reduces the opponent's relevant attacking stat by 1 if they attempt to use a Special or contact move. If the user is Aegislash, changes the user's set from Physical to Special or Special to Physical.",
- shortDesc: "King's Shield; -1 offense stat on hit; change set.",
- name: "Reset",
- gen: 8,
- pp: 10,
- priority: 4,
- flags: {},
- stallingMove: true,
- volatileStatus: 'reset',
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this .add('-anim', source, 'Petal Dance', target);
- this .add('-anim', source, 'King\'s Shield', source);
- },
- onTryHit(pokemon) {
- return !!this.queue.willAct() && this.runEvent('StallMove', pokemon);
- },
- onHit(pokemon) {
- pokemon.addVolatile('stall');
- if (pokemon.species.baseSpecies === 'Aegislash') {
- let specialSet = pokemon.moves.includes('shadowball');
- changeSet(this, pokemon, ssbSets[specialSet ? 'aegii' : 'aegii-Alt']);
- specialSet = pokemon.moves.includes('shadowball');
- const setType = specialSet ? 'specially' : 'physically';
- this.add('-message', `aegii now has a ${setType} oriented set.`);
- }
- },
- condition: {
- duration: 1,
- onStart(target) {
- this.add('-singleturn', target, 'Protect');
- },
- onTryHitPriority: 3,
- onTryHit(target, source, move) {
- if (!move.flags['protect'] || move.category === 'Status') {
- if (move.isZ || (move.isMax && !move.breaksProtect)) target.getMoveHitData(move).zBrokeProtect = true;
- return;
- }
- if (move.smartTarget) {
- move.smartTarget = false;
- } else {
- this.add('-activate', target, 'move: Protect');
- }
- const lockedmove = source.getVolatile('lockedmove');
- if (lockedmove) {
- // Outrage counter is reset
- if (source.volatiles['lockedmove'].duration === 2) {
- delete source.volatiles['lockedmove'];
- }
- }
- if (move.category === "Special") {
- this.boost({spa: -1}, source, target, this.dex.getActiveMove("Reset"));
- } else if (move.category === "Physical" && move.flags["contact"]) {
- this.boost({atk: -1}, source, target, this.dex.getActiveMove("Reset"));
- }
- return this.NOT_FAIL;
- },
- },
- secondary: null,
- target: "self",
- type: "Steel",
- },
-
- // Aelita
- xanaskeystolyoko: {
- accuracy: 100,
- basePower: 20,
- basePowerCallback(pokemon, target, move) {
- return move.basePower + 20 * pokemon.positiveBoosts();
- },
- category: "Physical",
- desc: "Power is equal to 20+(X*20), where X is the user's total stat stage changes that are greater than 0. User raises a random stat if it has less than 5 positive stat changes.",
- shortDesc: "+20 power/boost. +1 random stat if < 5 boosts.",
- name: "XANA's Keys To Lyoko",
- gen: 8,
- pp: 40,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Draco Meteor', target);
- },
- self: {
- onHit(pokemon) {
- if (pokemon.positiveBoosts() < 5) {
- const stats: BoostID[] = [];
- let stat: BoostID;
- for (stat in pokemon.boosts) {
- if (!['accuracy', 'evasion'].includes(stat) && pokemon.boosts[stat] < 6) {
- stats.push(stat);
- }
- }
- if (stats.length) {
- const randomStat = this.sample(stats);
- const boost: SparseBoostsTable = {};
- boost[randomStat] = 1;
- this.boost(boost);
- }
- }
- },
- },
- secondary: null,
- target: "normal",
- type: "Dragon",
- },
-
- // Aeonic
- lookingcool: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Sets up Stealth Rock on the opposing side of the field and boosts the user's Attack by 2 stages. Can only be used once per the user's time on the field.",
- shortDesc: "1 use per switch-in. +2 Atk + Stealth Rock.",
- name: "Looking Cool",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {snatch: 1},
- volatileStatus: 'lookingcool',
- onTryMove(target) {
- if (target.volatiles['lookingcool']) return false;
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- const foe = source.side.foe.active[0];
- this.add('-anim', source, 'Smokescreen', source);
- this.add('-anim', source, 'Stealth Rock', foe);
- },
- onHit(target, source, move) {
- const foe = source.side.foe;
- if (!foe.getSideCondition('stealthrock')) {
- foe.addSideCondition('stealthrock');
- }
- },
- boosts: {
- atk: 2,
- },
- secondary: null,
- target: "self",
- type: "Dark",
- },
-
- // Aethernum
- lilypadoverflow: {
- accuracy: 100,
- basePower: 62,
- basePowerCallback(source, target, move) {
- if (!source.volatiles['raindrop']?.layers) return move.basePower;
- return move.basePower + (source.volatiles['raindrop'].layers * 20);
- },
- category: "Special",
- desc: "Power is equal to 62 + (Number of Raindrops collected * 20). Whether or not this move is successful, the user's Defense and Special Defense decrease by as many stages as Raindrop had increased them, and the user's Raindrop count resets to 0.",
- shortDesc: "More power per Raindrop. Lose Raindrops.",
- name: "Lilypad Overflow",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Water Spout', target);
- this.add('-anim', source, 'Max Geyser', target);
- },
- onAfterMove(pokemon) {
- if (pokemon.volatiles['raindrop']) pokemon.removeVolatile('raindrop');
- },
- secondary: null,
- target: "normal",
- type: "Water",
- },
-
- // Akir
- ravelin: {
- accuracy: 100,
- basePower: 70,
- category: "Physical",
- desc: "Heals 50% of the user's max HP; Sets up Light Screen for 5 turns on the user's side.",
- shortDesc: "Recover + Light Screen.",
- name: "Ravelin",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1, heal: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Aura Sphere', target);
- this.add('-anim', source, 'Protect', source);
- },
- onAfterMoveSecondarySelf(pokemon, target, move) {
- this.heal(pokemon.maxhp / 2, pokemon, pokemon, move);
- if (pokemon.side.getSideCondition('lightscreen')) return;
- pokemon.side.addSideCondition('lightscreen');
- },
- secondary: null,
- target: "normal",
- type: "Steel",
- },
-
- // Alpha
- blisteringiceage: {
- accuracy: true,
- basePower: 190,
- category: "Special",
- desc: "User's ability becomes Ice Age, and the weather becomes an extremely heavy hailstorm that prevents damaging Steel-type moves from executing, causes Ice-type moves to be 50% stronger, causes all non-Ice-type Pokemon on the opposing side to take 1/8 damage from hail, and causes all moves to have a 10% chance to freeze. This weather bypasses Magic Guard and Overcoat. This weather remains in effect until the 3 turns are up, or the weather is changed by Delta Stream, Desolate Land, or Primordial Sea.",
- shortDesc: "Weather: Steel fail. 1.5x Ice.",
- name: "Blistering Ice Age",
- gen: 8,
- pp: 1,
- noPPBoosts: true,
- priority: 0,
- flags: {},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Hail', target);
- this.add('-anim', target, 'Subzero Slammer', target);
- this.add('-anim', source, 'Subzero Slammer', source);
- },
- onAfterMove(source) {
- source.baseAbility = 'iceage' as ID;
- source.setAbility('iceage');
- this.add('-ability', source, source.getAbility().name, '[from] move: Blistering Ice Age');
- },
- isZ: "caioniumz",
- secondary: null,
- target: "normal",
- type: "Ice",
- },
-
- // Annika
- datacorruption: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Replaces the target's moveset with four vaguely competitively viable moves. 100% chance to cause the target to flinch. Fails unless it is the user's first turn on the field.",
- shortDesc: "First Turn: Gives foe 4 new moves; flinches.",
- name: "Data Corruption",
- gen: 8,
- pp: 1,
- noPPBoosts: true,
- flags: {bypasssub: 1, reflectable: 1},
- priority: 3,
- onTry(pokemon, target) {
- if (pokemon.activeMoveActions > 1) {
- this.attrLastMove('[still]');
- this.add('-fail', pokemon);
- this.hint("Data Corruption only works on your first turn out.");
- return null;
- }
- },
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', target, 'Shift Gear', target);
- this.add('-anim', source, 'Plasma Fists', target);
- this.add('-anim', target, 'Nasty Plot', target);
- },
- onHit(target, source) {
- this.add('-message', `${source.name} corrupted the opposing ${target.name}'s data storage!`);
- // Ran from a script
- const possibleMoves = [
- "agility", "anchorshot", "appleacid", "aquatail", "aromatherapy", "attackorder", "aurasphere", "autotomize", "banefulbunker",
- "behemothbash", "behemothblade", "bellydrum", "blazekick", "blizzard", "blueflare", "bodypress", "bodyslam",
- "boltbeak", "boltstrike", "boomburst", "bravebird", "bugbuzz", "bulkup", "calmmind", "circlethrow", "clangingscales",
- "clangoroussoul", "clearsmog", "closecombat", "coil", "cottonguard", "courtchange", "crabhammer", "crosschop", "crunch",
- "curse", "darkestlariat", "darkpulse", "dazzlinggleam", "defog", "destinybond", "disable", "discharge", "doomdesire",
- "doubleedge", "doubleironbash", "dracometeor", "dragonclaw", "dragondance", "dragondarts", "dragonhammer", "dragonpulse",
- "dragontail", "drainingkiss", "drillpeck", "drillrun", "drumbeating", "dynamaxcannon", "earthpower", "earthquake",
- "encore", "energyball", "eruption", "expandingforce", "explosion", "extrasensory", "extremespeed", "facade",
- "fierydance", "fireblast", "firelash", "fishiousrend", "flamethrower", "flareblitz", "flashcannon", "fleurcannon",
- "flipturn", "focusblast", "foulplay", "freezedry", "fusionbolt", "fusionflare", "futuresight", "geargrind", "glare",
- "grassknot", "gravapple", "gunkshot", "gyroball", "haze", "headsmash", "healbell", "healingwish", "heatwave",
- "hex", "highhorsepower", "highjumpkick", "honeclaws", "hurricane", "hydropump", "hypervoice", "icebeam", "iciclecrash",
- "irondefense", "ironhead", "kingsshield", "knockoff", "lavaplume", "leafblade", "leafstorm", "leechlife",
- "leechseed", "lightscreen", "liquidation", "lowkick", "lunge", "magiccoat", "megahorn", "memento", "meteormash",
- "milkdrink", "moonblast", "moongeistbeam", "moonlight", "morningsun", "muddywater", "multiattack", "nastyplot",
- "nightdaze", "nightshade", "noretreat", "nuzzle", "obstruct", "outrage", "overdrive", "overheat", "painsplit",
- "poltergeist", "partingshot", "perishsong", "petalblizzard", "photongeyser", "plasmafists", "playrough", "poisonjab",
- "pollenpuff", "powergem", "powerwhip", "protect", "psychic", "psychicfangs", "psyshock", "psystrike", "pursuit",
- "pyroball", "quiverdance", "rapidspin", "recover", "reflect", "rest", "return", "roar", "rockpolish", "roost",
- "sacredsword", "scald", "scorchingsands", "secretsword", "seedbomb", "seismictoss", "selfdestruct", "shadowball",
- "shadowbone", "shadowclaw", "shellsidearm", "shellsmash", "shiftgear", "skullbash", "skyattack", "slackoff",
- "slam", "sleeppowder", "sleeptalk", "sludgebomb", "sludgewave", "snipeshot", "softboiled", "sparklingaria",
- "spectralthief", "spikes", "spikyshield", "spiritshackle", "spore", "stealthrock", "stickyweb", "stoneedge", "stormthrow",
- "strangesteam", "strengthsap", "substitute", "suckerpunch", "sunsteelstrike", "superpower", "surf", "surgingstrikes",
- "switcheroo", "swordsdance", "synthesis", "tailwind", "takedown", "taunt", "throatchop", "thunder", "thunderbolt",
- "thunderwave", "toxic", "toxicspikes", "transform", "triattack", "trick", "tripleaxel", "uturn", "vcreate",
- "voltswitch", "volttackle", "waterfall", "waterspout", "whirlwind", "wickedblow", "wildcharge", "willowisp",
- "wish", "woodhammer", "xscissor", "yawn", "zenheadbutt", "zingzap",
- ];
- const newMoves = [];
- for (let i = 0; i < 4; i++) {
- const moveIndex = this.random(possibleMoves.length);
- newMoves.push(possibleMoves[moveIndex]);
- possibleMoves.splice(moveIndex, 1);
- }
- const newMoveSlots = changeMoves(this, target, newMoves);
- target.m.datacorrupt = true;
- target.moveSlots = newMoveSlots;
- // @ts-ignore
- target.baseMoveSlots = newMoveSlots;
- },
- secondary: {
- chance: 100,
- volatileStatus: 'flinch',
- },
- target: "adjacentFoe",
- type: "Psychic",
- },
-
- // A Quag To The Past
- bountyplace: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Puts a bounty on the target. If the target is KOed by a direct attack, the attacker will gain +1 Attack, Defense, Special Attack, Special Defense, and Speed.",
- shortDesc: "If target is ever KOed, attacker omniboosts.",
- name: "Bounty Place",
- gen: 8,
- pp: 1,
- noPPBoosts: true,
- priority: 0,
- flags: {bypasssub: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Pay Day', target);
- this.add('-anim', source, 'Block', target);
- },
- onHit(target, source, move) {
- // See formats.ts for implementation
- target.m.hasBounty = true;
- this.add('-start', target, 'bounty', '[silent]');
- this.add('-message', `${source.name} placed a bounty on ${target.name}!`);
- },
- isZ: "quagniumz",
- secondary: null,
- target: "normal",
- type: "Ground",
- },
-
- // Arby
- quickhammer: {
- accuracy: 100,
- basePower: 40,
- category: "Special",
- desc: "Usually moves first (Priority +1). If this move KOes the opponent, the user gains +2 Special Attack. Otherwise, the user gains -1 Defense and Special Defense.",
- shortDesc: "+1 Prio. +2 SpA if KO, -1 Def/SpD if not.",
- name: "Quickhammer",
- gen: 8,
- pp: 10,
- priority: 1,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Crabhammer', target);
- },
- onAfterMoveSecondarySelf(pokemon, target, move) {
- if (!target || target.fainted || target.hp <= 0) {
- this.boost({spa: 2}, pokemon, pokemon, move);
- } else {
- this.boost({def: -1, spd: -1}, pokemon, pokemon, move);
- }
- },
- secondary: null,
- target: "normal",
- type: "Water",
- },
-
- // used for Arby's ability
- waveterrain: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "For 5 turns, the terrain becomes Wave Terrain. During the effect, the accuracy of Water type moves is multiplied by 1.2, even if the user is not grounded. Hazards and screens are removed and cannot be set while Wave Terrain is active. Fails if the current terrain is Inversion Terrain.",
- shortDesc: "5 turns. Removes hazards. Water move acc 1.2x.",
- name: "Wave Terrain",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {},
- terrain: 'waveterrain',
- condition: {
- duration: 5,
- durationCallback(source, effect) {
- if (source?.hasItem('terrainextender')) {
- return 8;
- }
- return 5;
- },
- onModifyAccuracy(accuracy, target, source, move) {
- if (move.type === 'Water') {
- return this.chainModify(1.2);
- }
- },
- onFieldStart(field, source, effect) {
- if (effect && effect.effectType === 'Ability') {
- this.add('-fieldstart', 'move: Wave Terrain', '[from] ability: ' + effect, '[of] ' + source);
- } else {
- this.add('-fieldstart', 'move: Wave Terrain');
- }
- this.add('-message', 'The battlefield suddenly flooded!');
- const removeAll = [
- 'reflect', 'lightscreen', 'auroraveil', 'safeguard', 'mist', 'spikes',
- 'toxicspikes', 'stealthrock', 'stickyweb', 'gmaxsteelsurge',
- ];
- const silentRemove = ['reflect', 'lightscreen', 'auroraveil', 'safeguard', 'mist'];
- for (const sideCondition of removeAll) {
- if (source.side.foe.removeSideCondition(sideCondition)) {
- if (!silentRemove.includes(sideCondition)) {
- this.add('-sideend', source.side.foe, this.dex.conditions.get(sideCondition).name, '[from] move: Wave Terrain', '[of] ' + source);
- }
- }
- if (source.side.removeSideCondition(sideCondition)) {
- if (!silentRemove.includes(sideCondition)) {
- this.add('-sideend', source.side, this.dex.conditions.get(sideCondition).name, '[from] move: Wave Terrain', '[of] ' + source);
- }
- }
- }
- this.add('-message', `Hazards were removed by the terrain!`);
- },
- onFieldResidualOrder: 21,
- onFieldResidualSubOrder: 3,
- onFieldEnd() {
- this.add('-fieldend', 'move: Wave Terrain');
- },
- },
- secondary: null,
- target: "all",
- type: "Water",
- },
-
- // Archas
- broadsidebarrage: {
- accuracy: 90,
- basePower: 30,
- category: "Physical",
- desc: "Hits 4 times. If one hit breaks the target's substitute, it will take damage for the remaining hits. This move is super effective against Steel-type Pokemon.",
- shortDesc: "Hits 4 times. Super effective on Steel.",
- name: "Broadside Barrage",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', target, 'Close Combat', target);
- this.add('-anim', target, 'Earthquake', target);
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Archas')}|Fire all guns! Fiiiiire!`);
- },
- onEffectiveness(typeMod, target, type) {
- if (type === 'Steel') return 1;
- },
- multihit: 4,
- secondary: null,
- target: "normal",
- type: "Steel",
- },
-
- // Arcticblast
- radiantburst: {
- accuracy: 100,
- basePower: 180,
- category: "Special",
- desc: "User gains Brilliant if not Brilliant without attacking. User attacks and loses Brilliant if Brilliant. Being Brilliant multiplies all stats by 1.5 and grants Perish Song immunity and Ingrain. This move loses priority if the user is already brilliant.",
- shortDesc: "Gain or lose Brilliant. Attack if Brilliant.",
- name: "Radiant Burst",
- gen: 8,
- pp: 10,
- priority: 1,
- flags: {protect: 1, snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onTry(source, target) {
- if (!source.volatiles['brilliant']) {
- this.add('-anim', source, 'Recover', source);
- source.addVolatile('brilliant');
- return null;
- }
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Diamond Storm', target);
- },
- onModifyPriority(priority, source, target, move) {
- if (source.volatiles['brilliant']) return 0;
- },
- onModifyMove(move, source) {
- if (!source.volatiles['brilliant']) {
- move.accuracy = true;
- move.target = "self";
- delete move.flags.protect;
- move.flags.bypasssub = 1;
- }
- },
- onHit(target, pokemon) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Arcticblast')}|YEET`);
- if (pokemon.volatiles['brilliant']) pokemon.removeVolatile('brilliant');
- },
- secondary: null,
- target: "normal",
- type: "Fairy",
- },
-
- // awa
- awa: {
- accuracy: 100,
- basePower: 90,
- category: "Physical",
- desc: "Sets up Sandstorm.",
- shortDesc: "Sets up Sandstorm.",
- name: "awa!",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Let\'s Snuggle Forever', target);
- },
- weather: 'sandstorm',
- secondary: null,
- target: "normal",
- type: "Rock",
- },
-
- // Beowulf
- buzzinspection: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "The user gains the ability Compound Eyes for the remainder of the battle and then switches out",
- shortDesc: "Gains Compound Eyes and switches.",
- name: "Buzz Inspection",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Night Shade', source);
- },
- onHit(pokemon) {
- pokemon.baseAbility = 'compoundeyes' as ID;
- pokemon.setAbility('compoundeyes');
- this.add('-ability', pokemon, pokemon.getAbility().name, '[from] move: Buzz Inspection');
- },
- selfSwitch: true,
- secondary: null,
- target: "self",
- type: "Bug",
- },
-
- // biggie
- juggernautpunch: {
- accuracy: 100,
- basePower: 150,
- category: "Physical",
- desc: "The user loses its focus and does nothing if it is hit by a damaging attack equal to or greater than 20% of the user's maxmimum HP this turn before it can execute the move.",
- shortDesc: "Fails if the user takes ≥20% before it hits.",
- name: "Juggernaut Punch",
- gen: 8,
- pp: 20,
- priority: -3,
- flags: {contact: 1, protect: 1, punch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Focus Punch', target);
- },
- beforeTurnCallback(pokemon) {
- pokemon.addVolatile('juggernautpunch');
- },
- beforeMoveCallback(pokemon) {
- if (pokemon.volatiles['juggernautpunch'] && pokemon.volatiles['juggernautpunch'].lostFocus) {
- this.add('cant', pokemon, 'Juggernaut Punch', 'Juggernaut Punch');
- return true;
- }
- },
- condition: {
- duration: 1,
- onStart(pokemon) {
- this.add('-singleturn', pokemon, 'move: Juggernaut Punch');
- },
- onDamagePriority: -101,
- onDamage(damage, target, source, effect) {
- if (effect.effectType !== 'Move') return;
- if (damage > target.baseMaxhp / 5) {
- target.volatiles['juggernautpunch'].lostFocus = true;
- }
- },
- },
- secondary: null,
- target: "normal",
- type: "Fighting",
- },
-
- // Billo
- fishingforhacks: {
- accuracy: 100,
- basePower: 80,
- category: "Special",
- desc: "Knocks off opponent's item and randomly sets Stealth Rocks, Spikes, or Toxic Spikes.",
- shortDesc: "Knock off foe's item. Set random hazard.",
- name: "Fishing for Hacks",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Mist Ball', target);
- },
- onAfterHit(target, source) {
- if (source.hp) {
- const item = target.takeItem(source);
- if (item) {
- this.add('-enditem', target, item.name, '[from] move: Fishing for Hacks', '[of] ' + source);
- }
- }
- const hazard = this.sample(['Stealth Rock', 'Spikes', 'Toxic Spikes']);
- target.side.addSideCondition(hazard);
- },
- secondary: null,
- target: "normal",
- type: "Fairy",
- },
-
- // Blaz
- bleakdecember: {
- accuracy: 100,
- basePower: 80,
- category: "Special",
- desc: "Damage is calculated using the user's Special Defense stat as its Special Attack, including stat stage changes. Other effects that modify the Special Attack stat are used as normal.",
- shortDesc: "Uses user's SpD stat as SpA in damage calculation.",
- name: "Bleak December",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Spirit Break', target);
- },
- overrideOffensiveStat: 'spd',
- secondary: null,
- target: "normal",
- type: "Fairy",
- },
-
- // Brandon
- flowershower: {
- accuracy: 100,
- basePower: 100,
- category: "Special",
- desc: "This move is physical if the target's Defense is lower than the target's Special Defense.",
- shortDesc: "Physical if target Def < Sp. Def.",
- name: "Flower Shower",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Petal Dance', target);
- },
- onModifyMove(move, source, target) {
- if (target && target.getStat('def', false, true) < target.getStat('spd', false, true)) {
- move.category = "Physical";
- }
- },
- secondary: null,
- target: "normal",
- type: "Grass",
- },
-
- // Used for Brandon's ability
- baneterrain: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "For 5 turns, the terrain becomes Bane Terrain. During the effect, moves hit off of the Pokemon's weaker attacking stat. Fails if the current terrain is Bane Terrain.",
- shortDesc: "5 turns. Moves hit off of weaker stat.",
- name: "Bane Terrain",
- pp: 10,
- priority: 0,
- flags: {nonsky: 1},
- terrain: 'baneterrain',
- condition: {
- duration: 5,
- durationCallback(source, effect) {
- if (source?.hasItem('terrainextender')) {
- return 8;
- }
- return 5;
- },
- onModifyMove(move, source, target) {
- if (move.overrideOffensiveStat && !['atk', 'spa'].includes(move.overrideOffensiveStat)) return;
- const attacker = move.overrideOffensivePokemon === 'target' ? target : source;
- if (!attacker) return;
- const attackerAtk = attacker.getStat('atk', false, true);
- const attackerSpa = attacker.getStat('spa', false, true);
- move.overrideOffensiveStat = attackerAtk > attackerSpa ? 'spa' : 'atk';
- },
- // Stat modifying in scripts.ts
- onFieldStart(field, source, effect) {
- if (effect?.effectType === 'Ability') {
- this.add('-fieldstart', 'move: Bane Terrain', '[from] ability: ' + effect, '[of] ' + source);
- } else {
- this.add('-fieldstart', 'move: Bane Terrain');
- }
- this.add('-message', 'The battlefield suddenly became grim!');
- },
- onFieldResidualOrder: 21,
- onFieldResidualSubOrder: 3,
- onFieldEnd() {
- this.add('-fieldend', 'move: Bane Terrain');
- },
- },
- secondary: null,
- target: "all",
- type: "Grass",
- zMove: {boost: {def: 1}},
- contestType: "Beautiful",
- },
-
- // brouha
- kinetosis: {
- accuracy: 100,
- basePower: 70,
- category: "Special",
- desc: "Badly poisons the target. If it is the user's first turn out, this move has +3 priority.",
- shortDesc: "First turn: +3 priority. Target: TOX.",
- name: "Kinetosis",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Aeroblast', target);
- this.add('-anim', source, 'Haze', target);
- },
- onModifyPriority(priority, source) {
- if (source.activeMoveActions < 1) return priority + 3;
- },
- secondary: {
- chance: 100,
- status: 'tox',
- },
- target: 'normal',
- type: 'Flying',
- },
-
- // Buffy
- pandorasbox: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Gains Protean and replaces Swords Dance and Pandora's Box with two moves from two random types.",
- shortDesc: "Gains Protean and some random moves.",
- name: "Pandora's Box",
- gen: 8,
- pp: 5,
- priority: 1,
- flags: {snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Teeter Dance', target);
- },
- volatileStatus: 'pandorasbox',
- condition: {
- onStart(target) {
- const typeMovePair: {[key: string]: string} = {
- Normal: 'Body Slam',
- Fighting: 'Drain Punch',
- Flying: 'Floaty Fall',
- Poison: 'Baneful Bunker',
- Ground: 'Shore Up',
- Rock: 'Stealth Rock',
- Bug: 'Sticky Web',
- Ghost: 'Shadow Sneak',
- Steel: 'Iron Defense',
- Fire: 'Fire Fang',
- Water: 'Life Dew',
- Grass: 'Synthesis',
- Electric: 'Thunder Fang',
- Psychic: 'Psychic Fangs',
- Ice: 'Icicle Crash',
- Dragon: 'Dragon Darts',
- Dark: 'Taunt',
- Fairy: 'Play Rough',
- };
- const newMoveTypes = Object.keys(typeMovePair);
- this.prng.shuffle(newMoveTypes);
- const moves = [typeMovePair[newMoveTypes[0]], typeMovePair[newMoveTypes[1]]];
- target.m.replacedMoves = moves;
- for (const moveSlot of target.moveSlots) {
- if (!(moveSlot.id === 'swordsdance' || moveSlot.id === 'pandorasbox')) continue;
- if (!target.m.backupMoves) {
- target.m.backupMoves = [this.dex.deepClone(moveSlot)];
- } else {
- target.m.backupMoves.push(this.dex.deepClone(moveSlot));
- }
- const moveData = this.dex.moves.get(this.toID(moves.pop()));
- if (!moveData.id) continue;
- target.moveSlots[target.moveSlots.indexOf(moveSlot)] = {
- move: moveData.name,
- id: moveData.id,
- pp: Math.floor(moveData.pp * (moveSlot.pp / moveSlot.maxpp)),
- maxpp: ((moveData.noPPBoosts || moveData.isZ) ? moveData.pp : moveData.pp * 8 / 5),
- target: moveData.target,
- disabled: false,
- disabledSource: '',
- used: false,
- };
- }
- target.setAbility('protean');
- this.add('-ability', target, target.getAbility().name, '[from] move: Pandora\'s Box');
- this.add('-message', `${target.name} learned new moves!`);
- },
- onEnd(pokemon) {
- if (!pokemon.m.backupMoves) return;
- for (const [index, moveSlot] of pokemon.moveSlots.entries()) {
- if (!(pokemon.m.replacedMoves.includes(moveSlot.move))) continue;
- pokemon.moveSlots[index] = pokemon.m.backupMoves.shift();
- pokemon.moveSlots[index].pp = Math.floor(pokemon.moveSlots[index].maxpp * (moveSlot.pp / moveSlot.maxpp));
- }
- delete pokemon.m.backupMoves;
- delete pokemon.m.replacedMoves;
- },
- },
- target: "self",
- type: "Dragon",
- },
-
- // Cake
- kevin: {
- accuracy: true,
- basePower: 100,
- category: "Physical",
- desc: "This move combines the user's current typing in its type effectiveness against the target. If the target lost HP, the user takes recoil damage equal to 1/8 of the HP lost by the target, rounded half up, but not less than 1 HP.",
- shortDesc: "This move is the user's type combo. 1/8 recoil.",
- name: "Kevin",
- gen: 8,
- pp: 10,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source, move) {
- this.add('-anim', source, 'Brave Bird', target);
- if (!this.randomChance(255, 256)) {
- this.attrLastMove('[miss]');
- this.add('-activate', target, 'move: Celebrate');
- this.add('-miss', source);
- this.hint("In Super Staff Bros, this move can still miss 1/256 of the time regardless of accuracy or evasion.");
- return null;
- }
- },
- onModifyType(move, pokemon, target) {
- move.type = pokemon.types[0];
- },
- onTryImmunity(target, pokemon) {
- if (pokemon.types[1]) {
- if (!target.runImmunity(pokemon.types[1])) return false;
- }
- return true;
- },
- onEffectiveness(typeMod, target, type, move) {
- if (!target) return;
- const pokemon = target.side.foe.active[0];
- if (pokemon.types[1]) {
- return typeMod + this.dex.getEffectiveness(pokemon.types[1], type);
- }
- return typeMod;
- },
- priority: 0,
- recoil: [1, 8],
- secondary: null,
- target: "normal",
- type: "Bird",
- },
-
- // cant say
- neverlucky: {
- accuracy: 85,
- basePower: 110,
- category: "Special",
- desc: "Doubles base power if statused. Has a 10% chance to boost every stat 1 stage. High Crit Ratio.",
- shortDesc: "x2 power if statused. 10% omniboost. High crit.",
- name: "Never Lucky",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Overheat', target);
- },
- onBasePower(basePower, pokemon) {
- if (pokemon.status && pokemon.status !== 'slp') {
- return this.chainModify(2);
- }
- },
- secondary: {
- chance: 10,
- self: {
- boosts: {
- atk: 1,
- def: 1,
- spa: 1,
- spd: 1,
- spe: 1,
- },
- },
- },
- critRatio: 2,
- target: "normal",
- type: "Fire",
- },
-
- // Celine
- statusguard: {
- accuracy: 100,
- basePower: 0,
- category: "Status",
- desc: "Protects from physical moves. If hit by physical move, opponent is either badly poisoned, burned, or paralyzed at random and is forced out. Special attacks and status moves go through this protect.",
- shortDesc: "Protected from physical moves. Gives brn/par/tox.",
- name: "Status Guard",
- gen: 8,
- pp: 10,
- priority: 4,
- flags: {},
- stallingMove: true,
- volatileStatus: 'statusguard',
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onHit(pokemon) {
- pokemon.addVolatile('stall');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Protect', source);
- },
- onTryHit(pokemon) {
- return !!this.queue.willAct() && this.runEvent('StallMove', pokemon);
- },
- condition: {
- duration: 1,
- onStart(target) {
- this.add('-singleturn', target, 'Protect');
- },
- onTryHitPriority: 3,
- onTryHit(target, source, move) {
- if (!move.flags['protect']) {
- if (move.isZ || (move.isMax && !move.breaksProtect)) target.getMoveHitData(move).zBrokeProtect = true;
- return;
- }
- if (move.category === 'Special' || move.category === 'Status') {
- return;
- } else if (move.smartTarget) {
- move.smartTarget = false;
- } else {
- this.add('-activate', target, 'move: Protect');
- }
- const lockedmove = source.getVolatile('lockedmove');
- if (lockedmove) {
- // Outrage counter is reset
- if (source.volatiles['lockedmove'].duration === 2) {
- delete source.volatiles['lockedmove'];
- }
- }
- if (move.category === 'Physical') {
- const statuses = ['brn', 'par', 'tox'];
- source.trySetStatus(this.sample(statuses), target);
- source.forceSwitchFlag = true;
- }
- return this.NOT_FAIL;
- },
- onHit(target, source, move) {
- if (move.category === 'Physical') {
- const statuses = ['brn', 'par', 'tox'];
- source.trySetStatus(this.sample(statuses), target);
- source.forceSwitchFlag = true;
- }
- },
- },
- secondary: null,
- target: "self",
- type: "Normal",
- },
-
- // c.kilgannon
- soulsiphon: {
- accuracy: 100,
- basePower: 70,
- category: "Physical",
- desc: "Lowers the target's Attack by 1 stage. The user restores its HP equal to the target's Attack stat calculated with its stat stage before this move was used. If Big Root is held by the user, the HP recovered is 1.3x normal, rounded half down. Fails if the target's Attack stat stage is -6.",
- shortDesc: "User heals HP=target's Atk stat. Lowers Atk by 1.",
- name: "Soul Siphon",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {contact: 1, mirror: 1, protect: 1, heal: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Supersonic Skystrike', target);
- },
- onHit(target, source) {
- if (target.boosts.atk === -6) return false;
- const atk = target.getStat('atk', false, true);
- const success = this.boost({atk: -1}, target, source, null, false, true);
- return !!(this.heal(atk, source, target) || success);
- },
- secondary: null,
- target: "normal",
- type: "Flying",
- },
-
- // Coconut
- devolutionbeam: {
- accuracy: 100,
- basePower: 80,
- category: "Special",
- desc: "If the target Pokemon is evolved, this move will reduce the target to its first-stage form. If the target Pokemon is single-stage or is already in its first-stage form, this move lowers all of the opponent's stats by 1. Hits Ghost types.",
- shortDesc: "Devolves evolved mons;-1 all stats to LC.",
- name: "Devolution Beam",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {protect: 1},
- ignoreImmunity: {'Normal': true},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Psywave', target);
- },
- onHit(target, source, move) {
- let species = target.species;
- if (species.isMega) species = this.dex.species.get(species.baseSpecies);
- const ability = target.ability;
- const isSingleStage = (species.nfe && !species.prevo) || (!species.nfe && !species.prevo);
- if (!isSingleStage) {
- let prevo = species.prevo;
- if (this.dex.species.get(prevo).prevo) {
- prevo = this.dex.species.get(prevo).prevo;
- }
- target.formeChange(prevo, this.effect);
- target.canMegaEvo = null;
- target.setAbility(ability);
- } else {
- this.boost({atk: -1, def: -1, spa: -1, spd: -1, spe: -1}, target, source);
- }
- },
- secondary: null,
- target: "normal",
- type: "Normal",
- },
-
- // dogknees
- bellyrubs: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Heals the user by 25% of their maximum HP. Boosts the user's Attack and Defense by 1 stage.",
- shortDesc: "Heals 25% HP. Boosts Atk/Def by 1 stage.",
- name: "Belly Rubs",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {heal: 1, snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Belly Drum', target);
- },
- self: {
- boosts: {
- atk: 1,
- def: 1,
- },
- },
- onHit(pokemon, target, move) {
- this.heal(pokemon.maxhp / 4, pokemon, pokemon, move);
- },
- secondary: null,
- zMove: {boost: {spe: 1}},
- target: "self",
- type: "Normal",
- },
-
- // drampa's grandpa
- getoffmylawn: {
- accuracy: 100,
- basePower: 78,
- category: "Special",
- desc: "The target is forced out after being damaged.",
- shortDesc: "Phazes target.",
- name: "GET OFF MY LAWN!",
- gen: 8,
- pp: 10,
- priority: -6,
- flags: {protect: 1, sound: 1, bypasssub: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Boomburst', target);
- },
- onHit() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('drampa\'s grandpa')}|GET OFF MY LAWN!!!`);
- },
- secondary: null,
- forceSwitch: true,
- target: "normal",
- type: "Normal",
- },
-
- // DragonWhale
- cloakdance: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "If Mimikyu's Disguise is intact, the user is not Mimikyu, or Mimikyu is the last remaining Pokemon, Attack goes up 2 stages. If Mimikyu's Disguise is busted and there are other Pokemon on Mimikyu's side, the Disguise will be repaired and Mimikyu will switch out.",
- shortDesc: "Busted: Repair, switch. Last mon/else: +2 Atk.",
- name: "Cloak Dance",
- pp: 5,
- priority: 0,
- flags: {snatch: 1, dance: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- const moveAnim = (!source.abilityState.busted || source.side.pokemonLeft === 1) ? 'Swords Dance' : 'Teleport';
- this.add('-anim', source, moveAnim, target);
- },
- onHit(target, source) {
- if (!source.abilityState.busted || source.side.pokemonLeft === 1) {
- this.boost({atk: 2}, target);
- } else {
- delete source.abilityState.busted;
- if (source.species.baseSpecies === 'Mimikyu') source.formeChange('Mimikyu', this.effect, true);
- source.switchFlag = true;
- }
- },
- secondary: null,
- target: "self",
- type: "Fairy",
- },
-
- // dream
- lockandkey: {
- accuracy: 100,
- basePower: 0,
- category: "Status",
- desc: "Raises the user's Special Attack and Special Defense stats by 1 stage and prevents the target from switching out.",
- shortDesc: "Raises user's SpA and SpD by 1. Traps foe.",
- name: "Lock and Key",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Calm Mind', source);
- this.add('-anim', target, 'Imprison', target);
- },
- onHit(target, source, move) {
- if (source.isActive) target.addVolatile('trapped', source, move, 'trapper');
- },
- self: {
- boosts: {
- spa: 1,
- spd: 1,
- },
- },
- secondary: null,
- target: "allAdjacentFoes",
- type: "Steel",
- },
-
- // Elgino
- navisgrace: {
- accuracy: 100,
- basePower: 90,
- category: "Special",
- desc: "This move is super effective on Steel- and Poison-type Pokemon.",
- shortDesc: "Super effective on Steel- and Poison-types.",
- name: "Navi's Grace",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1},
- secondary: null,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Dazzling Gleam', target);
- this.add('-anim', source, 'Earth Power', target);
- },
- onEffectiveness(typeMod, target, type) {
- if (type === 'Poison' || type === 'Steel') return 1;
- },
- target: 'normal',
- type: 'Fairy',
- },
-
- // Emeri
- forcedlanding: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "The user restores 1/2 of its maximum HP, rounded half up. For 5 turns, the evasiveness of all active Pokemon is multiplied by 0.6. At the time of use, Bounce, Fly, Magnet Rise, Sky Drop, and Telekinesis end immediately for all active Pokemon. During the effect, Bounce, Fly, Flying Press, High Jump Kick, Jump Kick, Magnet Rise, Sky Drop, Splash, and Telekinesis are prevented from being used by all active Pokemon. Ground-type attacks, Spikes, Toxic Spikes, Sticky Web, and the Arena Trap Ability can affect Flying types or Pokemon with the Levitate Ability. Fails if this move is already in effect.",
- shortDesc: "Restore 50% HP + set Gravity.",
- name: "Forced Landing",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {heal: 1},
- onHit(pokemon, target, move) {
- this.heal(pokemon.maxhp / 2, pokemon, pokemon, move);
- },
- pseudoWeather: 'gravity',
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Roost', source);
- this.add('-anim', source, 'Gravity', source);
- },
- secondary: null,
- target: "self",
- type: "Flying",
- },
-
- // EpicNikolai
- epicrage: {
- accuracy: 95,
- basePower: 120,
- category: "Physical",
- desc: "Has a 25% chance to paralyze the target, and take 40% recoil. If the user is fire-type, it has a 25% chance to burn the target and take 33% recoil.",
- shortDesc: "25% Par + 40% recoil.Fire: 25% burn + 33% recoil.",
- name: "Epic Rage",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Draco Meteor', target);
- },
- onModifyMove(move, pokemon) {
- if (!pokemon.types.includes('Fire')) return;
- move.secondaries = [{
- chance: 25,
- status: 'brn',
- }];
- move.recoil = [33, 100];
- },
- recoil: [4, 10],
- secondary: {
- chance: 25,
- status: "par",
- },
- target: "normal",
- type: "Fire",
- },
-
- // estarossa
- sandbalance: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "The user uses Roar, then switches out after forcing out the opposing Pokemon.",
- shortDesc: "Uses Roar, switches out after.",
- name: "Sand Balance",
- gen: 8,
- pp: 10,
- priority: -6,
- flags: {bypasssub: 1, protect: 1, mirror: 1, sound: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Roar', target);
- this.add('-anim', source, 'Parting Shot', target);
- },
- forceSwitch: true,
- selfSwitch: true,
- secondary: null,
- target: "normal",
- type: "Ground",
- },
-
- // explodingdaisies
- youhavenohope: {
- accuracy: 100,
- basePower: 0,
- damageCallback(pokemon, target) {
- return target.getUndynamaxedHP() - pokemon.hp;
- },
- onTryImmunity(target, pokemon) {
- return pokemon.hp < target.hp;
- },
- category: "Physical",
- desc: "Lowers the target's HP to the user's HP. This move bypasses the target's substitute.",
- shortDesc: "Lowers the target's HP to the user's HP.",
- name: "You Have No Hope!",
- pp: 1,
- noPPBoosts: true,
- priority: 0,
- flags: {bypasssub: 1, contact: 1, protect: 1, mirror: 1},
- gen: 8,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Endeavor', target);
- },
- onHit(target, source) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('explodingdaisies')}|You have no hope ${target.name}!`);
- },
- secondary: null,
- target: "normal",
- type: "Normal",
- },
- // fart
- soupstealing7starstrikeredux: {
- accuracy: 100,
- basePower: 40,
- basePowerCallback() {
- if (this.field.pseudoWeather.soupstealing7starstrikeredux) {
- return 40 * this.field.pseudoWeather.soupstealing7starstrikeredux.multiplier;
- }
- return 40;
- },
- category: "Physical",
- desc: "This move is either a Water, Fire, or Grass type move. The selected type is added to the user of this move. For every consecutive turn that this move is used by at least one Pokemon, this move's power is multiplied by the number of turns to pass, but not more than 5.",
- shortDesc: "Change type to F/W/G. Power+ on repeat.",
- name: "Soup-Stealing 7-Star Strike: Redux",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTry() {
- this.field.addPseudoWeather('soupstealing7starstrikeredux');
- },
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, "Conversion", source);
- },
- onModifyMove(move, pokemon) {
- const types = ['Fire', 'Water', 'Grass'];
- const randomType = this.sample(types);
- move.type = randomType;
- pokemon.addType(randomType);
- this.add('-start', pokemon, 'typeadd', randomType);
- },
- onHit(target, source) {
- this.add('-anim', source, 'Spectral Thief', target);
- if (this.randomChance(1, 2)) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('fart')}|I hl on soup`);
- } else {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('fart')}|I walk with purpose. bring me soup.`);
- }
- },
- condition: {
- duration: 2,
- onFieldStart() {
- this.effectState.multiplier = 1;
- },
- onFieldRestart() {
- if (this.effectState.duration !== 2) {
- this.effectState.duration = 2;
- if (this.effectState.multiplier < 5) {
- this.effectState.multiplier++;
- }
- }
- },
- },
- secondary: null,
- target: "normal",
- type: "Normal",
- },
-
- // Felucia
- riggeddice: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Inverts target's stat boosts if they have any; taunts otherwise. User then switches out.",
- shortDesc: "If target has boosts, invert; else, taunt. Switch out.",
- name: "Rigged Dice",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Smart Strike', source);
- },
- onHit(target, source, move) {
- let success = false;
- let i: BoostID;
- for (i in target.boosts) {
- if (target.boosts[i] === 0) continue;
- target.boosts[i] = -target.boosts[i];
- success = true;
- }
- if (success) {
- this.add('-invertboost', target, '[from] move: Rigged Dice');
- } else {
- target.addVolatile("taunt");
- }
- },
- selfSwitch: true,
- secondary: null,
- target: "normal",
- type: "Ice",
- },
-
- // Finland
- cradilychaos: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "All Pokemon on the field get a +1 boost to a random stat. The target is badly poisoned, regardless of typing. If the user is Alcremie, it changes to a non-Vanilla Cream forme.",
- shortDesc: "Random boosts to all mons. Tox. Change forme.",
- name: "Cradily Chaos",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Psywave', target);
- },
- onHit(target, source, move) {
- const boosts: BoostID[] = ['atk', 'def', 'spa', 'spd', 'spe'];
- const selfBoost: SparseBoostsTable = {};
- selfBoost[boosts[this.random(5)]] = 1;
- const oppBoost: SparseBoostsTable = {};
- oppBoost[boosts[this.random(5)]] = 1;
- this.boost(selfBoost, source);
- this.boost(oppBoost, target);
- target.trySetStatus('tox', source);
- if (source.species.baseSpecies === 'Alcremie') {
- const formes = ['Finland', 'Finland-Tsikhe', 'Finland-Nezavisa', 'Finland-Järvilaulu']
- .filter(forme => ssbSets[forme].species !== source.species.name);
- const newSet = this.sample(formes);
- changeSet(this, source, ssbSets[newSet]);
- }
- },
- secondary: null,
- target: "normal",
- type: "Poison",
- },
-
- // frostyicelad
- frostywave: {
- accuracy: 100,
- basePower: 95,
- category: "Special",
- desc: "This move and its effects ignore the Abilities of other Pokemon.",
- shortDesc: "Ignores abilities.",
- name: "Frosty Wave",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1, sound: 1, bypasssub: 1},
- ignoreAbility: true,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Boomburst', target);
- this.add('-anim', source, 'Frost Breath', target);
- },
- secondary: null,
- target: "allAdjacentFoes",
- type: "Ice",
- },
-
- // gallant's pear
- kinggirigirislash: {
- accuracy: 100,
- basePower: 100,
- category: "Special",
- desc: "Removes the opponent's Reflect, Light Screen, Aurora Veil, and Safeguard. Secondary effect depends on the user's secondary typing: Psychic: 100% chance to lower target's Speed by 1; Fire: 10% burn; Steel: 10% flinch; Rock: apply Smack Down; Electric: 10% paralyze; else: no additional effect.",
- shortDesc: "Breaks screens. Secondary depends on type.",
- name: "King Giri Giri Slash",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onModifyMove(move, pokemon) {
- move.type = pokemon.types[1] || "Normal";
- if (!move.secondaries) move.secondaries = [];
- if (move.type === 'Rock') {
- move.secondaries.push({
- chance: 100,
- volatileStatus: 'smackdown',
- });
- } else if (move.type === 'Fire') {
- move.secondaries.push({
- chance: 10,
- status: 'brn',
- });
- } else if (move.type === 'Steel') {
- move.secondaries.push({
- chance: 10,
- volatileStatus: 'flinch',
- });
- } else if (move.type === 'Electric') {
- move.secondaries.push({
- chance: 10,
- status: 'par',
- });
- } else if (move.type === 'Psychic') {
- move.secondaries.push({
- chance: 100,
- boosts: {spe: -1},
- });
- }
- },
- onTryHit(pokemon, source, move) {
- // will shatter screens through sub, before you hit
- if (pokemon.runImmunity(move.type)) {
- pokemon.side.removeSideCondition('reflect');
- pokemon.side.removeSideCondition('lightscreen');
- pokemon.side.removeSideCondition('auroraveil');
- }
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Solar Blade', target);
- },
- secondary: null,
- target: "normal",
- type: "Normal",
- },
-
- // Gimmick
- randomscreaming: {
- accuracy: 100,
- basePower: 50,
- category: "Special",
- desc: "Has a 10% chance to freeze the target. If the target is frozen, this move will deal double damage and thaw the target.",
- shortDesc: "10% frz. FRZ: 2x damage then thaw.",
- name: "Random Screaming",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1, sound: 1, bypasssub: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Hyper Voice', target);
- this.add('-anim', source, 'Misty Terrain', target);
- },
- onBasePower(basePower, source, target, move) {
- if (target.status === 'frz') {
- return this.chainModify(2);
- }
- },
- secondary: {
- chance: 10,
- status: 'frz',
- onHit() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Gimmick')}|Show me some more paaain, baaaby`);
- },
- },
- thawsTarget: true,
- target: "normal",
- type: "Fire",
- },
-
- // GMars
- gacha: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Lowers the user's Defense and Special Defense by 1 stage. Raises the user's Attack, Special Attack, and Speed by 2 stages. If the user is Minior-Meteor, its forme changes, with a different effect for each forme.",
- shortDesc: "Shell Smash; Minior: change forme.",
- name: "Gacha",
- pp: 15,
- priority: 0,
- flags: {snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Brick Break', source);
- },
- onHit(target, source, move) {
- if (target.species.id !== 'miniormeteor') return;
- let forme: string;
- let message = "";
- const random = this.random(100);
- let shiny = false;
- if (random < 3) {
- forme = "Minior-Violet";
- message = "Oof, Violet. Tough break. A Violet Minior is sluggish and won't always listen to your commands. Best of luck! Rating: ★ ☆ ☆ ☆ ☆ ";
- } else if (random < 13) {
- forme = "Minior-Indigo";
- message = "Uh oh, an Indigo Minior. Its inspiring color may have had some unintended effects and boosted your foe's attacking stats. Better hope you can take it down first! Rating: ★ ☆ ☆ ☆ ☆";
- } else if (random < 33) {
- forme = "Minior";
- message = "Nice one, a Red Minior is hard for your opponent to ignore. They'll be goaded into attacking the first time they see this! Rating: ★ ★ ★ ☆ ☆ ";
- } else if (random < 66) {
- forme = "Minior-Orange";
- message = "Solid, you pulled an Orange Minior. Nothing too fancy, but it can definitely get the job done if you use it right. Rating: ★ ★ ☆ ☆ ☆";
- } else if (random < 86) {
- forme = "Minior-Yellow";
- message = "Sweet, a Yellow Minior! This thing had a lot of static energy built up that released when you cracked it open, paralyzing the foe. Rating: ★ ★ ★ ☆ ☆ ";
- } else if (random < 96) {
- forme = "Minior-Blue";
- message = "Woah! You got a Blue Minior. This one's almost translucent; it looks like it'd be hard for an opponent to find a way to reduce its stats. Rating: ★ ★ ★ ★ ☆";
- } else if (random < 99) {
- forme = "Minior-Green";
- message = "Nice! You cracked a Green Minior, that's definitely a rare one. This type of Minior packs an extra punch, and it's great for breaking through defensive teams without risking multiple turns of setup. Rating: ★ ★ ★ ★ ★";
- } else {
- forme = "Minior";
- shiny = true;
- target.set.shiny = true;
- target.m.nowShiny = true;
- message = "YO!! I can't believe it, you cracked open a Shiny Minior! Its multicolored interior dazzles its opponents and throws off their priority moves. Big grats. Rating: ★ ★ ★ ★ ★ ★";
- }
- target.formeChange(forme, move, true);
- const details = target.species.name + (target.level === 100 ? '' : ', L' + target.level) +
- (target.gender === '' ? '' : ', ' + target.gender) + (target.set.shiny ? ', shiny' : '');
- if (shiny) this.add('replace', target, details);
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('GMars')}|${message}`);
- target.setAbility('capsulearmor');
- target.baseAbility = target.ability;
- if (target.set.shiny) return;
- if (forme === 'Minior-Indigo') {
- this.boost({atk: 1, spa: 1}, target.side.foe.active[0]);
- } else if (forme === 'Minior') {
- target.side.foe.active[0].addVolatile('taunt');
- } else if (forme === 'Minior-Yellow') {
- target.side.foe.active[0].trySetStatus('par', target);
- } else if (forme === 'Minior-Green') {
- this.boost({atk: 1}, target);
- }
- },
- boosts: {
- def: -1,
- spd: -1,
- atk: 2,
- spa: 2,
- spe: 2,
- },
- secondary: null,
- target: "self",
- type: "Normal",
- },
-
- // grimAuxiliatrix
- skyscrapersuplex: {
- accuracy: 100,
- basePower: 75,
- onBasePower(basePower, pokemon, target) {
- if (target?.statsRaisedThisTurn) {
- return this.chainModify(2);
- }
- },
- category: "Special",
- desc: "Power doubles if the target had a stat stage raised this turn.",
- shortDesc: "2x power if the target that had a stat rise this turn.",
- name: "Skyscraper Suplex",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Steel Beam', target);
- },
- secondary: null,
- target: "normal",
- type: "Steel",
- },
-
- // HoeenHero
- landfall: {
- accuracy: 100,
- category: "Special",
- basePower: 0,
- basePowerCallback(target, source, move) {
- const windSpeeds = [65, 85, 85, 95, 95, 95, 95, 115, 115, 140];
- move.basePower = windSpeeds[this.random(0, 10)];
- return move.basePower;
- },
- desc: "The foe is hit with a hurricane with a Base Power that varies based on the strength (category) of the hurricane. Category 1 is 65, category 2 is 85, category 3 is 95, category 4 is 115, and category 5 is 140. In addition, the target's side of the field is covered in a storm surge. Storm surge applies a 75% Speed multiplier to pokemon on that side of the field. Storm surge will last for as many turns as the hurricane's category (not including the turn Landfall was used).",
- shortDesc: "Higher category = +dmg, foe side speed 75%.",
- name: "Landfall",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Hurricane', target);
- this.add('-anim', source, 'Surf', target);
- },
- onHit(target, source, move) {
- const windSpeeds = [65, 85, 95, 115, 140];
- const category = windSpeeds.indexOf(move.basePower) + 1;
- this.add('-message', `A category ${category} hurricane made landfall!`);
- },
- sideCondition: 'stormsurge', // Programmed in conditions.ts
- target: "normal",
- type: "Water",
- },
-
- // Hubriz
- steroidanaphylaxia: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Inverts the target's stat stages.",
- name: "Steroid Anaphylaxia",
- gen: 8,
- pp: 20,
- priority: 1,
- flags: {protect: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onHit(target) {
- let success = false;
- let i: BoostID;
- for (i in target.boosts) {
- if (target.boosts[i] === 0) continue;
- target.boosts[i] = -target.boosts[i];
- success = true;
- }
- if (!success) return false;
- this.add('-invertboost', target, '[from] move: Steroid Anaphylaxia');
- },
- target: "normal",
- type: "Poison",
- },
-
- // Hydro
- hydrostatics: {
- accuracy: 100,
- basePower: 75,
- category: "Special",
- desc: "Has a 70% chance to raise the user's Special Attack by 1 stage and a 50% chance to paralyze the target. This move combines Electric in its type effectiveness against the target.",
- shortDesc: "70% +1 SpA; 50% par; +Electric in type effect.",
- name: "Hydrostatics",
- gen: 8,
- pp: 10,
- priority: 2,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Origin Pulse', target);
- this.add('-anim', source, 'Charge Beam', target);
- },
- secondaries: [{
- chance: 70,
- self: {
- boosts: {
- spa: 1,
- },
- },
- }, {
- chance: 50,
- status: 'par',
- }],
- onEffectiveness(typeMod, target, type, move) {
- return typeMod + this.dex.getEffectiveness('Electric', type);
- },
- target: "normal",
- type: "Water",
- },
-
- // Inactive
- paranoia: {
- accuracy: 90,
- basePower: 100,
- category: "Physical",
- desc: "Has a 15% chance to burn the target.",
- name: "Paranoia",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Max Flare', target);
- },
- secondary: {
- chance: 15,
- status: 'brn',
- },
- target: "normal",
- type: "Dark",
- },
-
- // instruct
- sodabreak: {
- accuracy: true,
- basePower: 10,
- category: "Physical",
- desc: "Has a 100% chance to make the target flinch. Causes the user to switch out. Fails unless it is the user's first turn on the field.",
- shortDesc: "First turn: Flinches the target then switches out.",
- name: "Soda Break",
- isNonstandard: "Custom",
- gen: 8,
- pp: 10,
- priority: 3,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Milk Drink', source);
- this.add('-anim', source, 'Fling', target);
- this.add('-anim', source, 'U-turn', target);
- },
- onTry(pokemon, target) {
- if (pokemon.activeMoveActions > 1) {
- this.attrLastMove('[still]');
- this.add('-fail', pokemon);
- this.hint("Soda Break only works on your first turn out.");
- return null;
- }
- },
- secondary: {
- chance: 100,
- volatileStatus: 'flinch',
- },
- selfSwitch: true,
- target: "normal",
- type: "???",
- },
-
- // Iyarito
- patronaattack: {
- accuracy: 100,
- basePower: 50,
- category: "Special",
- desc: "Usually goes first.",
- name: "Patrona Attack",
- gen: 8,
- pp: 20,
- priority: 1,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Moongeist Beam', target);
- },
- secondary: null,
- target: "normal",
- type: "Ghost",
- },
-
- // Jett
- thehuntison: {
- accuracy: 100,
- basePower: 55,
- basePowerCallback(pokemon, target, move) {
- // You can't get here unless the pursuit effect succeeds
- if (target.beingCalledBack) {
- this.debug('The Hunt is On! damage boost');
- return move.basePower * 2;
- }
- return move.basePower;
- },
- category: "Physical",
- desc: "If an opposing Pokemon switches out this turn, this move hits that Pokemon before it leaves the field, even if it was not the original target. If the user moves after an opponent using Parting Shot, U-turn, or Volt Switch, but not Baton Pass, it will hit that opponent before it leaves the field. Power doubles and no accuracy check is done if the user hits an opponent switching out, and the user's turn is over; if an opponent faints from this, the replacement Pokemon does not become active until the end of the turn.",
- shortDesc: "Foe: 2x power when switching.",
- name: "The Hunt is On!",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Sucker Punch', target);
- this.add('-anim', source, 'Pursuit', target);
- },
- beforeTurnCallback(pokemon) {
- for (const side of this.sides) {
- if (side === pokemon.side) continue;
- side.addSideCondition('thehuntison', pokemon);
- const data = side.getSideConditionData('thehuntison');
- if (!data.sources) {
- data.sources = [];
- }
- data.sources.push(pokemon);
- }
- },
- onModifyMove(move, source, target) {
- if (target?.beingCalledBack) move.accuracy = true;
- },
- onTryHit(target, pokemon) {
- target.side.removeSideCondition('thehuntison');
- },
- onAfterMoveSecondarySelf(pokemon, target, move) {
- if (!target || target.fainted || target.hp <= 0) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Jett')}|Owned!`);
- }
- },
- condition: {
- duration: 1,
- onBeforeSwitchOut(pokemon) {
- this.debug('Thehuntison start');
- let alreadyAdded = false;
- pokemon.removeVolatile('destinybond');
- for (const source of this.effectState.sources) {
- if (!this.queue.cancelMove(source) || !source.hp) continue;
- if (!alreadyAdded) {
- this.add('-activate', pokemon, 'move: The Hunt is On!');
- alreadyAdded = true;
- }
- this.actions.runMove('thehuntison', source, source.getLocOf(pokemon));
- }
- },
- },
- secondary: null,
- target: "normal",
- type: "Dark",
- },
-
- // Jho
- genrechange: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "If the user is a Toxtricity, it changes into its Low-Key forme and Nasty Plot and Overdrive change to Aura Sphere and Boomburst, respectively. If the user is a Toxtricity in its Low-Key forme, it changes into its Amped forme and Aura Sphere and Boomburst turn into Nasty Plot and Overdrive, respectively. Raises the user's Speed by 1 stage.",
- shortDesc: "Toxtricity: +1 Speed. Changes forme.",
- name: "Genre Change",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {snatch: 1, sound: 1},
- onTryMove(pokemon, target, move) {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Screech', source);
- // The transform animation is done via `formeChange`
- },
- onHit(pokemon) {
- if (pokemon.species.baseSpecies === 'Toxtricity') {
- if (pokemon.species.forme === 'Low-Key') {
- changeSet(this, pokemon, ssbSets['Jho']);
- } else {
- changeSet(this, pokemon, ssbSets['Jho-Low-Key']);
- }
- }
- },
- boosts: {
- spe: 1,
- },
- secondary: null,
- target: "self",
- type: "Normal",
- },
-
- // Jordy
- archeopssrage: {
- accuracy: 85,
- basePower: 90,
- category: "Physical",
- desc: "Upon damaging the target, the user gains +1 Speed.",
- shortDesc: "+1 Speed upon hit.",
- name: "Archeops's Rage",
- gen: 8,
- pp: 5,
- flags: {contact: 1, protect: 1, mirror: 1},
- priority: 0,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Sunsteel Strike', target);
- },
- self: {
- boosts: {
- spe: 1,
- },
- },
- secondary: null,
- target: "normal",
- type: "Flying",
- },
-
- // Kaiju Bunny
- cozycuddle: {
- accuracy: 95,
- basePower: 0,
- category: "Status",
- desc: "Traps the target and lowers its Attack and Defense by 2 stages.",
- shortDesc: "Target: trapped, Atk and Def lowered by 2.",
- name: "Cozy Cuddle",
- gen: 8,
- pp: 20,
- priority: 0,
- flags: {contact: 1, protect: 1, reflectable: 1},
- volatileStatus: 'cozycuddle',
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onTryHit(target, source, move) {
- if (target.volatiles['cozycuddle']) return false;
- if (target.volatiles['trapped']) {
- delete move.volatileStatus;
- }
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Flatter', target);
- this.add('-anim', source, 'Let\'s Snuggle Forever', target);
- },
- onHit(target, source, move) {
- this.boost({atk: -2, def: -2}, target, target);
- },
- condition: {
- onStart(pokemon, source) {
- this.add('-start', pokemon, 'Cozy Cuddle');
- },
- onTrapPokemon(pokemon) {
- if (this.effectState.source?.isActive) pokemon.tryTrap();
- },
- },
- secondary: null,
- target: "normal",
- type: "Fairy",
- },
-
- // Kalalokki
- blackbird: {
- accuracy: 100,
- basePower: 70,
- category: "Special",
- desc: "If this move is successful and the user has not fainted, the user switches out even if it is trapped and is replaced immediately by a selected party member. The user does not switch out if there are no unfainted party members, or if the target switched out using an Eject Button or through the effect of the Emergency Exit or Wimp Out Abilities.",
- shortDesc: "User switches out after damaging the target.",
- name: "Blackbird",
- gen: 8,
- pp: 20,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- selfSwitch: true,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Gust', target);
- this.add('-anim', source, 'Parting Shot', target);
- },
- secondary: null,
- target: "normal",
- type: "Flying",
- },
- gaelstrom: {
- accuracy: true,
- basePower: 140,
- category: "Special",
- desc: "Hits foe and phazes them out, phaze the next one out and then another one, set a random entry hazard at the end of the move.",
- shortDesc: "Hits foe, phazes 3 times, sets random hazard.",
- name: "Gaelstrom",
- gen: 8,
- pp: 1,
- noPPBoosts: true,
- priority: 0,
- flags: {},
- isZ: "kalalokkiumz",
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Hurricane', target);
- },
- sideCondition: 'gaelstrom',
- condition: {
- duration: 1,
- onSwitchIn(pokemon) {
- if (!this.effectState.count) this.effectState.count = 1;
- if (this.effectState.count < 3) {
- pokemon.forceSwitchFlag = true;
- this.effectState.count++;
- return;
- }
- pokemon.side.removeSideCondition('gaelstrom');
- },
- onSideStart(side, source) {
- side.addSideCondition(['spikes', 'toxicspikes', 'stealthrock', 'stickyweb'][this.random(4)], source);
- },
- },
- forceSwitch: true,
- target: "normal",
- type: "Flying",
- },
-
- // Kennedy
- topbins: {
- accuracy: 70,
- basePower: 130,
- category: "Physical",
- desc: "Has a 20% chance to burn the target and a 10% chance to cause the target to flinch.",
- shortDesc: "20% chance to burn. 10% chance to flinch.",
- name: "Top Bins",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Pyro Ball', target);
- this.add('-anim', source, 'Blaze Kick', target);
- },
- secondaries: [{
- chance: 20,
- status: 'brn',
- }, {
- chance: 10,
- volatileStatus: 'flinch',
- }],
- target: "normal",
- type: "Fire",
- },
-
- // Kev
- kingstrident: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Raises the user's Special Attack by 1 stage and Speed by 2 stages.",
- shortDesc: "Gives user +1 SpA and +2 Spe.",
- name: "King's Trident",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target) {
- this.add('-anim', target, 'Dragon Dance', target);
- },
- self: {
- boosts: {
- spa: 1,
- spe: 2,
- },
- },
- secondary: null,
- target: "self",
- type: "Water",
- },
-
- // Kingbaruk
- leaveittotheteam: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "The user faints and the Pokemon brought out to replace it gets Healing Wish effects and has its Attack, Defense, Special Attack, and Special Defense boosted by 1 stage.",
- shortDesc: "User faints. Next: healed & +1 Atk/Def/SpA/SpD.",
- name: "Leave it to the team!",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onTryHit(source) {
- if (!this.canSwitch(source.side)) {
- this.attrLastMove('[still]');
- this.add('-fail', source);
- return this.NOT_FAIL;
- }
- },
- selfdestruct: "ifHit",
- sideCondition: 'leaveittotheteam',
- condition: {
- duration: 2,
- onSideStart(side, source) {
- this.debug('Leave it to the team! started on ' + side.name);
- this.effectState.positions = [];
- for (const i of side.active.keys()) {
- this.effectState.positions[i] = false;
- }
- this.effectState.positions[source.position] = true;
- },
- onSideRestart(side, source) {
- this.effectState.positions[source.position] = true;
- },
- onSwitchInPriority: 1,
- onSwitchIn(target) {
- const positions: boolean[] = this.effectState.positions;
- if (target.getSlot() !== this.effectState.sourceSlot) {
- return;
- }
- if (!target.fainted) {
- target.heal(target.maxhp);
- this.boost({atk: 1, def: 1, spa: 1, spd: 1}, target);
- target.clearStatus();
- for (const moveSlot of target.moveSlots) {
- moveSlot.pp = moveSlot.maxpp;
- }
- this.add('-heal', target, target.getHealth, '[from] move: Leave it to the team!');
- positions[target.position] = false;
- }
- if (!positions.some(affected => affected === true)) {
- target.side.removeSideCondition('leaveittotheteam');
- }
- },
- },
- secondary: null,
- target: "self",
- type: "Fairy",
- },
-
- // KingSwordYT
- clashofpangoros: {
- accuracy: 100,
- basePower: 90,
- category: "Physical",
- desc: "Target can't use status moves for its next 3 turns. Lowers the target's Attack by 1 stage. At the end of the move, the user switches out.",
- shortDesc: "Taunts, lowers Atk, switches out.",
- name: "Clash of Pangoros",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1, heal: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Black Hole Eclipse', target);
- },
- onHit(target, pokemon, move) {
- this.boost({atk: -1}, target, target, move);
- target.addVolatile('taunt', pokemon);
- },
- selfSwitch: true,
- secondary: null,
- target: "normal",
- type: "Dark",
- },
-
- // Kipkluif
- kipup: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "The user will survive attacks made by other Pokemon during this turn with at least 1 HP. When used, if hit by an attack on the same turn this move was used, this Pokemon boosts its Defense and Special Defense by 2 stages if the relevant stat is at 0 or lower, or 1 stage if the relevant stat is at +1 or higher, and increases priority of the next used move by 1.",
- shortDesc: "Endure;If hit, +Def/SpD; next move +1 prio.",
- name: "Kip Up",
- pp: 10,
- priority: 3,
- flags: {},
- onTryMove(source) {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Focus Energy', source);
- },
- onHit(target, pokemon, move) {
- if (pokemon.volatiles['kipup']) return false;
- pokemon.addVolatile('kipup');
- },
- condition: {
- duration: 1,
- onStart(pokemon) {
- this.add('-message', 'This Pokémon prepares itself to be knocked down!');
- },
- onDamagePriority: -10,
- onDamage(damage, target, source, effect) {
- if (this.effectState.gotHit) return damage;
- if (effect?.effectType === 'Move' && damage >= target.hp) {
- this.add('-activate', target, 'move: Kip Up');
- return target.hp - 1;
- }
- },
- onHit(pokemon, source, move) {
- if (!pokemon.hp) return;
- if (this.effectState.gotHit) return;
- if (!pokemon.isAlly(source) && move.category !== 'Status') {
- this.effectState.gotHit = true;
- this.add('-message', 'Gossifleur was prepared for the impact!');
- const boosts: {[k: string]: number} = {def: 2, spd: 2};
- if (pokemon.boosts.def >= 1) boosts.def--;
- if (pokemon.boosts.spd >= 1) boosts.spd--;
- this.boost(boosts, pokemon);
- this.add('-message', "Gossifleur did a Kip Up and can jump right back into the action!");
- this.effectState.duration++;
- }
- },
- onModifyPriority(priority, pokemon, target, move) {
- if (!this.effectState.gotHit) return priority;
- return priority + 1;
- },
- },
- secondary: null,
- target: "self",
- type: "Fighting",
- },
-
- // Kris
- alphabetsoup: {
- accuracy: true,
- basePower: 100,
- category: "Special",
- desc: "The user changes into a random Pokemon with a first name letter that matches the forme Unown is currently in (A -> Alakazam, etc) that has base stats that would benefit from Unown's EV/IV/Nature spread and moves. Using it while in a forme that is not Unown will make it revert back to the Unown forme it transformed in (If an Unown transforms into Alakazam, it'll transform back to Unown-A when used again). Light of Ruin becomes Strange Steam, Psystrike becomes Psyshock, Secret Sword becomes Aura Sphere, Mind Blown becomes Flamethrower, and Seed Flare becomes Apple Acid while in a non-Unown forme. This move's type varies based on the user's primary type.",
- shortDesc: "Transform into Unown/mon. Type=user 1st type.",
- name: "Alphabet Soup",
- gen: 8,
- pp: 20,
- priority: 0,
- flags: {protect: 1},
- onTryMove(source) {
- this.attrLastMove('[still]');
- if (source.name !== 'Kris') {
- this.add('-fail', source);
- this.hint("Only Kris can use Alphabet Soup.");
- return null;
- }
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Dark Pulse', target);
- this.add('-anim', source, 'Teleport', source);
- },
- onModifyType(move, pokemon) {
- let type = pokemon.types[0];
- if (type === "Bird") type = "???";
- move.type = type;
- },
- onHit(target, source) {
- if (!source) return;
- if (source.species.id.includes('unown')) {
- const monList = Object.keys(this.dex.data.Pokedex).filter(speciesid => {
- const species = this.dex.species.get(speciesid);
- if (species.id.startsWith('unown')) return false;
- if (species.isNonstandard && ['Gigantamax', 'Unobtainable'].includes(species.isNonstandard)) return false;
- if (['Arceus', 'Silvally'].includes(species.baseSpecies) && species.types[0] !== 'Normal') return false;
- if (species.baseStats.spa < 80) return false;
- if (species.baseStats.spe < 80) return false;
- const unownLetter = source.species.id.charAt(5) || 'a';
- if (!species.id.startsWith(unownLetter.trim().toLowerCase())) return false;
- return true;
- });
- source.formeChange(this.sample(monList), this.effect);
- source.setAbility('Protean');
- source.moveSlots = source.moveSlots.map(slot => {
- const newMoves: {[k: string]: string} = {
- lightofruin: 'strangesteam',
- psystrike: 'psyshock',
- secretsword: 'aurasphere',
- mindblown: 'flamethrower',
- seedflare: 'appleacid',
- };
- if (slot.id in newMoves) {
- const newMove = this.dex.moves.get(newMoves[slot.id]);
- const newSlot = {
- id: newMove.id,
- move: newMove.name,
- pp: newMove.pp * 8 / 5,
- maxpp: newMove.pp * 8 / 5,
- disabled: slot.disabled,
- used: false,
- };
- return newSlot;
- }
- return slot;
- });
- } else {
- let transformingLetter = source.species.id[0];
- if (transformingLetter === 'a') transformingLetter = '';
- source.formeChange(`unown${transformingLetter}`, this.effect, true);
- source.moveSlots = source.moveSlots.map(slot => {
- const newMoves: {[k: string]: string} = {
- strangesteam: 'lightofruin',
- psyshock: 'psystrike',
- aurasphere: 'secretsword',
- flamethrower: 'mindblown',
- appleacid: 'seedflare',
- };
- if (slot.id in newMoves) {
- const newMove = this.dex.moves.get(newMoves[slot.id]);
- const newSlot = {
- id: newMove.id,
- move: newMove.name,
- pp: newMove.pp * 8 / 5,
- maxpp: newMove.pp * 8 / 5,
- disabled: slot.disabled,
- used: false,
- };
- return newSlot;
- }
- return slot;
- });
- }
- },
- secondary: null,
- target: "normal",
- type: "Dark",
- },
-
- // Lamp
- soulswap: {
- accuracy: 100,
- basePower: 90,
- category: "Special",
- desc: "The user copies the target's positive stat stage changes and then inverts the target's stats.",
- shortDesc: "Copies target's stat boosts then inverts.",
- name: "Soul Swap",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Spectral Thief', target);
- this.add('-anim', source, 'Teleport', source);
- this.add('-anim', source, 'Topsy-Turvy', target);
- },
- onHit(target, source) {
- let i: BoostID;
- const boosts: SparseBoostsTable = {};
- for (i in target.boosts) {
- const stage = target.boosts[i];
- if (stage > 0) {
- boosts[i] = stage;
- }
- if (target.boosts[i] !== 0) {
- target.boosts[i] = -target.boosts[i];
- }
- }
- this.add('-message', `${source.name} stole ${target.name}'s boosts!`);
- this.boost(boosts, source);
- this.add('-invertboost', target, '[from] move: Soul Swap');
- },
- secondary: null,
- target: "normal",
- type: "Ghost",
- },
-
- // Lionyx
- bigbang: {
- accuracy: 100,
- basePower: 120,
- category: "Special",
- desc: "The user loses HP equal to 33% of the damage dealt by this attack. Resets the field by clearing all hazards, terrains, screens, and weather.",
- shortDesc: "33% recoil; removes field conditions.",
- name: "Big Bang",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Extreme Evoboost', source);
- this.add('-anim', source, 'Light of Ruin', target);
- this.add('-anim', source, 'Dark Void', target);
- },
- onHit(target, source, move) {
- let success = false;
- const removeAll = [
- 'reflect', 'lightscreen', 'auroraveil', 'safeguard', 'mist',
- 'spikes', 'toxicspikes', 'stealthrock', 'stickyweb',
- ];
- const silentRemove = ['reflect', 'lightscreen', 'auroraveil', 'safeguard', 'mist'];
- for (const sideCondition of removeAll) {
- if (target.side.removeSideCondition(sideCondition)) {
- if (!silentRemove.includes(sideCondition)) {
- this.add('-sideend', target.side, this.dex.conditions.get(sideCondition).name, '[from] move: Big Bang', '[of] ' + source);
- }
- success = true;
- }
- if (source.side.removeSideCondition(sideCondition)) {
- if (!silentRemove.includes(sideCondition)) {
- this.add('-sideend', source.side, this.dex.conditions.get(sideCondition).name, '[from] move: Big Bang', '[of] ' + source);
- }
- success = true;
- }
- }
- this.field.clearTerrain();
- this.field.clearWeather();
- return success;
- },
- recoil: [33, 100],
- secondary: null,
- target: "normal",
- type: "Fairy",
- },
-
- // LittEleven
- nexthunt: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "If this Pokemon does not take damage this turn, it switches out to another Pokemon in the party and gives it a +2 boost corresponding to its highest stat. Fails otherwise.",
- shortDesc: "Focus: switch out, next Pokemon +2 Beast Boost.",
- name: "/nexthunt",
- pp: 10,
- priority: -6,
- flags: {snatch: 1},
- beforeTurnCallback(pokemon) {
- pokemon.addVolatile('nexthuntcheck');
- },
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Teleport', source);
- },
- beforeMoveCallback(pokemon) {
- if (pokemon.volatiles['nexthuntcheck'] && pokemon.volatiles['nexthuntcheck'].lostFocus) {
- this.add('cant', pokemon, '/nexthunt', '/nexthunt');
- return true;
- }
- },
- onHit(target, source, move) {
- this.add('-message', 'Time for the next hunt!');
- },
- sideCondition: 'nexthunt',
- condition: {
- duration: 1,
- onSideStart(side, source) {
- this.debug('/nexthunt started on ' + side.name);
- this.effectState.positions = [];
- for (const i of side.active.keys()) {
- this.effectState.positions[i] = false;
- }
- this.effectState.positions[source.position] = true;
- },
- onSideRestart(side, source) {
- this.effectState.positions[source.position] = true;
- },
- onSwitchInPriority: 1,
- onSwitchIn(target) {
- this.add('-activate', target, 'move: /nexthunt');
- let statName = 'atk';
- let bestStat = 0;
- let s: StatIDExceptHP;
- for (s in target.storedStats) {
- if (target.storedStats[s] > bestStat) {
- statName = s;
- bestStat = target.storedStats[s];
- }
- }
- this.boost({[statName]: 2}, target, null, this.dex.getActiveMove('/nexthunt'));
- },
- },
- selfSwitch: true,
- secondary: null,
- target: "self",
- type: "Normal",
- },
-
- // Lunala
- hatofwisdom: {
- accuracy: 100,
- basePower: 110,
- category: "Special",
- desc: "The user switches out, and this move deals damage one turn after it is used. At the end of that turn, the damage is calculated at that time and dealt to the Pokemon at the position the target had when the move was used. If the user is no longer active at the time, damage is calculated based on the user's natural Special Attack stat, types, and level, with no boosts from its held item or Ability. Fails if this move, Future Sight, or Doom Desire is already in effect for the target's position.",
- shortDesc: "Hits 1 turn after being used. User switches.",
- name: "Hat of Wisdom",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {futuremove: 1},
- ignoreImmunity: true,
- onTry(source, target) {
- this.attrLastMove('[still]');
- if (!target.side.addSlotCondition(target, 'futuremove')) return false;
- this.add('-anim', source, 'Calm Mind', target);
- this.add('-anim', source, 'Teleport', target);
- Object.assign(target.side.slotConditions[target.position]['futuremove'], {
- duration: 2,
- move: 'hatofwisdom',
- source: source,
- moveData: {
- id: 'hatofwisdom',
- name: "Hat of Wisdom",
- accuracy: 100,
- basePower: 110,
- category: "Special",
- priority: 0,
- flags: {futuremove: 1},
- ignoreImmunity: false,
- effectType: 'Move',
- type: 'Psychic',
- },
- });
- this.add('-start', source, 'move: Hat of Wisdom');
- source.switchFlag = 'hatofwisdom' as ID;
- return this.NOT_FAIL;
- },
- secondary: null,
- target: "normal",
- type: "Psychic",
- },
-
- // Mad Monty ¾°
- callamaty: {
- accuracy: 100,
- basePower: 75,
- category: "Physical",
- desc: "30% chance to paralyze. Starts Rain Dance if not currently active.",
- shortDesc: "30% paralyze. Sets Rain Dance.",
- name: "Ca-LLAMA-ty",
- pp: 10,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Dark Void', target);
- this.add('-anim', source, 'Plasma Fists', target);
- },
- secondary: {
- chance: 30,
- status: 'par',
- },
- self: {
- onHit(source) {
- this.field.setWeather('raindance');
- },
- },
- target: "normal",
- type: "Electric",
- },
-
- // MajorBowman
- corrosivecloud: {
- accuracy: true,
- basePower: 90,
- category: "Special",
- desc: "Has a 30% chance to burn the target. This move's type effectiveness against Steel is changed to be super effective no matter what this move's type is.",
- shortDesc: "30% chance to burn. Super effective on Steel.",
- name: "Corrosive Cloud",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Poison Gas', target);
- this.add('-anim', source, 'Fire Spin', target);
- },
- onEffectiveness(typeMod, target, type) {
- if (type === 'Steel') return 1;
- },
- ignoreImmunity: {'Poison': true},
- secondary: {
- chance: 30,
- status: 'brn',
- },
- target: "normal",
- type: "Poison",
- },
-
- // Marshmallon
- rawwwr: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Heals the user by 33% of its max HP. Forces the target to switch to a random ally. User switches out after.",
- shortDesc: "33% heal. Force out target, then switch.",
- name: "RAWWWR",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {reflectable: 1, mirror: 1, sound: 1, bypasssub: 1, heal: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Slack Off', source);
- this.add('-anim', source, 'Roar of Time', target);
- this.add('-anim', source, 'Roar', target);
- },
- onAfterMoveSecondarySelf(pokemon, target, move) {
- this.heal(pokemon.maxhp / 3, pokemon, pokemon, move);
- },
- forceSwitch: true,
- selfSwitch: true,
- secondary: null,
- target: "normal",
- type: "Dark",
- },
-
- // Meicoo
- spamguess: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Calls the following moves in order, each with their normal respective accuracy: Haze -> Worry Seed -> Poison Powder -> Stun Spore -> Leech Seed -> Struggle (150 BP)",
- shortDesc: "Does many things then struggles.",
- name: "spamguess",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- // fruit this move.
- onHit(target, source) {
- for (const move of ['Haze', 'Worry Seed', 'Poison Powder', 'Stun Spore', 'Leech Seed']) {
- this.actions.useMove(move, source);
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Meicoo')}|That is not the answer - try again!`);
- }
- const strgl = this.dex.getActiveMove('Struggle');
- strgl.basePower = 150;
- this.actions.useMove(strgl, source);
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Meicoo')}|That is not the answer - try again!`);
- },
- secondary: null,
- target: "self",
- type: "Fighting",
- },
-
-
- // Mitsuki
- terraforming: {
- accuracy: 100,
- basePower: 70,
- category: "Physical",
- desc: "Upon use, this move sets up Stealth Rock on the target's side of the field.",
- shortDesc: "Sets up Stealth Rock.",
- name: "Terraforming",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Rock Slide', target);
- this.add('-anim', source, 'Ingrain', target);
- this.add('-anim', source, 'Stealth Rock', target);
- },
- sideCondition: 'stealthrock',
- secondary: null,
- target: "normal",
- type: "Rock",
- },
-
- // n10siT
- "unbind": {
- accuracy: 100,
- basePower: 60,
- category: "Special",
- desc: "Has a 100% chance to raise the user's Speed by 1 stage. If the user is a Hoopa in its Confined forme, this move is Psychic type, and Hoopa will change into its Unbound forme. If the user is a Hoopa in its Unbound forme, this move is Dark type, and Hoopa will change into its Confined forme. This move cannot be used successfully unless the user's current form, while considering Transform, is Confined or Unbound Hoopa.",
- shortDesc: "Hoopa: Psychic; Unbound: Dark; 100% +1 Spe. Changes form.",
- name: "Unbind",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1},
- onTryMove(pokemon, target, move) {
- this.attrLastMove('[still]');
- if (pokemon.species.baseSpecies === 'Hoopa') {
- return;
- }
- this.add('-fail', pokemon, 'move: Unbind');
- this.hint("Only a Pokemon whose form is Hoopa or Hoopa-Unbound can use this move.");
- return null;
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Hyperspace Hole', target);
- this.add('-anim', source, 'Hyperspace Fury', target);
- },
- onHit(target, pokemon, move) {
- if (pokemon.baseSpecies.baseSpecies === 'Hoopa') {
- const forme = pokemon.species.forme === 'Unbound' ? '' : '-Unbound';
- pokemon.formeChange(`Hoopa${forme}`, this.effect, false, '[msg]');
- this.boost({spe: 1}, pokemon, pokemon, move);
- }
- },
- onModifyType(move, pokemon) {
- if (pokemon.baseSpecies.baseSpecies !== 'Hoopa') return;
- move.type = pokemon.species.name === 'Hoopa-Unbound' ? 'Dark' : 'Psychic';
- },
- secondary: null,
- target: "normal",
- type: "Psychic",
- },
-
- // naziel
- notsoworthypirouette: {
- accuracy: 100,
- basePower: 0,
- category: "Status",
- desc: "50% chance to OHKO the target; otherwise, it OHKOes itself. On successive uses, this move has a 1/X chance of OHKOing the target, where X starts at 2 and doubles each time this move OHKOes the target. X resets to 2 if this move is not used in a turn.",
- shortDesc: "50/50 to KO target/self. Worse used repeatedly.",
- name: "Not-so-worthy Pirouette",
- pp: 5,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, "High Jump Kick", target);
- },
- onHit(target, source) {
- source.addVolatile('notsoworthypirouette');
- const chance = source.volatiles['notsoworthypirouette']?.counter ? source.volatiles['notsoworthypirouette'].counter : 2;
- if (this.randomChance(1, chance)) {
- target.faint();
- } else {
- source.faint();
- }
- },
- condition: {
- duration: 2,
- onStart() {
- this.effectState.counter = 2;
- },
- onRestart() {
- this.effectState.counter *= 2;
- this.effectState.duration = 2;
- },
- },
- secondary: null,
- target: "normal",
- type: "Fairy",
- },
-
- // Theia
- madhacks: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Raises the user's Defense, Special Attack, and Special Defense by 1 stage. Sets Trick Room.",
- shortDesc: "+1 Def/Spa/Spd. Sets Trick Room.",
- name: "Mad Hacks",
- gen: 8,
- pp: 5,
- priority: -7,
- flags: {snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Acupressure', source);
- },
- onHit(target, source) {
- this.field.addPseudoWeather('trickroom');
- },
- boosts: {
- def: 1,
- spa: 1,
- spd: 1,
- },
- secondary: null,
- target: "self",
- type: "Ghost",
- },
-
- // Notater517
- technotubertransmission: {
- accuracy: 90,
- basePower: 145,
- category: "Special",
- desc: "If this move is successful, the user must recharge on the following turn and cannot select a move.",
- shortDesc: "User cannot move next turn.",
- name: "Techno Tuber Transmission",
- pp: 5,
- priority: 0,
- flags: {recharge: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Techno Blast', target);
- this.add('-anim', source, 'Never-Ending Nightmare', target);
- },
- onHit() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Notater517')}|/html For more phantasmic music, check out this link.`);
- },
- self: {
- volatileStatus: 'mustrecharge',
- },
- secondary: null,
- target: "normal",
- type: "Ghost",
- },
-
- // nui
- wincondition: {
- accuracy: 100,
- basePower: 0,
- category: "Status",
- desc: "Inflicts the opponent with random status of sleep, paralysis, burn, or toxic. Then uses Dream Eater, Iron Head, Fire Blast, or Venoshock, respectively.",
- shortDesc: "Chooses one of four move combos at random.",
- name: "Win Condition",
- pp: 10,
- priority: 0,
- flags: {protect: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, "Celebrate", target);
- },
- onHit(target, source) {
- const hax = this.sample(['slp', 'brn', 'par', 'tox']);
- target.trySetStatus(hax, source);
- if (hax === 'slp') {
- this.actions.useMove('Dream Eater', source);
- } else if (hax === 'par') {
- this.actions.useMove('Iron Head', source);
- } else if (hax === 'brn') {
- this.actions.useMove('Fire Blast', source);
- } else if (hax === 'tox') {
- this.actions.useMove('Venoshock', source);
- }
- },
- secondary: null,
- target: "normal",
- type: "Fairy",
- },
-
- // OM~!
- omzoom: {
- accuracy: 100,
- basePower: 70,
- category: "Physical",
- desc: "If this move is successful and the user has not fainted, the user switches out even if it is trapped and is replaced immediately by a selected party member. The user does not switch out if there are no unfainted party members, or if the target switched out using an Eject Button or through the effect of the Emergency Exit or Wimp Out Abilities.",
- shortDesc: "User switches out after damaging the target.",
- name: "OM Zoom",
- gen: 8,
- pp: 10,
- priority: 0,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Icicle Spear', target);
- this.add('-anim', source, 'U-turn', target);
- },
- onHit() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('OM~!')}|Bang Bang`);
- },
- flags: {protect: 1, mirror: 1},
- selfSwitch: true,
- secondary: null,
- target: "normal",
- type: "Ice",
- },
-
- // Overneat
- healingyou: {
- accuracy: 100,
- basePower: 115,
- category: "Physical",
- desc: "Heals the target by 50% of their maximum HP and eliminates any status problem before dealing damage, and lowers the target's Defense and Special Defense stat by 1 stage after dealing damage.",
- shortDesc: "Foe: heal 50%HP & status, dmg, then -1 Def/SpD.",
- name: "Healing you?",
- gen: 8,
- pp: 5,
- priority: 0,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Heal Pulse', target);
- this.heal(Math.ceil(target.baseMaxhp * 0.5));
- target.cureStatus();
- this.add('-anim', source, 'Close Combat', target);
- },
- flags: {contact: 1, mirror: 1, protect: 1},
- secondary: {
- chance: 100,
- boosts: {
- def: -1,
- spd: -1,
- },
- },
- target: "normal",
- type: "Dark",
- },
-
- // Pants
- wistfulthinking: {
- accuracy: 100,
- basePower: 0,
- category: "Status",
- desc: "Burns the target and switches out. The next Pokemon on the user's side heals 1/16 of their maximum HP per turn until they switch out.",
- shortDesc: "Burn foe; switch out. Heals replacement.",
- name: "Wistful Thinking",
- pp: 10,
- priority: 0,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Will-O-Wisp', target);
- this.add('-anim', source, 'Parting Shot', target);
- },
- onHit(target, source) {
- target.trySetStatus('brn', source);
- },
- self: {
- sideCondition: 'givewistfulthinking',
- },
- condition: {
- onStart(pokemon) {
- this.add('-start', pokemon, 'move: Wistful Thinking');
- },
- onResidualOrder: 5,
- onResidualSubOrder: 5,
- onResidual(pokemon) {
- this.heal(pokemon.baseMaxhp / 16);
- },
- },
- flags: {protect: 1, reflectable: 1},
- selfSwitch: true,
- secondary: null,
- target: "normal",
- type: "Ghost",
- },
-
- // Paradise
- rapidturn: {
- accuracy: 100,
- basePower: 50,
- category: "Physical",
- desc: "Removes entry hazards, then user switches out after dealing damage",
- shortDesc: "Removes hazards then switches out",
- name: "Rapid Turn",
- gen: 8,
- pp: 20,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Rapid Spin', target);
- this.add('-anim', source, 'U-turn', target);
- },
- onAfterHit(target, pokemon) {
- const sideConditions = [
- 'spikes', 'toxicspikes', 'stealthrock', 'stickyweb', 'gmaxsteelsurge',
- ];
- for (const condition of sideConditions) {
- if (pokemon.hp && pokemon.side.removeSideCondition(condition)) {
- this.add('-sideend', pokemon.side, this.dex.conditions.get(condition).name, '[from] move: Rapid Turn', '[of] ' + pokemon);
- }
- }
- if (pokemon.hp && pokemon.volatiles['partiallytrapped']) {
- pokemon.removeVolatile('partiallytrapped');
- }
- },
- onAfterSubDamage(damage, target, pokemon) {
- const sideConditions = [
- 'spikes', 'toxicspikes', 'stealthrock', 'stickyweb', 'gmaxsteelsurge',
- ];
- for (const condition of sideConditions) {
- if (pokemon.hp && pokemon.side.removeSideCondition(condition)) {
- this.add('-sideend', pokemon.side, this.dex.conditions.get(condition).name, '[from] move: Rapid Turn', '[of] ' + pokemon);
- }
- }
- if (pokemon.hp && pokemon.volatiles['partiallytrapped']) {
- pokemon.removeVolatile('partiallytrapped');
- }
- },
- selfSwitch: true,
- secondary: null,
- target: "normal",
- type: "Normal",
- },
-
- // PartMan
- balefulblaze: {
- accuracy: 100,
- basePower: 75,
- basePowerCallback(pokemon) {
- if (pokemon.set.shiny) {
- return 95;
- }
- return 75;
- },
- category: "Special",
- desc: "This move combines Ghost in its type effectiveness against the target. Raises the user's Special Attack by 1 stage if this move knocks out the target. If the user is shiny, the move's Base Power becomes 95.",
- shortDesc: "+Ghost. +1 SpA if KOes target. Shiny: BP=95.",
- name: "Baleful Blaze",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1, defrost: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Inferno', target);
- this.add('-anim', source, 'Hex', target);
- },
- onEffectiveness(typeMod, target, type, move) {
- return typeMod + this.dex.getEffectiveness('Ghost', type);
- },
- onAfterMoveSecondarySelf(pokemon, target, move) {
- if (!target || target.fainted || target.hp <= 0) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('PartMan')}|FOR SNOM!`);
- this.boost({spa: 1}, pokemon, pokemon, move);
- }
- },
- secondary: null,
- target: "normal",
- type: "Fire",
- },
-
- // peapod c
- submartingale: {
- accuracy: 100,
- basePower: 0,
- category: "Status",
- desc: "Inflicts the target with burn, toxic, or paralysis, then sets up a Substitute.",
- shortDesc: "Inflicts burn/toxic/paralysis. Makes Substitute.",
- name: "Submartingale",
- pp: 10,
- priority: 0,
- flags: {protect: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, "Dark Void", target);
- this.add('-anim', source, "Celebrate", target);
- },
- onTryHit(target, source) {
- this.actions.useMove('Substitute', source);
- },
- onHit(target, source) {
- target.trySetStatus('brn', source);
- target.trySetStatus('tox', source);
- target.trySetStatus('par', source);
- },
- secondary: null,
- target: "normal",
- type: "Dark",
- },
-
- // Perish Song
- trickery: {
- accuracy: 95,
- basePower: 100,
- category: "Physical",
- desc: "Changes the target's item to something random.",
- shortDesc: "Changes the target's item to something random.",
- name: "Trickery",
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, "Amnesia", source);
- this.add('-anim', source, "Trick", target);
- },
- onHit(target, source, effect) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Perish Song')}|/html `);
- const item = target.takeItem(source);
- if (!target.item) {
- if (item) this.add('-enditem', target, item.name, '[from] move: Trickery', '[of] ' + source);
- const items = this.dex.items.all().map(i => i.name);
- let randomItem = '';
- if (items.length) randomItem = this.sample(items);
- if (!randomItem) {
- return;
- }
- if (target.setItem(randomItem)) {
- this.add('-item', target, randomItem, '[from] move: Trickery', '[of] ' + source);
- }
- }
- },
- secondary: null,
- target: "normal",
- type: "Ground",
- },
-
- // phiwings99
- ghostof1v1past: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Imprisons and traps the target, and then transforms into them. The user will be trapped after the use of this move. The user faints if the target faints.",
- shortDesc: "Trap + ImprisonForm. Faints if the target faints.",
- name: "Ghost of 1v1 Past",
- gen: 8,
- pp: 1,
- noPPBoosts: true,
- priority: 0,
- flags: {protect: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Imprison', source);
- this.add('-anim', source, 'Mean Look', target);
- this.add('-anim', source, 'Transform', target);
- },
- onHit(target, pokemon, move) {
- target.addVolatile('trapped', pokemon, move, 'trapper');
- pokemon.addVolatile('imprison', pokemon, move);
- if (!pokemon.transformInto(target)) {
- return false;
- }
- pokemon.addVolatile('trapped', target, move, 'trapper');
- pokemon.addVolatile('ghostof1v1past', pokemon);
- pokemon.volatiles['ghostof1v1past'].targetPokemon = target;
- },
- condition: {
- onAnyFaint(target) {
- if (target === this.effectState.targetPokemon) this.effectState.source.faint();
- },
- },
- secondary: null,
- target: "normal",
- type: "Ghost",
- },
-
- // piloswine gripado
- iciclespirits: {
- accuracy: 100,
- basePower: 90,
- category: "Physical",
- desc: "The user recovers 1/2 the HP lost by the target, rounded half up. If Big Root is held by the user, the HP recovered is 1.3x normal, rounded half down.",
- shortDesc: "User recovers 50% of the damage dealt.",
- name: "Icicle Spirits",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1, heal: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Horn Leech', target);
- },
- drain: [1, 2],
- secondary: null,
- target: "normal",
- type: "Ice",
- },
-
- // PiraTe Princess
- dungeonsdragons: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Prevents the target from switching out and adds Dragon to the target's type. Has a 5% chance to either confuse the user or guarantee that the next attack is a critical hit, 15% chance to raise the user's Attack, Defense, Special Attack, Special Defense, or Speed by 1 stage, and a 15% chance to raise user's Special Attack and Speed by 1 stage.",
- shortDesc: "Target: can't switch,+Dragon. Does other things.",
- name: "Dungeons & Dragons",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Imprison', target);
- this.add('-anim', source, 'Trick-or-Treat', target);
- this.add('-anim', source, 'Shell Smash', source);
- },
- onHit(target, source, move) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('PiraTe Princess')}|did someone say d&d?`);
- target.addVolatile('trapped', source, move, 'trapper');
- if (!target.hasType('Dragon') && target.addType('Dragon')) {
- this.add('-start', target, 'typeadd', 'Dragon', '[from] move: Dungeons & Dragons');
- }
- const result = this.random(21);
- if (result === 20) {
- source.addVolatile('laserfocus');
- } else if (result >= 2 && result <= 16) {
- const boost: SparseBoostsTable = {};
- const stats: BoostID[] = ['atk', 'def', 'spa', 'spd', 'spe'];
- boost[stats[this.random(5)]] = 1;
- this.boost(boost, source);
- } else if (result >= 17 && result <= 19) {
- this.boost({spa: 1, spe: 1}, source);
- } else {
- source.addVolatile('confusion');
- }
- },
- target: "normal",
- type: "Dragon",
- },
-
- // Psynergy
- clearbreath: {
- accuracy: 100,
- basePower: 0,
- basePowerCallback(pokemon, target) {
- let power = 60 + 20 * target.positiveBoosts();
- if (power > 200) power = 200;
- return power;
- },
- category: "Special",
- desc: "Power is equal to 60+(X*20), where X is the target's total stat stage changes that are greater than 0, but not more than 200 power.",
- shortDesc: "60 power +20 for each of the target's stat boosts.",
- gen: 8,
- name: "Clear Breath",
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Dragon Breath', target);
- this.add('-anim', source, 'Haze', target);
- },
- secondary: null,
- target: "normal",
- type: "Flying",
- },
-
- // ptoad
- croak: {
- accuracy: 100,
- basePower: 20,
- basePowerCallback(pokemon, target, move) {
- const bp = move.basePower + 20 * pokemon.positiveBoosts();
- return bp;
- },
- category: "Special",
- desc: "Power is equal to 20+(X*20), where X is the user's total stat stage changes that are greater than 0. User raises 2 random stats by 1 if it has less than 8 positive stat changes.",
- shortDesc: "+20 power/boost. +1 2 random stats < 8 boosts.",
- name: "Croak",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1, sound: 1, bypasssub: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source, move) {
- this.add('-anim', source, 'Splash', source);
- if (source.positiveBoosts() < 8) {
- const stats: BoostID[] = [];
- let stat: BoostID;
- const exclude: string[] = ['accuracy', 'evasion'];
- for (stat in source.boosts) {
- if (source.boosts[stat] < 6 && !exclude.includes(stat)) {
- stats.push(stat);
- }
- }
- if (stats.length) {
- let randomStat = this.sample(stats);
- const boost: SparseBoostsTable = {};
- boost[randomStat] = 1;
- if (stats.length > 1) {
- stats.splice(stats.indexOf(randomStat), 1);
- randomStat = this.sample(stats);
- boost[randomStat] = 1;
- }
- this.boost(boost, source, source, move);
- }
- }
- this.add('-anim', source, 'Hyper Voice', source);
- },
- secondary: null,
- target: "normal",
- type: "Water",
- },
-
- // used for ptoad's ability
- swampyterrain: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "For 5 turns, the terrain becomes Swampy Terrain. During the effect, the power of Electric-type, Grass-type, and Ice-type attacks made by grounded Pokemon are halved and Water and Ground types heal 1/16 at the end of each turn if grounded. Fails if the current terrain is Swampy Terrain.",
- shortDesc: "5trn. Grounded:-Elec/Grs/Ice pow, Wtr/Grd:Lefts.",
- name: "Swampy Terrain",
- pp: 10,
- priority: 0,
- flags: {nonsky: 1},
- terrain: 'swampyterrain',
- condition: {
- duration: 5,
- durationCallback(source, effect) {
- if (source?.hasItem('terrainextender')) {
- return 8;
- }
- return 5;
- },
- onBasePowerPriority: 6,
- onBasePower(basePower, attacker, defender, move) {
- if (['Electric', 'Grass', 'Ice'].includes(move.type) && attacker.isGrounded() && !attacker.isSemiInvulnerable()) {
- this.debug('swampy terrain weaken');
- return this.chainModify(0.5);
- }
- },
- onFieldStart(field, source, effect) {
- if (effect?.effectType === 'Ability') {
- this.add('-fieldstart', 'move: Swampy Terrain', '[from] ability: ' + effect, '[of] ' + source);
- } else {
- this.add('-fieldstart', 'move: Swampy Terrain');
- }
- this.add('-message', 'The battlefield became swamped!');
- },
- onResidualOrder: 5,
- onResidual(pokemon) {
- if ((pokemon.hasType('Water') || pokemon.hasType('Ground')) && pokemon.isGrounded() && !pokemon.isSemiInvulnerable()) {
- this.debug('Pokemon is grounded and a Water or Ground type, healing through Swampy Terrain.');
- if (this.heal(pokemon.baseMaxhp / 16, pokemon, pokemon)) {
- this.add('-message', `${pokemon.name} was healed by the terrain!`);
- }
- }
- },
- onFieldResidualOrder: 21,
- onFieldResidualSubOrder: 3,
- onFieldEnd() {
- this.add('-fieldend', 'move: Swampy Terrain');
- },
- },
- secondary: null,
- target: "all",
- type: "Ground",
- },
-
- // Rabia
- psychodrive: {
- accuracy: 100,
- basePower: 80,
- category: "Special",
- desc: "Has a 30% chance to boost the user's Speed by 1 stage.",
- shortDesc: "30% chance to boost the user's Spe by 1.",
- name: "Psycho Drive",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Genesis Supernova', target);
- },
- secondary: {
- chance: 30,
- self: {
- boosts: {spe: 1},
- },
- },
- target: "normal",
- type: "Psychic",
- },
-
- // Rach
- spindawheel: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "The user uses a random hazard-setting move; burns, badly poisons, or paralyzes the target; and then switches out.",
- shortDesc: "Sets random hazard; brn/tox/par; switches.",
- name: "Spinda Wheel",
- gen: 8,
- pp: 20,
- priority: 0,
- flags: {reflectable: 1, protect: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- target.m.spindaHazard = this.sample(['Sticky Web', 'Stealth Rock', 'Spikes', 'Toxic Spikes', 'G-Max Steelsurge']);
- target.m.spindaStatus = this.sample(['Thunder Wave', 'Toxic', 'Will-O-Wisp']);
- if (target.m.spindaHazard) {
- this.add('-anim', source, target.m.spindaHazard, target);
- }
- if (target.m.spindaStatus) {
- this.add('-anim', source, target.m.spindaStatus, target);
- }
- },
- onHit(target, source, move) {
- if (target) {
- if (target.m.spindaHazard) {
- target.side.addSideCondition(target.m.spindaHazard);
- }
- if (target.m.spindaStatus) {
- const s = target.m.spindaStatus;
- target.trySetStatus(s === 'Toxic' ? 'tox' : s === 'Thunder Wave' ? 'par' : 'brn');
- }
- }
- },
- selfSwitch: true,
- secondary: null,
- target: "normal",
- type: "Normal",
- },
-
- // Rage
- shockedlapras: {
- accuracy: 100,
- basePower: 75,
- category: "Special",
- desc: "Has a 100% chance to paralyze the user.",
- shortDesc: "100% chance to paralyze the user.",
- name: ":shockedlapras:",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Thunder', target);
- if (!source.status) this.add('-anim', source, 'Thunder Wave', source);
- },
- onHit() {
- this.add(`raw|`);
- },
- secondary: {
- chance: 100,
- self: {
- status: 'par',
- },
- },
- target: "normal",
- type: "Electric",
- },
-
- // used for Rage's ability
- inversionterrain: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "For 5 turns, the terrain becomes Inversion Terrain. During the effect, the the type chart is inverted, and grounded, paralyzed Pokemon have their Speed doubled. Fails if the current terrain is Inversion Terrain.",
- shortDesc: "5 turns. Type chart inverted. Par: 2x Spe.",
- name: "Inversion Terrain",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {},
- terrain: 'inversionterrain',
- condition: {
- duration: 5,
- durationCallback(source, effect) {
- if (source?.hasItem('terrainextender')) {
- return 8;
- }
- return 5;
- },
- onNegateImmunity: false,
- onEffectivenessPriority: 1,
- onEffectiveness(typeMod, target, type, move) {
- // The effectiveness of Freeze Dry on Water isn't reverted
- if (move && move.id === 'freezedry' && type === 'Water') return;
- if (move && !this.dex.getImmunity(move, type)) return 1;
- return -typeMod;
- },
- onFieldStart(field, source, effect) {
- if (effect?.effectType === 'Ability') {
- this.add('-fieldstart', 'move: Inversion Terrain', '[from] ability: ' + effect, '[of] ' + source);
- } else {
- this.add('-fieldstart', 'move: Inversion Terrain');
- }
- this.add('-message', 'The battlefield became upside down!');
- },
- onFieldResidualOrder: 21,
- onFieldResidualSubOrder: 3,
- onFieldEnd() {
- this.add('-fieldend', 'move: Inversion Terrain');
- },
- },
- secondary: null,
- target: "all",
- type: "Psychic",
- },
-
- // Raihan Kibana
- stonykibbles: {
- accuracy: 100,
- basePower: 90,
- category: "Physical",
- desc: "For 5 turns, the weather becomes Sandstorm. At the end of each turn except the last, all active Pokemon lose 1/16 of their maximum HP, rounded down, unless they are a Ground, Rock, or Steel type, or have the Magic Guard, Overcoat, Sand Force, Sand Rush, or Sand Veil Abilities. During the effect, the Special Defense of Rock-type Pokemon is multiplied by 1.5 when taking damage from a special attack. Lasts for 8 turns if the user is holding Smooth Rock. Fails if the current weather is Sandstorm.",
- shortDesc: "Sets Sandstorm.",
- name: "Stony Kibbles",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onHit() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Raihan Kibana')}|Let the winds blow! Stream forward, Sandstorm!`);
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Rock Slide', target);
- this.add('-anim', source, 'Crunch', target);
- this.add('-anim', source, 'Sandstorm', target);
- },
- weather: 'Sandstorm',
- target: "normal",
- type: "Normal",
- },
-
- // Raj.Shoot
- fanservice: {
- accuracy: 100,
- basePower: 90,
- category: "Physical",
- desc: "The user has its Attack and Speed raised by 1 stage after KOing a target. If the user is a Charizard in its base form, it will Mega Evolve into Mega Charizard X.",
- shortDesc: "+1 Atk/Spe after KO. Mega evolves user.",
- name: "Fan Service",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {contact: 1, protect: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source, move) {
- this.add('-anim', source, 'Sacred Fire', target);
- },
- onAfterMoveSecondarySelf(pokemon, target, move) {
- if (!target || target.fainted || target.hp <= 0) {
- this.boost({atk: 1, spe: 1}, pokemon, pokemon, move);
- }
- },
- onHit(target, source) {
- if (source.species.id === 'charizard') {
- this.actions.runMegaEvo(source);
- }
- },
- secondary: null,
- target: "normal",
- type: "Grass",
- },
-
- // Ransei
- ripsei: {
- accuracy: 100,
- basePower: 0,
- damageCallback(pokemon) {
- const damage = pokemon.hp;
- return damage;
- },
- category: "Special",
- desc: "Deals damage to the target equal to the user's current HP. If this move is successful, the user faints.",
- shortDesc: "Does damage equal to the user's HP. User faints.",
- name: "ripsei",
- gen: 8,
- pp: 5,
- priority: 1,
- flags: {contact: 1, protect: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Final Gambit', target);
- },
- onAfterMove(pokemon, target, move) {
- if (pokemon.moveThisTurnResult === true) {
- pokemon.faint();
- }
- },
- secondary: null,
- target: "normal",
- type: "Fighting",
- },
-
- // RavioliQueen
- witchinghour: {
- accuracy: 90,
- basePower: 60,
- category: "Special",
- desc: "50% chance to trap the target, dealing 1/8th of their HP, rounded down, in damage each turn it is trapped.",
- shortDesc: "50% to trap, dealing 1/8 each turn.",
- name: "Witching Hour",
- pp: 5,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Spirit Shackle', target);
- this.add('-anim', source, 'Curse', target);
- },
- secondary: {
- chance: 50,
- volatileStatus: 'haunting',
- },
- target: "normal",
- type: "Ghost",
- },
-
- // for RavioliQueen's ability
- pitchblackterrain: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "For 5 turns, Non Ghost types take 1/16th damage; Has boosting effects on Mismagius.",
- shortDesc: "5 turns. Non Ghost types take 1/16th damage; Has boosting effects on Mismagius.",
- name: "Pitch Black Terrain",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {},
- terrain: 'pitchblackterrain',
- condition: {
- duration: 5,
- durationCallback(source, effect) {
- if (source?.hasItem('terrainextender')) {
- return 8;
- }
- return 5;
- },
- onHit(target, source, move) {
- if (!target.hp || target.species.name !== 'Mismagius') return;
- if (move?.effectType === 'Move' && move.category !== 'Status') {
- if (this.boost({spe: 1}, target)) {
- this.add('-message', `${target.name} got a boost by the terrain!`);
- }
- }
- },
- onSwitchInPriority: -1,
- onSwitchIn(target) {
- if (target?.species.name !== 'Mismagius') return;
- if (this.boost({spa: 1, spd: 1}, target)) {
- this.add('-message', `${target.name} got a boost by the terrain!`);
- }
- },
- onFieldStart(field, source, effect) {
- if (effect?.effectType === 'Ability') {
- this.add('-fieldstart', 'move: Pitch Black Terrain', '[from] ability: ' + effect, '[of] ' + source);
- } else {
- this.add('-fieldstart', 'move: Pitch Black Terrain');
- }
- this.add('-message', 'The battlefield became dark!');
- if (source?.species.name !== 'Mismagius') return;
- if (this.boost({spa: 1, spd: 1}, source)) {
- this.add('-message', `${source.name} got a boost by the terrain!`);
- }
- },
- onResidualOrder: 5,
- onResidual(pokemon) {
- if (pokemon.isSemiInvulnerable()) return;
- if (!pokemon || pokemon.hasType('Ghost')) return;
- if (this.damage(pokemon.baseMaxhp / 16, pokemon)) {
- this.add('-message', `${pokemon.name} was hurt by the terrain!`);
- }
- },
- onFieldResidualOrder: 21,
- onFieldResidualSubOrder: 3,
- onFieldEnd() {
- this.add('-fieldend', 'move: Pitch Black Terrain');
- },
- },
- secondary: null,
- target: "all",
- type: "Ghost",
- },
-
- // Robb576
- integeroverflow: {
- accuracy: true,
- basePower: 200,
- category: "Special",
- desc: "This move becomes a physical attack if the user's Attack is greater than its Special Attack, including stat stage changes. This move and its effects ignore the Abilities of other Pokemon.",
- shortDesc: "Physical if user's Atk > Sp. Atk. Ignores Abilities.",
- name: "Integer Overflow",
- gen: 8,
- pp: 1,
- noPPBoosts: true,
- priority: 0,
- flags: {},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Light That Burns The Sky', target);
- },
- onModifyMove(move, pokemon) {
- if (pokemon.getStat('atk', false, true) > pokemon.getStat('spa', false, true)) move.category = 'Physical';
- },
- ignoreAbility: true,
- isZ: "modium6z",
- secondary: null,
- target: "normal",
- type: "Psychic",
- },
-
- mode5offensive: {
- accuracy: true,
- basePower: 30,
- category: "Special",
- desc: "This move hits three times. Every hit has a 20% chance to drop the target's SpD by 1 stage.",
- shortDesc: "3 hits. Each hit: 20% -1 SpD.",
- name: "Mode [5: Offensive]",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Focus Blast', target);
- this.add('-anim', source, 'Zap Cannon', target);
- },
- secondary: {
- chance: 20,
- boosts: {
- spd: -1,
- },
- },
- multihit: 3,
- target: "normal",
- type: "Fighting",
- },
-
- mode7defensive: {
- accuracy: 100,
- basePower: 0,
- category: "Status",
- desc: "This move cures the user's party of all status conditions, and then forces the target to switch to a random ally.",
- shortDesc: "Heal Bell + Whirlwind.",
- name: "Mode [7: Defensive]",
- gen: 8,
- pp: 15,
- priority: -6,
- flags: {reflectable: 1, protect: 1, sound: 1, bypasssub: 1},
- forceSwitch: true,
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Heal Bell', source);
- this.add('-anim', source, 'Roar', source);
- },
- onHit(pokemon, source) {
- this.add('-activate', source, 'move: Mode [7: Defensive]');
- const side = source.side;
- let success = false;
- for (const ally of side.pokemon) {
- if (ally.hasAbility('soundproof')) continue;
- if (ally.cureStatus()) success = true;
- }
- return success;
- },
- target: "normal",
- type: "Normal",
- },
-
- // Sectonia
- homunculussvanity: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Raises the Defense and Special Defense by 1 stage. Lowers the foe's higher offensive stat by 1 stage.",
- shortDesc: "+1 Def & SpD. -1 to foe's highest offensive stat.",
- name: "Homunculus's Vanity",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Cosmic Power', source);
- this.add('-anim', source, 'Psychic', target);
- },
- self: {
- onHit(source) {
- let totalatk = 0;
- let totalspa = 0;
- for (const target of source.foes()) {
- totalatk += target.getStat('atk', false, true);
- totalspa += target.getStat('spa', false, true);
- if (totalatk && totalatk >= totalspa) {
- this.boost({atk: -1}, target);
- } else if (totalspa) {
- this.boost({spa: -1}, target);
- }
- }
- this.boost({def: 1, spd: 1}, source);
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Sectonia')}|Jelly baby ;w;`);
- },
- },
- secondary: null,
- target: "self",
- type: "Psychic",
- zMove: {boost: {atk: 1}},
- },
-
- // Segmr
- tsukuyomi: {
- accuracy: 100,
- basePower: 0,
- category: "Status",
- desc: "The user loses 1/4 of its maximum HP, rounded down and even if it would cause fainting, in exchange for the target losing 1/4 of its maximum HP, rounded down, at the end of each turn while it is active. If the target uses Baton Pass, the replacement will continue to be affected. Fails if there is no target or if the target is already affected. Prevents the target from switching out. The target can still switch out if it is holding Shed Shell or uses Baton Pass, Parting Shot, Teleport, U-turn, or Volt Switch. If the target leaves the field using Baton Pass, the replacement will remain trapped. The effect ends if the user leaves the field.",
- shortDesc: "Curses the target for 1/4 HP and traps it.",
- name: "Tsukuyomi",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {bypasssub: 1, protect: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Dark Void', target);
- this.add('-anim', source, 'Curse', target);
- },
- onHit(pokemon, source, move) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Segmr')}|I don't like naruto actually let someone else write this message plz.`);
- this.directDamage(source.maxhp / 4, source, source);
- pokemon.addVolatile('curse');
- pokemon.addVolatile('trapped', source, move, 'trapper');
- },
- secondary: null,
- target: "normal",
- type: "Dark",
- },
-
- // sejesensei
- badopinion: {
- accuracy: 90,
- basePower: 120,
- category: "Physical",
- desc: "Forces the opponent out. The user's Defense is raised by 1 stage upon hitting.",
- shortDesc: "Forces the opponent out. +1 Def.",
- name: "Bad Opinion",
- gen: 8,
- pp: 10,
- priority: -6,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Hyper Voice', target);
- this.add('-anim', source, 'Sludge Bomb', target);
- },
- onHit() {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('sejesensei')}|Please go read To Love-Ru I swear its really good, wait... don’t leave…`);
- },
- self: {
- boosts: {
- def: 1,
- },
- },
- forceSwitch: true,
- secondary: null,
- target: "normal",
- type: "Poison",
- },
-
- // Seso
- legendaryswordsman: {
- accuracy: 85,
- basePower: 95,
- onTry(source, target) {
- this.attrLastMove('[still]');
- const action = this.queue.willMove(target);
- const move = action?.choice === 'move' ? action.move : null;
- if (!move || (move.category === 'Status' && move.id !== 'mefirst') || target.volatiles['mustrecharge']) {
- if (move?.category === 'Status') {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Seso')}|Irritating a better swordsman than yourself is always a good way to end up dead.`);
- } else {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Seso')}|Scars on the back are a swordsman's shame.`);
- }
- return false;
- }
- },
- category: "Physical",
- desc: "If the move hits, the user gains +1 Speed. This move deals not very effective damage to Flying-type Pokemon. This move fails if the target does not intend to attack.",
- shortDesc: "+1 Spe on hit. Fails if target doesn't attack.",
- name: "Legendary Swordsman",
- gen: 8,
- pp: 10,
- priority: 1,
- flags: {contact: 1, protect: 1},
- ignoreImmunity: {'Ground': true},
- onEffectiveness(typeMod, target, type) {
- if (type === 'Flying') return -1;
- },
- onTryMove(source, target, move) {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Seso')}|FORWARD!`);
- this.add('-anim', source, 'Gear Grind', target);
- this.add('-anim', source, 'Thief', target);
- },
- secondary: {
- chance: 100,
- self: {
- boosts: {
- spe: 1,
- },
- },
- },
- target: "normal",
- type: "Ground",
- },
-
- // Shadecession
- shadeuppercut: {
- accuracy: 100,
- basePower: 90,
- category: "Physical",
- desc: "This move ignores type effectiveness, substitutes, and the opposing side's Reflect, Light Screen, Safeguard, Mist and Aurora Veil.",
- shortDesc: "Ignores typing, sub, & screens.",
- name: "Shade Uppercut",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {contact: 1, protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Sky Uppercut', target);
- this.add('-anim', source, 'Shadow Sneak', target);
- },
- onEffectiveness(typeMod, target, type) {
- return 0;
- },
- infiltrates: true,
- secondary: null,
- target: "normal",
- type: "Dark",
- },
-
- // Soft Flex
- updraft: {
- accuracy: 75,
- basePower: 75,
- category: "Special",
- desc: "Changes target's secondary typing to Flying for 2-5 turns unless the target is Ground-type or affected by Ingrain. This move cannot miss in rain.",
- shortDesc: "Target: +Flying type. Rain: never misses.",
- name: "Updraft",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Twister', target);
- },
- onModifyMove(move, pokemon, target) {
- if (target && ['raindance', 'primordialsea'].includes(target.effectiveWeather())) {
- move.accuracy = true;
- }
- },
- condition: {
- noCopy: true,
- duration: 5,
- durationCallback(target, source) {
- return this.random(5, 7);
- },
- onStart(target) {
- this.effectState.origTypes = target.getTypes(); // store original types
- if (target.getTypes().length === 1) { // single type mons
- if (!target.addType('Flying')) return false;
- this.add('-start', target, 'typeadd', 'Flying', '[from] move: Updraft');
- } else { // dual typed mons
- const primary = target.getTypes()[0]; // take the first type
- if (!target.setType([primary, 'Flying'])) return false;
- this.add('-start', target, 'typechange', primary + '/Flying', '[from] move: Updraft');
- }
- },
- onEnd(target) {
- if (!target.setType(this.effectState.origTypes)) return false; // reset the types
- this.add('-start', target, 'typechange', this.effectState.origTypes.join('/'), '[silent]');
- },
- },
- secondary: {
- chance: 100,
- onHit(target) {
- if (target.hasType(['Flying', 'Ground']) || target.volatiles['ingrain'] || target.volatiles['brilliant']) return false;
- target.addVolatile('updraft');
- },
- },
- target: "normal",
- type: "Flying",
- },
-
- // used for Soft Flex's ability
- tempestterrain: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Heals Electric types for 1/16 of their maximum HP, rounded down, at the end of each turn. Causes Flying- and Steel-types and Levitate users to lose 1/16 of their maximum HP, rounded down, at the end of each turn; if the Pokemon is also Electric-type, they only get the healing effect.",
- shortDesc: "Heals Electrics. Hurts Flyings and Steels.",
- name: "Tempest Terrain",
- pp: 10,
- priority: 0,
- flags: {nonsky: 1},
- terrain: 'tempestterrain',
- condition: {
- duration: 5,
- durationCallback(source, effect) {
- if (source?.hasItem('terrainextender')) {
- return 8;
- }
- return 5;
- },
- onResidualOrder: 5,
- onResidual(pokemon) {
- if (pokemon.hasType('Electric')) {
- if (this.heal(pokemon.baseMaxhp / 8, pokemon)) {
- this.add('-message', `${pokemon.name} was healed by the terrain!`);
- }
- } else if (!pokemon.hasType('Electric') && (pokemon.hasType(['Flying', 'Steel']) || pokemon.hasAbility('levitate'))) {
- if (this.damage(pokemon.baseMaxhp / 8, pokemon)) {
- this.add('-message', `${pokemon.name} was hurt by the terrain!`);
- }
- }
- },
- onFieldStart(field, source, effect) {
- if (effect?.effectType === 'Ability') {
- this.add('-fieldstart', 'move: Tempest Terrain', '[from] ability: ' + effect, '[of] ' + source);
- } else {
- this.add('-fieldstart', 'move: Tempest Terrain');
- }
- this.add('-message', 'The battlefield became stormy!');
- },
- onFieldResidualOrder: 21,
- onFieldResidualSubOrder: 3,
- onFieldEnd() {
- this.add('-fieldend', 'move: Tempest Terrain');
- },
- },
- secondary: null,
- target: "all",
- type: "Electric",
- zMove: {boost: {spe: 1}},
- contestType: "Clever",
- },
-
- // Spandan
- imtoxicyoureslippinunder: {
- accuracy: true,
- basePower: 110,
- category: "Physical",
- overrideOffensivePokemon: 'target',
- overrideOffensiveStat: 'spd',
- desc: "This move uses the target's Special Defense to calculate damage (like Foul Play). This move is neutrally effective against Steel-types.",
- shortDesc: "Uses foe's SpD as user's Atk. Hits Steel.",
- name: "I'm Toxic You're Slippin' Under",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Sludge Bomb', target);
- this.add('-anim', source, 'Sludge Wave', target);
- },
- ignoreImmunity: {'Poison': true},
- secondary: null,
- target: "normal",
- type: "Poison",
- },
-
- // Struchni
- veto: {
- accuracy: 100,
- basePower: 80,
- category: "Physical",
- desc: "If the user's stats was raised on the previous turn, double power and gain +1 priority.",
- shortDesc: "If stat raised last turn: x2 power, +1 prio.",
- name: "Veto",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {contact: 1, protect: 1},
- onTryMove(source) {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Head Smash', target);
- },
- // Veto interactions located in formats.ts
- onModifyPriority(priority, source, target, move) {
- if (source.m.statsRaisedLastTurn) {
- return priority + 1;
- }
- },
- basePowerCallback(pokemon, target, move) {
- if (pokemon.m.statsRaisedLastTurn) {
- return move.basePower * 2;
- }
- return move.basePower;
- },
- onHit(target, source) {
- if (source.m.statsRaisedLastTurn) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Struchni')}|**veto**`);
- }
- },
- target: "normal",
- type: "Steel",
- },
-
- // Teclis
- kaboom: {
- accuracy: 100,
- basePower: 150,
- category: "Special",
- desc: "This move's Base Power is equal to 70+(80*user's current HP/user's max HP). Sets Sunny Day.",
- shortDesc: "Better Eruption. Sets Sun.",
- name: "Kaboom",
- pp: 5,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- weather: 'sunnyday',
- basePowerCallback(pokemon, target, move) {
- return 70 + 80 * Math.floor(pokemon.hp / pokemon.maxhp);
- },
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Eruption', target);
- this.add('-anim', source, 'Earthquake', target);
- },
- secondary: null,
- target: "normal",
- type: "Fire",
- },
-
- // temp
- dropadraco: {
- accuracy: 90,
- basePower: 130,
- category: "Special",
- desc: "Lowers the user's Special Attack by 2 stages, then raises it by 1 stage.",
- shortDesc: "Lowers user's Sp. Atk by 2, then raises by 1.",
- name: "DROP A DRACO",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Draco Meteor', target);
- },
- self: {
- boosts: {
- spa: -2,
- },
- },
- onAfterMoveSecondarySelf(source, target) {
- this.boost({spa: 1}, source, source, this.dex.getActiveMove('dropadraco'));
- },
- secondary: null,
- target: "normal",
- type: "Dragon",
- },
-
- // The Immortal
- wattup: {
- accuracy: 100,
- basePower: 73,
- category: "Special",
- desc: "Has a 75% chance to raise the user's Speed by 1 stage.",
- shortDesc: "75% chance to raise the user's Speed by 1 stage.",
- name: "Watt Up",
- gen: 8,
- pp: 15,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Volt Switch', target);
- this.add('-anim', source, 'Nasty Plot', source);
- },
- secondary: {
- chance: 75,
- self: {
- boosts: {
- spe: 1,
- },
- },
- },
- target: "normal",
- type: "Electric",
- },
-
- // thewaffleman
- icepress: {
- accuracy: 100,
- basePower: 80,
- category: "Physical",
- desc: "Damage is calculated using the user's Defense stat as its Attack, including stat stage changes. Other effects that modify the Attack stat are used as normal. This move has a 10% chance to freeze the target and is super effective against Fire-types.",
- shortDesc: "Body Press. 10% Frz. SE vs Fire.",
- name: "Ice Press",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1, contact: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Body Press', target);
- },
- onEffectiveness(typeMod, target, type) {
- if (type === 'Fire') return 1;
- },
- overrideOffensiveStat: 'def',
- secondary: {
- chance: 10,
- status: "frz",
- },
- target: "normal",
- type: "Ice",
- },
-
- // tiki
- rightoncue: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "Randomly uses 1-5 different support moves.",
- shortDesc: "Uses 1-5 support moves.",
- name: "Right. On. Cue!",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onHit(target, source) {
- const supportMoves = [
- 'Wish', 'Heal Bell', 'Defog', 'Spikes', 'Taunt', 'Torment',
- 'Haze', 'Encore', 'Reflect', 'Light Screen', 'Sticky Web', 'Acupressure',
- 'Gastro Acid', 'Hail', 'Heal Block', 'Spite', 'Parting Shot', 'Trick Room',
- ];
- const randomTurns = this.random(5) + 1;
- let successes = 0;
- for (let x = 1; x <= randomTurns; x++) {
- const randomMove = this.sample(supportMoves);
- supportMoves.splice(supportMoves.indexOf(randomMove), 1);
- this.actions.useMove(randomMove, target);
- successes++;
- }
- if (successes === 1) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('tiki')}|truly a dumpster fire`);
- } else if (successes >= 4) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('tiki')}|whos ${source.side.foe.name}?`);
- }
- },
- secondary: null,
- target: "self",
- type: "Normal",
- },
-
- // trace
- herocreation: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "The user switches out and raises the incoming Pokemon's Attack and Special Attack by 1 stage.",
- shortDesc: "User switches, +1 Atk/SpA to replacement.",
- name: "Hero Creation",
- gen: 8,
- pp: 10,
- priority: -6,
- flags: {snatch: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Teleport', source);
- this.add('-anim', source, 'Work Up', source);
- },
- selfSwitch: true,
- sideCondition: 'herocreation',
- condition: {
- duration: 1,
- onSideStart(side, source) {
- this.debug('Hero Creation started on ' + side.name);
- this.effectState.positions = [];
- for (const i of side.active.keys()) {
- this.effectState.positions[i] = false;
- }
- this.effectState.positions[source.position] = true;
- },
- onSideRestart(side, source) {
- this.effectState.positions[source.position] = true;
- },
- onSwitchInPriority: 1,
- onSwitchIn(target) {
- this.add('-activate', target, 'move: Hero Creation');
- this.boost({atk: 1, spa: 1}, target, null, this.dex.getActiveMove('herocreation'));
- },
- },
- secondary: null,
- target: "self",
- type: "Psychic",
- },
-
- // Trickster
- soulshatteringstare: {
- accuracy: true,
- basePower: 0,
- category: "Status",
- desc: "The user loses 1/4 of its maximum HP, rounded down and even if it would cause fainting, in exchange for the target losing 1/4 of its maximum HP, rounded down, at the end of each turn while it is active. If the target uses Baton Pass, the replacement will continue to be affected. For 5 turns, the target is prevented from restoring any HP as long as it remains active. During the effect, healing and draining moves are unusable, and Abilities and items that grant healing will not heal the user. If an affected Pokemon uses Baton Pass, the replacement will remain unable to restore its HP. Pain Split and the Regenerator Ability are unaffected.",
- shortDesc: "Curses target for 1/4 HP & blocks it from healing.",
- name: "Soul-Shattering Stare",
- gen: 8,
- pp: 10,
- priority: -7,
- flags: {bypasssub: 1, protect: 1, reflectable: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Glare', target);
- this.add('-anim', source, 'Trick-or-Treat', source);
- },
- onHit(pokemon, source) {
- this.directDamage(source.maxhp / 4, source, source);
- pokemon.addVolatile('curse');
- pokemon.addVolatile('healblock');
- },
- secondary: null,
- target: "normal",
- type: "Ghost",
- },
-
- // Vexen
- asteriusstrike: {
- accuracy: 85,
- basePower: 100,
- category: "Physical",
- desc: "Has a 25% chance to confuse the target.",
- shortDesc: "25% chance to confuse the target.",
- name: "Asterius Strike",
- gen: 8,
- pp: 5,
- priority: 0,
- flags: {protect: 1, contact: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Giga Impact', target);
- },
- secondary: {
- chance: 25,
- volatileStatus: 'confusion',
- },
- target: "normal",
- type: "Normal",
- },
-
- // vivalospride
- dripbayless: {
- accuracy: true,
- basePower: 85,
- category: "Special",
- desc: "This move's type effectiveness against Water is changed to be super effective no matter what this move's type is.",
- shortDesc: "Super effective on Water.",
- name: "DRIP BAYLESS",
- gen: 8,
- pp: 20,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Lava Plume', target);
- this.add('-anim', source, 'Sunny Day', target);
- },
- onEffectiveness(typeMod, target, type) {
- if (type === 'Water') return 1;
- },
- secondary: null,
- target: "normal",
- type: "Fire",
- },
-
- // Volco
- glitchexploiting: {
- accuracy: 100,
- basePower: 60,
- category: "Special",
- desc: "1/4096 chance to KO the target and then the user, and a 1/1024 chance to force out the target and then the user; 20% chance to burn the target, and a 5% chance to freeze or paralyze a random Pokemon on the field; 30% chance to confuse the target.",
- shortDesc: "Has a chance to do many things.",
- name: "Glitch Exploiting",
- gen: 8,
- pp: 10,
- priority: 0,
- flags: {protect: 1, mirror: 1},
- onTryMove() {
- this.attrLastMove('[still]');
- },
- onPrepareHit(target, source) {
- this.add('-anim', source, 'Explosion', target);
- this.add('-anim', source, 'Tackle', source);
- this.add('-anim', source, 'Blue Flare', target);
- },
- onHit(target, source, move) {
- const random = this.random(4096);
- if (random === 1) {
- target.faint(source, move);
- source.faint(source, move);
- } else if ([1024, 2048, 3072, 4096].includes(random)) {
- this.add(`c:|${Math.floor(Date.now() / 1000)}|${getName('Volco')}|haha memory corruption go brrr...`);
- target.forceSwitchFlag = true;
- source.forceSwitchFlag = true;
- } else if (random === 69) {
- this.add(`raw|
Pokemon Showdown has not crashed! It just got sick of all the rng in Volco's Glitch Exploiting move and gave up. (Do not report this, this is intended.)
`, rank as GroupSymbol);
},
addrankhtmlboxhelp: [
- `/addrankhtmlbox [rank], [message] - Shows everyone with the specified rank or higher a message, parsing HTML code contained. Requires: * # &`,
+ `/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) {
@@ -223,10 +223,10 @@ export const commands: Chat.ChatCommands = {
}
},
adduhtmlhelp: [
- `/adduhtml [name], [message] - Shows everyone a message that can change, parsing HTML code contained. Requires: * # &`,
+ `/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: * # &`,
+ `/changeuhtml [name], [message] - Changes the message previously shown with /adduhtml [name]. Requires: * # ~`,
],
changerankuhtml: 'addrankuhtml',
addrankuhtml(target, room, user, connection, cmd) {
@@ -249,10 +249,10 @@ export const commands: Chat.ChatCommands = {
room.sendRankedUsers(html, rank as GroupSymbol);
},
addrankuhtmlhelp: [
- `/addrankuhtml [rank], [name], [message] - Shows everyone with the specified rank or higher a message that can change, parsing HTML code contained. Requires: * # &`,
+ `/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: * # &`,
+ `/changerankuhtml [rank], [name], [message] - Changes the message previously shown with /addrankuhtml [rank], [name]. Requires: * # ~`,
],
deletenamecolor: 'setnamecolor',
@@ -297,11 +297,11 @@ export const commands: Chat.ChatCommands = {
},
setnamecolorhelp: [
`/setnamecolor OR /snc [username], [source name] - Set [username]'s name color to match the [source name]'s color.`,
- `Requires: &`,
+ `Requires: ~`,
],
deletenamecolorhelp: [
`/deletenamecolor OR /dnc [username] - Remove [username]'s namecolor.`,
- `Requires: &`,
+ `Requires: ~`,
],
pline(target, room, user) {
@@ -313,7 +313,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(target);
},
plinehelp: [
- `/pline [protocol lines] - Adds the given [protocol lines] to the current room. Requires: & console access`,
+ `/pline [protocol lines] - Adds the given [protocol lines] to the current room. Requires: ~ console access`,
],
pminfobox(target, room, user, connection) {
@@ -334,7 +334,7 @@ export const commands: Chat.ChatCommands = {
targetUser.lastPM = user.id;
user.lastPM = targetUser.id;
},
- pminfoboxhelp: [`/pminfobox [user], [html]- PMs an [html] infobox to [user]. Requires * # &`],
+ pminfoboxhelp: [`/pminfobox [user], [html]- PMs an [html] infobox to [user]. Requires * # ~`],
pmuhtmlchange: 'pmuhtml',
pmuhtml(target, room, user, connection, cmd) {
@@ -355,9 +355,9 @@ export const commands: Chat.ChatCommands = {
targetUser.lastPM = user.id;
user.lastPM = targetUser.id;
},
- pmuhtmlhelp: [`/pmuhtml [user], [name], [html] - PMs [html] that can change to [user]. Requires * # &`],
+ pmuhtmlhelp: [`/pmuhtml [user], [name], [html] - PMs [html] that can change to [user]. Requires * # ~`],
pmuhtmlchangehelp: [
- `/pmuhtmlchange [user], [name], [html] - Changes html that was previously PMed to [user] to [html]. Requires * # &`,
+ `/pmuhtmlchange [user], [name], [html] - Changes html that was previously PMed to [user] to [html]. Requires * # ~`,
],
closehtmlpage: 'sendhtmlpage',
@@ -368,7 +368,8 @@ export const commands: Chat.ChatCommands = {
const closeHtmlPage = cmd === 'closehtmlpage';
- const {targetUser, rest} = this.requireUser(target);
+ const [targetStr, rest] = this.splitOne(target).map(str => str.trim());
+ const targets = targetStr.split('|').map(u => u.trim());
let [pageid, content] = this.splitOne(rest);
let selector: string | undefined;
if (cmd === 'changehtmlpageselector') {
@@ -381,56 +382,79 @@ export const commands: Chat.ChatCommands = {
pageid = `${user.id}-${toID(pageid)}`;
- if (targetUser.locked && !this.user.can('lock')) {
- this.errorReply("This user is currently locked, so you cannot send them HTML.");
- return false;
- }
+ const successes: string[] = [], errors: string[] = [];
- let targetConnections = [];
- // find if a connection has specifically requested this page
- for (const c of targetUser.connections) {
- if (c.lastRequestedPage === pageid) {
- targetConnections.push(c);
+ content = this.checkHTML(content);
+
+ targets.forEach(targetUsername => {
+ const targetUser = Users.get(targetUsername);
+ if (!targetUser) return errors.push(`${targetUsername} [offline/misspelled]`);
+
+ if (targetUser.locked && !this.user.can('lock')) {
+ return errors.push(`${targetUser.name} [locked]`);
}
- }
- if (!targetConnections.length) {
- // no connection has requested it - verify that we share a room
- this.checkPMHTML(targetUser);
- targetConnections = targetUser.connections;
- }
- content = this.checkHTML(content);
+ let targetConnections = [];
+ // find if a connection has specifically requested this page
+ for (const c of targetUser.connections) {
+ if (c.lastRequestedPage === pageid) {
+ targetConnections.push(c);
+ }
+ }
+ if (!targetConnections.length) {
+ // no connection has requested it - verify that we share a room
+ try {
+ this.checkPMHTML(targetUser);
+ } catch {
+ return errors.push(`${targetUser.name} [not in room / blocking PMs]`);
+ }
+ targetConnections = targetUser.connections;
+ }
- for (const targetConnection of targetConnections) {
- const context = new Chat.PageContext({
- user: targetUser,
- connection: targetConnection,
- pageid: `view-bot-${pageid}`,
- });
- if (closeHtmlPage) {
- context.send(`|deinit|`);
- } else if (selector) {
- context.send(`|selectorhtml|${selector}|${content}`);
- } else {
- context.title = `[${user.name}] ${pageid}`;
- context.setHTML(content);
+ for (const targetConnection of targetConnections) {
+ const context = new Chat.PageContext({
+ user: targetUser,
+ connection: targetConnection,
+ pageid: `view-bot-${pageid}`,
+ });
+ if (closeHtmlPage) {
+ context.send(`|deinit|`);
+ } else if (selector) {
+ context.send(`|selectorhtml|${selector}|${content}`);
+ } else {
+ context.title = `[${user.name}] ${pageid}`;
+ context.setHTML(content);
+ }
}
- }
+ successes.push(targetUser.name);
+ });
if (closeHtmlPage) {
- this.sendReply(`Closed the bot page ${pageid} for ${targetUser.name}.`);
+ if (successes.length) {
+ this.sendReply(`Closed the bot page ${pageid} for ${Chat.toListString(successes)}.`);
+ }
+ if (errors.length) {
+ this.errorReply(`Unable to close the bot page for ${Chat.toListString(errors)}.`);
+ }
} else {
- this.sendReply(`Sent ${targetUser.name}${selector ? ` the selector ${selector} on` : ''} the bot page ${pageid}.`);
+ if (successes.length) {
+ this.sendReply(`Sent ${Chat.toListString(successes)}${selector ? ` the selector ${selector} on` : ''} the bot page ${pageid}.`);
+ }
+ if (errors.length) {
+ this.errorReply(`Unable to send the bot page ${pageid} to ${Chat.toListString(errors)}.`);
+ }
}
+
+ if (!successes.length) return false;
},
sendhtmlpagehelp: [
- `/sendhtmlpage [userid], [pageid], [html] - Sends [userid] the bot page [pageid] with the content [html]. Requires: * # &`,
+ `/sendhtmlpage [userid], [pageid], [html] - Sends [userid] the bot page [pageid] with the content [html]. Requires: * # ~`,
],
changehtmlpageselectorhelp: [
- `/changehtmlpageselector [userid], [pageid], [selector], [html] - Sends [userid] the content [html] for the selector [selector] on the bot page [pageid]. Requires: * # &`,
+ `/changehtmlpageselector [userid], [pageid], [selector], [html] - Sends [userid] the content [html] for the selector [selector] on the bot page [pageid]. Requires: * # ~`,
],
closehtmlpagehelp: [
- `/closehtmlpage [userid], [pageid], - Closes the bot page [pageid] for [userid]. Requires: * # &`,
+ `/closehtmlpage [userid], [pageid], - Closes the bot page [pageid] for [userid]. Requires: * # ~`,
],
highlighthtmlpage(target, room, user) {
@@ -473,15 +497,8 @@ export const commands: Chat.ChatCommands = {
room = this.requireRoom();
this.checkCan('addhtml', null, room);
- const {targetUser, rest} = this.requireUser(target);
-
- if (targetUser.locked && !this.user.can('lock')) {
- throw new Chat.ErrorMessage("This user is currently locked, so you cannot send them private HTML.");
- }
-
- if (!(targetUser.id in room.users)) {
- throw new Chat.ErrorMessage("You cannot send private HTML to users who are not in this room.");
- }
+ const [targetStr, rest] = this.splitOne(target).map(str => str.trim());
+ const targets = targetStr.split('|').map(u => u.trim());
let html: string;
let messageType: string;
@@ -499,18 +516,38 @@ export const commands: Chat.ChatCommands = {
html = this.checkHTML(html);
if (!html) return this.parse('/help sendprivatehtmlbox');
-
html = `${Utils.html`
[Private from ${user.name}]
`}${Chat.collapseLineBreaksHTML(html)}`;
if (plainHtml) html = `
${html}
`;
- targetUser.sendTo(room, `|${messageType}|${html}`);
+ const successes: string[] = [], errors: string[] = [];
+
+ targets.forEach(targetUsername => {
+ const targetUser = Users.get(targetUsername);
+
+ if (!targetUser) return errors.push(`${targetUsername} [offline/misspelled]`);
+
+ if (targetUser.locked && !this.user.can('lock')) {
+ return errors.push(`${targetUser.name} [locked]`);
+ }
+
+ if (!(targetUser.id in room!.users)) {
+ return errors.push(`${targetUser.name} [not in room]`);
+ }
+
+ successes.push(targetUser.name);
+ targetUser.sendTo(room, `|${messageType}|${html}`);
+ });
- this.sendReply(`Sent private HTML to ${targetUser.name}.`);
+
+ if (successes.length) this.sendReply(`Sent private HTML to ${Chat.toListString(successes)}.`);
+ if (errors.length) this.errorReply(`Unable to send private HTML to ${Chat.toListString(errors)}.`);
+
+ if (!successes.length) return false;
},
sendprivatehtmlboxhelp: [
- `/sendprivatehtmlbox [userid], [html] - Sends [userid] the private [html]. Requires: * # &`,
- `/sendprivateuhtml [userid], [name], [html] - Sends [userid] the private [html] that can change. Requires: * # &`,
- `/changeprivateuhtml [userid], [name], [html] - Changes the message previously sent with /sendprivateuhtml [userid], [name], [html]. Requires: * # &`,
+ `/sendprivatehtmlbox [userid], [html] - Sends [userid] the private [html]. Requires: * # ~`,
+ `/sendprivateuhtml [userid], [name], [html] - Sends [userid] the private [html] that can change. Requires: * # ~`,
+ `/changeprivateuhtml [userid], [name], [html] - Changes the message previously sent with /sendprivateuhtml [userid], [name], [html]. Requires: * # ~`,
],
botmsg(target, room, user, connection) {
@@ -557,7 +594,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(`||[Main process] RSS: ${results[0]}, Heap: ${results[1]} / ${results[2]}`);
},
memoryusagehelp: [
- `/memoryusage OR /memusage - Get the current memory usage of the server. Requires: &`,
+ `/memoryusage OR /memusage - Get the current memory usage of the server. Requires: ~`,
],
forcehotpatch: 'hotpatch',
@@ -879,8 +916,8 @@ export const commands: Chat.ChatCommands = {
);
},
nohotpatchhelp: [
- `/nohotpatch [chat|formats|battles|validator|tournaments|punishments|modlog|all] [reason] - Disables hotpatching the specified part of the simulator. Requires: &`,
- `/allowhotpatch [chat|formats|battles|validator|tournaments|punishments|modlog|all] [reason] - Enables hotpatching the specified part of the simulator. Requires: &`,
+ `/nohotpatch [chat|formats|battles|validator|tournaments|punishments|modlog|all] [reason] - Disables hotpatching the specified part of the simulator. Requires: ~`,
+ `/allowhotpatch [chat|formats|battles|validator|tournaments|punishments|modlog|all] [reason] - Enables hotpatching the specified part of the simulator. Requires: ~`,
],
async processes(target, room, user) {
@@ -976,7 +1013,7 @@ export const commands: Chat.ChatCommands = {
this.sendReplyBox(buf);
},
processeshelp: [
- `/processes - Get information about the running processes on the server. Requires: &.`,
+ `/processes - Get information about the running processes on the server. Requires: ~.`,
],
async savelearnsets(target, room, user, connection) {
@@ -997,7 +1034,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply("learnsets.js saved.");
},
savelearnsetshelp: [
- `/savelearnsets - Saves the learnset list currently active on the server. Requires: &`,
+ `/savelearnsets - Saves the learnset list currently active on the server. Requires: ~`,
],
toggleripgrep(target, room, user) {
@@ -1005,7 +1042,7 @@ export const commands: Chat.ChatCommands = {
Config.disableripgrep = !Config.disableripgrep;
this.addGlobalModAction(`${user.name} ${Config.disableripgrep ? 'disabled' : 'enabled'} Ripgrep-related functionality.`);
},
- toggleripgrephelp: [`/toggleripgrep - Disable/enable all functionality depending on Ripgrep. Requires: &`],
+ toggleripgrephelp: [`/toggleripgrep - Disable/enable all functionality depending on Ripgrep. Requires: ~`],
disablecommand(target, room, user) {
this.checkCan('makeroom');
@@ -1028,7 +1065,7 @@ export const commands: Chat.ChatCommands = {
this.addGlobalModAction(`${user.name} disabled the command /${fullCmd}.`);
this.globalModlog(`DISABLECOMMAND`, null, target);
},
- disablecommandhelp: [`/disablecommand [command] - Disables the given [command]. Requires: &`],
+ disablecommandhelp: [`/disablecommand [command] - Disables the given [command]. Requires: ~`],
widendatacenters: 'adddatacenters',
adddatacenters() {
@@ -1057,10 +1094,10 @@ export const commands: Chat.ChatCommands = {
curRoom.addRaw(`
${innerHTML}
`).update();
}
for (const u of Users.users.values()) {
- if (u.connected) u.send(`|pm|&|${u.tempGroup}${u.name}|/raw
${innerHTML}
`);
+ if (u.connected) u.send(`|pm|~|${u.tempGroup}${u.name}|/raw
${innerHTML}
`);
}
},
- disableladderhelp: [`/disableladder - Stops all rated battles from updating the ladder. Requires: &`],
+ disableladderhelp: [`/disableladder - Stops all rated battles from updating the ladder. Requires: ~`],
enableladder(target, room, user) {
this.checkCan('disableladder');
@@ -1081,10 +1118,10 @@ export const commands: Chat.ChatCommands = {
curRoom.addRaw(`
${innerHTML}
`).update();
}
for (const u of Users.users.values()) {
- if (u.connected) u.send(`|pm|&|${u.tempGroup}${u.name}|/raw
${innerHTML}
`);
+ if (u.connected) u.send(`|pm|~|${u.tempGroup}${u.name}|/raw
${innerHTML}
`);
}
},
- enableladderhelp: [`/enable - Allows all rated games to update the ladder. Requires: &`],
+ enableladderhelp: [`/enable - Allows all rated games to update the ladder. Requires: ~`],
lockdown(target, room, user) {
this.checkCan('lockdown');
@@ -1100,7 +1137,7 @@ export const commands: Chat.ChatCommands = {
this.stafflog(`${user.name} used /lockdown`);
},
lockdownhelp: [
- `/lockdown - locks down the server, which prevents new battles from starting so that the server can eventually be restarted. Requires: &`,
+ `/lockdown - locks down the server, which prevents new battles from starting so that the server can eventually be restarted. Requires: ~`,
],
autolockdown: 'autolockdownkill',
@@ -1124,8 +1161,8 @@ export const commands: Chat.ChatCommands = {
}
},
autolockdownkillhelp: [
- `/autolockdownkill on - Turns on the setting to enable the server to automatically kill itself upon the final battle finishing. Requires &`,
- `/autolockdownkill off - Turns off the setting to enable the server to automatically kill itself upon the final battle finishing. Requires &`,
+ `/autolockdownkill on - Turns on the setting to enable the server to automatically kill itself upon the final battle finishing. Requires ~`,
+ `/autolockdownkill off - Turns off the setting to enable the server to automatically kill itself upon the final battle finishing. Requires ~`,
],
prelockdown(target, room, user) {
@@ -1134,7 +1171,7 @@ export const commands: Chat.ChatCommands = {
this.privateGlobalModAction(`${user.name} used /prelockdown (disabled tournaments in preparation for server restart)`);
},
- prelockdownhelp: [`/prelockdown - Prevents new tournaments from starting so that the server can be restarted. Requires: &`],
+ prelockdownhelp: [`/prelockdown - Prevents new tournaments from starting so that the server can be restarted. Requires: ~`],
slowlockdown(target, room, user) {
this.checkCan('lockdown');
@@ -1145,7 +1182,7 @@ export const commands: Chat.ChatCommands = {
},
slowlockdownhelp: [
`/slowlockdown - Locks down the server, but disables the automatic restart after all battles end.`,
- `Requires: &`,
+ `Requires: ~`,
],
crashfixed: 'endlockdown',
@@ -1167,7 +1204,7 @@ export const commands: Chat.ChatCommands = {
curRoom.addRaw(message).update();
}
for (const curUser of Users.users.values()) {
- curUser.send(`|pm|&|${curUser.tempGroup}${curUser.name}|/raw ${message}`);
+ curUser.send(`|pm|~|${curUser.tempGroup}${curUser.name}|/raw ${message}`);
}
} else {
this.sendReply("Preparation for the server shutdown was canceled.");
@@ -1177,8 +1214,8 @@ export const commands: Chat.ChatCommands = {
this.stafflog(`${user.name} used /endlockdown`);
},
endlockdownhelp: [
- `/endlockdown - Cancels the server restart and takes the server out of lockdown state. Requires: &`,
- `/crashfixed - Ends the active lockdown caused by a crash without the need of a restart. Requires: &`,
+ `/endlockdown - Cancels the server restart and takes the server out of lockdown state. Requires: ~`,
+ `/crashfixed - Ends the active lockdown caused by a crash without the need of a restart. Requires: ~`,
],
emergency(target, room, user) {
@@ -1195,7 +1232,7 @@ export const commands: Chat.ChatCommands = {
this.stafflog(`${user.name} used /emergency.`);
},
emergencyhelp: [
- `/emergency - Turns on emergency mode and enables extra logging. Requires: &`,
+ `/emergency - Turns on emergency mode and enables extra logging. Requires: ~`,
],
endemergency(target, room, user) {
@@ -1212,7 +1249,7 @@ export const commands: Chat.ChatCommands = {
this.stafflog(`${user.name} used /endemergency.`);
},
endemergencyhelp: [
- `/endemergency - Turns off emergency mode. Requires: &`,
+ `/endemergency - Turns off emergency mode. Requires: ~`,
],
remainingbattles() {
@@ -1234,7 +1271,7 @@ export const commands: Chat.ChatCommands = {
this.sendReplyBox(buf);
},
remainingbattleshelp: [
- `/remainingbattles - View a list of the remaining battles during lockdown. Requires: &`,
+ `/remainingbattles - View a list of the remaining battles during lockdown. Requires: ~`,
],
async savebattles(target, room, user) {
@@ -1264,7 +1301,7 @@ export const commands: Chat.ChatCommands = {
Rooms.global.lockdown = true; // we don't want more battles starting while we save
for (const u of Users.users.values()) {
u.send(
- `|pm|&|${u.getIdentity()}|/raw
The server is restarting soon. ` +
+ `|pm|~|${u.getIdentity()}|/raw
The server is restarting soon. ` +
`While battles are being saved, no more can be started. If you're in a battle, it will be paused during saving. ` +
`After the restart, you will be able to resume your battles from where you left off.`
);
@@ -1291,7 +1328,7 @@ export const commands: Chat.ChatCommands = {
},
killhelp: [
`/kill - kills the server. Use the argument \`nosave\` to prevent the saving of battles.`,
- ` If this argument is used, the server must be in lockdown. Requires: &`,
+ ` If this argument is used, the server must be in lockdown. Requires: ~`,
],
loadbanlist(target, room, user, connection) {
@@ -1304,16 +1341,21 @@ export const commands: Chat.ChatCommands = {
);
},
loadbanlisthelp: [
- `/loadbanlist - Loads the bans located at ipbans.txt. The command is executed automatically at startup. Requires: &`,
+ `/loadbanlist - Loads the bans located at ipbans.txt. The command is executed automatically at startup. Requires: ~`,
],
refreshpage(target, room, user) {
this.checkCan('lockdown');
+ if (user.lastCommand !== 'refreshpage') {
+ user.lastCommand = 'refreshpage';
+ this.errorReply(`Are you sure you wish to refresh the page for every user online?`);
+ return this.errorReply(`If you are sure, please type /refreshpage again to confirm.`);
+ }
Rooms.global.sendAll('|refresh|');
this.stafflog(`${user.name} used /refreshpage`);
},
refreshpagehelp: [
- `/refreshpage - refreshes the page for every user online. Requires: &`,
+ `/refreshpage - refreshes the page for every user online. Requires: ~`,
],
async updateserver(target, room, user, connection) {
@@ -1414,7 +1456,7 @@ export const commands: Chat.ChatCommands = {
},
updateclienthelp: [
`/updateclient [full] - Update the client source code. Provide the argument 'full' to make it a full rebuild.`,
- `Requires: & console access`,
+ `Requires: ~ console access`,
],
async rebuild() {
@@ -1438,7 +1480,7 @@ export const commands: Chat.ChatCommands = {
this.runBroadcast();
this.sendReply(`${stdout}${stderr}`);
},
- bashhelp: [`/bash [command] - Executes a bash command on the server. Requires: & console access`],
+ bashhelp: [`/bash [command] - Executes a bash command on the server. Requires: ~ console access`],
async eval(target, room, user, connection) {
this.canUseConsole();
@@ -1483,7 +1525,7 @@ export const commands: Chat.ChatCommands = {
}
},
evalhelp: [
- `/eval [code] - Evaluates the code given and shows results. Requires: & console access.`,
+ `/eval [code] - Evaluates the code given and shows results. Requires: ~ console access.`,
],
async evalsql(target, room) {
@@ -1565,7 +1607,7 @@ export const commands: Chat.ChatCommands = {
},
evalsqlhelp: [
`/evalsql [database], [query] - Evaluates the given SQL [query] in the given [database].`,
- `Requires: & console access`,
+ `Requires: ~ console access`,
],
evalbattle(target, room, user, connection) {
@@ -1579,7 +1621,7 @@ export const commands: Chat.ChatCommands = {
void room.battle.stream.write(`>eval ${target.replace(/\n/g, '\f')}`);
},
evalbattlehelp: [
- `/evalbattle [code] - Evaluates the code in the battle stream of the current room. Requires: & console access.`,
+ `/evalbattle [code] - Evaluates the code in the battle stream of the current room. Requires: ~ console access.`,
],
ebat: 'editbattle',
diff --git a/server/chat-commands/avatars.tsx b/server/chat-commands/avatars.tsx
index 2c6efec8e60b..1c17bfc5ccc7 100644
--- a/server/chat-commands/avatars.tsx
+++ b/server/chat-commands/avatars.tsx
@@ -202,7 +202,7 @@ export const Avatars = new class {
const entry = customAvatars[user.id];
if (entry?.notNotified) {
user.send(
- `|pm|&|${user.getIdentity()}|/raw ` +
+ `|pm|~|${user.getIdentity()}|/raw ` +
Chat.html`${<>
You have a new custom avatar!
@@ -529,7 +529,7 @@ const OFFICIAL_AVATARS = new Set([
]);
const OFFICIAL_AVATARS_BELIOT419 = new Set([
- 'acerola', 'aetheremployee', 'aetheremployeef', 'aetherfoundation', 'aetherfoundationf', 'anabel',
+ 'acerola', 'aetheremployee', 'aetheremployeef', 'aetherfoundation', 'aetherfoundationf', 'anabel-gen7',
'beauty-gen7', 'blue-gen7', 'burnet', 'colress-gen7', 'dexio', 'elio', 'faba', 'gladion-stance',
'gladion', 'grimsley-gen7', 'hapu', 'hau-stance', 'hau', 'hiker-gen7', 'ilima', 'kahili', 'kiawe',
'kukui-stand', 'kukui', 'lana', 'lass-gen7', 'lillie-z', 'lillie', 'lusamine-nihilego', 'lusamine',
@@ -553,7 +553,7 @@ const OFFICIAL_AVATARS_BRUMIRAGE = new Set([
'mai', 'marnie', 'may-contest', 'melony', 'milo', 'mina-lgpe', 'mustard', 'mustard-master', 'nessa',
'oleana', 'opal', 'peony', 'pesselle', 'phoebe-gen6', 'piers', 'raihan', 'rei', 'rose', 'sabi', 'sada-ai',
'sanqua', 'shielbert', 'sonia', 'sonia-professor', 'sordward', 'sordward-shielbert', 'tateandliza-gen6',
- 'turo-ai', 'victor', 'victor-dojo', 'volo', 'yellgrunt', 'yellgruntf', 'zisu',
+ 'turo-ai', 'victor', 'victor-dojo', 'volo', 'yellgrunt', 'yellgruntf', 'zisu', 'miku-flying', 'miku-ground',
]);
const OFFICIAL_AVATARS_ZACWEAVILE = new Set([
@@ -625,14 +625,30 @@ const OFFICIAL_AVATARS_KYLEDOVE = new Set([
'laventon2', 'liza-masters', 'mallow-masters', 'musician-gen9', 'nemona-s', 'officeworker-gen9', 'officeworkerf-gen9',
'pearlclanmember', 'raifort', 'saguaro', 'salvatore', 'scientist-gen9', 'shauna-masters', 'silver-masters',
'steven-masters4', 'tate-masters', 'waiter-gen9', 'waitress-gen9',
+ 'acerola-masters2', 'aetherfoundation2', 'amarys', 'artist-gen9', 'backpacker-gen9', 'blackbelt-gen9', 'blue-masters2',
+ 'brendan-rs', 'briar', 'cabbie-gen9', 'caretaker', 'clair-masters', 'clive-v', 'cook-gen9', 'courier', 'crispin', 'cyrano',
+ 'delinquent-gen9', 'delinquentf-gen9', 'delinquentf2-gen9', 'drayton', 'flaregrunt', 'flaregruntf', 'florian-festival',
+ 'gloria-league', 'gloria-tundra', 'hau-masters', 'hiker-gen9', 'hyde', 'janitor-gen9', 'juliana-festival',
+ 'kieran-champion', 'lacey', 'lana-masters', 'leaf-masters2', 'liza-gen6', 'lysandre-masters', 'may-e', 'may-rs', 'miku-fire',
+ 'miku-grass', 'miku-psychic', 'miku-water', 'mina-masters', 'mustard-champion', 'nate-masters', 'nate-pokestar', 'ogreclan',
+ 'perrin', 'piers-masters', 'red-masters3', 'rosa-pokestar2', 'roxanne-masters', 'roxie-masters', 'ruffian', 'sycamore-masters',
+ 'tate-gen6', 'tucker', 'victor-league', 'victor-tundra', 'viola-masters', 'wallace-masters', 'worker-gen9', 'yukito-hideko',
+ 'aarune', 'adaman-masters', 'allister-unmasked', 'anabel', 'aquagrunt-rse', 'aquagruntf-rse', 'aquasuit', 'archie-usum',
+ 'arlo', 'barry-masters', 'blanche-casual', 'blanche', 'brandon', 'candela-casual', 'candela', 'candice-masters', 'christoph',
+ 'cliff', 'curtis', 'dana', 'gladion-masters', 'greta', 'gurkinn', 'heath', 'irida-masters', 'jamie', 'magmagrunt-rse',
+ 'magmagruntf-rse', 'magmasuit', 'magnus', 'mateo', 'mirror', 'mohn-anime', 'mohn', 'mom-paldea', 'mom-unova', 'mrbriney',
+ 'mrstone', 'nancy', 'nate-pokestar3', 'neroli', 'peony-league', 'phil', 'player-go', 'playerf-go', 'rhi', 'rita', 'river',
+ 'rosa-pokestar3', 'sabrina-frlg', 'selene-masters', 'sierra', 'spark-casual', 'spark', 'spenser', 'toddsnap', 'toddsnap2',
+ 'victor-masters', 'vince', 'wally-rse', 'willow-casual', 'willow', 'yancy', 'zinnia-masters',
+ 'acerola-masters3', 'bianca-masters', 'cheren-masters', 'gardenia-masters',
]);
const OFFICIAL_AVATARS_HYOOPPA = new Set([
- 'brendan', 'maxie-gen6', 'may',
+ 'brendan', 'brendan-e', 'maxie-gen6', 'may',
]);
const OFFICIAL_AVATARS_GRAPO = new Set([
- 'glacia', 'peonia', 'skyla-masters2', 'volo-ginkgo',
+ 'glacia', 'peonia', 'phoebe-masters', 'rosa-masters3', 'scottie-masters', 'skyla-masters2', 'volo-ginkgo',
]);
const OFFICIAL_AVATARS_FIFTY = new Set([
@@ -640,7 +656,11 @@ const OFFICIAL_AVATARS_FIFTY = new Set([
]);
const OFFICIAL_AVATARS_HORO = new Set([
- 'florian-bb', 'juliana-bb',
+ 'florian-bb', 'juliana-bb', 'red-lgpe',
+]);
+
+const OFFICIAL_AVATARS_SELENA = new Set([
+ 'kris',
]);
for (const avatar of OFFICIAL_AVATARS_BELIOT419) OFFICIAL_AVATARS.add(avatar);
@@ -652,6 +672,7 @@ for (const avatar of OFFICIAL_AVATARS_HYOOPPA) OFFICIAL_AVATARS.add(avatar);
for (const avatar of OFFICIAL_AVATARS_GRAPO) OFFICIAL_AVATARS.add(avatar);
for (const avatar of OFFICIAL_AVATARS_FIFTY) OFFICIAL_AVATARS.add(avatar);
for (const avatar of OFFICIAL_AVATARS_HORO) OFFICIAL_AVATARS.add(avatar);
+for (const avatar of OFFICIAL_AVATARS_SELENA) OFFICIAL_AVATARS.add(avatar);
export const commands: Chat.ChatCommands = {
avatar(target, room, user) {
@@ -701,6 +722,9 @@ export const commands: Chat.ChatCommands = {
if (OFFICIAL_AVATARS_HORO.has(avatar)) {
this.sendReply(`|raw|(${this.tr`Artist: `}Horo)`);
}
+ if (OFFICIAL_AVATARS_SELENA.has(avatar)) {
+ this.sendReply(`|raw|(${this.tr`Artist: `}Selena)`);
+ }
}
},
avatarhelp: [`/avatar [avatar name or number] - Change your trainer sprite.`],
@@ -762,7 +786,7 @@ export const commands: Chat.ChatCommands = {
avatarshelp: [
`/avatars - Explains how to change avatars.`,
`/avatars [username] - Shows custom avatars available to a user.`,
- `!avatars - Show everyone that information. Requires: + % @ # &`,
+ `!avatars - Show everyone that information. Requires: + % @ # ~`,
],
addavatar() {
@@ -916,7 +940,7 @@ export const commands: Chat.ChatCommands = {
Avatars.tryNotify(Users.get(to));
},
moveavatarshelp: [
- `/moveavatars [from user], [to user] - Move all of the custom avatars from [from user] to [to user]. Requires: &`,
+ `/moveavatars [from user], [to user] - Move all of the custom avatars from [from user] to [to user]. Requires: ~`,
],
async masspavatar(target, room, user) {
diff --git a/server/chat-commands/core.ts b/server/chat-commands/core.ts
index 17fd2a20af32..9334f78074c6 100644
--- a/server/chat-commands/core.ts
+++ b/server/chat-commands/core.ts
@@ -16,7 +16,7 @@
/* eslint no-else-return: "error" */
import {Utils} from '../../lib';
import type {UserSettings} from '../users';
-import type {GlobalPermission} from '../user-groups';
+import type {GlobalPermission, RoomPermission} from '../user-groups';
export const crqHandlers: {[k: string]: Chat.CRQHandler} = {
userdetails(target, user, trustable) {
@@ -131,6 +131,50 @@ export const crqHandlers: {[k: string]: Chat.CRQHandler} = {
}
return roominfo;
},
+ fullformat(target, user, trustable) {
+ if (!trustable) return false;
+
+ if (target.length > 225) {
+ return null;
+ }
+ const targetRoom = Rooms.get(target);
+ if (!targetRoom?.battle?.playerTable[user.id]) {
+ return null;
+ }
+
+ return targetRoom.battle.format;
+ },
+ cmdsearch(target, user, trustable) {
+ // in no world should ths be a thing. our longest command name is 37 chars
+ if (target.length > 40) return null;
+ const cmdPrefix = target.charAt(0);
+ if (!['/', '!'].includes(cmdPrefix)) return null;
+ target = toID(target.slice(1));
+
+ const results = [];
+ for (const command of Chat.allCommands()) {
+ if (cmdPrefix === '!' && !command.broadcastable) continue;
+ const req = command.requiredPermission as GlobalPermission;
+ if (!!req &&
+ !(command.hasRoomPermissions ? !!this.room && user.can(req as RoomPermission, null, this.room) : user.can(req))
+ ) {
+ continue;
+ }
+ const cmds = [
+ command.fullCmd,
+ ...command.aliases.map(x => command.fullCmd.replace(command.cmd, `${x}`)),
+ ];
+ for (const cmd of cmds) {
+ if (toID(cmd).startsWith(target)) {
+ results.push(cmdPrefix + cmd);
+ break;
+ }
+ }
+ // limit number of results to prevent spam
+ if (results.length >= 20) break;
+ }
+ return results;
+ },
};
export const commands: Chat.ChatCommands = {
@@ -229,6 +273,28 @@ export const commands: Chat.ChatCommands = {
},
noreplyhelp: [`/noreply [command] - Runs the command without displaying the response.`],
+ async linksmogon(target, room, user) {
+ if (Config.smogonauth && !Users.globalAuth.atLeast(user, Config.smogonauth)) {
+ throw new Chat.ErrorMessage("Access denied.");
+ }
+ if (!user.registered) {
+ throw new Chat.ErrorMessage(
+ "You must be registered in order to use this command. If you just registered, please refresh and try again."
+ );
+ }
+ this.sendReply("Linking...");
+ const response = await LoginServer.request("smogon/validate", {
+ username: user.id,
+ });
+ const name = response[0]?.signed_username;
+ if (response[1] || !name) {
+ throw new Chat.ErrorMessage("Error while verifying username: " + (response[1]?.message || "malformed name received"));
+ }
+ const link = `https://www.smogon.com/tools/connect-ps-account/${user.id}/${name}`;
+ user.send(`|openpage|${link}`);
+ this.sendReply(`|html|If the page failed to open, you may link your Smogon and PS accounts by clicking this link.`);
+ },
+
async msgroom(target, room, user, connection) {
const [targetId, message] = Utils.splitFirst(target, ',').map(i => i.trim());
if (!targetId || !message) {
@@ -279,7 +345,7 @@ export const commands: Chat.ChatCommands = {
}
user.lastCommand = 'pm';
return this.errorReply(
- this.tr`User ${targetUsername} is offline. If you still want to PM them, send the message again, or use /offlinemsg.`
+ this.tr`User ${targetUsername} is offline. Send the message again to confirm. If you are using /msg, use /offlinemsg instead.`
);
}
let error = this.tr`User ${targetUsername} not found. Did you misspell their name?`;
@@ -299,7 +365,7 @@ export const commands: Chat.ChatCommands = {
}
user.lastCommand = 'pm';
return this.errorReply(
- this.tr`User ${targetUsername} is offline. If you still want to PM them, send the message again, or use /offlinemsg.`
+ this.tr`User ${targetUsername} is offline. Send the message again to confirm. If you are using /msg, use /offlinemsg instead.`
);
}
return this.errorReply(`${targetUsername} is offline.`);
@@ -362,10 +428,23 @@ export const commands: Chat.ChatCommands = {
const pmTarget = this.pmTarget; // not room means it's a PM
if (!pmTarget) {
- const {targetUser, rest: targetRoomid} = this.requireUser(target);
- const targetRoom = targetRoomid ? Rooms.search(targetRoomid) : room;
- if (!targetRoom) return this.errorReply(this.tr`The room "${targetRoomid}" was not found.`);
- return this.parse(`/pm ${targetUser.name}, /invite ${targetRoom.roomid}`);
+ const users = target.split(',').map(part => part.trim());
+ let targetRoom;
+ if (users.length > 1 && Rooms.search(users[users.length - 1])) {
+ targetRoom = users.pop();
+ } else {
+ targetRoom = room;
+ }
+ if (users.length > 1 && !user.trusted) {
+ return this.errorReply("You do not have permission to mass-invite users.");
+ }
+ if (users.length > 10) {
+ return this.errorReply("You cannot invite more than 10 users at once.");
+ }
+ for (const toInvite of users) {
+ this.parse(`/pm ${toInvite}, /invite ${targetRoom}`);
+ }
+ return;
}
const targetRoom = Rooms.search(target);
@@ -392,6 +471,7 @@ export const commands: Chat.ChatCommands = {
},
invitehelp: [
`/invite [username] - Invites the player [username] to join the room you sent the command to.`,
+ `/invite [comma-separated usernames] - Invites multiple users to join the room you sent the command to. Requires trusted`,
`/invite [username], [roomname] - Invites the player [username] to join the room [roomname].`,
`(in a PM) /invite [roomname] - Invites the player you're PMing to join the room [roomname].`,
],
@@ -492,7 +572,7 @@ export const commands: Chat.ChatCommands = {
},
blockinviteshelp: [
`/blockinvites [rank] - Allows only users with the given [rank] to invite you to rooms.`,
- `Valid settings: autoconfirmed, trusted, unlocked, +, %, @, &.`,
+ `Valid settings: autoconfirmed, trusted, unlocked, +, %, @, ~.`,
`/unblockinvites - Allows anyone to invite you to rooms.`,
],
@@ -561,7 +641,7 @@ export const commands: Chat.ChatCommands = {
},
clearstatushelp: [
`/clearstatus - Clears your status message.`,
- `/clearstatus user, reason - Clears another person's status message. Requires: % @ &`,
+ `/clearstatus user, reason - Clears another person's status message. Requires: % @ ~`,
],
unaway: 'back',
@@ -620,8 +700,7 @@ export const commands: Chat.ChatCommands = {
if (user.tempGroup === group) {
return this.errorReply(this.tr`You already have the temporary symbol '${group}'.`);
}
- if (!Users.Auth.isValidSymbol(group) || !(group in Config.groups) ||
- (group === Users.SECTIONLEADER_SYMBOL && !(Users.globalAuth.sectionLeaders.has(user.id) || user.can('bypassall')))) {
+ if (!Users.Auth.isValidSymbol(group) || !(group in Config.groups)) {
return this.errorReply(this.tr`You must specify a valid group symbol.`);
}
if (!isShow && Config.groups[group].rank > Config.groups[user.tempGroup].rank) {
@@ -784,7 +863,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(this.tr`Battle input log re-requested.`);
}
},
- exportinputloghelp: [`/exportinputlog - Asks players in a battle for permission to export an inputlog. Requires: &`],
+ exportinputloghelp: [`/exportinputlog - Asks players in a battle for permission to export an inputlog. Requires: ~`],
importinputlog(target, room, user, connection) {
this.checkCan('importinputlog');
@@ -797,22 +876,10 @@ export const commands: Chat.ChatCommands = {
}
const formatid = target.slice(formatIndex + 12, nextQuoteIndex);
- const battleRoom = Rooms.createBattle({format: formatid, inputLog: target});
+ const battleRoom = Rooms.createBattle({format: formatid, players: [], inputLog: target});
if (!battleRoom) return; // createBattle will inform the user if creating the battle failed
- const nameIndex1 = target.indexOf(`"name":"`);
- const nameNextQuoteIndex1 = target.indexOf(`"`, nameIndex1 + 8);
- const nameIndex2 = target.indexOf(`"name":"`, nameNextQuoteIndex1 + 1);
- const nameNextQuoteIndex2 = target.indexOf(`"`, nameIndex2 + 8);
- if (nameIndex1 >= 0 && nameNextQuoteIndex1 >= 0 && nameIndex2 >= 0 && nameNextQuoteIndex2 >= 0) {
- const battle = battleRoom.battle!;
- battle.p1.name = target.slice(nameIndex1 + 8, nameNextQuoteIndex1);
- battle.p2.name = target.slice(nameIndex2 + 8, nameNextQuoteIndex2);
- }
battleRoom.auth.set(user.id, Users.HOST_SYMBOL);
- for (const player of battleRoom.battle!.players) {
- player.hasTeam = true;
- }
this.parse(`/join ${battleRoom.roomid}`);
setTimeout(() => {
// timer to make sure this goes under the battle
@@ -821,7 +888,7 @@ export const commands: Chat.ChatCommands = {
battleRoom.battle!.sendInviteForm(user);
}, 500);
},
- importinputloghelp: [`/importinputlog [inputlog] - Starts a battle with a given inputlog. Requires: + % @ &`],
+ importinputloghelp: [`/importinputlog [inputlog] - Starts a battle with a given inputlog. Requires: + % @ ~`],
showteam: 'showset',
async showset(target, room, user, connection, cmd) {
@@ -869,7 +936,7 @@ export const commands: Chat.ChatCommands = {
confirmready(target, room, user) {
const game = this.requireGame(Rooms.BestOfGame);
- game.confirmReady(user.id);
+ game.confirmReady(user);
},
acceptopenteamsheets(target, room, user, connection, cmd) {
@@ -982,7 +1049,7 @@ export const commands: Chat.ChatCommands = {
}
}
},
- offertiehelp: [`/offertie - Offers a tie to all players in a battle; if all accept, it ties. Can only be used after 100+ turns have passed. Requires: \u2606 @ # &`],
+ offertiehelp: [`/offertie - Offers a tie to all players in a battle; if all accept, it ties. Can only be used after 100+ turns have passed. Requires: \u2606 @ # ~`],
rejectdraw: 'rejecttie',
rejecttie(target, room, user) {
@@ -1095,7 +1162,7 @@ export const commands: Chat.ChatCommands = {
if (room.battle.replaySaved) this.parse('/savereplay');
this.addModAction(room.tr`${user.name} hid the replay of this battle.`);
},
- hidereplayhelp: [`/hidereplay - Hides the replay of the current battle. Requires: ${Users.PLAYER_SYMBOL} &`],
+ hidereplayhelp: [`/hidereplay - Hides the replay of the current battle. Requires: ${Users.PLAYER_SYMBOL} ~`],
addplayer: 'invitebattle',
invitebattle(target, room, user, connection) {
@@ -1221,7 +1288,7 @@ export const commands: Chat.ChatCommands = {
},
uninvitebattlehelp: [
`/uninvitebattle [username] - Revokes an invite from a user to join a battle.`,
- `Requires: ${Users.PLAYER_SYMBOL} &`,
+ `Requires: ${Users.PLAYER_SYMBOL} ~`,
],
restoreplayers(target, room, user) {
@@ -1283,7 +1350,7 @@ export const commands: Chat.ChatCommands = {
this.errorReply("/kickbattle - User isn't in battle.");
}
},
- kickbattlehelp: [`/kickbattle [username], [reason] - Kicks a user from a battle with reason. Requires: % @ &`],
+ kickbattlehelp: [`/kickbattle [username], [reason] - Kicks a user from a battle with reason. Requires: % @ ~`],
kickinactive(target, room, user) {
this.parse(`/timer on`);
@@ -1329,7 +1396,7 @@ export const commands: Chat.ChatCommands = {
}
},
timerhelp: [
- `/timer [start|stop] - Starts or stops the game timer. Requires: ${Users.PLAYER_SYMBOL} % @ &`,
+ `/timer [start|stop] - Starts or stops the game timer. Requires: ${Users.PLAYER_SYMBOL} % @ ~`,
],
autotimer: 'forcetimer',
@@ -1348,7 +1415,7 @@ export const commands: Chat.ChatCommands = {
}
},
forcetimerhelp: [
- `/forcetimer [start|stop] - Forces all battles to have the inactive timer enabled. Requires: &`,
+ `/forcetimer [start|stop] - Forces all battles to have the inactive timer enabled. Requires: ~`,
],
forcetie: 'forcewin',
@@ -1376,8 +1443,8 @@ export const commands: Chat.ChatCommands = {
this.modlog('FORCEWIN', targetUser.id);
},
forcewinhelp: [
- `/forcetie - Forces the current match to end in a tie. Requires: &`,
- `/forcewin [user] - Forces the current match to end in a win for a user. Requires: &`,
+ `/forcetie - Forces the current match to end in a tie. Requires: ~`,
+ `/forcewin [user] - Forces the current match to end in a win for a user. Requires: ~`,
],
/*********************************************************
@@ -1636,7 +1703,7 @@ export const commands: Chat.ChatCommands = {
if (target.startsWith('/') || target.startsWith('!')) target = target.slice(1);
if (!target) {
- const broadcastMsg = this.tr`(replace / with ! to broadcast. Broadcasting requires: + % @ # &)`;
+ const broadcastMsg = this.tr`(replace / with ! to broadcast. Broadcasting requires: + % @ # ~)`;
this.sendReply(`${this.tr`COMMANDS`}: /report, /msg, /reply, /logout, /challenge, /search, /rating, /whois, /user, /join, /leave, /userauth, /roomauth`);
this.sendReply(`${this.tr`BATTLE ROOM COMMANDS`}: /savereplay, /hideroom, /inviteonly, /invite, /timer, /forfeit`);
diff --git a/server/chat-commands/info.ts b/server/chat-commands/info.ts
index 541c9e2efe8a..3115769ef988 100644
--- a/server/chat-commands/info.ts
+++ b/server/chat-commands/info.ts
@@ -16,6 +16,11 @@ import {RoomSections} from './room-settings';
const ONLINE_SYMBOL = ` \u25C9 `;
const OFFLINE_SYMBOL = ` \u25CC `;
+interface DexResources {
+ url: string;
+ resources: {resource_name: string, url: string}[];
+}
+
export function getCommonBattles(
userID1: ID, user1: User | null, userID2: ID, user2: User | null, connection: Connection
) {
@@ -67,6 +72,30 @@ export function findFormats(targetId: string, isOMSearch = false) {
return {totalMatches, sections};
}
+export const formatsDataCache = new Map();
+export async function getFormatResources(format: string) {
+ const cached = formatsDataCache.get(format);
+ if (cached !== undefined) return cached;
+ try {
+ const raw = await Net(`https://www.smogon.com/dex/api/formats/by-ps-name/${format}`).get();
+ const data = JSON.parse(raw);
+ formatsDataCache.set(format, data);
+ return data;
+ } catch {
+ // some sort of json error or request can't be made
+ // so something on smogon's end. freeze the request, punt
+ formatsDataCache.set(format, null);
+ return null;
+ }
+}
+
+// clear every 15 minutes to ensure it's only minimally stale
+const resourceRefreshInterval = setInterval(() => formatsDataCache.clear(), 15 * 60 * 1000);
+
+export function destroy() {
+ clearInterval(resourceRefreshInterval);
+}
+
export const commands: Chat.ChatCommands = {
ip: 'whois',
rooms: 'whois',
@@ -318,7 +347,7 @@ export const commands: Chat.ChatCommands = {
},
whoishelp: [
`/whois - Get details on yourself: alts, group, IP address, and rooms.`,
- `/whois [username] - Get details on a username: alts (Requires: % @ &), group, IP address (Requires: @ &), and rooms.`,
+ `/whois [username] - Get details on a username: alts (Requires: % @ ~), group, IP address (Requires: @ ~), and rooms.`,
],
'chp': 'offlinewhois',
@@ -418,7 +447,7 @@ export const commands: Chat.ChatCommands = {
return Utils.html`${shortId}`;
}).join(' | '));
},
- sharedbattleshelp: [`/sharedbattles [user1], [user2] - Finds recent battles common to [user1] and [user2]. Requires % @ &`],
+ sharedbattleshelp: [`/sharedbattles [user1], [user2] - Finds recent battles common to [user1] and [user2]. Requires % @ ~`],
sp: 'showpunishments',
showpunishments(target, room, user) {
@@ -428,14 +457,14 @@ export const commands: Chat.ChatCommands = {
}
return this.parse(`/join view-punishments-${room}`);
},
- showpunishmentshelp: [`/showpunishments - Shows the current punishments in the room. Requires: % @ # &`],
+ showpunishmentshelp: [`/showpunishments - Shows the current punishments in the room. Requires: % @ # ~`],
sgp: 'showglobalpunishments',
showglobalpunishments(target, room, user) {
this.checkCan('lock');
return this.parse(`/join view-globalpunishments`);
},
- showglobalpunishmentshelp: [`/showpunishments - Shows the current global punishments. Requires: % @ # &`],
+ showglobalpunishmentshelp: [`/showpunishments - Shows the current global punishments. Requires: % @ # ~`],
async host(target, room, user, connection, cmd) {
if (!target) return this.parse('/help host');
@@ -446,7 +475,7 @@ export const commands: Chat.ChatCommands = {
const dnsblMessage = dnsbl ? ` [${dnsbl}]` : ``;
this.sendReply(`IP ${target}: ${host || "ERROR"} [${hostType}]${dnsblMessage}`);
},
- hosthelp: [`/host [ip] - Gets the host for a given IP. Requires: % @ &`],
+ hosthelp: [`/host [ip] - Gets the host for a given IP. Requires: % @ ~`],
searchip: 'ipsearch',
ipsearchall: 'ipsearch',
@@ -502,7 +531,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(`More than 100 users found. Use /ipsearchall for the full list.`);
}
},
- ipsearchhelp: [`/ipsearch [ip|range|host], (room) - Find all users with specified IP, IP range, or host. If a room is provided only users in the room will be shown. Requires: &`],
+ ipsearchhelp: [`/ipsearch [ip|range|host], (room) - Find all users with specified IP, IP range, or host. If a room is provided only users in the room will be shown. Requires: ~`],
checkchallenges(target, room, user) {
room = this.requireRoom();
@@ -527,7 +556,7 @@ export const commands: Chat.ChatCommands = {
const [from, to] = user1.id === chall.from ? [user1, user2] : [user2, user1];
this.sendReplyBox(Utils.html`${from.name} is challenging ${to.name} in ${Dex.formats.get(chall.format).name}.`);
},
- checkchallengeshelp: [`!checkchallenges [user1], [user2] - Check if the specified users are challenging each other. Requires: * @ # &`],
+ checkchallengeshelp: [`!checkchallenges [user1], [user2] - Check if the specified users are challenging each other. Requires: * @ # ~`],
/*********************************************************
* Client fallback
@@ -552,10 +581,11 @@ export const commands: Chat.ChatCommands = {
pokedex: 'data',
data(target, room, user, connection, cmd) {
if (!this.runBroadcast()) return;
+ target = target.trim();
const gen = parseInt(cmd.substr(-1));
if (gen) target += `, gen${gen}`;
- const {dex, format, targets} = this.splitFormat(target, true);
+ const {dex, format, targets} = this.splitFormat(target, true, true);
let buffer = '';
target = targets.join(',');
@@ -630,7 +660,7 @@ export const commands: Chat.ChatCommands = {
};
details["Weight"] = `${pokemon.weighthg / 10} kg (${weighthit} BP)`;
const gmaxMove = pokemon.canGigantamax || dex.species.get(pokemon.changesFrom).canGigantamax;
- if (gmaxMove && dex.gen >= 8) details["G-Max Move"] = gmaxMove;
+ if (gmaxMove && dex.gen === 8) details["G-Max Move"] = gmaxMove;
if (pokemon.color && dex.gen >= 5) details["Dex Colour"] = pokemon.color;
if (pokemon.eggGroups && dex.gen >= 2) details["Egg Group(s)"] = pokemon.eggGroups.join(", ");
const evos: string[] = [];
@@ -716,7 +746,9 @@ export const commands: Chat.ChatCommands = {
Gen: String(move.gen) || 'CAP',
};
- if (move.isNonstandard === "Past" && dex.gen >= 8) details["✗ Past Gens Only"] = "";
+ const pastGensOnly = (move.isNonstandard === "Past" && dex.gen >= 8) ||
+ (move.isNonstandard === "Gigantamax" && dex.gen !== 8);
+ if (pastGensOnly) details["✗ Past Gens Only"] = "";
if (move.secondary || move.secondaries || move.hasSheerForce) details["✓ Boosted by Sheer Force"] = "";
if (move.flags['contact'] && dex.gen >= 3) details["✓ Contact"] = "";
if (move.flags['sound'] && dex.gen >= 3) details["✓ Sound"] = "";
@@ -774,13 +806,11 @@ export const commands: Chat.ChatCommands = {
}
}
- if (dex.gen >= 8) {
- if (move.isMax) {
- details["✓ Max Move"] = "";
- if (typeof move.isMax === "string") details["User"] = `${move.isMax}`;
- } else if (move.maxMove?.basePower) {
- details["Dynamax Power"] = String(move.maxMove.basePower);
- }
+ if (move.isMax) {
+ details["✓ Max Move"] = "";
+ if (typeof move.isMax === "string") details["User"] = `${move.isMax}`;
+ } else if (dex.gen === 8 && move.maxMove?.basePower) {
+ details["Dynamax Power"] = String(move.maxMove.basePower);
}
const targetTypes: {[k: string]: string} = {
@@ -792,7 +822,7 @@ export const commands: Chat.ChatCommands = {
allAdjacentFoes: "All Adjacent Opponents",
foeSide: "Opposing Side",
allySide: "User's Side",
- allyTeam: "User's Side",
+ allyTeam: "User's Team",
allAdjacent: "All Adjacent Pok\u00e9mon",
any: "Any Pok\u00e9mon",
all: "All Pok\u00e9mon",
@@ -820,8 +850,8 @@ export const commands: Chat.ChatCommands = {
details = {
Gen: String(ability.gen) || 'CAP',
};
- if (ability.isPermanent) details["✓ Not affected by Gastro Acid"] = "";
- if (ability.isBreakable) details["✓ Ignored by Mold Breaker"] = "";
+ if (ability.flags['cantsuppress']) details["✓ Not affected by Gastro Acid"] = "";
+ if (ability.flags['breakable']) details["✓ Ignored by Mold Breaker"] = "";
}
break;
default:
@@ -839,7 +869,7 @@ export const commands: Chat.ChatCommands = {
datahelp: [
`/data [pokemon/item/move/ability/nature] - Get details on this pokemon/item/move/ability/nature.`,
`/data [pokemon/item/move/ability/nature], Gen [generation number/format name] - Get details on this pokemon/item/move/ability/nature for that generation/format.`,
- `!data [pokemon/item/move/ability/nature] - Show everyone these details. Requires: + % @ # &`,
+ `!data [pokemon/item/move/ability/nature] - Show everyone these details. Requires: + % @ # ~`,
],
dt: 'details',
@@ -862,7 +892,7 @@ export const commands: Chat.ChatCommands = {
`/details [Pok\u00e9mon/item/move/ability/nature], Gen [generation number]: get details on this Pok\u00e9mon/item/move/ability/nature in that generation. ` +
`You can also append the generation number to /dt; for example, /dt1 Mewtwo gets details on Mewtwo in Gen 1. ` +
`/details [Pok\u00e9mon/item/move/ability/nature], [format]: get details on this Pok\u00e9mon/item/move/ability/nature in that format. ` +
- `!details [Pok\u00e9mon/item/move/ability/nature]: show everyone these details. Requires: + % @ # &`
+ `!details [Pok\u00e9mon/item/move/ability/nature]: show everyone these details. Requires: + % @ # ~`
);
},
@@ -968,8 +998,8 @@ export const commands: Chat.ChatCommands = {
weaknesshelp: [
`/weakness [pokemon] - Provides a Pok\u00e9mon's resistances, weaknesses, and immunities, ignoring abilities.`,
`/weakness [type 1]/[type 2] - Provides a type or type combination's resistances, weaknesses, and immunities, ignoring abilities.`,
- `!weakness [pokemon] - Shows everyone a Pok\u00e9mon's resistances, weaknesses, and immunities, ignoring abilities. Requires: + % @ # &`,
- `!weakness [type 1]/[type 2] - Shows everyone a type or type combination's resistances, weaknesses, and immunities, ignoring abilities. Requires: + % @ # &`,
+ `!weakness [pokemon] - Shows everyone a Pok\u00e9mon's resistances, weaknesses, and immunities, ignoring abilities. Requires: + % @ # ~`,
+ `!weakness [type 1]/[type 2] - Shows everyone a type or type combination's resistances, weaknesses, and immunities, ignoring abilities. Requires: + % @ # ~`,
],
eff: 'effectiveness',
@@ -1130,23 +1160,14 @@ export const commands: Chat.ChatCommands = {
const immune: string[] = [];
for (const type in bestCoverage) {
- switch (bestCoverage[type]) {
- case 0:
+ if (bestCoverage[type] === 0) {
immune.push(type);
- break;
- case 0.25:
- case 0.5:
+ } else if (bestCoverage[type] < 1) {
resists.push(type);
- break;
- case 1:
- neutral.push(type);
- break;
- case 2:
- case 4:
+ } else if (bestCoverage[type] > 1) {
superEff.push(type);
- break;
- default:
- throw new Error(`/coverage effectiveness of ${bestCoverage[type]} from parameters: ${target}`);
+ } else {
+ neutral.push(type);
}
}
buffer.push(`Coverage for ${sources.join(' + ')}:`);
@@ -1207,23 +1228,14 @@ export const commands: Chat.ChatCommands = {
bestEff = Math.pow(2, bestEff);
}
}
- switch (bestEff) {
- case 0:
+ if (bestEff === 0) {
cell += `bgcolor=#666666 title="${typing}">${bestEff}`;
- break;
- case 0.25:
- case 0.5:
+ } else if (bestEff < 1) {
cell += `bgcolor=#AA5544 title="${typing}">${bestEff}`;
- break;
- case 1:
- cell += `bgcolor=#6688AA title="${typing}">${bestEff}`;
- break;
- case 2:
- case 4:
+ } else if (bestEff > 1) {
cell += `bgcolor=#559955 title="${typing}">${bestEff}`;
- break;
- default:
- throw new Error(`/coverage effectiveness of ${bestEff} from parameters: ${target}`);
+ } else {
+ cell += `bgcolor=#6688AA title="${typing}">${bestEff}`;
}
cell += '';
buffer += cell;
@@ -1286,8 +1298,11 @@ export const commands: Chat.ChatCommands = {
} else if (lowercase.startsWith('lv') || lowercase.startsWith('level')) {
level = parseInt(arg.replace(/\D/g, ''));
lvlSet = true;
+ if (isNaN(level)) {
+ return this.sendReplyBox('Invalid value for level: ' + Utils.escapeHTML(arg));
+ }
if (level < 1 || level > 9999) {
- return this.sendReplyBox('Invalid value for level: ' + level);
+ return this.sendReplyBox('Level should be between 1 and 9999.');
}
continue;
}
@@ -1558,11 +1573,10 @@ export const commands: Chat.ChatCommands = {
const globalRanks = [
`Global ranks`,
`+ Global Voice - They can use ! commands like !groups`,
- `§ Section Leader - They oversee rooms in a particular section`,
`% Global Driver - Like Voice, and they can lock users and check for alts`,
`@ Global Moderator - The above, and they can globally ban users`,
`* Global Bot - An automated account that can use HTML anywhere`,
- `& Global Administrator - They can do anything, like change what this message says and promote users globally`,
+ `~ Global Administrator - They can do anything, like change what this message says and promote users globally`,
];
this.sendReplyBox(
@@ -1574,7 +1588,7 @@ export const commands: Chat.ChatCommands = {
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: + % @ # &`,
+ `!groups - Shows everyone that information. Requires: + % @ # ~`,
],
punishments(target, room, user) {
@@ -1618,7 +1632,7 @@ export const commands: Chat.ChatCommands = {
},
punishmentshelp: [
`/punishments - Explains punishments.`,
- `!punishments - Show everyone that information. Requires: + % @ # &`,
+ `!punishments - Show everyone that information. Requires: + % @ # ~`,
],
repo: 'opensource',
@@ -1638,7 +1652,7 @@ export const commands: Chat.ChatCommands = {
},
opensourcehelp: [
`/opensource - Links to PS's source code repository.`,
- `!opensource - Show everyone that information. Requires: + % @ # &`,
+ `!opensource - Show everyone that information. Requires: + % @ # ~`,
],
staff(target, room, user) {
@@ -1668,7 +1682,7 @@ export const commands: Chat.ChatCommands = {
suggestion: 'suggestions',
suggestions(target, room, user) {
if (!this.runBroadcast()) return;
- this.sendReplyBox(`Make a suggestion for Pokémon Showdown`);
+ this.sendReplyBox(`Make a suggestion for Pokémon Showdown`);
},
suggestionshelp: [`/suggestions - Links to the place to make suggestions for Pokemon Showdown.`],
@@ -1677,12 +1691,12 @@ export const commands: Chat.ChatCommands = {
bugs(target, room, user) {
if (!this.runBroadcast()) return;
if (room?.battle) {
- this.sendReplyBox(`
`);
}
@@ -2037,9 +2057,9 @@ export const commands: Chat.ChatCommands = {
},
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: # &`,
+ `!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(target, room, user) {
@@ -2077,13 +2097,13 @@ export const commands: Chat.ChatCommands = {
buffer.push(`${this.tr`Proxy lock help`}`);
}
if (showAll || ['ca', 'customavatar', 'customavatars'].includes(target)) {
- buffer.push(this.tr`Custom avatars are given to Global Staff members, contributors (coders and spriters) to Pokemon Showdown, and Smogon badgeholders at the discretion of Zarel. They are also sometimes given out as prizes for major room events or Smogon tournaments.`);
+ buffer.push(this.tr`Custom avatars are given to Global Staff members, contributors (coders and spriters) to Pokemon Showdown, and Smogon badgeholders at the discretion of the PS! Administrators. They are also sometimes given out as rewards for major events such as PSPL (Pokemon Showdown Premier League). If you're curious, you can view the entire list of custom avatars.`);
}
if (showAll || ['privacy', 'private'].includes(target)) {
buffer.push(`${this.tr`Pokémon Showdown privacy policy`}`);
}
if (showAll || ['lostpassword', 'password', 'lostpass'].includes(target)) {
- buffer.push(`If you need your Pokémon Showdown password reset, you can fill out a ${this.tr`Password Reset Form`}. You will need to make a Smogon account to be able to fill out the form, as password resets are processed through the Smogon forums.`);
+ buffer.push(`If you need your Pokémon Showdown password reset, you can fill out a ${this.tr`Password Reset Form`}. You will need to make a Smogon account to be able to fill out a form; that's what the email address you sign in to Smogon with is for (PS accounts for regular users don't have emails associated with them).`);
}
if (!buffer.length && target) {
this.errorReply(`'${target}' is an invalid FAQ.`);
@@ -2096,7 +2116,7 @@ export const commands: Chat.ChatCommands = {
},
faqhelp: [
`/faq [theme] - Provides a link to the FAQ. Add autoconfirmed, badges, proxy, 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, proxy, ladder, staff, or tiers for a link to these questions. Add all for all of them. Requires: + % @ # &`,
+ `!faq [theme] - Shows everyone a link to the FAQ. Add autoconfirmed, badges, proxy, ladder, staff, or tiers for a link to these questions. Add all for all of them. Requires: + % @ # ~`,
],
analysis: 'smogdex',
@@ -2276,14 +2296,14 @@ export const commands: Chat.ChatCommands = {
},
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: + % @ # &`,
+ `!analysis [pokemon], [generation], [format] - Shows everyone this link. Requires: + % @ # ~`,
],
- veekun(target, broadcast, user) {
- if (!target) return this.parse('/help veekun');
+ bulbapedia(target, broadcast, user) {
+ if (!target) return this.parse('/help bulbapedia');
if (!this.runBroadcast()) return;
- const baseLink = 'http://veekun.com/dex/';
+ const baseLink = 'https://bulbapedia.bulbagarden.net/wiki/';
const pokemon = Dex.species.get(target);
const item = Dex.items.get(target);
@@ -2298,28 +2318,11 @@ export const commands: Chat.ChatCommands = {
if (pokemon.isNonstandard && pokemon.isNonstandard !== 'Past') {
return this.errorReply(`${pokemon.name} is not a real Pok\u00e9mon.`);
}
+ let baseSpecies = pokemon.baseSpecies;
+ if (pokemon.id.startsWith('flabebe')) baseSpecies = 'Flabébé';
+ const link = `${baseLink}${encodeURIComponent(baseSpecies)}_(Pokémon)`;
- const baseSpecies = pokemon.baseSpecies || pokemon.name;
- 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.species.get(baseSpecies + forme).battleOnly) forme = forme.substr(0, 4);
- if (baseSpecies === 'Pikachu' && Dex.species.get(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.name} description by Veekun`);
+ this.sendReplyBox(Utils.html`${pokemon.name} in-game information, provided by Bulbapedia`);
}
// Item
@@ -2328,8 +2331,9 @@ export const commands: Chat.ChatCommands = {
if (item.isNonstandard && item.isNonstandard !== 'Past') {
return this.errorReply(`${item.name} is not a real item.`);
}
- const link = `${baseLink}items/${item.name.toLowerCase()}`;
- this.sendReplyBox(`${item.name} item description by Veekun`);
+ let link = `${baseLink}${encodeURIComponent(item.name)}`;
+ if (Dex.moves.get(item.name).exists) link += '_(item)';
+ this.sendReplyBox(Utils.html`${item.name} item description, provided by Bulbapedia`);
}
// Ability
@@ -2338,8 +2342,8 @@ export const commands: Chat.ChatCommands = {
if (ability.isNonstandard && ability.isNonstandard !== 'Past') {
return this.errorReply(`${ability.name} is not a real ability.`);
}
- const link = `${baseLink}abilities/${ability.name.toLowerCase()}`;
- this.sendReplyBox(`${ability.name} ability description by Veekun`);
+ const link = `${baseLink}${encodeURIComponent(ability.name)}_(Ability)`;
+ this.sendReplyBox(`${ability.name} ability description, provided by Bulbapedia`);
}
// Move
@@ -2348,24 +2352,24 @@ export const commands: Chat.ChatCommands = {
if (move.isNonstandard && move.isNonstandard !== 'Past') {
return this.errorReply(`${move.name} is not a real move.`);
}
- const link = `${baseLink}moves/${move.name.toLowerCase()}`;
- this.sendReplyBox(`${move.name} move description by Veekun`);
+ const link = `${baseLink}${encodeURIComponent(move.name)}_(move)`;
+ this.sendReplyBox(`${move.name} move description, provided by Bulbapedia`);
}
// Nature
if (nature.exists) {
atLeastOne = true;
- const link = `${baseLink}natures/${nature.name.toLowerCase()}`;
- this.sendReplyBox(`${nature.name} nature description by Veekun`);
+ const link = `${baseLink}Nature`;
+ this.sendReplyBox(`Nature descriptions, provided by Bulbapedia`);
}
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: + % @ # &`,
+ bulbapediahelp: [
+ `/bulbapedia [pokemon/item/move/ability/nature] - Links to Bulbapedia wiki page for this pokemon/item/move/ability/nature.`,
+ `!bulbapedia [pokemon/item/move/ability/nature] - Shows everyone this link. Requires: + % @ # ~`,
],
register() {
@@ -2484,8 +2488,7 @@ export const commands: Chat.ChatCommands = {
pr: 'pickrandom',
pick: 'pickrandom',
pickrandom(target, room, user) {
- if (!target) return false;
- if (!target.includes(',')) return this.parse('/help pick');
+ if (!target || !target.includes(',')) return this.parse('/help pick');
if (!this.runBroadcast(true)) return false;
if (this.broadcasting) {
[, target] = Utils.splitFirst(this.message, ' ');
@@ -2581,7 +2584,7 @@ export const commands: Chat.ChatCommands = {
buf = Utils.html``;
if (resized) buf += Utils.html` full-size image`;
} else {
- buf = await YouTube.generateVideoDisplay(request.link, false, true);
+ buf = await YouTube.generateVideoDisplay(request.link, false);
if (!buf) return this.errorReply('Could not get YouTube video');
}
buf += Utils.html`
(Requested by ${request.name})`;
@@ -2593,7 +2596,7 @@ export const commands: Chat.ChatCommands = {
room.add(`|c| ${request.name}|/raw ${buf}`);
this.privateModAction(`${user.name} approved showing media from ${request.name}.`);
},
- approveshowhelp: [`/approveshow [user] - Approves the media display request of [user]. Requires: % @ # &`],
+ approveshowhelp: [`/approveshow [user] - Approves the media display request of [user]. Requires: % @ # ~`],
denyshow(target, room, user) {
room = this.requireRoom();
@@ -2617,13 +2620,13 @@ export const commands: Chat.ChatCommands = {
room.sendUser(targetUser, `|raw|
Your media request was denied.
`);
room.sendUser(targetUser, `|notify|Media request denied`);
},
- denyshowhelp: [`/denyshow [user] - Denies the media display request of [user]. Requires: % @ # &`],
+ denyshowhelp: [`/denyshow [user] - Denies the media display request of [user]. Requires: % @ # ~`],
approvallog(target, room, user) {
room = this.requireRoom();
return this.parse(`/sl approved showing media from, ${room.roomid}`);
},
- approvalloghelp: [`/approvallog - View a log of past media approvals in the current room. Requires: % @ # &`],
+ approvalloghelp: [`/approvallog - View a log of past media approvals in the current room. Requires: ~`],
viewapprovals(target, room, user) {
room = this.requireRoom();
@@ -2631,7 +2634,7 @@ export const commands: Chat.ChatCommands = {
},
viewapprovalshelp: [
`/viewapprovals - View a list of users who have requested to show media in the current room.`,
- `Requires: % @ # &`,
+ `Requires: % @ # ~`,
],
async show(target, room, user, connection) {
@@ -2656,14 +2659,12 @@ export const commands: Chat.ChatCommands = {
this.runBroadcast();
let buf;
if (YouTube.linkRegex.test(link)) {
- buf = await YouTube.generateVideoDisplay(link, false, this.broadcasting);
+ buf = await YouTube.generateVideoDisplay(link, false);
this.message = this.message.replace(/&ab_channel=(.*)(&|)/ig, '').replace(/https:\/\/www\./ig, '');
} else if (Twitch.linkRegex.test(link)) {
const channelId = Twitch.linkRegex.exec(link)?.[2]?.trim();
if (!channelId) return this.errorReply(`Specify a Twitch channel.`);
- const info = await Twitch.getChannel(channelId);
- if (!info) return this.errorReply(`Channel ${channelId} not found.`);
- buf = `Watching ${info.display_name}... `;
+ buf = Utils.html`Watching ${channelId}... `;
buf += ``;
} else {
if (Chat.linkRegex.test(link)) {
@@ -2698,7 +2699,7 @@ export const commands: Chat.ChatCommands = {
},
showhelp: [
`/show [url] - Shows you an image, audio clip, video file, or YouTube video.`,
- `!show [url] - Shows an image, audio clip, video file, or YouTube video to everyone in a chatroom. Requires: whitelist % @ # &`,
+ `!show [url] - Shows an image, audio clip, video file, or YouTube video to everyone in a chatroom. Requires: whitelist % @ # ~`,
],
rebroadcast(target, room, user, connection) {
@@ -2779,7 +2780,7 @@ export const commands: Chat.ChatCommands = {
}
},
codehelp: [
- `!code [code] - Broadcasts code to a room. Accepts multi-line arguments. Requires: + % @ & #`,
+ `!code [code] - Broadcasts code to a room. Accepts multi-line arguments. Requires: + % @ ~ #`,
`/code [code] - Shows you code. Accepts multi-line arguments.`,
],
@@ -2800,7 +2801,7 @@ export const commands: Chat.ChatCommands = {
allowEmpty: true, useIDs: false,
});
const format = Dex.formats.get(toID(args.format[0]));
- if (!format.exists) {
+ if (format.effectType !== 'Format') {
return this.popupReply(`The format '${format}' does not exist.`);
}
delete args.format;
@@ -2925,7 +2926,7 @@ export const commands: Chat.ChatCommands = {
}
this.sendReplyBox(buf);
},
- adminhelphelp: [`/adminhelp - Programmatically generates a list of all administrator commands. Requires: &`],
+ adminhelphelp: [`/adminhelp - Programmatically generates a list of all administrator commands. Requires: ~`],
altlog: 'altslog',
altslog(target, room, user) {
@@ -2937,8 +2938,65 @@ export const commands: Chat.ChatCommands = {
return this.parse(`/join view-altslog-${target}`);
},
altsloghelp: [
- `/altslog [userid] - View the alternate account history for the given [userid]. Requires: % @ &`,
+ `/altslog [userid] - View the alternate account history for the given [userid]. Requires: % @ ~`,
],
+
+ randtopic(target, room, user) {
+ room = this.requireRoom();
+ if (!room.settings.topics?.length) {
+ return this.errorReply(`This room has no random topics to select from.`);
+ }
+ this.runBroadcast();
+ this.sendReply(Utils.html`|html|
${Utils.randomElement(room.settings.topics)}
`);
+ },
+ randtopichelp: [
+ `/randtopic - Randomly selects a topic from the room's discussion topic pool and displays it.`,
+ `/addtopic [target] - Adds the [target] to the pool of random discussion topics. Requires: % @ # ~`,
+ `/removetopic [index] - Removes the topic from the room's topic pool. Requires: % @ # ~`,
+ `/randomtopics - View the discussion topic pool for the current room.`,
+ ],
+
+ addtopic(target, room, user) {
+ room = this.requireRoom();
+ this.checkCan('mute', null, room);
+ target = target.trim();
+ if (!toID(target).length) {
+ return this.parse(`/help randtopic`);
+ }
+ if (!room.settings.topics) room.settings.topics = [];
+ room.settings.topics.push(target);
+ this.privateModAction(`${user.name} added the topic "${target}" to the random topic pool.`);
+ this.modlog('ADDTOPIC', null, target);
+ room.saveSettings();
+ },
+ addtopichelp: [`/addtopic [target] - Adds the [target] to the pool of random discussion topics. Requires: % @ # ~`],
+
+ removetopic(target, room, user) {
+ room = this.requireRoom();
+ this.checkCan('mute', null, room);
+ if (!toID(target)) {
+ return this.parse(`/help randtopic`);
+ }
+ const index = Number(toID(target)) - 1;
+ if (isNaN(index)) {
+ return this.errorReply(`Invalid topic index: ${target}. Must be a number.`);
+ }
+ if (!room.settings.topics?.[index]) {
+ return this.errorReply(`Topic ${index + 1} not found.`);
+ }
+ const topic = room.settings.topics.splice(index, 1)[0];
+ room.saveSettings();
+ this.privateModAction(`${user.name} removed topic ${index + 1} from the random topic pool.`);
+ this.modlog('REMOVETOPIC', null, topic);
+ },
+ removetopichelp: [`/removetopic [index] - Removes the topic from the room's topic pool. Requires: % @ # ~`],
+
+ listtopics: 'randomtopics',
+ randtopics: 'randomtopics',
+ randomtopics(target, room, user) {
+ room = this.requireRoom();
+ return this.parse(`/join view-topics-${room}`);
+ },
};
export const handlers: Chat.Handlers = {
@@ -3014,6 +3072,22 @@ export const pages: Chat.PageTable = {
}
return buf;
},
+ topics(query, user) {
+ const room = this.requireRoom();
+ this.title = `[Topics] ${room.title}`;
+ const topics = room.settings.topics || [];
+ let buf;
+ if (!topics.length) {
+ buf = `
This room has no discussion topics saved.
`;
+ return buf;
+ }
+ buf = `
Random topics for ${room.title} (${topics.length}):
`;
+ for (const [i, topic] of topics.entries()) {
+ buf += Utils.html`
`;
}
return buf;
diff --git a/server/chat-commands/moderation.ts b/server/chat-commands/moderation.ts
index 725ffeed7c58..5023ee007aae 100644
--- a/server/chat-commands/moderation.ts
+++ b/server/chat-commands/moderation.ts
@@ -23,7 +23,7 @@ const REQUIRE_REASONS = true;
/**
* Promotes a user within a room. Returns a User object if a popup should be shown to the user,
- * and null otherwise. Throws a Chat.ErrorMesage on an error.
+ * and null otherwise. Throws a Chat.ErrorMessage on an error.
*
* @param promoter the User object of the user who is promoting
* @param room the Room in which the promotion is happening
@@ -173,7 +173,7 @@ export const commands: Chat.ChatCommands = {
}
room.saveSettings();
},
- roomownerhelp: [`/roomowner [username] - Appoints [username] as a room owner. Requires: &`],
+ roomownerhelp: [`/roomowner [username] - Appoints [username] as a room owner. Requires: ~`],
roomdemote: 'roompromote',
forceroompromote: 'roompromote',
@@ -276,9 +276,9 @@ export const commands: Chat.ChatCommands = {
room.saveSettings();
},
roompromotehelp: [
- `/roompromote OR /roomdemote [comma-separated usernames], [group symbol] - Promotes/demotes the user(s) to the specified room rank. Requires: @ # &`,
- `/room[group] [comma-separated usernames] - Promotes/demotes the user(s) to the specified room rank. Requires: @ # &`,
- `/roomdeauth [comma-separated usernames] - Removes all room rank from the user(s). Requires: @ # &`,
+ `/roompromote OR /roomdemote [comma-separated usernames], [group symbol] - Promotes/demotes the user(s) to the specified room rank. Requires: @ # ~`,
+ `/room[group] [comma-separated usernames] - Promotes/demotes the user(s) to the specified room rank. Requires: @ # ~`,
+ `/roomdeauth [comma-separated usernames] - Removes all room rank from the user(s). Requires: @ # ~`,
],
auth: 'authority',
@@ -303,8 +303,6 @@ export const commands: Chat.ChatCommands = {
const buffer = Utils.sortBy(
Object.entries(rankLists) as [GroupSymbol, string[]][],
([symbol]) => -Users.Auth.getGroup(symbol).rank
- ).filter(
- ([symbol]) => symbol !== Users.SECTIONLEADER_SYMBOL
).map(
([symbol, names]) => (
`${(Config.groups[symbol] ? `**${Config.groups[symbol].name}s** (${symbol})` : symbol)}:\n` +
@@ -467,7 +465,7 @@ export const commands: Chat.ChatCommands = {
],
async autojoin(target, room, user, connection) {
- const targets = target.split(',');
+ const targets = target.split(',').filter(Boolean);
if (targets.length > 16 || connection.inRooms.size > 1) {
return connection.popup("To prevent DoS attacks, you can only use /autojoin for 16 or fewer rooms, when you haven't joined any rooms yet. Please use /join for each room separately.");
}
@@ -614,7 +612,7 @@ export const commands: Chat.ChatCommands = {
warnhelp: [
`/warn OR /k [username], [reason] - Warns a user showing them the site rules and [reason] in an overlay.`,
`/warn OR /k [username], [reason] spoiler: [private reason] - Warns a user, marking [private reason] only in the modlog.`,
- `Requires: % @ # &`,
+ `Requires: % @ # ~`,
],
redirect: 'redir',
@@ -658,7 +656,7 @@ export const commands: Chat.ChatCommands = {
},
redirhelp: [
`/redirect OR /redir [username], [roomname] - [DEPRECATED]`,
- `Attempts to redirect the [username] to the [roomname]. Requires: &`,
+ `Attempts to redirect the [username] to the [roomname]. Requires: ~`,
],
m: 'mute',
@@ -694,24 +692,30 @@ export const commands: Chat.ChatCommands = {
}
this.addModAction(`${targetUser.name} was muted by ${user.name} for ${Chat.toDurationString(muteDuration)}.${(publicReason ? ` (${publicReason})` : ``)}`);
this.modlog(`${cmd.includes('h') ? 'HOUR' : ''}MUTE`, targetUser, privateReason);
+ this.update(); // force an update so the (hide lines from x user) message is on the mod action above
+
+ const ids = [targetUser.getLastId()];
+ if (ids[0] !== toID(inputUsername)) {
+ ids.push(toID(inputUsername));
+ }
+ room.hideText(ids);
+
if (targetUser.autoconfirmed && targetUser.autoconfirmed !== targetUser.id) {
const displayMessage = `${targetUser.name}'s ac account: ${targetUser.autoconfirmed}`;
this.privateModAction(displayMessage);
}
- const userid = targetUser.getLastId();
- this.add(`|hidelines|unlink|${userid}`);
- if (userid !== toID(inputUsername)) this.add(`|hidelines|unlink|${toID(inputUsername)}`);
+ Chat.runHandlers('onPunishUser', 'MUTE', user, room);
room.mute(targetUser, muteDuration);
},
- mutehelp: [`/mute OR /m [username], [reason] - Mutes a user with reason for 7 minutes. Requires: % @ # &`],
+ mutehelp: [`/mute OR /m [username], [reason] - Mutes a user with reason for 7 minutes. Requires: % @ # ~`],
hm: 'hourmute',
hourmute(target) {
if (!target) return this.parse('/help hourmute');
this.run('mute');
},
- hourmutehelp: [`/hourmute OR /hm [username], [reason] - Mutes a user with reason for an hour. Requires: % @ # &`],
+ hourmutehelp: [`/hourmute OR /hm [username], [reason] - Mutes a user with reason for an hour. Requires: % @ # ~`],
um: 'unmute',
unmute(target, room, user) {
@@ -733,7 +737,7 @@ export const commands: Chat.ChatCommands = {
this.errorReply(`${(targetUser ? targetUser.name : targetUsername)} is not muted.`);
}
},
- unmutehelp: [`/unmute [username] - Removes mute from user. Requires: % @ # &`],
+ unmutehelp: [`/unmute [username] - Removes mute from user. Requires: % @ # ~`],
rb: 'ban',
weekban: 'ban',
@@ -798,6 +802,7 @@ export const commands: Chat.ChatCommands = {
const time = week ? Date.now() + 7 * 24 * 60 * 60 * 1000 : null;
const affected = Punishments.roomBan(room, targetUser, time, null, privateReason);
+ for (const u of affected) Chat.runHandlers('onPunishUser', 'ROOMBAN', u, room);
if (!room.settings.isPrivate && room.persist) {
const acAccount = (targetUser.autoconfirmed !== userid && targetUser.autoconfirmed);
let displayMessage = '';
@@ -822,8 +827,8 @@ export const commands: Chat.ChatCommands = {
return true;
},
banhelp: [
- `/ban [username], [reason] - Bans the user from the room you are in. Requires: @ # &`,
- `/weekban [username], [reason] - Bans the user from the room you are in for a week. Requires: @ # &`,
+ `/ban [username], [reason] - Bans the user from the room you are in. Requires: @ # ~`,
+ `/weekban [username], [reason] - Bans the user from the room you are in for a week. Requires: @ # ~`,
],
unroomban: 'unban',
@@ -844,7 +849,7 @@ export const commands: Chat.ChatCommands = {
this.errorReply(`User '${target}' is not banned from this room.`);
}
},
- unbanhelp: [`/unban [username] - Unbans the user from the room you are in. Requires: @ # &`],
+ unbanhelp: [`/unban [username] - Unbans the user from the room you are in. Requires: @ # ~`],
forcelock: 'lock',
forceweeklock: 'lock',
@@ -918,6 +923,7 @@ export const commands: Chat.ChatCommands = {
affected = await Punishments.lock(userid, duration, null, false, publicReason);
}
+ for (const u of affected) Chat.runHandlers('onPunishUser', 'LOCK', u, room);
this.globalModlog(
(force ? `FORCE` : ``) + (week ? "WEEKLOCK" : (month ? "MONTHLOCK" : "LOCK")), targetUser || userid, privateReason
);
@@ -968,7 +974,7 @@ export const commands: Chat.ChatCommands = {
return true;
},
lockhelp: [
- `/lock OR /l [username], [reason] - Locks the user from talking in all chats. Requires: % @ &`,
+ `/lock OR /l [username], [reason] - Locks the user from talking in all chats. Requires: % @ ~`,
`/weeklock OR /wl [username], [reason] - Same as /lock, but locks users for a week.`,
`/lock OR /l [username], [reason] spoiler: [private reason] - Marks [private reason] in modlog only.`,
],
@@ -1062,12 +1068,12 @@ export const commands: Chat.ChatCommands = {
this.privateGlobalModAction(`${user.name} unlocked the ${range ? "IP range" : "IP"}: ${target}`);
this.globalModlog(`UNLOCK${range ? 'RANGE' : 'IP'}`, null, null, target);
},
- unlockiphelp: [`/unlockip [ip] - Unlocks a punished ip while leaving the original punishment intact. Requires: @ &`],
- unlocknamehelp: [`/unlockname [name] - Unlocks a punished alt, leaving the original lock intact. Requires: % @ &`],
+ unlockiphelp: [`/unlockip [ip] - Unlocks a punished ip while leaving the original punishment intact. Requires: @ ~`],
+ unlocknamehelp: [`/unlockname [name] - Unlocks a punished alt, leaving the original lock intact. Requires: % @ ~`],
unlockhelp: [
- `/unlock [username] - Unlocks the user. Requires: % @ &`,
- `/unlockname [username] - Unlocks a punished alt while leaving the original punishment intact. Requires: % @ &`,
- `/unlockip [ip] - Unlocks a punished ip while leaving the original punishment intact. Requires: @ &`,
+ `/unlock [username] - Unlocks the user. Requires: % @ ~`,
+ `/unlockname [username] - Unlocks a punished alt while leaving the original punishment intact. Requires: % @ ~`,
+ `/unlockip [ip] - Unlocks a punished ip while leaving the original punishment intact. Requires: @ ~`,
],
forceglobalban: 'globalban',
@@ -1122,6 +1128,7 @@ export const commands: Chat.ChatCommands = {
this.addGlobalModAction(`${name} was globally banned by ${user.name}.${(publicReason ? ` (${publicReason})` : ``)}`);
const affected = await Punishments.ban(userid, null, null, false, publicReason);
+ for (const u of affected) Chat.runHandlers('onPunishUser', 'BAN', u, room);
const acAccount = (targetUser && targetUser.autoconfirmed !== userid && targetUser.autoconfirmed);
let displayMessage = '';
if (affected.length > 1) {
@@ -1149,7 +1156,7 @@ export const commands: Chat.ChatCommands = {
return true;
},
globalbanhelp: [
- `/globalban OR /gban [username], [reason] - Kick user from all rooms and ban user's IP address with reason. Requires: @ &`,
+ `/globalban OR /gban [username], [reason] - Kick user from all rooms and ban user's IP address with reason. Requires: @ ~`,
`/globalban OR /gban [username], [reason] spoiler: [private reason] - Marks [private reason] in modlog only.`,
],
@@ -1167,7 +1174,7 @@ export const commands: Chat.ChatCommands = {
this.addGlobalModAction(`${name} was globally unbanned by ${user.name}.`);
this.globalModlog("UNBAN", target);
},
- unglobalbanhelp: [`/unglobalban [username] - Unban a user. Requires: @ &`],
+ unglobalbanhelp: [`/unglobalban [username] - Unban a user. Requires: @ ~`],
deroomvoiceall(target, room, user) {
room = this.requireRoom();
@@ -1198,7 +1205,7 @@ export const commands: Chat.ChatCommands = {
this.addModAction(`All ${count} roomvoices have been cleared by ${user.name}.`);
this.modlog('DEROOMVOICEALL');
},
- deroomvoiceallhelp: [`/deroomvoiceall - Devoice all roomvoiced users. Requires: # &`],
+ deroomvoiceallhelp: [`/deroomvoiceall - Devoice all roomvoiced users. Requires: # ~`],
// this is a separate command for two reasons
// a - yearticketban is preferred over /ht yearban
@@ -1248,7 +1255,7 @@ export const commands: Chat.ChatCommands = {
},
yearticketbanhelp: [
`/yearticketban [IP/userid] - Ban an IP or a userid from opening tickets for a year. `,
- `Accepts wildcards to ban ranges. Requires: &`,
+ `Accepts wildcards to ban ranges. Requires: ~`,
],
rangeban: 'banip',
@@ -1286,7 +1293,7 @@ export const commands: Chat.ChatCommands = {
},
baniphelp: [
`/banip [ip] OR /yearbanip [ip] - Globally bans this IP or IP range for an hour. Accepts wildcards to ban ranges.`,
- `Existing users on the IP will not be banned. Requires: &`,
+ `Existing users on the IP will not be banned. Requires: ~`,
],
unrangeban: 'unbanip',
@@ -1304,7 +1311,7 @@ export const commands: Chat.ChatCommands = {
this.addGlobalModAction(`${user.name} unbanned the ${(target.endsWith('*') ? "IP range" : "IP")}: ${target}`);
this.modlog('UNRANGEBAN', null, target);
},
- unbaniphelp: [`/unbanip [ip] - Unbans. Accepts wildcards to ban ranges. Requires: &`],
+ unbaniphelp: [`/unbanip [ip] - Unbans. Accepts wildcards to ban ranges. Requires: ~`],
forceyearlockname: 'yearlockname',
yearlockid: 'yearlockname',
@@ -1372,7 +1379,7 @@ export const commands: Chat.ChatCommands = {
`/lockip [ip] - Globally locks this IP or IP range for an hour. Accepts wildcards to ban ranges.`,
`/yearlockip [ip] - Globally locks this IP or IP range for one year. Accepts wildcards to ban ranges.`,
`/yearnamelockip [ip] - Namelocks this IP or IP range for one year. Accepts wildcards to ban ranges.`,
- `Existing users on the IP will not be banned. Requires: &`,
+ `Existing users on the IP will not be banned. Requires: ~`,
],
/*********************************************************
@@ -1420,7 +1427,10 @@ export const commands: Chat.ChatCommands = {
this.privateModAction(`${user.name} notes: ${target}`);
},
- modnotehelp: [`/modnote [note] - Adds a moderator note that can be read through modlog. Requires: % @ # &`],
+ modnotehelp: [
+ `/modnote - Adds a moderator note that can be read through modlog. Requires: % @ # ~`,
+ `/modnote [] - Adds a moderator note to a user's modlog that can be read through modlog. Requires: % @ # ~`,
+ ],
globalpromote: 'promote',
promote(target, room, user, connection, cmd) {
@@ -1496,7 +1506,7 @@ export const commands: Chat.ChatCommands = {
}
}
},
- promotehelp: [`/promote [username], [group] - Promotes the user to the specified group. Requires: &`],
+ promotehelp: [`/promote [username], [group] - Promotes the user to the specified group. Requires: ~`],
untrustuser: 'trustuser',
unconfirmuser: 'trustuser',
@@ -1555,8 +1565,8 @@ export const commands: Chat.ChatCommands = {
}
},
trustuserhelp: [
- `/trustuser [username] - Trusts the user (makes them immune to locks). Requires: &`,
- `/untrustuser [username] - Removes the trusted user status from the user. Requires: &`,
+ `/trustuser [username] - Trusts the user (makes them immune to locks). Requires: ~`,
+ `/untrustuser [username] - Removes the trusted user status from the user. Requires: ~`,
],
desectionleader: 'sectionleader',
@@ -1575,19 +1585,10 @@ export const commands: Chat.ChatCommands = {
} else if (!Users.globalAuth.sectionLeaders.has(targetUser?.id || userid) && demoting) {
throw new Chat.ErrorMessage(`${name} is not a Section Leader.`);
}
- const staffRoom = Rooms.get('staff');
if (!demoting) {
Users.globalAuth.setSection(userid, section);
this.addGlobalModAction(`${name} was appointed Section Leader of ${RoomSections.sectionNames[section]} by ${user.name}.`);
this.globalModlog(`SECTION LEADER`, userid, section);
- if (targetUser) {
- // do not use global /forcepromote
- if (!Users.globalAuth.atLeast(targetUser, Users.SECTIONLEADER_SYMBOL)) {
- this.parse(`/globalsectionleader ${userid}`);
- }
- } else {
- this.sendReply(`User ${userid} is offline and unrecognized, and so can't be globally promoted.`);
- }
targetUser?.popup(`You were appointed Section Leader of ${RoomSections.sectionNames[section]} by ${user.name}.`);
} else {
const group = Users.globalAuth.get(userid);
@@ -1595,7 +1596,6 @@ export const commands: Chat.ChatCommands = {
this.privateGlobalModAction(`${name} was demoted from Section Leader of ${RoomSections.sectionNames[section]} by ${user.name}.`);
if (group === ' ') this.sendReply(`They are also no longer manually trusted. If they should be, use '/trustuser'.`);
this.globalModlog(`DESECTION LEADER`, userid, section);
- if (staffRoom?.auth.getDirect(userid) as any === '\u25B8') this.parse(`/msgroom staff,/roomdeauth ${userid}`);
targetUser?.popup(`You were demoted from Section Leader of ${RoomSections.sectionNames[section]} by ${user.name}.`);
}
@@ -1612,7 +1612,7 @@ export const commands: Chat.ChatCommands = {
`/desectionleader [target user] - Demotes [target user] from Section Leader.`,
`Valid sections: ${RoomSections.sections.join(', ')}`,
`If you want to change which section someone leads, demote them and then re-promote them in the desired section.`,
- `Requires: &`,
+ `Requires: ~`,
],
globaldemote: 'demote',
@@ -1620,7 +1620,7 @@ export const commands: Chat.ChatCommands = {
if (!target) return this.parse('/help demote');
this.run('promote');
},
- demotehelp: [`/demote [username], [group] - Demotes the user to the specified group. Requires: &`],
+ demotehelp: [`/demote [username], [group] - Demotes the user to the specified group. Requires: ~`],
forcepromote(target, room, user, connection) {
// warning: never document this command in /help
@@ -1677,7 +1677,7 @@ export const commands: Chat.ChatCommands = {
this.add(Utils.html`|raw|
`);
this.modlog(`HTMLDECLARE`, null, target);
},
- htmldeclarehelp: [`/htmldeclare [message] - Anonymously announces a message using safe HTML. Requires: # * &`],
+ htmldeclarehelp: [`/htmldeclare [message] - Anonymously announces a message using safe HTML. Requires: # * ~`],
gdeclare: 'globaldeclare',
globaldeclare(target, room, user) {
@@ -1704,11 +1704,11 @@ export const commands: Chat.ChatCommands = {
this.checkHTML(target);
for (const u of Users.users.values()) {
- if (u.connected) u.send(`|pm|&|${u.tempGroup}${u.name}|/raw
${target}
`);
+ if (u.connected) u.send(`|pm|~|${u.tempGroup}${u.name}|/raw
${target}
`);
}
this.globalModlog(`GLOBALDECLARE`, null, target);
},
- globaldeclarehelp: [`/globaldeclare [message] - Anonymously sends a private message to all the users on the site. Requires: &`],
+ globaldeclarehelp: [`/globaldeclare [message] - Anonymously sends a private message to all the users on the site. Requires: ~`],
cdeclare: 'chatdeclare',
chatdeclare(target, room, user) {
@@ -1723,7 +1723,7 @@ export const commands: Chat.ChatCommands = {
}
this.globalModlog(`CHATDECLARE`, null, target);
},
- chatdeclarehelp: [`/cdeclare [message] - Anonymously announces a message to all chatrooms on the server. Requires: &`],
+ chatdeclarehelp: [`/cdeclare [message] - Anonymously announces a message to all chatrooms on the server. Requires: ~`],
wall: 'announce',
announce(target, room, user) {
@@ -1735,7 +1735,7 @@ export const commands: Chat.ChatCommands = {
return `/announce ${target}`;
},
- announcehelp: [`/announce OR /wall [message] - Makes an announcement. Requires: % @ # &`],
+ announcehelp: [`/announce OR /wall [message] - Makes an announcement. Requires: % @ # ~`],
notifyoffrank: 'notifyrank',
notifyrank(target, room, user, connection, cmd) {
@@ -1771,8 +1771,8 @@ export const commands: Chat.ChatCommands = {
}
},
notifyrankhelp: [
- `/notifyrank [rank], [title], [message], [highlight] - Sends a notification to users who are [rank] or higher (and highlight on [highlight], if specified). Requires: # * &`,
- `/notifyoffrank [rank] - Closes the notification previously sent with /notifyrank [rank]. Requires: # * &`,
+ `/notifyrank [rank], [title], [message], [highlight] - Sends a notification to users who are [rank] or higher (and highlight on [highlight], if specified). Requires: # * ~`,
+ `/notifyoffrank [rank] - Closes the notification previously sent with /notifyrank [rank]. Requires: # * ~`,
],
notifyoffuser: 'notifyuser',
@@ -1800,8 +1800,8 @@ export const commands: Chat.ChatCommands = {
}
},
notifyuserhelp: [
- `/notifyuser [username], [title], [message] - Sends a notification to [user]. Requires: # * &`,
- `/notifyoffuser [user] - Closes the notification previously sent with /notifyuser [user]. Requires: # * &`,
+ `/notifyuser [username], [title], [message] - Sends a notification to [user]. Requires: # * ~`,
+ `/notifyoffuser [user] - Closes the notification previously sent with /notifyuser [user]. Requires: # * ~`,
],
fr: 'forcerename',
@@ -1865,8 +1865,8 @@ export const commands: Chat.ChatCommands = {
return true;
},
forcerenamehelp: [
- `/forcerename OR /fr [username], [reason] - Forcibly change a user's name and shows them the [reason]. Requires: % @ &`,
- `/allowname [username] - Unmarks a forcerenamed username, stopping staff from being notified when it is used. Requires % @ &`,
+ `/forcerename OR /fr [username], [reason] - Forcibly change a user's name and shows them the [reason]. Requires: % @ ~`,
+ `/allowname [username] - Unmarks a forcerenamed username, stopping staff from being notified when it is used. Requires % @ ~`,
],
nfr: 'noforcerename',
@@ -1964,6 +1964,7 @@ export const commands: Chat.ChatCommands = {
}
const duration = week ? 7 * 24 * 60 * 60 * 1000 : 48 * 60 * 60 * 1000;
await Punishments.namelock(userid, Date.now() + duration, null, false, publicReason);
+ if (targetUser) Chat.runHandlers('onPunishUser', 'NAMELOCK', targetUser, room);
// Automatically upload replays as evidence/reference to the punishment
if (room?.battle) this.parse('/savereplay forpunishment');
Monitor.forceRenames.set(userid, false);
@@ -1980,7 +1981,7 @@ export const commands: Chat.ChatCommands = {
return true;
},
- namelockhelp: [`/namelock OR /nl [user], [reason] - Name locks a [user] and shows the [reason]. Requires: % @ &`],
+ namelockhelp: [`/namelock OR /nl [user], [reason] - Name locks a [user] and shows the [reason]. Requires: % @ ~`],
unl: 'unnamelock',
unnamelock(target, room, user) {
@@ -2003,7 +2004,7 @@ export const commands: Chat.ChatCommands = {
if (!reason) this.globalModlog("UNNAMELOCK", toID(target));
if (targetUser) targetUser.popup(`${user.name} has unnamelocked you.`);
},
- unnamelockhelp: [`/unnamelock [username] - Unnamelocks the user. Requires: % @ &`],
+ unnamelockhelp: [`/unnamelock [username] - Unnamelocks the user. Requires: % @ ~`],
hidetextalts: 'hidetext',
hidealttext: 'hidetext',
@@ -2080,9 +2081,9 @@ export const commands: Chat.ChatCommands = {
}
},
hidetexthelp: [
- `/hidetext [username], [optional reason] - Removes a user's messages from chat, with an optional reason. Requires: % @ # &`,
- `/hidealtstext [username], [optional reason] - Removes a user's messages and their alternate accounts' messages from the chat, with an optional reason. Requires: % @ # &`,
- `/hidelines [username], [number], [optional reason] - Removes the [number] most recent messages from a user, with an optional reason. Requires: % @ # &`,
+ `/hidetext [username], [optional reason] - Removes a user's messages from chat, with an optional reason. Requires: % @ # ~`,
+ `/hidealtstext [username], [optional reason] - Removes a user's messages and their alternate accounts' messages from the chat, with an optional reason. Requires: % @ # ~`,
+ `/hidelines [username], [number], [optional reason] - Removes the [number] most recent messages from a user, with an optional reason. Requires: % @ # ~`,
`Use /cleartext, /clearaltstext, and /clearlines to remove messages without displaying a button to reveal them.`,
],
@@ -2153,6 +2154,7 @@ export const commands: Chat.ChatCommands = {
const affected = Punishments.roomBlacklist(room, targetUser, expireTime, null, reason);
+ for (const u of affected) Chat.runHandlers('onPunishUser', 'BLACKLIST', u, room);
if (!room.settings.isPrivate && room.persist) {
const acAccount = (targetUser.autoconfirmed !== userid && targetUser.autoconfirmed);
let displayMessage = '';
@@ -2174,11 +2176,11 @@ export const commands: Chat.ChatCommands = {
return true;
},
blacklisthelp: [
- `/blacklist [username], [reason] - Blacklists the user from the room you are in for a year. Requires: # &`,
- `/permablacklist OR /permabl - blacklist a user for 10 years. Requires: # &`,
- `/unblacklist [username] - Unblacklists the user from the room you are in. Requires: # &`,
- `/showblacklist OR /showbl - show a list of blacklisted users in the room. Requires: % @ # &`,
- `/expiringblacklists OR /expiringbls - show a list of blacklisted users from the room whose blacklists are expiring in 3 months or less. Requires: % @ # &`,
+ `/blacklist [username], [reason] - Blacklists the user from the room you are in for a year. Requires: # ~`,
+ `/permablacklist OR /permabl - blacklist a user for 10 years. Requires: # ~`,
+ `/unblacklist [username] - Unblacklists the user from the room you are in. Requires: # ~`,
+ `/showblacklist OR /showbl - show a list of blacklisted users in the room. Requires: % @ # ~`,
+ `/expiringblacklists OR /expiringbls - show a list of blacklisted users from the room whose blacklists are expiring in 3 months or less. Requires: % @ # ~`,
],
forcebattleban: 'battleban',
@@ -2228,7 +2230,7 @@ export const commands: Chat.ChatCommands = {
},
battlebanhelp: [
`/battleban [username], [reason] - [DEPRECATED]`,
- `Prevents the user from starting new battles for 2 days and shows them the [reason]. Requires: &`,
+ `Prevents the user from starting new battles for 2 days and shows them the [reason]. Requires: ~`,
],
unbattleban(target, room, user) {
@@ -2246,7 +2248,7 @@ export const commands: Chat.ChatCommands = {
this.errorReply(`User ${target} is not banned from battling.`);
}
},
- unbattlebanhelp: [`/unbattleban [username] - [DEPRECATED] Allows a user to battle again. Requires: % @ &`],
+ unbattlebanhelp: [`/unbattleban [username] - [DEPRECATED] Allows a user to battle again. Requires: % @ ~`],
monthgroupchatban: 'groupchatban',
monthgcban: 'groupchatban',
@@ -2310,7 +2312,7 @@ export const commands: Chat.ChatCommands = {
groupchatbanhelp: [
`/groupchatban [user], [optional reason]`,
`/monthgroupchatban [user], [optional reason]`,
- `Bans the user from joining or creating groupchats for a week (or month). Requires: % @ &`,
+ `Bans the user from joining or creating groupchats for a week (or month). Requires: % @ ~`,
],
ungcban: 'ungroupchatban',
@@ -2331,7 +2333,7 @@ export const commands: Chat.ChatCommands = {
this.errorReply(`User ${target} is not banned from using groupchats.`);
}
},
- ungroupchatbanhelp: [`/ungroupchatban [user] - Allows a groupchatbanned user to use groupchats again. Requires: % @ &`],
+ ungroupchatbanhelp: [`/ungroupchatban [user] - Allows a groupchatbanned user to use groupchats again. Requires: % @ ~`],
nameblacklist: 'blacklistname',
permablacklistname: 'blacklistname',
@@ -2385,8 +2387,8 @@ export const commands: Chat.ChatCommands = {
return true;
},
blacklistnamehelp: [
- `/blacklistname OR /nameblacklist [name1, name2, etc.] | reason - Blacklists all name(s) from the room you are in for a year. Requires: # &`,
- `/permablacklistname [name1, name2, etc.] | reason - Blacklists all name(s) from the room you are in for 10 years. Requires: # &`,
+ `/blacklistname OR /nameblacklist [name1, name2, etc.] | reason - Blacklists all name(s) from the room you are in for a year. Requires: # ~`,
+ `/permablacklistname [name1, name2, etc.] | reason - Blacklists all name(s) from the room you are in for 10 years. Requires: # ~`,
],
unab: 'unblacklist',
@@ -2406,7 +2408,7 @@ export const commands: Chat.ChatCommands = {
this.errorReply(`User '${target}' is not blacklisted.`);
}
},
- unblacklisthelp: [`/unblacklist [username] - Unblacklists the user from the room you are in. Requires: # &`],
+ unblacklisthelp: [`/unblacklist [username] - Unblacklists the user from the room you are in. Requires: # ~`],
unblacklistall(target, room, user) {
room = this.requireRoom();
@@ -2428,7 +2430,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('UNBLACKLISTALL');
this.roomlog(`Unblacklisted users: ${unblacklisted.join(', ')}`);
},
- unblacklistallhelp: [`/unblacklistall - Unblacklists all blacklisted users in the current room. Requires: # &`],
+ unblacklistallhelp: [`/unblacklistall - Unblacklists all blacklisted users in the current room. Requires: # ~`],
expiringbls: 'showblacklist',
expiringblacklists: 'showblacklist',
@@ -2492,7 +2494,7 @@ export const commands: Chat.ChatCommands = {
this.sendReplyBox(buf);
},
showblacklisthelp: [
- `/showblacklist OR /showbl - show a list of blacklisted users in the room. Requires: % @ # &`,
- `/expiringblacklists OR /expiringbls - show a list of blacklisted users from the room whose blacklists are expiring in 3 months or less. Requires: % @ # &`,
+ `/showblacklist OR /showbl - show a list of blacklisted users in the room. Requires: % @ # ~`,
+ `/expiringblacklists OR /expiringbls - show a list of blacklisted users from the room whose blacklists are expiring in 3 months or less. Requires: % @ # ~`,
],
};
diff --git a/server/chat-commands/room-settings.ts b/server/chat-commands/room-settings.ts
index 983e396683f4..76e1ce7589a9 100644
--- a/server/chat-commands/room-settings.ts
+++ b/server/chat-commands/room-settings.ts
@@ -89,7 +89,7 @@ export const commands: Chat.ChatCommands = {
if (
room.settings.modchat && room.settings.modchat.length <= 1 &&
!room.auth.atLeast(user, room.settings.modchat) &&
- // Upper Staff should probably be able to set /modchat & in secret rooms
+ // Upper Staff should probably be able to set /modchat ~ in secret rooms
!user.can('bypassall')
) {
return this.errorReply(`/modchat - Access denied for changing a setting currently at ${room.settings.modchat}.`);
@@ -155,7 +155,7 @@ export const commands: Chat.ChatCommands = {
room.saveSettings();
},
modchathelp: [
- `/modchat [off/autoconfirmed/trusted/+/%/@/*/player/#/&] - Set the level of moderated chat. Requires: % \u2606 for off/autoconfirmed/+/player options, * @ # & for all the options`,
+ `/modchat [off/autoconfirmed/trusted/+/%/@/*/player/#/~] - Set the level of moderated chat. Requires: % \u2606 for off/autoconfirmed/+/player options, * @ # ~ for all the options`,
],
automodchat(target, room, user) {
@@ -185,7 +185,7 @@ export const commands: Chat.ChatCommands = {
return this.parse(`/help automodchat`);
}
}
- const validGroups = [...Config.groupsranking as string[], 'trusted'];
+ const validGroups = [...Config.groupsranking as string[], 'trusted', 'autoconfirmed'];
if (!validGroups.includes(rank)) {
return this.errorReply(`Invalid rank.`);
}
@@ -202,7 +202,7 @@ export const commands: Chat.ChatCommands = {
},
automodchathelp: [
`/automodchat [number], [rank] - Sets modchat [rank] to automatically turn on after [number] minutes with no staff.`,
- `[number] must be between 5 and 480. Requires: # &`,
+ `[number] must be between 5 and 480. Requires: # ~`,
`/automodchat off - Turns off automodchat.`,
],
@@ -243,7 +243,7 @@ export const commands: Chat.ChatCommands = {
}
},
inviteonlyhelp: [
- `/inviteonly [on|off] - Sets modjoin %. Users can't join unless invited with /invite. Requires: # &`,
+ `/inviteonly [on|off] - Sets modjoin %. Users can't join unless invited with /invite. Requires: # ~`,
`/ioo - Shortcut for /inviteonly on`,
`/inviteonlynext OR /ionext - Sets your next battle to be invite-only.`,
`/ionext off - Sets your next battle to be publicly visible.`,
@@ -328,8 +328,8 @@ export const commands: Chat.ChatCommands = {
if (!room.settings.isPrivate) return this.parse('/hiddenroom');
},
modjoinhelp: [
- `/modjoin [+|%|@|*|player|&|#|off] - Sets modjoin. Users lower than the specified rank can't join this room unless they have a room rank. Requires: \u2606 # &`,
- `/modjoin [sync|off] - Sets modjoin. Only users who can speak in modchat can join this room. Requires: \u2606 # &`,
+ `/modjoin [+|%|@|*|player|~|#|off] - Sets modjoin. Users lower than the specified rank can't join this room unless they have a room rank. Requires: \u2606 # ~`,
+ `/modjoin [sync|off] - Sets modjoin. Only users who can speak in modchat can join this room. Requires: \u2606 # ~`,
],
roomlanguage(target, room, user) {
@@ -349,7 +349,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(`The room's language has been set to ${Chat.languages.get(targetLanguage)}`);
},
roomlanguagehelp: [
- `/roomlanguage [language] - Sets the the language for the room, which changes language of a few commands. Requires # &`,
+ `/roomlanguage [language] - Sets the the language for the room, which changes language of a few commands. Requires # ~`,
`Supported Languages: English, Spanish, Italian, French, Simplified Chinese, Traditional Chinese, Japanese, Hindi, Turkish, Dutch, German.`,
],
@@ -385,8 +385,8 @@ export const commands: Chat.ChatCommands = {
room.saveSettings();
},
slowchathelp: [
- `/slowchat [number] - Sets a limit on how often users in the room can send messages, between 2 and 60 seconds. Requires @ # &`,
- `/slowchat off - Disables slowchat in the room. Requires @ # &`,
+ `/slowchat [number] - Sets a limit on how often users in the room can send messages, between 2 and 60 seconds. Requires % @ # ~`,
+ `/slowchat off - Disables slowchat in the room. Requires % @ # ~`,
],
permission: 'permissions',
permissions: {
@@ -440,8 +440,8 @@ export const commands: Chat.ChatCommands = {
return this.privateModAction(`${user.name} set the required rank for ${perm} to ${displayRank}.`);
},
sethelp: [
- `/permissions set [command], [rank symbol] - sets the required permission to use the command [command] to [rank]. Requires: # &`,
- `/permissions clear [command] - resets the required permission to use the command [command] to the default. Requires: # &`,
+ `/permissions set [command], [rank symbol] - sets the required permission to use the command [command] to [rank]. Requires: # ~`,
+ `/permissions clear [command] - resets the required permission to use the command [command] to the default. Requires: # ~`,
],
view(target, room, user) {
room = this.requireRoom();
@@ -517,7 +517,7 @@ export const commands: Chat.ChatCommands = {
room.saveSettings();
},
stretchfilterhelp: [
- `/stretchfilter [on/off] - Toggles filtering messages in the room for stretchingggggggg. Requires # &`,
+ `/stretchfilter [on/off] - Toggles filtering messages in the room for stretchingggggggg. Requires # ~`,
],
capitals: 'capsfilter',
@@ -546,7 +546,7 @@ export const commands: Chat.ChatCommands = {
room.saveSettings();
},
- capsfilterhelp: [`/capsfilter [on/off] - Toggles filtering messages in the room for EXCESSIVE CAPS. Requires # &`],
+ capsfilterhelp: [`/capsfilter [on/off] - Toggles filtering messages in the room for EXCESSIVE CAPS. Requires # ~`],
emojis: 'emojifilter',
emoji: 'emojifilter',
@@ -574,7 +574,7 @@ export const commands: Chat.ChatCommands = {
room.saveSettings();
},
- emojifilterhelp: [`/emojifilter [on/off] - Toggles filtering messages in the room for emojis. Requires # &`],
+ emojifilterhelp: [`/emojifilter [on/off] - Toggles filtering messages in the room for emojis. Requires # ~`],
linkfilter(target, room, user) {
room = this.requireRoom();
@@ -601,7 +601,7 @@ export const commands: Chat.ChatCommands = {
room.saveSettings();
},
- linkfilterhelp: [`/linkfilter [on/off] - Toggles filtering messages in the room for links. Requires # &`],
+ linkfilterhelp: [`/linkfilter [on/off] - Toggles filtering messages in the room for links. Requires # ~`],
banwords: 'banword',
banword: {
@@ -715,10 +715,10 @@ export const commands: Chat.ChatCommands = {
},
},
banwordhelp: [
- `/banword add [words] - Adds the comma-separated list of phrases to the banword list of the current room. Requires: # &`,
- `/banword addregex [words] - Adds the comma-separated list of regular expressions to the banword list of the current room. Requires &`,
- `/banword delete [words] - Removes the comma-separated list of phrases from the banword list. Requires: # &`,
- `/banword list - Shows the list of banned words in the current room. Requires: % @ # &`,
+ `/banword add [words] - Adds the comma-separated list of phrases to the banword list of the current room. Requires: # ~`,
+ `/banword addregex [words] - Adds the comma-separated list of regular expressions to the banword list of the current room. Requires ~`,
+ `/banword delete [words] - Removes the comma-separated list of phrases from the banword list. Requires: # ~`,
+ `/banword list - Shows the list of banned words in the current room. Requires: % @ # ~`,
],
showapprovals(target, room, user) {
@@ -750,7 +750,7 @@ export const commands: Chat.ChatCommands = {
},
showapprovalshelp: [
`/showapprovals [setting] - Enable or disable the use of media approvals in the current room.`,
- `Requires: # &`,
+ `Requires: # ~`,
],
showmedia(target, room, user) {
@@ -761,7 +761,7 @@ export const commands: Chat.ChatCommands = {
hightraffic(target, room, user) {
room = this.requireRoom();
if (!target) {
- return this.sendReply(`This room is: ${room.settings.highTraffic ? 'high traffic' : 'low traffic'}`);
+ return this.sendReply(`This room is: ${room.settings.highTraffic ? 'high' : 'low'} traffic`);
}
this.checkCan('makeroom');
@@ -774,10 +774,10 @@ export const commands: Chat.ChatCommands = {
}
room.saveSettings();
this.modlog(`HIGHTRAFFIC`, null, `${!!room.settings.highTraffic}`);
- this.addModAction(`This room was marked as high traffic by ${user.name}.`);
+ this.addModAction(`This room was marked as ${room.settings.highTraffic ? 'high' : 'low'} traffic by ${user.name}.`);
},
hightraffichelp: [
- `/hightraffic [on|off] - (Un)marks a room as a high traffic room. Requires &`,
+ `/hightraffic [on|off] - (Un)marks a room as a high traffic room. Requires ~`,
`When a room is marked as high-traffic, PS requires all messages sent to that room to contain at least 2 letters.`,
],
@@ -793,7 +793,7 @@ export const commands: Chat.ChatCommands = {
const id = toID(target);
if (!id || this.cmd === 'makechatroom') return this.parse('/help makechatroom');
if (!Rooms.global.addChatRoom(target)) {
- return this.errorReply(`An error occurred while trying to create the room '${target}'.`);
+ return this.errorReply(`The room '${target}' already exists or it is using an invalid title.`);
}
const targetRoom = Rooms.search(target);
@@ -819,8 +819,8 @@ export const commands: Chat.ChatCommands = {
}
},
makechatroomhelp: [
- `/makeprivatechatroom [roomname] - Creates a new private room named [roomname]. Requires: &`,
- `/makepublicchatroom [roomname] - Creates a new public room named [roomname]. Requires: &`,
+ `/makeprivatechatroom [roomname] - Creates a new private room named [roomname]. Requires: ~`,
+ `/makepublicchatroom [roomname] - Creates a new public room named [roomname]. Requires: ~`,
],
subroomgroupchat: 'makegroupchat',
@@ -960,7 +960,7 @@ export const commands: Chat.ChatCommands = {
return this.errorReply(`The room "${target}" isn't registered.`);
},
deregisterchatroomhelp: [
- `/deregisterchatroom [roomname] - Deletes room [roomname] after the next server restart. Requires: &`,
+ `/deregisterchatroom [roomname] - Deletes room [roomname] after the next server restart. Requires: ~`,
],
deletechatroom: 'deleteroom',
@@ -1019,8 +1019,8 @@ export const commands: Chat.ChatCommands = {
room.destroy();
},
deleteroomhelp: [
- `/deleteroom [roomname] - Deletes room [roomname]. Must be typed in the room to delete. Requires: &`,
- `/deletegroupchat - Deletes the current room, if it's a groupchat. Requires: ★ # &`,
+ `/deleteroom [roomname] - Deletes room [roomname]. Must be typed in the room to delete. Requires: ~`,
+ `/deletegroupchat - Deletes the current room, if it's a groupchat. Requires: ★ # ~`,
],
rename() {
@@ -1079,7 +1079,7 @@ export const commands: Chat.ChatCommands = {
}
room.add(Utils.html`|raw|
The room has been renamed to ${target}
`).update();
},
- renameroomhelp: [`/renameroom [new title] - Renames the current room to [new title]. Case-sensitive. Requires &`],
+ renameroomhelp: [`/renameroom [new title] - Renames the current room to [new title]. Case-sensitive. Requires ~`],
hideroom: 'privateroom',
hiddenroom: 'privateroom',
@@ -1088,10 +1088,11 @@ export const commands: Chat.ChatCommands = {
unlistroom: 'privateroom',
privateroom(target, room, user, connection, cmd) {
room = this.requireRoom();
- if (room.battle) {
+ const battle = room.battle || room.bestOf;
+ if (battle) {
this.checkCan('editprivacy', null, room);
- if (room.battle.forcedSettings.privacy) {
- return this.errorReply(`This battle is required to be public because a player has a name prefixed by '${room.battle.forcedSettings.privacy}'.`);
+ if (battle.forcedSettings.privacy) {
+ return this.errorReply(`This battle is required to be public because a player has a name prefixed by '${battle.forcedSettings.privacy}'.`);
}
if (room.tour?.forcePublic) {
return this.errorReply(`This battle can't be hidden, because the tournament is set to be forced public.`);
@@ -1136,7 +1137,7 @@ export const commands: Chat.ChatCommands = {
if (room.parent && room.parent.settings.isPrivate) {
return this.errorReply(`This room's parent ${room.parent.title} must be public for this room to be public.`);
}
- if (room.settings.isPersonal && !room.battle) {
+ if (room.settings.isPersonal && !battle) {
return this.errorReply(`This room can't be made public.`);
}
if (room.privacySetter && user.can('nooverride', null, room) && !user.can('makeroom')) {
@@ -1156,7 +1157,7 @@ export const commands: Chat.ChatCommands = {
room.setPrivate(false);
} else {
const settingName = (setting === true ? 'secret' : setting);
- if (room.subRooms) {
+ if (room.subRooms && !room.bestOf) {
if (settingName === 'secret') return this.errorReply("Secret rooms cannot have subrooms.");
for (const subRoom of room.subRooms.values()) {
if (!subRoom.settings.isPrivate) {
@@ -1173,15 +1174,15 @@ export const commands: Chat.ChatCommands = {
}
this.addModAction(`${user.name} made this room ${settingName}.`);
this.modlog(`${settingName.toUpperCase()}ROOM`);
- if (!room.settings.isPersonal && !room.battle) room.setSection();
+ if (!room.settings.isPersonal && !battle) room.setSection();
room.setPrivate(setting);
room.privacySetter = new Set([user.id]);
}
},
privateroomhelp: [
- `/secretroom - Makes a room secret. Secret rooms are visible to & and up. Requires: &`,
- `/hiddenroom [on/off] - Makes a room hidden. Hidden rooms are visible to % and up, and inherit global ranks. Requires: \u2606 &`,
- `/publicroom - Makes a room public. Requires: \u2606 &`,
+ `/secretroom - Makes a room secret. Secret rooms are visible to ~ and up. Requires: ~`,
+ `/hiddenroom [on/off] - Makes a room hidden. Hidden rooms are visible to % and up, and inherit global ranks. Requires: \u2606 ~`,
+ `/publicroom - Makes a room public. Requires: \u2606 ~`,
],
hidenext(target, room, user) {
@@ -1229,8 +1230,8 @@ export const commands: Chat.ChatCommands = {
}
},
roomspotlighthelp: [
- `/roomspotlight [spotlight] - Makes the room this command is used in a spotlight room for the [spotlight] category on the roomlist. Requires: &`,
- `/roomspotlight off - Removes the room this command is used in from the list of spotlight rooms. Requires: &`,
+ `/roomspotlight [spotlight] - Makes the room this command is used in a spotlight room for the [spotlight] category on the roomlist. Requires: ~`,
+ `/roomspotlight off - Removes the room this command is used in from the list of spotlight rooms. Requires: ~`,
],
setsubroom: 'subroom',
@@ -1289,7 +1290,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('UNSUBROOM');
return this.addModAction(`This room was unset as a subroom by ${user.name}.`);
},
- unsubroomhelp: [`/unsubroom - Unmarks the current room as a subroom. Requires: &`],
+ unsubroomhelp: [`/unsubroom - Unmarks the current room as a subroom. Requires: ~`],
parentroom: 'subrooms',
subrooms(target, room, user, connection, cmd) {
@@ -1317,8 +1318,8 @@ export const commands: Chat.ChatCommands = {
},
subroomhelp: [
- `/subroom [room] - Marks the current room as a subroom of [room]. Requires: &`,
- `/unsubroom - Unmarks the current room as a subroom. Requires: &`,
+ `/subroom [room] - Marks the current room as a subroom of [room]. Requires: ~`,
+ `/unsubroom - Unmarks the current room as a subroom. Requires: ~`,
`/subrooms - Displays the current room's subrooms.`,
`/parentroom - Displays the current room's parent room.`,
],
@@ -1354,7 +1355,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('ROOMDESC', null, `to "${target}"`);
room.saveSettings();
},
- roomdeschelp: [`/roomdesc [description] - Sets the [description] of the current room. Requires: &`],
+ roomdeschelp: [`/roomdesc [description] - Sets the [description] of the current room. Requires: ~`],
topic: 'roomintro',
roomintro(target, room, user, connection, cmd) {
@@ -1391,7 +1392,7 @@ export const commands: Chat.ChatCommands = {
},
roomintrohelp: [
`/roomintro - Display the room introduction of the current room.`,
- `/roomintro [content] - Set an introduction for the room. Requires: # &`,
+ `/roomintro [content] - Set an introduction for the room. Requires: # ~`,
],
deletetopic: 'deleteroomintro',
@@ -1406,7 +1407,7 @@ export const commands: Chat.ChatCommands = {
delete room.settings.introMessage;
room.saveSettings();
},
- deleteroomintrohelp: [`/deleteroomintro - Deletes the current room's introduction. Requires: # &`],
+ deleteroomintrohelp: [`/deleteroomintro - Deletes the current room's introduction. Requires: # ~`],
stafftopic: 'staffintro',
staffintro(target, room, user, connection, cmd) {
@@ -1441,7 +1442,7 @@ export const commands: Chat.ChatCommands = {
this.roomlog(room.settings.staffMessage.replace(/\n/g, ``));
room.saveSettings();
},
- staffintrohelp: [`/staffintro [content] - Set an introduction for staff members. Requires: @ # &`],
+ staffintrohelp: [`/staffintro [content] - Set an introduction for staff members. Requires: @ # ~`],
deletestafftopic: 'deletestaffintro',
deletestaffintro(target, room, user) {
@@ -1455,7 +1456,7 @@ export const commands: Chat.ChatCommands = {
delete room.settings.staffMessage;
room.saveSettings();
},
- deletestaffintrohelp: [`/deletestaffintro - Deletes the current room's staff introduction. Requires: @ # &`],
+ deletestaffintrohelp: [`/deletestaffintro - Deletes the current room's staff introduction. Requires: @ # ~`],
roomalias(target, room, user) {
room = this.requireRoom();
@@ -1487,8 +1488,8 @@ export const commands: Chat.ChatCommands = {
},
roomaliashelp: [
`/roomalias - displays a list of all room aliases of the room the command was entered in.`,
- `/roomalias [alias] - adds the given room alias to the room the command was entered in. Requires: &`,
- `/removeroomalias [alias] - removes the given room alias of the room the command was entered in. Requires: &`,
+ `/roomalias [alias] - adds the given room alias to the room the command was entered in. Requires: ~`,
+ `/removeroomalias [alias] - removes the given room alias of the room the command was entered in. Requires: ~`,
],
deleteroomalias: 'removeroomalias',
@@ -1521,7 +1522,7 @@ export const commands: Chat.ChatCommands = {
}
},
removeroomaliashelp: [
- `/removeroomalias [alias] - removes the given room alias of the room the command was entered in. Requires: &`,
+ `/removeroomalias [alias] - removes the given room alias of the room the command was entered in. Requires: ~`,
],
resettierdisplay: 'roomtierdisplay',
@@ -1566,8 +1567,8 @@ export const commands: Chat.ChatCommands = {
},
roomtierdisplayhelp: [
`/roomtierdisplay - displays the current room's display.`,
- `/roomtierdisplay [option] - changes the current room's tier display. Valid options are: tiers, doubles tiers, numbers. Requires: # &`,
- `/resettierdisplay - resets the current room's tier display. Requires: # &`,
+ `/roomtierdisplay [option] - changes the current room's tier display. Valid options are: tiers, doubles tiers, numbers. Requires: # ~`,
+ `/resettierdisplay - resets the current room's tier display. Requires: # ~`,
],
setroomsection: 'roomsection',
@@ -1587,7 +1588,7 @@ export const commands: Chat.ChatCommands = {
this.globalModlog('ROOMSECTION', null, section || 'none');
},
roomsectionhelp: [
- `/roomsection [section] - Sets the room this is used in to the specified [section]. Requires: &`,
+ `/roomsection [section] - Sets the room this is used in to the specified [section]. Requires: ~`,
`Valid sections: ${sections.join(', ')}`,
],
@@ -1614,7 +1615,7 @@ export const commands: Chat.ChatCommands = {
target = toID(target);
const format = Dex.formats.get(target);
- if (format.exists) {
+ if (format.effectType === 'Format') {
target = format.name;
}
const {isMatch} = this.extractFormat(target);
@@ -1626,8 +1627,8 @@ export const commands: Chat.ChatCommands = {
this.privateModAction(`${user.name} set this room's default format to ${target}.`);
},
roomdefaultformathelp: [
- `/roomdefaultformat [format] or [mod] or gen[number] - Sets this room's default format/mod. Requires: # &`,
- `/roomdefaultformat off - Clears this room's default format/mod. Requires: # &`,
+ `/roomdefaultformat [format] or [mod] or gen[number] - Sets this room's default format/mod. Requires: # ~`,
+ `/roomdefaultformat off - Clears this room's default format/mod. Requires: # ~`,
`Affected commands: /details, /coverage, /effectiveness, /weakness, /learn`,
],
};
@@ -1742,7 +1743,7 @@ export const pages: Chat.PageTable = {
atLeastOne = true;
buf += `
${permission}
`;
if (room.auth.atLeast(user, '#')) {
- buf += roomGroups.filter(group => group !== Users.SECTIONLEADER_SYMBOL).map(group => (
+ buf += roomGroups.map(group => (
requiredRank === group ?
Utils.html`` :
Utils.html``
diff --git a/server/chat-formatter.ts b/server/chat-formatter.ts
index c3425eee1be1..672b51e7f9b3 100644
--- a/server/chat-formatter.ts
+++ b/server/chat-formatter.ts
@@ -39,7 +39,9 @@ REGEXFREE SOURCE FOR LINKREGEX
|
# parentheses in URLs should be matched, so they're not confused
# for parentheses around URLs
- \( ( [^\\s()<>&] | & )* \)
+ \( ( [^\s()<>&[\]] | & )* \)
+ |
+ \[ ( [^\s()<>&[\]] | & )* ]
)*
# URLs usually don't end with punctuation, so don't allow
# punctuation symbols that probably arent related to URL.
@@ -47,7 +49,7 @@ REGEXFREE SOURCE FOR LINKREGEX
[^\s()[\]{}\".,!?;:&<>*`^~\\]
|
# annoyingly, Wikipedia URLs often end in )
- \( ( [^\s()<>&] | & )* \)
+ \( ( [^\s()<>&[\]] | & )* \)
)
)?
)?
@@ -58,9 +60,19 @@ REGEXFREE SOURCE FOR LINKREGEX
(?! [^ ]*> )
*/
-export const linkRegex = /(?:(?:https?:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*|www\.[a-z0-9-]+(?:\.[a-z0-9-]+)+|\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:(?:com?|org|net|edu|info|us|jp)\b|[a-z]{2,3}(?=:[0-9]|\/)))(?::[0-9]+)?(?:\/(?:(?:[^\s()&<>]|&|"|\((?:[^\\s()<>&]|&)*\))*(?:[^\s()[\]{}".,!?;:&<>*`^~\\]|\((?:[^\s()<>&]|&)*\)))?)?|[a-z0-9.]+@[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})(?![^ ]*>)/ig;
+export const linkRegex = /(?:(?:https?:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)*|www\.[a-z0-9-]+(?:\.[a-z0-9-]+)+|\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:(?:com?|org|net|edu|info|us|jp)\b|[a-z]{2,3}(?=:[0-9]|\/)))(?::[0-9]+)?(?:\/(?:(?:[^\s()&<>]|&|"|\((?:[^\s()<>&[\]]|&)*\)|\[(?:[^\s()<>&[\]]|&)*])*(?:[^\s()[\]{}".,!?;:&<>*`^~\\]|\((?:[^\s()<>&[\]]|&)*\)))?)?|[a-z0-9.]+@[a-z0-9-]+(?:\.[a-z0-9-]+)*\.[a-z]{2,})(?![^ ]*>)/ig;
-type SpanType = '_' | '*' | '~' | '^' | '\\' | '|' | '<' | '[' | '`' | 'a' | 'spoiler' | '>' | '(';
+/**
+ * A span is a part of the text that's formatted. In the text:
+ *
+ * Hi, **this** is an example.
+ *
+ * The word `this` is a `*` span. Many spans are just a symbol repeated, and
+ * that symbol is the span type, but also many are more complicated.
+ * For an explanation of all of these, see the `TextFormatter#get` function
+ * implementation.
+ */
+type SpanType = '_' | '*' | '~' | '^' | '\\' | '|' | '<' | '[' | '`' | 'a' | 'u' | 'spoiler' | '>' | '(';
type FormatSpan = [SpanType, number];
@@ -68,12 +80,16 @@ class TextFormatter {
readonly str: string;
readonly buffers: string[];
readonly stack: FormatSpan[];
+ /** Allows access to special formatting (links without URL preview, pokemon icons) */
readonly isTrusted: boolean;
+ /** Replace \n with */
readonly replaceLinebreaks: boolean;
+ /** Discord-style WYSIWYM output; markup characters are in `` */
+ readonly showSyntax: boolean;
/** offset of str that's been parsed so far */
offset: number;
- constructor(str: string, isTrusted = false, replaceLinebreaks = false) {
+ constructor(str: string, isTrusted = false, replaceLinebreaks = false, showSyntax = false) {
// escapeHTML, without escaping /
str = `${str}`
.replace(/&/g, '&')
@@ -84,6 +100,7 @@ class TextFormatter {
// filter links first
str = str.replace(linkRegex, uri => {
+ if (showSyntax) return `${uri}`;
let fulluri;
if (/^[a-z0-9.]+@/ig.test(uri)) {
fulluri = 'mailto:' + uri;
@@ -110,6 +127,7 @@ class TextFormatter {
this.stack = [];
this.isTrusted = isTrusted;
this.replaceLinebreaks = this.isTrusted || replaceLinebreaks;
+ this.showSyntax = showSyntax;
this.offset = 0;
}
// debugAt(i=0, j=i+1) { console.log(`${this.slice(0, i)}[${this.slice(i, j)}]${this.slice(j, this.str.length)}`); }
@@ -122,6 +140,15 @@ class TextFormatter {
return this.str.charAt(start);
}
+ /**
+ * We've encountered a possible start for a span. It's pushed onto our span
+ * stack.
+ *
+ * The span stack saves the start position so it can be replaced with HTML
+ * if we find an end for the span, but we don't actually replace it until
+ * `closeSpan` is called, so nothing happens (it stays plaintext) if no end
+ * is found.
+ */
pushSpan(spanType: SpanType, start: number, end: number) {
this.pushSlice(start);
this.stack.push([spanType, this.buffers.length]);
@@ -155,7 +182,8 @@ class TextFormatter {
}
/**
- * Attempt to close a span.
+ * We've encountered a possible end for a span. If it's in the span stack,
+ * we transform it into HTML.
*/
closeSpan(spanType: SpanType, start: number, end: number) {
// loop backwards
@@ -181,11 +209,12 @@ class TextFormatter {
case '~': tagName = 's'; break;
case '^': tagName = 'sup'; break;
case '\\': tagName = 'sub'; break;
- case '|': tagName = 'span'; attrs = ' class="spoiler"'; break;
+ case '|': tagName = 'span'; attrs = (this.showSyntax ? ' class="spoiler-shown"' : ' class="spoiler"'); break;
}
+ const syntax = (this.showSyntax ? `${spanType}${spanType}` : '');
if (tagName) {
- this.buffers[startIndex] = `<${tagName}${attrs}>`;
- this.buffers.push(`${tagName}>`);
+ this.buffers[startIndex] = `${syntax}<${tagName}${attrs}>`;
+ this.buffers.push(`${tagName}>${syntax}`);
this.offset = end;
}
return true;
@@ -203,7 +232,7 @@ class TextFormatter {
switch (span[0]) {
case 'spoiler':
this.buffers.push(``);
- this.buffers[span[1]] = ``;
+ this.buffers[span[1]] = (this.showSyntax ? `` : ``);
break;
case '>':
this.buffers.push(``);
@@ -230,9 +259,15 @@ class TextFormatter {
return encodeURIComponent(component);
}
+ /**
+ * Handles special cases.
+ */
runLookahead(spanType: SpanType, start: number) {
switch (spanType) {
case '`':
+ // code span. Not only are the contents not formatted, but
+ // the start and end delimiters must match in length.
+ // ``Neither `this` nor ```this``` end this code span.``
{
let delimLength = 0;
let i = start;
@@ -253,9 +288,9 @@ class TextFormatter {
i++;
}
if (curDelimLength !== delimLength) return false;
+ const end = i;
// matching delims found
this.pushSlice(start);
- this.buffers.push(``);
let innerStart = start + delimLength;
let innerEnd = i - delimLength;
if (innerStart + 1 >= innerEnd) {
@@ -268,12 +303,20 @@ class TextFormatter {
} else if (this.at(innerEnd - 1) === ' ' && this.at(innerEnd - 2) === '`') {
innerEnd--; // strip ending space
}
+ if (this.showSyntax) this.buffers.push(`${this.slice(start, innerStart)}`);
+ this.buffers.push(``);
this.buffers.push(this.slice(innerStart, innerEnd));
this.buffers.push(``);
- this.offset = i;
+ if (this.showSyntax) this.buffers.push(`${this.slice(innerEnd, end)}`);
+ this.offset = end;
}
return true;
case '[':
+ // Link span. Several possiblilities:
+ // [[text ]] - a link with custom text
+ // [[search term]] - Google search
+ // [[wiki: search term]] - Wikipedia search
+ // [[pokemon: species name]] - icon (also item:, type:, category:)
{
if (this.slice(start, start + 2) !== '[[') return false;
let i = start + 2;
@@ -287,6 +330,9 @@ class TextFormatter {
i++;
}
if (this.slice(i, i + 2) !== ']]') return false;
+
+ this.pushSlice(start);
+ this.offset = i + 2;
let termEnd = i;
let uri = '';
if (anglePos >= 0 && this.slice(i - 4, i) === '>') { // `>`
@@ -295,17 +341,21 @@ class TextFormatter {
if (this.at(termEnd - 1) === ' ') termEnd--;
uri = encodeURI(uri.replace(/^([a-z]*[^a-z:])/g, 'http://$1'));
}
- let term = this.slice(start + 2, termEnd).replace(/<\/?a(?: [^>]+)?>/g, '');
- if (uri && !this.isTrusted) {
+ let term = this.slice(start + 2, termEnd).replace(/<\/?[au](?: [^>]+)?>/g, '');
+ if (this.showSyntax) {
+ term += `${this.slice(termEnd, i)}`;
+ } else if (uri && !this.isTrusted) {
const shortUri = uri.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '');
term += ` <${shortUri}>`;
uri += '" rel="noopener';
}
+
if (colonPos > 0) {
const key = this.slice(start + 2, colonPos).toLowerCase();
switch (key) {
case 'w':
case 'wiki':
+ if (this.showSyntax) break;
term = term.slice(term.charAt(key.length + 1) === ' ' ? key.length + 2 : key.length + 1);
uri = `//en.wikipedia.org/w/index.php?title=Special:Search&search=${this.toUriComponent(term)}`;
term = `wiki: ${term}`;
@@ -314,6 +364,10 @@ class TextFormatter {
case 'item':
case 'type':
case 'category':
+ if (this.showSyntax) {
+ this.buffers.push(`${this.slice(start, this.offset)}`);
+ return true;
+ }
term = term.slice(term.charAt(key.length + 1) === ' ' ? key.length + 2 : key.length + 1);
let display = '';
@@ -334,28 +388,42 @@ class TextFormatter {
if (!uri) {
uri = `//www.google.com/search?ie=UTF-8&btnI&q=${this.toUriComponent(term)}`;
}
- this.pushSlice(start);
- this.buffers.push(`${term}`);
- this.offset = i + 2;
+ if (this.showSyntax) {
+ this.buffers.push(`[[${term}]]`);
+ } else {
+ this.buffers.push(`${term}`);
+ }
}
return true;
case '<':
+ // Roomid-link span. Not to be confused with a URL span.
+ // `<>`
{
if (this.slice(start, start + 8) !== '<<') return false; // <<
let i = start + 8;
while (/[a-z0-9-]/.test(this.at(i))) i++;
if (this.slice(i, i + 8) !== '>>') return false; // >>
+
this.pushSlice(start);
const roomid = this.slice(start + 8, i);
- this.buffers.push(`«${roomid}»`);
+ if (this.showSyntax) {
+ this.buffers.push(`<<${roomid}>>`);
+ } else {
+ this.buffers.push(`«${roomid}»`);
+ }
this.offset = i + 8;
}
return true;
- case 'a':
+ case 'a': case 'u':
+ // URL span. Skip to the end of the link - where `` or `` is.
+ // Nothing inside should be formatted further (obviously we don't want
+ // `example.com/__foo__` to turn `foo` italic).
{
- let i = start + 1;
- while (this.at(i) !== '/' || this.at(i + 1) !== 'a' || this.at(i + 2) !== '>') i++; //
- i += 3;
+ let i = start + 2;
+ // Find or .
+ // We need to check the location of `>` to disambiguate from .
+ while (this.at(i) !== '<' || this.at(i + 1) !== '/' || this.at(i + 3) !== '>') i++;
+ i += 4;
this.pushSlice(i);
}
return true;
@@ -365,7 +433,9 @@ class TextFormatter {
get() {
let beginningOfLine = this.offset;
- // main loop! i tracks our position
+ // main loop! `i` tracks our position
+ // Note that we skip around a lot; `i` is mutated inside the loop
+ // pretty often.
for (let i = beginningOfLine; i < this.str.length; i++) {
const char = this.at(i);
switch (char) {
@@ -375,7 +445,11 @@ class TextFormatter {
case '^':
case '\\':
case '|':
+ // Must be exactly two chars long.
if (this.at(i + 1) === char && this.at(i + 2) !== char) {
+ // This is a completely normal two-char span. Close it if it's
+ // already open, open it if it's not.
+ // The inside of regular spans must not start or end with a space.
if (!(this.at(i - 1) !== ' ' && this.closeSpan(char, i, i + 2))) {
if (this.at(i + 2) !== ' ') this.pushSpan(char, i, i + 2);
}
@@ -387,9 +461,11 @@ class TextFormatter {
while (this.at(i + 1) === char) i++;
break;
case '(':
+ // `(` span - does nothing except end spans
this.stack.push(['(', -1]);
break;
case ')':
+ // end of `(` span
this.closeParenSpan(i);
if (i < this.offset) {
i = this.offset - 1;
@@ -397,6 +473,9 @@ class TextFormatter {
}
break;
case '`':
+ // ` ``code`` ` span. Uses lookahead because its contents are not
+ // formatted.
+ // Must be at least two `` ` `` in a row.
if (this.at(i + 1) === '`') this.runLookahead('`', i);
if (i < this.offset) {
i = this.offset - 1;
@@ -405,6 +484,9 @@ class TextFormatter {
while (this.at(i + 1) === '`') i++;
break;
case '[':
+ // `[` (link) span. Uses lookahead because it might contain a
+ // URL which can't be formatted, or search terms that can't be
+ // formatted.
this.runLookahead('[', i);
if (i < this.offset) {
i = this.offset - 1;
@@ -413,6 +495,9 @@ class TextFormatter {
while (this.at(i + 1) === '[') i++;
break;
case ':':
+ // Looks behind for `spoiler:` or `spoilers:`. Spoiler spans
+ // are also weird because they don't require an ending symbol,
+ // although that's not handled here.
if (i < 7) break;
if (this.slice(i - 7, i + 1).toLowerCase() === 'spoiler:' ||
this.slice(i - 8, i + 1).toLowerCase() === 'spoilers:') {
@@ -421,11 +506,16 @@ class TextFormatter {
}
break;
case '&': // escaped '<' or '>'
+ // greentext or roomid
if (i === beginningOfLine && this.slice(i, i + 4) === '>') {
+ // greentext span, normal except it lacks an ending span
+ // check for certain emoticons like `>_>` or `>w<`
if (!"._/=:;".includes(this.at(i + 4)) && !['w<', 'w>'].includes(this.slice(i + 4, i + 9))) {
this.pushSpan('>', i, i);
}
} else {
+ // completely normal `<>` span
+ // uses lookahead because roomids can't be formatted.
this.runLookahead('<', i);
}
if (i < this.offset) {
@@ -434,7 +524,10 @@ class TextFormatter {
}
while (this.slice(i + 1, i + 5) === 'lt;&') i += 4;
break;
- case '<': // guaranteed to be or
+ // URL span
+ // The constructor has already converted `<` to `<` and URLs
+ // to links, so `<` must be the start of a converted link.
this.runLookahead('a', i);
if (i < this.offset) {
i = this.offset - 1;
@@ -444,6 +537,7 @@ class TextFormatter {
break;
case '\r':
case '\n':
+ // End of the line. No spans span multiple lines.
this.popAllSpans(i);
if (this.replaceLinebreaks) {
this.buffers.push(` `);
@@ -462,8 +556,8 @@ class TextFormatter {
/**
* Takes a string and converts it to HTML by replacing standard chat formatting with the appropriate HTML tags.
*/
-export function formatText(str: string, isTrusted = false, replaceLinebreaks = false) {
- return new TextFormatter(str, isTrusted, replaceLinebreaks).get();
+export function formatText(str: string, isTrusted = false, replaceLinebreaks = false, showSyntax = false) {
+ return new TextFormatter(str, isTrusted, replaceLinebreaks, showSyntax).get();
}
/**
diff --git a/server/chat-plugins/abuse-monitor.ts b/server/chat-plugins/abuse-monitor.ts
index a67541832def..acd0e5da46d7 100644
--- a/server/chat-plugins/abuse-monitor.ts
+++ b/server/chat-plugins/abuse-monitor.ts
@@ -177,7 +177,7 @@ function displayResolved(review: ReviewRequest, justSubmitted = false) {
if (!user) return;
const resolved = review.resolved;
if (!resolved) return;
- const prefix = `|pm|&|${user.getIdentity()}|`;
+ const prefix = `|pm|~|${user.getIdentity()}|`;
user.send(
prefix +
`Your Artemis review for <<${review.room}>> was resolved by ${resolved.by}` +
@@ -329,7 +329,7 @@ export async function runActions(user: User, room: GameRoom, message: string, re
if (user.trusted) {
// force just logging for any sort of punishment. requested by staff
Rooms.get('staff')?.add(
- `|c|&|/log [Artemis] ${getViewLink(room.roomid)} ${punishment} recommended for trusted user ${user.id}` +
+ `|c|~|/log [Artemis] ${getViewLink(room.roomid)} ${punishment} recommended for trusted user ${user.id}` +
`${user.trusted !== user.id ? ` [${user.trusted}]` : ''} `
).update();
return; // we want nothing else to be executed. staff want trusted users to be reviewed manually for now
@@ -389,8 +389,8 @@ function globalModlog(
const getViewLink = (roomid: RoomID) => `<>`;
function addGlobalModAction(message: string, room: GameRoom) {
- room.add(`|c|&|/log ${message}`).update();
- Rooms.get(`staff`)?.add(`|c|&|/log ${getViewLink(room.roomid)} ${message}`).update();
+ room.add(`|c|~|/log ${message}`).update();
+ Rooms.get(`staff`)?.add(`|c|~|/log ${getViewLink(room.roomid)} ${message}`).update();
}
const DISCLAIMER = (
@@ -402,7 +402,7 @@ const DISCLAIMER = (
export async function lock(user: User, room: GameRoom, reason: string, isWeek?: boolean) {
if (settings.recommendOnly) {
Rooms.get('staff')?.add(
- `|c|&|/log [Artemis] ${getViewLink(room.roomid)} ${isWeek ? "WEEK" : ""}LOCK recommended for ${user.id}`
+ `|c|~|/log [Artemis] ${getViewLink(room.roomid)} ${isWeek ? "WEEK" : ""}LOCK recommended for ${user.id}`
).update();
room.hideText([user.id], undefined, true);
return false;
@@ -420,11 +420,11 @@ export async function lock(user: User, room: GameRoom, reason: string, isWeek?:
addGlobalModAction(`${user.name} was locked from talking by Artemis${isWeek ? ' for a week. ' : ". "}(${reason})`, room);
if (affected.length > 1) {
Rooms.get('staff')?.add(
- `|c|&|/log (${user.id}'s ` +
+ `|c|~|/log (${user.id}'s ` +
`locked alts: ${affected.slice(1).map(curUser => curUser.getLastName()).join(", ")})`
);
}
- room.add(`|c|&|/raw ${DISCLAIMER}`).update();
+ room.add(`|c|~|/raw ${DISCLAIMER}`).update();
room.hideText(affected.map(f => f.id), undefined, true);
let message = `|popup||html|Artemis has locked you from talking in chats, battles, and PMing regular users`;
message += ` ${!isWeek ? "for two days" : "for a week"}`;
@@ -462,7 +462,7 @@ const punishmentHandlers: Record = {
if (room.auth.get(u) !== Users.PLAYER_SYMBOL) continue;
u.sendTo(
room.roomid,
- `|c|&|/uhtml report,` +
+ `|c|~|/uhtml report,` +
`Toxicity has been automatically detected in this battle, ` +
`please click below if you would like to report it. ` +
`Make a report`
@@ -490,7 +490,7 @@ const punishmentHandlers: Record = {
punishments['WARN']++;
punishmentCache.set(user, punishments);
- room.add(`|c|&|/raw ${DISCLAIMER}`).update();
+ room.add(`|c|~|/raw ${DISCLAIMER}`).update();
room.hideText([user.id], undefined, true);
},
lock(user, room, response, message) {
@@ -553,7 +553,7 @@ export const chatfilter: Chat.ChatFilter = function (message, user, room) {
}
} else {
this.sendReply(
- `|c|&|/raw
` +
+ `|c|~|/raw
` +
`Your behavior in this battle has been automatically identified as breaking ` +
`Pokemon Showdown's global rules. ` +
`Repeated instances of misbehavior may incur harsher punishment.
`
@@ -670,11 +670,11 @@ function getFlaggedRooms() {
}
export function writeStats(type: string, entry: AnyObject) {
- const path = `logs/artemis/${type}/${Chat.toTimestamp(new Date()).split(' ')[0].slice(0, -3)}.jsonl`;
+ const path = `artemis/${type}/${Chat.toTimestamp(new Date()).split(' ')[0].slice(0, -3)}.jsonl`;
try {
- FS(path).parentDir().mkdirpSync();
+ Monitor.logPath(path).parentDir().mkdirpSync();
} catch {}
- void FS(path).append(JSON.stringify(entry) + "\n");
+ void Monitor.logPath(path).append(JSON.stringify(entry) + "\n");
}
function saveSettings(path?: string) {
@@ -968,7 +968,7 @@ export const commands: Chat.ChatCommands = {
const result = await this.parse(`${cmd} ${rest}`, {bypassRoomCheck: true});
if (result) { // command succeeded - send followup
this.add(
- '|c|&|/raw If you have questions about this action, please contact staff ' +
+ '|c|~|/raw If you have questions about this action, please contact staff ' +
'by making a help ticket'
);
}
@@ -1679,7 +1679,7 @@ export const commands: Chat.ChatCommands = {
this.refreshPage('abusemonitor-settings');
},
edithistory(target, room, user) {
- this.checkCan('globalban');
+ this.checkCan('lock');
target = toID(target);
if (!target) {
return this.parse(`/help abusemonitor`);
@@ -1688,7 +1688,7 @@ export const commands: Chat.ChatCommands = {
},
ignoremodlog: {
add(target, room, user) {
- this.checkCan('globalban');
+ this.checkCan('lock');
let targetUser: string;
[targetUser, target] = this.splitOne(target).map(f => f.trim());
targetUser = toID(targetUser);
@@ -1721,7 +1721,7 @@ export const commands: Chat.ChatCommands = {
this.refreshPage(`abusemonitor-edithistory-${targetUser}`);
},
remove(target, room, user) {
- this.checkCan('globalban');
+ this.checkCan('lock');
let [targetUser, rawNum] = this.splitOne(target).map(f => f.trim());
targetUser = toID(targetUser);
const num = Number(rawNum);
@@ -1759,27 +1759,27 @@ export const commands: Chat.ChatCommands = {
abusemonitorhelp() {
return this.sendReplyBox([
`Staff commands:`,
- `/am userlogs [user] - View the Artemis flagged message logs for the given [user]. Requires: % @ &`,
- `/am unmute [user] - Remove the Artemis mute from the given [user]. Requires: % @ &`,
- `/am review - Submit feedback for manual abuse monitor review. Requires: % @ &`,
+ `/am userlogs [user] - View the Artemis flagged message logs for the given [user]. Requires: % @ ~`,
+ `/am unmute [user] - Remove the Artemis mute from the given [user]. Requires: % @ ~`,
+ `/am review - Submit feedback for manual abuse monitor review. Requires: % @ ~`,
` Management commands:`,
- `/am toggle - Toggle the abuse monitor on and off. Requires: whitelist &`,
- `/am threshold [number] - Set the abuse monitor trigger threshold. Requires: whitelist &`,
- `/am resolve [room] - Mark a abuse monitor flagged room as handled by staff. Requires: % @ &`,
- `/am respawn - Respawns abuse monitor processes. Requires: whitelist &`,
+ `/am toggle - Toggle the abuse monitor on and off. Requires: whitelist ~`,
+ `/am threshold [number] - Set the abuse monitor trigger threshold. Requires: whitelist ~`,
+ `/am resolve [room] - Mark a abuse monitor flagged room as handled by staff. Requires: % @ ~`,
+ `/am respawn - Respawns abuse monitor processes. Requires: whitelist ~`,
`/am logs [count][, userid] - View logs of recent matches by the abuse monitor. `,
- `If a userid is given, searches only logs from that userid. Requires: whitelist &`,
- `/am edithistory [user] - Clear specific abuse monitor hit(s) for a user. Requires: @ &`,
- `/am userclear [user] - Clear all logged abuse monitor hits for a user. Requires: whitelist &`,
- `/am deletelog [number] - Deletes a abuse monitor log matching the row ID [number] given. Requires: whitelist &`,
- `/am editspecial [type], [percent], [score] - Sets a special case for the abuse monitor. Requires: whitelist &`,
+ `If a userid is given, searches only logs from that userid. Requires: whitelist ~`,
+ `/am edithistory [user] - Clear specific abuse monitor hit(s) for a user. Requires: % @ ~`,
+ `/am userclear [user] - Clear all logged abuse monitor hits for a user. Requires: whitelist ~`,
+ `/am deletelog [number] - Deletes a abuse monitor log matching the row ID [number] given. Requires: whitelist ~`,
+ `/am editspecial [type], [percent], [score] - Sets a special case for the abuse monitor. Requires: whitelist ~`,
`[score] can be either a number or MAXIMUM, which will set it to the maximum score possible (that will trigger an action)`,
- `/am deletespecial [type], [percent] - Deletes a special case for the abuse monitor. Requires: whitelist &`,
- `/am editmin [number] - Sets the minimum percent needed to process for all flags. Requires: whitelist &`,
- `/am viewsettings - View the current settings for the abuse monitor. Requires: whitelist &`,
+ `/am deletespecial [type], [percent] - Deletes a special case for the abuse monitor. Requires: whitelist ~`,
+ `/am editmin [number] - Sets the minimum percent needed to process for all flags. Requires: whitelist ~`,
+ `/am viewsettings - View the current settings for the abuse monitor. Requires: whitelist ~`,
`/am thresholdincrement [num], [amount][, min turns] - Sets the threshold increment for the abuse monitor to increase [amount] every [num] turns.`,
- `If [min turns] is provided, increments will start after that turn number. Requires: whitelist &`,
- `/am deleteincrement - clear abuse-monitor threshold increment. Requires: whitelist &`,
+ `If [min turns] is provided, increments will start after that turn number. Requires: whitelist ~`,
+ `/am deleteincrement - clear abuse-monitor threshold increment. Requires: whitelist ~`,
``,
].join(' '));
},
@@ -2068,7 +2068,7 @@ export const pages: Chat.PageTable = {
data += `
${cur.successes} (${percent(cur.successes, cur.total)}%)`;
if (cur.failures) {
data += ` | ${cur.failures} (${percent(cur.failures, cur.total)}%)`;
- } else { // so one cannot confuse dead tickets & false hit tickets
+ } else { // so one cannot confuse dead tickets ~ false hit tickets
data += ' | 0 (0%)';
}
if (cur.dead) data += ` | ${cur.dead}`;
@@ -2092,7 +2092,7 @@ export const pages: Chat.PageTable = {
types: {} as Record,
};
const inaccurate = new Set();
- const logPath = FS(`logs/artemis/punishments/${dateString}.jsonl`);
+ const logPath = Monitor.logPath(`artemis/punishments/${dateString}.jsonl`);
if (await logPath.exists()) {
const stream = logPath.createReadStream();
for await (const line of stream.byLine()) {
@@ -2107,7 +2107,7 @@ export const pages: Chat.PageTable = {
}
}
- const reviewLogPath = FS(`logs/artemis/reviews/${dateString}.jsonl`);
+ const reviewLogPath = Monitor.logPath(`artemis/reviews/${dateString}.jsonl`);
if (await reviewLogPath.exists()) {
const stream = reviewLogPath.createReadStream();
for await (const line of stream.byLine()) {
@@ -2145,7 +2145,7 @@ export const pages: Chat.PageTable = {
data += `
${curAccurate} (${percent(curAccurate, cur.total)}%)`;
if (cur.inaccurate) {
data += ` | ${cur.inaccurate} (${percent(cur.inaccurate, cur.total)}%)`;
- } else { // so one cannot confuse dead tickets & false hit tickets
+ } else { // so one cannot confuse dead tickets ~ false hit tickets
data += ' | 0 (0%)';
}
data += '
';
@@ -2312,7 +2312,7 @@ export const pages: Chat.PageTable = {
return buf;
},
async edithistory(query, user) {
- this.checkCan('globalban');
+ this.checkCan('lock');
const targetUser = toID(query[0]);
if (!targetUser) {
return this.errorReply(`Specify a user.`);
diff --git a/server/chat-plugins/announcements.ts b/server/chat-plugins/announcements.ts
index 3716783d6477..a44ee1e3a91e 100644
--- a/server/chat-plugins/announcements.ts
+++ b/server/chat-plugins/announcements.ts
@@ -98,7 +98,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('ANNOUNCEMENT');
return this.privateModAction(room.tr`An announcement was started by ${user.name}.`);
},
- newhelp: [`/announcement create [announcement] - Creates an announcement. Requires: % @ # &`],
+ newhelp: [`/announcement create [announcement] - Creates an announcement. Requires: % @ # ~`],
htmledit: 'edit',
edit(target, room, user, connection, cmd, message) {
@@ -125,7 +125,7 @@ export const commands: Chat.ChatCommands = {
this.privateModAction(room.tr`The announcement was edited by ${user.name}.`);
this.parse('/announcement display');
},
- edithelp: [`/announcement edit [announcement] - Edits the announcement. Requires: % @ # &`],
+ edithelp: [`/announcement edit [announcement] - Edits the announcement. Requires: % @ # ~`],
timer(target, room, user) {
room = this.requireRoom();
@@ -155,8 +155,8 @@ export const commands: Chat.ChatCommands = {
}
},
timerhelp: [
- `/announcement timer [minutes] - Sets the announcement to automatically end after [minutes] minutes. Requires: % @ # &`,
- `/announcement timer clear - Clears the announcement's timer. Requires: % @ # &`,
+ `/announcement timer [minutes] - Sets the announcement to automatically end after [minutes] minutes. Requires: % @ # ~`,
+ `/announcement timer clear - Clears the announcement's timer. Requires: % @ # ~`,
],
close: 'end',
@@ -170,7 +170,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('ANNOUNCEMENT END');
this.privateModAction(room.tr`The announcement was ended by ${user.name}.`);
},
- endhelp: [`/announcement end - Ends a announcement and displays the results. Requires: % @ # &`],
+ endhelp: [`/announcement end - Ends a announcement and displays the results. Requires: % @ # ~`],
show: '',
display: '',
@@ -191,18 +191,18 @@ export const commands: Chat.ChatCommands = {
announcementhelp: [
`/announcement allows rooms to run their own announcements. These announcements are limited to one announcement at a time per room.`,
`Accepts the following commands:`,
- `/announcement create [announcement] - Creates a announcement. Requires: % @ # &`,
- `/announcement htmlcreate [announcement] - Creates a announcement, with HTML allowed. Requires: # &`,
- `/announcement edit [announcement] - Edits the announcement. Requires: % @ # &`,
- `/announcement htmledit [announcement] - Edits the announcement, with HTML allowed. Requires: # &`,
- `/announcement timer [minutes] - Sets the announcement to automatically end after [minutes]. Requires: % @ # &`,
+ `/announcement create [announcement] - Creates a announcement. Requires: % @ # ~`,
+ `/announcement htmlcreate [announcement] - Creates a announcement, with HTML allowed. Requires: # ~`,
+ `/announcement edit [announcement] - Edits the announcement. Requires: % @ # ~`,
+ `/announcement htmledit [announcement] - Edits the announcement, with HTML allowed. Requires: # ~`,
+ `/announcement timer [minutes] - Sets the announcement to automatically end after [minutes]. Requires: % @ # ~`,
`/announcement display - Displays the announcement`,
- `/announcement end - Ends a announcement. Requires: % @ # &`,
+ `/announcement end - Ends a announcement. Requires: % @ # ~`,
],
};
process.nextTick(() => {
- Chat.multiLinePattern.register('/announcement (new|create|htmlcreate) ');
+ Chat.multiLinePattern.register('/announcement (new|create|htmlcreate|edit|htmledit) ');
});
// should handle restarts and also hotpatches
diff --git a/server/chat-plugins/auction.ts b/server/chat-plugins/auction.ts
new file mode 100644
index 000000000000..7fca3803eac4
--- /dev/null
+++ b/server/chat-plugins/auction.ts
@@ -0,0 +1,1124 @@
+/**
+ * Chat plugin to run auctions for team tournaments.
+ *
+ * Based on the original Scrappie auction system
+ * https://github.com/Hidden50/Pokemon-Showdown-Node-Bot/blob/master/commands/base-auctions.js
+ * @author Karthik
+ */
+import {Net, Utils} from '../../lib';
+
+interface Player {
+ id: ID;
+ name: string;
+ team?: Team;
+ price: number;
+ tiers?: string[];
+}
+
+interface Manager {
+ id: ID;
+ team: Team;
+}
+
+class Team {
+ id: ID;
+ name: string;
+ players: Player[];
+ credits: number;
+ suspended: boolean;
+ private auction: Auction;
+ constructor(name: string, auction: Auction) {
+ this.id = toID(name);
+ this.name = name;
+ this.players = [];
+ this.credits = auction.startingCredits;
+ this.suspended = false;
+ this.auction = auction;
+ }
+
+ getManagers() {
+ return [...this.auction.managers.values()]
+ .filter(m => m.team === this)
+ .map(m => Users.getExact(m.id)?.name || m.id);
+ }
+
+ addPlayer(player: Player, price = 0) {
+ player.team?.removePlayer(player);
+ this.players.push(player);
+ this.credits -= price;
+ player.team = this;
+ player.price = price;
+ }
+
+ removePlayer(player: Player) {
+ const pIndex = this.players.indexOf(player);
+ if (pIndex === -1) return;
+ this.players.splice(pIndex, 1);
+ delete player.team;
+ player.price = 0;
+ }
+
+ isSuspended() {
+ return this.suspended || (
+ this.auction.type === 'snake' ?
+ this.players.length >= this.auction.minPlayers :
+ this.credits < this.auction.minBid
+ );
+ }
+
+ maxBid(credits = this.credits) {
+ return credits + this.auction.minBid * Math.min(0, this.players.length - this.auction.minPlayers + 1);
+ }
+}
+
+function parseCredits(amount: string) {
+ let credits = Number(amount.replace(',', '.'));
+ if (Math.abs(credits) < 500) credits *= 1000;
+ if (!credits || credits % 500 !== 0) {
+ throw new Chat.ErrorMessage(`The amount of credits must be a multiple of 500.`);
+ }
+ return credits;
+}
+
+export class Auction extends Rooms.SimpleRoomGame {
+ override readonly gameid = 'auction' as ID;
+ owners: Set = new Set();
+ teams: Map = new Map();
+ managers: Map = new Map();
+ auctionPlayers: Map = new Map();
+
+ startingCredits: number;
+ minBid = 3000;
+ minPlayers = 10;
+ type: 'auction' | 'blind' | 'snake' = 'auction';
+
+ lastQueue: Team[] | null = null;
+ queue: Team[] = [];
+ nomTimer: NodeJS.Timer = null!;
+ nomTimeLimit = 0;
+ nomTimeRemaining = 0;
+ bidTimer: NodeJS.Timer = null!;
+ bidTimeLimit = 10;
+ bidTimeRemaining = 10;
+ nominatingTeam: Team = null!;
+ nominatedPlayer: Player = null!;
+ highestBidder: Team = null!;
+ highestBid = 0;
+ /** Used for blind mode */
+ bidsPlaced: Map = new Map();
+ state: 'setup' | 'nom' | 'bid' = 'setup';
+ constructor(room: Room, startingCredits = 100000) {
+ super(room);
+ this.title = 'Auction';
+ this.startingCredits = startingCredits;
+ }
+
+ sendMessage(message: string) {
+ this.room.add(`|c|~|${message}`).update();
+ }
+
+ sendHTMLBox(htmlContent: string) {
+ this.room.add(`|html|
${htmlContent}
`).update();
+ }
+
+ checkOwner(user: User) {
+ if (!this.owners.has(user.id) && !Users.Auth.hasPermission(user, 'declare', null, this.room)) {
+ throw new Chat.ErrorMessage(`You must be an auction owner to use this command.`);
+ }
+ }
+
+ addOwners(users: string[]) {
+ for (const name of users) {
+ const user = Users.getExact(name);
+ if (!user) throw new Chat.ErrorMessage(`User "${name}" not found.`);
+ if (this.owners.has(user.id)) throw new Chat.ErrorMessage(`${user.name} is already an auction owner.`);
+ this.owners.add(user.id);
+ }
+ }
+
+ removeOwners(users: string[]) {
+ for (const name of users) {
+ const id = toID(name);
+ if (!this.owners.has(id)) throw new Chat.ErrorMessage(`User "${name}" is not an auction owner.`);
+ this.owners.delete(id);
+ }
+ }
+
+ generateUsernameList(players: (string | Player)[], max = players.length, clickable = false) {
+ let buf = ``;
+ buf += players.slice(0, max).map(p => {
+ if (typeof p === 'object') {
+ return `${Utils.escapeHTML(p.name)}`;
+ }
+ return `${Utils.escapeHTML(p)}`;
+ }).join(', ');
+ if (players.length > max) {
+ buf += ` (+${players.length - max})`;
+ }
+ buf += ``;
+ return buf;
+ }
+
+ generatePriceList() {
+ const players = Utils.sortBy(this.getDraftedPlayers(), p => -p.price);
+ let buf = '';
+ let smogonExport = '';
+
+ for (const team of this.teams.values()) {
+ let table = `
`;
+ for (const player of players.filter(p => p.team === team)) {
+ table += Utils.html`
`;
+ this.room.add(`|uhtml${change ? 'change' : ''}|timer|${buf}`).update();
+ }
+
+ setMinBid(amount: number) {
+ if (this.state !== 'setup') {
+ throw new Chat.ErrorMessage(`The minimum bid cannot be changed after the auction has started.`);
+ }
+ if (amount > 500000) throw new Chat.ErrorMessage(`The minimum bid must not exceed 500,000.`);
+ this.minBid = amount;
+ }
+
+ setMinPlayers(amount: number) {
+ if (this.state !== 'setup') {
+ throw new Chat.ErrorMessage(`The minimum number of players cannot be changed after the auction has started.`);
+ }
+ if (!amount || amount > 30) {
+ throw new Chat.ErrorMessage(`The minimum number of players must be between 1 and 30.`);
+ }
+ this.minPlayers = amount;
+ }
+
+ setNomTimeLimit(seconds: number) {
+ if (this.state !== 'setup') {
+ throw new Chat.ErrorMessage(`The nomination time limit cannot be changed after the auction has started.`);
+ }
+ if (isNaN(seconds) || (seconds && (seconds < 7 || seconds > 300))) {
+ throw new Chat.ErrorMessage(`The nomination time limit must be between 7 and 300 seconds.`);
+ }
+ this.nomTimeLimit = this.nomTimeRemaining = seconds;
+ }
+
+ setBidTimeLimit(seconds: number) {
+ if (this.state !== 'setup') {
+ throw new Chat.ErrorMessage(`The bid time limit cannot be changed after the auction has started.`);
+ }
+ if (!seconds || seconds < 7 || seconds > 120) {
+ throw new Chat.ErrorMessage(`The bid time limit must be between 7 and 120 seconds.`);
+ }
+ this.bidTimeLimit = this.bidTimeRemaining = seconds;
+ }
+
+ setType(auctionType: string) {
+ if (this.state !== 'setup') {
+ throw new Chat.ErrorMessage(`The auction type cannot be changed after the auction has started.`);
+ }
+ if (!['auction', 'blind', 'snake'].includes(toID(auctionType))) {
+ throw new Chat.ErrorMessage(`Invalid auction type "${auctionType}". Valid types are "auction", "blind", and "snake".`);
+ }
+ this.type = toID(auctionType) as 'auction' | 'blind' | 'snake';
+ this.nomTimeLimit = this.nomTimeRemaining = this.type === 'snake' ? 60 : 0;
+ this.bidTimeLimit = this.bidTimeRemaining = this.type === 'blind' ? 30 : 10;
+ }
+
+ getUndraftedPlayers() {
+ return [...this.auctionPlayers.values()].filter(p => !p.team);
+ }
+
+ getDraftedPlayers() {
+ return [...this.auctionPlayers.values()].filter(p => p.team);
+ }
+
+ importPlayers(data: string) {
+ if (this.state !== 'setup') {
+ throw new Chat.ErrorMessage(`Player lists cannot be imported after the auction has started.`);
+ }
+ const rows = data.replace('\r', '').split('\n');
+ const tierNames = rows.shift()!.split('\t').slice(1);
+ const playerList = new Map();
+ for (const row of rows) {
+ const tiers = [];
+ const [name, ...tierData] = row.split('\t');
+ for (let i = 0; i < tierData.length; i++) {
+ if (['y', 'Y', '\u2713', '\u2714'].includes(tierData[i].trim())) {
+ if (!tierNames[i]) throw new Chat.ErrorMessage(`Invalid tier data found in the pastebin.`);
+ if (tierNames[i].length > 30) throw new Chat.ErrorMessage(`Tier names must be 30 characters or less.`);
+ tiers.push(tierNames[i]);
+ }
+ }
+ if (name.length > 25) throw new Chat.ErrorMessage(`Player names must be 25 characters or less.`);
+ const player: Player = {
+ id: toID(name),
+ name,
+ price: 0,
+ };
+ if (tiers.length) player.tiers = tiers;
+ playerList.set(player.id, player);
+ }
+ this.auctionPlayers = playerList;
+ }
+
+ addAuctionPlayer(name: string, tiers?: string[]) {
+ if (this.state === 'bid') throw new Chat.ErrorMessage(`Players cannot be added during a nomination.`);
+ if (name.length > 25) throw new Chat.ErrorMessage(`Player names must be 25 characters or less.`);
+ const player: Player = {
+ id: toID(name),
+ name,
+ price: 0,
+ };
+ if (tiers?.length) {
+ if (tiers.some(tier => tier.length > 30)) {
+ throw new Chat.ErrorMessage(`Tier names must be 30 characters or less.`);
+ }
+ player.tiers = tiers;
+ }
+ this.auctionPlayers.set(player.id, player);
+ return player;
+ }
+
+ removeAuctionPlayer(name: string) {
+ if (this.state === 'bid') throw new Chat.ErrorMessage(`Players cannot be removed during a nomination.`);
+ const player = this.auctionPlayers.get(toID(name));
+ if (!player) throw new Chat.ErrorMessage(`Player "${name}" not found.`);
+ player.team?.removePlayer(player);
+ this.auctionPlayers.delete(player.id);
+ if (this.state !== 'setup' && !this.getUndraftedPlayers().length) {
+ this.end('The auction has ended because there are no players remaining in the draft pool.');
+ }
+ return player;
+ }
+
+ assignPlayer(name: string, teamName?: string) {
+ if (this.state === 'bid') throw new Chat.ErrorMessage(`Players cannot be assigned during a nomination.`);
+ const player = this.auctionPlayers.get(toID(name));
+ if (!player) throw new Chat.ErrorMessage(`Player "${name}" not found.`);
+ if (teamName) {
+ const team = this.teams.get(toID(teamName));
+ if (!team) throw new Chat.ErrorMessage(`Team "${teamName}" not found.`);
+ team.addPlayer(player);
+ if (!this.getUndraftedPlayers().length) {
+ return this.end('The auction has ended because there are no players remaining in the draft pool.');
+ }
+ } else {
+ player.team?.removePlayer(player);
+ }
+ }
+
+ addTeam(name: string) {
+ if (this.state !== 'setup') throw new Chat.ErrorMessage(`Teams cannot be added after the auction has started.`);
+ if (name.length > 40) throw new Chat.ErrorMessage(`Team names must be 40 characters or less.`);
+ const team = new Team(name, this);
+ this.teams.set(team.id, team);
+ const teams = [...this.teams.values()];
+ this.queue = teams.concat(teams.slice().reverse());
+ return team;
+ }
+
+ removeTeam(name: string) {
+ if (this.state !== 'setup') throw new Chat.ErrorMessage(`Teams cannot be removed after the auction has started.`);
+ const team = this.teams.get(toID(name));
+ if (!team) throw new Chat.ErrorMessage(`Team "${name}" not found.`);
+ this.queue = this.queue.filter(t => t !== team);
+ this.teams.delete(team.id);
+ return team;
+ }
+
+ suspendTeam(name: string) {
+ if (this.state === 'bid') throw new Chat.ErrorMessage(`Teams cannot be suspended during a nomination.`);
+ const team = this.teams.get(toID(name));
+ if (!team) throw new Chat.ErrorMessage(`Team "${name}" not found.`);
+ if (team.suspended) throw new Chat.ErrorMessage(`Team ${name} is already suspended.`);
+ if (this.nominatingTeam === team) throw new Chat.ErrorMessage(`The nominating team cannot be suspended.`);
+ team.suspended = true;
+ return team;
+ }
+
+ unsuspendTeam(name: string) {
+ if (this.state === 'bid') throw new Chat.ErrorMessage(`Teams cannot be unsuspended during a nomination.`);
+ const team = this.teams.get(toID(name));
+ if (!team) throw new Chat.ErrorMessage(`Team "${name}" not found.`);
+ if (!team.suspended) throw new Chat.ErrorMessage(`Team ${name} is not suspended.`);
+ team.suspended = false;
+ return team;
+ }
+
+ addManagers(teamName: string, users: string[]) {
+ const team = this.teams.get(toID(teamName));
+ if (!team) throw new Chat.ErrorMessage(`Team "${teamName}" not found.`);
+ const problemUsers = users.filter(user => !toID(user) || toID(user).length > 18);
+ if (problemUsers.length) {
+ throw new Chat.ErrorMessage(`Invalid usernames: ${problemUsers.join(', ')}`);
+ }
+ for (const id of users.map(toID)) {
+ const manager = this.managers.get(id);
+ if (!manager) {
+ this.managers.set(id, {id, team});
+ } else {
+ manager.team = team;
+ }
+ }
+ return team;
+ }
+
+ removeManagers(users: string[]) {
+ const problemUsers = users.filter(user => !this.managers.has(toID(user)));
+ if (problemUsers.length) {
+ throw new Chat.ErrorMessage(`Invalid managers: ${problemUsers.join(', ')}`);
+ }
+ for (const id of users.map(toID)) {
+ this.managers.delete(id);
+ }
+ }
+
+ addCreditsToTeam(teamName: string, amount: number) {
+ if (this.type === 'snake') throw new Chat.ErrorMessage(`Snake draft does not support credits.`);
+ if (this.state === 'bid') throw new Chat.ErrorMessage(`Credits cannot be changed during a nomination.`);
+ const team = this.teams.get(toID(teamName));
+ if (!team) throw new Chat.ErrorMessage(`Team "${teamName}" not found.`);
+ const newCredits = team.credits + amount;
+ if (newCredits <= 0 || newCredits > 10000000) {
+ throw new Chat.ErrorMessage(`A team must have between 0 and 10,000,000 credits.`);
+ }
+ if (team.maxBid(newCredits) < this.minBid) {
+ throw new Chat.ErrorMessage(`A team must have enough credits to draft the minimum amount of players.`);
+ }
+ team.credits = newCredits;
+ return team;
+ }
+
+ start() {
+ if (this.state !== 'setup') throw new Chat.ErrorMessage(`The auction has already started.`);
+ if (this.teams.size < 2) throw new Chat.ErrorMessage(`The auction needs at least 2 teams to start.`);
+ const problemTeams = [...this.teams.values()].filter(t => t.maxBid() < this.minBid).map(t => t.name);
+ if (problemTeams.length) {
+ throw new Chat.ErrorMessage(`The following teams do not have enough credits to draft the minimum amount of players: ${problemTeams.join(', ')}`);
+ }
+ this.next();
+ }
+
+ reset() {
+ const teams = [...this.teams.values()];
+ for (const team of teams) {
+ team.credits = this.startingCredits;
+ team.suspended = false;
+ for (const player of team.players) {
+ delete player.team;
+ player.price = 0;
+ }
+ team.players = [];
+ }
+ this.lastQueue = null;
+ this.queue = teams.concat(teams.slice().reverse());
+ this.clearNomTimer();
+ this.clearBidTimer();
+ this.state = 'setup';
+ this.sendHTMLBox(this.generateAuctionTable());
+ }
+
+ next() {
+ this.state = 'nom';
+ if (!this.queue.filter(team => !team.isSuspended()).length) {
+ return this.end('The auction has ended because there are no teams remaining that can draft players.');
+ }
+ if (!this.getUndraftedPlayers().length) {
+ return this.end('The auction has ended because there are no players remaining in the draft pool.');
+ }
+ do {
+ this.nominatingTeam = this.queue.shift()!;
+ this.queue.push(this.nominatingTeam);
+ } while (this.nominatingTeam.isSuspended());
+ this.sendHTMLBox(this.generateAuctionTable());
+ this.sendMessage(`/html It is now ${Utils.escapeHTML(this.nominatingTeam.name)}'s turn to nominate a player. Managers: ${this.nominatingTeam.getManagers().map(m => `${Utils.escapeHTML(m)}`).join(' ')}`);
+ this.startNomTimer();
+ }
+
+ nominate(user: User, target: string) {
+ if (this.state !== 'nom') throw new Chat.ErrorMessage(`You cannot nominate players right now.`);
+ const manager = this.managers.get(user.id);
+ if (!manager || manager.team !== this.nominatingTeam) this.checkOwner(user);
+
+ // For undo
+ this.lastQueue = this.queue.slice();
+ this.lastQueue.unshift(this.lastQueue.pop()!);
+
+ const player = this.auctionPlayers.get(toID(target));
+ if (!player) throw new Chat.ErrorMessage(`${target} is not a valid player.`);
+ if (player.team) throw new Chat.ErrorMessage(`${player.name} has already been drafted.`);
+ this.clearNomTimer();
+ this.nominatedPlayer = player;
+ if (this.type === 'snake') {
+ this.sendMessage(Utils.html`/html ${this.nominatingTeam.name} drafted ${this.nominatedPlayer.name}!`);
+ this.nominatingTeam.addPlayer(this.nominatedPlayer);
+ this.next();
+ } else {
+ this.state = 'bid';
+ this.highestBid = this.minBid;
+ this.highestBidder = this.nominatingTeam;
+
+ const notifyMsg = Utils.html`|notify|${this.room.title} Auction|${player.name} has been nominated!`;
+ for (const currManager of this.managers.values()) {
+ if (currManager.team === this.nominatingTeam) continue;
+ Users.getExact(currManager.id)?.sendTo(this.room, notifyMsg);
+ }
+
+ this.sendMessage(Utils.html`/html ${user.name} from team ${this.nominatingTeam.name} has nominated ${player.name} for auction. Use /bid or type a number to place a bid!`);
+ this.sendBidInfo();
+ this.startBidTimer();
+ }
+ }
+
+ bid(user: User, bid: number) {
+ if (this.state !== 'bid') throw new Chat.ErrorMessage(`There are no players up for auction right now.`);
+ const team = this.managers.get(user.id)?.team;
+ if (!team) throw new Chat.ErrorMessage(`Only managers can bid on players.`);
+ if (team.isSuspended()) throw new Chat.ErrorMessage(`Your team is suspended and cannot place bids.`);
+
+ if (bid > team.maxBid()) throw new Chat.ErrorMessage(`Your team cannot afford to bid that much.`);
+
+ if (this.type === 'blind') {
+ if (this.bidsPlaced.has(team)) throw new Chat.ErrorMessage(`Your team has already placed a bid.`);
+ if (bid <= this.minBid) throw new Chat.ErrorMessage(`Your bid must be higher than the minimum bid.`);
+
+ const msg = `|c:|${Math.floor(Date.now() / 1000)}|&|/html Your team placed a bid of ${bid} on ${Utils.escapeHTML(this.nominatedPlayer.name)}.`;
+ for (const manager of this.managers.values()) {
+ if (manager.team !== team) continue;
+ Users.getExact(manager.id)?.sendTo(this.room, msg);
+ }
+
+ if (bid > this.highestBid) {
+ this.highestBid = bid;
+ this.highestBidder = team;
+ }
+ this.bidsPlaced.set(team, bid);
+ if (this.bidsPlaced.size === this.teams.size) {
+ this.finishCurrentNom();
+ }
+ } else {
+ if (bid <= this.highestBid) throw new Chat.ErrorMessage(`Your bid must be higher than the current bid.`);
+ this.highestBid = bid;
+ this.highestBidder = team;
+ this.sendMessage(Utils.html`/html ${user.name}[${team.name}]: ${bid}`);
+ this.sendBidInfo();
+ this.startBidTimer();
+ }
+ }
+
+ onChatMessage(message: string, user: User) {
+ if (this.state === 'bid' && Number(message.replace(',', '.'))) {
+ this.bid(user, parseCredits(message));
+ return '';
+ }
+ }
+
+ skipNom() {
+ if (this.state !== 'nom') throw new Chat.ErrorMessage(`Nominations cannot be skipped right now.`);
+ this.nominatedPlayer = null!;
+ this.sendMessage(`**${this.nominatingTeam.name}**'s nomination turn has been skipped!`);
+ this.clearNomTimer();
+ this.next();
+ }
+
+ finishCurrentNom() {
+ if (this.type === 'blind') {
+ let buf = `
Team
Bid
`;
+ if (!this.bidsPlaced.has(this.nominatingTeam)) {
+ buf += Utils.html`
${this.nominatingTeam.name}
${this.minBid}
`;
+ }
+ for (const [team, bid] of this.bidsPlaced) {
+ buf += Utils.html`
${team.name}
${bid}
`;
+ }
+ buf += `
`;
+ this.sendHTMLBox(buf);
+ this.bidsPlaced.clear();
+ }
+ this.sendMessage(Utils.html`/html ${this.highestBidder.name} bought ${this.nominatedPlayer.name} for ${this.highestBid} credits!`);
+ this.highestBidder.addPlayer(this.nominatedPlayer, this.highestBid);
+ this.clearBidTimer();
+ this.next();
+ }
+
+ undoLastNom() {
+ if (this.state !== 'nom') throw new Chat.ErrorMessage(`Nominations cannot be undone right now.`);
+ if (!this.lastQueue) throw new Chat.ErrorMessage(`Only one nomination can be undone at a time.`);
+ this.queue = this.lastQueue;
+ this.lastQueue = null;
+ if (this.nominatedPlayer) {
+ this.highestBidder.removePlayer(this.nominatedPlayer);
+ this.highestBidder.credits += this.highestBid;
+ }
+ this.next();
+ }
+
+ clearNomTimer() {
+ clearInterval(this.nomTimer);
+ this.nomTimeRemaining = this.nomTimeLimit;
+ this.room.add('|uhtmlchange|timer|');
+ }
+
+ startNomTimer() {
+ if (!this.nomTimeLimit) return;
+ this.clearNomTimer();
+ this.sendTimer(false, true);
+ this.nomTimer = setInterval(() => this.pokeNomTimer(), 1000);
+ }
+
+ clearBidTimer() {
+ clearInterval(this.bidTimer);
+ this.bidTimeRemaining = this.bidTimeLimit;
+ this.room.add('|uhtmlchange|timer|');
+ }
+
+ startBidTimer() {
+ if (!this.bidTimeLimit) return;
+ this.clearBidTimer();
+ this.sendTimer();
+ this.bidTimer = setInterval(() => this.pokeBidTimer(), 1000);
+ }
+
+ pokeNomTimer() {
+ this.nomTimeRemaining--;
+ if (!this.nomTimeRemaining) {
+ this.skipNom();
+ } else {
+ this.sendTimer(true, true);
+ if (this.nomTimeRemaining % 30 === 0 || [20, 10, 5].includes(this.nomTimeRemaining)) {
+ this.sendMessage(`/html ${this.nomTimeRemaining} seconds left!`);
+ }
+ }
+ }
+
+ pokeBidTimer() {
+ this.bidTimeRemaining--;
+ if (!this.bidTimeRemaining) {
+ this.finishCurrentNom();
+ } else {
+ this.sendTimer(true);
+ if (this.bidTimeRemaining % 30 === 0 || [20, 10, 5].includes(this.bidTimeRemaining)) {
+ this.sendMessage(`/html ${this.bidTimeRemaining} seconds left!`);
+ }
+ }
+ }
+
+ end(message?: string) {
+ this.sendHTMLBox(this.generateAuctionTable(true));
+ this.sendHTMLBox(this.generatePriceList());
+ if (message) this.sendMessage(message);
+ this.destroy();
+ }
+
+ destroy() {
+ this.clearNomTimer();
+ this.clearBidTimer();
+ super.destroy();
+ }
+}
+
+export const commands: Chat.ChatCommands = {
+ auction: {
+ create(target, room, user) {
+ room = this.requireRoom();
+ this.checkCan('minigame', null, room);
+ if (room.game) return this.errorReply(`There is already a game of ${room.game.title} in progress in this room.`);
+ if (room.settings.auctionDisabled) return this.errorReply('Auctions are currently disabled in this room.');
+
+ let startingCredits;
+ if (target) {
+ startingCredits = parseCredits(target);
+ if (startingCredits < 10000 || startingCredits > 10000000) {
+ return this.errorReply(`Starting credits must be between 10,000 and 10,000,000.`);
+ }
+ }
+ const auction = new Auction(room, startingCredits);
+ auction.addOwners([user.id]);
+ room.game = auction;
+ this.addModAction(`An auction was created by ${user.name}.`);
+ this.modlog(`AUCTION CREATE`);
+ },
+ createhelp: [
+ `/auction create [startingcredits] - Creates an auction. Requires: % @ # ~`,
+ ],
+ start(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ auction.start();
+ this.addModAction(`The auction was started by ${user.name}.`);
+ this.modlog(`AUCTION START`);
+ },
+ reset(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ auction.reset();
+ this.addModAction(`The auction was reset by ${user.name}.`);
+ this.modlog(`AUCTION RESET`);
+ },
+ delete: 'end',
+ stop: 'end',
+ end(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ auction.end();
+ this.addModAction(`The auction was ended by ${user.name}.`);
+ this.modlog('AUCTION END');
+ },
+ info: 'display',
+ display(target, room, user) {
+ this.runBroadcast();
+ const auction = this.requireGame(Auction);
+ this.sendReplyBox(auction.generateAuctionTable());
+ },
+ pricelist(target, room, user) {
+ this.runBroadcast();
+ const auction = this.requireGame(Auction);
+ this.sendReplyBox(auction.generatePriceList());
+ },
+ minbid(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction minbid');
+ const amount = parseCredits(target);
+ auction.setMinBid(amount);
+ this.addModAction(`${user.name} set the minimum bid to ${amount}.`);
+ this.modlog('AUCTION MINBID', null, `${amount}`);
+ },
+ minbidhelp: [
+ `/auction minbid [amount] - Sets the minimum bid. Requires: # ~ auction owner`,
+ ],
+ minplayers(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction minplayers');
+ const amount = parseInt(target);
+ auction.setMinPlayers(amount);
+ this.addModAction(`${user.name} set the minimum number of players to ${amount}.`);
+ },
+ minplayershelp: [
+ `/auction minplayers [amount] - Sets the minimum number of players. Requires: # ~ auction owner`,
+ ],
+ nomtimer(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction nomtimer');
+ const seconds = this.meansNo(target) ? 0 : parseInt(target);
+ auction.setNomTimeLimit(seconds);
+ this.addModAction(`${user.name} set the nomination timer to ${seconds} seconds.`);
+ },
+ nomtimerhelp: [
+ `/auction nomtimer [seconds/off] - Sets the nomination timer to [seconds] seconds or disables it. Requires: # ~ auction owner`,
+ ],
+ bidtimer(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction settimer');
+ const seconds = parseInt(target);
+ auction.setBidTimeLimit(seconds);
+ this.addModAction(`${user.name} set the bid timer to ${seconds} seconds.`);
+ },
+ bidtimerhelp: [
+ `/auction timer [seconds] - Sets the bid timer to [seconds] seconds. Requires: # ~ auction owner`,
+ ],
+ settype(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction settype');
+ auction.setType(target);
+ this.addModAction(`${user.name} set the auction type to ${toID(target)}.`);
+ },
+ settypehelp: [
+ `/auction settype [auction|blind|snake] - Sets the auction type. Requires: # ~ auction owner`,
+ ],
+ addowner: 'addowners',
+ addowners(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ const owners = target.split(',').map(x => x.trim());
+ if (!owners.length) return this.parse('/help auction addowners');
+ auction.addOwners(owners);
+ this.addModAction(`${user.name} added ${Chat.toListString(owners.map(o => Users.getExact(o)!.name))} as auction owner${Chat.plural(owners.length)}.`);
+ },
+ addownershelp: [
+ `/auction addowners [user1], [user2], ... - Adds users as auction owners. Requires: # ~ auction owner`,
+ ],
+ removeowner: 'removeowners',
+ removeowners(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ const owners = target.split(',').map(x => x.trim());
+ if (!owners.length) return this.parse('/help auction removeowners');
+ auction.removeOwners(owners);
+ this.addModAction(`${user.name} removed ${Chat.toListString(owners.map(o => Users.getExact(o)?.name || o))} as auction owner${Chat.plural(owners.length)}.`);
+ },
+ removeownershelp: [
+ `/auction removeowners [user1], [user2], ... - Removes users as auction owners. Requires: # ~ auction owner`,
+ ],
+ async importplayers(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction importplayers');
+ if (!/^https?:\/\/pastebin\.com\/[a-zA-Z0-9]+$/.test(target)) {
+ return this.errorReply('Invalid pastebin URL.');
+ }
+ let data = '';
+ try {
+ data = await Net(`https://pastebin.com/raw/${target.split('/').pop()}`).get();
+ } catch {}
+ if (!data) return this.errorReply('Error fetching data from pastebin.');
+
+ auction.importPlayers(data);
+ this.addModAction(`${user.name} imported the player list from ${target}.`);
+ },
+ importplayershelp: [
+ `/auction importplayers [pastebin url] - Imports a list of players from a pastebin. Requires: # ~ auction owner`,
+ `The pastebin should be a list of tab-separated values with the first row containing tier names and subsequent rows containing the player names and a Y in the column corresponding to the tier.`,
+ `See https://pastebin.com/jPTbJBva for an example.`,
+ ],
+ addplayer(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ const [name, ...tiers] = target.split(',').map(x => x.trim());
+ if (!name) return this.parse('/help auction addplayer');
+ const player = auction.addAuctionPlayer(name, tiers);
+ this.addModAction(`${user.name} added player ${player.name} to the auction.`);
+ },
+ addplayerhelp: [
+ `/auction addplayer [name], [tier1], [tier2], ... - Adds a player to the auction. Requires: # ~ auction owner`,
+ ],
+ removeplayer(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction removeplayer');
+ const player = auction.removeAuctionPlayer(target);
+ this.addModAction(`${user.name} removed player ${player.name} from the auction.`);
+ },
+ removeplayerhelp: [
+ `/auction removeplayer [name] - Removes a player from the auction. Requires: # ~ auction owner`,
+ ],
+ assignplayer(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ const [player, team] = target.split(',').map(x => x.trim());
+ if (!player) return this.parse('/help auction assignplayer');
+ if (team) {
+ auction.assignPlayer(player, team);
+ this.addModAction(`${user.name} assigned player ${player} to team ${team}.`);
+ } else {
+ auction.assignPlayer(player);
+ this.sendReply(`${user.name} returned player ${player} to the draft pool.`);
+ }
+ },
+ assignplayerhelp: [
+ `/auction assignplayer [player], [team] - Assigns a player to a team. If team is blank, returns player to draft pool. Requires: # ~ auction owner`,
+ ],
+ addteam(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ const [name, ...managerNames] = target.split(',').map(x => x.trim());
+ if (!name) return this.parse('/help auction addteam');
+ const team = auction.addTeam(name);
+ this.addModAction(`${user.name} added team ${team.name} to the auction.`);
+ auction.addManagers(team.name, managerNames);
+ },
+ addteamhelp: [
+ `/auction addteam [name], [manager1], [manager2], ... - Adds a team to the auction. Requires: # ~ auction owner`,
+ ],
+ removeteam(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction removeteam');
+ const team = auction.removeTeam(target);
+ this.addModAction(`${user.name} removed team ${team.name} from the auction.`);
+ },
+ removeteamhelp: [
+ `/auction removeteam [team] - Removes a team from the auction. Requires: # ~ auction owner`,
+ ],
+ suspendteam(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction suspendteam');
+ const team = auction.suspendTeam(target);
+ this.addModAction(`${user.name} suspended team ${team.name}.`);
+ },
+ suspendteamhelp: [
+ `/auction suspendteam [team] - Suspends a team from the auction. Requires: # ~ auction owner`,
+ `Suspended teams have their nomination turns skipped and are not allowed to place bids.`,
+ ],
+ unsuspendteam(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction unsuspendteam');
+ const team = auction.unsuspendTeam(target);
+ this.addModAction(`${user.name} unsuspended team ${team.name}.`);
+ },
+ unsuspendteamhelp: [
+ `/auction unsuspendteam [team] - Unsuspends a team from the auction. Requires: # ~ auction owner`,
+ ],
+ addmanager: 'addmanagers',
+ addmanagers(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ const [teamName, ...managerNames] = target.split(',').map(x => x.trim());
+ if (!teamName || !managerNames.length) return this.parse('/help auction addmanagers');
+ const team = auction.addManagers(teamName, managerNames);
+ const managers = managerNames.map(m => Users.getExact(m)?.name || toID(m));
+ this.addModAction(`${user.name} added ${Chat.toListString(managers)} as manager${Chat.plural(managers.length)} for team ${team.name}.`);
+ },
+ addmanagershelp: [
+ `/auction addmanagers [team], [user1], [user2], ... - Adds users as managers to a team. Requires: # ~ auction owner`,
+ ],
+ removemanager: 'removemanagers',
+ removemanagers(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ if (!target) return this.parse('/help auction removemanagers');
+ const managerNames = target.split(',').map(x => x.trim());
+ auction.removeManagers(managerNames);
+ const managers = managerNames.map(m => Users.getExact(m)?.name || toID(m));
+ this.addModAction(`${user.name} removed ${Chat.toListString(managers)} as manager${Chat.plural(managers.length)}.`);
+ },
+ removemanagershelp: [
+ `/auction removemanagers [user1], [user2], ... - Removes users as managers. Requires: # ~ auction owner`,
+ ],
+ addcredits(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ const [teamName, amount] = target.split(',').map(x => x.trim());
+ if (!teamName || !amount) return this.parse('/help auction addcredits');
+ const credits = parseCredits(amount);
+ const team = auction.addCreditsToTeam(teamName, credits);
+ this.addModAction(`${user.name} ${credits < 0 ? 'removed' : 'added'} ${Math.abs(credits)} credits ${credits < 0 ? 'from' : 'to'} team ${team.name}.`);
+ },
+ addcreditshelp: [
+ `/auction addcredits [team], [amount] - Adds credits to a team. Requires: # ~ auction owner`,
+ ],
+ nom: 'nominate',
+ nominate(target, room, user) {
+ const auction = this.requireGame(Auction);
+ if (!target) return this.parse('/help auction nominate');
+ auction.nominate(user, target);
+ },
+ nominatehelp: [
+ `/auction nominate OR /nom [player] - Nominates a player for auction.`,
+ ],
+ bid(target, room, user) {
+ const auction = this.requireGame(Auction);
+ if (!target) return this.parse('/help auction bid');
+ auction.bid(user, parseCredits(target));
+ },
+ bidhelp: [
+ `/auction bid OR /bid [amount] - Bids on a player for the specified amount. If the amount is less than 500, it will be multiplied by 1000.`,
+ `During the bidding phase, all numbers that are sent in the chat will be treated as bids.`,
+ ],
+ skip: 'skipnom',
+ skipnom(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ auction.skipNom();
+ this.addModAction(`${user.name} skipped the previous nomination.`);
+ },
+ undo(target, room, user) {
+ const auction = this.requireGame(Auction);
+ auction.checkOwner(user);
+
+ auction.undoLastNom();
+ this.addModAction(`${user.name} undid the last nomination.`);
+ },
+ disable(target, room) {
+ room = this.requireRoom();
+ this.checkCan('gamemanagement', null, room);
+ if (room.settings.auctionDisabled) {
+ return this.errorReply('Auctions are already disabled.');
+ }
+ room.settings.auctionDisabled = true;
+ room.saveSettings();
+ this.sendReply('Auctions have been disabled for this room.');
+ },
+ enable(target, room) {
+ room = this.requireRoom();
+ this.checkCan('gamemanagement', null, room);
+ if (!room.settings.auctionDisabled) {
+ return this.errorReply('Auctions are already enabled.');
+ }
+ delete room.settings.auctionDisabled;
+ room.saveSettings();
+ this.sendReply('Auctions have been enabled for this room.');
+ },
+ ongoing: 'running',
+ running() {
+ if (!this.runBroadcast()) return;
+ const runningAuctions = [...Rooms.rooms.values()].filter(r => r.getGame(Auction)).map(r => r.title);
+ this.sendReply(`Running auctions: ${runningAuctions.join(', ') || 'None'}`);
+ },
+ '': 'help',
+ help() {
+ this.parse('/help auction');
+ },
+ },
+ auctionhelp() {
+ if (!this.runBroadcast()) return;
+ this.sendReplyBox(
+ `Auction commands ` +
+ `- create [startingcredits]: Creates an auction. ` +
+ `- start: Starts the auction. ` +
+ `- reset: Resets the auction. ` +
+ `- end: Ends the auction. ` +
+ `- running: Shows a list of rooms with running auctions. ` +
+ `- display: Displays the current state of the auction. ` +
+ `- pricelist: Displays the current prices of players by team. ` +
+ `- nom [player]: Nominates a player for auction. ` +
+ `- bid [amount]: Bids on a player for the specified amount. If the amount is less than 500, it will be multiplied by 1000. ` +
+ `You may use /bid and /nom directly without the /auction prefix. ` +
+ `During the bidding phase, all numbers that are sent in the chat will be treated as bids.
` +
+ `Configuration Commands` +
+ `- minbid [amount]: Sets the minimum bid. ` +
+ `- minplayers [amount]: Sets the minimum number of players. ` +
+ `- nomtimer [seconds]: Sets the nomination timer to [seconds] seconds. ` +
+ `- bidtimer [seconds]: Sets the bid timer to [seconds] seconds. ` +
+ `- blindmode [on/off]: Enables or disables blind mode. ` +
+ `- addowners [user1], [user2], ...: Adds users as auction owners. ` +
+ `- removeowners [user1], [user2], ...: Removes users as auction owners. ` +
+ `- importplayers [pastebin url]: Imports a list of players from a pastebin. ` +
+ `- addplayer [name], [tier1], [tier2], ...: Adds a player to the auction. ` +
+ `- removeplayer [name]: Removes a player from the auction. ` +
+ `- assignplayer [player], [team]: Assigns a player to a team. If team is blank, returns player to draft pool. ` +
+ `- addteam [name], [manager1], [manager2], ...: Adds a team to the auction. ` +
+ `- removeteam [name]: Removes the given team from the auction. ` +
+ `- suspendteam [name]: Suspends the given team from the auction. ` +
+ `- unsuspendteam [name]: Unsuspends the given team from the auction. ` +
+ `- addmanagers [team], [user1], [user2], ...: Adds users as managers to a team. ` +
+ `- removemanagers [user1], [user2], ...: Removes users as managers.. ` +
+ `- addcredits [team], [amount]: Adds credits to a team. ` +
+ `- skipnom: Skips the current nomination. ` +
+ `- undo: Undoes the last nomination. ` +
+ `- [enable/disable]: Enables or disables auctions from being started in a room. ` +
+ ``
+ );
+ },
+ nom(target) {
+ this.parse(`/auction nominate ${target}`);
+ },
+ bid(target) {
+ this.parse(`/auction bid ${target}`);
+ },
+ overpay() {
+ this.requireGame(Auction);
+ this.checkChat();
+ return '/announce OVERPAY!';
+ },
+};
+
+export const roomSettings: Chat.SettingsHandler = room => ({
+ label: "Auction",
+ permission: 'editroom',
+ options: [
+ [`disabled`, room.settings.auctionDisabled || 'auction disable'],
+ [`enabled`, !room.settings.auctionDisabled || 'auction enable'],
+ ],
+});
diff --git a/server/chat-plugins/battlesearch.ts b/server/chat-plugins/battlesearch.ts
index a22216fbd036..21b96dbd3e2d 100644
--- a/server/chat-plugins/battlesearch.ts
+++ b/server/chat-plugins/battlesearch.ts
@@ -29,11 +29,11 @@ interface BattleSearchResults {
const MAX_BATTLESEARCH_PROCESSES = 1;
export async function runBattleSearch(userids: ID[], month: string, tierid: ID, turnLimit?: number) {
const useRipgrep = await checkRipgrepAvailability();
- const pathString = `logs/${month}/${tierid}/`;
+ const pathString = `${month}/${tierid}/`;
const results: {[k: string]: BattleSearchResults} = {};
let files = [];
try {
- files = await FS(pathString).readdir();
+ files = await Monitor.logPath(pathString).readdir();
} catch (err: any) {
if (err.code === 'ENOENT') {
return results;
@@ -41,7 +41,7 @@ export async function runBattleSearch(userids: ID[], month: string, tierid: ID,
throw err;
}
const [userid] = userids;
- files = files.filter(item => item.startsWith(month)).map(item => `logs/${month}/${tierid}/${item}`);
+ files = files.filter(item => item.startsWith(month)).map(item => Monitor.logPath(`${month}/${tierid}/${item}`).path);
if (useRipgrep) {
// Matches non-word (including _ which counts as a word) characters between letters/numbers
@@ -276,7 +276,7 @@ async function rustBattleSearch(
const day = date.getDate().toString().padStart(2, '0');
directories.push(
- FS(path.join('logs', `${year}-${month}`, format, `${year}-${month}-${day}`)).path
+ Monitor.logPath(path.join(`${year}-${month}`, format, `${year}-${month}-${day}`)).path
);
}
@@ -340,7 +340,7 @@ export const pages: Chat.PageTable = {
buf += ``;
const months = Utils.sortBy(
- (await FS('logs/').readdir()).filter(f => f.length === 7 && f.includes('-')),
+ (await Monitor.logPath('/').readdir()).filter(f => f.length === 7 && f.includes('-')),
name => ({reverse: name})
);
if (!month) {
@@ -357,7 +357,7 @@ export const pages: Chat.PageTable = {
}
const tierid = toID(formatid);
- const tiers = Utils.sortBy(await FS(`logs/${month}/`).readdir(), tier => [
+ const tiers = Utils.sortBy(await Monitor.logPath(`${month}/`).readdir(), tier => [
// First sort by gen with the latest being first
tier.startsWith('gen') ? -parseInt(tier.charAt(3)) : -6,
// Then sort alphabetically
@@ -440,11 +440,11 @@ export const commands: Chat.ChatCommands = {
this.runBroadcast();
return this.sendReply(
'/battlesearch [args] - Searches rated battle history for the provided [args] and returns information on battles between the userids given.\n' +
- `If a number is provided in the [args], it is assumed to be a turn limit, else they're assumed to be userids. Requires &`
+ `If a number is provided in the [args], it is assumed to be a turn limit, else they're assumed to be userids. Requires ~`
);
},
rustbattlesearchhelp: [
- `/battlesearch , , - Searches for battles played by in the past days. Requires: &`,
+ `/battlesearch , , - Searches for battles played by in the past days. Requires: ~`,
],
};
diff --git a/server/chat-plugins/calculator.ts b/server/chat-plugins/calculator.ts
index 727fc970c48b..0dff3198b78a 100644
--- a/server/chat-plugins/calculator.ts
+++ b/server/chat-plugins/calculator.ts
@@ -185,10 +185,15 @@ export const commands: Chat.ChatCommands = {
if (baseResult === expression) baseResult = '';
}
let resultStr = '';
+ const resultTruncated = parseFloat(result.toPrecision(15));
+ let resultDisplay = resultTruncated.toString();
+ if (resultTruncated > 10 ** 15) {
+ resultDisplay = resultTruncated.toExponential();
+ }
if (baseResult) {
- resultStr = `${baseResult} = ${result}`;
+ resultStr = `${baseResult} = ${resultDisplay}`;
} else {
- resultStr = `${result}`;
+ resultStr = `${resultDisplay}`;
}
this.sendReplyBox(`${expression} = ${resultStr}`);
} catch (e: any) {
diff --git a/server/chat-plugins/cg-teams-leveling.ts b/server/chat-plugins/cg-teams-leveling.ts
index 8071ae7fe37c..4abd55b6b282 100644
--- a/server/chat-plugins/cg-teams-leveling.ts
+++ b/server/chat-plugins/cg-teams-leveling.ts
@@ -35,30 +35,15 @@ async function updateStats(battle: RoomBattle, winner: ID) {
if (!incrementWins || !incrementLosses) await dbSetupPromise;
if (battle.rated < 1000 || toID(battle.format) !== 'gen9computergeneratedteams') return;
- const p1 = Users.get(battle.p1.name);
- const p2 = Users.get(battle.p2.name);
- if (!p1 || !p2) return;
-
- const p1team = await battle.getTeam(p1);
- const p2team = await battle.getTeam(p2);
- if (!p1team || !p2team) return;
-
- let loserTeam, winnerTeam;
- if (winner === p1.id) {
- loserTeam = p2team;
- winnerTeam = p1team;
- } else {
- loserTeam = p1team;
- winnerTeam = p2team;
- }
-
- for (const species of winnerTeam) {
- await addPokemon?.run([toID(species.species), species.level]);
- await incrementWins?.run([toID(species.species)]);
- }
- for (const species of loserTeam) {
- await addPokemon?.run([toID(species.species), species.level]);
- await incrementLosses?.run([toID(species.species)]);
+ for (const player of battle.players) {
+ const team = await battle.getPlayerTeam(player);
+ if (!team) return;
+ const increment = (player.id === winner ? incrementWins : incrementLosses);
+
+ for (const species of team) {
+ await addPokemon?.run([toID(species.species), species.level]);
+ await increment?.run([toID(species.species)]);
+ }
}
}
diff --git a/server/chat-plugins/chat-monitor.ts b/server/chat-plugins/chat-monitor.ts
index 555d20478359..f74c20783487 100644
--- a/server/chat-plugins/chat-monitor.ts
+++ b/server/chat-plugins/chat-monitor.ts
@@ -13,7 +13,7 @@ const EVASION_DETECTION_SUBSTITUTIONS: {[k: string]: string[]} = {
c: ["c", "ç", "ᑕ", "C", "ⓒ", "Ⓒ", "¢", "͏", "₵", "ċ", "Ċ", "ፈ", "ς", "ḉ", "Ḉ", "Ꮯ", "ƈ", "̾", "c", "C", "ᴄ", "ɔ", "🅒", "𝐜", "𝐂", "𝘤", "𝘊", "𝙘", "𝘾", "𝒸", "𝓬", "𝓒", "𝕔", "ℂ", "𝔠", "ℭ", "𝖈", "𝕮", "🄲", "🅲", "𝒞", "𝚌", "𝙲", "☾", "с"],
d: ["d", "ᗪ", "D", "ⓓ", "Ⓓ", "∂", "Đ", "ď", "Ď", "Ꮄ", "Ḋ", "Ꭰ", "ɖ", "d", "D", "ᴅ", "🅓", "𝐝", "𝐃", "𝘥", "𝘋", "𝙙", "𝘿", "𝒹", "𝓭", "𝓓", "𝕕", "", "𝔡", "𝖉", "𝕯", "🄳", "🅳", "𝒟", "ԃ", "𝚍", "𝙳", "◗", "ⅾ"],
e: ["e", "3", "é", "ê", "E", "ⓔ", "Ⓔ", "є", "͏", "Ɇ", "ệ", "Ệ", "Ꮛ", "ε", "Σ", "ḕ", "Ḕ", "Ꭼ", "ɛ", "̾", "e", "E", "ᴇ", "ǝ", "🅔", "𝐞", "𝐄", "𝘦", "𝘌", "𝙚", "𝙀", "ℯ", "𝓮", "𝓔", "𝕖", "𝔻", "𝔢", "𝔇", "𝖊", "𝕰", "🄴", "🅴", "𝑒", "𝐸", "ҽ", "𝚎", "𝙴", "€", "е", "ё", "𝓮"],
- f: ["f", "ᖴ", "F", "ⓕ", "Ⓕ", "₣", "ḟ", "Ḟ", "Ꭶ", "ғ", "ʄ", "f", "F", "ɟ", "🅕", "𝐟", "𝐅", "𝘧", "𝘍", "𝙛", "𝙁", "𝒻", "𝓯", "𝓕", "𝕗", "𝔼", "𝔣", "𝔈", "𝖋", "𝕱", "🄵", "🅵", "𝐹", "ϝ", "𝚏", "𝙵", "Ϝ", "f"],
+ f: ["f", "ᖴ", "F", "ⓕ", "Ⓕ", "₣", "ḟ", "Ḟ", "Ꭶ", "ғ", "ʄ", "f", "F", "ɟ", "🅕", "𝐟", "𝐅", "𝘧", "𝘍", "𝙛", "𝙁", "𝒻", "𝓯", "𝓕", "𝕗", "𝔼", "𝔣", "𝔈", "𝖋", "𝕱", "🄵", "🅵", "𝐹", "ϝ", "𝚏", "𝙵", "Ϝ", "f", "Ƒ"],
g: ["g", "q", "6", "9", "G", "ⓖ", "Ⓖ", "͏", "₲", "ġ", "Ġ", "Ꮆ", "ϑ", "Ḡ", "ɢ", "̾", "g", "G", "ƃ", "🅖", "𝐠", "𝐆", "𝘨", "𝘎", "𝙜", "𝙂", "ℊ", "𝓰", "𝓖", "𝕘", "𝔽", "𝔤", "𝔉", "𝖌", "𝕲", "🄶", "🅶", "𝑔", "𝒢", "ɠ", "𝚐", "𝙶", "❡", "ց", "𝙶", "𝓰", "Ԍ"],
h: [
"h", "ᕼ", "H", "ⓗ", "Ⓗ", "н", "Ⱨ", "ḧ", "Ḧ", "Ꮒ", "ɦ", "h", "H", "ʜ", "ɥ", "🅗", "𝐡", "𝐇", "𝘩", "𝘏", "𝙝", "𝙃", "𝒽", "𝓱", "𝓗", "𝕙", "𝔾", "𝔥", "𝔊", "𝖍", "𝕳", "🄷", "🅷", "𝐻", "ԋ", "𝚑", "𝙷", "♄", "h",
@@ -428,7 +428,7 @@ export const namefilter: Chat.NameFilter = (name, user) => {
if (Punishments.namefilterwhitelist.has(id)) return name;
if (Monitor.forceRenames.has(id)) {
if (typeof Monitor.forceRenames.get(id) === 'number') {
- // we check this for hotpatching reasons, since on the initial chat patch this will still be a Utils.MultiSet
+ // we check this for hotpatching reasons, since on the initial chat patch this will still be a Utils.Multiset
// we're gonna assume no one has seen it since that covers people who _haven't_ actually, and those who have
// likely will not be attempting to log into it
Monitor.forceRenames.set(id, false);
@@ -502,7 +502,7 @@ export const nicknamefilter: Chat.NicknameFilter = (name, user) => {
lcName = lcName.replace('herapist', '').replace('grape', '').replace('scrape', '');
for (const list in filterWords) {
- if (!Chat.monitors[list]) continue;
+ if (!Chat.monitors[list]) continue;
if (Chat.monitors[list].location === 'BATTLES') continue;
for (const line of filterWords[list]) {
let {regex, word} = line;
@@ -742,14 +742,14 @@ export const commands: Chat.ChatCommands = {
},
testhelp: [
`/filter test [test string] - Tests whether or not the provided test string would trigger any of the chat monitors.`,
- `Requires: % @ &`,
+ `Requires: % @ ~`,
],
},
filterhelp: [
- `/filter add list, word, reason[, optional public reason] - Adds a word to the given filter list. Requires: &`,
- `/filter remove list, words - Removes words from the given filter list. Requires: &`,
- `/filter view - Opens the list of filtered words. Requires: % @ &`,
- `/filter test [test string] - Tests whether or not the provided test string would trigger any of the chat monitors. Requires: % @ &`,
+ `/filter add list, word, reason[, optional public reason] - Adds a word to the given filter list. Requires: ~`,
+ `/filter remove list, words - Removes words from the given filter list. Requires: ~`,
+ `/filter view - Opens the list of filtered words. Requires: % @ ~`,
+ `/filter test [test string] - Tests whether or not the provided test string would trigger any of the chat monitors. Requires: % @ ~`,
`You may use / instead of , in /filter add if you want to specify a reason that includes commas.`,
],
allowname(target, room, user) {
diff --git a/server/chat-plugins/chatlog.ts b/server/chat-plugins/chatlog.ts
index 38039b8b1865..f1a59930318c 100644
--- a/server/chat-plugins/chatlog.ts
+++ b/server/chat-plugins/chatlog.ts
@@ -5,19 +5,14 @@
* @license MIT
*/
-import {Utils, FS, Dashycode, ProcessManager, Repl, Net} from '../../lib';
-import {Config} from '../config-loader';
-import {Dex} from '../../sim/dex';
-import {Chat} from '../chat';
+import {Utils, FS, Dashycode, ProcessManager, Net, Streams} from '../../lib';
+import {SQL} from '../../lib/database';
+import {roomlogTable} from '../roomlogs';
const DAY = 24 * 60 * 60 * 1000;
-const MAX_RESULTS = 3000;
const MAX_MEMORY = 67108864; // 64MB
-const MAX_PROCESSES = 1;
const MAX_TOPUSERS = 100;
-const CHATLOG_PM_TIMEOUT = 1 * 60 * 60 * 1000; // 1 hour
-
const UPPER_STAFF_ROOMS = ['upperstaff', 'adminlog', 'slowlog'];
interface ChatlogSearch {
@@ -63,8 +58,12 @@ export class LogReaderRoom {
}
async listMonths() {
+ if (roomlogTable) {
+ const dates = await roomlogTable.query()`SELECT DISTINCT month FROM roomlog_dates WHERE roomid = ${this.roomid}`;
+ return dates.map(x => x.month);
+ }
try {
- const listing = await FS(`logs/chat/${this.roomid}`).readdir();
+ const listing = await Monitor.logPath(`chat/${this.roomid}`).readdir();
return listing.filter(file => /^[0-9][0-9][0-9][0-9]-[0-9][0-9]$/.test(file));
} catch {
return [];
@@ -72,8 +71,14 @@ export class LogReaderRoom {
}
async listDays(month: string) {
+ if (roomlogTable) {
+ const dates = await (
+ roomlogTable.query()`SELECT DISTINCT date FROM roomlog_dates WHERE roomid = ${this.roomid} AND month = ${month}`
+ );
+ return dates.map(x => x.date);
+ }
try {
- const listing = await FS(`logs/chat/${this.roomid}/${month}`).readdir();
+ const listing = await Monitor.logPath(`chat/${this.roomid}/${month}`).readdir();
return listing.filter(file => file.endsWith(".txt")).map(file => file.slice(0, -4));
} catch {
return [];
@@ -81,21 +86,43 @@ export class LogReaderRoom {
}
async getLog(day: string) {
+ if (roomlogTable) {
+ const [dayStart, dayEnd] = LogReader.dayToRange(day);
+ const logs = await roomlogTable.selectAll(
+ ['log', 'time']
+ )`WHERE roomid = ${this.roomid} AND time BETWEEN ${dayStart}::int::timestamp AND ${dayEnd}::int::timestamp`;
+ return new Streams.ObjectReadStream({
+ read(this: Streams.ObjectReadStream) {
+ for (const {log, time} of logs) {
+ this.buf.push(`${Chat.toTimestamp(time).split(' ')[1]} ${log}`);
+ }
+ this.pushEnd();
+ },
+ });
+ }
const month = LogReader.getMonth(day);
- const log = FS(`logs/chat/${this.roomid}/${month}/${day}.txt`);
+ const log = Monitor.logPath(`chat/${this.roomid}/${month}/${day}.txt`);
if (!await log.exists()) return null;
- return log.createReadStream();
+ return log.createReadStream().byLine();
}
}
export const LogReader = new class {
async get(roomid: RoomID) {
- if (!await FS(`logs/chat/${roomid}`).exists()) return null;
+ if (roomlogTable) {
+ if (!(await roomlogTable.selectOne()`WHERE roomid = ${roomid}`)) return null;
+ } else {
+ if (!await Monitor.logPath(`chat/${roomid}`).exists()) return null;
+ }
return new LogReaderRoom(roomid);
}
async list() {
- const listing = await FS(`logs/chat`).readdir();
+ if (roomlogTable) {
+ const roomids = await roomlogTable.query()`SELECT DISTINCT roomid FROM roomlogs`;
+ return roomids.map(x => x.roomid) as RoomID[];
+ }
+ const listing = await Monitor.logPath(`chat`).readdir();
return listing.filter(file => /^[a-z0-9-]+$/.test(file)) as RoomID[];
}
@@ -151,25 +178,23 @@ export const LogReader = new class {
return {official, normal, hidden, secret, deleted, personal, deletedPersonal};
}
- async read(roomid: RoomID, day: string, limit: number) {
- const roomLog = await LogReader.get(roomid);
- const stream = await roomLog!.getLog(day);
- let buf = '';
- let i = (LogSearcher as FSLogSearcher).results || 0;
- if (!stream) {
- buf += `
Room "${roomid}" doesn't have logs for ${day}
`;
- } else {
- for await (const line of stream.byLine()) {
- const rendered = LogViewer.renderLine(line);
- if (rendered) {
- buf += `${line}\n`;
- i++;
- if (i > limit) break;
- }
- }
- }
- return buf;
+ /** @returns [dayStart, dayEnd] as seconds (NOT milliseconds) since Unix epoch */
+ dayToRange(day: string): [number, number] {
+ const nextDay = LogReader.nextDay(day);
+ return [
+ Math.trunc(new Date(day).getTime() / 1000),
+ Math.trunc(new Date(nextDay).getTime() / 1000),
+ ];
}
+ /** @returns [monthStart, monthEnd] as seconds (NOT milliseconds) since Unix epoch */
+ monthToRange(month: string): [number, number] {
+ const nextMonth = LogReader.nextMonth(month);
+ return [
+ Math.trunc(new Date(`${month}-01`).getTime() / 1000),
+ Math.trunc(new Date(`${nextMonth}-01`).getTime() / 1000),
+ ];
+ }
+
getMonth(day?: string) {
if (!day) day = Chat.toTimestamp(new Date()).split(' ')[0];
return day.slice(0, 7);
@@ -204,119 +229,6 @@ export const LogReader = new class {
// won't crash on the input text.
return /^[0-9]{4}-(?:0[0-9]|1[0-2])-(?:[0-2][0-9]|3[0-1])$/.test(text);
}
- async findBattleLog(tier: ID, number: number): Promise {
- // binary search!
- const months = (await FS('logs').readdir()).filter(this.isMonth).sort();
- if (!months.length) return null;
-
- // find first day
- let firstDay!: string;
- while (months.length) {
- const month = months[0];
- try {
- const days = (await FS(`logs/${month}/${tier}/`).readdir()).filter(this.isDay).sort();
- firstDay = days[0];
- break;
- } catch {}
- months.shift();
- }
- if (!firstDay) return null;
-
- // find last day
- let lastDay!: string;
- while (months.length) {
- const month = months[months.length - 1];
- try {
- const days = (await FS(`logs/${month}/${tier}/`).readdir()).filter(this.isDay).sort();
- lastDay = days[days.length - 1];
- break;
- } catch {}
- months.pop();
- }
- if (!lastDay) throw new Error(`getBattleLog month range search for ${tier}`);
-
- const getBattleNum = (battleName: string) => Number(battleName.split('-')[1].slice(0, -9));
-
- const getDayRange = async (day: string) => {
- const month = day.slice(0, 7);
-
- try {
- const battles = (await FS(`logs/${month}/${tier}/${day}`).readdir()).filter(
- b => b.endsWith('.log.json')
- );
- Utils.sortBy(battles, getBattleNum);
-
- return [getBattleNum(battles[0]), getBattleNum(battles[battles.length - 1])];
- } catch {
- return null;
- }
- };
-
- const dayExists = (day: string) => FS(`logs/${day.slice(0, 7)}/${tier}/${day}`).exists();
-
- const nextExistingDay = async (day: string) => {
- for (let i = 0; i < 3650; i++) {
- day = this.nextDay(day);
- if (await dayExists(day)) return day;
- if (day === lastDay) return null;
- }
- return null;
- };
-
- const prevExistingDay = async (day: string) => {
- for (let i = 0; i < 3650; i++) {
- day = this.prevDay(day);
- if (await dayExists(day)) return day;
- if (day === firstDay) return null;
- }
- return null;
- };
-
- for (let i = 0; i < 100; i++) {
- const middleDay = new Date(
- (new Date(firstDay).getTime() + new Date(lastDay).getTime()) / 2
- ).toISOString().slice(0, 10);
-
- let currentDay: string | null = middleDay;
- let dayRange = await getDayRange(middleDay);
-
- if (!dayRange) {
- currentDay = await nextExistingDay(middleDay);
- if (!currentDay) {
- const lastExistingDay = await prevExistingDay(middleDay);
- if (!lastExistingDay) throw new Error(`couldn't find existing day`);
- lastDay = lastExistingDay;
- continue;
- }
- dayRange = await getDayRange(currentDay);
- if (!dayRange) throw new Error(`existing day was a lie`);
- }
-
- const [lowest, highest] = dayRange;
-
- if (number < lowest) {
- // before currentDay
- if (firstDay === currentDay) return null;
- lastDay = this.prevDay(currentDay);
- } else if (number > highest) {
- // after currentDay
- if (lastDay === currentDay) return null;
- firstDay = this.nextDay(currentDay);
- } else {
- // during currentDay
- const month = currentDay.slice(0, 7);
- const path = FS(`logs/${month}/${tier}/${currentDay}/${tier}-${number}.log.json`);
- if (await path.exists()) {
- return JSON.parse(path.readSync()).log;
- }
- return null;
- }
- }
-
- // 100 iterations is enough to search 2**100 days, which is around 1e30 days
- // for comparison, a millennium is 365000 days
- throw new Error(`Infinite loop looking for ${tier}-${number}`);
- }
};
export const LogViewer = new class {
@@ -343,8 +255,11 @@ export const LogViewer = new class {
if (!stream) {
buf += `
Room "${roomid}" doesn't have logs for ${day}
`;
} else {
- for await (const line of stream.byLine()) {
- buf += this.renderLine(line, opts, {roomid, date: day});
+ for await (const line of stream) {
+ // sometimes there can be newlines in there. parse accordingly
+ for (const part of line.split('\n')) {
+ buf += this.renderLine(part, opts, {roomid, date: day});
+ }
}
}
buf += ``;
@@ -358,24 +273,6 @@ export const LogViewer = new class {
return this.linkify(buf);
}
- async battle(tier: string, number: number, context: Chat.PageContext) {
- if (number > Rooms.global.lastBattle) {
- throw new Chat.ErrorMessage(`That battle cannot exist, as the number has not been used.`);
- }
- const roomid = `battle-${tier}-${number}` as RoomID;
- context.setHTML(`
Locating battle logs for the battle ${tier}-${number}...
`);
- const log = await PM.query({
- queryType: 'battlesearch', roomid: toID(tier), search: number,
- });
- if (!log) return context.setHTML(this.error("Logs not found."));
- const {connection} = context;
- context.close();
- connection.sendTo(
- roomid, `|init|battle\n|title|[Battle Log] ${tier}-${number}\n${log.join('\n')}`
- );
- connection.sendTo(roomid, `|expire|This is a battle log.`);
- }
-
parseChatLine(line: string, day: string) {
const [timestamp, type, ...rest] = line.split('|');
if (type === 'c:') {
@@ -567,9 +464,6 @@ export const LogViewer = new class {
}
};
-/** Match with two lines of context in either direction */
-type SearchMatch = readonly [string, string, string, string, string];
-
export abstract class Searcher {
static checkEnabled() {
if (global.Config.disableripgrep) {
@@ -581,19 +475,7 @@ export abstract class Searcher {
const id = toID(user);
return `.${[...id].join('[^a-zA-Z0-9]*')}[^a-zA-Z0-9]*`;
}
- constructSearchRegex(str: string) {
- // modified regex replace
- str = str.replace(/[\\^$.*?()[\]{}|]/g, '\\$&');
- const searches = str.split('+');
- if (searches.length <= 1) {
- if (str.length <= 3) return `\b${str}`;
- return str;
- }
- return `^` + searches.filter(Boolean).map(term => `(?=.*${term})`).join('');
- }
- abstract searchLogs(roomid: RoomID, search: string, limit?: number | null, date?: string | null): Promise;
abstract searchLinecounts(roomid: RoomID, month: string, user?: ID): Promise;
- abstract getSharedBattles(userids: string[]): Promise;
renderLinecountResults(
results: {[date: string]: {[userid: string]: number}} | null,
roomid: RoomID, month: string, user?: ID
@@ -601,13 +483,13 @@ export abstract class Searcher {
let buf = Utils.html`
Linecounts on `;
buf += `${roomid}${user ? ` for the user ${user}` : ` (top ${MAX_TOPUSERS})`}
`;
buf += `Total lines: {total} `;
- buf += `Month: ${month}: `;
+ buf += `Month: ${month} `;
const nextMonth = LogReader.nextMonth(month);
const prevMonth = LogReader.prevMonth(month);
- if (FS(`logs/chat/${roomid}/${prevMonth}`).existsSync()) {
+ if (Monitor.logPath(`chat/${roomid}/${prevMonth}`).existsSync()) {
buf += `Previous month`;
}
- if (FS(`logs/chat/${roomid}/${nextMonth}`).existsSync()) {
+ if (Monitor.logPath(`chat/${roomid}/${nextMonth}`).existsSync()) {
buf += ` Next month`;
}
if (!results) {
@@ -616,7 +498,7 @@ export abstract class Searcher {
return buf;
} else if (user) {
buf += '';
- const sortedDays = Utils.sortBy(Object.keys(results), day => ({reverse: day}));
+ const sortedDays = Utils.sortBy(Object.keys(results));
let total = 0;
for (const day of sortedDays) {
const dayResults = results[day][user];
@@ -630,7 +512,7 @@ export abstract class Searcher {
buf += '';
// squish the results together
const totalResults: {[k: string]: number} = {};
- for (const date in results) {
+ for (const date of Utils.sortBy(Object.keys(results))) {
for (const userid in results[date]) {
if (!totalResults[userid]) totalResults[userid] = 0;
totalResults[userid] += results[date][userid];
@@ -651,71 +533,39 @@ export abstract class Searcher {
buf += `
`;
return LogViewer.linkify(buf);
}
- async runSearch(
- context: Chat.PageContext, search: string, roomid: RoomID, date: string | null, limit: number | null
- ) {
- context.title = `[Search] [${roomid}] ${search}`;
- if (!['ripgrep', 'fs'].includes(Config.chatlogreader)) {
- throw new Error(`Config.chatlogreader must be 'fs' or 'ripgrep'.`);
- }
- context.setHTML(
- `
Running a chatlog search for "${search}" on room ${roomid}` +
- (date ? date !== 'all' ? `, on the date "${date}"` : ', on all dates' : '') +
- `.
Searching for "${search}" in ${roomid} (${month}):`
- );
- buf += this.renderDayResults(results, roomid);
- if (total > limit) {
- // cap is met & is not being used in a year read
- buf += ` Max results reached, capped at ${limit}`;
- buf += `
`;
- }
- return buf;
- }
async searchLinecounts(room: RoomID, month: string, user?: ID) {
// don't need to check if logs exist since ripgrepSearchMonth does that
const regexString = (
@@ -1251,93 +818,44 @@ export class RipgrepLogSearcher extends Searcher {
}
return this.renderLinecountResults(results, room, month, user);
}
- async getSharedBattles(userids: string[]) {
- const regexString = userids.map(id => `(?=.*?("p(1|2)":"${[...id].join('[^a-zA-Z0-9]*')}[^a-zA-Z0-9]*"))`).join('');
- const results: string[] = [];
- try {
- const {stdout} = await ProcessManager.exec(['rg', '-e', regexString, '-i', '-tjson', 'logs/', '-P']);
- for (const line of stdout.split('\n')) {
- const [name] = line.split(':');
- const battleName = name.split('/').pop()!;
- results.push(battleName.slice(0, -9));
- }
- } catch (e: any) {
- if (e.code !== 1) throw e;
- }
- return results.filter(Boolean);
- }
}
-export const LogSearcher: Searcher = new (Config.chatlogreader === 'ripgrep' ? RipgrepLogSearcher : FSLogSearcher)();
-
-export const PM = new ProcessManager.QueryProcessManager(module, async data => {
- const start = Date.now();
- try {
- let result: any;
- const {date, search, roomid, limit, queryType} = data;
- switch (queryType) {
- case 'linecount':
- result = await LogSearcher.searchLinecounts(roomid, date, search);
- break;
- case 'search':
- result = await LogSearcher.searchLogs(roomid, search, limit, date);
- break;
- case 'sharedsearch':
- result = await LogSearcher.getSharedBattles(search);
- break;
- case 'battlesearch':
- result = await LogReader.findBattleLog(roomid, search);
- break;
- case 'roomstats':
- result = await LogSearcher.activityStats(roomid, search);
- break;
- default:
- return LogViewer.error(`Config.chatlogreader is not configured.`);
- }
- const elapsedTime = Date.now() - start;
- if (elapsedTime > 3000) {
- Monitor.slow(`[Slow chatlog query]: ${elapsedTime}ms: ${JSON.stringify(data)}`);
- }
- return result;
- } catch (e: any) {
- if (e.name?.endsWith('ErrorMessage')) {
- return LogViewer.error(e.message);
+export class DatabaseLogSearcher extends Searcher {
+ async searchLinecounts(roomid: RoomID, month: string, user?: ID) {
+ user = toID(user);
+ if (!Rooms.Roomlogs.table) throw new Error(`Database search made while database is disabled.`);
+ const results: {[date: string]: {[user: string]: number}} = {};
+ const [monthStart, monthEnd] = LogReader.monthToRange(month);
+ const rows = await Rooms.Roomlogs.table.selectAll()`
+ WHERE ${user ? SQL`userid = ${user} AND ` : SQL``}roomid = ${roomid} AND
+ time BETWEEN ${monthStart}::int::timestamp AND ${monthEnd}::int::timestamp AND
+ type = ${'c'}
+ `;
+
+ for (const row of rows) {
+ // 'c' rows should always have userids, so this should never be an issue.
+ // this is just to appease TS.
+ if (!row.userid) continue;
+ const day = Chat.toTimestamp(row.time).split(' ')[0];
+ if (!results[day]) results[day] = {};
+ if (!results[day][row.userid]) results[day][row.userid] = 0;
+ results[day][row.userid]++;
}
- Monitor.crashlog(e, 'A chatlog search query', data);
- return LogViewer.error(`Sorry! Your chatlog search crashed. We've been notified and will fix this.`);
+
+ return this.renderLinecountResults(results, roomid, month, user);
}
-}, CHATLOG_PM_TIMEOUT, message => {
- if (message.startsWith(`SLOW\n`)) {
- Monitor.slow(message.slice(5));
+ activityStats(room: RoomID, month: string): Promise<{average: RoomStats, days: RoomStats[]}> {
+ throw new Chat.ErrorMessage('This is not yet implemented for the new logs database.');
}
-});
-
-if (!PM.isParentProcess) {
- // This is a child process!
- global.Config = Config;
- global.Monitor = {
- crashlog(error: Error, source = 'A chatlog search process', details: AnyObject | null = null) {
- const repr = JSON.stringify([error.name, error.message, source, details]);
- process.send!(`THROW\n@!!@${repr}\n${error.stack}`);
- },
- slow(text: string) {
- process.send!(`CALLBACK\nSLOW\n${text}`);
- },
- };
- global.Dex = Dex;
- global.toID = Dex.toID;
- process.on('uncaughtException', err => {
- if (Config.crashguard) {
- Monitor.crashlog(err, 'A chatlog search child process');
- }
- });
- // eslint-disable-next-line no-eval
- Repl.start('chatlog', cmd => eval(cmd));
-} else {
- PM.spawn(MAX_PROCESSES);
}
-const accessLog = FS(`logs/chatlog-access.txt`).createAppendStream();
+export const LogSearcher: Searcher = new (
+ Rooms.Roomlogs.table ? DatabaseLogSearcher :
+ // no db, determine fs reader type.
+ Config.chatlogreader === 'ripgrep' ? RipgrepLogSearcher : FSLogSearcher
+)();
+
+const accessLog = Monitor.logPath(`chatlog-access.txt`).createAppendStream();
export const pages: Chat.PageTable = {
async chatlog(args, user, connection) {
@@ -1383,22 +901,7 @@ export const pages: Chat.PageTable = {
void accessLog.writeLine(`${user.id}: <${roomid}> ${date}`);
this.title = '[Logs] ' + roomid;
- /** null = no limit */
- let limit: number | null = null;
let search;
- if (opts?.startsWith('search-')) {
- let [input, limitString] = opts.split('--limit-');
- input = input.slice(7);
- search = Dashycode.decode(input);
- if (search.length < 3) return this.errorReply(`That's too short of a search query.`);
- if (limitString) {
- limit = parseInt(limitString) || null;
- } else {
- limit = 500;
- }
- opts = '';
- }
- const isAll = (toID(date) === 'all' || toID(date) === 'alltime');
const parsedDate = new Date(date as string);
const validDateStrings = ['all', 'alltime'];
@@ -1414,7 +917,7 @@ export const pages: Chat.PageTable = {
if (date && search) {
Searcher.checkEnabled();
this.checkCan('bypassall');
- return LogSearcher.runSearch(this, search, roomid, isAll ? null : date, limit);
+ return LogSearcher.runSearch();
} else if (date) {
if (date === 'today') {
this.setHTML(await LogViewer.day(roomid, LogReader.today(), opts));
@@ -1449,14 +952,6 @@ export const pages: Chat.PageTable = {
this.title = `[Log Stats] ${date}`;
return LogSearcher.runLinecountSearch(this, room ? room.roomid : args[2] as RoomID, date, toID(target));
},
- battlelog(args, user) {
- const [tierName, battleNum] = args;
- const tier = toID(tierName);
- const num = parseInt(battleNum);
- if (isNaN(num)) return this.errorReply(`Invalid battle number.`);
- void accessLog.writeLine(`${user.id}: battle-${tier}-${num}`);
- return LogViewer.battle(tier, num, this);
- },
async logsaccess(query) {
this.checkCan('rangeban');
const type = toID(query.shift());
@@ -1479,7 +974,7 @@ export const pages: Chat.PageTable = {
let buf = `
${title}`;
if (userid) buf += ` for ${userid}`;
buf += `
`;
- const accessStream = FS(`logs/chatlog-access.txt`).createReadStream();
+ const accessStream = Monitor.logPath(`chatlog-access.txt`).createReadStream();
for await (const line of accessStream.byLine()) {
const [id, rest] = Utils.splitFirst(line, ': ');
if (userid && id !== userid) continue;
@@ -1510,6 +1005,9 @@ export const pages: Chat.PageTable = {
export const commands: Chat.ChatCommands = {
chatlogs: 'chatlog',
cl: 'chatlog',
+ roomlog: 'chatlog',
+ rl: 'chatlog',
+ roomlogs: 'chatlog',
chatlog(target, room, user) {
const [tarRoom, ...opts] = target.split(',');
const targetRoom = tarRoom ? Rooms.search(tarRoom) : room;
@@ -1520,7 +1018,7 @@ export const commands: Chat.ChatCommands = {
chatloghelp() {
const strings = [
`/chatlog [optional room], [opts] - View chatlogs from the given room. `,
- `If none is specified, shows logs from the room you're in. Requires: % @ * # &`,
+ `If none is specified, shows logs from the room you're in. Requires: % @ * # ~`,
`Supported options:`,
`txt - Do not render logs.`,
`txt-onlychat - Show only chat lines, untransformed.`,
@@ -1572,7 +1070,7 @@ export const commands: Chat.ChatCommands = {
`If you provide a user argument in the form user=username, it will search for messages (that match the other arguments) only from that user. ` +
`All other arguments will be considered part of the search ` +
`(if more than one argument is specified, it searches for lines containing all terms). ` +
- "Requires: &
";
+ "Requires: ~
";
return this.sendReplyBox(buffer);
},
topusers: 'linecount',
@@ -1648,23 +1146,6 @@ export const commands: Chat.ChatCommands = {
`/linecount [room], [month], [user].. This does not use any defaults. `
);
},
- slb: 'sharedloggedbattles',
- async sharedloggedbattles(target, room, user) {
- this.checkCan('lock');
- if (Config.nobattlesearch) return this.errorReply(`/${this.cmd} has been temporarily disabled due to load issues.`);
- const targets = target.split(',').map(toID).filter(Boolean);
- if (targets.length < 2 || targets.length > 2) {
- return this.errorReply(`Specify two users.`);
- }
- const results = await LogSearcher.sharedBattles(targets);
- if (room?.settings.staffRoom || this.pmTarget?.isStaff) {
- this.runBroadcast();
- }
- return this.sendReplyBox(results);
- },
- sharedloggedbattleshelp: [
- `/sharedloggedbattles OR /slb [user1, user2] - View shared battle logs between user1 and user2`,
- ],
battlelog(target, room, user) {
this.checkCan('lock');
target = target.trim();
@@ -1678,7 +1159,7 @@ export const commands: Chat.ChatCommands = {
},
battleloghelp: [
`/battlelog [battle link] - View the log of the given [battle link], even if the replay was not saved.`,
- `Requires: % @ &`,
+ `Requires: % @ ~`,
],
@@ -1711,6 +1192,16 @@ export const commands: Chat.ChatCommands = {
let log: string[];
if (tarRoom) {
log = tarRoom.log.log;
+ } else if (Rooms.Replays.db) {
+ let battleId = roomid.replace('battle-', '');
+ if (battleId.endsWith('pw')) {
+ battleId = battleId.slice(0, battleId.lastIndexOf("-", battleId.length - 2));
+ }
+ const replayData = await Rooms.Replays.get(battleId);
+ if (!replayData) {
+ return this.errorReply(`No room or replay found for that battle.`);
+ }
+ log = replayData.log.split('\n');
} else {
try {
const raw = await Net(`https://${Config.routes.replays}/${roomid.slice('battle-'.length)}.json`).get();
@@ -1746,7 +1237,7 @@ export const commands: Chat.ChatCommands = {
getbattlechathelp: [
`/getbattlechat [battle link][, username] - Gets all battle chat logs from the given [battle link].`,
`If a [username] is given, searches only chat messages from the given username.`,
- `Requires: % @ &`,
+ `Requires: % @ ~`,
],
logsaccess(target, room, user) {
@@ -1757,7 +1248,7 @@ export const commands: Chat.ChatCommands = {
logsaccesshelp: [
`/logsaccess [type], [user] - View chatlog access logs for the given [type] and [user].`,
`If no arguments are given, shows the entire access log.`,
- `Requires: &`,
+ `Requires: ~`,
],
@@ -1769,7 +1260,7 @@ export const commands: Chat.ChatCommands = {
if (target.length < 3) {
return this.errorReply(`Too short of a search term.`);
}
- const files = await FS(`logs/chat`).readdir();
+ const files = await Monitor.logPath(`chat`).readdir();
const buffer = [];
for (const roomid of files) {
if (roomid.startsWith('groupchat-') && roomid.includes(target)) {
@@ -1783,7 +1274,7 @@ export const commands: Chat.ChatCommands = {
);
},
groupchatsearchhelp: [
- `/groupchatsearch [target] - Searches for logs of groupchats with names containing the [target]. Requires: % @ &`,
+ `/groupchatsearch [target] - Searches for logs of groupchats with names containing the [target]. Requires: % @ ~`,
],
roomact: 'roomactivity',
@@ -1797,6 +1288,6 @@ export const commands: Chat.ChatCommands = {
roomactivityhelp: [
`/roomactivity [room][, date] - View room activity logs for the given room.`,
`If a date is provided, it searches for logs from that date. Otherwise, it searches the current month.`,
- `Requires: &`,
+ `Requires: ~`,
],
};
diff --git a/server/chat-plugins/daily-spotlight.ts b/server/chat-plugins/daily-spotlight.ts
index 3acd8a3f495f..380b6c82360a 100644
--- a/server/chat-plugins/daily-spotlight.ts
+++ b/server/chat-plugins/daily-spotlight.ts
@@ -52,7 +52,7 @@ function nextDaily() {
const midnight = new Date();
midnight.setHours(24, 0, 0, 0);
-let timeout = setTimeout(nextDaily, midnight.valueOf() - Date.now());
+let timeout = setTimeout(nextDaily, midnight.getTime() - Date.now());
export async function renderSpotlight(roomid: RoomID, key: string, index: number) {
let imgHTML = '';
@@ -308,13 +308,13 @@ export const commands: Chat.ChatCommands = {
dailyhelp() {
this.sendReply(
`|html|/daily [name]: shows the daily spotlight. ` +
- `!daily [name]: shows the daily spotlight to everyone. Requires: + % @ # & ` +
- `/setdaily [name], [image], [description]: sets the daily spotlight. Image can be left out. Requires: % @ # &` +
- `/queuedaily [name], [image], [description]: queues a daily spotlight. At midnight, the spotlight with this name will automatically switch to the next queued spotlight. Image can be left out. Requires: % @ # & ` +
- `/queuedailyat [name], [queue number], [image], [description]: inserts a daily spotlight into the queue at the specified number (starting from 1). Requires: % @ # & ` +
- `/replacedaily [name], [queue number], [image], [description]: replaces the daily spotlight queued at the specified number. Requires: % @ # & ` +
- `/removedaily [name][, queue number]: if no queue number is provided, deletes all queued and current spotlights with the given name. If a number is provided, removes a specific future spotlight from the queue. Requires: % @ # & ` +
- `/swapdaily [name], [queue number], [queue number]: swaps the two queued spotlights at the given queue numbers. Requires: % @ # & ` +
+ `!daily [name]: shows the daily spotlight to everyone. Requires: + % @ # ~ ` +
+ `/setdaily [name], [image], [description]: sets the daily spotlight. Image can be left out. Requires: % @ # ~` +
+ `/queuedaily [name], [image], [description]: queues a daily spotlight. At midnight, the spotlight with this name will automatically switch to the next queued spotlight. Image can be left out. Requires: % @ # ~ ` +
+ `/queuedailyat [name], [queue number], [image], [description]: inserts a daily spotlight into the queue at the specified number (starting from 1). Requires: % @ # ~ ` +
+ `/replacedaily [name], [queue number], [image], [description]: replaces the daily spotlight queued at the specified number. Requires: % @ # ~ ` +
+ `/removedaily [name][, queue number]: if no queue number is provided, deletes all queued and current spotlights with the given name. If a number is provided, removes a specific future spotlight from the queue. Requires: % @ # ~ ` +
+ `/swapdaily [name], [queue number], [queue number]: swaps the two queued spotlights at the given queue numbers. Requires: % @ # ~ ` +
`/viewspotlights [sorter]: shows all current spotlights in the room. For staff, also shows queued spotlights.` +
`[sorter] can either be unset, 'time', or 'alphabet'. These sort by either the time added, or alphabetical order.` +
``
diff --git a/server/chat-plugins/datasearch.ts b/server/chat-plugins/datasearch.ts
index c4ae39dbb3b3..ab39045fc85f 100644
--- a/server/chat-plugins/datasearch.ts
+++ b/server/chat-plugins/datasearch.ts
@@ -180,7 +180,11 @@ export const commands: Chat.ChatCommands = {
}
}
if (!qty) targetsBuffer.push("random1");
-
+ const defaultFormat = this.extractFormat(room?.settings.defaultFormat || room?.battle?.format);
+ if (!target.includes('mod=')) {
+ const dex = defaultFormat.dex;
+ if (dex) targetsBuffer.push(`mod=${dex.currentMod}`);
+ }
const response = await runSearch({
target: targetsBuffer.join(","),
cmd: 'randmove',
@@ -227,7 +231,11 @@ export const commands: Chat.ChatCommands = {
}
}
if (!qty) targetsBuffer.push("random1");
-
+ const defaultFormat = this.extractFormat(room?.settings.defaultFormat || room?.battle?.format);
+ if (!target.includes('mod=')) {
+ const dex = defaultFormat.dex;
+ if (dex) targetsBuffer.push(`mod=${dex.currentMod}`);
+ }
const response = await runSearch({
target: targetsBuffer.join(","),
cmd: 'randpoke',
@@ -355,7 +363,7 @@ export const commands: Chat.ChatCommands = {
`- zmove, max, or gmax as parameters will search for Z-Moves, Max Moves, and G-Max Moves respectively. ` +
`- Move targets must be preceded with targets ; e.g. targets user searches for moves that target the user. ` +
`- Valid move targets are: one ally, user or ally, one adjacent opponent, all Pokemon, all adjacent Pokemon, all adjacent opponents, user and allies, user's side, user's team, any Pokemon, opponent's side, one adjacent Pokemon, random adjacent Pokemon, scripted, and user. ` +
- `- Valid flags are: allyanim, bypasssub (bypasses Substitute), bite, bullet, cantusetwice, charge, contact, dance, defrost, distance (can target any Pokemon in Triples), failcopycat, failencore, failinstruct, failmefirst, failmimic, futuremove, gravity, heal, highcrit, instruct, mefirst, mimic, mirror (reflected by Mirror Move), mustpressure, multihit, noassist, nonsky, noparentalbond, nosleeptalk, ohko, pivot, pledgecombo, powder, priority, protect, pulse, punch, recharge, recovery, reflectable, secondary, slicing, snatch, sound, and wind. ` +
+ `- Valid flags are: allyanim, bypasssub (bypasses Substitute), bite, bullet, cantusetwice, charge, contact, dance, defrost, distance (can target any Pokemon in Triples), failcopycat, failencore, failinstruct, failmefirst, failmimic, futuremove, gravity, heal, highcrit, instruct, metronome, mimic, mirror (reflected by Mirror Move), mustpressure, multihit, noassist, nonsky, noparentalbond, nosketch, nosleeptalk, ohko, pivot, pledgecombo, powder, priority, protect, pulse, punch, recharge, recovery, reflectable, secondary, slicing, snatch, sound, and wind. ` +
`- protection as a parameter will search protection moves like Protect, Detect, etc. ` +
`- A search that includes !protect will show all moves that bypass protection. ` +
` ` +
@@ -387,7 +395,7 @@ export const commands: Chat.ChatCommands = {
if (!target) return this.parse('/help itemsearch');
target = target.slice(0, 300);
const targetGen = parseInt(cmd[cmd.length - 1]);
- if (targetGen) target += ` maxgen${targetGen}`;
+ if (targetGen) target = `maxgen${targetGen} ${target}`;
const response = await runSearch({
target,
@@ -543,7 +551,7 @@ export const commands: Chat.ChatCommands = {
},
learnhelp: [
`/learn [ruleset], [pokemon], [move, move, ...] - Displays how the Pok\u00e9mon can learn the given moves, if it can at all.`,
- `!learn [ruleset], [pokemon], [move, move, ...] - Show everyone that information. Requires: + % @ # &`,
+ `!learn [ruleset], [pokemon], [move, move, ...] - Show everyone that information. Requires: + % @ # ~`,
`Specifying a ruleset is entirely optional. The ruleset can be a format, a generation (e.g.: gen3) or "min source gen [number]".`,
`A value of 'min source gen [number]' indicates that trading (or Pokémon Bank) from generations before [number] is not allowed.`,
`/learn5 displays how the Pok\u00e9mon can learn the given moves at level 5, if it can at all.`,
@@ -1077,11 +1085,7 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
if (
species.gen <= mod.gen &&
(
- (
- nationalSearch &&
- species.isNonstandard &&
- !["Custom", "Glitch", "Pokestar", "Future"].includes(species.isNonstandard)
- ) ||
+ (nationalSearch && species.natDexTier !== 'Illegal') ||
((species.tier !== 'Unreleased' || unreleasedSearch) && species.tier !== 'Illegal')
) &&
(!species.tier.startsWith("CAP") || capSearch) &&
@@ -1100,8 +1104,22 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
Object.values(search).reduce(accumulateKeyCount, 0)
));
+ // Prepare move validator and pokemonSource outside the hot loop
+ // but don't prepare them at all if there are no moves to check...
+ // These only ever get accessed if there are moves to filter by.
+ let validator;
+ let pokemonSource;
+ if (Object.values(searches).some(search => Object.keys(search.moves).length !== 0)) {
+ const format = Object.entries(Dex.data.Rulesets).find(([a, f]) => f.mod === usedMod)?.[1].name || 'gen9ou';
+ const ruleTable = Dex.formats.getRuleTable(Dex.formats.get(format));
+ const additionalRules = [];
+ if (nationalSearch && !ruleTable.has('standardnatdex')) additionalRules.push('standardnatdex');
+ if (nationalSearch && ruleTable.valueRules.has('minsourcegen')) additionalRules.push('!!minsourcegen=3');
+ validator = TeamValidator.get(`${format}${additionalRules.length ? `@@@${additionalRules.join(',')}` : ''}`);
+ }
for (const alts of searches) {
if (alts.skip) continue;
+ const altsMoves = Object.keys(alts.moves).map(x => mod.moves.get(x)).filter(move => move.gen <= mod.gen);
for (const mon in dex) {
let matched = false;
if (alts.gens && Object.keys(alts.gens).length) {
@@ -1131,7 +1149,7 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
// LC handling, checks for LC Pokemon in higher tiers that need to be handled separately,
// as well as event-only Pokemon that are not eligible for LC despite being the first stage
let format = Dex.formats.get('gen' + mod.gen + 'lc');
- if (!format.exists) format = Dex.formats.get('gen9lc');
+ if (format.effectType !== 'Format') format = Dex.formats.get('gen9lc');
if (
alts.tiers.LC &&
!dex[mon].prevo &&
@@ -1262,20 +1280,13 @@ function runDexsearch(target: string, cmd: string, canAll: boolean, message: str
}
if (matched) continue;
- const format = Object.entries(Dex.data.Rulesets).find(([a, f]) => f.mod === usedMod);
- const formatStr = format ? format[1].name : 'gen9ou';
- const ruleTable = Dex.formats.getRuleTable(Dex.formats.get(formatStr));
- const additionalRules = [];
- if (nationalSearch && !ruleTable.has('standardnatdex')) additionalRules.push('standardnatdex');
- if (nationalSearch && ruleTable.valueRules.has('minsourcegen')) additionalRules.push('!!minsourcegen=3');
- const validator = TeamValidator.get(`${formatStr}${additionalRules.length ? `@@@${additionalRules.join(',')}` : ''}`);
- const pokemonSource = validator.allSources();
- for (const move of Object.keys(alts.moves).map(x => mod.moves.get(x))) {
- if (move.gen <= mod.gen && !validator.checkCanLearn(move, dex[mon], pokemonSource) === alts.moves[move.id]) {
+ for (const move of altsMoves) {
+ pokemonSource = validator?.allSources();
+ if (validator && !validator.checkCanLearn(move, dex[mon], pokemonSource) === alts.moves[move.id]) {
matched = true;
break;
}
- if (!pokemonSource.size()) break;
+ if (pokemonSource && !pokemonSource.size()) break;
}
if (matched) continue;
@@ -1380,8 +1391,9 @@ function runMovesearch(target: string, cmd: string, canAll: boolean, message: st
const allProperties = ['basePower', 'accuracy', 'priority', 'pp'];
const allFlags = [
'allyanim', 'bypasssub', 'bite', 'bullet', 'cantusetwice', 'charge', 'contact', 'dance', 'defrost', 'distance', 'failcopycat', 'failencore',
- 'failinstruct', 'failmefirst', 'failmimic', 'futuremove', 'gravity', 'heal', 'mirror', 'mustpressure', 'noassist', 'nonsky', 'noparentalbond',
- 'nosleeptalk', 'pledgecombo', 'powder', 'protect', 'pulse', 'punch', 'recharge', 'reflectable', 'slicing', 'snatch', 'sound', 'wind',
+ 'failinstruct', 'failmefirst', 'failmimic', 'futuremove', 'gravity', 'heal', 'metronome', 'mirror', 'mustpressure', 'noassist', 'nonsky',
+ 'noparentalbond', 'nosketch', 'nosleeptalk', 'pledgecombo', 'powder', 'protect', 'pulse', 'punch', 'recharge', 'reflectable', 'slicing',
+ 'snatch', 'sound', 'wind',
// Not flags directly from move data, but still useful to sort by
'highcrit', 'multihit', 'ohko', 'protection', 'secondary',
@@ -1811,7 +1823,8 @@ function runMovesearch(target: string, cmd: string, canAll: boolean, message: st
if (move.gen <= mod.gen) {
if (
(!nationalSearch && move.isNonstandard && move.isNonstandard !== "Gigantamax") ||
- (nationalSearch && move.isNonstandard && !["Gigantamax", "Past"].includes(move.isNonstandard))
+ (nationalSearch && move.isNonstandard && !["Gigantamax", "Past", "Unobtainable"].includes(move.isNonstandard)) ||
+ (move.isMax && mod.gen !== 8)
) {
continue;
} else {
@@ -2564,7 +2577,7 @@ function runLearn(target: string, cmd: string, canAll: boolean, formatid: string
while (targets.length) {
const targetid = toID(targets[0]);
if (targetid === 'pentagon') {
- if (format.exists) {
+ if (format.effectType === 'Format') {
return {error: "'pentagon' can't be used with formats."};
}
minSourceGen = 6;
@@ -2572,7 +2585,7 @@ function runLearn(target: string, cmd: string, canAll: boolean, formatid: string
continue;
}
if (targetid.startsWith('minsourcegen')) {
- if (format.exists) {
+ if (format.effectType === 'Format') {
return {error: "'min source gen' can't be used with formats."};
}
minSourceGen = parseInt(targetid.slice(12));
@@ -2588,14 +2601,15 @@ function runLearn(target: string, cmd: string, canAll: boolean, formatid: string
break;
}
let gen;
- if (!format.exists) {
+ if (format.effectType !== 'Format') {
+ if (!(formatid in Dex.dexes)) {
+ // can happen if you hotpatch formats without hotpatching chat
+ return {error: `"${formatid}" is not a supported format.`};
+ }
const dex = Dex.mod(formatid).includeData();
- // can happen if you hotpatch formats without hotpatching chat
- if (!dex) return {error: `"${formatid}" is not a supported format.`};
-
gen = dex.gen;
formatName = `Gen ${gen}`;
- format = new Dex.Format({mod: formatid});
+ format = new Dex.Format({mod: formatid, effectType: 'Format', exists: true});
const ruleTable = dex.formats.getRuleTable(format);
if (minSourceGen) {
formatName += ` (Min Source Gen = ${minSourceGen})`;
diff --git a/server/chat-plugins/friends.ts b/server/chat-plugins/friends.ts
index 53cfe28aacb5..d8d43c8a2520 100644
--- a/server/chat-plugins/friends.ts
+++ b/server/chat-plugins/friends.ts
@@ -58,7 +58,7 @@ export const Friends = new class {
for (const f of friends) {
const curUser = Users.getExact(f.friend);
if (curUser?.settings.allowFriendNotifications) {
- curUser.send(`|pm|&|${curUser.getIdentity()}|${message}`);
+ curUser.send(`|pm|~|${curUser.getIdentity()}|${message}`);
}
}
}
@@ -145,9 +145,7 @@ export const Friends = new class {
buf += `On an alternate account `;
}
if (login && typeof login === 'number' && !user?.connected) {
- // THIS IS A TERRIBLE HACK BUT IT WORKS OKAY
- const time = Chat.toTimestamp(new Date(Number(login)), {human: true});
- buf += `Last seen: ${time.split(' ').reverse().join(', on ')}`;
+ buf += `Last seen: `;
buf += ` (${Chat.toDurationString(Date.now() - login, {precision: 1})} ago)`;
} else if (typeof login === 'string') {
buf += `${login}`;
diff --git a/server/chat-plugins/github.ts b/server/chat-plugins/github.ts
index 3acd015a17de..3239dbac4d3d 100644
--- a/server/chat-plugins/github.ts
+++ b/server/chat-plugins/github.ts
@@ -238,11 +238,11 @@ export const commands: Chat.ChatCommands = {
},
},
githubhelp: [
- `/github ban [username], [reason] - Bans a GitHub user from having their GitHub actions reported to Dev room. Requires: % @ # &`,
- `/github unban [username] - Unbans a GitHub user from having their GitHub actions reported to Dev room. Requires: % @ # &`,
- `/github bans - Lists all GitHub users that are currently gitbanned. Requires: % @ # &`,
- `/github setname [username], [name] - Sets a GitHub user's name on reported GitHub actions to be [name]. Requires: % @ # &`,
- `/github clearname [username] - Removes a GitHub user's name from the GitHub username list. Requires: % @ # &`,
+ `/github ban [username], [reason] - Bans a GitHub user from having their GitHub actions reported to Dev room. Requires: % @ # ~`,
+ `/github unban [username] - Unbans a GitHub user from having their GitHub actions reported to Dev room. Requires: % @ # ~`,
+ `/github bans - Lists all GitHub users that are currently gitbanned. Requires: % @ # ~`,
+ `/github setname [username], [name] - Sets a GitHub user's name on reported GitHub actions to be [name]. Requires: % @ # ~`,
+ `/github clearname [username] - Removes a GitHub user's name from the GitHub username list. Requires: % @ # ~`,
`/github names - Lists all GitHub usernames that are currently on our list.`,
],
};
diff --git a/server/chat-plugins/hangman.ts b/server/chat-plugins/hangman.ts
index f7d17561bf17..ecc9c3cf2b07 100644
--- a/server/chat-plugins/hangman.ts
+++ b/server/chat-plugins/hangman.ts
@@ -45,6 +45,7 @@ try {
const maxMistakes = 6;
export class Hangman extends Rooms.SimpleRoomGame {
+ override readonly gameid = 'hangman' as ID;
gameNumber: number;
creator: ID;
word: string;
@@ -69,7 +70,6 @@ export class Hangman extends Rooms.SimpleRoomGame {
this.gameNumber = room.nextGameNumber();
- this.gameid = 'hangman' as ID;
this.title = 'Hangman';
this.creator = user.id;
this.word = word;
@@ -341,7 +341,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('HANGMAN');
return this.addModAction(`A game of hangman was started by ${user.name} – use /guess to play!`);
},
- createhelp: ["/hangman create [word], [hint] - Makes a new hangman game. Requires: % @ # &"],
+ createhelp: ["/hangman create [word], [hint] - Makes a new hangman game. Requires: % @ # ~"],
guess(target, room, user) {
const word = this.filter(target);
@@ -363,7 +363,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('ENDHANGMAN');
return this.privateModAction(`The game of hangman was ended by ${user.name}.`);
},
- endhelp: ["/hangman end - Ends the game of hangman before the man is hanged or word is guessed. Requires: % @ # &"],
+ endhelp: ["/hangman end - Ends the game of hangman before the man is hanged or word is guessed. Requires: % @ # ~"],
disable(target, room, user) {
room = this.requireRoom();
@@ -553,20 +553,20 @@ export const commands: Chat.ChatCommands = {
hangmanhelp: [
`/hangman allows users to play the popular game hangman in PS rooms.`,
`Accepts the following commands:`,
- `/hangman create [word], [hint] - Makes a new hangman game. Requires: % @ # &`,
+ `/hangman create [word], [hint] - Makes a new hangman game. Requires: % @ # ~`,
`/hangman guess [letter] - Makes a guess for the letter entered.`,
`/hangman guess [word] - Same as a letter, but guesses an entire word.`,
`/hangman display - Displays the game.`,
- `/hangman end - Ends the game of hangman before the man is hanged or word is guessed. Requires: % @ # &`,
- `/hangman [enable/disable] - Enables or disables hangman from being started in a room. Requires: # &`,
+ `/hangman end - Ends the game of hangman before the man is hanged or word is guessed. Requires: % @ # ~`,
+ `/hangman [enable/disable] - Enables or disables hangman from being started in a room. Requires: # ~`,
`/hangman random [tag]- Runs a random hangman, if the room has any added. `,
- `If a tag is given, randomizes from only terms with those tags. Requires: % @ # &`,
- `/hangman addrandom [word], [...hints] - Adds an entry for [word] with the [hints] provided to the room's hangman pool. Requires: % @ # &`,
+ `If a tag is given, randomizes from only terms with those tags. Requires: % @ # ~`,
+ `/hangman addrandom [word], [...hints] - Adds an entry for [word] with the [hints] provided to the room's hangman pool. Requires: % @ # ~`,
`/hangman removerandom [word][, hints] - Removes data from the hangman entry for [word]. If hints are given, removes only those hints.` +
- ` Otherwise it removes the entire entry. Requires: % @ & #`,
- `/hangman addtag [word], [...tags] - Adds tags to the hangman term matching [word]. Requires: % @ & #`,
+ ` Otherwise it removes the entire entry. Requires: % @ ~ #`,
+ `/hangman addtag [word], [...tags] - Adds tags to the hangman term matching [word]. Requires: % @ ~ #`,
`/hangman untag [term][, ...tags] - Removes tags from the hangman [term]. If tags are given, removes only those tags. Requires: % @ # * `,
- `/hangman terms - Displays all random hangman in a room. Requires: % @ # &`,
+ `/hangman terms - Displays all random hangman in a room. Requires: % @ # ~`,
],
};
diff --git a/server/chat-plugins/helptickets-auto.ts b/server/chat-plugins/helptickets-auto.ts
index 1515c49eec71..9f8e56988ba4 100644
--- a/server/chat-plugins/helptickets-auto.ts
+++ b/server/chat-plugins/helptickets-auto.ts
@@ -122,7 +122,7 @@ export function globalModlog(action: string, user: User | ID | null, note: strin
}
export function addModAction(message: string) {
- Rooms.get('staff')?.add(`|c|&|/log ${message}`).update();
+ Rooms.get('staff')?.add(`|c|~|/log ${message}`).update();
}
export async function getModlog(params: {user?: ID, ip?: string, actions?: string[]}) {
@@ -518,7 +518,7 @@ export async function runPunishments(ticket: TicketState & {text: [string, strin
ticket.recommended = [];
for (const res of result.values()) {
Rooms.get('abuselog')?.add(
- `|c|&|/log [${ticket.type} Monitor] Recommended: ${res.action}: for ${res.user} (${res.reason})`
+ `|c|~|/log [${ticket.type} Monitor] Recommended: ${res.action}: for ${res.user} (${res.reason})`
).update();
ticket.recommended.push(`${res.action}: for ${res.user} (${res.reason})`);
}
@@ -719,11 +719,11 @@ export const commands: Chat.ChatCommands = {
},
},
autohelptickethelp: [
- `/aht addpunishment [args] - Adds a punishment with the given [args]. Requires: whitelist &`,
- `/aht deletepunishment [index] - Deletes the automatic helpticket punishment at [index]. Requires: whitelist &`,
- `/aht viewpunishments - View automatic helpticket punishments. Requires: whitelist &`,
- `/aht togglepunishments [on | off] - Turn [on | off] automatic helpticket punishments. Requires: whitelist &`,
- `/aht stats - View success rates of the Artemis ticket handler. Requires: whitelist &`,
+ `/aht addpunishment [args] - Adds a punishment with the given [args]. Requires: whitelist ~`,
+ `/aht deletepunishment [index] - Deletes the automatic helpticket punishment at [index]. Requires: whitelist ~`,
+ `/aht viewpunishments - View automatic helpticket punishments. Requires: whitelist ~`,
+ `/aht togglepunishments [on | off] - Turn [on | off] automatic helpticket punishments. Requires: whitelist ~`,
+ `/aht stats - View success rates of the Artemis ticket handler. Requires: whitelist ~`,
],
};
@@ -786,7 +786,7 @@ export const pages: Chat.PageTable = {
data += `
${cur.successes} (${percent(cur.successes, cur.total)}%)`;
if (cur.failures) {
data += ` | ${cur.failures} (${percent(cur.failures, cur.total)}%)`;
- } else { // so one cannot confuse dead tickets & false hit tickets
+ } else { // so one cannot confuse dead tickets ~ false hit tickets
data += ' | 0 (0%)';
}
data += '
';
diff --git a/server/chat-plugins/helptickets.ts b/server/chat-plugins/helptickets.ts
index 62200ac7bbb4..57bf3935eb35 100644
--- a/server/chat-plugins/helptickets.ts
+++ b/server/chat-plugins/helptickets.ts
@@ -169,48 +169,41 @@ export function writeStats(line: string) {
const date = new Date();
const month = Chat.toTimestamp(date).split(' ')[0].split('-', 2).join('-');
try {
- FS(`logs/tickets/${month}.tsv`).appendSync(line + '\n');
+ Monitor.logPath(`tickets/${month}.tsv`).appendSync(line + '\n');
} catch (e: any) {
if (e.code !== 'ENOENT') throw e;
}
}
export class HelpTicket extends Rooms.SimpleRoomGame {
- room: ChatRoom;
+ override readonly gameid = "helpticket" as ID;
+ override readonly allowRenames = true;
+ override room: ChatRoom;
ticket: TicketState;
claimQueue: string[];
- involvedStaff: Set;
+ involvedStaff = new Set();
createTime: number;
activationTime: number;
- emptyRoom: boolean;
- firstClaimTime: number;
- unclaimedTime: number;
+ emptyRoom = false;
+ firstClaimTime = 0;
+ unclaimedTime = 0;
lastUnclaimedStart: number;
- closeTime: number;
- resolution: 'unknown' | 'dead' | 'unresolved' | 'resolved';
- result: TicketResult | null;
+ closeTime = 0;
+ resolution: 'unknown' | 'dead' | 'unresolved' | 'resolved' = 'unknown';
+ result: TicketResult | null = null;
constructor(room: ChatRoom, ticket: TicketState) {
super(room);
this.room = room;
this.room.settings.language = Users.get(ticket.creator)?.language || 'english' as ID;
this.title = `Help Ticket - ${ticket.type}`;
- this.gameid = "helpticket" as ID;
- this.allowRenames = true;
this.ticket = ticket;
this.claimQueue = [];
/* Stats */
- this.involvedStaff = new Set();
this.createTime = Date.now();
this.activationTime = (ticket.active ? this.createTime : 0);
- this.emptyRoom = false;
- this.firstClaimTime = 0;
- this.unclaimedTime = 0;
this.lastUnclaimedStart = (ticket.active ? this.createTime : 0);
- this.closeTime = 0;
- this.resolution = 'unknown';
- this.result = null;
}
onJoin(user: User, connection: Connection) {
@@ -293,8 +286,8 @@ export class HelpTicket extends Rooms.SimpleRoomGame {
if (
(!user.isStaff || this.ticket.userid === user.id) && (message.length < 3 || blockedMessages.includes(toID(message)))
) {
- this.room.add(`|c|&Staff|${this.room.tr`Hello! The global staff team would be happy to help you, but you need to explain what's going on first.`}`);
- this.room.add(`|c|&Staff|${this.room.tr`Please post the information I requested above so a global staff member can come to help.`}`);
+ this.room.add(`|c|~Staff|${this.room.tr`Hello! The global staff team would be happy to help you, but you need to explain what's going on first.`}`);
+ this.room.add(`|c|~Staff|${this.room.tr`Please post the information I requested above so a global staff member can come to help.`}`);
this.room.update();
return false;
}
@@ -303,11 +296,11 @@ export class HelpTicket extends Rooms.SimpleRoomGame {
this.activationTime = Date.now();
if (!this.ticket.claimed) this.lastUnclaimedStart = Date.now();
notifyStaff();
- this.room.add(`|c|&Staff|${this.room.tr`Thank you for the information, global staff will be here shortly. Please stay in the room.`}`).update();
+ this.room.add(`|c|~Staff|${this.room.tr`Thank you for the information, global staff will be here shortly. Please stay in the room.`}`).update();
switch (this.ticket.type) {
case 'PM Harassment':
this.room.add(
- `|c|&Staff|Global staff might take more than a few minutes to handle your report. ` +
+ `|c|~Staff|Global staff might take more than a few minutes to handle your report. ` +
`If you are being disturbed by another user, you can type \`\`/ignore [username]\`\` in any chat to ignore their messages immediately`
).update();
break;
@@ -318,7 +311,7 @@ export class HelpTicket extends Rooms.SimpleRoomGame {
forfeit(user: User) {
if (!(user.id in this.playerTable)) return;
- this.removePlayer(user);
+ this.removePlayer(this.playerTable[user.id]);
if (!this.ticket.open) return;
this.room.modlog({action: 'TICKETABANDON', isGlobal: false, loggedBy: user.id});
this.addText(`${user.name} is no longer interested in this ticket.`, user);
@@ -477,15 +470,11 @@ export class HelpTicket extends Rooms.SimpleRoomGame {
}
this.room.game = null;
- // @ts-ignore
- this.room = null;
- for (const player of this.players) {
- player.destroy();
- }
- // @ts-ignore
- this.players = null;
- // @ts-ignore
- this.playerTable = null;
+ (this.room as any) = null;
+ this.setEnded();
+ for (const player of this.players) player.destroy();
+ (this.players as any) = null;
+ (this.playerTable as any) = null;
}
onChatMessage(message: string, user: User) {
HelpTicket.uploadReplaysFrom(message, user, user.connections[0]);
@@ -517,7 +506,7 @@ export class HelpTicket extends Rooms.SimpleRoomGame {
recommended: ticket.recommended,
};
const date = Chat.toTimestamp(new Date()).split(' ')[0];
- void FS(`logs/tickets/${date.slice(0, -3)}.jsonl`).append(JSON.stringify(entry) + '\n');
+ void Monitor.logPath(`tickets/${date.slice(0, -3)}.jsonl`).append(JSON.stringify(entry) + '\n');
}
/**
@@ -543,7 +532,7 @@ export class HelpTicket extends Rooms.SimpleRoomGame {
let lines;
try {
lines = await ProcessManager.exec([
- `rg`, FS(`logs/tickets/${date ? `${date}.jsonl` : ''}`).path, ...args,
+ `rg`, Monitor.logPath(`tickets/${date ? `${date}.jsonl` : ''}`).path, ...args,
]);
} catch (e: any) {
if (e.message.includes('No such file or directory')) {
@@ -563,7 +552,7 @@ export class HelpTicket extends Rooms.SimpleRoomGame {
}
} else {
if (!date) throw new Chat.ErrorMessage(`Specify a month.`);
- const path = FS(`logs/tickets/${date}.jsonl`);
+ const path = Monitor.logPath(`tickets/${date}.jsonl`);
if (!path.existsSync()) {
throw new Chat.ErrorMessage(`There are no logs for the month "${date}".`);
}
@@ -714,12 +703,12 @@ export class HelpTicket extends Rooms.SimpleRoomGame {
const {result, time, by, seen, note} = ticket.resolved as ResolvedTicketInfo;
if (seen) return;
const timeString = (Date.now() - time) > 1000 ? `, ${Chat.toDurationString(Date.now() - time)} ago.` : '.';
- user.send(`|pm|&Staff|${user.getIdentity()}|Hello! Your report was resolved by ${by}${timeString}`);
+ user.send(`|pm|~Staff|${user.getIdentity()}|Hello! Your report was resolved by ${by}${timeString}`);
if (result?.trim()) {
- user.send(`|pm|&Staff|${user.getIdentity()}|The result was "${result}"`);
+ user.send(`|pm|~Staff|${user.getIdentity()}|The result was "${result}"`);
}
if (note?.trim()) {
- user.send(`|pm|&Staff|${user.getIdentity()}|/raw ${note}`);
+ user.send(`|pm|~Staff|${user.getIdentity()}|/raw ${note}`);
}
tickets[userid].resolved!.seen = true;
writeTickets();
@@ -770,7 +759,7 @@ function notifyUnclaimedTicket(hasAssistRequest: boolean) {
if (ticket.needsDelayWarning && !ticket.claimed && delayWarnings[ticket.type]) {
ticketRoom.add(
- `|c|&Staff|${ticketRoom.tr(delayWarningPreamble)}${ticketRoom.tr(delayWarnings[ticket.type])}`
+ `|c|~Staff|${ticketRoom.tr(delayWarningPreamble)}${ticketRoom.tr(delayWarnings[ticket.type])}`
).update();
ticket.needsDelayWarning = false;
}
@@ -920,13 +909,9 @@ export async function getOpponent(link: string, submitter: ID): Promise {
const battleRoom = Rooms.get(battle);
const seenPokemon = new Set();
- if (battleRoom && battleRoom.type !== 'chat') {
- const playerTable: Partial = {};
- const monTable: BattleInfo['pokemon'] = {};
- // i kinda hate this, but this will always be accurate to the battle players.
- // consulting room.battle.playerTable might be invalid (if battle is over), etc.
- for (const line of battleRoom.log.log) {
- // |switch|p2a: badnite|Dragonite, M|323/323
- if (line.startsWith('|switch|')) { // name cannot have been seen until it switches in
+ let data: {log: string, players: string[]} | null = null;
+ // try battle room first
+ if (battleRoom && battleRoom.type !== 'chat' && battleRoom.battle) {
+ data = {
+ log: battleRoom.log.log.join('\n'),
+ players: battleRoom.battle.players.map(x => x.id),
+ };
+ } else { // fall back to replay
+ if (noReplay) return null;
+ battle = battle.replace(`battle-`, ''); // don't wanna strip passwords
+
+ if (Rooms.Replays.db) { // direct conn exists, use it
+ if (battle.endsWith('pw')) {
+ battle = battle.slice(0, battle.lastIndexOf("-", battle.length - 2));
+ }
+ data = await Rooms.Replays.get(battle);
+ } else {
+ // call out to API
+ try {
+ const raw = await Net(`https://${Config.routes.replays}/${battle}.json`).get();
+ data = JSON.parse(raw);
+ } catch {}
+ }
+ }
+
+ // parse
+ if (data?.log?.length) {
+ const log = data.log.split('\n');
+ const players: BattleInfo['players'] = {} as any;
+ for (const [i, id] of data.players.entries()) {
+ players[`p${i + 1}` as SideID] = toID(id);
+ }
+ const chat = [];
+ const mons: BattleInfo['pokemon'] = {};
+ for (const line of log) {
+ if (line.startsWith('|c|')) {
+ chat.push(line);
+ } else if (line.startsWith('|switch|')) {
const [, , playerWithNick, speciesWithGender] = line.split('|');
- let [slot, name] = playerWithNick.split(':');
const species = speciesWithGender.split(',')[0].trim(); // should always exist
+ let [slot, name] = playerWithNick.split(':');
slot = slot.slice(0, -1); // p2a -> p2
- if (!monTable[slot]) monTable[slot] = [];
- const identifier = `${name || ""}-${species}`;
- if (seenPokemon.has(identifier)) continue;
- // technically, if several mons have the same name and species, this will ignore them.
- // BUT if they have the same name and species we only need to see it once
- // so it doesn't matter
- seenPokemon.add(identifier);
+ // safe to not check here bc this should always exist in the players table.
+ // if it doesn't, there's a problem
+ const id = players[slot as SideID] as string;
+ if (!mons[id]) mons[id] = [];
name = name?.trim() || "";
- monTable[slot].push({
- species,
- name: species === name ? undefined : name,
+ const setId = `${name || ""}-${species}`;
+ if (seenPokemon.has(setId)) continue;
+ seenPokemon.add(setId);
+ mons[id].push({
+ species, // don't want to see a name if it's the same as the species
+ name: name === species ? undefined : name,
});
}
- if (line.startsWith('|player|')) {
- // |player|p1|Mia|miapi.png|1000
- const [, , playerSlot, name] = line.split('|');
- playerTable[playerSlot as SideID] = toID(name);
- }
- for (const k in monTable) {
- // SideID => userID, cannot do conversion at time of collection
- // because the playerID => userid mapping might not be there.
- // strictly, yes it will, but this is for maximum safety.
- const userid = playerTable[k as SideID];
- if (userid) {
- monTable[userid] = monTable[k];
- delete monTable[k];
- }
- }
}
return {
- log: battleRoom.log.log.filter(k => k.startsWith('|c|')),
- title: battleRoom.title,
- url: `/${battle}`,
- players: playerTable as BattleInfo['players'],
- pokemon: monTable,
+ log: chat,
+ title: `${players.p1} vs ${players.p2}`,
+ url: `https://${Config.routes.replays}/${battle}`,
+ players,
+ pokemon: mons,
};
}
- if (noReplay) return null;
- battle = battle.replace(`battle-`, ''); // don't wanna strip passwords
- try {
- const raw = await Net(`https://${Config.routes.replays}/${battle}.json`).get();
- const data = JSON.parse(raw);
- if (data.log?.length) {
- const log = data.log.split('\n');
- const players = {
- p1: toID(data.p1),
- p2: toID(data.p2),
- p3: toID(data.p3),
- p4: toID(data.p4),
- };
- const chat = [];
- const mons: BattleInfo['pokemon'] = {};
- for (const line of log) {
- if (line.startsWith('|c|')) {
- chat.push(line);
- } else if (line.startsWith('|switch|')) {
- const [, , playerWithNick, speciesWithGender] = line.split('|');
- const species = speciesWithGender.split(',')[0].trim(); // should always exist
- let [slot, name] = playerWithNick.split(':');
- slot = slot.slice(0, -1); // p2a -> p2
- // safe to not check here bc this should always exist in the players table.
- // if it doesn't, there's a problem
- const id = players[slot as SideID];
- if (!mons[id]) mons[id] = [];
- name = name?.trim() || "";
- const setId = `${name || ""}-${species}`;
- if (seenPokemon.has(setId)) continue;
- seenPokemon.add(setId);
- mons[id].push({
- species, // don't want to see a name if it's the same as the species
- name: name === species ? undefined : name,
- });
- }
- }
- return {
- log: chat,
- title: `${data.p1} vs ${data.p2}`,
- url: `https://${Config.routes.replays}/${battle}`,
- players,
- pokemon: mons,
- };
- }
- } catch {}
return null;
}
@@ -1205,7 +1159,7 @@ export const textTickets: {[k: string]: TextTicketInfo} = {
];
const tar = toID(ticket.text[0]); // should always be the reported userid
const name = Utils.escapeHTML(Users.getExact(tar)?.name || tar);
- buf += ` Reported user: ${name} `;
+ buf += ` Reported user:${name} `;
buf += ` `;
buf += ``;
buf += `Punish ${name} (reported user)`;
@@ -1350,30 +1304,28 @@ export const textTickets: {[k: string]: TextTicketInfo} = {
buf += `
`;
return buf;
},
onSubmit(ticket, text, submitter, conn) {
@@ -1445,6 +1397,12 @@ export const textTickets: {[k: string]: TextTicketInfo} = {
if (!(user.locked || user.namelocked || user.semilocked)) {
return ['You are not punished.'];
}
+ if (!user.registered) {
+ return [
+ "Because this account isn't registered (with a password), we cannot verify your identity.",
+ "Please come back with a different account you've registered in the past.",
+ ];
+ }
const punishments = Punishments.search(user.id);
const userids = [user.id, ...user.previousIDs];
@@ -1726,11 +1684,8 @@ export const pages: Chat.PageTable = {
buf += ``;
break;
case 'password':
- buf += `
Password resets are currently closed to regular users due to policy revamp and administrative backlog.
`;
- buf += `
Users with a public room auth (Voice or higher) and Smogon badgeholders can still get their passwords reset `;
- buf += `(see this post for more informations).
`;
- buf += `
To those who do not belong to those groups, we apologize for the temporary inconvenience.
`;
- buf += `
Thanks for your understanding!
`;
+ buf += `
If you need your Pokémon Showdown password reset, you can fill out a Password Reset Form.
`;
+ buf += `
You will need to make a Smogon account to be able to fill out a form.`;
break;
case 'roomhelp':
buf += `
${this.tr`If you are a room driver or up in a public room, and you need help watching the chat, one or more global staff members would be happy to assist you!`}
`;
// Calculate next/previous month for stats and validate stats exist for the month
@@ -2118,13 +2081,13 @@ export const pages: Chat.PageTable = {
const nextString = Chat.toTimestamp(nextDate).split(' ')[0].split('-', 2).join('-');
let buttonBar = '';
- if (FS(`logs/tickets/${prevString}.tsv`).readIfExistsSync()) {
+ if (Monitor.logPath(`tickets/${prevString}.tsv`).readIfExistsSync()) {
buttonBar += `< ${this.tr`Previous Month`}`;
} else {
buttonBar += `< ${this.tr`Previous Month`}`;
}
buttonBar += `${this.tr`Ticket Stats`}${this.tr`Staff Stats`}`;
- if (FS(`logs/tickets/${nextString}.tsv`).readIfExistsSync()) {
+ if (Monitor.logPath(`tickets/${nextString}.tsv`).readIfExistsSync()) {
buttonBar += `${this.tr`Next Month`} >`;
} else {
buttonBar += `${this.tr`Next Month`} >`;
@@ -2380,7 +2343,7 @@ export const commands: Chat.ChatCommands = {
const validation = await textTicket.checker?.(text, contextString || '', ticket.type, user, reportTarget);
if (Array.isArray(validation) && validation.length) {
this.parse(`/join view-${pageId}`);
- return this.popupReply(`|html|` + validation.join('||'));
+ return this.popupReply(`|html|` + validation.join(' '));
}
ticket.text = [text, contextString];
ticket.active = true;
@@ -2509,7 +2472,7 @@ export const commands: Chat.ChatCommands = {
break;
}
if (context) {
- helpRoom.add(`|c|&Staff|${this.tr(context)}`);
+ helpRoom.add(`|c|~Staff|${this.tr(context)}`);
helpRoom.update();
}
if (pmRequestButton) {
@@ -2578,7 +2541,7 @@ export const commands: Chat.ChatCommands = {
this.checkCan('lock');
return this.parse('/join view-help-tickets');
},
- listhelp: [`/helpticket list - Lists all tickets. Requires: % @ &`],
+ listhelp: [`/helpticket list - Lists all tickets. Requires: % @ ~`],
inapnames: 'massview',
usernames: 'massview',
@@ -2597,7 +2560,7 @@ export const commands: Chat.ChatCommands = {
this.checkCan('lock');
return this.parse('/join view-help-stats');
},
- statshelp: [`/helpticket stats - List the stats for help tickets. Requires: % @ &`],
+ statshelp: [`/helpticket stats - List the stats for help tickets. Requires: % @ ~`],
note: 'addnote',
addnote(target, room, user) {
@@ -2621,10 +2584,8 @@ export const commands: Chat.ChatCommands = {
this.globalModlog(`HELPTICKET NOTE`, ticket.userid, note);
},
addnotehelp: [
- `/helpticket note [ticket userid], [note] - Adds a note to the [ticket], to be displayed in the hover text.`,
- `Requires: % @ &`,
+ `/helpticket note [ticket userid], [note] - Adds a note to the [ticket], to be displayed in the hover text. Requires: % @ ~`,
],
-
removenote(target, room, user) {
this.checkCan('lock');
target = target.trim();
@@ -2654,7 +2615,7 @@ export const commands: Chat.ChatCommands = {
removenotehelp: [
`/helpticket removenote [ticket userid], [staff] - Removes a note from the [ticket].`,
`If a [staff] userid is given, removes the note from that staff member (defaults to your userid).`,
- `Requires: % @ &`,
+ `Requires: % @ ~`,
],
ar: 'addresponse',
@@ -2685,7 +2646,7 @@ export const commands: Chat.ChatCommands = {
},
addresponsehelp: [
`/helpticket addresponse [type], [name], [response] - Adds a [response] button to the given ticket [type] with the given [name].`,
- `Requires: % @ &`,
+ `Requires: % @ ~`,
],
rr: 'removeresponse',
@@ -2710,7 +2671,7 @@ export const commands: Chat.ChatCommands = {
},
removeresponsehelp: [
`/helpticket removeresponse [type], [name] - Removes the response button with the given [name] from the given ticket [type].`,
- `Requires: % @ &`,
+ `Requires: % @ ~`,
],
lr: 'listresponses',
@@ -2736,7 +2697,7 @@ export const commands: Chat.ChatCommands = {
},
listresponseshelp: [
`/helpticket listresponses [optional type] - List current response buttons for text tickets. `,
- `If a [type] is given, lists responses only for that type. Requires: % @ &`,
+ `If a [type] is given, lists responses only for that type. Requires: % @ ~`,
],
close(target, room, user) {
@@ -2771,7 +2732,7 @@ export const commands: Chat.ChatCommands = {
ticket.claimed = user.name;
this.sendReply(`You closed ${ticket.creator}'s ticket.`);
},
- closehelp: [`/helpticket close [user] - Closes an open ticket. Requires: % @ &`],
+ closehelp: [`/helpticket close [user] - Closes an open ticket. Requires: % @ ~`],
tb: 'ban',
ticketban: 'ban',
@@ -2870,7 +2831,7 @@ export const commands: Chat.ChatCommands = {
notifyStaff();
return true;
},
- banhelp: [`/helpticket ban [user], (reason) - Bans a user from creating tickets for 2 days. Requires: % @ &`],
+ banhelp: [`/helpticket ban [user], (reason) - Bans a user from creating tickets for 2 days. Requires: % @ ~`],
unticketban: 'unban',
unban(target, room, user) {
@@ -2889,7 +2850,7 @@ export const commands: Chat.ChatCommands = {
this.globalModlog("UNTICKETBAN", toID(target));
Users.get(target)?.popup(`${user.name} has ticket unbanned you.`);
},
- unbanhelp: [`/helpticket unban [user] - Ticket unbans a user. Requires: % @ &`],
+ unbanhelp: [`/helpticket unban [user] - Ticket unbans a user. Requires: % @ ~`],
ignore(target, room, user) {
this.checkCan('lock');
@@ -2900,7 +2861,7 @@ export const commands: Chat.ChatCommands = {
user.update();
this.sendReply(this.tr`You are now ignoring help ticket notifications.`);
},
- ignorehelp: [`/helpticket ignore - Ignore notifications for unclaimed help tickets. Requires: % @ &`],
+ ignorehelp: [`/helpticket ignore - Ignore notifications for unclaimed help tickets. Requires: % @ ~`],
unignore(target, room, user) {
this.checkCan('lock');
@@ -2911,7 +2872,7 @@ export const commands: Chat.ChatCommands = {
user.update();
this.sendReply(this.tr`You will now receive help ticket notifications.`);
},
- unignorehelp: [`/helpticket unignore - Stop ignoring notifications for help tickets. Requires: % @ &`],
+ unignorehelp: [`/helpticket unignore - Stop ignoring notifications for help tickets. Requires: % @ ~`],
delete(target, room, user) {
// This is a utility only to be used if something goes wrong
@@ -2929,7 +2890,7 @@ export const commands: Chat.ChatCommands = {
}
this.sendReply(this.tr`You deleted ${target}'s ticket.`);
},
- deletehelp: [`/helpticket delete [user] - Deletes a user's ticket. Requires: &`],
+ deletehelp: [`/helpticket delete [user] - Deletes a user's ticket. Requires: ~`],
logs(target, room, user) {
this.checkCan('lock');
@@ -2941,7 +2902,7 @@ export const commands: Chat.ChatCommands = {
logshelp: [
`/helpticket logs [userid][, month] - View logs of the [userid]'s text tickets. `,
`If a [month] is given, searches only that month.`,
- `Requires: % @ &`,
+ `Requires: % @ ~`,
],
async private(target, room, user) {
@@ -2953,20 +2914,20 @@ export const commands: Chat.ChatCommands = {
if (!/[0-9]{4}-[0-9]{2}-[0-9]{2}/.test(date)) {
return this.errorReply(`Invalid date (must be YYYY-MM-DD format).`);
}
- const logPath = FS(`logs/chat/help-${userid}/${date.slice(0, -3)}/${date}.txt`);
+ const logPath = Monitor.logPath(`chat/help-${userid}/${date.slice(0, -3)}/${date}.txt`);
if (!(await logPath.exists())) {
return this.errorReply(`There are no logs for tickets from '${userid}' on the date '${date}'.`);
}
- if (!(await FS(`logs/private/${userid}`).exists())) {
- await FS(`logs/private/${userid}`).mkdirp();
+ if (!(await Monitor.logPath(`private/${userid}`).exists())) {
+ await Monitor.logPath(`private/${userid}`).mkdirp();
}
- await logPath.copyFile(`logs/private/${userid}/${date}.txt`);
+ await logPath.copyFile(Monitor.logPath(`private/${userid}/${date}.txt`).path);
await logPath.write(''); // empty out the logfile
this.globalModlog(`HELPTICKET PRIVATELOGS`, null, `${userid} (${date})`);
this.privateGlobalModAction(`${user.name} set the ticket logs for '${userid}' on '${date}' to be private.`);
},
privatehelp: [
- `/helpticket private [user], [date] - Makes the ticket logs for a user on a date private to upperstaff. Requires: &`,
+ `/helpticket private [user], [date] - Makes the ticket logs for a user on a date private to upperstaff. Requires: ~`,
],
async public(target, room, user) {
this.checkCan('bypassall');
@@ -2977,21 +2938,21 @@ export const commands: Chat.ChatCommands = {
if (!/[0-9]{4}-[0-9]{2}-[0-9]{2}/.test(date)) {
return this.errorReply(`Invalid date (must be YYYY-MM-DD format).`);
}
- const logPath = FS(`logs/private/${userid}/${date}.txt`);
+ const logPath = Monitor.logPath(`private/${userid}/${date}.txt`);
if (!(await logPath.exists())) {
return this.errorReply(`There are no logs for tickets from '${userid}' on the date '${date}'.`);
}
- const monthPath = FS(`logs/chat/help-${userid}/${date.slice(0, -3)}`);
+ const monthPath = Monitor.logPath(`chat/help-${userid}/${date.slice(0, -3)}`);
if (!(await monthPath.exists())) {
await monthPath.mkdirp();
}
- await logPath.copyFile(`logs/chat/help-${userid}/${date.slice(0, -3)}/${date}.txt`);
+ await logPath.copyFile(Monitor.logPath(`chat/help-${userid}/${date.slice(0, -3)}/${date}.txt`).path);
await logPath.unlinkIfExists();
this.globalModlog(`HELPTICKET PUBLICLOGS`, null, `${userid} (${date})`);
this.privateGlobalModAction(`${user.name} set the ticket logs for '${userid}' on '${date}' to be public.`);
},
publichelp: [
- `/helpticket public [user], [date] - Makes the ticket logs for the [user] on the [date] public to staff. Requires: &`,
+ `/helpticket public [user], [date] - Makes the ticket logs for the [user] on the [date] public to staff. Requires: ~`,
],
},
@@ -3003,18 +2964,17 @@ export const commands: Chat.ChatCommands = {
helptickethelp: [
`/helpticket create - Creates a new ticket, requesting help from global staff.`,
- `/helpticket list - Lists all tickets. Requires: % @ &`,
- `/helpticket close [user] - Closes an open ticket. Requires: % @ &`,
- `/helpticket ban [user], (reason) - Bans a user from creating tickets for 2 days. Requires: % @ &`,
- `/helpticket unban [user] - Ticket unbans a user. Requires: % @ &`,
- `/helpticket ignore - Ignore notifications for unclaimed help tickets. Requires: % @ &`,
- `/helpticket unignore - Stop ignoring notifications for help tickets. Requires: % @ &`,
- `/helpticket delete [user] - Deletes a user's ticket. Requires: &`,
- `/helpticket logs [userid][, month] - View logs of the [userid]'s text tickets. Requires: % @ &`,
- `/helpticket note [ticket userid], [note] - Adds a note to the [ticket], to be displayed in the hover text. `,
- `Requires: % @ &`,
- `/helpticket private [user], [date] - Makes the ticket logs for a user on a date private to upperstaff. Requires: &`,
- `/helpticket public [user], [date] - Makes the ticket logs for the [user] on the [date] public to staff. Requires: &`,
+ `/helpticket list - Lists all tickets. Requires: % @ ~`,
+ `/helpticket close [user] - Closes an open ticket. Requires: % @ ~`,
+ `/helpticket ban [user], (reason) - Bans a user from creating tickets for 2 days. Requires: % @ ~`,
+ `/helpticket unban [user] - Ticket unbans a user. Requires: % @ ~`,
+ `/helpticket ignore - Ignore notifications for unclaimed help tickets. Requires: % @ ~`,
+ `/helpticket unignore - Stop ignoring notifications for help tickets. Requires: % @ ~`,
+ `/helpticket delete [user] - Deletes a user's ticket. Requires: ~`,
+ `/helpticket logs [userid][, month] - View logs of the [userid]'s text tickets. Requires: % @ ~`,
+ `/helpticket note [ticket userid], [note] - Adds a note to the [ticket], to be displayed in the hover text. Requires: % @ ~`,
+ `/helpticket private [user], [date] - Makes the ticket logs for a user on a date private to upperstaff. Requires: ~`,
+ `/helpticket public [user], [date] - Makes the ticket logs for the [user] on the [date] public to staff. Requires: ~`,
],
};
diff --git a/server/chat-plugins/hosts.ts b/server/chat-plugins/hosts.ts
index da60a6a6aa6e..bd4ac11fa54c 100644
--- a/server/chat-plugins/hosts.ts
+++ b/server/chat-plugins/hosts.ts
@@ -231,8 +231,8 @@ export const commands: Chat.ChatCommands = {
return this.parse(`/join view-ranges-${type}`);
},
viewhelp: [
- `/ipranges view - View the list of all IP ranges. Requires: hosts manager @ &`,
- `/ipranges view [type] - View the list of a particular type of IP range ('residential', 'mobile', or 'proxy'). Requires: hosts manager @ &`,
+ `/ipranges view - View the list of all IP ranges. Requires: hosts manager @ ~`,
+ `/ipranges view [type] - View the list of a particular type of IP range ('residential', 'mobile', or 'proxy'). Requires: hosts manager @ ~`,
],
// Originally by Zarel
@@ -269,8 +269,8 @@ export const commands: Chat.ChatCommands = {
this.globalModlog('IPRANGE ADD', null, formatRange(range, true));
},
addhelp: [
- `/ipranges add [type], [low]-[high], [host] - Adds an IP range. Requires: hosts manager &`,
- `/ipranges widen [type], [low]-[high], [host] - Adds an IP range, allowing a new range to completely cover an old range. Requires: hosts manager &`,
+ `/ipranges add [type], [low]-[high], [host] - Adds an IP range. Requires: hosts manager ~`,
+ `/ipranges widen [type], [low]-[high], [host] - Adds an IP range, allowing a new range to completely cover an old range. Requires: hosts manager ~`,
`For example: /ipranges add proxy, 5.152.192.0 - 5.152.223.255, redstation.com`,
`Get datacenter info from whois; [low], [high] are the range in the last inetnum; [type] is one of res, proxy, or mobile.`,
],
@@ -289,7 +289,7 @@ export const commands: Chat.ChatCommands = {
this.globalModlog('IPRANGE REMOVE', null, formatRange(range, true));
},
removehelp: [
- `/ipranges remove [low IP]-[high IP] - Removes an IP range. Requires: hosts manager &`,
+ `/ipranges remove [low IP]-[high IP] - Removes an IP range. Requires: hosts manager ~`,
`Example: /ipranges remove 5.152.192.0-5.152.223.255`,
],
@@ -316,20 +316,20 @@ export const commands: Chat.ChatCommands = {
this.globalModlog('IPRANGE RENAME', null, `IP range ${formatRange(toRename, true)} to ${range.host}`);
},
renamehelp: [
- `/ipranges rename [type], [low IP]-[high IP], [host] - Changes the host an IP range resolves to. Requires: hosts manager &`,
+ `/ipranges rename [type], [low IP]-[high IP], [host] - Changes the host an IP range resolves to. Requires: hosts manager ~`,
],
},
iprangeshelp() {
const help = [
- `/ipranges view [type]: view the list of a particular type of IP range (residential, mobile, or proxy). Requires: hosts manager @ &`,
- `/ipranges add [type], [low IP]-[high IP], [host]: add IP ranges (can be multiline). Requires: hosts manager &/ipranges view: view the list of all IP ranges. Requires: hosts manager @ &`,
- `/ipranges widen [type], [low IP]-[high IP], [host]: add IP ranges, allowing a new range to completely cover an old range. Requires: hosts manager &`,
+ `/ipranges view [type]: view the list of a particular type of IP range (residential, mobile, or proxy). Requires: hosts manager @ ~`,
+ `/ipranges add [type], [low IP]-[high IP], [host]: add IP ranges (can be multiline). Requires: hosts manager ~/ipranges view: view the list of all IP ranges. Requires: hosts manager @ ~`,
+ `/ipranges widen [type], [low IP]-[high IP], [host]: add IP ranges, allowing a new range to completely cover an old range. Requires: hosts manager ~`,
`For example: /ipranges add proxy, 5.152.192.0-5.152.223.255, redstation.com.`,
`Get datacenter info from /whois; [low IP], [high IP] are the range in the last inetnum.`,
- `/ipranges remove [low IP]-[high IP]: remove IP range(s). Can be multiline. Requires: hosts manager &`,
+ `/ipranges remove [low IP]-[high IP]: remove IP range(s). Can be multiline. Requires: hosts manager ~`,
`For example: /ipranges remove 5.152.192.0, 5.152.223.255.`,
- `/ipranges rename [type], [low IP]-[high IP], [host]: changes the host an IP range resolves to. Requires: hosts manager &`,
+ `/ipranges rename [type], [low IP]-[high IP], [host]: changes the host an IP range resolves to. Requires: hosts manager ~`,
];
return this.sendReply(`|html|${help.join(' ')}`);
},
@@ -343,8 +343,8 @@ export const commands: Chat.ChatCommands = {
return this.parse(`/join view-hosts-${type}`);
},
viewhostshelp: [
- `/viewhosts - View the list of hosts. Requires: hosts manager @ &`,
- `/viewhosts [type] - View the list of a particular type of host. Requires: hosts manager @ &`,
+ `/viewhosts - View the list of hosts. Requires: hosts manager @ ~`,
+ `/viewhosts [type] - View the list of a particular type of host. Requires: hosts manager @ ~`,
`Host types are: 'all', 'residential', 'mobile', and 'ranges'.`,
],
@@ -421,8 +421,8 @@ export const commands: Chat.ChatCommands = {
this.globalModlog(removing ? 'REMOVEHOSTS' : 'ADDHOSTS', null, `${type}: ${hosts.join(', ')}`);
},
addhostshelp: [
- `/addhosts [category], host1, host2, ... - Adds hosts to the given category. Requires: hosts manager &`,
- `/removehosts [category], host1, host2, ... - Removes hosts from the given category. Requires: hosts manager &`,
+ `/addhosts [category], host1, host2, ... - Adds hosts to the given category. Requires: hosts manager ~`,
+ `/removehosts [category], host1, host2, ... - Removes hosts from the given category. Requires: hosts manager ~`,
`Categories are: 'openproxy' (which takes IP addresses, not hosts), 'proxy', 'residential', and 'mobile'.`,
],
@@ -431,7 +431,7 @@ export const commands: Chat.ChatCommands = {
return this.parse('/join view-proxies');
},
viewproxieshelp: [
- `/viewproxies - View the list of proxies. Requires: hosts manager @ &`,
+ `/viewproxies - View the list of proxies. Requires: hosts manager @ ~`,
],
markshared(target, room, user) {
@@ -471,7 +471,7 @@ export const commands: Chat.ChatCommands = {
},
marksharedhelp: [
`/markshared [IP], [owner/organization of IP] - Marks an IP address as shared.`,
- `Note: the owner/organization (i.e., University of Minnesota) of the shared IP is required. Requires @ &`,
+ `Note: the owner/organization (i.e., University of Minnesota) of the shared IP is required. Requires @ ~`,
],
unmarkshared(target, room, user) {
@@ -501,7 +501,7 @@ export const commands: Chat.ChatCommands = {
this.privateGlobalModAction(`The IP '${target}' was unmarked as shared by ${user.name}.`);
this.globalModlog('UNSHAREDIP', null, null, target);
},
- unmarksharedhelp: [`/unmarkshared [IP] - Unmarks a shared IP address. Requires @ &`],
+ unmarksharedhelp: [`/unmarkshared [IP] - Unmarks a shared IP address. Requires @ ~`],
marksharedblacklist: 'nomarkshared',
marksharedbl: 'nomarkshared',
@@ -573,10 +573,10 @@ export const commands: Chat.ChatCommands = {
},
},
nomarksharedhelp: [
- `/nomarkshared add [IP], [reason] - Prevents an IP from being marked as shared until it's removed from this list. Requires &`,
+ `/nomarkshared add [IP], [reason] - Prevents an IP from being marked as shared until it's removed from this list. Requires ~`,
`Note: Reasons are required.`,
- `/nomarkshared remove [IP] - Removes an IP from the nomarkshared list. Requires &`,
- `/nomarkshared view - Lists all IPs prevented from being marked as shared. Requires @ &`,
+ `/nomarkshared remove [IP] - Removes an IP from the nomarkshared list. Requires ~`,
+ `/nomarkshared view - Lists all IPs prevented from being marked as shared. Requires @ ~`,
],
sharedips: 'viewsharedips',
@@ -584,6 +584,6 @@ export const commands: Chat.ChatCommands = {
return this.parse('/join view-sharedips');
},
viewsharedipshelp: [
- `/viewsharedips — Lists IP addresses marked as shared. Requires: hosts manager @ &`,
+ `/viewsharedips — Lists IP addresses marked as shared. Requires: hosts manager @ ~`,
],
};
diff --git a/server/chat-plugins/mafia.ts b/server/chat-plugins/mafia.ts
index 8d5bfb0556f3..090bf2e55c9c 100644
--- a/server/chat-plugins/mafia.ts
+++ b/server/chat-plugins/mafia.ts
@@ -75,6 +75,7 @@ interface MafiaIDEAData {
// eg, GestI has 2 things to pick, role and alignment
picks: string[];
}
+
interface MafiaIDEAModule {
data: MafiaIDEAData | null;
timer: NodeJS.Timer | null;
@@ -83,12 +84,22 @@ interface MafiaIDEAModule {
// users that haven't picked a role yet
waitingPick: string[];
}
+
interface MafiaIDEAPlayerData {
choices: string[];
originalChoices: string[];
picks: {[choice: string]: string | null};
}
+// The different possible ways for a player to be eliminated
+enum MafiaEliminateType {
+ ELIMINATE = "was eliminated", // standard (vote + faction kill + anything else)
+ KICK = "was kicked from the game", // staff kick
+ TREESTUMP = "was treestumped", // can still talk
+ SPIRIT = "became a restless spirit", // can still vote
+ SPIRITSTUMP = "became a restless treestump" // treestump + spirit
+}
+
const DATA_FILE = 'config/chat-plugins/mafia-data.json';
const LOGS_FILE = 'config/chat-plugins/mafia-logs.json';
@@ -179,8 +190,8 @@ class MafiaPlayer extends Rooms.RoomGamePlayer {
/** false - can't hammer (priest), true - can only hammer (actor) */
hammerRestriction: null | boolean;
lastVote: number;
- treestump: boolean;
- restless: boolean;
+ eliminated: MafiaEliminateType | null;
+ eliminationOrder: number;
silenced: boolean;
nighttalk: boolean;
revealed: string;
@@ -195,8 +206,8 @@ class MafiaPlayer extends Rooms.RoomGamePlayer {
this.voting = '';
this.hammerRestriction = null;
this.lastVote = 0;
- this.treestump = false;
- this.restless = false;
+ this.eliminated = null;
+ this.eliminationOrder = 0;
this.silenced = false;
this.nighttalk = false;
this.revealed = '';
@@ -205,7 +216,14 @@ class MafiaPlayer extends Rooms.RoomGamePlayer {
this.actionArr = [];
}
- getRole(button = false) {
+ /**
+ * Called if the player's name changes.
+ */
+ updateSafeName() {
+ this.safeName = Utils.escapeHTML(this.name);
+ }
+
+ getStylizedRole(button = false) {
if (!this.role) return;
let color = MafiaData.alignments[this.role.alignment].color;
if (button && MafiaData.alignments[this.role.alignment].buttonColor) {
@@ -214,14 +232,47 @@ class MafiaPlayer extends Rooms.RoomGamePlayer {
return `${this.role.safeName}`;
}
+ isEliminated() {
+ return this.eliminated !== null;
+ }
+
+ isTreestump() {
+ return this.eliminated === MafiaEliminateType.TREESTUMP ||
+ this.eliminated === MafiaEliminateType.SPIRITSTUMP;
+ }
+
+ isSpirit() {
+ return this.eliminated === MafiaEliminateType.SPIRIT ||
+ this.eliminated === MafiaEliminateType.SPIRITSTUMP;
+ }
+
+ // Only call updateHtmlRoom if the player is still involved in the game in some way
+ tryUpdateHtmlRoom() {
+ if ([null, MafiaEliminateType.SPIRIT, MafiaEliminateType.TREESTUMP,
+ MafiaEliminateType.SPIRITSTUMP].includes(this.eliminated)) {
+ this.updateHtmlRoom();
+ }
+ }
+
+ /**
+ * Updates the mafia HTML room for this player.
+ * @param id Only provided during the destruction process to update the HTML one last time after player.id is cleared.
+ */
updateHtmlRoom() {
+ if (this.game.ended) return this.closeHtmlRoom();
const user = Users.get(this.id);
- if (!user?.connected) return;
- if (this.game.ended) return user.send(`>view-mafia-${this.game.room.roomid}\n|deinit`);
+ if (!user || !user.connected) return;
for (const conn of user.connections) {
void Chat.resolvePage(`view-mafia-${this.game.room.roomid}`, user, conn);
}
}
+
+ closeHtmlRoom() {
+ const user = Users.get(this.id);
+ if (!user || !user.connected) return;
+ return user.send(`>view-mafia-${this.game.room.roomid}\n|deinit`);
+ }
+
updateHtmlVotes() {
const user = Users.get(this.id);
if (!user?.connected) return;
@@ -231,13 +282,13 @@ class MafiaPlayer extends Rooms.RoomGamePlayer {
}
class Mafia extends Rooms.RoomGame {
+ override readonly gameid = 'mafia' as ID;
started: boolean;
theme: MafiaDataTheme | null;
hostid: ID;
host: string;
cohostids: ID[];
cohosts: string[];
- dead: {[userid: string]: MafiaPlayer};
subs: ID[];
autoSub: boolean;
@@ -251,9 +302,9 @@ class Mafia extends Rooms.RoomGame {
hammerModifiers: {[userid: string]: number};
hasPlurality: ID | null;
- enableNL: boolean;
+ enableNV: boolean;
voteLock: boolean;
- votingAll: boolean;
+ votingEnabled: boolean;
forceVote: boolean;
closedSetup: boolean;
noReveal: boolean;
@@ -275,12 +326,10 @@ class Mafia extends Rooms.RoomGame {
constructor(room: ChatRoom, host: User) {
super(room);
- this.gameid = 'mafia' as ID;
this.title = 'Mafia';
this.playerCap = 20;
this.allowRenames = false;
this.started = false;
- this.ended = false;
this.theme = null;
@@ -289,7 +338,6 @@ class Mafia extends Rooms.RoomGame {
this.cohostids = [];
this.cohosts = [];
- this.dead = Object.create(null);
this.subs = [];
this.autoSub = true;
this.requestedSub = [];
@@ -302,9 +350,9 @@ class Mafia extends Rooms.RoomGame {
this.hammerModifiers = Object.create(null);
this.hasPlurality = null;
- this.enableNL = true;
+ this.enableNV = true;
this.voteLock = false;
- this.votingAll = true;
+ this.votingEnabled = true;
this.forceVote = false;
this.closedSetup = false;
this.noReveal = true;
@@ -332,24 +380,52 @@ class Mafia extends Rooms.RoomGame {
this.sendHTML(this.roomWindow());
}
- join(user: User) {
- if (this.phase !== 'signups') return user.sendTo(this.room, `|error|The game of ${this.title} has already started.`);
- this.canJoin(user, true);
- if (this.playerCount >= this.playerCap) return user.sendTo(this.room, `|error|The game of ${this.title} is full.`);
- if (!this.addPlayer(user)) return user.sendTo(this.room, `|error|You have already joined the game of ${this.title}.`);
+ join(user: User, staffAdd: User | null = null, force = false) {
+ if (this.phase !== 'signups' && !staffAdd) {
+ return this.sendUser(user, `|error|The game of ${this.title} has already started.`);
+ }
+
+ this.canJoin(user, !staffAdd, force);
+ if (this.playerCount >= this.playerCap) return this.sendUser(user, `|error|The game of ${this.title} is full.`);
+
+ const player = this.addPlayer(user);
+ if (!player) return this.sendUser(user, `|error|You have already joined the game of ${this.title}.`);
+ if (this.started) {
+ player.role = {
+ name: `Unknown`,
+ safeName: `Unknown`,
+ id: `unknown`,
+ alignment: 'solo',
+ image: '',
+ memo: [`You were added to the game after it had started. To learn about your role, PM the host (${this.host}).`],
+ };
+ this.roles.push(player.role);
+ this.played.push(player.id);
+ } else {
+ // TODO improve reseting roles
+ this.originalRoles = [];
+ this.originalRoleString = '';
+ this.roles = [];
+ this.roleString = '';
+ }
+
if (this.subs.includes(user.id)) this.subs.splice(this.subs.indexOf(user.id), 1);
- this.playerTable[user.id].updateHtmlRoom();
- this.sendRoom(`${this.playerTable[user.id].name} has joined the game.`);
+ player.updateHtmlRoom();
+ if (staffAdd) {
+ this.sendDeclare(`${player.name} has been added to the game by ${staffAdd.name}.`);
+ this.logAction(staffAdd, 'added player');
+ } else {
+ this.sendRoom(`${player.name} has joined the game.`);
+ }
}
leave(user: User) {
- if (!(user.id in this.playerTable)) {
- return user.sendTo(this.room, `|error|You have not joined the game of ${this.title}.`);
+ const player = this.getPlayer(user.id);
+ if (!player) {
+ return this.sendUser(user, `|error|You have not joined the game of ${this.title}.`);
}
- if (this.phase !== 'signups') return user.sendTo(this.room, `|error|The game of ${this.title} has already started.`);
- this.playerTable[user.id].destroy();
- delete this.playerTable[user.id];
- this.playerCount--;
+ if (this.phase !== 'signups') return this.sendUser(user, `|error|The game of ${this.title} has already started.`);
+ this.removePlayer(player);
let subIndex = this.requestedSub.indexOf(user.id);
if (subIndex !== -1) this.requestedSub.splice(subIndex, 1);
subIndex = this.hostRequestedSub.indexOf(user.id);
@@ -398,6 +474,16 @@ class Mafia extends Rooms.RoomGame {
return new MafiaPlayer(user, this);
}
+ getPlayer(userid: ID) {
+ const matches = this.players.filter(p => p.id === userid);
+ if (matches.length > 1) {
+ // Should never happen
+ throw new Error(`Duplicate player IDs in Mafia game! Matches: ${matches.map(p => p.id).join(', ')}`);
+ }
+
+ return matches.length > 0 ? matches[0] : null;
+ }
+
setRoles(user: User, roleString: string, force = false, reset = false) {
let roles = roleString.split(',').map(x => x.trim());
@@ -424,14 +510,14 @@ class Mafia extends Rooms.RoomGame {
roles = IDEA.roles;
this.theme = null;
} else {
- return user.sendTo(this.room, `|error|${roles[0]} is not a valid theme or IDEA.`);
+ return this.sendUser(user, `|error|${roles[0]} is not a valid theme or IDEA.`);
}
} else {
this.theme = null;
}
if (roles.length < this.playerCount) {
- return user.sendTo(this.room, `|error|You have not provided enough roles for the players.`);
+ return this.sendUser(user, `|error|You have not provided enough roles for the players.`);
} else if (roles.length > this.playerCount) {
user.sendTo(
this.room,
@@ -482,9 +568,9 @@ class Mafia extends Rooms.RoomGame {
}
if (problems.length) {
for (const problem of problems) {
- user.sendTo(this.room, `|error|${problem}`);
+ this.sendUser(user, `|error|${problem}`);
}
- return user.sendTo(this.room, `|error|To forcibly set the roles, use /mafia force${reset ? "re" : ""}setroles`);
+ return this.sendUser(user, `|error|To forcibly set the roles, use /mafia force${reset ? "re" : ""}setroles`);
}
this.IDEA.data = null;
@@ -511,15 +597,15 @@ class Mafia extends Rooms.RoomGame {
const host = Users.get(hostid);
if (host?.connected) host.send(`>${this.room.roomid}\n|notify|It's night in your game of Mafia!`);
}
- for (const player of Object.values(this.playerTable)) {
+ for (const player of this.players) {
const user = Users.get(player.id);
if (user?.connected) {
- user.sendTo(this.room.roomid, `|notify|It's night in the game of Mafia! Send in an action or idle.`);
+ this.sendUser(user, `|notify|It's night in the game of Mafia! Send in an action or idle.`);
}
+
+ player.actionArr.length = 0; // Yes, this works. It empties the array.
}
- for (const player in this.playerTable) {
- this.playerTable[player].actionArr.splice(0, this.playerTable[player].actionArr.length);
- }
+
if (this.timer) this.setDeadline(0);
this.sendDeclare(`The game has been reset.`);
this.distributeRoles();
@@ -530,6 +616,7 @@ class Mafia extends Rooms.RoomGame {
}
return;
}
+
static parseRole(roleString: string) {
const roleName = roleString.replace(/solo/, '').trim();
@@ -614,21 +701,21 @@ class Mafia extends Rooms.RoomGame {
start(user: User, day = false) {
if (!user) return;
if (this.phase !== 'locked' && this.phase !== 'IDEAlocked') {
- if (this.phase === 'signups') return user.sendTo(this.room, `You need to close the signups first.`);
+ if (this.phase === 'signups') return this.sendUser(user, `You need to close the signups first.`);
if (this.phase === 'IDEApicking') {
- return user.sendTo(this.room, `You must wait for IDEA picks to finish before starting.`);
+ return this.sendUser(user, `You must wait for IDEA picks to finish before starting.`);
}
- return user.sendTo(this.room, `The game is already started!`);
+ return this.sendUser(user, `The game is already started!`);
}
- if (this.playerCount < 2) return user.sendTo(this.room, `You need at least 2 players to start.`);
+ if (this.playerCount < 2) return this.sendUser(user, `You need at least 2 players to start.`);
if (this.phase === 'IDEAlocked') {
- for (const p in this.playerTable) {
- if (!this.playerTable[p].role) return user.sendTo(this.room, `|error|Not all players have a role.`);
+ for (const p of this.players) {
+ if (!p.role) return this.sendUser(user, `|error|Not all players have a role.`);
}
} else {
- if (!Object.keys(this.roles).length) return user.sendTo(this.room, `You need to set the roles before starting.`);
+ if (!Object.keys(this.roles).length) return this.sendUser(user, `You need to set the roles before starting.`);
if (Object.keys(this.roles).length < this.playerCount) {
- return user.sendTo(this.room, `You have not provided enough roles for the players.`);
+ return this.sendUser(user, `You have not provided enough roles for the players.`);
}
}
this.started = true;
@@ -648,18 +735,19 @@ class Mafia extends Rooms.RoomGame {
distributeRoles() {
const roles = Utils.shuffle(this.roles.slice());
if (roles.length) {
- for (const p in this.playerTable) {
+ for (const p of this.players) {
const role = roles.shift()!;
- this.playerTable[p].role = role;
- const u = Users.get(p);
- this.playerTable[p].revealed = '';
+ p.role = role;
+ const u = Users.get(p.id);
+ p.revealed = '';
if (u?.connected) {
u.send(`>${this.room.roomid}\n|notify|Your role is ${role.safeName}. For more details of your role, check your Role PM.`);
}
}
}
- this.dead = {};
- this.played = [this.hostid, ...this.cohostids, ...(Object.keys(this.playerTable) as ID[])];
+
+ this.clearEliminations();
+ this.played = [this.hostid, ...this.cohostids, ...(this.players.map(p => p.id))];
this.sendDeclare(`The roles have been distributed.`);
this.updatePlayers();
}
@@ -667,10 +755,10 @@ class Mafia extends Rooms.RoomGame {
getPartners(alignment: string, player: MafiaPlayer) {
if (!player?.role || ['town', 'solo', 'traitor'].includes(player.role.alignment)) return "";
const partners = [];
- for (const p in this.playerTable) {
- if (p === player.id) continue;
- const role = this.playerTable[p].role;
- if (role && role.alignment === player.role.alignment) partners.push(this.playerTable[p].name);
+ for (const p of this.players) {
+ if (p.id === player.id) continue;
+ const role = p.role;
+ if (role && role.alignment === player.role.alignment) partners.push(p.name);
}
return partners.join(", ");
}
@@ -680,7 +768,7 @@ class Mafia extends Rooms.RoomGame {
if (this.dayNum === 0 && extension !== null) return this.sendUser(this.hostid, `|error|You cannot extend on day 0.`);
if (this.timer) this.setDeadline(0);
if (extension === null) {
- if (!isNaN(this.hammerCount)) this.hammerCount = Math.floor(Object.keys(this.playerTable).length / 2) + 1;
+ if (!isNaN(this.hammerCount)) this.hammerCount = Math.floor(this.getRemainingPlayers().length / 2) + 1;
this.clearVotes();
}
this.phase = 'day';
@@ -695,8 +783,8 @@ class Mafia extends Rooms.RoomGame {
} else {
this.sendDeclare(`Day ${this.dayNum}. The hammer count is set at ${this.hammerCount}`);
}
- for (const p in this.playerTable) {
- this.playerTable[p].action = null;
+ for (const p of this.players) {
+ p.action = null;
}
this.sendPlayerList();
this.updatePlayers();
@@ -710,137 +798,165 @@ class Mafia extends Rooms.RoomGame {
const host = Users.get(hostid);
if (host?.connected) host.send(`>${this.room.roomid}\n|notify|It's night in your game of Mafia!`);
}
- for (const player of Object.values(this.playerTable)) {
+
+ for (const player of this.players) {
const user = Users.get(player.id);
if (user?.connected) {
- user.sendTo(this.room.roomid, `|notify|It's night in the game of Mafia! Send in an action or idle.`);
+ this.sendUser(user, `|notify|It's night in the game of Mafia! Send in an action or idle.`);
}
}
+
if (this.takeIdles) {
this.sendDeclare(`Night ${this.dayNum}. Submit whether you are using an action or idle. If you are using an action, DM your action to the host.`);
} else {
this.sendDeclare(`Night ${this.dayNum}. PM the host your action, or idle.`);
}
+
const hasPlurality = this.getPlurality();
+
if (!early && hasPlurality) {
- this.sendRoom(`Plurality is on ${this.playerTable[hasPlurality] ? this.playerTable[hasPlurality].name : 'No Vote'}`);
+ this.sendRoom(`Plurality is on ${this.getPlayer(hasPlurality)?.name || 'No Vote'}`);
}
if (!early && !initial) this.sendRoom(`|raw|
${this.voteBox()}
`);
- if (initial && !isNaN(this.hammerCount)) this.hammerCount = Math.floor(Object.keys(this.playerTable).length / 2) + 1;
+ if (initial && !isNaN(this.hammerCount)) this.hammerCount = Math.floor(this.getRemainingPlayers().length / 2) + 1;
this.updatePlayers();
}
- vote(userid: ID, target: ID) {
- if (!this.votingAll) return this.sendUser(userid, `|error|Voting is not allowed.`);
- if (this.phase !== 'day') return this.sendUser(userid, `|error|You can only vote during the day.`);
- let player = this.playerTable[userid];
- if (!player && this.dead[userid] && this.dead[userid].restless) player = this.dead[userid];
- if (!player) return;
- if (!(target in this.playerTable) && target !== 'novote') {
- return this.sendUser(userid, `|error|${target} is not a valid player.`);
+ vote(voter: MafiaPlayer, targetId: ID) {
+ if (!this.votingEnabled) return this.sendUser(voter, `|error|Voting is not allowed.`);
+ if (this.phase !== 'day') return this.sendUser(voter, `|error|You can only vote during the day.`);
+ if (!voter || (voter.isEliminated() && !voter.isSpirit())) return;
+
+ const target = this.getPlayer(targetId);
+ if ((!target || target.isEliminated()) && targetId !== 'novote') {
+ return this.sendUser(voter, `|error|${targetId} is not a valid player.`);
}
- if (!this.enableNL && target === 'novote') return this.sendUser(userid, `|error|No Vote is not allowed.`);
- if (target === player.id && !this.selfEnabled) return this.sendUser(userid, `|error|Self voting is not allowed.`);
- if (this.voteLock && player.voting) {
- return this.sendUser(userid, `|error|You cannot switch your vote because votes are locked.`);
+
+ if (!this.enableNV && targetId === 'novote') return this.sendUser(voter, `|error|No Vote is not allowed.`);
+ if (targetId === voter.id && !this.selfEnabled) return this.sendUser(voter, `|error|Self voting is not allowed.`);
+
+ if (this.voteLock && voter.voting) {
+ return this.sendUser(voter, `|error|You cannot switch your vote because votes are locked.`);
}
- const hammering = this.hammerCount - 1 <= (this.votes[target] ? this.votes[target].count : 0);
- if (target === player.id && !hammering && this.selfEnabled === 'hammer') {
- return this.sendUser(userid, `|error|You may only vote yourself when placing the hammer vote.`);
+
+ const currentVotes = this.votes[targetId] ? this.votes[targetId].count : 0;
+ // 1 is added to the existing count to represent the vote we are processing now
+ const hammering = currentVotes + 1 >= this.hammerCount;
+ if (targetId === voter.id && !hammering && this.selfEnabled === 'hammer') {
+ return this.sendUser(voter, `|error|You may only vote yourself when placing the hammer vote.`);
}
- if (player.hammerRestriction !== null) {
- this.sendUser(userid, `${this.hammerCount - 1} <= ${(this.votes[target] ? this.votes[target].count : 0)}`);
- if (player.hammerRestriction && !hammering) {
- return this.sendUser(userid, `|error|You can only vote when placing the hammer vote.`);
+
+ if (voter.hammerRestriction !== null) {
+ if (voter.hammerRestriction && !hammering) {
+ return this.sendUser(voter, `|error|You can only vote when placing the hammer vote.`);
+ } else if (!voter.hammerRestriction && hammering) {
+ return this.sendUser(voter, `|error|You cannot place the hammer vote.`);
}
- if (!player.hammerRestriction && hammering) return this.sendUser(userid, `|error|You cannot place the hammer vote.`);
}
- if (player.lastVote + 2000 >= Date.now()) {
+
+ if (voter.lastVote + 2000 >= Date.now()) {
return this.sendUser(
- userid,
- `|error|You must wait another ${Chat.toDurationString((player.lastVote + 2000) - Date.now()) || '1 second'} before you can change your vote.`
+ voter,
+ `|error|You must wait another ${Chat.toDurationString((voter.lastVote + 2000) - Date.now()) || '1 second'} before you can change your vote.`
);
}
- const previousVote = player.voting;
- if (previousVote) this.unvote(userid, true);
- let vote = this.votes[target];
+
+ // -- VALID --
+
+ const previousVote = voter.voting;
+ if (previousVote) this.unvote(voter, true);
+ let vote = this.votes[targetId];
if (!vote) {
- this.votes[target] = {
- count: 1, trueCount: this.getVoteValue(userid), lastVote: Date.now(), dir: 'up', voters: [userid],
+ this.votes[targetId] = {
+ count: 1, trueCount: this.getVoteValue(voter), lastVote: Date.now(), dir: 'up', voters: [voter.id],
};
- vote = this.votes[target];
+ vote = this.votes[targetId];
} else {
vote.count++;
- vote.trueCount += this.getVoteValue(userid);
+ vote.trueCount += this.getVoteValue(voter);
vote.lastVote = Date.now();
vote.dir = 'up';
- vote.voters.push(userid);
+ vote.voters.push(voter.id);
}
- player.voting = target;
- const name = player.voting === 'novote' ? 'No Vote' : this.playerTable[player.voting].name;
- const targetUser = Users.get(userid);
+ voter.voting = targetId;
+ voter.lastVote = Date.now();
+
+ const name = voter.voting === 'novote' ? 'No Vote' : target?.name;
if (previousVote) {
- this.sendTimestamp(`${(targetUser ? targetUser.name : userid)} has shifted their vote from ${previousVote === 'novote' ? 'No Vote' : this.playerTable[previousVote].name} to ${name}`);
+ this.sendTimestamp(`${voter.name} has shifted their vote from ${previousVote === 'novote' ? 'No Vote' : this.getPlayer(previousVote)?.name} to ${name}`);
} else {
this.sendTimestamp(
name === 'No Vote' ?
- `${(targetUser ? targetUser.name : userid)} has abstained from voting.` :
- `${(targetUser ? targetUser.name : userid)} has voted ${name}.`
+ `${voter.name} has abstained from voting.` :
+ `${voter.name} has voted ${name}.`
);
}
- player.lastVote = Date.now();
+
this.hasPlurality = null;
- if (this.getHammerValue(target) <= vote.trueCount) {
+ if (this.getHammerValue(targetId) <= vote.trueCount) {
// HAMMER
- this.sendDeclare(`Hammer! ${target === 'novote' ? 'Nobody' : Utils.escapeHTML(name)} was voted out!`);
+ this.sendDeclare(`Hammer! ${targetId === 'novote' ? 'Nobody' : Utils.escapeHTML(name as string)} was voted out!`);
this.sendRoom(`|raw|
${this.voteBox()}
`);
- if (target !== 'novote') this.eliminate(target, 'kill');
+ if (targetId !== 'novote') this.eliminate(target as MafiaPlayer, MafiaEliminateType.ELIMINATE);
this.night(true);
return;
}
this.updatePlayersVotes();
}
- unvote(userid: ID, force = false) {
- if (this.phase !== 'day' && !force) return this.sendUser(userid, `|error|You can only vote during the day.`);
- let player = this.playerTable[userid];
+ unvote(voter: MafiaPlayer, force = false) {
+ // Force skips (most) validation
+ if (!force) {
+ if (this.phase !== 'day') return this.sendUser(voter, `|error|You can only vote during the day.`);
- // autoselfvote blocking doesn't apply to restless spirits
- if (player && this.forceVote && !force) {
- return this.sendUser(userid, `|error|You can only shift your vote, not unvote.`);
- }
+ if (voter.isEliminated() && !voter.isSpirit()) {
+ return; // can't vote
+ }
- if (!player && this.dead[userid] && this.dead[userid].restless) player = this.dead[userid];
- if (!player?.voting) return this.sendUser(userid, `|error|You are not voting for anyone.`);
- if (this.voteLock && player?.voting) {
- return this.sendUser(userid, `|error|You cannot unvote because votes are locked.`);
- }
- if (player.lastVote + 2000 >= Date.now() && !force) {
- return this.sendUser(
- userid,
- `|error|You must wait another ${Chat.toDurationString((player.lastVote + 2000) - Date.now()) || '1 second'} before you can change your vote.`
- );
+ // autoselfvote blocking doesn't apply to restless spirits
+ if (!voter.isEliminated() && this.forceVote) {
+ return this.sendUser(voter, `|error|You can only shift your vote, not unvote.`);
+ }
+
+ if (this.voteLock && voter.voting) {
+ return this.sendUser(voter, `|error|You cannot unvote because votes are locked.`);
+ }
+
+ if (voter.lastVote + 2000 >= Date.now()) {
+ return this.sendUser(
+ voter,
+ `|error|You must wait another ${Chat.toDurationString((voter.lastVote + 2000) - Date.now()) || '1 second'} before you can change your vote.`
+ );
+ }
}
- const vote = this.votes[player.voting];
+
+ if (!voter.voting) return this.sendUser(voter, `|error|You are not voting for anyone.`);
+
+ const vote = this.votes[voter.voting];
vote.count--;
- vote.trueCount -= this.getVoteValue(userid);
+ vote.trueCount -= this.getVoteValue(voter);
if (vote.count <= 0) {
- delete this.votes[player.voting];
+ delete this.votes[voter.voting];
} else {
vote.lastVote = Date.now();
vote.dir = 'down';
- vote.voters.splice(vote.voters.indexOf(userid), 1);
+ vote.voters.splice(vote.voters.indexOf(voter.id), 1);
}
- const targetUser = Users.get(userid);
+
+ const target = this.getPlayer(voter.voting);
+ if (!target && voter.voting !== 'novote') {
+ throw new Error(`Unable to find target when unvoting. Voter: ${voter.id}, Target: ${voter.voting}`);
+ }
+
if (!force) {
this.sendTimestamp(
- player.voting === 'novote' ?
- `${(targetUser ? targetUser.name : userid)} is no longer abstaining from voting.` :
- `${(targetUser ? targetUser.name : userid)} has unvoted ${this.playerTable[player.voting].name}.`
+ voter.voting === 'novote' ?
+ `${voter.name} is no longer abstaining from voting.` :
+ `${voter.name} has unvoted ${target?.name}.`
);
}
- player.voting = '';
- player.lastVote = Date.now();
+ voter.voting = '';
+ voter.lastVote = Date.now();
this.hasPlurality = null;
this.updatePlayersVotes();
}
@@ -857,45 +973,59 @@ class Mafia extends Rooms.RoomGame {
-vote.count,
]);
for (const [key, vote] of list) {
- buf += `${vote.count}${plur === key ? '*' : ''} ${this.playerTable[key]?.safeName || 'No Vote'} (${vote.voters.map(a => this.playerTable[a]?.safeName || a).join(', ')}) `;
+ const player = this.getPlayer(toID(key));
+ buf += `${vote.count}${plur === key ? '*' : ''} ${player?.safeName || 'No Vote'} (${vote.voters.map(a => this.getPlayer(a)?.safeName || a).join(', ')}) `;
}
return buf;
}
+
voteBoxFor(userid: ID) {
let buf = '';
buf += `
Votes (Hammer: ${this.hammerCount || 'Disabled'})
`;
const plur = this.getPlurality();
- for (const key of Object.keys(this.playerTable).concat((this.enableNL ? ['novote'] : [])) as ID[]) {
- if (this.votes[key]) {
- buf += `
`;
}
return buf;
}
- applyVoteModifier(user: User, target: ID, mod: number) {
- const targetPlayer = this.playerTable[target] || this.dead[target];
- if (!targetPlayer) return this.sendUser(user, `|error|${target} is not in the game of mafia.`);
- const oldMod = this.voteModifiers[target];
+
+ applyVoteModifier(requester: User, targetPlayer: MafiaPlayer, mod: number) {
+ if (!targetPlayer) return this.sendUser(requester, `|error|${targetPlayer} is not in the game of mafia.`);
+ const oldMod = this.voteModifiers[targetPlayer.id];
if (mod === oldMod || ((isNaN(mod) || mod === 1) && oldMod === undefined)) {
- if (isNaN(mod) || mod === 1) return this.sendUser(user, `|error|${target} already has no vote modifier.`);
- return this.sendUser(user, `|error|${target} already has a vote modifier of ${mod}`);
+ if (isNaN(mod) || mod === 1) return this.sendUser(requester, `|error|${targetPlayer} already has no vote modifier.`);
+ return this.sendUser(requester, `|error|${targetPlayer} already has a vote modifier of ${mod}`);
}
const newMod = isNaN(mod) ? 1 : mod;
if (targetPlayer.voting) {
@@ -906,60 +1036,62 @@ class Mafia extends Rooms.RoomGame {
}
}
if (newMod === 1) {
- delete this.voteModifiers[target];
- return this.sendUser(user, `${targetPlayer.name} has had their vote modifier removed.`);
+ delete this.voteModifiers[targetPlayer.id];
+ return this.sendUser(requester, `${targetPlayer.name} has had their vote modifier removed.`);
} else {
- this.voteModifiers[target] = newMod;
- return this.sendUser(user, `${targetPlayer.name} has been given a vote modifier of ${newMod}`);
+ this.voteModifiers[targetPlayer.id] = newMod;
+ return this.sendUser(requester, `${targetPlayer.name} has been given a vote modifier of ${newMod}`);
}
}
- applyHammerModifier(user: User, target: ID, mod: number) {
- if (!(target in this.playerTable || target === 'novote')) {
- return this.sendUser(user, `|error|${target} is not in the game of mafia.`);
- }
- const oldMod = this.hammerModifiers[target];
+
+ applyHammerModifier(user: User, target: MafiaPlayer, mod: number) {
+ const oldMod = this.hammerModifiers[target.id];
if (mod === oldMod || ((isNaN(mod) || mod === 0) && oldMod === undefined)) {
if (isNaN(mod) || mod === 0) return this.sendUser(user, `|error|${target} already has no hammer modifier.`);
return this.sendUser(user, `|error|${target} already has a hammer modifier of ${mod}`);
}
const newMod = isNaN(mod) ? 0 : mod;
- if (this.votes[target]) {
+ if (this.votes[target.id]) {
// do this manually since we havent actually changed the value yet
- if (this.hammerCount + newMod <= this.votes[target].trueCount) {
+ if (this.hammerCount + newMod <= this.votes[target.id].trueCount) {
// make sure these strings are the same
this.sendRoom(`${target} has been voted due to a modifier change! They have not been eliminated.`);
this.night(true);
}
}
if (newMod === 0) {
- delete this.hammerModifiers[target];
+ delete this.hammerModifiers[target.id];
return this.sendUser(user, `${target} has had their hammer modifier removed.`);
} else {
- this.hammerModifiers[target] = newMod;
+ this.hammerModifiers[target.id] = newMod;
return this.sendUser(user, `${target} has been given a hammer modifier of ${newMod}`);
}
}
+
clearVoteModifiers(user: User) {
- for (const player of [...Object.keys(this.playerTable), ...Object.keys(this.dead)] as ID[]) {
- if (this.voteModifiers[player]) this.applyVoteModifier(user, player, 1);
+ for (const player of this.players) {
+ if (this.voteModifiers[player.id]) this.applyVoteModifier(user, player, 1);
}
}
+
clearHammerModifiers(user: User) {
- for (const player of ['novote', ...Object.keys(this.playerTable)] as ID[]) {
- if (this.hammerModifiers[player]) this.applyHammerModifier(user, player, 0);
+ for (const player of this.players) {
+ if (this.hammerModifiers[player.id]) this.applyHammerModifier(user, player, 0);
}
}
- getVoteValue(userid: ID) {
- const mod = this.voteModifiers[userid];
+ getVoteValue(player: MafiaPlayer) {
+ const mod = this.voteModifiers[player.id];
return (mod === undefined ? 1 : mod);
}
- getHammerValue(userid: ID) {
- const mod = this.hammerModifiers[userid];
+
+ getHammerValue(player: ID) {
+ const mod = this.hammerModifiers[player];
return (mod === undefined ? this.hammerCount : this.hammerCount + mod);
}
+
resetHammer() {
- this.setHammer(Math.floor(Object.keys(this.playerTable).length / 2) + 1);
+ this.setHammer(Math.floor(this.players.length / 2) + 1);
}
setHammer(count: number) {
@@ -1016,62 +1148,38 @@ class Mafia extends Rooms.RoomGame {
return this.hasPlurality;
}
- eliminate(toEliminate: string, ability: string) {
- if (!(toEliminate in this.playerTable || toEliminate in this.dead)) return;
+ override removePlayer(player: MafiaPlayer) {
+ player.closeHtmlRoom();
+ const result = super.removePlayer(player);
+ return result;
+ }
+
+ eliminate(toEliminate: MafiaPlayer, ability: MafiaEliminateType) {
if (!this.started) {
// Game has not started, simply kick the player
- const player = this.playerTable[toEliminate];
- this.sendDeclare(`${player.safeName} was kicked from the game!`);
- if (this.hostRequestedSub.includes(player.id)) {
- this.hostRequestedSub.splice(this.hostRequestedSub.indexOf(player.id), 1);
+ this.sendDeclare(`${toEliminate.safeName} was kicked from the game!`);
+ if (this.hostRequestedSub.includes(toEliminate.id)) {
+ this.hostRequestedSub.splice(this.hostRequestedSub.indexOf(toEliminate.id), 1);
}
- if (this.requestedSub.includes(player.id)) {
- this.requestedSub.splice(this.requestedSub.indexOf(player.id), 1);
+ if (this.requestedSub.includes(toEliminate.id)) {
+ this.requestedSub.splice(this.requestedSub.indexOf(toEliminate.id), 1);
}
- delete this.playerTable[player.id];
- this.playerCount--;
- player.updateHtmlRoom();
- player.destroy();
+ this.removePlayer(toEliminate);
return;
}
- if (toEliminate in this.playerTable) {
- this.dead[toEliminate] = this.playerTable[toEliminate];
- } else {
- this.playerCount++; // so that the playercount decrement later isn't unnecessary
- }
-
- const player = this.dead[toEliminate];
- let msg = `${player.safeName}`;
- switch (ability) {
- case 'treestump':
- this.dead[player.id].treestump = true;
- this.dead[player.id].restless = false;
- msg += ` has been treestumped`;
- break;
- case 'spirit':
- this.dead[player.id].treestump = false;
- this.dead[player.id].restless = true;
- msg += ` became a restless spirit`;
- break;
- case 'spiritstump':
- this.dead[player.id].treestump = true;
- this.dead[player.id].restless = true;
- msg += ` became a restless treestump`;
- break;
- case 'kick':
- this.dead[player.id].treestump = false;
- this.dead[player.id].restless = false;
- msg += ` was kicked from the game`;
- break;
- default:
- this.dead[player.id].treestump = false;
- this.dead[player.id].restless = false;
- msg += ` was eliminated`;
- }
- if (player.voting) this.unvote(player.id, true);
- this.sendDeclare(`${msg}! ${!this.noReveal && toID(ability) === 'kill' ? `${player.safeName}'s role was ${player.getRole()}.` : ''}`);
- if (player.role && !this.noReveal && toID(ability) === 'kill') player.revealed = player.getRole()!;
- const targetRole = player.role;
+
+ toEliminate.eliminationOrder = this.getEliminatedPlayers() // Before eliminating, get other eliminated players
+ .map(p => p.eliminationOrder) // convert to an array of elimination order numbers
+ .reduce((a, b) => Math.max(a, b), 0) + 1; // get the largest of the existing elim order numbers and add 1
+ toEliminate.eliminated = ability;
+
+ if (toEliminate.voting) this.unvote(toEliminate, true);
+ this.sendDeclare(`${toEliminate.safeName} ${ability}! ${!this.noReveal && ability === MafiaEliminateType.ELIMINATE ? `${toEliminate.safeName}'s role was ${toEliminate.getStylizedRole()}.` : ''}`);
+ if (toEliminate.role && !this.noReveal && ability === MafiaEliminateType.ELIMINATE) {
+ toEliminate.revealed = toEliminate.getStylizedRole()!;
+ }
+
+ const targetRole = toEliminate.role;
if (targetRole) {
for (const [roleIndex, role] of this.roles.entries()) {
if (role.id === targetRole.id) {
@@ -1080,96 +1188,75 @@ class Mafia extends Rooms.RoomGame {
}
}
}
- this.clearVotes(player.id);
- delete this.playerTable[player.id];
- let subIndex = this.requestedSub.indexOf(player.id);
+ this.clearVotes(toEliminate.id);
+ let subIndex = this.requestedSub.indexOf(toEliminate.id);
if (subIndex !== -1) this.requestedSub.splice(subIndex, 1);
- subIndex = this.hostRequestedSub.indexOf(player.id);
+ subIndex = this.hostRequestedSub.indexOf(toEliminate.id);
if (subIndex !== -1) this.hostRequestedSub.splice(subIndex, 1);
- this.playerCount--;
this.updateRoleString();
+ if (ability === MafiaEliminateType.KICK) {
+ toEliminate.closeHtmlRoom();
+ this.removePlayer(toEliminate);
+ }
this.updatePlayers();
- player.updateHtmlRoom();
}
revealRole(user: User, toReveal: MafiaPlayer, revealAs: string) {
if (!this.started) {
- return user.sendTo(this.room, `|error|You may only reveal roles once the game has started.`);
+ return this.sendUser(user, `|error|You may only reveal roles once the game has started.`);
}
if (!toReveal.role) {
- return user.sendTo(this.room, `|error|The user ${toReveal.id} is not assigned a role.`);
+ return this.sendUser(user, `|error|The user ${toReveal.id} is not assigned a role.`);
}
toReveal.revealed = revealAs;
- this.sendDeclare(`${toReveal.safeName}'s role ${toReveal.id in this.playerTable ? `is` : `was`} ${revealAs}.`);
+ this.sendDeclare(`${toReveal.safeName}'s role ${toReveal.isEliminated() ? `was` : `is`} ${revealAs}.`);
this.updatePlayers();
}
- revive(user: User, toRevive: string, force = false) {
+ revive(user: User, toRevive: MafiaPlayer) {
if (this.phase === 'IDEApicking') {
- return user.sendTo(this.room, `|error|You cannot add or remove players while IDEA roles are being picked.`);
+ return this.sendUser(user, `|error|You cannot add or remove players while IDEA roles are being picked.`);
}
- if (toRevive in this.playerTable) {
- user.sendTo(this.room, `|error|The user ${toRevive} is already a living player.`);
+ if (!toRevive.isEliminated()) {
+ this.sendUser(user, `|error|The user ${toRevive} is already a living player.`);
return;
}
- if (toRevive in this.dead) {
- const deadPlayer = this.dead[toRevive];
- if (deadPlayer.treestump) deadPlayer.treestump = false;
- if (deadPlayer.restless) deadPlayer.restless = false;
- this.sendDeclare(`${deadPlayer.safeName} was revived!`);
- this.playerTable[deadPlayer.id] = deadPlayer;
- const targetRole = deadPlayer.role;
- if (targetRole) {
- this.roles.push(targetRole);
- } else {
- // Should never happen
- deadPlayer.role = {
- name: `Unknown`,
- safeName: `Unknown`,
- id: `unknown`,
- alignment: 'solo',
- image: '',
- memo: [
- `You were revived, but had no role. Please let a Mafia Room Owner know this happened. To learn about your role, PM the host (${this.host}).`,
- ],
- };
- this.roles.push(deadPlayer.role);
- }
- Utils.sortBy(this.roles, r => [r.alignment, r.name]);
- delete this.dead[deadPlayer.id];
+
+ toRevive.eliminated = null;
+ this.sendDeclare(`${toRevive.safeName} was revived!`);
+ const targetRole = toRevive.role;
+ if (targetRole) {
+ this.roles.push(targetRole);
} else {
- const targetUser = Users.get(toRevive);
- if (!targetUser) return;
- this.canJoin(targetUser, false, force);
- const player = this.makePlayer(targetUser);
- if (this.started) {
- player.role = {
- name: `Unknown`,
- safeName: `Unknown`,
- id: `unknown`,
- alignment: 'solo',
- image: '',
- memo: [`You were added to the game after it had started. To learn about your role, PM the host (${this.host}).`],
- };
- this.roles.push(player.role);
- this.played.push(targetUser.id);
- } else {
- this.originalRoles = [];
- this.originalRoleString = '';
- this.roles = [];
- this.roleString = '';
- }
- if (this.subs.includes(targetUser.id)) this.subs.splice(this.subs.indexOf(targetUser.id), 1);
- this.playerTable[targetUser.id] = player;
- this.sendDeclare(Utils.html`${targetUser.name} has been added to the game by ${user.name}!`);
+ // Should never happen
+ toRevive.role = {
+ name: `Unknown`,
+ safeName: `Unknown`,
+ id: `unknown`,
+ alignment: 'solo',
+ image: '',
+ memo: [
+ `You were revived, but had no role. Please let a Mafia Room Owner know this happened. To learn about your role, PM the host (${this.host}).`,
+ ],
+ };
+ this.roles.push(toRevive.role);
}
- this.playerCount++;
+ Utils.sortBy(this.roles, r => [r.alignment, r.name]);
+
this.updateRoleString();
this.updatePlayers();
return true;
}
+ getRemainingPlayers() {
+ return this.players.filter(player => !player.isEliminated());
+ }
+
+ getEliminatedPlayers() {
+ return this.players.filter(player => player.isEliminated()).sort((a, b) => a.eliminationOrder - b.eliminationOrder);
+ }
+
setDeadline(minutes: number, silent = false) {
if (isNaN(minutes)) return;
if (!minutes) {
@@ -1211,56 +1298,46 @@ class Mafia extends Rooms.RoomGame {
this.sendTimestamp(`**The deadline has been set for ${minutes} minute${minutes === 1 ? '' : 's'}.**`);
}
- sub(player: string, replacement: string) {
- const oldPlayer = this.playerTable[player];
- if (!oldPlayer) return; // should never happen
+ sub(player: MafiaPlayer, newUser: User) {
+ const oldPlayerId = player.id;
+ const oldSafeName = player.safeName;
+ this.setPlayerUser(player, newUser);
+ player.updateSafeName();
- const newUser = Users.get(replacement);
- if (!newUser) return; // should never happen
- const newPlayer = this.makePlayer(newUser);
- newPlayer.role = oldPlayer.role;
- newPlayer.IDEA = oldPlayer.IDEA;
- if (oldPlayer.voting) {
+ if (player.voting) {
// Dont change plurality
- const vote = this.votes[oldPlayer.voting];
- vote.voters.splice(vote.voters.indexOf(oldPlayer.id), 1);
- vote.voters.push(newPlayer.id);
- newPlayer.voting = oldPlayer.voting;
- oldPlayer.voting = '';
+ const vote = this.votes[player.voting];
+ vote.voters.splice(vote.voters.indexOf(oldPlayerId), 1);
+ vote.voters.push(player.id);
}
- this.playerTable[newPlayer.id] = newPlayer;
// Transfer votes on the old player to the new one
- if (this.votes[oldPlayer.id]) {
- this.votes[newPlayer.id] = this.votes[oldPlayer.id];
- delete this.votes[oldPlayer.id];
- for (const p in this.playerTable) {
- if (this.playerTable[p].voting === oldPlayer.id) this.playerTable[p].voting = newPlayer.id;
- }
- for (const p in this.dead) {
- if (this.dead[p].restless && this.dead[p].voting === oldPlayer.id) this.dead[p].voting = newPlayer.id;
+ if (this.votes[oldPlayerId]) {
+ this.votes[player.id] = this.votes[oldPlayerId];
+ delete this.votes[oldPlayerId];
+
+ for (const p of this.players) {
+ if (p.voting === oldPlayerId) {
+ p.voting = player.id;
+ }
}
}
- if (this.hasPlurality === oldPlayer.id) this.hasPlurality = newPlayer.id;
- for (let i = 1; i < this.dayNum; i++) {
- newPlayer.actionArr[i] = oldPlayer.actionArr[i];
- }
+ if (this.hasPlurality === oldPlayerId) this.hasPlurality = player.id;
+
if (newUser?.connected) {
for (const conn of newUser.connections) {
void Chat.resolvePage(`view-mafia-${this.room.roomid}`, newUser, conn);
}
- newUser.send(`>${this.room.roomid}\n|notify|You have been substituted in the mafia game for ${oldPlayer.safeName}.`);
+ newUser.send(`>${this.room.roomid}\n|notify|You have been substituted in the mafia game for ${oldSafeName}.`);
}
- if (this.started) this.played.push(newPlayer.id);
- this.sendDeclare(`${oldPlayer.safeName} has been subbed out. ${newPlayer.safeName} has joined the game.`);
- delete this.playerTable[oldPlayer.id];
- oldPlayer.destroy();
+ if (this.started) this.played.push(player.id);
+ this.sendDeclare(`${oldSafeName} has been subbed out. ${player.safeName} has joined the game.`);
this.updatePlayers();
if (this.room.roomid === 'mafia' && this.started) {
const month = new Date().toLocaleString("en-us", {month: "numeric", year: "numeric"});
if (!logs.leavers[month]) logs.leavers[month] = {};
- if (!logs.leavers[month][player]) logs.leavers[month][player] = 0;
- logs.leavers[month][player]++;
+ if (!logs.leavers[month][player.id]) logs.leavers[month][player.id] = 0;
+ logs.leavers[month][player.id]++;
writeFile(LOGS_FILE, logs);
}
}
@@ -1274,19 +1351,19 @@ class Mafia extends Rooms.RoomGame {
if (!nextSub) return;
const sub = Users.get(nextSub, true);
if (!sub?.connected || !sub.named || !this.room.users[sub.id]) return; // should never happen, just to be safe
- const toSubOut = userid || this.hostRequestedSub.shift() || this.requestedSub.shift();
+ const toSubOut = this.getPlayer(userid || this.hostRequestedSub.shift() || this.requestedSub.shift() || '');
if (!toSubOut) {
// Should never happen
this.subs.unshift(nextSub);
return;
}
- if (this.hostRequestedSub.includes(toSubOut)) {
- this.hostRequestedSub.splice(this.hostRequestedSub.indexOf(toSubOut), 1);
+ if (this.hostRequestedSub.includes(toSubOut.id)) {
+ this.hostRequestedSub.splice(this.hostRequestedSub.indexOf(toSubOut.id), 1);
}
- if (this.requestedSub.includes(toSubOut)) {
- this.requestedSub.splice(this.requestedSub.indexOf(toSubOut), 1);
+ if (this.requestedSub.includes(toSubOut.id)) {
+ this.requestedSub.splice(this.requestedSub.indexOf(toSubOut.id), 1);
}
- this.sub(toSubOut, sub.id);
+ this.sub(toSubOut, sub);
}
customIdeaInit(user: User, choices: number, picks: string[], rolesString: string) {
@@ -1318,19 +1395,19 @@ class Mafia extends Rooms.RoomGame {
if (moduleID in MafiaData.aliases) moduleID = MafiaData.aliases[moduleID];
this.IDEA.data = MafiaData.IDEAs[moduleID];
- if (!this.IDEA.data) return user.sendTo(this.room, `|error|${moduleID} is not a valid IDEA.`);
+ if (!this.IDEA.data) return this.sendUser(user, `|error|${moduleID} is not a valid IDEA.`);
return this.ideaDistributeRoles(user);
}
ideaDistributeRoles(user: User) {
- if (!this.IDEA.data) return user.sendTo(this.room, `|error|No IDEA module loaded`);
+ if (!this.IDEA.data) return this.sendUser(user, `|error|No IDEA module loaded`);
if (this.phase !== 'locked' && this.phase !== 'IDEAlocked') {
- return user.sendTo(this.room, `|error|The game must be in a locked state to distribute IDEA roles.`);
+ return this.sendUser(user, `|error|The game must be in a locked state to distribute IDEA roles.`);
}
const neededRoles = this.IDEA.data.choices * this.playerCount;
if (neededRoles > this.IDEA.data.roles.length) {
- return user.sendTo(this.room, `|error|Not enough roles in the IDEA module.`);
+ return this.sendUser(user, `|error|Not enough roles in the IDEA module.`);
}
const roles = [];
@@ -1345,8 +1422,7 @@ class Mafia extends Rooms.RoomGame {
}
Utils.shuffle(roles);
this.IDEA.waitingPick = [];
- for (const p in this.playerTable) {
- const player = this.playerTable[p];
+ for (const player of this.players) {
player.role = null;
player.IDEA = {
choices: roles.splice(0, this.IDEA.data.choices),
@@ -1356,9 +1432,9 @@ class Mafia extends Rooms.RoomGame {
player.IDEA.originalChoices = player.IDEA.choices.slice();
for (const pick of this.IDEA.data.picks) {
player.IDEA.picks[pick] = null;
- this.IDEA.waitingPick.push(p);
+ this.IDEA.waitingPick.push(player.id);
}
- const u = Users.get(p);
+ const u = Users.get(player.id);
if (u?.connected) u.send(`>${this.room.roomid}\n|notify|Pick your role in the IDEA module.`);
}
@@ -1377,13 +1453,15 @@ class Mafia extends Rooms.RoomGame {
if (!this.IDEA?.data) {
return this.sendRoom(`Trying to pick an IDEA role with no module running, target: ${JSON.stringify(selection)}. Please report this to a mod.`);
}
- const player = this.playerTable[user.id];
- if (!player.IDEA) {
+
+ const player = this.getPlayer(user.id);
+ if (!player?.IDEA) {
return this.sendRoom(`Trying to pick an IDEA role with no player IDEA object, user: ${user.id}. Please report this to a mod.`);
}
+
selection = selection.map(toID);
if (selection.length === 1 && this.IDEA.data.picks.length === 1) selection = [this.IDEA.data.picks[0], selection[0]];
- if (selection.length !== 2) return user.sendTo(this.room, `|error|Invalid selection.`);
+ if (selection.length !== 2) return this.sendUser(user, `|error|Invalid selection.`);
// input is formatted as ['selection', 'role']
// eg: ['role', 'bloodhound']
@@ -1392,7 +1470,7 @@ class Mafia extends Rooms.RoomGame {
if (selection[1]) {
const roleIndex = player.IDEA.choices.map(toID).indexOf(selection[1] as ID);
if (roleIndex === -1) {
- return user.sendTo(this.room, `|error|${selection[1]} is not an available role, perhaps it is already selected?`);
+ return this.sendUser(user, `|error|${selection[1]} is not an available role, perhaps it is already selected?`);
}
selection[1] = player.IDEA.choices.splice(roleIndex, 1)[0];
} else {
@@ -1418,7 +1496,7 @@ class Mafia extends Rooms.RoomGame {
this.ideaFinalizePicks();
return;
}
- return user.sendTo(this.room, buf);
+ return this.sendUser(user, buf);
}
ideaFinalizePicks() {
@@ -1426,8 +1504,7 @@ class Mafia extends Rooms.RoomGame {
return this.sendRoom(`Tried to finalize IDEA picks with no IDEA module running, please report this to a mod.`);
}
const randed = [];
- for (const p in this.playerTable) {
- const player = this.playerTable[p];
+ for (const player of this.players) {
if (!player.IDEA) {
return this.sendRoom(`Trying to pick an IDEA role with no player IDEA object, user: ${player.id}. Please report this to a mod.`);
}
@@ -1446,7 +1523,7 @@ class Mafia extends Rooms.RoomGame {
}
role.push(`${choice}: ${player.IDEA.picks[choice]}`);
}
- if (randPicked) randed.push(p);
+ if (randPicked) randed.push(player.id);
// if there's only one option, it's their role, parse it properly
let roleName = '';
if (this.IDEA.data.picks.length === 1) {
@@ -1484,10 +1561,13 @@ class Mafia extends Rooms.RoomGame {
if (player.IDEA.choices.includes('Innocent Discard')) player.role.alignment = 'town';
}
this.IDEA.discardsHTML = `Discards: `;
- for (const p of Object.keys(this.playerTable).sort()) {
- const IDEA = this.playerTable[p].IDEA;
- if (!IDEA) return this.sendRoom(`No IDEA data for player ${p} when finalising IDEAs. Please report this to a mod.`);
- this.IDEA.discardsHTML += `${this.playerTable[p].safeName}: ${IDEA.choices.join(', ')} `;
+ for (const player of this.players.sort((a, b) => a.id.localeCompare(b.id))) {
+ const IDEA = player.IDEA;
+ if (!IDEA) {
+ return this.sendRoom(`No IDEA data for player ${player} when finalising IDEAs. Please report this to a mod.`);
+ }
+
+ this.IDEA.discardsHTML += `${player.safeName}: ${IDEA.choices.join(', ')} `;
}
this.phase = 'IDEAlocked';
@@ -1500,26 +1580,20 @@ class Mafia extends Rooms.RoomGame {
}
sendPlayerList() {
- this.room.add(`|c:|${(Math.floor(Date.now() / 1000))}|~|**Players (${this.playerCount})**: ${Object.values(this.playerTable).map(p => p.name).sort().join(', ')}`).update();
+ this.room.add(`|c:|${(Math.floor(Date.now() / 1000))}|~|**Players (${this.getRemainingPlayers().length})**: ${this.getRemainingPlayers().map(p => p.name).sort().join(', ')}`).update();
}
updatePlayers() {
- for (const p in this.playerTable) {
- this.playerTable[p].updateHtmlRoom();
- }
- for (const p in this.dead) {
- if (this.dead[p].restless || this.dead[p].treestump) this.dead[p].updateHtmlRoom();
+ for (const p of this.players) {
+ p.tryUpdateHtmlRoom();
}
// Now do the host
this.updateHost();
}
updatePlayersVotes() {
- for (const p in this.playerTable) {
- this.playerTable[p].updateHtmlVotes();
- }
- for (const p in this.dead) {
- if (this.dead[p].restless || this.dead[p].treestump) this.dead[p].updateHtmlVotes();
+ for (const p of this.players) {
+ p.tryUpdateHtmlRoom();
}
}
@@ -1577,34 +1651,41 @@ class Mafia extends Rooms.RoomGame {
}
canJoin(user: User, self = false, force = false) {
- if (!user?.connected) return `User not found.`;
+ if (!user?.connected) throw new Chat.ErrorMessage(`User not found.`);
const targetString = self ? `You are` : `${user.id} is`;
- if (!this.room.users[user.id]) return `${targetString} not in the room.`;
+ if (!this.room.users[user.id]) throw new Chat.ErrorMessage(`${targetString} not in the room.`);
for (const id of [user.id, ...user.previousIDs]) {
- if (this.playerTable[id] || this.dead[id]) throw new Chat.ErrorMessage(`${targetString} already in the game.`);
+ if (this.getPlayer(id)) throw new Chat.ErrorMessage(`${targetString} already in the game.`);
if (!force && this.played.includes(id)) {
- throw new Chat.ErrorMessage(`${self ? `You were` : `${user.id} was`} already in the game.`);
+ throw new Chat.ErrorMessage(`${targetString} a previous player and cannot rejoin.`);
}
if (Mafia.isGameBanned(this.room, user)) {
- throw new Chat.ErrorMessage(`${self ? `You are` : `${user.id} is`} banned from joining mafia games.`);
+ throw new Chat.ErrorMessage(`${targetString} banned from joining mafia games.`);
}
if (this.hostid === id) throw new Chat.ErrorMessage(`${targetString} the host.`);
if (this.cohostids.includes(id)) throw new Chat.ErrorMessage(`${targetString} a cohost.`);
}
- if (!force) {
- for (const alt of user.getAltUsers(true)) {
- if (this.playerTable[alt.id] || this.played.includes(alt.id)) {
- throw new Chat.ErrorMessage(`${self ? `You already have` : `${user.id} already has`} an alt in the game.`);
- }
- if (this.hostid === alt.id || this.cohostids.includes(alt.id)) {
- throw new Chat.ErrorMessage(`${self ? `You have` : `${user.id} has`} an alt as a game host.`);
- }
+
+ for (const alt of user.getAltUsers(true)) {
+ if (!force && (this.getPlayer(alt.id) || this.played.includes(alt.id))) {
+ throw new Chat.ErrorMessage(`${self ? `You already have` : `${user.id} already has`} an alt in the game.`);
+ }
+ if (this.hostid === alt.id || this.cohostids.includes(alt.id)) {
+ throw new Chat.ErrorMessage(`${self ? `You have` : `${user.id} has`} an alt as a game host.`);
}
}
}
- sendUser(user: User | string | null, message: string) {
- const userObject = (typeof user === 'string' ? Users.get(user) : user);
+ sendUser(user: MafiaPlayer | User | string, message: string) {
+ let userObject: User | null;
+ if (user instanceof MafiaPlayer) {
+ userObject = user.getUser();
+ } else if (typeof user === "string") {
+ userObject = Users.get(user);
+ } else {
+ userObject = user;
+ }
+
if (!userObject?.connected) return;
userObject.sendTo(this.room, message);
}
@@ -1624,64 +1705,76 @@ class Mafia extends Rooms.RoomGame {
}
this.selfEnabled = setting;
if (!setting) {
- for (const player of Object.values(this.playerTable)) {
- if (player.voting === player.id) this.unvote(player.id, true);
+ for (const player of this.players) {
+ if (player.voting === player.id) this.unvote(player, true);
}
}
this.updatePlayers();
}
+
setNoVote(user: User, setting: boolean) {
- if (this.enableNL === setting) {
- return user.sendTo(this.room, `|error|No Vote is already ${setting ? 'enabled' : 'disabled'}.`);
+ if (this.enableNV === setting) {
+ return this.sendUser(user, `|error|No Vote is already ${setting ? 'enabled' : 'disabled'}.`);
}
- this.enableNL = setting;
+ this.enableNV = setting;
this.sendDeclare(`No Vote has been ${setting ? 'enabled' : 'disabled'}.`);
- if (!setting) this.clearVotes('novote');
+ if (!setting) this.clearVotes('novote' as ID);
this.updatePlayers();
}
+
setVotelock(user: User, setting: boolean) {
- if (!this.started) return user.sendTo(this.room, `The game has not started yet.`);
+ if (!this.started) return this.sendUser(user, `The game has not started yet.`);
if ((this.voteLock) === setting) {
- return user.sendTo(this.room, `|error|Votes are already ${setting ? 'set to lock' : 'set to not lock'}.`);
+ return this.sendUser(user, `|error|Votes are already ${setting ? 'set to lock' : 'set to not lock'}.`);
}
this.voteLock = setting;
this.clearVotes();
this.sendDeclare(`Votes are cleared and ${setting ? 'set to lock' : 'set to not lock'}.`);
this.updatePlayers();
}
+
setVoting(user: User, setting: boolean) {
- if (!this.started) return user.sendTo(this.room, `The game has not started yet.`);
- if (this.votingAll === setting) {
- return user.sendTo(this.room, `|error|Voting is already ${setting ? 'allowed' : 'disallowed'}.`);
+ if (!this.started) return this.sendUser(user, `The game has not started yet.`);
+ if (this.votingEnabled === setting) {
+ return this.sendUser(user, `|error|Voting is already ${setting ? 'allowed' : 'disallowed'}.`);
}
- this.votingAll = setting;
+ this.votingEnabled = setting;
this.clearVotes();
this.sendDeclare(`Voting is now ${setting ? 'allowed' : 'disallowed'}.`);
this.updatePlayers();
}
- clearVotes(target = '') {
+
+ clearVotes(target: ID = '') {
if (target) delete this.votes[target];
if (!target) this.votes = Object.create(null);
- for (const player of Object.values(this.playerTable)) {
+ for (const player of this.players) {
+ if (player.isEliminated() && !player.isSpirit()) continue;
+
if (this.forceVote) {
if (!target || (player.voting === target)) {
player.voting = player.id;
this.votes[player.id] = {
- count: 1, trueCount: this.getVoteValue(player.id), lastVote: Date.now(), dir: 'up', voters: [player.id],
+ count: 1, trueCount: this.getVoteValue(player), lastVote: Date.now(), dir: 'up', voters: [player.id],
};
}
} else {
if (!target || (player.voting === target)) player.voting = '';
}
}
- for (const player of Object.values(this.dead)) {
- if (player.restless && (!target || player.voting === target)) player.voting = '';
- }
this.hasPlurality = null;
}
+ /**
+ * Only intended to be used during pre-game setup.
+ */
+ clearEliminations() {
+ for (const player of this.players) {
+ player.eliminated = null;
+ }
+ }
+
onChatMessage(message: string, user: User) {
const subIndex = this.hostRequestedSub.indexOf(user.id);
if (subIndex !== -1) {
@@ -1696,13 +1789,8 @@ class Mafia extends Rooms.RoomGame {
return;
}
- let dead = false;
- let player = this.playerTable[user.id];
- if (!player) {
- player = this.dead[user.id];
- dead = !!player;
- }
-
+ const player = this.getPlayer(user.id);
+ const eliminated = player && player.isEliminated();
const staff = user.can('mute', null, this.room);
if (!player) {
@@ -1718,8 +1806,8 @@ class Mafia extends Rooms.RoomGame {
return `You are silenced and cannot speak.${staff ? " You can remove this with /mafia unsilence." : ''}`;
}
- if (dead) {
- if (!player.treestump) {
+ if (eliminated) {
+ if (!player.isTreestump()) {
return `You are dead.${staff ? " You can treestump yourself with /mafia treestump." : ''}`;
}
}
@@ -1732,12 +1820,13 @@ class Mafia extends Rooms.RoomGame {
}
onConnect(user: User) {
- user.sendTo(this.room, `|uhtml|mafia|${this.roomWindow()}`);
+ this.sendUser(user, `|uhtml|mafia|${this.roomWindow()}`);
}
onJoin(user: User) {
- if (user.id in this.playerTable) {
- return this.playerTable[user.id].updateHtmlRoom();
+ const player = this.getPlayer(user.id);
+ if (player) {
+ return player.updateHtmlRoom();
}
if (user.id === this.hostid || this.cohostids.includes(user.id)) return this.updateHost(user.id);
}
@@ -1745,26 +1834,27 @@ class Mafia extends Rooms.RoomGame {
removeBannedUser(user: User) {
// Player was banned, attempt to sub now
// If we can't sub now, make subbing them out the top priority
- if (!(user.id in this.playerTable)) return;
+ if (!this.getPlayer(user.id)) return;
this.requestedSub.unshift(user.id);
this.nextSub();
}
forfeit(user: User) {
// Add the player to the sub list.
- if (!(user.id in this.playerTable)) return;
+ const player = this.getPlayer(user.id);
+ if (!player || player.isEliminated()) return;
this.requestedSub.push(user.id);
this.nextSub();
}
end() {
- this.ended = true;
+ this.setEnded();
this.sendHTML(this.roomWindow());
this.updatePlayers();
if (this.room.roomid === 'mafia' && this.started) {
// Intead of using this.played, which shows players who have subbed out as well
// We check who played through to the end when recording playlogs
- const played = Object.keys(this.playerTable).concat(Object.keys(this.dead));
+ const played = this.players.map(p => p.id);
const month = new Date().toLocaleString("en-us", {month: "numeric", year: "numeric"});
if (!logs.plays[month]) logs.plays[month] = {};
for (const player of played) {
@@ -1785,19 +1875,11 @@ class Mafia extends Rooms.RoomGame {
this.destroy();
}
- destroy() {
- // Slightly modified to handle dead players
+ override destroy() {
+ // Ensure timers are cleared as a part of game destruction
if (this.timer) clearTimeout(this.timer);
if (this.IDEA.timer) clearTimeout(this.IDEA.timer);
- this.room.game = null;
- // @ts-ignore readonly
- this.room = null;
- for (const i in this.playerTable) {
- this.playerTable[i].destroy();
- }
- for (const i in this.dead) {
- this.dead[i].destroy();
- }
+ super.destroy();
}
}
@@ -1812,31 +1894,35 @@ export const pages: Chat.PageTable = {
if (!room?.users[user.id] || !game || game.ended) {
return this.close();
}
- const isPlayer = user.id in game.playerTable;
+
+ const isPlayer = game.getPlayer(user.id);
const isHost = user.id === game.hostid || game.cohostids.includes(user.id);
+ const players = game.getRemainingPlayers();
this.title = game.title;
let buf = `
IDEA information: `;
- const IDEA = game.playerTable[user.id].IDEA;
+ const IDEA = isPlayer.IDEA;
if (!IDEA) {
return game.sendRoom(`IDEA picking phase but no IDEA object for user: ${user.id}. Please report this to a mod.`);
}
@@ -1890,12 +1976,12 @@ export const pages: Chat.PageTable = {
}
}
if (isPlayer) {
- const role = game.playerTable[user.id].role;
+ const role = isPlayer.role;
let previousActionsPL = ` `;
if (role) {
- buf += `
${game.playerTable[user.id].safeName}, you are a ${game.playerTable[user.id].getRole()}
`;
+ buf += `
${isPlayer.safeName}, you are a ${isPlayer.getStylizedRole()}
`;
if (!['town', 'solo'].includes(role.alignment)) {
- buf += `
${isPlayer ? 'Request to be subbed out' : 'Cancel sub request'}`;
+ buf += `
`;
+ } else {
+ buf += `
${isPlayer ? 'Cancel sub request' : 'Join the game as a sub'}`;
+ buf += `
`;
+ }
}
}
buf += `
`;
@@ -2178,7 +2262,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('MAFIAHOST', targetUser, null, {noalts: true, noip: true});
},
hosthelp: [
- `/mafia host [user] - Create a game of Mafia with [user] as the host. Requires whitelist + % @ # &, drivers+ can host other people.`,
+ `/mafia host [user] - Create a game of Mafia with [user] as the host. Requires whitelist + % @ # ~, drivers+ can host other people.`,
],
q: 'queue',
@@ -2231,8 +2315,8 @@ export const commands: Chat.ChatCommands = {
},
queuehelp: [
`/mafia queue - Shows the upcoming users who are going to host.`,
- `/mafia queue add, (user) - Adds the user to the hosting queue. Requires whitelist + % @ # &`,
- `/mafia queue remove, (user) - Removes the user from the hosting queue. Requires whitelist + % @ # &`,
+ `/mafia queue add, (user) - Adds the user to the hosting queue. Requires whitelist + % @ # ~`,
+ `/mafia queue remove, (user) - Removes the user from the hosting queue. Requires whitelist + % @ # ~`,
],
qadd: 'queueadd',
@@ -2269,9 +2353,8 @@ export const commands: Chat.ChatCommands = {
const game = this.requireGame(Mafia);
if (game.hostid !== user.id && !game.cohostids.includes(user.id)) this.checkCan('mute', null, room);
if (game.phase !== 'signups') return this.errorReply(`Signups are already closed.`);
- if (toID(target) === 'none') target = '20';
const num = parseInt(target);
- if (isNaN(num) || num > 20 || num < 2) return this.parse('/help mafia playercap');
+ if (isNaN(num) || num > 50 || num < 2) return this.parse('/help mafia playercap');
if (num < game.playerCount) {
return this.errorReply(`Player cap has to be equal or more than the amount of players in game.`);
}
@@ -2281,7 +2364,7 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `set playercap to ${num}`);
},
playercaphelp: [
- `/mafia playercap [cap|none]- Limit the number of players being able to join the game. Player cap cannot be more than 20 or less than 2. Requires host % @ # &`,
+ `/mafia playercap [cap]- Limit the number of players being able to join the game. Player cap cannot be more than 50 or less than 2. Default is 20. Requires host % @ # ~`,
],
close(target, room, user) {
@@ -2295,7 +2378,7 @@ export const commands: Chat.ChatCommands = {
game.updatePlayers();
game.logAction(user, `closed signups`);
},
- closehelp: [`/mafia close - Closes signups for the current game. Requires host % @ # &`],
+ closehelp: [`/mafia close - Closes signups for the current game. Requires host % @ # ~`],
cs: 'closedsetup',
closedsetup(target, room, user) {
@@ -2316,7 +2399,7 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `${game.closedSetup ? 'enabled' : 'disabled'} closed setup`);
},
closedsetuphelp: [
- `/mafia closedsetup [on|off] - Sets if the game is a closed setup. Closed setups don't show the role list to players. Requires host % @ # &`,
+ `/mafia closedsetup [on|off] - Sets if the game is a closed setup. Closed setups don't show the role list to players. Requires host % @ # ~`,
],
reveal(target, room, user) {
@@ -2336,7 +2419,7 @@ export const commands: Chat.ChatCommands = {
game.updatePlayers();
game.logAction(user, `${game.noReveal ? 'disabled' : 'enabled'} reveals`);
},
- revealhelp: [`/mafia reveal [on|off] - Sets if roles reveal on death or not. Requires host % @ # &`],
+ revealhelp: [`/mafia reveal [on|off] - Sets if roles reveal on death or not. Requires host % @ # ~`],
takeidles(target, room, user) {
room = this.requireRoom();
@@ -2351,7 +2434,7 @@ export const commands: Chat.ChatCommands = {
game.sendDeclare(`Actions and idles are ${game.takeIdles ? 'now' : 'no longer'} being accepted.`);
game.updatePlayers();
},
- takeidleshelp: [`/mafia takeidles [on|off] - Sets if idles are accepted by the script or not. Requires host % @ # &`],
+ takeidleshelp: [`/mafia takeidles [on|off] - Sets if idles are accepted by the script or not. Requires host % @ # ~`],
resetroles: 'setroles',
forceresetroles: 'setroles',
@@ -2392,7 +2475,7 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, 'reset the game state');
},
resetgamehelp: [
- `/mafia resetgame - Resets game data. Does not change settings from the host (besides deadlines) or add/remove any players. Requires host % @ # &`,
+ `/mafia resetgame - Resets game data. Does not change settings from the host (besides deadlines) or add/remove any players. Requires host % @ # ~`,
],
idea(target, room, user) {
@@ -2410,8 +2493,8 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `started an IDEA`);
},
ideahelp: [
- `/mafia idea [idea] - starts the IDEA module [idea]. Requires + % @ # &, voices can only start for themselves`,
- `/mafia ideareroll - rerolls the IDEA module. Requires host % @ # &`,
+ `/mafia idea [idea] - starts the IDEA module [idea]. Requires + % @ # ~, voices can only start for themselves`,
+ `/mafia ideareroll - rerolls the IDEA module. Requires host % @ # ~`,
`/mafia ideapick [selection], [role] - selects a role`,
`/mafia ideadiscards - shows the discarded roles`,
],
@@ -2436,7 +2519,7 @@ export const commands: Chat.ChatCommands = {
},
customideahelp: [
`/mafia customidea choices, picks (new line here, shift+enter)`,
- `(comma or newline separated rolelist) - Starts an IDEA module with custom roles. Requires % @ # &`,
+ `(comma or newline separated rolelist) - Starts an IDEA module with custom roles. Requires % @ # ~`,
`choices refers to the number of roles you get to pick from. In GI, this is 2, in GestI, this is 3.`,
`picks refers to what you choose. In GI, this should be 'role', in GestI, this should be 'role, alignment'`,
],
@@ -2444,13 +2527,14 @@ export const commands: Chat.ChatCommands = {
room = this.requireRoom();
const args = target.split(',');
const game = this.requireGame(Mafia);
- if (!(user.id in game.playerTable)) {
+ const player = game.getPlayer(user.id);
+ if (!player) {
return user.sendTo(room, '|error|You are not a player in the game.');
}
if (game.phase !== 'IDEApicking') {
return this.errorReply(`The game is not in the IDEA picking phase.`);
}
- game.ideaPick(user, args);
+ game.ideaPick(user, args); // TODO use player object
},
ideareroll(target, room, user) {
@@ -2460,7 +2544,7 @@ export const commands: Chat.ChatCommands = {
game.ideaDistributeRoles(user);
game.logAction(user, `rerolled an IDEA`);
},
- idearerollhelp: [`/mafia ideareroll - rerolls the roles for the current IDEA module. Requires host % @ # &`],
+ idearerollhelp: [`/mafia ideareroll - rerolls the roles for the current IDEA module. Requires host % @ # ~`],
discards: 'ideadiscards',
ideadiscards(target, room, user) {
@@ -2488,8 +2572,8 @@ export const commands: Chat.ChatCommands = {
},
ideadiscardshelp: [
`/mafia ideadiscards - shows the discarded roles`,
- `/mafia ideadiscards off - hides discards from the players. Requires host % @ # &`,
- `/mafia ideadiscards on - shows discards to the players. Requires host % @ # &`,
+ `/mafia ideadiscards off - hides discards from the players. Requires host % @ # ~`,
+ `/mafia ideadiscards on - shows discards to the players. Requires host % @ # ~`,
],
daystart: 'start',
@@ -2507,7 +2591,7 @@ export const commands: Chat.ChatCommands = {
game.start(user, cmd === 'daystart');
game.logAction(user, `started the game`);
},
- starthelp: [`/mafia start - Start the game of mafia. Signups must be closed. Requires host % @ # &`],
+ starthelp: [`/mafia start - Start the game of mafia. Signups must be closed. Requires host % @ # ~`],
extend: 'day',
night: 'day',
@@ -2526,8 +2610,7 @@ export const commands: Chat.ChatCommands = {
if (extension > 10) extension = 10;
}
if (cmd === 'extend') {
- for (const p in game.playerTable) {
- const player = game.playerTable[p];
+ for (const player of game.getRemainingPlayers()) {
player.actionArr[game.dayNum] = '';
}
}
@@ -2536,9 +2619,9 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `set day/night`);
},
dayhelp: [
- `/mafia day - Move to the next game day. Requires host % @ # &`,
- `/mafia night - Move to the next game night. Requires host % @ # &`,
- `/mafia extend (minutes) - Return to the previous game day. If (minutes) is provided, set the deadline for (minutes) minutes. Requires host % @ # &`,
+ `/mafia day - Move to the next game day. Requires host % @ # ~`,
+ `/mafia night - Move to the next game night. Requires host % @ # ~`,
+ `/mafia extend (minutes) - Return to the previous game day. If (minutes) is provided, set the deadline for (minutes) minutes. Requires host % @ # ~`,
],
prod(target, room, user) {
@@ -2546,7 +2629,7 @@ export const commands: Chat.ChatCommands = {
const game = this.requireGame(Mafia);
if (game.hostid !== user.id && !game.cohostids.includes(user.id)) this.checkCan('mute', null, room);
if (game.phase !== 'night') return;
- for (const player of Object.values(game.playerTable)) {
+ for (const player of game.getRemainingPlayers()) {
const playerid = Users.get(player.id);
if (playerid?.connected && player.action === null) {
playerid.sendTo(room, `|notify|Send in an action or idle!`);
@@ -2556,7 +2639,7 @@ export const commands: Chat.ChatCommands = {
game.sendDeclare(`Unsubmitted players have been reminded to submit an action or idle.`);
},
prodhelp: [
- `/mafia prod - Notifies players that they must submit an action or idle if they haven't yet. Requires host % @ # &`,
+ `/mafia prod - Notifies players that they must submit an action or idle if they haven't yet. Requires host % @ # ~`,
],
v: 'vote',
@@ -2564,11 +2647,11 @@ export const commands: Chat.ChatCommands = {
room = this.requireRoom();
const game = this.requireGame(Mafia);
this.checkChat(null, room);
- if (!(user.id in game.playerTable) &&
- (!(user.id in game.dead) || !game.dead[user.id].restless)) {
+ const player = game.getPlayer(user.id);
+ if (!player || (player.isEliminated() && !player.isSpirit())) {
return this.errorReply(`You are not in the game of ${game.title}.`);
}
- game.vote(user.id, toID(target));
+ game.vote(player, toID(target));
},
votehelp: [`/mafia vote [player|novote] - Vote the specified player or abstain from voting.`],
@@ -2579,11 +2662,11 @@ export const commands: Chat.ChatCommands = {
room = this.requireRoom();
const game = this.requireGame(Mafia);
this.checkChat(null, room);
- if (!(user.id in game.playerTable) &&
- (!(user.id in game.dead) || !game.dead[user.id].restless)) {
+ const player = game.getPlayer(user.id);
+ if (!player || (player.isEliminated() && !player.isSpirit())) {
return this.errorReply(`You are not in the game of ${game.title}.`);
}
- game.unvote(user.id);
+ game.unvote(player);
},
unvotehelp: [`/mafia unvote - Withdraw your vote. Fails if you're not voting anyone`],
@@ -2611,7 +2694,7 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `changed selfvote`);
},
selfvotehelp: [
- `/mafia selfvote [on|hammer|off] - Allows players to self vote themselves either at hammer or anytime. Requires host % @ # &`,
+ `/mafia selfvote [on|hammer|off] - Allows players to self vote themselves either at hammer or anytime. Requires host % @ # ~`,
],
treestump: 'kill',
@@ -2626,35 +2709,42 @@ export const commands: Chat.ChatCommands = {
return this.errorReply(`You cannot add or remove players while IDEA roles are being picked.`); // needs to be here since eliminate doesn't pass the user
}
if (!target) return this.parse('/help mafia kill');
- const player = game.playerTable[toID(target)];
- const dead = game.dead[toID(target)];
- let repeat;
- if (dead) {
- switch (cmd) {
- case 'treestump':
- repeat = dead.treestump && !dead.restless;
- break;
- case 'spirit':
- repeat = !dead.treestump && dead.restless;
- break;
- case 'spiritstump':
- repeat = dead.treestump && dead.restless;
- break;
- case 'kill': case 'kick':
- repeat = !dead.treestump && !dead.restless;
- break;
- }
+ const player = game.getPlayer(toID(target));
+ if (!player) {
+ return this.errorReply(`${target.trim()} is not a player.`);
}
- if (dead && repeat) return this.errorReply(`${dead.safeName} has already been ${cmd}ed.`);
- if (player || dead) {
- game.eliminate(toID(target), cmd);
- game.logAction(user, `${cmd}ed ${(dead || player).safeName}`);
- } else {
- this.errorReply(`${target.trim()} is not a player.`);
+
+ let repeat, elimType;
+
+ switch (cmd) {
+ case 'treestump':
+ elimType = MafiaEliminateType.TREESTUMP;
+ repeat = player.isTreestump() && !player.isSpirit();
+ break;
+ case 'spirit':
+ elimType = MafiaEliminateType.SPIRIT;
+ repeat = !player.isTreestump() && player.isSpirit();
+ break;
+ case 'spiritstump':
+ elimType = MafiaEliminateType.SPIRITSTUMP;
+ repeat = player.isTreestump() && player.isSpirit();
+ break;
+ case 'kick':
+ elimType = MafiaEliminateType.KICK;
+ break;
+ default:
+ elimType = MafiaEliminateType.ELIMINATE;
+ repeat = player.eliminated === MafiaEliminateType.ELIMINATE;
+ break;
}
+
+ if (repeat) return this.errorReply(`${player.safeName} has already been ${cmd}ed.`);
+
+ game.eliminate(player, elimType);
+ game.logAction(user, `${cmd}ed ${player.safeName}`);
},
killhelp: [
- `/mafia kill [player] - Kill a player, eliminating them from the game. Requires host % @ # &`,
+ `/mafia kill [player] - Kill a player, eliminating them from the game. Requires host % @ # ~`,
`/mafia treestump [player] - Kills a player, but allows them to talk during the day still.`,
`/mafia spirit [player] - Kills a player, but allows them to vote still.`,
`/mafia spiritstump [player] Kills a player, but allows them to talk and vote during the day.`,
@@ -2679,10 +2769,9 @@ export const commands: Chat.ChatCommands = {
}
if (!args[0]) return this.parse('/help mafia revealas');
for (const targetUsername of args) {
- let player = game.playerTable[toID(targetUsername)];
- if (!player) player = game.dead[toID(targetUsername)];
+ const player = game.getPlayer(toID(targetUsername));
if (player) {
- game.revealRole(user, player, `${cmd === 'revealas' ? revealAs : player.getRole()}`);
+ game.revealRole(user, player, `${cmd === 'revealas' ? revealAs : player.getStylizedRole()}`);
game.logAction(user, `revealed ${player.name}`);
if (cmd === 'revealas') {
game.secretLogAction(user, `fakerevealed ${player.name} as ${revealedRole!.role.name}`);
@@ -2693,8 +2782,8 @@ export const commands: Chat.ChatCommands = {
}
},
revealrolehelp: [
- `/mafia revealrole [player] - Reveals the role of a player. Requires host % @ # &`,
- `/mafia revealas [player], [role] - Fakereveals the role of a player as a certain role. Requires host % @ # &`,
+ `/mafia revealrole [player] - Reveals the role of a player. Requires host % @ # ~`,
+ `/mafia revealas [player], [role] - Fakereveals the role of a player as a certain role. Requires host % @ # ~`,
],
unidle: 'idle',
@@ -2704,12 +2793,19 @@ export const commands: Chat.ChatCommands = {
idle(target, room, user, connection, cmd) {
room = this.requireRoom();
const game = this.requireGame(Mafia);
- const player = game.playerTable[user.id];
+ const player = game.getPlayer(user.id);
if (!player) return this.errorReply(`You are not in the game of ${game.title}.`);
- if (game.phase !== 'night') return this.errorReply(`You can only submit an action or idle during the night phase.`);
+
+ if (player.isEliminated()) {
+ return this.errorReply(`You have been eliminated from the game and cannot take any actions.`);
+ }
+ if (game.phase !== 'night') {
+ return this.errorReply(`You can only submit an action or idle during the night phase.`);
+ }
if (!game.takeIdles) {
return this.errorReply(`The host is not accepting idles through the script. Send your action or idle to the host.`);
}
+
switch (cmd) {
case 'idle':
player.action = false;
@@ -2745,21 +2841,40 @@ export const commands: Chat.ChatCommands = {
`/mafia action [details] - Tells the host you are using an action with the given submission details.`,
],
- forceadd: 'revive',
- add: 'revive',
+ forceadd: 'add',
+ add(target, room, user, connection, cmd) {
+ room = this.requireRoom();
+ const game = this.requireGame(Mafia);
+ if (game.hostid !== user.id && !game.cohostids.includes(user.id)) this.checkCan('mute', null, room);
+ if (!toID(target)) return this.parse('/help mafia add');
+ const targetUser = Users.get(target);
+ if (!targetUser) {
+ throw new Chat.ErrorMessage(`The user "${target}" was not found.`);
+ }
+ game.join(targetUser, user, cmd === 'forceadd');
+ },
+ addhelp: [
+ `/mafia add [player] - Add a new player to the game. Requires host % @ # ~`,
+ ],
+
revive(target, room, user, connection, cmd) {
room = this.requireRoom();
const game = this.requireGame(Mafia);
if (game.hostid !== user.id && !game.cohostids.includes(user.id)) this.checkCan('mute', null, room);
if (!toID(target)) return this.parse('/help mafia revive');
- let didSomething = false;
- if (game.revive(user, toID(target), cmd === 'forceadd')) {
- didSomething = true;
+
+ const player = game.getPlayer(toID(target));
+ if (!player) {
+ throw new Chat.ErrorMessage(`"${target}" is not currently playing`);
}
- if (didSomething) game.logAction(user, `added players`);
+ if (!player.isEliminated()) {
+ throw new Chat.ErrorMessage(`${player.name} has not been eliminated.`);
+ }
+
+ game.revive(user, player);
},
revivehelp: [
- `/mafia revive [player] - Revive a player who died or add a new player to the game. Requires host % @ # &`,
+ `/mafia revive [player] - Revives a player who was eliminated. Requires host % @ # ~`,
],
dl: 'deadline',
@@ -2800,12 +2915,17 @@ export const commands: Chat.ChatCommands = {
const game = this.requireGame(Mafia);
if (game.hostid !== user.id && !game.cohostids.includes(user.id)) this.checkCan('mute', null, room);
if (!game.started) return this.errorReply(`The game has not started yet.`);
- const [player, mod] = target.split(',');
+ const [playerId, mod] = target.split(',');
+ const player = game.getPlayer(toID(playerId));
+ if (!player) {
+ throw new Chat.ErrorMessage(`The player "${playerId}" does not exist.`);
+ }
+
if (cmd === 'applyhammermodifier') {
- game.applyHammerModifier(user, toID(player), parseInt(mod));
+ game.applyHammerModifier(user, player, parseInt(mod));
game.secretLogAction(user, `changed a hammer modifier`);
} else {
- game.applyVoteModifier(user, toID(player), parseInt(mod));
+ game.applyVoteModifier(user, player, parseInt(mod));
game.secretLogAction(user, `changed a vote modifier`);
}
},
@@ -2872,7 +2992,7 @@ export const commands: Chat.ChatCommands = {
if (!game.started) return this.errorReply(`The game has not started yet.`);
target = toID(target);
- const targetPlayer = game.playerTable[target] || game.dead[target];
+ const targetPlayer = game.getPlayer(target as ID);
const silence = cmd === 'silence';
if (!targetPlayer) return this.errorReply(`${target} is not in the game of mafia.`);
if (silence === targetPlayer.silenced) {
@@ -2883,8 +3003,8 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `${!silence ? 'un' : ''}silenced a player`);
},
silencehelp: [
- `/mafia silence [player] - Silences [player], preventing them from talking at all. Requires host % @ # &`,
- `/mafia unsilence [player] - Removes a silence on [player], allowing them to talk again. Requires host % @ # &`,
+ `/mafia silence [player] - Silences [player], preventing them from talking at all. Requires host % @ # ~`,
+ `/mafia unsilence [player] - Removes a silence on [player], allowing them to talk again. Requires host % @ # ~`,
],
insomniac: 'nighttalk',
@@ -2897,7 +3017,7 @@ export const commands: Chat.ChatCommands = {
if (!game.started) return this.errorReply(`The game has not started yet.`);
target = toID(target);
- const targetPlayer = game.playerTable[target] || game.dead[target];
+ const targetPlayer = game.getPlayer(target as ID);
const nighttalk = !cmd.startsWith('un');
if (!targetPlayer) return this.errorReply(`${target} is not in the game of mafia.`);
if (nighttalk === targetPlayer.nighttalk) {
@@ -2908,8 +3028,8 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `${!nighttalk ? 'un' : ''}insomniacd a player`);
},
nighttalkhelp: [
- `/mafia nighttalk [player] - Makes [player] an insomniac, allowing them to talk freely during the night. Requires host % @ # &`,
- `/mafia unnighttalk [player] - Removes [player] as an insomniac, preventing them from talking during the night. Requires host % @ # &`,
+ `/mafia nighttalk [player] - Makes [player] an insomniac, allowing them to talk freely during the night. Requires host % @ # ~`,
+ `/mafia unnighttalk [player] - Removes [player] as an insomniac, preventing them from talking during the night. Requires host % @ # ~`,
],
actor: 'priest',
unactor: 'priest',
@@ -2921,7 +3041,7 @@ export const commands: Chat.ChatCommands = {
if (!game.started) return this.errorReply(`The game has not started yet.`);
target = toID(target);
- const targetPlayer = game.playerTable[target] || game.dead[target];
+ const targetPlayer = game.getPlayer(target as ID);
if (!targetPlayer) return this.errorReply(`${target} is not in the game of mafia.`);
const actor = cmd.endsWith('actor');
@@ -2944,13 +3064,13 @@ export const commands: Chat.ChatCommands = {
this.sendReply(`${targetPlayer.name} is now ${targetPlayer.hammerRestriction ? "an actor (can only hammer)" : "a priest (can't hammer)"}.`);
if (actor) {
// target is an actor, remove their vote because it's now impossible
- game.unvote(targetPlayer.id, true);
+ game.unvote(targetPlayer, true);
}
game.logAction(user, `made a player actor/priest`);
},
priesthelp: [
- `/mafia (un)priest [player] - Makes [player] a priest, preventing them from placing the hammer vote. Requires host % @ # &`,
- `/mafia (un)actor [player] - Makes [player] an actor, preventing them from placing non-hammer votes. Requires host % @ # &`,
+ `/mafia (un)priest [player] - Makes [player] a priest, preventing them from placing the hammer vote. Requires host % @ # ~`,
+ `/mafia (un)actor [player] - Makes [player] an actor, preventing them from placing non-hammer votes. Requires host % @ # ~`,
],
shifthammer: 'hammer',
@@ -3000,7 +3120,7 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `changed votelock status`);
},
votelockhelp: [
- `/mafia votelock [on|off] - Allows or disallows players to change their vote. Requires host % @ # &`,
+ `/mafia votelock [on|off] - Allows or disallows players to change their vote. Requires host % @ # ~`,
],
voting: 'votesall',
@@ -3019,7 +3139,7 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `changed voting status`);
},
votinghelp: [
- `/mafia voting [on|off] - Allows or disallows players to vote. Requires host % @ # &`,
+ `/mafia voting [on|off] - Allows or disallows players to vote. Requires host % @ # ~`,
],
enablenv: 'enablenl',
@@ -3037,7 +3157,7 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `changed novote status`);
},
enablenlhelp: [
- `/mafia [enablenv|disablenv] - Allows or disallows players abstain from voting. Requires host % @ # &`,
+ `/mafia [enablenv|disablenv] - Allows or disallows players abstain from voting. Requires host % @ # ~`,
],
forcevote(target, room, user) {
@@ -3060,7 +3180,7 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `changed forcevote status`);
},
forcevotehelp: [
- `/mafia forcevote [yes/no] - Forces players' votes onto themselves, and prevents unvoting. Requires host % @ # &`,
+ `/mafia forcevote [yes/no] - Forces players' votes onto themselves, and prevents unvoting. Requires host % @ # ~`,
],
votes(target, room, user) {
@@ -3091,7 +3211,8 @@ export const commands: Chat.ChatCommands = {
if (this.broadcasting) {
game.sendPlayerList();
} else {
- this.sendReplyBox(`Players (${game.playerCount}): ${Object.values(game.playerTable).map(p => p.safeName).sort().join(', ')}`);
+ const players = game.getRemainingPlayers();
+ this.sendReplyBox(`Players (${players.length}): ${players.map(p => p.safeName).sort().join(', ')}`);
}
},
@@ -3122,8 +3243,7 @@ export const commands: Chat.ChatCommands = {
return this.errorReply(`Only the host can view roles.`);
}
if (!game.started) return this.errorReply(`The game has not started.`);
- const players = [...Object.values(game.playerTable), ...Object.values(game.dead)];
- this.sendReplyBox(players.map(
+ this.sendReplyBox(game.players.map(
p => `${p.safeName}: ${p.role ? (p.role.alignment === 'solo' ? 'Solo ' : '') + p.role.safeName : 'No role'}`
).join(' '));
},
@@ -3151,16 +3271,18 @@ export const commands: Chat.ChatCommands = {
const game = this.requireGame(Mafia);
const args = target.split(',');
const action = toID(args.shift());
+ const player = game.getPlayer(user.id);
+
switch (action) {
case 'in':
- if (user.id in game.playerTable) {
+ if (player) {
// Check if they have requested to be subbed out.
if (!game.requestedSub.includes(user.id)) {
return this.errorReply(`You have not requested to be subbed out.`);
}
game.requestedSub.splice(game.requestedSub.indexOf(user.id), 1);
this.errorReply(`You have cancelled your request to sub out.`);
- game.playerTable[user.id].updateHtmlRoom();
+ player.updateHtmlRoom();
} else {
this.checkChat(null, room);
if (game.subs.includes(user.id)) return this.errorReply(`You are already on the sub list.`);
@@ -3174,12 +3296,15 @@ export const commands: Chat.ChatCommands = {
}
break;
case 'out':
- if (user.id in game.playerTable) {
+ if (player) {
+ if (player.isEliminated()) {
+ return this.errorReply(`You cannot request to be subbed out once eliminated.`);
+ }
if (game.requestedSub.includes(user.id)) {
return this.errorReply(`You have already requested to be subbed out.`);
}
game.requestedSub.push(user.id);
- game.playerTable[user.id].updateHtmlRoom();
+ player.updateHtmlRoom();
game.nextSub();
} else {
if (game.hostid === user.id || game.cohostids.includes(user.id)) {
@@ -3194,7 +3319,7 @@ export const commands: Chat.ChatCommands = {
case 'next':
if (game.hostid !== user.id && !game.cohostids.includes(user.id)) this.checkCan('mute', null, room);
const toSub = args.shift();
- if (!(toID(toSub) in game.playerTable)) return this.errorReply(`${toSub} is not in the game.`);
+ if (!game.getPlayer(toID(toSub))) return this.errorReply(`${toSub} is not in the game.`);
if (!game.subs.length) {
if (game.hostRequestedSub.includes(toID(toSub))) {
return this.errorReply(`${toSub} is already on the list to be subbed out.`);
@@ -3239,9 +3364,9 @@ export const commands: Chat.ChatCommands = {
break;
default:
if (game.hostid !== user.id && !game.cohostids.includes(user.id)) this.checkCan('mute', null, room);
- const toSubOut = action;
+ const toSubOut = game.getPlayer(action);
const toSubIn = toID(args.shift());
- if (!(toSubOut in game.playerTable)) return this.errorReply(`${toSubOut} is not in the game.`);
+ if (!toSubOut) return this.errorReply(`${toSubOut} is not in the game.`);
const targetUser = Users.get(toSubIn);
if (!targetUser) return this.errorReply(`The user "${toSubIn}" was not found.`);
@@ -3249,23 +3374,24 @@ export const commands: Chat.ChatCommands = {
if (game.subs.includes(targetUser.id)) {
game.subs.splice(game.subs.indexOf(targetUser.id), 1);
}
- if (game.hostRequestedSub.includes(toSubOut)) {
- game.hostRequestedSub.splice(game.hostRequestedSub.indexOf(toSubOut), 1);
+ if (game.hostRequestedSub.includes(toSubOut.id)) {
+ game.hostRequestedSub.splice(game.hostRequestedSub.indexOf(toSubOut.id), 1);
}
- if (game.requestedSub.includes(toSubOut)) {
- game.requestedSub.splice(game.requestedSub.indexOf(toSubOut), 1);
+ if (game.requestedSub.includes(toSubOut.id)) {
+ game.requestedSub.splice(game.requestedSub.indexOf(toSubOut.id), 1);
}
- game.sub(toSubOut, toSubIn);
+
+ game.sub(toSubOut, targetUser);
game.logAction(user, `substituted a player`);
}
},
subhelp: [
`/mafia sub in - Request to sub into the game, or cancel a request to sub out.`,
`/mafia sub out - Request to sub out of the game, or cancel a request to sub in.`,
- `/mafia sub next, [player] - Forcibly sub [player] out of the game. Requires host % @ # &`,
- `/mafia sub remove, [user] - Remove [user] from the sublist. Requres host % @ # &`,
- `/mafia sub unrequest, [player] - Remove's a player's request to sub out of the game. Requires host % @ # &`,
- `/mafia sub [player], [user] - Forcibly sub [player] for [user]. Requires host % @ # &`,
+ `/mafia sub next, [player] - Forcibly sub [player] out of the game. Requires host % @ # ~`,
+ `/mafia sub remove, [user] - Remove [user] from the sublist. Requres host % @ # ~`,
+ `/mafia sub unrequest, [player] - Remove's a player's request to sub out of the game. Requires host % @ # ~`,
+ `/mafia sub [player], [user] - Forcibly sub [player] for [user]. Requires host % @ # ~`,
],
autosub(target, room, user) {
@@ -3287,12 +3413,10 @@ export const commands: Chat.ChatCommands = {
game.logAction(user, `changed autosub status`);
},
autosubhelp: [
- `/mafia autosub [yes|no] - Sets if players will automatically sub out if a user is on the sublist. Requires host % @ # &`,
+ `/mafia autosub [yes|no] - Sets if players will automatically sub out if a user is on the sublist. Requires host % @ # ~`,
],
cohost: 'subhost',
- forcecohost: 'subhost',
- forcesubhost: 'subhost',
subhost(target, room, user, connection, cmd) {
room = this.requireRoom();
const game = this.requireGame(Mafia);
@@ -3303,15 +3427,11 @@ export const commands: Chat.ChatCommands = {
if (!room.users[targetUser.id]) return this.errorReply(`${targetUser.name} is not in this room, and cannot be hosted.`);
if (game.hostid === targetUser.id) return this.errorReply(`${targetUser.name} is already the host.`);
if (game.cohostids.includes(targetUser.id)) return this.errorReply(`${targetUser.name} is already a cohost.`);
- if (targetUser.id in game.playerTable) return this.errorReply(`The host cannot be ingame.`);
- if (targetUser.id in game.dead) {
- if (!cmd.includes('force')) {
- return this.errorReply(`${targetUser.name} could potentially be revived. To continue anyway, use /mafia force${cmd} ${target}.`);
- }
- if (game.dead[targetUser.id].voting) game.unvote(targetUser.id);
- game.dead[targetUser.id].destroy();
- delete game.dead[targetUser.id];
+
+ if (game.getPlayer(targetUser.id)) {
+ return this.errorReply(`${targetUser.name} cannot become a host because they are playing.`);
}
+
if (game.subs.includes(targetUser.id)) game.subs.splice(game.subs.indexOf(targetUser.id), 1);
if (cmd.includes('cohost')) {
game.cohostids.push(targetUser.id);
@@ -3370,7 +3490,7 @@ export const commands: Chat.ChatCommands = {
game.end();
this.modlog('MAFIAEND', null);
},
- endhelp: [`/mafia end - End the current game of mafia. Requires host + % @ # &`],
+ endhelp: [`/mafia end - End the current game of mafia. Requires host + % @ # ~`],
role: 'data',
alignment: 'data',
@@ -3385,8 +3505,10 @@ export const commands: Chat.ChatCommands = {
if (!game) {
return this.errorReply(`There is no game of mafia running in this room. If you meant to display information about a role, use /mafia role [role name]`);
}
- if (!(user.id in game.playerTable)) return this.errorReply(`You are not in the game of ${game.title}.`);
- const role = game.playerTable[user.id].role;
+
+ const player = game.getPlayer(user.id);
+ if (!player) return this.errorReply(`You are not in the game of ${game.title}.`);
+ const role = player.role;
if (!role) return this.errorReply(`You do not have a role yet.`);
return this.sendReplyBox(`Your role is: ${role.safeName}`);
}
@@ -3502,7 +3624,7 @@ export const commands: Chat.ChatCommands = {
for (let faction of args) {
faction = toID(faction);
const inFaction = [];
- for (const player of [...Object.values(game.playerTable), ...Object.values(game.dead)]) {
+ for (const player of game.players) {
if (player.role && toID(player.role.alignment) === faction) {
toGiveTo.push(player.id);
inFaction.push(player.id);
@@ -3593,7 +3715,7 @@ export const commands: Chat.ChatCommands = {
},
leaderboardhelp: [
`/mafia [leaderboard|mvpladder] - View the leaderboard or MVP ladder for the current or last month.`,
- `/mafia [hostlogs|playlogs|leaverlogs] - View the host, play, or leaver logs for the current or last month. Requires % @ # &`,
+ `/mafia [hostlogs|playlogs|leaverlogs] - View the host, play, or leaver logs for the current or last month. Requires % @ # ~`,
],
gameban: 'hostban',
@@ -3639,8 +3761,8 @@ export const commands: Chat.ChatCommands = {
this.privateModAction(`${targetUser.name} was banned from ${cmd === 'hostban' ? 'hosting' : 'playing'} mafia games by ${user.name}.`);
},
hostbanhelp: [
- `/mafia (un)hostban [user], [reason], [duration] - Ban a user from hosting games for [duration] days. Requires % @ # &`,
- `/mafia (un)gameban [user], [reason], [duration] - Ban a user from playing games for [duration] days. Requires % @ # &`,
+ `/mafia (un)hostban [user], [reason], [duration] - Ban a user from hosting games for [duration] days. Requires % @ # ~`,
+ `/mafia (un)gameban [user], [reason], [duration] - Ban a user from playing games for [duration] days. Requires % @ # ~`,
],
ban: 'gamebanhelp',
@@ -3701,7 +3823,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(`The role ${id} was added to the database.`);
},
addrolehelp: [
- `/mafia addrole name|alignment|image|memo1|memo2... - adds a role to the database. Name, memo are required. Requires % @ # &`,
+ `/mafia addrole name|alignment|image|memo1|memo2... - adds a role to the database. Name, memo are required. Requires % @ # ~`,
],
overwritealignment: 'addalignment',
@@ -3736,7 +3858,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(`The alignment ${id} was added to the database.`);
},
addalignmenthelp: [
- `/mafia addalignment name|plural|color|button color|image|memo1|memo2... - adds a memo to the database. Name, plural, memo are required. Requires % @ # &`,
+ `/mafia addalignment name|plural|color|button color|image|memo1|memo2... - adds a memo to the database. Name, plural, memo are required. Requires % @ # ~`,
],
overwritetheme: 'addtheme',
@@ -3784,7 +3906,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(`The theme ${id} was added to the database.`);
},
addthemehelp: [
- `/mafia addtheme name|description|players:rolelist|players:rolelist... - adds a theme to the database. Requires % @ # &`,
+ `/mafia addtheme name|description|players:rolelist|players:rolelist... - adds a theme to the database. Requires % @ # ~`,
],
overwriteidea: 'addidea',
@@ -3829,7 +3951,7 @@ export const commands: Chat.ChatCommands = {
},
addideahelp: [
`/mafia addidea name|choices (number)|pick1|pick2... (new line here)`,
- `(newline separated rolelist) - Adds an IDEA to the database. Requires % @ # &`,
+ `(newline separated rolelist) - Adds an IDEA to the database. Requires % @ # ~`,
],
overwriteterm: 'addterm',
@@ -3855,7 +3977,7 @@ export const commands: Chat.ChatCommands = {
this.modlog(`MAFIAADDTERM`, null, id, {noalts: true, noip: true});
this.sendReply(`The term ${id} was added to the database.`);
},
- addtermhelp: [`/mafia addterm name|memo1|memo2... - Adds a term to the database. Requires % @ # &`],
+ addtermhelp: [`/mafia addterm name|memo1|memo2... - Adds a term to the database. Requires % @ # ~`],
overwritealias: 'addalias',
addalias(target, room, user, connection, cmd) {
@@ -3882,7 +4004,7 @@ export const commands: Chat.ChatCommands = {
this.sendReply(`The alias ${from} was added, pointing to ${to}.`);
},
addaliashelp: [
- `/mafia addalias from,to - Adds an alias to the database, redirecting (from) to (to). Requires % @ # &`,
+ `/mafia addalias from,to - Adds an alias to the database, redirecting (from) to (to). Requires % @ # ~`,
],
deletedata(target, room, user) {
@@ -3926,7 +4048,7 @@ export const commands: Chat.ChatCommands = {
this.modlog(`MAFIADELETEDATA`, null, `${entry} from ${source}`, {noalts: true, noip: true});
this.sendReply(`The entry ${entry} was deleted from the ${source} database.`);
},
- deletedatahelp: [`/mafia deletedata source,entry - Removes an entry from the database. Requires % @ # &`],
+ deletedatahelp: [`/mafia deletedata source,entry - Removes an entry from the database. Requires % @ # ~`],
listdata(target, room, user) {
if (!(target in MafiaData)) {
return this.errorReply(`Invalid source. Valid sources are ${Object.keys(MafiaData).join(', ')}`);
@@ -3956,7 +4078,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('MAFIADISABLE', null);
return this.sendReply("Mafia has been disabled for this room.");
},
- disablehelp: [`/mafia disable - Disables mafia in this room. Requires # &`],
+ disablehelp: [`/mafia disable - Disables mafia in this room. Requires # ~`],
enable(target, room, user) {
room = this.requireRoom();
@@ -3969,7 +4091,7 @@ export const commands: Chat.ChatCommands = {
this.modlog('MAFIAENABLE', null);
return this.sendReply("Mafia has been enabled for this room.");
},
- enablehelp: [`/mafia enable - Enables mafia in this room. Requires # &`],
+ enablehelp: [`/mafia enable - Enables mafia in this room. Requires # ~`],
},
mafiahelp(target, room, user) {
if (!this.runBroadcast()) return;
@@ -3977,18 +4099,18 @@ export const commands: Chat.ChatCommands = {
buf += `General Commands`;
buf += [
` General Commands for the Mafia Plugin: `,
- `/mafia host [user] - Create a game of Mafia with [user] as the host. Roomvoices can only host themselves. Requires + % @ # &`,
- `/mafia nexthost - Host the next user in the host queue. Only works in the Mafia Room. Requires + % @ # &`,
- `/mafia forcehost [user] - Bypass the host queue and host [user]. Only works in the Mafia Room. Requires % @ # &`,
+ `/mafia host [user] - Create a game of Mafia with [user] as the host. Roomvoices can only host themselves. Requires + % @ # ~`,
+ `/mafia nexthost - Host the next user in the host queue. Only works in the Mafia Room. Requires + % @ # ~`,
+ `/mafia forcehost [user] - Bypass the host queue and host [user]. Only works in the Mafia Room. Requires % @ # ~`,
`/mafia sub [in|out] - Request to sub into the game, or cancel a request to sub out.`,
`/mafia spectate - Spectate the game of mafia.`,
`/mafia votes - Display the current vote count, and who's voting who.`,
`/mafia players - Display the current list of players, will highlight players.`,
`/mafia [rl|orl] - Display the role list or the original role list for the current game.`,
`/mafia data [alignment|role|modifier|theme|term] - Get information on a mafia alignment, role, modifier, theme, or term.`,
- `/mafia subhost [user] - Substitues the user as the new game host. Requires % @ # &`,
- `/mafia (un)cohost [user] - Adds/removes the user as a cohost. Cohosts can talk during the game, as well as perform host actions. Requires % @ # &`,
- `/mafia [enable|disable] - Enables/disables mafia in this room. Requires # &`,
+ `/mafia subhost [user] - Substitues the user as the new game host. Requires % @ # ~`,
+ `/mafia (un)cohost [user] - Adds/removes the user as a cohost. Cohosts can talk during the game, as well as perform host actions. Requires % @ # ~`,
+ `/mafia [enable|disable] - Enables/disables mafia in this room. Requires # ~`,
].join(' ');
buf += `Player Commands`;
buf += [
@@ -4005,74 +4127,75 @@ export const commands: Chat.ChatCommands = {
buf += `Host Commands`;
buf += [
` Commands for game hosts and Cohosts to use: `,
- `/mafia playercap [cap|none]- Limit the number of players able to join the game. Player cap cannot be more than 20 or less than 2. Requires host % @ # &`,
- `/mafia close - Closes signups for the current game. Requires host % @ # &`,
- `/mafia closedsetup [on|off] - Sets if the game is a closed setup. Closed setups don't show the role list to players. Requires host % @ # &`,
- `/mafia takeidles [on|off] - Sets if idles are accepted by the script or not. Requires host % @ # &`,
- `/mafia prod - Notifies players that they must submit an action or idle if they haven't yet. Requires host % @ # &`,
- `/mafia reveal [on|off] - Sets if roles reveal on death or not. Requires host % @ # &`,
- `/mafia selfvote [on|hammer|off] - Allows players to self vote either at hammer or anytime. Requires host % @ # &`,
- `/mafia [enablenl|disablenl] - Allows or disallows players abstain from voting. Requires host % @ # &`,
- `/mafia votelock [on|off] - Allows or disallows players to change their vote. Requires host % @ # &`,
- `/mafia voting [on|off] - Allows or disallows voting. Requires host % @ # &`,
- `/mafia forcevote [yes/no] - Forces players' votes onto themselves, and prevents unvoting. Requires host % @ # &`,
- `/mafia setroles [comma seperated roles] - Set the roles for a game of mafia. You need to provide one role per player. Requires host % @ # &`,
- `/mafia forcesetroles [comma seperated roles] - Forcibly set the roles for a game of mafia. No role PM information or alignment will be set. Requires host % @ # &`,
- `/mafia start - Start the game of mafia. Signups must be closed. Requires host % @ # &`,
- `/mafia [day|night] - Move to the next game day or night. Requires host % @ # &`,
- `/mafia extend (minutes) - Return to the previous game day. If (minutes) is provided, set the deadline for (minutes) minutes. Requires host % @ # &`,
- `/mafia kill [player] - Kill a player, eliminating them from the game. Requires host % @ # &`,
- `/mafia treestump [player] - Kills a player, but allows them to talk during the day still. Requires host % @ # &`,
- `/mafia spirit [player] - Kills a player, but allows them to vote still. Requires host % @ # &`,
- `/mafia spiritstump [player] - Kills a player, but allows them to talk and vote during the day. Requires host % @ # &`,
- `/mafia kick [player] - Kicks a player from the game without revealing their role. Requires host % @ # &`,
- `/mafia revive [player] - Revive a player who died or add a new player to the game. Requires host % @ # &`,
- `/mafia revealrole [player] - Reveals the role of a player. Requires host % @ # &`,
- `/mafia revealas [player], [role] - Fakereveals the role of a player as a certain role. Requires host % @ # &`,
- `/mafia (un)silence [player] - Silences [player], preventing them from talking at all. Requires host % @ # &`,
- `/mafia (un)nighttalk [player] - Allows [player] to talk freely during the night. Requires host % @ # &`,
- `/mafia (un)[priest|actor] [player] - Makes [player] a priest (can't hammer) or actor (can only hammer). Requires host % @ # &`,
+ `/mafia playercap [cap|none]- Limit the number of players able to join the game. Player cap cannot be more than 20 or less than 2. Requires host % @ # ~`,
+ `/mafia close - Closes signups for the current game. Requires host % @ # ~`,
+ `/mafia closedsetup [on|off] - Sets if the game is a closed setup. Closed setups don't show the role list to players. Requires host % @ # ~`,
+ `/mafia takeidles [on|off] - Sets if idles are accepted by the script or not. Requires host % @ # ~`,
+ `/mafia prod - Notifies players that they must submit an action or idle if they haven't yet. Requires host % @ # ~`,
+ `/mafia reveal [on|off] - Sets if roles reveal on death or not. Requires host % @ # ~`,
+ `/mafia selfvote [on|hammer|off] - Allows players to self vote either at hammer or anytime. Requires host % @ # ~`,
+ `/mafia [enablenl|disablenl] - Allows or disallows players abstain from voting. Requires host % @ # ~`,
+ `/mafia votelock [on|off] - Allows or disallows players to change their vote. Requires host % @ # ~`,
+ `/mafia voting [on|off] - Allows or disallows voting. Requires host % @ # ~`,
+ `/mafia forcevote [yes/no] - Forces players' votes onto themselves, and prevents unvoting. Requires host % @ # ~`,
+ `/mafia setroles [comma seperated roles] - Set the roles for a game of mafia. You need to provide one role per player. Requires host % @ # ~`,
+ `/mafia forcesetroles [comma seperated roles] - Forcibly set the roles for a game of mafia. No role PM information or alignment will be set. Requires host % @ # ~`,
+ `/mafia start - Start the game of mafia. Signups must be closed. Requires host % @ # ~`,
+ `/mafia [day|night] - Move to the next game day or night. Requires host % @ # ~`,
+ `/mafia extend (minutes) - Return to the previous game day. If (minutes) is provided, set the deadline for (minutes) minutes. Requires host % @ # ~`,
+ `/mafia kill [player] - Kill a player, eliminating them from the game. Requires host % @ # ~`,
+ `/mafia treestump [player] - Kills a player, but allows them to talk during the day still. Requires host % @ # ~`,
+ `/mafia spirit [player] - Kills a player, but allows them to vote still. Requires host % @ # ~`,
+ `/mafia spiritstump [player] - Kills a player, but allows them to talk and vote during the day. Requires host % @ # ~`,
+ `/mafia kick [player] - Kicks a player from the game without revealing their role. Requires host % @ # ~`,
+ `/mafia revive [player] - Revives a player who was eliminated. Requires host % @ # ~`,
+ `/mafia add [player] - Adds a new player to the game. Requires host % @ # ~`,
+ `/mafia revealrole [player] - Reveals the role of a player. Requires host % @ # ~`,
+ `/mafia revealas [player], [role] - Fakereveals the role of a player as a certain role. Requires host % @ # ~`,
+ `/mafia (un)silence [player] - Silences [player], preventing them from talking at all. Requires host % @ # ~`,
+ `/mafia (un)nighttalk [player] - Allows [player] to talk freely during the night. Requires host % @ # ~`,
+ `/mafia (un)[priest|actor] [player] - Makes [player] a priest (can't hammer) or actor (can only hammer). Requires host % @ # ~`,
`/mafia deadline [minutes|off] - Sets or removes the deadline for the game. Cannot be more than 20 minutes.`,
- `/mafia sub next, [player] - Forcibly sub [player] out of the game. Requires host % @ # &`,
- `/mafia sub remove, [user] - Forcibly remove [user] from the sublist. Requres host % @ # &`,
- `/mafia sub unrequest, [player] - Remove's a player's request to sub out of the game. Requires host % @ # &`,
- `/mafia sub [player], [user] - Forcibly sub [player] for [user]. Requires host % @ # &`,
- `/mafia autosub [yes|no] - Sets if players will automatically sub out if a user is on the sublist. Defaults to yes. Requires host % @ # &`,
- `/mafia (un)[love|hate] [player] - Makes it take 1 more (love) or less (hate) vote to hammer [player]. Requires host % @ # &`,
- `/mafia (un)[mayor|voteless] [player] - Makes [player]'s' vote worth 2 votes (mayor) or makes [player]'s vote worth 0 votes (voteless). Requires host % @ # &`,
+ `/mafia sub next, [player] - Forcibly sub [player] out of the game. Requires host % @ # ~`,
+ `/mafia sub remove, [user] - Forcibly remove [user] from the sublist. Requres host % @ # ~`,
+ `/mafia sub unrequest, [player] - Remove's a player's request to sub out of the game. Requires host % @ # ~`,
+ `/mafia sub [player], [user] - Forcibly sub [player] for [user]. Requires host % @ # ~`,
+ `/mafia autosub [yes|no] - Sets if players will automatically sub out if a user is on the sublist. Defaults to yes. Requires host % @ # ~`,
+ `/mafia (un)[love|hate] [player] - Makes it take 1 more (love) or less (hate) vote to hammer [player]. Requires host % @ # ~`,
+ `/mafia (un)[mayor|voteless] [player] - Makes [player]'s' vote worth 2 votes (mayor) or makes [player]'s vote worth 0 votes (voteless). Requires host % @ # ~`,
`/mafia hammer [hammer] - sets the hammer count to [hammer] and resets votes`,
`/mafia hammer off - disables hammering`,
`/mafia shifthammer [hammer] - sets the hammer count to [hammer] without resetting votes`,
`/mafia resethammer - sets the hammer to the default, resetting votes`,
`/mafia playerroles - View all the player's roles in chat. Requires host`,
- `/mafia resetgame - Resets game data. Does not change settings from the host besides deadlines or add/remove any players. Requires host % @ # &`,
- `/mafia end - End the current game of mafia. Requires host + % @ # &`,
+ `/mafia resetgame - Resets game data. Does not change settings from the host besides deadlines or add/remove any players. Requires host % @ # ~`,
+ `/mafia end - End the current game of mafia. Requires host + % @ # ~`,
].join(' ');
buf += `IDEA Module Commands`;
buf += [
` Commands for using IDEA modules `,
- `/mafia idea [idea] - starts the IDEA module [idea]. Requires + % @ # &, voices can only start for themselves`,
- `/mafia ideareroll - rerolls the IDEA module. Requires host % @ # &`,
+ `/mafia idea [idea] - starts the IDEA module [idea]. Requires + % @ # ~, voices can only start for themselves`,
+ `/mafia ideareroll - rerolls the IDEA module. Requires host % @ # ~`,
`/mafia ideapick [selection], [role] - selects a role`,
`/mafia ideadiscards - shows the discarded roles`,
- `/mafia ideadiscards [off|on] - hides discards from the players. Requires host % @ # &`,
+ `/mafia ideadiscards [off|on] - hides discards from the players. Requires host % @ # ~`,
`/mafia customidea choices, picks (new line here, shift+enter)`,
- `(comma or newline separated rolelist) - Starts an IDEA module with custom roles. Requires % @ # &`,
+ `(comma or newline separated rolelist) - Starts an IDEA module with custom roles. Requires % @ # ~`,
].join(' ');
buf += ``;
buf += `Mafia Room Specific Commands`;
buf += [
` Commands that are only useable in the Mafia Room: `,
- `/mafia queue add, [user] - Adds the user to the host queue. Requires + % @ # &, voices can only add themselves.`,
- `/mafia queue remove, [user] - Removes the user from the queue. You can remove yourself regardless of rank. Requires % @ # &.`,
+ `/mafia queue add, [user] - Adds the user to the host queue. Requires + % @ # ~, voices can only add themselves.`,
+ `/mafia queue remove, [user] - Removes the user from the queue. You can remove yourself regardless of rank. Requires % @ # ~.`,
`/mafia queue - Shows the list of users who are in queue to host.`,
`/mafia win (points) [user1], [user2], [user3], ... - Award the specified users points to the mafia leaderboard for this month. The amount of points can be negative to take points. Defaults to 10 points.`,
- `/mafia winfaction (points), [faction] - Award the specified points to all the players in the given faction. Requires % @ # &`,
+ `/mafia winfaction (points), [faction] - Award the specified points to all the players in the given faction. Requires % @ # ~`,
`/mafia (un)mvp [user1], [user2], ... - Gives a MVP point and 10 leaderboard points to the users specified.`,
`/mafia [leaderboard|mvpladder] - View the leaderboard or MVP ladder for the current or last month.`,
- `/mafia [hostlogs|playlogs] - View the host logs or play logs for the current or last month. Requires % @ # &`,
- `/mafia (un)hostban [user], [duration] - Ban a user from hosting games for [duration] days. Requires % @ # &`,
- `/mafia (un)gameban [user], [duration] - Ban a user from playing games for [duration] days. Requires % @ # &`,
+ `/mafia [hostlogs|playlogs] - View the host logs or play logs for the current or last month. Requires % @ # ~`,
+ `/mafia (un)hostban [user], [duration] - Ban a user from hosting games for [duration] days. Requires % @ # ~`,
+ `/mafia (un)gameban [user], [duration] - Ban a user from playing games for [duration] days. Requires % @ # ~`,
].join(' ');
buf += ``;
diff --git a/server/chat-plugins/modlog-viewer.ts b/server/chat-plugins/modlog-viewer.ts
index cef81f7ea48a..eeecbfaff04e 100644
--- a/server/chat-plugins/modlog-viewer.ts
+++ b/server/chat-plugins/modlog-viewer.ts
@@ -356,7 +356,7 @@ export const commands: Chat.ChatCommands = {
if (!target) return this.parse(`/help modlogstats`);
return this.parse(`/join view-modlogstats-${target}`);
},
- modlogstatshelp: [`/modlogstats [userid] - Fetch all information on that [userid] from the modlog (IPs, alts, etc). Requires: @ &`],
+ modlogstatshelp: [`/modlogstats [userid] - Fetch all information on that [userid] from the modlog (IPs, alts, etc). Requires: @ ~`],
};
export const pages: Chat.PageTable = {
@@ -389,7 +389,7 @@ export const pages: Chat.PageTable = {
if (entry.ip) {
let ipTable = punishmentsByIp.get(entry.ip);
if (!ipTable) {
- ipTable = new Utils.Multiset();
+ ipTable = new Utils.Multiset();
punishmentsByIp.set(entry.ip, ipTable);
}
ipTable.add(entry.action);
@@ -448,7 +448,7 @@ export const pages: Chat.PageTable = {
for (const [ip, table] of punishmentsByIp) {
buf += `
`;
- return buf;
- },
- },
-};
-
-Chat.multiLinePattern.register(`/sampleteams add `);
diff --git a/server/chat-plugins/scavenger-games.ts b/server/chat-plugins/scavenger-games.ts
index 08ebad8b32a8..beeec1aadc36 100644
--- a/server/chat-plugins/scavenger-games.ts
+++ b/server/chat-plugins/scavenger-games.ts
@@ -7,7 +7,7 @@
* @license MIT license
*/
-import {ScavengerHunt, ScavengerHuntPlayer} from './scavengers';
+import {ScavengerHunt, ScavengerHuntPlayer, sanitizeAnswer} from './scavengers';
import {Utils} from '../../lib';
export type TwistEvent = (this: ScavengerHunt, ...args: any[]) => void;
@@ -92,10 +92,9 @@ class Leaderboard {
async htmlLadder(): Promise {
const data = await this.visualize('points');
- const display = `
Rank
Name
Points
${data.map(line =>
+ return `
Rank
Name
Points
${data.map(line =>
`
${line.rank}
${line.name}
${line.points}
`).join('')
}
`;
- return display;
}
}
@@ -341,9 +340,9 @@ const TWISTS: {[k: string]: Twist} = {
return true;
},
- onLeave(user) {
- for (const ip of user.ips) {
- this.altIps[ip] = {id: user.id, name: user.name};
+ onLeave(player) {
+ for (const ip of player.joinIps) {
+ this.altIps[ip] = {id: player.id, name: player.name};
}
},
@@ -356,15 +355,21 @@ const TWISTS: {[k: string]: Twist} = {
onComplete(player, time, blitz) {
const now = Date.now();
- const takenTime = Chat.toDurationString(now - this.startTimes[player.id], {hhmmss: true});
- const result = {name: player.name, id: player.id, time: takenTime, blitz};
+ const takenTime = now - this.startTimes[player.id];
+ const result = {
+ name: player.name,
+ id: player.id,
+ time: Chat.toDurationString(takenTime, {hhmmss: true}),
+ duration: takenTime,
+ blitz,
+ };
this.completed.push(result);
const place = Utils.formatOrder(this.completed.length);
this.announce(
Utils.html`${result.name} is the ${place} player to finish the hunt! (${takenTime}${(blitz ? " - BLITZ" : "")})`
);
- Utils.sortBy(this.completed, entry => entry.time);
+ Utils.sortBy(this.completed, entry => entry.duration);
player.destroy(); // remove from user.games;
return true;
@@ -400,7 +405,7 @@ const TWISTS: {[k: string]: Twist} = {
const currentQuestion = player.currentQuestion;
if (currentQuestion + 1 === this.questions.length) {
- this.guesses[player.id] = value.split(',').map((part: string) => toID(part));
+ this.guesses[player.id] = value.split(',').map((part: string) => sanitizeAnswer(part));
this.onComplete(player);
return true;
@@ -506,7 +511,7 @@ const TWISTS: {[k: string]: Twist} = {
const curr = player.currentQuestion;
if (!this.guesses[curr][player.id]) this.guesses[curr][player.id] = new Set();
- this.guesses[curr][player.id].add(toID(value));
+ this.guesses[curr][player.id].add(sanitizeAnswer(value));
throw new Chat.ErrorMessage("That is not the answer - try again!");
},
@@ -517,11 +522,8 @@ const TWISTS: {[k: string]: Twist} = {
const mines: {mine: string, users: string[]}[][] = [];
- for (let index = 0; index < this.mines.length; index++) {
- mines[index] = [];
- for (const mine of this.mines[index]) {
- mines[index].push({mine: mine.substr(1), users: []});
- }
+ for (const mineSet of this.mines as string[][]) {
+ mines.push(mineSet.map(mine => ({mine: mine.substr(1), users: [] as string[]})));
}
for (const player of Object.values(this.playerTable)) {
@@ -541,7 +543,7 @@ const TWISTS: {[k: string]: Twist} = {
`${this.completed.length > sliceIndex ? `Consolation Prize: ${this.completed.slice(sliceIndex).map(e => `${Utils.escapeHTML(e.name)}[${e.time}]`).join(', ')} ` : ''} ` +
`Solution: ` +
`${this.questions.map((q, i) => (
- `${i + 1}) ${Chat.formatText(q.hint)} [${Utils.escapeHTML(q.answer.join(' / '))}] ` +
+ `${i + 1}) ${this.formatOutput(q.hint)} [${Utils.escapeHTML(q.answer.join(' / '))}] ` +
`Mines: ${mines[i].map(({mine, users}) => Utils.escapeHTML(`${mine}: ${users.join(' / ') || '-'}`)).join(' ')}`
)).join(" ")}` +
``
@@ -555,9 +557,10 @@ const TWISTS: {[k: string]: Twist} = {
const mines: string[] = this.mines[q];
for (const [playerId, guesses] of Object.entries(guessObj)) {
const player = this.playerTable[playerId];
+ if (!player) continue;
if (!player.mines) player.mines = [];
(player.mines as {index: number, mine: string}[]).push(...mines
- .filter(mine => (guesses as Set).has(toID(mine)))
+ .filter(mine => (guesses as Set).has(sanitizeAnswer(mine)))
.map(mine => ({index: q, mine: mine.substr(1)})));
}
}
@@ -784,11 +787,11 @@ const MODES: {[k: string]: GameMode | string} = {
if (staffHost) staffHost.sendTo(this.room, `${targetUser.name} has received their first hint early.`);
targetUser.sendTo(
this.room,
- `|raw|The first hint to the next hunt is: ${Chat.formatText(this.questions[0].hint)}`
+ `|raw|The first hint to the next hunt is:
${this.formatOutput(this.questions[0].hint)}
`
);
targetUser.sendTo(
this.room,
- `|notify|Early Hint|The first hint to the next hunt is: ${Chat.formatText(this.questions[0].hint)}`
+ `|notify|Early Hint|The first hint to the next hunt is: