feat: NODE_PROFANITY_FILTER option to disable chat censoring

The filter runs in two independent layers, both now gated:

- Engine: WordEnc.filter skips censoring (bad words, domains, TLDs,
  fragments) when NODE_PROFANITY_FILTER=false, keeping casing
  normalization. Covers public chat ingest and PM encode.
- Browser client: the setting is injected via bot.ejs/client.ejs into
  the Client constructor and sets WordFilter.enabled, so the local
  re-filter follows the server automatically.
- Lite client: new SessionOptions.profanityFilter; the runner reads
  PROFANITY_FILTER from bot.env / process env (no server config
  channel exists headless).

Default unchanged (filter on).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-06 23:41:32 -04:00
1 parent 515f29ded5
commit 933fe0dbe0
10 files changed
+68 -23

No files matched your search

+2
View File
@@ -91,6 +91,8 @@ Running the server locally has many advantages, primary being the ability to set
You can set tickrate in `server/engine/.env` via the `NODE_TICKRATE` variable (default is 400ms, try 200ms or 30ms for faster gameplay, especially useful for headless testing). You can also change it at runtime with the in-game `::speed <ms>` command (minimum 20ms, doesn't persist across restarts).
The chat profanity filter can be disabled with `NODE_PROFANITY_FILTER=false` (default on). The server censors chat before broadcasting and injects the setting into the browser client; headless lite runners have no config channel from the server, so also set `PROFANITY_FILTER=false` in the bot's `bot.env` (or process env) to stop their local re-censoring.
You want all three of these running:
```sh
+13 -10
View File
@@ -4,6 +4,7 @@ import WordEncFragments from '#/cache/wordenc/WordEncFragments.js';
import WordEncTlds from '#/cache/wordenc/WordEncTlds.js';
import Jagfile from '#/io/Jagfile.js';
import Packet from '#/io/Packet.js';
import Environment from '#/util/Environment.js';
export default class WordEnc {
static PERIOD = new Uint16Array(
@@ -70,16 +71,18 @@ export default class WordEnc {
const trimmed = characters.join('').trim();
const lowercase = trimmed.toLowerCase();
const filtered = [...lowercase];
this.wordEncTlds.filter(filtered);
this.wordEncBadWords.filter(filtered);
this.wordEncDomains.filter(filtered);
this.wordEncFragments.filter(filtered);
for (let index = 0; index < this.whitelist.length; index++) {
let offset = -1;
while ((offset = lowercase.indexOf(this.whitelist[index], offset + 1)) !== -1) {
const whitelisted: string[] = [...this.whitelist[index]];
for (let charIndex = 0; charIndex < whitelisted.length; charIndex++) {
filtered[charIndex + offset] = whitelisted[charIndex];
if (Environment.NODE_PROFANITY_FILTER) {
this.wordEncTlds.filter(filtered);
this.wordEncBadWords.filter(filtered);
this.wordEncDomains.filter(filtered);
this.wordEncFragments.filter(filtered);
for (let index = 0; index < this.whitelist.length; index++) {
let offset = -1;
while ((offset = lowercase.indexOf(this.whitelist[index], offset + 1)) !== -1) {
const whitelisted: string[] = [...this.whitelist[index]];
for (let charIndex = 0; charIndex < whitelisted.length; charIndex++) {
filtered[charIndex + offset] = whitelisted[charIndex];
}
}
}
}
+5
View File
@@ -42,6 +42,11 @@ export default {
// stalls at ~60% ("Connecting to update server"). Required for the bot client to load.
NODE_WS_ONDEMAND: tryParseBoolean(process.env.NODE_WS_ONDEMAND, true),
NODE_INFINITE_RUN: tryParseBoolean(process.env.NODE_INFINITE_RUN, true),
// When false, WordEnc.filter stops censoring chat (bad words, domains, TLDs)
// but still normalizes casing. Also injected into the browser client via
// bot.ejs/client.ejs so its local WordFilter matches; the lite client has no
// config channel and reads PROFANITY_FILTER from bot.env instead.
NODE_PROFANITY_FILTER: tryParseBoolean(process.env.NODE_PROFANITY_FILTER, true),
NODE_RANDOM_EVENTS: tryParseBoolean(process.env.NODE_RANDOM_EVENTS, false),
NODE_TICKRATE: tryParseInt(process.env.NODE_TICKRATE, 400),
HISCORES_WEB_PORT: tryParseInt(process.env.HISCORES_WEB_PORT, 8889),
+3
View File
@@ -24,6 +24,7 @@ export async function handleClientPage(url: URL): Promise<Response | null> {
members: Environment.NODE_MEMBERS,
botUsername,
maxMessageLength: Environment.node.maxMessageLength,
profanityFilter: Environment.NODE_PROFANITY_FILTER,
cachebust: CLIENT_CACHEBUST,
per_deployment_token: Environment.WEB_SOCKET_TOKEN_PROTECTION ? getPublicPerDeploymentToken() : ''
}), {
@@ -39,6 +40,7 @@ export async function handleClientPage(url: URL): Promise<Response | null> {
nodeid: Environment.NODE_ID,
lowmem,
members: Environment.NODE_MEMBERS,
profanityFilter: Environment.NODE_PROFANITY_FILTER,
cachebust: CLIENT_CACHEBUST,
per_deployment_token: Environment.WEB_SOCKET_TOKEN_PROTECTION ? getPublicPerDeploymentToken() : ''
}), {
@@ -64,6 +66,7 @@ export async function handleClientPage(url: URL): Promise<Response | null> {
nodeid: Environment.NODE_ID,
lowmem,
members: Environment.NODE_MEMBERS,
profanityFilter: Environment.NODE_PROFANITY_FILTER,
cachebust: CLIENT_CACHEBUST,
per_deployment_token: Environment.WEB_SOCKET_TOKEN_PROTECTION ? getPublicPerDeploymentToken() : ''
}), {
+1 -1
View File
@@ -437,7 +437,7 @@
let clientInstance = null;
(() => {
clientInstance = new Client(<%= nodeid %>, <%= lowmem %>, <%= members %>, <%= maxMessageLength %>);
clientInstance = new Client(<%= nodeid %>, <%= lowmem %>, <%= members %>, <%= maxMessageLength %>, <%= profanityFilter %>);
window.gameClient = clientInstance;
// Auto-initialize from URL params (login, fps)
autoInitFromUrlParams();
+1 -1
View File
@@ -415,7 +415,7 @@
import { Client } from './client/client.js?v=<%= cachebust %>';
(() => {
new Client(<%= nodeid %>, <%= lowmem %>, <%= members %>);
new Client(<%= nodeid %>, <%= lowmem %>, <%= members %>, undefined, <%= profanityFilter %>);
})();
loadSettings();
+7 -1
View File
@@ -659,7 +659,7 @@ export class Client extends GameShell {
// ----
constructor(nodeid: number, lowmem: boolean, members: boolean, maxMessageLength?: number) {
constructor(nodeid: number, lowmem: boolean, members: boolean, maxMessageLength?: number, profanityFilter?: boolean) {
super();
this.searchParams = new URLSearchParams(window.location.search);
@@ -669,6 +669,12 @@ export class Client extends GameShell {
Client.maxMessageLength = maxMessageLength;
}
// rs-sdk: server-injected (bot.ejs/client.ejs) so the local filter matches
// the server's NODE_PROFANITY_FILTER; the server censors independently.
if (profanityFilter === false) {
WordFilter.enabled = false;
}
if (typeof nodeid === 'undefined' || typeof lowmem === 'undefined' || typeof members === 'undefined') {
return;
}
+5
View File
@@ -258,10 +258,15 @@ const env = Object.fromEntries(
);
const host = env.SERVER || 'localhost';
// No config channel from the server (see LiteClient's maxMessageLength note), so
// mirror the server's NODE_PROFANITY_FILTER by hand: PROFANITY_FILTER=false in
// bot.env or the process env disables local chat censoring.
const profanityRaw = (env.PROFANITY_FILTER ?? process.env.PROFANITY_FILTER ?? '').toLowerCase();
const session = await startSession({
host,
username: env.BOT_USERNAME!,
password: env.PASSWORD!,
profanityFilter: ['false', '0', 'off', 'no'].includes(profanityRaw) ? false : undefined,
quiet: true
});
+11
View File
@@ -19,6 +19,7 @@ import { GameConnection } from './net/GameConnection.js';
import { LiteClient } from './LiteClient.js';
import { LocIndex } from './world/LocIndex.js';
import { ClientProt } from '#/io/ClientProt.js';
import WordFilter from '#/wordfilter/WordFilter.js';
const LOOP_MS = 20;
/** Engine drops a socket after TIMEOUT_NO_RESPONSE; NO_TIMEOUT keeps it alive. */
@@ -39,6 +40,13 @@ export interface SessionOptions {
cacheDir?: string;
members?: boolean;
maxMessageLength?: number;
/**
* Local chat censoring (WordFilter). Default on. Process-wide (WordFilter is
* static), so in a multi-bot process like swarm.ts the last session started
* wins. Cosmetic unless the server's NODE_PROFANITY_FILTER is also off - the
* engine censors chat before it reaches any client.
*/
profanityFilter?: boolean;
quiet?: boolean;
/**
* Called exactly once when the packet loop ends, whatever the cause. Fires
@@ -76,6 +84,9 @@ export interface LiteSession {
}
export async function startSession(opts: SessionOptions): Promise<LiteSession> {
if (opts.profanityFilter !== undefined) {
WordFilter.enabled = opts.profanityFilter;
}
const secured = opts.secured ?? !(opts.host.startsWith('localhost') || opts.host.startsWith('127.'));
const origin = `${secured ? 'https' : 'http'}://${opts.host}`;
const cacheDir = opts.cacheDir ?? `${process.env.HOME}/.cache/rs-sdk-lite`;
+20 -10
View File
@@ -2,6 +2,14 @@ import JagFile from '#/io/JagFile.js';
import Packet from '#/io/Packet.js';
export default class WordFilter {
/**
* When false, filter() still normalizes casing (WordPack lowercases the wire
* text) but censors nothing. Note the server censors independently
* (WordEnc.filter, gated by NODE_PROFANITY_FILTER) - disabling only the
* client filter has no visible effect unless the server's is off too.
*/
static enabled: boolean = true;
private static readonly PERIOD: Uint16Array = new Uint16Array(
['d', 'o', 't']
.join('')
@@ -46,16 +54,18 @@ export default class WordFilter {
const trimmed: string = characters.join('').trim();
const lowercase: string = trimmed.toLowerCase();
const filtered: string[] = [...lowercase];
this.filterTlds(filtered);
this.filterBadWords(filtered);
this.filterDomains(filtered);
this.filterFragments(filtered);
for (let index: number = 0; index < this.whitelist.length; index++) {
let offset: number = -1;
while ((offset = lowercase.indexOf(this.whitelist[index], offset + 1)) !== -1) {
const whitelisted: string[] = [...this.whitelist[index]];
for (let charIndex: number = 0; charIndex < whitelisted.length; charIndex++) {
filtered[charIndex + offset] = whitelisted[charIndex];
if (this.enabled) {
this.filterTlds(filtered);
this.filterBadWords(filtered);
this.filterDomains(filtered);
this.filterFragments(filtered);
for (let index: number = 0; index < this.whitelist.length; index++) {
let offset: number = -1;
while ((offset = lowercase.indexOf(this.whitelist[index], offset + 1)) !== -1) {
const whitelisted: string[] = [...this.whitelist[index]];
for (let charIndex: number = 0; charIndex < whitelisted.length; charIndex++) {
filtered[charIndex + offset] = whitelisted[charIndex];
}
}
}
}