diff --git a/CODEOWNERS b/CODEOWNERS
index 3aad92168b..96bd1e44d1 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -16,5 +16,6 @@ server/chat-plugins/rock-paper-scissors.ts @mia-pi-git
server/chat-plugins/scavenger*.ts @xfix @sparkychildcharlie
server/chat-plugins/the-studio.ts @KrisXV
server/chat-plugins/trivia.ts @AnnikaCodes
+server/chat-plugins/username-prefixes.ts @AnnikaCodes
server/modlog.ts @monsanto
test/random-battles/* @AnnikaCodes
diff --git a/server/chat-plugins/username-prefixes.ts b/server/chat-plugins/username-prefixes.ts
new file mode 100644
index 0000000000..2e3ee44f21
--- /dev/null
+++ b/server/chat-plugins/username-prefixes.ts
@@ -0,0 +1,127 @@
+/**
+ * Code to manage username prefixes that force battles to be public or disable modchat.
+ * @author Annika
+ */
+
+import {FS} from '../../lib';
+
+const PREFIXES_FILE = 'config/chat-plugins/username-prefixes.json';
+
+export class PrefixManager {
+ constructor() {
+ // after a restart/newly using the plugin, load prefixes from config.js
+ if (!Chat.oldPlugins['username-prefixes']) this.refreshConfig(true);
+ }
+
+ save() {
+ FS(PREFIXES_FILE).writeUpdate(() => JSON.stringify(Config.forcedprefixes || {}));
+ }
+
+ refreshConfig(configJustLoaded = false) {
+ if (!Config.forcedprefixes) Config.forcedprefixes = {};
+ if (configJustLoaded) {
+ // if we just loaded the config file, ensure that all prefixes are IDs
+ if (Config.forcedprefixes.privacy) Config.forcedprefixes.privacy = Config.forcedprefixes.privacy.map(toID);
+ if (Config.forcedprefixes.modchat) Config.forcedprefixes.modchat = Config.forcedprefixes.modchat.map(toID);
+ }
+
+ let data: AnyObject;
+ try {
+ data = JSON.parse(FS(PREFIXES_FILE).readSync());
+ } catch (e) {
+ if (e.code !== 'ENOENT') throw e;
+ return;
+ }
+ for (const [type, prefixes] of Object.values(data)) {
+ if (!Config.forcedprefixes[type]) Config.forcedprefixes[type] = [];
+ for (const prefix of prefixes) {
+ if (Config.forcedprefixes[type].includes(prefix)) continue;
+ Config.forcedprefixes[type].push(prefix);
+ }
+ }
+ }
+
+ addPrefix(prefix: ID, type: 'privacy' | 'modchat') {
+ if (!Config.forcedprefixes[type]) Config.forcedprefixes[type] = [];
+ if (Config.forcedprefixes[type].includes(prefix)) {
+ throw new Chat.ErrorMessage(`Username prefix '${prefix}' is already configured to force ${type}.`);
+ }
+
+ Config.forcedprefixes[type].push(prefix);
+ this.save();
+ }
+
+ removePrefix(prefix: ID, type: 'privacy' | 'modchat') {
+ if (!Config.forcedprefixes[type]?.includes(prefix)) {
+ throw new Chat.ErrorMessage(`Username prefix '${prefix}' is not configured to force ${type}!`);
+ }
+
+ Config.forcedprefixes[type] = Config.forcedprefixes[type].filter((curPrefix: ID) => curPrefix !== prefix);
+ this.save();
+ }
+
+ validateType(type: string) {
+ if (type !== 'privacy' && type !== 'modchat') {
+ throw new Chat.ErrorMessage(`'${type}' is not a valid type of forced prefix. Valid types are 'privacy' and 'modchat'.`);
+ }
+ return type;
+ }
+}
+
+export const prefixManager = new PrefixManager();
+
+export const commands: Chat.ChatCommands = {
+ forcedprefix: 'usernameprefix',
+ forcedprefixes: 'usernameprefix',
+ usernameprefixes: 'usernameprefix',
+ usernameprefix: {
+ help: '',
+ ''() {
+ this.parse(`/help forcedprefix`);
+ },
+
+ delete: 'add',
+ remove: 'add',
+ add(target, room, user, connection, cmd) {
+ this.checkCan('rangeban');
+
+ const isAdding = cmd.includes('add');
+
+ const [prefix, type] = target.split(',').map(toID);
+ if (!prefix || !type) return this.parse(`/help usernameprefix`);
+ if (prefix.length > 18) {
+ throw new Chat.ErrorMessage(`Specified prefix '${prefix}' is longer than the maximum user ID length.`);
+ }
+
+ if (isAdding) {
+ prefixManager.addPrefix(prefix, prefixManager.validateType(type));
+ } else {
+ prefixManager.removePrefix(prefix, prefixManager.validateType(type));
+ }
+
+ this.globalModlog(`FORCEDPREFIX ${isAdding ? 'ADD' : 'REMOVE'}`, null, `'${prefix}' ${isAdding ? 'to' : 'from'} ${type}`);
+ this.addGlobalModAction(`${user.name} set the username prefix ${prefix} to${isAdding ? '' : ' no longer'} disable ${type}.`);
+ },
+
+ view(target) {
+ this.checkCan('rangeban');
+
+ const types = target ? [prefixManager.validateType(toID(target))] : ['privacy', 'modchat'];
+
+ return this.sendReplyBox(types.map(type => {
+ const info = Config.forcedprefixes[type].length ?
+ `${Config.forcedprefixes[type].join(', ')}` : `none`;
+ return `Username prefixes that disable ${type}: ${info}.`;
+ }).join(`
`));
+ },
+ },
+ usernameprefixhelp() {
+ return this.sendReplyBox(
+ `/usernameprefix add [prefix], [type]: Sets the username prefix [prefix] to disable privacy or modchat on battles where at least one player has the prefix.
` +
+ `/usernameprefix remove [prefix], [type]: Removes a prefix configuration.
` +
+ `/usernameprefix view [optional type]: Displays the currently configured username prefixes.
` +
+ `Valid types are privacy (which forces battles to take place in public rooms) and modchat (which prevents players from setting moderated chat).
` +
+ `Requires: &`
+ );
+ },
+};
diff --git a/server/index.ts b/server/index.ts
index eaa0a2e30a..e6a73d7fd3 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -90,6 +90,9 @@ if (Config.watchconfig) {
FS(require.resolve('../config/config')).onModify(() => {
try {
global.Config = ConfigLoader.load(true);
+ // ensure that battle prefixes configured via the chat plugin are not overwritten
+ // by battle prefixes manually specified in config.js
+ Chat.plugins['username-prefixes']?.prefixManager.refreshConfig(true);
Monitor.notice('Reloaded ../config/config.js');
} catch (e) {
Monitor.adminlog("Error reloading ../config/config.js: " + e.stack);
diff --git a/test/server/chat-plugins/username-prefixes.js b/test/server/chat-plugins/username-prefixes.js
new file mode 100644
index 0000000000..58f2e1ced5
--- /dev/null
+++ b/test/server/chat-plugins/username-prefixes.js
@@ -0,0 +1,44 @@
+/**
+ * Tests for the username-prefixes chat plugin.
+ * @author Annika
+ */
+'use strict';
+
+const assert = require('assert').strict;
+const {PrefixManager} = require('../../../.server-dist/chat-plugins/username-prefixes');
+
+describe('PrefixManager', () => {
+ beforeEach(() => {
+ this.prefixManager = new PrefixManager();
+ Config.forcedprefixes = {privacy: [], modchat: []};
+ });
+
+ it('Config.forcedprefixes should reflect prefix additions and removals', () => {
+ this.prefixManager.addPrefix('forcedpublic', 'privacy');
+ this.prefixManager.addPrefix('nomodchat', 'modchat');
+
+ assert(Config.forcedprefixes.privacy.includes('forcedpublic'));
+ assert(Config.forcedprefixes.modchat.includes('nomodchat'));
+
+ this.prefixManager.removePrefix('forcedpublic', 'privacy');
+ this.prefixManager.removePrefix('nomodchat', 'modchat');
+
+ assert(!Config.forcedprefixes.privacy.includes('forcedpublic'));
+ assert(!Config.forcedprefixes.modchat.includes('nomodchat'));
+ });
+
+ it('should not overwrite manually specified prefixes', () => {
+ Config.forcedprefixes.modchat = ['manual'];
+ this.prefixManager.addPrefix('nomodchat', 'modchat');
+
+ assert.deepEqual(Config.forcedprefixes.modchat, ['manual', 'nomodchat']);
+ });
+
+ it('should correctly validate prefix types', () => {
+ assert.doesNotThrow(() => this.prefixManager.validateType('privacy'));
+ assert.doesNotThrow(() => this.prefixManager.validateType('modchat'));
+
+ assert.throws(() => this.prefixManager.validateType('gibberish'));
+ assert.throws(() => this.prefixManager.validateType(''));
+ });
+});