diff --git a/server/src/services/audioForwardService/bonkSound.js b/server/src/services/audioForwardService/bonkSound.js new file mode 100644 index 00000000..33bd6e2b --- /dev/null +++ b/server/src/services/audioForwardService/bonkSound.js @@ -0,0 +1,55 @@ +// audio Forward Service bonk sound +// Purpose: Plays the built-in bonk sound effect on the rover a bonked user is driving. +// Scope: Keeps the fun commands and the audio pipeline decoupled by listening to the server event bus only. +const path = require('path'); +const fs = require('fs'); +const { subscribe } = require('../eventBus'); + +/* + Lives in server/assets rather than server/public because the webui build writes + to server/public with emptyOutDir enabled, which deletes anything else in there. + server/assets is a plain checked-in asset directory that no build step touches. +*/ +const BONK_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'assets', 'bonk.wav'); + +function registerBonkSound(deps) { + const { + logger, + playServerAudioFile, + soundPath = BONK_SOUND_PATH, + } = deps; + + subscribe('fun.bonked', (event = {}) => { + const roverId = String(event?.payload?.roverId || '').trim(); + if (!roverId) return; + + /* + The sound is optional. An operator who has not dropped a bonk.wav into + server/assets still gets a fully working `rs bonk` command, so a missing + file is reported once at debug volume rather than thrown at the caller. + */ + if (!fs.existsSync(soundPath)) { + logger.info('Bonk sound file is not installed; skipping playback', { soundPath }); + return; + } + + try { + playServerAudioFile(roverId, soundPath, { source: 'bonk' }); + logger.info('Played bonk sound', { roverId, soundPath }); + } catch (err) { + // Playback interrupts mic forwarding and spawns ffmpeg, so an offline rover + // or a missing encoder must not turn into a failed chat command. The bonk + // itself already happened; the sound is layered on top of it. + logger.warn('Failed to play bonk sound', { + roverId, + soundPath, + error: err?.message || String(err), + }); + } + }); +} + +module.exports = { + registerBonkSound, + BONK_SOUND_PATH, +}; diff --git a/server/src/services/audioForwardService/bonkSound.test.js b/server/src/services/audioForwardService/bonkSound.test.js new file mode 100644 index 00000000..70db57c6 --- /dev/null +++ b/server/src/services/audioForwardService/bonkSound.test.js @@ -0,0 +1,84 @@ +// audio Forward Service bonk sound tests +// Purpose: Verifies the bonk cue plays for a real event and stays contained when the file or rover is missing. +// Scope: Subscribes through the real event bus with a playback double; no ffmpeg runs. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { publishEvent } = require('../eventBus'); +const { registerBonkSound, BONK_SOUND_PATH } = require('./bonkSound'); + +const soundDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bonk-sound-test-')); +const presentSound = path.join(soundDir, 'bonk.wav'); +fs.writeFileSync(presentSound, 'not really audio, only the path is read here'); +const missingSound = path.join(soundDir, 'absent.wav'); + +function harness({ soundPath = presentSound, playImpl = null } = {}) { + const played = []; + const warnings = []; + registerBonkSound({ + logger: { + info: () => {}, + warn: (message, meta) => warnings.push({ message, meta }), + }, + playServerAudioFile: (roverId, filePath, options) => { + played.push({ roverId, filePath, options }); + if (playImpl) playImpl(); + }, + soundPath, + }); + return { played, warnings }; +} + +// Each registerBonkSound call adds another subscriber to the shared bus, so every +// test publishes a distinct rover id and asserts only on its own rover. +function bonk(roverId) { + publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId, targetLabel: 'bob' } }); +} + +test('a bonk event plays the sound on the named rover', () => { + const { played } = harness(); + bonk('rover-play'); + + const mine = played.filter((entry) => entry.roverId === 'rover-play'); + assert.equal(mine.length, 1); + assert.equal(mine[0].filePath, presentSound); + assert.equal(mine[0].options.source, 'bonk'); +}); + +test('an event with no rover id is ignored', () => { + const { played } = harness(); + publishEvent({ source: 'test', type: 'fun.bonked', payload: {} }); + publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId: ' ' } }); + assert.equal(played.length, 0); +}); + +test('a missing sound file skips playback instead of throwing', () => { + const { played, warnings } = harness({ soundPath: missingSound }); + assert.doesNotThrow(() => bonk('rover-missing')); + assert.equal(played.filter((entry) => entry.roverId === 'rover-missing').length, 0); + assert.equal(warnings.length, 0, 'a not-installed sound is informational, not a warning'); +}); + +test('a playback failure is contained and logged rather than thrown at the caller', () => { + const { warnings } = harness({ + playImpl: () => { + throw new Error('Rover offline'); + }, + }); + assert.doesNotThrow(() => bonk('rover-offline')); + assert.ok(warnings.some((entry) => entry.meta?.error === 'Rover offline')); +}); + +test('the default sound path lives in server/assets, which the webui build does not wipe', () => { + // webui/vite.config.js builds to ../server/public with emptyOutDir enabled, so a + // sound stored there would be deleted by the next build. + assert.match(BONK_SOUND_PATH, /server\/assets\/bonk\.wav$/); + assert.doesNotMatch(BONK_SOUND_PATH, /server\/public/); +}); + +test.after(() => { + fs.rmSync(soundDir, { recursive: true, force: true }); +}); diff --git a/server/src/services/audioForwardService/index.js b/server/src/services/audioForwardService/index.js index 7778e32c..04a89404 100644 --- a/server/src/services/audioForwardService/index.js +++ b/server/src/services/audioForwardService/index.js @@ -14,6 +14,7 @@ const { createAudioForwardPolicy } = require('./policy'); const { createAudioForwardWorkerEngine } = require('./workerEngine'); const { registerAudioForwardHooks } = require('./hooks'); const { registerChargeCompleteSound } = require('./chargeCompleteSound'); +const { registerBonkSound } = require('./bonkSound'); const audioForwardEvents = new EventEmitter(); const config = loadConfig(); @@ -151,6 +152,11 @@ registerChargeCompleteSound({ playServerAudioFile, }); +registerBonkSound({ + logger, + playServerAudioFile, +}); + module.exports = { getAudioForwardState, audioForwardEvents, diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index 8c15d39b..f979d012 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -28,8 +28,10 @@ const { } = require('../verificationService'); const { publishEvent } = require('../eventBus'); const assignmentService = require('../assignmentService'); +const funStatsService = require('../funStatsService'); const { loadConfig } = require('../../helpers/configLoader'); const { createCommandHandlers } = require('../operatorCommandService'); +const { createCooldownGate } = require('../operatorCommandService/cooldowns'); const { parseCommandText } = require('../operatorCommandService/config'); const { createWebTransportHandlers } = require('../operatorCommandService/webTransport'); const { commandReplyToText } = require('./commandResultFormatter'); @@ -42,6 +44,14 @@ const { const config = loadConfig(); const discordConfig = config.discord || {}; +/* + Site chat builds a fresh command router for every message so each router can + close over the sending socket. Fun command cooldowns therefore have to live out + here: a gate created inside the router would be thrown away after one message + and would never actually rate limit anything. +*/ +const commandCooldowns = createCooldownGate(); + function isTextCommand(text) { return parseCommandText(text, config).matched; } @@ -123,6 +133,12 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) { actor: { bot: false, id: socket.id, + /* + Fun command tallies are keyed by identity rather than connection, so the + canonical user id is passed alongside the socket id. Without it a user's + bonk count would reset on every reconnect and split across browser tabs. + */ + userId: String(socket?.data?.userId || '').trim() || null, label: nickname, isAdmin: isAdmin(socket), isLockdownAdmin: isLockdownAdmin(socket), @@ -191,6 +207,19 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) { grantAudioGainBoost, revokeAudioGainBoost, sanitizeMentions, + funStatsService, + commandCooldowns, + // Lets `rs bonk` announce itself so audioForwardService can play the bonk + // sound on the rover the target is driving. + publishEvent, + /* + Fun commands that move hardware need the sending socket so they can prove + the caller holds control. issueCommand is required lazily for the same + reason replayEngineV2 is: commandService registers socket handlers on load, + and chatService should not pull that forward in the boot order. + */ + getActorSocket: () => socket, + issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload), sendToChannel: null, isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket), isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket), diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index 7a671993..e187dfee 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -56,10 +56,13 @@ const { approveRequest: approvePrivateAccessRequest, denyRequest: denyPrivateAccessRequest, } = require('../privateRoverAccessRequestService'); -const { subscribe } = require('../eventBus'); +const { subscribe, publishEvent } = require('../eventBus'); +const funStatsService = require('../funStatsService'); +const { issueCommand } = require('../commandService'); const { createPresenceManager } = require('./presence'); const { createChannelIO } = require('./channelIO'); const { createCommandHandlers } = require('../operatorCommandService'); +const { createCooldownGate } = require('../operatorCommandService/cooldowns'); const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter'); const { createIntegrations } = require('./integrations'); const { createFleetDailyReports } = require('./fleetDailyReports'); @@ -209,6 +212,10 @@ if (discordConfig?.channels?.replay) { }); } +// The Discord router is built once for the process, so one gate here covers every +// guild and channel this bot answers in. +const commandCooldowns = createCooldownGate(); + const commandDependencies = { logger, client, @@ -257,6 +264,18 @@ const commandDependencies = { grantAudioGainBoost, revokeAudioGainBoost, sanitizeMentions, + funStatsService, + commandCooldowns, + // A Discord bonk still plays the sound on the rover the target is driving; only + // the commands that need the caller's own socket are unavailable from here. + publishEvent, + /* + Discord has no socket behind a message, so the hardware-backed fun commands + cannot prove drive control and decline with an explanation instead. The text, + counter, and read-only fun commands work normally from here. + */ + getActorSocket: () => null, + issueCommand, sendToChannel: channelIO.sendToChannel, isAdminUser, isLockdownAdminUser, diff --git a/server/src/services/funStatsService/index.js b/server/src/services/funStatsService/index.js new file mode 100644 index 00000000..cff4d5e7 --- /dev/null +++ b/server/src/services/funStatsService/index.js @@ -0,0 +1,178 @@ +// Fun Stats Service +// Purpose: Persists the running counters behind the social `rs` fun commands. +// Scope: Owns storage and clamping only; command handlers decide what a counter means. +const fs = require('fs'); +const path = require('path'); +const logger = require('../../globals/logger').child('funStatsService'); +const { resolveDataPath } = require('../../helpers/dataPaths'); + +const STORE_PATH = resolveDataPath('fun-stats.json'); + +// Counters are additive and never authoritative for anything but bragging +// rights, so the ceiling only exists to keep a runaway loop from writing an +// unbounded integer into the store. +const MAX_COUNT = 1_000_000; +const MAX_LABEL_LENGTH = 64; +const ACTOR_COUNTERS = [ + 'bonksGiven', + 'bonksTaken', + 'hugsGiven', + 'hugsTaken', + 'slapsGiven', + 'slapsTaken', +]; + +/* + This service deliberately keeps its own tiny JSON store rather than reusing + identityService.createJsonStore. Fun counters are keyed by an actor key that + spans transports (`user:` for site chat, `discord:` for Discord), and + a Discord id has no row in `users`, so it cannot live in `user_feature_state` + without violating that table's foreign key. Keeping storage local also means + the counters can be unit tested without opening the identity database. +*/ +let cache = null; + +function clampCount(value) { + const count = Number(value); + if (!Number.isFinite(count) || count <= 0) return 0; + return Math.min(Math.floor(count), MAX_COUNT); +} + +function normalizeLabel(value) { + const label = String(value || '').trim().replace(/\s+/g, ' '); + if (!label) return null; + return label.slice(0, MAX_LABEL_LENGTH); +} + +function normalizeActor(raw = {}) { + const actor = { label: normalizeLabel(raw.label) }; + ACTOR_COUNTERS.forEach((key) => { + actor[key] = clampCount(raw[key]); + }); + actor.updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : null; + return actor; +} + +function normalizeStore(raw = {}) { + const actors = {}; + const rawActors = raw && typeof raw.actors === 'object' && raw.actors ? raw.actors : {}; + Object.keys(rawActors).forEach((key) => { + const actorKey = String(key || '').trim(); + if (!actorKey) return; + actors[actorKey] = normalizeActor(rawActors[actorKey]); + }); + + const rovers = {}; + const rawRovers = raw && typeof raw.rovers === 'object' && raw.rovers ? raw.rovers : {}; + Object.keys(rawRovers).forEach((key) => { + const roverId = String(key || '').trim(); + if (!roverId) return; + const entry = rawRovers[roverId] || {}; + rovers[roverId] = { + pets: clampCount(entry.pets), + updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : null, + }; + }); + + return { actors, rovers }; +} + +function loadState() { + if (cache) return cache; + try { + cache = normalizeStore(JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'))); + } catch (err) { + if (err.code !== 'ENOENT') { + logger.warn('Failed to load fun stats store', { path: STORE_PATH, error: err.message }); + } + cache = normalizeStore({}); + } + return cache; +} + +function persistState(next) { + const normalized = normalizeStore(next); + try { + fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true }); + const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8'); + fs.renameSync(tempPath, STORE_PATH); + } catch (err) { + // A failed write must not break the command that triggered it. The joke + // still lands; only the tally is lost. + logger.warn('Failed to persist fun stats store', { path: STORE_PATH, error: err.message }); + } + cache = normalized; + return cache; +} + +function getActorStats(actorKey) { + const key = String(actorKey || '').trim(); + if (!key) return normalizeActor({}); + return { ...(loadState().actors[key] || normalizeActor({})) }; +} + +/* + `patch` is a map of counter name to increment. Unknown counter names are + ignored rather than stored so a typo in a handler cannot quietly create a + parallel counter that never shows up on the leaderboard. +*/ +function bumpActorStats(actorKey, { label = null, ...patch } = {}) { + const key = String(actorKey || '').trim(); + if (!key) return normalizeActor({}); + const state = loadState(); + const current = state.actors[key] || normalizeActor({}); + const next = { ...current }; + const resolvedLabel = normalizeLabel(label); + if (resolvedLabel) next.label = resolvedLabel; + ACTOR_COUNTERS.forEach((counter) => { + const delta = Number(patch[counter]); + if (!Number.isFinite(delta) || delta === 0) return; + next[counter] = clampCount(current[counter] + delta); + }); + next.updatedAt = Date.now(); + persistState({ ...state, actors: { ...state.actors, [key]: next } }); + return { ...next }; +} + +function listActorStats() { + const { actors } = loadState(); + return Object.keys(actors).map((actorKey) => ({ actorKey, ...actors[actorKey] })); +} + +function bumpRoverPets(roverId, by = 1) { + const id = String(roverId || '').trim(); + if (!id) return 0; + const state = loadState(); + const current = state.rovers[id] || { pets: 0, updatedAt: null }; + const delta = Number(by); + const next = { + pets: clampCount(current.pets + (Number.isFinite(delta) ? delta : 0)), + updatedAt: Date.now(), + }; + persistState({ ...state, rovers: { ...state.rovers, [id]: next } }); + return next.pets; +} + +function getRoverPets(roverId) { + const id = String(roverId || '').trim(); + if (!id) return 0; + return loadState().rovers[id]?.pets || 0; +} + +// Tests drive the store through a temporary SERVER_DATA_DIR, so they need a way +// to drop the module-level cache between cases. +function resetCacheForTests() { + cache = null; +} + +module.exports = { + ACTOR_COUNTERS, + STORE_PATH, + getActorStats, + bumpActorStats, + listActorStats, + bumpRoverPets, + getRoverPets, + resetCacheForTests, +}; diff --git a/server/src/services/funStatsService/index.test.js b/server/src/services/funStatsService/index.test.js new file mode 100644 index 00000000..45bfe083 --- /dev/null +++ b/server/src/services/funStatsService/index.test.js @@ -0,0 +1,131 @@ +// Fun Stats Service Tests +// Purpose: Verifies counter persistence, clamping, and that a corrupt store degrades instead of throwing. +// Scope: Runs against a temporary SERVER_DATA_DIR so the real data directory is never touched. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fun-stats-test-')); +process.env.SERVER_DATA_DIR = dataDir; + +const funStatsService = require('./index'); + +function reset() { + try { + fs.rmSync(funStatsService.STORE_PATH, { force: true }); + } catch { + // A missing store is the normal starting state. + } + funStatsService.resetCacheForTests(); +} + +test('counters start at zero for an unknown actor', () => { + reset(); + const stats = funStatsService.getActorStats('user:nobody'); + assert.equal(stats.bonksGiven, 0); + assert.equal(stats.bonksTaken, 0); + assert.equal(stats.label, null); +}); + +test('bumping a counter accumulates and records the label', () => { + reset(); + funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 }); + const stats = funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 }); + assert.equal(stats.bonksGiven, 2); + assert.equal(stats.label, 'alice'); +}); + +test('counters are independent of one another', () => { + reset(); + funStatsService.bumpActorStats('user:alice', { bonksGiven: 3, hugsGiven: 1 }); + const stats = funStatsService.getActorStats('user:alice'); + assert.equal(stats.bonksGiven, 3); + assert.equal(stats.hugsGiven, 1); + assert.equal(stats.slapsGiven, 0); +}); + +test('an unrecognized counter name is ignored rather than silently stored', () => { + reset(); + funStatsService.bumpActorStats('user:alice', { notACounter: 5 }); + const stats = funStatsService.getActorStats('user:alice'); + assert.equal(stats.notACounter, undefined); +}); + +test('state survives a cold read from disk', () => { + reset(); + funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 7 }); + funStatsService.resetCacheForTests(); + assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 7); +}); + +test('an empty actor key is refused so anonymous bumps cannot share a bucket', () => { + reset(); + funStatsService.bumpActorStats('', { bonksGiven: 1 }); + assert.deepEqual(funStatsService.listActorStats(), []); +}); + +test('rover pets accumulate per rover', () => { + reset(); + assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 1); + assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 2); + assert.equal(funStatsService.bumpRoverPets('rover-2', 1), 1); + assert.equal(funStatsService.getRoverPets('rover-1'), 2); + assert.equal(funStatsService.getRoverPets('unknown'), 0); +}); + +test('listActorStats returns every actor with their key', () => { + reset(); + funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 }); + funStatsService.bumpActorStats('discord:4242', { label: 'dave', bonksGiven: 2 }); + const keys = funStatsService.listActorStats().map((row) => row.actorKey).sort(); + assert.deepEqual(keys, ['discord:4242', 'user:alice']); +}); + +test('negative and non-numeric deltas cannot drive a counter below zero', () => { + reset(); + funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 }); + funStatsService.bumpActorStats('user:alice', { bonksGiven: -50 }); + assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0); + + funStatsService.bumpActorStats('user:alice', { bonksGiven: Number.NaN }); + assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0); +}); + +test('labels are trimmed and length capped', () => { + reset(); + const stats = funStatsService.bumpActorStats('user:alice', { label: ` ${'x'.repeat(200)} `, bonksGiven: 1 }); + assert.equal(stats.label.length, 64); +}); + +test('a corrupt store file degrades to empty instead of throwing', () => { + reset(); + fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true }); + fs.writeFileSync(funStatsService.STORE_PATH, '{not json at all', 'utf8'); + funStatsService.resetCacheForTests(); + + assert.deepEqual(funStatsService.listActorStats(), []); + // And it must still be writable afterwards. + assert.equal(funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 }).bonksGiven, 1); +}); + +test('a store with the wrong shape is normalized rather than trusted', () => { + reset(); + fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true }); + fs.writeFileSync( + funStatsService.STORE_PATH, + JSON.stringify({ actors: { 'user:alice': { bonksGiven: 'lots', label: 42 } }, rovers: 'nope' }), + 'utf8', + ); + funStatsService.resetCacheForTests(); + + const stats = funStatsService.getActorStats('user:alice'); + assert.equal(stats.bonksGiven, 0); + assert.equal(stats.label, '42'); + assert.equal(funStatsService.getRoverPets('rover-1'), 0); +}); + +test.after(() => { + fs.rmSync(dataDir, { recursive: true, force: true }); +}); diff --git a/server/src/services/operatorCommandService/commands/funHelpers.js b/server/src/services/operatorCommandService/commands/funHelpers.js new file mode 100644 index 00000000..742a7d00 --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funHelpers.js @@ -0,0 +1,174 @@ +// Operator Fun Command Helpers +// Purpose: Shared actor identity, target lookup, and deterministic randomness for the fun commands. +// Scope: No side effects; every function here is safe to call before permission checks pass. +const { normalizeSearchText, normalizeText, resolveRoverSelector } = require('./resolvers'); + +// Echoed user text is capped so a fun command cannot be used to shout a wall of +// text into every bridged Discord channel. +const MAX_ECHO_LENGTH = 180; +const PLAIN_MENTIONS = { parse: [], repliedUser: false }; + +/* + Fun counters have to survive across transports, so they are keyed by a stable + identity rather than a connection. Site chat resolves to the identity user id + that moderation already uses; Discord has no row in that database, so it gets + its own key space. An unidentified site socket falls back to its socket id, + which means its tally resets on reconnect — acceptable for a joke counter, and + much better than crediting every anonymous visitor to one shared bucket. +*/ +function buildActorKey(request) { + const transport = normalizeText(request?.transport) || 'unknown'; + if (transport === 'discord') { + const discordId = normalizeText(request?.actor?.id); + return discordId ? `discord:${discordId}` : null; + } + const userId = normalizeText(request?.actor?.userId); + if (userId) return `user:${userId}`; + const socketId = normalizeText(request?.actor?.id); + return socketId ? `socket:${socketId}` : null; +} + +function actorLabel(request) { + return normalizeText(request?.actor?.label) || 'someone'; +} + +/* + Collapses every connected socket for one person onto their canonical user id so + extra browser tabs cannot make a target look ambiguous. Mirrors the same + approach the deter command uses for moderation targets. +*/ +function findOnlineUsers(io, getNickname, selector) { + const normalizedSelector = normalizeSearchText(selector); + if (!normalizedSelector) return []; + + const sockets = io?.sockets?.sockets; + if (!sockets || typeof sockets.forEach !== 'function') return []; + + const byUserId = new Map(); + sockets.forEach((socket) => { + const nickname = getNickname?.(socket); + if (normalizeSearchText(nickname) !== normalizedSelector) return; + const userId = normalizeText(socket?.data?.userId); + const key = userId || `socket:${normalizeText(socket?.id)}`; + if (!key) return; + if (!byUserId.has(key)) { + byUserId.set(key, { userId: userId || null, nickname: normalizeText(nickname), socket }); + } + }); + + return Array.from(byUserId.values()); +} + +/* + A fun command should still work when the target is not a real user — bonking + "the dishwasher" is half the point. So an unmatched selector is not an error: + it becomes a plain label and simply credits nobody's tally. Ambiguity is + treated the same way, because guessing which of two identical nicknames took + the hit would be worse than crediting neither. +*/ +function resolveFunTarget({ io, getNickname, selector }) { + const label = clampEcho(selector); + if (!label) return null; + + const matches = findOnlineUsers(io, getNickname, selector); + if (matches.length === 1) { + const [match] = matches; + return { + label: match.nickname || label, + actorKey: match.userId ? `user:${match.userId}` : null, + socket: match.socket || null, + online: true, + }; + } + + return { label, actorKey: null, socket: null, online: false }; +} + +/* + Rover-scoped fun commands accept an explicit rover name and otherwise fall back + to whichever rover the caller is already attached to. Discord has no socket + behind it, so the fallback simply is not available there and the caller is asked + to name a rover rather than having one chosen for them. +*/ +function createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix = 'rs' }) { + return function resolveTargetRover(selector, action = 'pet') { + const query = normalizeText(selector); + if (query) { + const resolved = resolveRoverSelector(query, rovers); + if (resolved.error) return { error: resolved.error }; + return { id: resolved.id, name: resolved.label || resolved.id, record: resolved.record }; + } + + const socket = getActorSocket?.() || null; + if (!socket) return { error: `Name a rover: \`${commandPrefix} ${action} \`` }; + + // getPrimaryRoverForSocket takes a socket id and returns a rover id string. + const roverId = normalizeText(roverManager?.getPrimaryRoverForSocket?.(socket.id)); + if (!roverId) return { error: 'You are not on a rover right now. Name one instead.' }; + const record = rovers.get(roverId) || null; + return { id: roverId, name: record?.meta?.name || roverId, record, socket }; + }; +} + +function clampEcho(value) { + const text = normalizeText(value).replace(/\s+/g, ' '); + if (!text) return ''; + if (text.length <= MAX_ECHO_LENGTH) return text; + return `${text.slice(0, MAX_ECHO_LENGTH - 1)}…`; +} + +/* + FNV-1a. Fun commands that judge something — `ship`, `rate`, `8ball` — use a + hash of the input instead of Math.random so the same question always gets the + same answer. Re-rolling until you like the verdict is not funny; a server that + stubbornly insists your ship rating is 4% is. +*/ +function hashSeed(value) { + const text = normalizeSearchText(value); + let hash = 0x811c9dc5; + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +function pickBySeed(list, seed) { + const items = Array.isArray(list) ? list : []; + if (!items.length) return null; + return items[seed % items.length]; +} + +// Order-independent so `rs ship a b` and `rs ship b a` agree with each other. +function pairSeed(left, right) { + const pair = [normalizeSearchText(left), normalizeSearchText(right)].sort(); + return hashSeed(pair.join(' ')); +} + +function percentFromSeed(seed) { + return seed % 101; +} + +function ordinal(count) { + const value = Number(count) || 0; + const mod100 = value % 100; + if (mod100 >= 11 && mod100 <= 13) return `${value}th`; + const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[value % 10] || 'th'; + return `${value}${suffix}`; +} + +module.exports = { + MAX_ECHO_LENGTH, + PLAIN_MENTIONS, + actorLabel, + buildActorKey, + clampEcho, + createRoverResolver, + findOnlineUsers, + hashSeed, + ordinal, + pairSeed, + percentFromSeed, + pickBySeed, + resolveFunTarget, +}; diff --git a/server/src/services/operatorCommandService/commands/funHelpers.test.js b/server/src/services/operatorCommandService/commands/funHelpers.test.js new file mode 100644 index 00000000..91376b82 --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funHelpers.test.js @@ -0,0 +1,162 @@ +// Operator Fun Helper Tests +// Purpose: Locks down actor identity, target resolution, and the deterministic seeding the fun commands depend on. +// Scope: Pure helpers plus in-memory socket doubles; nothing here touches the fun stats store. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildActorKey, + clampEcho, + createRoverResolver, + hashSeed, + ordinal, + pairSeed, + percentFromSeed, + pickBySeed, + resolveFunTarget, + MAX_ECHO_LENGTH, +} = require('./funHelpers'); + +function socket(id, userId, nickname) { + return { id, data: { userId, nickname } }; +} + +function harness(sockets = []) { + return { + io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } }, + getNickname: (entry) => entry?.data?.nickname || '', + }; +} + +test('site chat keys on the identity user id, not the socket', () => { + assert.equal( + buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1', userId: 'u-alice' } }), + 'user:u-alice', + ); +}); + +test('an unidentified site socket falls back to its socket id', () => { + assert.equal( + buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1' } }), + 'socket:socket-1', + ); +}); + +test('discord actors get their own key space so ids cannot collide with identity ids', () => { + assert.equal(buildActorKey({ transport: 'discord', actor: { id: '4242' } }), 'discord:4242'); +}); + +test('an actor with no usable id at all is rejected rather than sharing a bucket', () => { + assert.equal(buildActorKey({ transport: 'web-chat', actor: {} }), null); + assert.equal(buildActorKey({ transport: 'discord', actor: {} }), null); +}); + +test('a single online nickname resolves to that user and credits their tally', () => { + const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]); + const resolved = resolveFunTarget({ io, getNickname, selector: 'BOB' }); + assert.equal(resolved.label, 'bob'); + assert.equal(resolved.actorKey, 'user:u-bob'); + assert.equal(resolved.online, true); +}); + +test('multiple tabs for one person do not make the target ambiguous', () => { + const { io, getNickname } = harness([ + socket('s1', 'u-bob', 'bob'), + socket('s2', 'u-bob', 'bob'), + ]); + const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' }); + assert.equal(resolved.actorKey, 'user:u-bob'); +}); + +test('an unmatched selector still works but credits nobody', () => { + const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]); + const resolved = resolveFunTarget({ io, getNickname, selector: 'the dishwasher' }); + assert.equal(resolved.label, 'the dishwasher'); + assert.equal(resolved.actorKey, null); + assert.equal(resolved.online, false); +}); + +test('two different people sharing a nickname credit neither', () => { + const { io, getNickname } = harness([ + socket('s1', 'u-bob', 'bob'), + socket('s2', 'u-other', 'bob'), + ]); + const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' }); + assert.equal(resolved.actorKey, null); +}); + +test('echoed text is length capped so a fun command cannot shout a wall of text', () => { + const long = 'a'.repeat(500); + const clamped = clampEcho(long); + assert.equal(clamped.length, MAX_ECHO_LENGTH); + assert.ok(clamped.endsWith('…')); +}); + +test('ship is order independent so both spellings agree', () => { + assert.equal(pairSeed('alice', 'bob'), pairSeed('bob', 'alice')); +}); + +test('seeded verdicts are stable, so a rating cannot be rerolled by asking again', () => { + const first = percentFromSeed(pairSeed('alice', 'bob')); + const second = percentFromSeed(pairSeed('alice', 'bob')); + assert.equal(first, second); + assert.ok(first >= 0 && first <= 100); +}); + +test('hashSeed ignores case and surrounding whitespace', () => { + assert.equal(hashSeed(' Will It Dock '), hashSeed('will it dock')); +}); + +test('pickBySeed stays in range and tolerates an empty list', () => { + const list = ['a', 'b', 'c']; + for (let seed = 0; seed < 20; seed += 1) { + assert.ok(list.includes(pickBySeed(list, seed))); + } + assert.equal(pickBySeed([], 5), null); +}); + +test('ordinal handles the teens correctly', () => { + assert.equal(ordinal(1), '1st'); + assert.equal(ordinal(2), '2nd'); + assert.equal(ordinal(3), '3rd'); + assert.equal(ordinal(4), '4th'); + assert.equal(ordinal(11), '11th'); + assert.equal(ordinal(12), '12th'); + assert.equal(ordinal(13), '13th'); + assert.equal(ordinal(21), '21st'); + assert.equal(ordinal(111), '111th'); +}); + +test('an explicit rover name wins over whatever the caller is attached to', () => { + const rovers = new Map([ + ['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }], + ['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }], + ]); + const resolve = createRoverResolver({ + rovers, + roverManager: { getPrimaryRoverForSocket: () => 'rover-1' }, + getActorSocket: () => ({ id: 's1' }), + }); + assert.equal(resolve('Roomba Two').id, 'rover-2'); +}); + +test('with no rover named the caller\'s current rover is used', () => { + const rovers = new Map([['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }]]); + const resolve = createRoverResolver({ + rovers, + roverManager: { getPrimaryRoverForSocket: (socketId) => (socketId === 's1' ? 'rover-1' : null) }, + getActorSocket: () => ({ id: 's1' }), + }); + const resolved = resolve(''); + assert.equal(resolved.id, 'rover-1'); + assert.equal(resolved.name, 'Roomba One'); +}); + +test('without a socket the caller is asked to name a rover instead of one being chosen', () => { + const resolve = createRoverResolver({ + rovers: new Map(), + roverManager: {}, + getActorSocket: () => null, + commandPrefix: 'rs', + }); + assert.match(resolve('', 'pet').error, /Name a rover/); +}); diff --git a/server/src/services/operatorCommandService/commands/funRover.js b/server/src/services/operatorCommandService/commands/funRover.js new file mode 100644 index 00000000..80b7df7c --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funRover.js @@ -0,0 +1,285 @@ +// Operator Fun Rover Commands +// Purpose: Implements the fun commands that actually make the fleet or the room do something (honk, boo, disco, spin, vibecheck). +// Scope: Every handler here re-checks control and feature gating itself, because issueCommand bypasses the socket command guards. +const { getCommandConfig } = require('../../operatorCommandService/config'); +const { describeWait } = require('../cooldowns'); +const { + PLAIN_MENTIONS, + actorLabel, + buildActorKey, + createRoverResolver, + hashSeed, + pickBySeed, + resolveFunTarget, +} = require('./funHelpers'); + +// Durations are deliberately short and are also bounded rover-side: roverd +// enforces its own horn MaxDuration, so a lost stop command cannot leave a horn +// sounding forever. +const HONK_MS = 600; +const HONK_FREQ_HZ = 440; +const SPIN_MS = 1200; +const SPIN_SPEED = 120; +const DISCO_MS = 12 * 1000; +const DISCO_TICK_MS = 750; + +const HONK_ACTOR_COOLDOWN_MS = 20 * 1000; +const HONK_ROVER_COOLDOWN_MS = 8 * 1000; +const BOO_COOLDOWN_MS = 30 * 1000; +const SPIN_COOLDOWN_MS = 25 * 1000; +const DISCO_COOLDOWN_MS = 2 * 60 * 1000; +const VIBECHECK_COOLDOWN_MS = 5 * 1000; + +/* + Taunts are a fixed list rather than caller-supplied text on purpose. `boo` puts + audio out of a speaker in a room full of people, so letting it read arbitrary + input would turn a joke command into an unmoderated TTS channel aimed at + whoever is nearest the rover. +*/ +const BOO_TAUNTS = [ + 'Boo.', 'Your driving is being reviewed.', 'That was a choice.', + 'The wall was right there.', 'Someone in chat is laughing at you.', + 'I have seen better parking from the Neato.', 'Boo. Respectfully.', + 'This is a citizen\'s arrest.', 'Turn left. No, the other left.', +]; + +const VIBE_VERDICTS = [ + 'immaculate', 'acceptable', 'questionable', 'concerning', 'dire', 'unwell', +]; + +function describeBattery(batteryState) { + const display = Number(batteryState?.percentDisplay); + if (Number.isFinite(display)) return `${Math.round(display)}%`; + const percent = Number(batteryState?.percent); + if (Number.isFinite(percent)) return `${Math.round(percent * 100)}%`; + return 'unknown'; +} + +function createFunRoverCommands({ + io, + rovers, + roverManager, + getNickname, + getActiveDrivers, + getActorSocket, + issueCommand, + homeAssistantService, + isFeatureEnabled, + sanitizeMentions, + cooldowns, + logger, + config, +}) { + const { prefix: commandPrefix } = getCommandConfig(config); + const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || '')); + const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix }); + + function reply(message, content) { + return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS }); + } + + function gate(message, action, windowMs) { + const actorKey = buildActorKey(message); + if (!actorKey) return { error: 'Could not identify you well enough to do that.' }; + const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs); + if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` }; + return { actorKey, label: actorLabel(message) }; + } + + /* + issueCommand is the raw rover transport: it performs none of the ownership, + deterrence, or private-safety checks that the socket `command` handler applies. + Any fun command that moves hardware therefore has to prove control here, which + also means these commands are inherently site-chat only — a Discord message has + no socket and so can never satisfy canDrive. + */ + function requireDriveControl(action, selector) { + const socket = getActorSocket?.() || null; + if (!socket) { + return { error: `\`${commandPrefix} ${action}\` only works from site chat, where you can actually be driving.` }; + } + const rover = resolveTargetRover(selector, action); + if (rover.error) return { error: rover.error }; + if (!roverManager?.canDrive?.(rover.id, socket)) { + return { error: `You need control of ${rover.name} to do that.` }; + } + return { rover, socket }; + } + + function safeIssue(roverId, payload, context) { + try { + issueCommand(roverId, payload); + return true; + } catch (err) { + // Deferred stop commands routinely land after a rover drops off. That is + // expected, not an incident, so it is logged at debug volume and swallowed. + logger?.warn?.('Fun command could not reach rover', { roverId, context, error: err.message }); + return false; + } + } + + async function handleHonk(message, tokens = []) { + const control = requireDriveControl('honk', tokens.join(' ')); + if (control.error) return reply(message, control.error); + const { rover } = control; + + if (rover.record?.meta?.horn?.enabled === false) { + return reply(message, `${rover.name} has no horn fitted.`); + } + + const gated = gate(message, 'honk', HONK_ACTOR_COOLDOWN_MS); + if (gated.error) return reply(message, gated.error); + + // A second, rover-scoped window stops a group of drivers taking turns to + // honk the same rover continuously while each stays inside their own limit. + const roverWait = cooldowns.consume(`honk:rover:${rover.id}`, HONK_ROVER_COOLDOWN_MS); + if (roverWait > 0) { + return reply(message, `${rover.name} was just honked. Give it ${describeWait(roverWait)}.`); + } + + if (!safeIssue(rover.id, { type: 'horn', horn: { action: 'start', waveform: 'sine', freqs: [HONK_FREQ_HZ] } }, 'honk:start')) { + return reply(message, `${rover.name} is offline.`); + } + setTimeout(() => safeIssue(rover.id, { type: 'horn', horn: { action: 'stop' } }, 'honk:stop'), HONK_MS); + + return reply(message, `📢 HONK. (${rover.name})`); + } + + async function handleSpin(message, tokens = []) { + const control = requireDriveControl('spin', tokens.join(' ')); + if (control.error) return reply(message, control.error); + const { rover, socket } = control; + + const gated = gate(message, 'spin', SPIN_COOLDOWN_MS); + if (gated.error) return reply(message, gated.error); + + const maxWheelSpeed = Number(rover.record?.meta?.maxWheelSpeed); + const speed = Math.max(1, Math.min(SPIN_SPEED, Number.isFinite(maxWheelSpeed) && maxWheelSpeed > 0 ? maxWheelSpeed : SPIN_SPEED)); + let driveDirect = { left: speed, right: -speed }; + /* + Private rovers can carry a reduced speed ceiling that the socket path would + normally apply. Applying it explicitly keeps a fun command from being the one + way to exceed a limit an admin set for a specific rover. + */ + const safeDrive = roverManager?.applyPrivateDriveSafety?.(rover.id, socket, driveDirect); + if (safeDrive) driveDirect = safeDrive; + + if (!safeIssue(rover.id, { type: 'drive', driveDirect }, 'spin:start')) { + return reply(message, `${rover.name} is offline.`); + } + setTimeout(() => safeIssue(rover.id, { type: 'drive', driveDirect: { left: 0, right: 0 } }, 'spin:stop'), SPIN_MS); + + return reply(message, `🌀 ${rover.name} is doing a spin.`); + } + + async function handleBoo(message, tokens = []) { + const selector = tokens.join(' ').trim(); + if (!selector) return reply(message, `Usage: \`${commandPrefix} boo \``); + + const gated = gate(message, 'boo', BOO_COOLDOWN_MS); + if (gated.error) return reply(message, gated.error); + + const resolved = resolveFunTarget({ io, getNickname, selector }); + if (!resolved) return reply(message, `Usage: \`${commandPrefix} boo \``); + if (!resolved.online || !resolved.socket) { + return reply(message, `${resolved.label} is not here to be booed.`); + } + + // Boo lands on the rover the target is actually driving, so it needs the + // active-driver map rather than merely which rovers they are watching. + const drivers = getActiveDrivers?.() || {}; + const roverId = Object.keys(drivers).find((id) => drivers[id] === resolved.socket.id) || null; + if (!roverId) return reply(message, `${resolved.label} is not driving anything right now.`); + + const record = rovers.get(String(roverId)); + const roverName = record?.meta?.name || roverId; + if (record?.meta?.audio?.ttsEnabled === false) { + return reply(message, `${roverName} cannot speak.`); + } + + const taunt = pickBySeed(BOO_TAUNTS, hashSeed(`${gated.actorKey}:${resolved.label}`)); + if (!safeIssue(roverId, { type: 'tts', tts: { text: taunt, speak: true, engine: 'chromegtts' } }, 'boo')) { + return reply(message, `${roverName} is offline.`); + } + + return reply(message, `👻 Booed ${resolved.label} through ${roverName}.`); + } + + async function handleDisco(message) { + if (!homeAssistantService || !isFeatureEnabled?.('homeAssistant')) { + return reply(message, 'Room light controls are unavailable.'); + } + + // An admin lock on the room lights is a policy boundary. Disco is a scene + // change like `rs lights on`, so it must not be the one command that ignores it. + const lightPolicy = homeAssistantService.getLightPolicyState?.() || {}; + if (lightPolicy.locked) { + return reply(message, 'Room lights are locked. No disco.'); + } + + const gated = gate(message, 'disco', DISCO_COOLDOWN_MS); + if (gated.error) return reply(message, gated.error); + + const setAll = homeAssistantService.setAllControllableEntitiesState; + if (typeof setAll !== 'function') { + return reply(message, 'Room light controls are unavailable.'); + } + + const endsAt = Date.now() + DISCO_MS; + let on = false; + /* + Held in a local interval rather than the rewards effect store because a + disco is short and disposable. Nothing needs to survive a restart, and the + final tick always restores the lights to on. + */ + const timer = setInterval(() => { + if (Date.now() >= endsAt) { + clearInterval(timer); + Promise.resolve(setAll('on')).catch((err) => { + logger?.warn?.('Disco could not restore lights', { error: err.message }); + }); + return; + } + on = !on; + Promise.resolve(setAll(on ? 'on' : 'off')).catch((err) => { + logger?.warn?.('Disco tick failed', { error: err.message }); + }); + }, DISCO_TICK_MS); + + return reply(message, `🪩 Disco for ${Math.round(DISCO_MS / 1000)} seconds. Started by ${gated.label}.`); + } + + async function handleVibecheck(message, tokens = []) { + const gated = gate(message, 'vibecheck', VIBECHECK_COOLDOWN_MS); + if (gated.error) return reply(message, gated.error); + + const rover = resolveTargetRover(tokens.join(' '), 'vibecheck'); + if (rover.error) return reply(message, rover.error); + + const record = rover.record || rovers.get(rover.id) || null; + const battery = describeBattery(record?.batteryState); + const offline = !record?.ws; + const locked = Boolean(record?.locked); + const urgent = Boolean(record?.batteryState?.urgentActive); + const warn = Boolean(record?.batteryState?.warnActive); + + let verdict; + if (offline) verdict = 'nonexistent — it is offline'; + else if (urgent) verdict = 'dying'; + else if (warn) verdict = 'running low'; + else if (locked) verdict = 'locked out and sulking'; + else verdict = pickBySeed(VIBE_VERDICTS, hashSeed(`${rover.id}:${battery}`)); + + return reply(message, `🔍 ${rover.name}: vibes are **${verdict}**. Battery ${battery}.`); + } + + return { + honk: handleHonk, + boo: handleBoo, + disco: handleDisco, + spin: handleSpin, + vibecheck: handleVibecheck, + }; +} + +module.exports = { createFunRoverCommands, describeBattery }; diff --git a/server/src/services/operatorCommandService/commands/funRover.test.js b/server/src/services/operatorCommandService/commands/funRover.test.js new file mode 100644 index 00000000..f6f349ca --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funRover.test.js @@ -0,0 +1,297 @@ +// Operator Fun Rover Command Tests +// Purpose: Verifies the control, feature, and lock checks the hardware-backed fun commands must make themselves. +// Scope: issueCommand, roverManager, and Home Assistant are all doubles; no real rover or timer is involved. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createFunRoverCommands, describeBattery } = require('./funRover'); +const { createCooldownGate } = require('../cooldowns'); + +const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } }; +const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } }; + +function createHarness({ + canDrive = true, + socket = ALICE, + hornEnabled = true, + ttsEnabled = true, + online = true, + maxWheelSpeed = 300, + privateSafetyDrive = null, + activeDrivers = {}, + homeAssistantService = null, + featureEnabled = false, + sockets = [ALICE, BOB], +} = {}) { + const issued = []; + const record = { + id: 'rover-1', + ws: online ? {} : null, + locked: false, + meta: { + name: 'Roomba One', + maxWheelSpeed, + horn: { enabled: hornEnabled }, + audio: { ttsEnabled }, + }, + batteryState: { percentDisplay: 74, warnActive: false, urgentActive: false }, + }; + const rovers = new Map([['rover-1', record]]); + + const handlers = createFunRoverCommands({ + io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } }, + rovers, + roverManager: { + canDrive: () => canDrive, + getPrimaryRoverForSocket: () => 'rover-1', + applyPrivateDriveSafety: () => privateSafetyDrive, + }, + getNickname: (entry) => entry?.data?.nickname || '', + getActiveDrivers: () => activeDrivers, + getActorSocket: () => socket, + issueCommand: (roverId, payload) => { + if (!record.ws) throw new Error('Rover offline'); + issued.push({ roverId, ...payload }); + return 'cmd-1'; + }, + homeAssistantService, + isFeatureEnabled: () => featureEnabled, + sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'), + cooldowns: createCooldownGate(), + logger: { warn: () => {} }, + config: { commands: { prefix: 'rs' } }, + }); + + return { handlers, issued, record, rovers }; +} + +function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) { + const replies = []; + return { + transport: 'web-chat', + actor, + replies, + reply: async (payload) => { + replies.push(payload); + return null; + }, + }; +} + +test('honk starts the horn and schedules a stop', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { handlers, issued } = createHarness(); + const msg = message(); + await handlers.honk(msg, []); + + assert.match(msg.replies[0].content, /HONK/); + assert.deepEqual(issued.map((entry) => entry.horn.action), ['start']); + + // The stop is deferred, so nothing has released the horn yet. + t.mock.timers.tick(1000); + assert.deepEqual(issued.map((entry) => entry.horn.action), ['start', 'stop']); +}); + +test('honk is refused without drive control', async () => { + const { handlers, issued } = createHarness({ canDrive: false }); + const msg = message(); + await handlers.honk(msg, []); + + assert.match(msg.replies[0].content, /need control of Roomba One/); + assert.equal(issued.length, 0); +}); + +test('honk is refused from a transport with no socket, so Discord cannot drive hardware', async () => { + const { handlers, issued } = createHarness({ socket: null }); + const msg = message({ id: '4242', label: 'DiscordUser' }); + await handlers.honk(msg, []); + + assert.match(msg.replies[0].content, /only works from site chat/); + assert.equal(issued.length, 0); +}); + +test('honk is refused on a rover with no horn fitted', async () => { + const { handlers, issued } = createHarness({ hornEnabled: false }); + const msg = message(); + await handlers.honk(msg, []); + + assert.match(msg.replies[0].content, /no horn fitted/); + assert.equal(issued.length, 0); +}); + +test('a second driver cannot bypass the rover cooldown with their own fresh actor window', async () => { + const { handlers, issued } = createHarness(); + await handlers.honk(message(), []); + + const other = message({ id: 's2', userId: 'u-bob', label: 'bob' }); + await handlers.honk(other, []); + assert.match(other.replies[0].content, /was just honked/); + // Only the first honk reached the rover. + assert.equal(issued.filter((entry) => entry.horn?.action === 'start').length, 1); +}); + +test('an offline rover reports offline instead of claiming a honk happened', async () => { + const { handlers } = createHarness({ online: false }); + const msg = message(); + await handlers.honk(msg, []); + assert.match(msg.replies[0].content, /is offline/); +}); + +test('spin clamps to the rover wheel speed ceiling and always stops itself', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { handlers, issued } = createHarness({ maxWheelSpeed: 50 }); + const msg = message(); + await handlers.spin(msg, []); + + assert.equal(issued[0].driveDirect.left, 50); + assert.equal(issued[0].driveDirect.right, -50); + + t.mock.timers.tick(2000); + assert.deepEqual(issued[1].driveDirect, { left: 0, right: 0 }); +}); + +test('spin honours a private rover safety override rather than bypassing it', async () => { + const { handlers, issued } = createHarness({ privateSafetyDrive: { left: 20, right: -20 } }); + await handlers.spin(message(), []); + assert.deepEqual(issued[0].driveDirect, { left: 20, right: -20 }); +}); + +test('spin is refused without drive control', async () => { + const { handlers, issued } = createHarness({ canDrive: false }); + const msg = message(); + await handlers.spin(msg, []); + assert.match(msg.replies[0].content, /need control/); + assert.equal(issued.length, 0); +}); + +test('boo speaks a canned taunt rather than any caller supplied text', async () => { + const { handlers, issued } = createHarness({ activeDrivers: { 'rover-1': 's2' } }); + const msg = message(); + await handlers.boo(msg, ['bob']); + + assert.equal(issued.length, 1); + assert.equal(issued[0].type, 'tts'); + // The spoken text must not contain anything the caller typed. + assert.doesNotMatch(issued[0].tts.text, /bob/i); + assert.ok(issued[0].tts.text.length > 0); +}); + +test('boo is refused when the target is not driving anything', async () => { + const { handlers, issued } = createHarness({ activeDrivers: {} }); + const msg = message(); + await handlers.boo(msg, ['bob']); + + assert.match(msg.replies[0].content, /not driving anything/); + assert.equal(issued.length, 0); +}); + +test('boo is refused when the target is not online at all', async () => { + const { handlers, issued } = createHarness({ sockets: [ALICE] }); + const msg = message(); + await handlers.boo(msg, ['nobody-here']); + + assert.match(msg.replies[0].content, /not here to be booed/); + assert.equal(issued.length, 0); +}); + +test('boo is refused on a rover that cannot speak', async () => { + const { handlers, issued } = createHarness({ ttsEnabled: false, activeDrivers: { 'rover-1': 's2' } }); + const msg = message(); + await handlers.boo(msg, ['bob']); + + assert.match(msg.replies[0].content, /cannot speak/); + assert.equal(issued.length, 0); +}); + +test('disco is unavailable when the Home Assistant feature is off', async () => { + const calls = []; + const { handlers } = createHarness({ + featureEnabled: false, + homeAssistantService: { + getLightPolicyState: () => ({}), + setAllControllableEntitiesState: (state) => calls.push(state), + }, + }); + const msg = message(); + await handlers.disco(msg, []); + + assert.match(msg.replies[0].content, /unavailable/); + assert.equal(calls.length, 0); +}); + +test('disco obeys the room light lock', async () => { + const calls = []; + const { handlers } = createHarness({ + featureEnabled: true, + homeAssistantService: { + getLightPolicyState: () => ({ locked: true, lockState: 'on' }), + setAllControllableEntitiesState: (state) => calls.push(state), + }, + }); + const msg = message(); + await handlers.disco(msg, []); + + assert.match(msg.replies[0].content, /locked/); + assert.equal(calls.length, 0); +}); + +test('disco strobes while unlocked and restores the lights on when it ends', async (t) => { + t.mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'] }); + const calls = []; + const { handlers } = createHarness({ + featureEnabled: true, + homeAssistantService: { + getLightPolicyState: () => ({ locked: false }), + setAllControllableEntitiesState: (state) => { + calls.push(state); + return Promise.resolve(); + }, + }, + }); + const msg = message(); + await handlers.disco(msg, []); + assert.match(msg.replies[0].content, /Disco/); + + t.mock.timers.tick(3000); + assert.ok(calls.length >= 2, `expected several ticks, saw ${calls.length}`); + assert.ok(calls.includes('on') && calls.includes('off')); + + // Past the end of the window the lights must be put back on and left alone. + t.mock.timers.tick(20 * 1000); + assert.equal(calls[calls.length - 1], 'on'); + const settled = calls.length; + t.mock.timers.tick(20 * 1000); + assert.equal(calls.length, settled); +}); + +test('vibecheck reports the battery and never issues a command', async () => { + const { handlers, issued } = createHarness(); + const msg = message(); + await handlers.vibecheck(msg, []); + + assert.match(msg.replies[0].content, /Roomba One/); + assert.match(msg.replies[0].content, /Battery 74%/); + assert.equal(issued.length, 0); +}); + +test('vibecheck leads with the real problem when the battery is urgent', async () => { + const { handlers, record } = createHarness(); + record.batteryState = { percentDisplay: 4, warnActive: true, urgentActive: true }; + const msg = message(); + await handlers.vibecheck(msg, []); + assert.match(msg.replies[0].content, /dying/); +}); + +test('vibecheck reports an offline rover as offline', async () => { + const { handlers, record } = createHarness(); + record.ws = null; + const msg = message(); + await handlers.vibecheck(msg, []); + assert.match(msg.replies[0].content, /offline/); +}); + +test('describeBattery falls back through the available fields', () => { + assert.equal(describeBattery({ percentDisplay: 55.4 }), '55%'); + assert.equal(describeBattery({ percent: 0.42 }), '42%'); + assert.equal(describeBattery({}), 'unknown'); + assert.equal(describeBattery(null), 'unknown'); +}); diff --git a/server/src/services/operatorCommandService/commands/funStats.js b/server/src/services/operatorCommandService/commands/funStats.js new file mode 100644 index 00000000..dc2c7656 --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funStats.js @@ -0,0 +1,118 @@ +// Operator Fun Stats Commands +// Purpose: Implements the fun commands that read or extend persistent counters (bonkboard, pet, snitch). +// Scope: Reads the roster and the fun stats store; issues no rover commands. +const { getCommandConfig } = require('../../operatorCommandService/config'); +const { describeWait } = require('../cooldowns'); +const { PLAIN_MENTIONS, actorLabel, buildActorKey, createRoverResolver } = require('./funHelpers'); + +const PET_COOLDOWN_MS = 10 * 1000; +const READ_COOLDOWN_MS = 5 * 1000; +const LEADERBOARD_SIZE = 10; + +function formatLeaderboard(title, rows, counter) { + const ranked = rows + .filter((row) => Number(row[counter]) > 0) + .sort((left, right) => Number(right[counter]) - Number(left[counter])) + .slice(0, LEADERBOARD_SIZE); + if (!ranked.length) return null; + const lines = ranked.map((row, index) => `${index + 1}. ${row.label || 'unknown'} — ${row[counter]}`); + return [`**${title}**`, ...lines].join('\n'); +} + +function createFunStatsCommands({ + io, + rovers, + getNickname, + getActiveDrivers, + getActorSocket, + roverManager, + sanitizeMentions, + funStatsService, + cooldowns, + config, +}) { + const { prefix: commandPrefix } = getCommandConfig(config); + const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || '')); + + function reply(message, content) { + return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS }); + } + + function gate(message, action, windowMs) { + const actorKey = buildActorKey(message); + if (!actorKey) return { error: 'Could not identify you well enough to do that.' }; + const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs); + if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` }; + return { actorKey, label: actorLabel(message) }; + } + + const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix }); + + async function handleBonkboard(message) { + const gated = gate(message, 'bonkboard', READ_COOLDOWN_MS); + if (gated.error) return reply(message, gated.error); + + const rows = funStatsService.listActorStats(); + const sections = [ + formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'), + formatLeaderboard('Most bonks taken', rows, 'bonksTaken'), + formatLeaderboard('Most hugs given', rows, 'hugsGiven'), + ].filter(Boolean); + + if (!sections.length) { + return reply(message, `Nobody has been bonked yet. Fix that with \`${commandPrefix} bonk \`.`); + } + return reply(message, sections.join('\n\n').slice(0, 1900)); + } + + async function handlePet(message, tokens = []) { + const gated = gate(message, 'pet', PET_COOLDOWN_MS); + if (gated.error) return reply(message, gated.error); + + const rover = resolveTargetRover(tokens.join(' '), 'pet'); + if (rover.error) return reply(message, rover.error); + + const pets = funStatsService.bumpRoverPets(rover.id, 1); + return reply(message, `🤖 ${gated.label} pets ${rover.name}. It has now been petted ${pets} time${pets === 1 ? '' : 's'}.`); + } + + /* + Reads the same active-driver map the turn system uses, so it reports real + control rather than who merely has the page open. Rovers with nobody driving + are listed too — an empty fleet is exactly what a snitch should report. + */ + async function handleSnitch(message) { + const gated = gate(message, 'snitch', READ_COOLDOWN_MS); + if (gated.error) return reply(message, gated.error); + + const drivers = getActiveDrivers?.() || {}; + const sockets = io?.sockets?.sockets; + const lines = []; + + rovers.forEach((record, roverId) => { + const id = String(roverId); + const name = record?.meta?.name || id; + const socketId = drivers[id]; + const socket = socketId && sockets?.get ? sockets.get(socketId) : null; + const nickname = socket ? getNickname?.(socket) : null; + if (nickname) { + lines.push(`• ${name} — ${nickname}`); + } else if (socketId) { + lines.push(`• ${name} — someone who will not say their name`); + } else { + lines.push(`• ${name} — nobody`); + } + }); + + if (!lines.length) return reply(message, 'No rovers are online to snitch about.'); + return reply(message, ['🕵️ Currently driving:', ...lines].join('\n').slice(0, 1900)); + } + + return { + bonkboard: handleBonkboard, + pet: handlePet, + snitch: handleSnitch, + }; +} + +module.exports = { createFunStatsCommands, formatLeaderboard }; diff --git a/server/src/services/operatorCommandService/commands/funStats.test.js b/server/src/services/operatorCommandService/commands/funStats.test.js new file mode 100644 index 00000000..ffcf2e67 --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funStats.test.js @@ -0,0 +1,166 @@ +// Operator Fun Stats Command Tests +// Purpose: Verifies the leaderboard ordering, rover pet counting, and what snitch reports. +// Scope: In-memory stats and roster doubles only. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createFunStatsCommands, formatLeaderboard } = require('./funStats'); +const { createCooldownGate } = require('../cooldowns'); + +const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } }; +const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } }; + +function createHarness({ + actorRows = [], + activeDrivers = {}, + socket = ALICE, + rovers = new Map([ + ['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }], + ['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }], + ]), +} = {}) { + const pets = new Map(); + const handlers = createFunStatsCommands({ + io: { sockets: { sockets: new Map([[ALICE.id, ALICE], [BOB.id, BOB]]) } }, + rovers, + getNickname: (entry) => entry?.data?.nickname || '', + getActiveDrivers: () => activeDrivers, + getActorSocket: () => socket, + roverManager: { getPrimaryRoverForSocket: () => 'rover-1' }, + sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'), + funStatsService: { + listActorStats: () => actorRows, + bumpRoverPets: (roverId, by) => { + const next = (pets.get(roverId) || 0) + by; + pets.set(roverId, next); + return next; + }, + }, + cooldowns: createCooldownGate(), + config: { commands: { prefix: 'rs' } }, + }); + return { handlers, pets }; +} + +function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) { + const replies = []; + return { + transport: 'web-chat', + actor, + replies, + reply: async (payload) => { + replies.push(payload); + return null; + }, + }; +} + +test('the leaderboard sorts descending and drops zero scores', () => { + const rows = [ + { label: 'alice', bonksGiven: 2 }, + { label: 'bob', bonksGiven: 9 }, + { label: 'carol', bonksGiven: 0 }, + ]; + const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'); + const lines = rendered.split('\n'); + assert.equal(lines[1], '1. bob — 9'); + assert.equal(lines[2], '2. alice — 2'); + assert.equal(lines.length, 3, 'carol should not appear with a zero score'); +}); + +test('the leaderboard is capped at ten entries', () => { + const rows = Array.from({ length: 25 }, (_, index) => ({ label: `user${index}`, bonksGiven: index + 1 })); + const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'); + assert.equal(rendered.split('\n').length - 1, 10); +}); + +test('an all-zero counter renders no section at all', () => { + assert.equal(formatLeaderboard('Most bonks dealt', [{ label: 'alice', bonksGiven: 0 }], 'bonksGiven'), null); +}); + +test('bonkboard says so when nothing has happened yet', async () => { + const { handlers } = createHarness({ actorRows: [] }); + const msg = message(); + await handlers.bonkboard(msg, []); + assert.match(msg.replies[0].content, /Nobody has been bonked yet/); +}); + +test('bonkboard renders each populated section', async () => { + const { handlers } = createHarness({ + actorRows: [ + { label: 'alice', bonksGiven: 3, bonksTaken: 0, hugsGiven: 1 }, + { label: 'bob', bonksGiven: 0, bonksTaken: 3, hugsGiven: 0 }, + ], + }); + const msg = message(); + await handlers.bonkboard(msg, []); + + assert.match(msg.replies[0].content, /Most bonks dealt/); + assert.match(msg.replies[0].content, /Most bonks taken/); + assert.match(msg.replies[0].content, /Most hugs given/); +}); + +test('bonkboard sanitizes stored labels, so a hostile nickname cannot ping a guild', async () => { + const { handlers } = createHarness({ actorRows: [{ label: '@everyone', bonksGiven: 1 }] }); + const msg = message(); + await handlers.bonkboard(msg, []); + assert.doesNotMatch(msg.replies[0].content, /@everyone/); +}); + +test('pet counts against the rover the caller is on when none is named', async () => { + const { handlers, pets } = createHarness(); + const msg = message(); + await handlers.pet(msg, []); + + assert.match(msg.replies[0].content, /pets Roomba One/); + assert.match(msg.replies[0].content, /petted 1 time\./); + assert.equal(pets.get('rover-1'), 1); +}); + +test('pet accepts an explicit rover and keeps a separate count per rover', async () => { + const { handlers, pets } = createHarness(); + await handlers.pet(message(), ['Roomba Two']); + await handlers.pet(message({ id: 's2', userId: 'u-bob', label: 'bob' }), ['Roomba Two']); + + assert.equal(pets.get('rover-2'), 2); + assert.equal(pets.get('rover-1'), undefined); +}); + +test('pet pluralizes the running total', async () => { + const { handlers } = createHarness(); + await handlers.pet(message(), []); + const second = message({ id: 's2', userId: 'u-bob', label: 'bob' }); + await handlers.pet(second, []); + assert.match(second.replies[0].content, /petted 2 times\./); +}); + +test('pet from a transport with no socket asks for a rover name', async () => { + const { handlers, pets } = createHarness({ socket: null }); + const msg = message({ id: '4242', label: 'DiscordUser' }); + await handlers.pet(msg, []); + + assert.match(msg.replies[0].content, /Name a rover/); + assert.equal(pets.size, 0); +}); + +test('snitch names the active driver and reports idle rovers as nobody', async () => { + const { handlers } = createHarness({ activeDrivers: { 'rover-1': 's2' } }); + const msg = message(); + await handlers.snitch(msg, []); + + assert.match(msg.replies[0].content, /Roomba One — bob/); + assert.match(msg.replies[0].content, /Roomba Two — nobody/); +}); + +test('snitch handles a driver socket that has already gone away', async () => { + const { handlers } = createHarness({ activeDrivers: { 'rover-1': 'ghost-socket' } }); + const msg = message(); + await handlers.snitch(msg, []); + assert.match(msg.replies[0].content, /Roomba One — someone who will not say their name/); +}); + +test('snitch reports an empty fleet rather than an empty message', async () => { + const { handlers } = createHarness({ rovers: new Map() }); + const msg = message(); + await handlers.snitch(msg, []); + assert.match(msg.replies[0].content, /No rovers are online/); +}); diff --git a/server/src/services/operatorCommandService/commands/funText.js b/server/src/services/operatorCommandService/commands/funText.js new file mode 100644 index 00000000..d3e856bf --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funText.js @@ -0,0 +1,361 @@ +// Operator Fun Text Commands +// Purpose: Implements the social, text-only `rs` commands (bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted). +// Scope: Text and counters only; nothing here touches rover hardware. +const { getCommandConfig } = require('../../operatorCommandService/config'); +const { describeWait } = require('../cooldowns'); +const { + PLAIN_MENTIONS, + actorLabel, + buildActorKey, + clampEcho, + hashSeed, + ordinal, + pairSeed, + percentFromSeed, + pickBySeed, + resolveFunTarget, +} = require('./funHelpers'); + +const TEXT_COOLDOWN_MS = 4 * 1000; + +/* + The bonk sound gets its own, much longer rover-scoped window. Playing it + interrupts whatever that rover is forwarding — including a live microphone — so + the audio must not be spammable even though the text bonk stays snappy, and a + group of people bonking one driver cannot chain it either. +*/ +const BONK_SOUND_ROVER_COOLDOWN_MS = 20 * 1000; + +const SLAP_ITEMS = [ + 'a large trout', 'a rolled-up service manual', 'a dead AA battery', 'a docking station', + 'a suspiciously warm power brick', 'half a roll of duct tape', 'a decommissioned brush guard', + 'a bag of loose screws', 'an unlabelled USB cable', 'a soggy floor sensor', + 'the heaviest available wrench', 'a stack of unread pull requests', +]; + +const EIGHT_BALL_ANSWERS = [ + 'Yes.', 'No.', 'Absolutely.', 'Absolutely not.', 'Ask again once the battery is charged.', + 'Signs point to the docking station.', 'The overseer says no.', 'Almost certainly.', + 'Not while anyone is watching.', 'Outlook cloudy, sensors dirty.', 'Try it and find out.', + 'That is a maintenance window problem.', 'Only on a Tuesday.', 'The rover has already decided.', +]; + +const HUG_FLAVOURS = [ + 'gently', 'aggressively', 'with both brush guards', 'at full wheel speed', + 'for slightly too long', 'while beeping softly', 'without asking first', +]; + +const WANTED_CRIMES = [ + 'reckless docking', 'driving with the brush on indoors', 'excessive honking', + 'unauthorised carpet donuts', 'battery hoarding', 'ignoring the global objective', + 'parking in the doorway', 'nine consecutive turn skips', 'talking to the Neato', + 'strobing the room lights for fun', 'stealing another rover\'s charger', +]; + +const RATE_SUFFIXES = [ + 'No further questions.', 'I stand by this.', 'Do not appeal.', 'Take it or leave it.', + 'The sensors agree.', 'This rating is final.', +]; + +/* + A dice roll is one of the few places a fun command should be genuinely random: + the whole point is that nobody can predict it. Everything that passes judgement + on a thing (`ship`, `rate`, `8ball`, `wanted`) is seeded from the input instead, + so re-running it cannot reroll a verdict somebody disliked. +*/ +function rollDice(count, sides) { + let total = 0; + const rolls = []; + for (let index = 0; index < count; index += 1) { + const value = 1 + Math.floor(Math.random() * sides); + rolls.push(value); + total += value; + } + return { rolls, total }; +} + +function parseDiceSpec(spec) { + const text = String(spec || '').trim().toLowerCase() || '1d6'; + const match = /^(\d*)d(\d+)$/.exec(text); + if (!match) { + // A bare number is read as a single die with that many sides so `rs roll 20` + // does the obvious thing instead of erroring. + const bare = /^(\d+)$/.exec(text); + if (!bare) return { error: 'Roll format is `NdN`, for example `2d6`.' }; + const sides = Number(bare[1]); + if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' }; + return { count: 1, sides }; + } + const count = match[1] === '' ? 1 : Number(match[1]); + const sides = Number(match[2]); + if (count < 1 || count > 20) return { error: 'Roll between 1 and 20 dice.' }; + if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' }; + return { count, sides }; +} + +function uwuify(text) { + return String(text || '') + .replace(/[rl]/g, 'w') + .replace(/[RL]/g, 'W') + .replace(/n([aeiou])/g, 'ny$1') + .replace(/N([aeiou])/g, 'Ny$1') + .replace(/ove/g, 'uv') + .replace(/!+/g, ' !!'); +} + +function createFunTextCommands({ + io, + getNickname, + getActiveDrivers, + publishEvent, + sanitizeMentions, + funStatsService, + cooldowns, + config, +}) { + const { prefix: commandPrefix } = getCommandConfig(config); + const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || '')); + + /* + Announces the bonk so audioForwardService can play the sound on the rover the + target is driving. Published as an event rather than calling the audio pipeline + directly, matching how the charging-complete cue is wired: the command layer + stays unaware of ffmpeg, and a server without the sound installed simply has + nothing listening that can do anything. + */ + function announceBonk(targetSocket, targetLabel, actorLabelText) { + if (!targetSocket || typeof publishEvent !== 'function') return; + + const drivers = getActiveDrivers?.() || {}; + const roverId = Object.keys(drivers).find((id) => drivers[id] === targetSocket.id) || null; + if (!roverId) return; + + if (cooldowns.consume(`bonk:sound:${roverId}`, BONK_SOUND_ROVER_COOLDOWN_MS) > 0) return; + + publishEvent({ + source: 'funCommands', + type: 'fun.bonked', + payload: { roverId, targetLabel, actor: actorLabelText }, + }); + } + + function reply(message, content) { + return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS }); + } + + /* + Every fun command runs through one gate so the cooldown, the actor identity, + and the "who am I talking about" resolution cannot drift apart between + commands. `needsTarget` commands reply with their own usage line when the + selector is missing rather than silently acting on nothing. + */ + function gate(message, action, { windowMs = TEXT_COOLDOWN_MS } = {}) { + const actorKey = buildActorKey(message); + if (!actorKey) return { error: 'Could not identify you well enough to do that.' }; + const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs); + if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` }; + return { actorKey, label: actorLabel(message) }; + } + + function target(selector) { + return resolveFunTarget({ io, getNickname, selector }); + } + + /* + Shared shape for the three "do a thing to someone" commands. Only the verb, + the counter names, and the flavour text differ, and keeping them in one place + means a fix to self-targeting or tally credit applies to all of them. + */ + function createInteraction({ action, counterGiven, counterTaken, selfReply, render, onApplied }) { + return async function handleInteraction(message, tokens = []) { + const selector = tokens.join(' ').trim(); + if (!selector) { + return reply(message, `Usage: \`${commandPrefix} ${action} \``); + } + + const gated = gate(message, action); + if (gated.error) return reply(message, gated.error); + + const resolved = target(selector); + if (!resolved) return reply(message, `Usage: \`${commandPrefix} ${action} \``); + + if (resolved.actorKey && resolved.actorKey === gated.actorKey) { + return reply(message, selfReply(gated.label)); + } + + funStatsService.bumpActorStats(gated.actorKey, { label: gated.label, [counterGiven]: 1 }); + const targetStats = resolved.actorKey + ? funStatsService.bumpActorStats(resolved.actorKey, { label: resolved.label, [counterTaken]: 1 }) + : null; + + // Side effects run after the tallies so a failure in an optional extra (the + // bonk sound) cannot cost the user their recorded bonk. + onApplied?.({ actor: gated.label, resolved }); + + return reply(message, render({ + actor: gated.label, + actorKey: gated.actorKey, + targetLabel: resolved.label, + targetOnline: resolved.online, + takenCount: targetStats ? targetStats[counterTaken] : null, + })); + }; + } + + const handleBonk = createInteraction({ + action: 'bonk', + counterGiven: 'bonksGiven', + counterTaken: 'bonksTaken', + selfReply: (label) => `${label} bonked themselves. That is between you and the rover.`, + onApplied: ({ actor, resolved }) => announceBonk(resolved.socket, resolved.label, actor), + render: ({ targetLabel, takenCount }) => { + const tally = takenCount ? ` That is their ${ordinal(takenCount)} bonk.` : ''; + return `🔨 Bonked ${targetLabel}.${tally}`; + }, + }); + + const handleHug = createInteraction({ + action: 'hug', + counterGiven: 'hugsGiven', + counterTaken: 'hugsTaken', + selfReply: (label) => `${label} hugged themselves. Genuinely fine. No notes.`, + render: ({ actor, targetLabel, takenCount }) => { + const flavour = pickBySeed(HUG_FLAVOURS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`)); + const tally = takenCount ? ` (${takenCount} total)` : ''; + return `🫂 ${actor} hugs ${targetLabel} ${flavour}.${tally}`; + }, + }); + + const handleSlap = createInteraction({ + action: 'slap', + counterGiven: 'slapsGiven', + counterTaken: 'slapsTaken', + selfReply: (label) => `${label} slapped themselves with a large trout. Bold.`, + render: ({ actor, targetLabel, takenCount }) => { + // Seeding on the running count means the weapon changes every time without + // being unpredictable for the same repeat number. + const item = pickBySeed(SLAP_ITEMS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`)); + return `🐟 ${actor} slaps ${targetLabel} around a bit with ${item}.`; + }, + }); + + async function handleEightBall(message, tokens = []) { + const question = clampEcho(tokens.join(' ')); + if (!question) return reply(message, `Usage: \`${commandPrefix} 8ball \``); + + const gated = gate(message, '8ball'); + if (gated.error) return reply(message, gated.error); + + const answer = pickBySeed(EIGHT_BALL_ANSWERS, hashSeed(question)); + return reply(message, `🎱 ${question}\n${answer}`); + } + + async function handleRoll(message, tokens = []) { + const gated = gate(message, 'roll'); + if (gated.error) return reply(message, gated.error); + + const spec = parseDiceSpec(tokens.join('')); + if (spec.error) return reply(message, spec.error); + + const { rolls, total } = rollDice(spec.count, spec.sides); + const detail = rolls.length > 1 ? ` (${rolls.join(' + ')})` : ''; + return reply(message, `🎲 ${gated.label} rolled ${spec.count}d${spec.sides}: **${total}**${detail}`); + } + + async function handleCoin(message) { + const gated = gate(message, 'coin'); + if (gated.error) return reply(message, gated.error); + const side = Math.random() < 0.5 ? 'Heads' : 'Tails'; + return reply(message, `🪙 ${side}.`); + } + + async function handleShip(message, tokens = []) { + const parts = tokens.join(' ').split(/\s+(?:and|\+|&)\s+|\s*,\s*/i).map((part) => clampEcho(part)).filter(Boolean); + const [left, right] = parts.length >= 2 ? parts : [parts[0], null]; + if (!left || !right) { + return reply(message, `Usage: \`${commandPrefix} ship and \``); + } + + const gated = gate(message, 'ship'); + if (gated.error) return reply(message, gated.error); + + const score = percentFromSeed(pairSeed(left, right)); + const verdict = score >= 90 ? 'Get them a shared charging dock.' + : score >= 65 ? 'Promising.' + : score >= 35 ? 'Needs work.' + : score >= 10 ? 'The sensors are not hopeful.' + : 'Absolutely not.'; + return reply(message, `💞 ${left} + ${right} = **${score}%**. ${verdict}`); + } + + async function handleRate(message, tokens = []) { + const thing = clampEcho(tokens.join(' ')); + if (!thing) return reply(message, `Usage: \`${commandPrefix} rate \``); + + const gated = gate(message, 'rate'); + if (gated.error) return reply(message, gated.error); + + const seed = hashSeed(thing); + const score = seed % 11; + const suffix = pickBySeed(RATE_SUFFIXES, seed); + return reply(message, `📊 I rate ${thing} **${score}/10**. ${suffix}`); + } + + async function handleUwu(message, tokens = []) { + const text = clampEcho(tokens.join(' ')); + if (!text) return reply(message, `Usage: \`${commandPrefix} uwu \``); + + const gated = gate(message, 'uwu'); + if (gated.error) return reply(message, gated.error); + + return reply(message, uwuify(text)); + } + + async function handleWanted(message, tokens = []) { + const selector = tokens.join(' ').trim(); + if (!selector) return reply(message, `Usage: \`${commandPrefix} wanted \``); + + const gated = gate(message, 'wanted'); + if (gated.error) return reply(message, gated.error); + + const resolved = target(selector); + if (!resolved) return reply(message, `Usage: \`${commandPrefix} wanted \``); + + const seed = hashSeed(resolved.label); + const crime = pickBySeed(WANTED_CRIMES, seed); + // Bounty is seeded so a given name always carries the same price. Somebody + // being permanently worth 12 credits is funnier than a fresh number each time. + const bounty = 25 + (seed % 4776); + const stats = resolved.actorKey ? funStatsService.getActorStats(resolved.actorKey) : null; + const priors = stats && stats.bonksTaken ? `\nPrior bonks on record: ${stats.bonksTaken}.` : ''; + return reply(message, [ + '```', + ' WANTED', + ` ${resolved.label}`, + ` for ${crime}`, + ` reward: ${bounty} credits`, + '```', + ].join('\n') + priors); + } + + return { + bonk: handleBonk, + hug: handleHug, + slap: handleSlap, + '8ball': handleEightBall, + roll: handleRoll, + coin: handleCoin, + ship: handleShip, + rate: handleRate, + uwu: handleUwu, + wanted: handleWanted, + }; +} + +module.exports = { + createFunTextCommands, + // Exported for tests: the parsing and transform rules are the parts most + // likely to regress, and they are pure. + parseDiceSpec, + uwuify, +}; diff --git a/server/src/services/operatorCommandService/commands/funText.test.js b/server/src/services/operatorCommandService/commands/funText.test.js new file mode 100644 index 00000000..9e137171 --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funText.test.js @@ -0,0 +1,304 @@ +// Operator Fun Text Command Tests +// Purpose: Verifies tally credit, self-targeting, cooldown refusal, mention sanitizing, and dice parsing. +// Scope: Uses an in-memory stats double so no test touches the fun stats file. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createFunTextCommands, parseDiceSpec, uwuify } = require('./funText'); +const { createCooldownGate } = require('../cooldowns'); + +function createStatsDouble() { + const store = new Map(); + return { + calls: [], + bumpActorStats(actorKey, { label = null, ...patch } = {}) { + this.calls.push({ actorKey, label, patch }); + const current = store.get(actorKey) || {}; + const next = { ...current, label: label || current.label }; + Object.keys(patch).forEach((key) => { + next[key] = (Number(current[key]) || 0) + Number(patch[key] || 0); + }); + store.set(actorKey, next); + return next; + }, + getActorStats(actorKey) { + return store.get(actorKey) || {}; + }, + listActorStats() { + return Array.from(store.entries()).map(([actorKey, value]) => ({ actorKey, ...value })); + }, + }; +} + +function createHarness({ sockets = [], activeDrivers = {} } = {}) { + const stats = createStatsDouble(); + const events = []; + const handlers = createFunTextCommands({ + io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } }, + getNickname: (entry) => entry?.data?.nickname || '', + getActiveDrivers: () => activeDrivers, + publishEvent: (event) => events.push(event), + // Matches the real sanitizer so tests exercise the actual escaping rules. + sanitizeMentions: (text) => String(text || '') + .replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]') + .replace(/@everyone/gi, '[everyone]') + .replace(/@here/gi, '[here]'), + funStatsService: stats, + cooldowns: createCooldownGate(), + config: { commands: { prefix: 'rs' } }, + }); + return { handlers, stats, events }; +} + +function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) { + const replies = []; + return { + transport: 'web-chat', + actor, + replies, + reply: async (payload) => { + replies.push(payload); + return null; + }, + }; +} + +const bob = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } }; + +test('bonk credits both sides and reports the running tally', async () => { + const { handlers, stats } = createHarness({ sockets: [bob] }); + const msg = message(); + await handlers.bonk(msg, ['bob']); + + assert.match(msg.replies[0].content, /Bonked bob\./); + assert.match(msg.replies[0].content, /1st bonk/); + assert.deepEqual( + stats.calls.map((call) => [call.actorKey, Object.keys(call.patch)[0]]), + [['user:u-alice', 'bonksGiven'], ['user:u-bob', 'bonksTaken']], + ); +}); + +test('the tally ordinal advances across repeat bonks', async () => { + const { handlers } = createHarness({ sockets: [bob] }); + await handlers.bonk(message(), ['bob']); + // A second actor avoids the first actor's cooldown while still hitting bob. + const second = message({ id: 's3', userId: 'u-carol', label: 'carol' }); + await handlers.bonk(second, ['bob']); + assert.match(second.replies[0].content, /2nd bonk/); +}); + +test('bonking an offline name still replies but credits nobody', async () => { + const { handlers, stats } = createHarness({ sockets: [bob] }); + const msg = message(); + await handlers.bonk(msg, ['the', 'dishwasher']); + + assert.match(msg.replies[0].content, /Bonked the dishwasher\./); + assert.doesNotMatch(msg.replies[0].content, /bonk\b.*\dst|\dnd|\drd|\dth/); + assert.deepEqual(stats.calls.map((call) => call.actorKey), ['user:u-alice']); +}); + +test('self-bonking is a special case and records nothing', async () => { + const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } }; + const { handlers, stats } = createHarness({ sockets: [alice] }); + const msg = message(); + await handlers.bonk(msg, ['alice']); + + assert.match(msg.replies[0].content, /themselves/); + assert.equal(stats.calls.length, 0); +}); + +test('a repeat inside the cooldown window is refused and records nothing extra', async () => { + const { handlers, stats } = createHarness({ sockets: [bob] }); + await handlers.bonk(message(), ['bob']); + const countAfterFirst = stats.calls.length; + + const second = message(); + await handlers.bonk(second, ['bob']); + assert.match(second.replies[0].content, /Slow down/); + assert.equal(stats.calls.length, countAfterFirst); +}); + +test('cooldowns are per command, so a bonk does not block a hug', async () => { + const { handlers } = createHarness({ sockets: [bob] }); + await handlers.bonk(message(), ['bob']); + const hug = message(); + await handlers.hug(hug, ['bob']); + assert.doesNotMatch(hug.replies[0].content, /Slow down/); +}); + +test('a missing target replies with usage and does not burn the cooldown', async () => { + const { handlers } = createHarness({ sockets: [bob] }); + const first = message(); + await handlers.bonk(first, []); + assert.match(first.replies[0].content, /Usage: `rs bonk `/); + + const second = message(); + await handlers.bonk(second, ['bob']); + assert.match(second.replies[0].content, /Bonked bob/); +}); + +test('every reply is sanitized so a fun command cannot ping a whole guild', async () => { + const { handlers } = createHarness(); + const msg = message(); + await handlers.bonk(msg, ['@everyone']); + assert.doesNotMatch(msg.replies[0].content, /@everyone/); + assert.match(msg.replies[0].content, /\[everyone\]/); + + const roleMsg = message({ id: 's9', userId: 'u-dave', label: 'dave' }); + await handlers.slap(roleMsg, ['<@&123456>']); + assert.match(roleMsg.replies[0].content, /\[ping removed\]/); +}); + +test('an actor with no identity at all is refused rather than sharing a tally', async () => { + const { handlers, stats } = createHarness({ sockets: [bob] }); + const msg = message({ label: 'ghost' }); + await handlers.bonk(msg, ['bob']); + assert.match(msg.replies[0].content, /Could not identify you/); + assert.equal(stats.calls.length, 0); +}); + +test('ship agrees with itself regardless of argument order', async () => { + const { handlers } = createHarness(); + const forward = message(); + await handlers.ship(forward, ['alice', 'and', 'bob']); + + const { handlers: other } = createHarness(); + const backward = message(); + await other.ship(backward, ['bob', 'and', 'alice']); + + const score = (text) => /\*\*(\d+)%\*\*/.exec(text)[1]; + assert.equal(score(forward.replies[0].content), score(backward.replies[0].content)); +}); + +test('ship needs two sides', async () => { + const { handlers } = createHarness(); + const msg = message(); + await handlers.ship(msg, ['alice']); + assert.match(msg.replies[0].content, /Usage: `rs ship/); +}); + +test('8ball gives the same answer to the same question', async () => { + const first = createHarness(); + const a = message(); + await first.handlers['8ball'](a, ['will', 'it', 'dock']); + + const second = createHarness(); + const b = message(); + await second.handlers['8ball'](b, ['WILL', 'IT', 'DOCK']); + + assert.equal(a.replies[0].content.split('\n')[1], b.replies[0].content.split('\n')[1]); +}); + +test('rate stays inside 0 to 10', async () => { + for (const thing of ['carpet', 'the dock', 'a', 'zzzzzz', 'rover 3']) { + const { handlers } = createHarness(); + const msg = message(); + await handlers.rate(msg, [thing]); + const score = Number(/\*\*(\d+)\/10\*\*/.exec(msg.replies[0].content)[1]); + assert.ok(score >= 0 && score <= 10, `${thing} scored ${score}`); + } +}); + +test('dice specs parse the accepted forms and reject the rest', () => { + assert.deepEqual(parseDiceSpec('2d6'), { count: 2, sides: 6 }); + assert.deepEqual(parseDiceSpec('d20'), { count: 1, sides: 20 }); + assert.deepEqual(parseDiceSpec(''), { count: 1, sides: 6 }); + // A bare number is read as one die of that many sides. + assert.deepEqual(parseDiceSpec('20'), { count: 1, sides: 20 }); + assert.match(parseDiceSpec('21d6').error, /between 1 and 20 dice/); + assert.match(parseDiceSpec('1d1').error, /2 and 1000 sides/); + assert.match(parseDiceSpec('1d2000').error, /2 and 1000 sides/); + assert.match(parseDiceSpec('banana').error, /NdN/); +}); + +test('roll totals stay within the possible range for the spec', async () => { + for (let attempt = 0; attempt < 25; attempt += 1) { + const { handlers } = createHarness(); + const msg = message(); + await handlers.roll(msg, ['3d6']); + const total = Number(/\*\*(\d+)\*\*/.exec(msg.replies[0].content)[1]); + assert.ok(total >= 3 && total <= 18, `rolled ${total}`); + } +}); + +test('uwu transforms text without dropping it', () => { + assert.equal(uwuify('hello world'), 'hewwo wowwd'); + assert.equal(uwuify('love'), 'wuv'); + assert.equal(uwuify('nice'), 'nyice'); +}); + +test('bonking someone who is driving announces the sound for their rover', async () => { + const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } }); + await handlers.bonk(message(), ['bob']); + + assert.equal(events.length, 1); + assert.equal(events[0].type, 'fun.bonked'); + assert.equal(events[0].payload.roverId, 'rover-1'); + assert.equal(events[0].payload.targetLabel, 'bob'); +}); + +test('bonking someone who is not driving announces nothing', async () => { + const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: {} }); + const msg = message(); + await handlers.bonk(msg, ['bob']); + + // The text bonk still lands and is still tallied; only the sound is skipped. + assert.match(msg.replies[0].content, /Bonked bob/); + assert.equal(events.length, 0); +}); + +test('bonking a name that is not a real user announces nothing', async () => { + const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } }); + await handlers.bonk(message(), ['the dishwasher']); + assert.equal(events.length, 0); +}); + +test('the bonk sound is rate limited per rover so it cannot interrupt a mic repeatedly', async () => { + const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } }); + await handlers.bonk(message(), ['bob']); + // A different actor has their own text cooldown but must not get a second sound. + await handlers.bonk(message({ id: 's3', userId: 'u-carol', label: 'carol' }), ['bob']); + await handlers.bonk(message({ id: 's4', userId: 'u-erin', label: 'erin' }), ['bob']); + + assert.equal(events.length, 1); +}); + +test('a self-bonk never announces a sound', async () => { + const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } }; + const { handlers, events } = createHarness({ sockets: [alice], activeDrivers: { 'rover-1': 's1' } }); + await handlers.bonk(message(), ['alice']); + assert.equal(events.length, 0); +}); + +test('hug and slap do not announce a bonk sound', async () => { + const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } }); + await handlers.hug(message(), ['bob']); + await handlers.slap(message(), ['bob']); + assert.equal(events.length, 0); +}); + +test('a transport with no publishEvent still bonks normally', async () => { + const stats = createStatsDouble(); + const handlers = createFunTextCommands({ + io: { sockets: { sockets: new Map([[bob.id, bob]]) } }, + getNickname: (entry) => entry?.data?.nickname || '', + getActiveDrivers: () => ({ 'rover-1': 's2' }), + publishEvent: undefined, + sanitizeMentions: (text) => String(text || ''), + funStatsService: stats, + cooldowns: createCooldownGate(), + config: { commands: { prefix: 'rs' } }, + }); + const msg = message(); + await handlers.bonk(msg, ['bob']); + assert.match(msg.replies[0].content, /Bonked bob/); +}); + +test('wanted includes prior bonks when the target has any on record', async () => { + const { handlers } = createHarness({ sockets: [bob] }); + await handlers.bonk(message(), ['bob']); + + const msg = message({ id: 's4', userId: 'u-erin', label: 'erin' }); + await handlers.wanted(msg, ['bob']); + assert.match(msg.replies[0].content, /WANTED/); + assert.match(msg.replies[0].content, /Prior bonks on record: 1/); +}); diff --git a/server/src/services/operatorCommandService/cooldowns.js b/server/src/services/operatorCommandService/cooldowns.js new file mode 100644 index 00000000..2c51bcfd --- /dev/null +++ b/server/src/services/operatorCommandService/cooldowns.js @@ -0,0 +1,70 @@ +// Operator Command Cooldowns +// Purpose: Rate limits individual commands per actor without coupling to a transport. +// Scope: In-memory only; a restart clears every cooldown by design. +const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000; + +/* + Fun commands are reachable from both site chat and Discord, and site chat's own + rate limit only bounds messages per socket rather than a specific command. A + per-actor, per-command gate is what stops one person turning `rs honk` into a + siren, and it is deliberately in-memory: a cooldown that survives a restart + would be a moderation feature, not a spam guard. +*/ +function createCooldownGate({ sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS } = {}) { + const expiries = new Map(); + let lastSweep = 0; + + function sweep(now) { + if (now - lastSweep < sweepIntervalMs) return; + lastSweep = now; + expiries.forEach((expiresAt, key) => { + if (expiresAt <= now) expiries.delete(key); + }); + } + + function remaining(key, now = Date.now()) { + const expiresAt = expiries.get(String(key)); + if (!expiresAt) return 0; + return Math.max(0, expiresAt - now); + } + + /* + Returns the remaining wait when the gate is closed, or 0 after arming the + next window. Callers therefore treat any non-zero result as a refusal, and a + refused call never extends the existing window. + */ + function consume(key, windowMs, now = Date.now()) { + const normalizedKey = String(key || '').trim(); + if (!normalizedKey) return 0; + const window = Number(windowMs); + if (!Number.isFinite(window) || window <= 0) return 0; + + sweep(now); + const wait = remaining(normalizedKey, now); + if (wait > 0) return wait; + expiries.set(normalizedKey, now + window); + return 0; + } + + function clear(key) { + expiries.delete(String(key || '').trim()); + } + + function reset() { + expiries.clear(); + lastSweep = 0; + } + + return { consume, remaining, clear, reset }; +} + +function describeWait(waitMs) { + const seconds = Math.ceil(Number(waitMs || 0) / 1000); + if (seconds <= 1) return '1s'; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return rest ? `${minutes}m ${rest}s` : `${minutes}m`; +} + +module.exports = { createCooldownGate, describeWait }; diff --git a/server/src/services/operatorCommandService/cooldowns.test.js b/server/src/services/operatorCommandService/cooldowns.test.js new file mode 100644 index 00000000..1896632c --- /dev/null +++ b/server/src/services/operatorCommandService/cooldowns.test.js @@ -0,0 +1,53 @@ +// Operator Command Cooldown Tests +// Purpose: Verifies the per-actor rate limit opens and closes on the boundaries callers rely on. +// Scope: Pure; the gate takes an injected clock so no test needs to sleep. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createCooldownGate, describeWait } = require('./cooldowns'); + +test('first use passes and an immediate repeat is refused', () => { + const gate = createCooldownGate(); + assert.equal(gate.consume('bonk:alice', 1000, 0), 0); + assert.equal(gate.consume('bonk:alice', 1000, 0), 1000); + assert.equal(gate.consume('bonk:alice', 1000, 400), 600); +}); + +test('the window reopens exactly when it expires', () => { + const gate = createCooldownGate(); + gate.consume('honk:alice', 1000, 0); + assert.equal(gate.consume('honk:alice', 1000, 999), 1); + assert.equal(gate.consume('honk:alice', 1000, 1000), 0); +}); + +test('a refused call does not extend the existing window', () => { + const gate = createCooldownGate(); + gate.consume('honk:alice', 1000, 0); + // Hammering the gate at t=500 must not push the reopen time out to t=1500. + gate.consume('honk:alice', 1000, 500); + gate.consume('honk:alice', 1000, 900); + assert.equal(gate.consume('honk:alice', 1000, 1000), 0); +}); + +test('cooldowns are scoped per key so different actors and commands do not collide', () => { + const gate = createCooldownGate(); + assert.equal(gate.consume('bonk:alice', 1000, 0), 0); + assert.equal(gate.consume('bonk:bob', 1000, 0), 0); + assert.equal(gate.consume('hug:alice', 1000, 0), 0); + assert.equal(gate.consume('bonk:alice', 1000, 0), 1000); +}); + +test('a missing key or non-positive window never gates', () => { + const gate = createCooldownGate(); + assert.equal(gate.consume('', 1000, 0), 0); + assert.equal(gate.consume('bonk:alice', 0, 0), 0); + assert.equal(gate.consume('bonk:alice', -5, 0), 0); + // None of the above should have armed anything. + assert.equal(gate.remaining('bonk:alice', 0), 0); +}); + +test('describeWait rounds up and switches to minutes', () => { + assert.equal(describeWait(1), '1s'); + assert.equal(describeWait(4200), '5s'); + assert.equal(describeWait(60 * 1000), '1m'); + assert.equal(describeWait(95 * 1000), '1m 35s'); +}); diff --git a/server/src/services/operatorCommandService/help.js b/server/src/services/operatorCommandService/help.js index f3bd4a29..9b9f740b 100644 --- a/server/src/services/operatorCommandService/help.js +++ b/server/src/services/operatorCommandService/help.js @@ -25,7 +25,7 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = '' const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic; const categoryNames = requestedCategory && CATEGORIES[requestedCategory] ? [requestedCategory] - : ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])]; + : ['system', 'admin', 'features', 'fun', ...(includeDiscord ? ['discord'] : [])]; const output = ['**Rover Bot Commands**']; for (const categoryName of categoryNames) { diff --git a/server/src/services/operatorCommandService/index.js b/server/src/services/operatorCommandService/index.js index df6aa2c6..09160a77 100644 --- a/server/src/services/operatorCommandService/index.js +++ b/server/src/services/operatorCommandService/index.js @@ -13,9 +13,22 @@ const { createLightsCommand } = require('./commands/lights'); const { createKickCommand } = require('./commands/kick'); const { createLiftCommand } = require('./commands/lift'); const { createNeatoCommand } = require('./commands/neato'); +const { createFunTextCommands } = require('./commands/funText'); +const { createFunStatsCommands } = require('./commands/funStats'); +const { createFunRoverCommands } = require('./commands/funRover'); +const { createCooldownGate } = require('./cooldowns'); const { getCommandConfig } = require('./config'); const { buildCommandRegistry } = require('./registry'); +/* + Commands that enforce their own permissions inside their handler rather than at + the dispatcher. `goal` and `reason` are readable by anyone but only writable by + an admin; `verify` and `deter` reject non-lockdown-admins themselves so they can + explain which role is missing. Listing them here preserves that behavior now + that the general non-admin gate is driven by registry metadata. +*/ +const SELF_GATED_ACTIONS = new Set(['', 'status', 'help', 'replay', 'bridge', 'goal', 'reason', 'verify', 'deter']); + function createCommandHandlers(deps) { const { getMode, @@ -56,6 +69,20 @@ function createCommandHandlers(deps) { const handleLiftCommand = createLiftCommand(deps); const handleNeatoCommand = createNeatoCommand(deps); + /* + Fun commands share one cooldown gate. Web chat rebuilds this router per + message, so an injected gate is what makes the cooldowns actually shared + across a user's messages; a gate created here would be discarded every time + and rate limit nothing. Falling back to a local gate keeps the router usable + on its own (and in tests) without making every caller supply one. + */ + const cooldowns = deps.commandCooldowns || createCooldownGate(); + const funHandlers = { + ...createFunTextCommands({ ...deps, cooldowns }), + ...createFunStatsCommands({ ...deps, cooldowns }), + ...createFunRoverCommands({ ...deps, cooldowns }), + }; + function stripCommandPrefix(content) { const trimmed = String(content || '').trim(); const lower = trimmed.toLowerCase(); @@ -101,6 +128,8 @@ function createCommandHandlers(deps) { // lockdown admin while the entire server is in lockdown. const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'gain', 'lights', 'kick', 'lift', 'neato']); const isAccessModeCommand = commandDefinition?.permission === 'access-mode'; + const isPublicCommand = commandDefinition?.permission === 'public'; + const isFunCommand = commandDefinition?.category === 'fun'; // Feature commands are public activities while access is open or managed // by turns. In admin mode they follow the same admin-only boundary as rover @@ -112,12 +141,17 @@ function createCommandHandlers(deps) { return; } - if (!isAccessModeCommand && !isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') { + if (!isAccessModeCommand && !isPublicCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) { await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); return; } - if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) { + /* + Lockdown exists to make the server quiet and controlled, so the whole fun + category is suspended alongside the moderation-sensitive actions rather than + leaving a horn command reachable by anyone while the fleet is locked down. + */ + if (mode === MODES.LOCKDOWN && (moderationActions.has(action) || isFunCommand) && !isLockdownAdmin) { await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); return; } @@ -158,6 +192,7 @@ function createCommandHandlers(deps) { case 'gain': return handleGainCommand(request, tokens); default: + if (funHandlers[action]) return funHandlers[action](request, tokens); return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled })); } } diff --git a/server/src/services/operatorCommandService/index.test.js b/server/src/services/operatorCommandService/index.test.js new file mode 100644 index 00000000..e7877a98 --- /dev/null +++ b/server/src/services/operatorCommandService/index.test.js @@ -0,0 +1,181 @@ +// Operator Command Dispatcher Tests +// Purpose: Pins the permission, mode, and prefix policy that the registry-driven gate replaced a hardcoded action list with. +// Scope: Exercises the router with doubles; individual command behavior is covered by each command's own tests. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { createCommandHandlers } = require('./index'); + +const MODES = { OPEN: 'open', TURNS: 'turns', ADMIN: 'admin', LOCKDOWN: 'lockdown' }; +const ADMIN_DENIAL = /Only admins can run that command/; +const LOCKDOWN_DENIAL = /Lockdown mode: only lockdown admins/; +const FEATURE_DENIAL = /Admin mode: only admins can run feature commands/; + +const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } }; + +function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) { + const rovers = new Map([['rover-1', { + id: 'rover-1', + ws: {}, + meta: { name: 'Roomba One', horn: { enabled: true } }, + batteryState: { percentDisplay: 50 }, + }]]); + + const { handleCommand } = createCommandHandlers({ + logger: { warn: () => {}, info: () => {} }, + io: { sockets: { sockets: new Map([[ALICE.id, ALICE]]) } }, + rovers, + roverManager: { + canDrive: () => true, + getPrimaryRoverForSocket: () => 'rover-1', + applyPrivateDriveSafety: () => null, + }, + getMode: () => mode, + MODES, + getNickname: (entry) => entry?.data?.nickname || '', + getActiveDrivers: () => ({}), + getActorSocket: () => ALICE, + issueCommand: () => 'cmd-1', + isFeatureEnabled: () => featureEnabled, + sanitizeMentions: (text) => String(text || ''), + funStatsService: { + bumpActorStats: () => ({}), + getActorStats: () => ({}), + listActorStats: () => [], + bumpRoverPets: () => 1, + getRoverPets: () => 0, + }, + homeAssistantService: { getLightPolicyState: () => ({}), setAllControllableEntitiesState: () => Promise.resolve() }, + liftService: null, + neatoService: null, + listVerifiedUsers: () => [], + listDeterredUsers: () => [], + listMutedUsers: () => [], + getGlobalObjective: () => null, + getAdminReason: () => null, + config: { commands: { prefix: 'rs' } }, + transportHandlers: { + status: async (request) => request.reply({ content: '[status handler]' }), + bridge: async (request) => request.reply({ content: '[bridge handler]' }), + }, + }); + + return async function run(text, actor) { + const replies = []; + await handleCommand({ + content: text, + transport: 'web-chat', + actor, + reply: async (payload) => { + replies.push(typeof payload === 'string' ? payload : payload?.content); + }, + }); + return replies.join('\n'); + }; +} + +const nonAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: false, isLockdownAdmin: false }; +const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: false }; +const lockdownAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: true }; + +test('a non-admin can run fun commands', async () => { + const run = createRouter(); + for (const command of ['rs coin', 'rs rate carpet', 'rs uwu hi', 'rs bonkboard', 'rs vibecheck', 'rs snitch']) { + const reply = await run(command, nonAdmin); + assert.doesNotMatch(reply, ADMIN_DENIAL, `${command} should be public`); + assert.ok(reply.length > 0, `${command} should reply`); + } +}); + +test('admin-only commands stay admin-only for a non-admin', async () => { + const run = createRouter(); + for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs kick alice']) { + assert.match(await run(command, nonAdmin), ADMIN_DENIAL, `${command} must stay admin-only`); + } +}); + +test('commands that police themselves still reach their handler as a non-admin', async () => { + const run = createRouter(); + // These reply with their own role-specific message, so the dispatcher must not + // short-circuit them with the generic admin denial. + for (const command of ['rs goal', 'rs reason', 'rs verify list', 'rs deter list']) { + assert.doesNotMatch(await run(command, nonAdmin), ADMIN_DENIAL, `${command} enforces its own permission`); + } +}); + +test('system commands remain reachable by anyone', async () => { + const run = createRouter(); + assert.match(await run('rs status', nonAdmin), /\[status handler\]/); + assert.match(await run('rs', nonAdmin), /\[status handler\]/); + assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/); +}); + +test('the fun category appears in help', async () => { + const run = createRouter(); + const help = await run('rs help fun', nonAdmin); + assert.match(help, /\*\*Fun\*\*/); + for (const command of ['bonk', 'honk', 'disco', 'vibecheck', 'bonkboard']) { + assert.match(help, new RegExp(`rs ${command}`), `help should list ${command}`); + } +}); + +test('admin mode restricts access-mode feature commands but not the public fun ones', async () => { + const run = createRouter({ mode: MODES.ADMIN }); + assert.match(await run('rs lights on', nonAdmin), FEATURE_DENIAL); + assert.match(await run('rs disco', nonAdmin), FEATURE_DENIAL); + assert.doesNotMatch(await run('rs coin', nonAdmin), FEATURE_DENIAL); +}); + +test('lockdown suspends the whole fun category for anyone but a lockdown admin', async () => { + const run = createRouter({ mode: MODES.LOCKDOWN }); + for (const command of ['rs coin', 'rs bonk bob', 'rs honk', 'rs disco', 'rs vibecheck']) { + assert.match(await run(command, nonAdmin), LOCKDOWN_DENIAL, `${command} should be suspended in lockdown`); + } + // A plain admin is not enough during lockdown. + assert.match(await run('rs coin', admin), LOCKDOWN_DENIAL); + assert.doesNotMatch(await run('rs coin', lockdownAdmin), LOCKDOWN_DENIAL); +}); + +test('lockdown still suspends the pre-existing moderation-sensitive commands', async () => { + const run = createRouter({ mode: MODES.LOCKDOWN }); + for (const command of ['rs lock rover-1', 'rs mode open', 'rs lights on', 'rs goal', 'rs kick alice']) { + assert.match(await run(command, admin), LOCKDOWN_DENIAL, `${command} should stay lockdown-gated`); + } +}); + +test('status and help survive lockdown', async () => { + const run = createRouter({ mode: MODES.LOCKDOWN }); + assert.match(await run('rs status', nonAdmin), /\[status handler\]/); + assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/); +}); + +test('a disabled required feature is reported before any permission check', async () => { + const run = createRouter({ featureEnabled: false }); + assert.match(await run('rs disco', nonAdmin), /Home Assistant feature is not configured/); + assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is not configured/); +}); + +test('ordinary words that merely start with the prefix are not commands', async () => { + const run = createRouter(); + assert.equal(await run('rsvp', nonAdmin), ''); + assert.equal(await run('rspecial delivery', nonAdmin), ''); + assert.equal(await run('hello there', nonAdmin), ''); +}); + +test('an unknown command is not treated as public', async () => { + const run = createRouter(); + // Unknown actions carry no registry entry, so they must fall through to the + // same admin denial they did before the permission refactor. + assert.match(await run('rs notacommand', nonAdmin), ADMIN_DENIAL); + assert.match(await run('rs notacommand', admin), /Rover Bot Commands/); +}); + +test('bot actors are ignored entirely', async () => { + const run = createRouter(); + assert.equal(await run('rs coin', { ...nonAdmin, bot: true }), ''); +}); + +test('command matching is case insensitive', async () => { + const run = createRouter(); + assert.doesNotMatch(await run('RS COIN', nonAdmin), ADMIN_DENIAL); + assert.match(await run('Rs Status', nonAdmin), /\[status handler\]/); +}); diff --git a/server/src/services/operatorCommandService/registry.js b/server/src/services/operatorCommandService/registry.js index 094fc725..c2d53a25 100644 --- a/server/src/services/operatorCommandService/registry.js +++ b/server/src/services/operatorCommandService/registry.js @@ -5,6 +5,13 @@ const CATEGORIES = { system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] }, admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'gain'] }, features: { title: 'Features', names: ['lights', 'lift', 'neato'] }, + fun: { + title: 'Fun', + names: [ + 'bonk', 'hug', 'slap', 'bonkboard', '8ball', 'roll', 'coin', 'ship', 'rate', 'uwu', + 'wanted', 'pet', 'snitch', 'honk', 'boo', 'spin', 'disco', 'vibecheck', + ], + }, discord: { title: 'Discord', names: ['bridge'] }, }; @@ -61,6 +68,40 @@ function buildCommandRegistry(prefix, timeCommand) { lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift `], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' }, neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato `], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' }, bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here `, `${prefix} bridge mode `, `${prefix} bridge off`], access: 'Discord server manager' }, + /* + Fun commands are the first entries to use `permission: 'public'`. Before + they existed, every non-admin-reachable command was named in a hardcoded + allowlist in the dispatcher; declaring the permission here instead means a + new fun command does not need a dispatcher edit to be usable. + */ + bonk: { category: 'fun', summary: 'Bonk someone. Keeps a running tally.', usage: [`${prefix} bonk `], access: 'Public', permission: 'public' }, + hug: { category: 'fun', summary: 'Hug someone.', usage: [`${prefix} hug `], access: 'Public', permission: 'public' }, + slap: { category: 'fun', summary: 'Slap someone with a random object.', usage: [`${prefix} slap `], access: 'Public', permission: 'public' }, + bonkboard: { category: 'fun', summary: 'Show the bonk and hug leaderboards.', usage: [`${prefix} bonkboard`], access: 'Public', permission: 'public' }, + '8ball': { category: 'fun', summary: 'Ask the magic 8 ball. Same question always gets the same answer.', usage: [`${prefix} 8ball `], access: 'Public', permission: 'public' }, + roll: { category: 'fun', summary: 'Roll dice.', usage: [`${prefix} roll [NdN]`], access: 'Public', permission: 'public' }, + coin: { category: 'fun', summary: 'Flip a coin.', usage: [`${prefix} coin`], access: 'Public', permission: 'public' }, + ship: { category: 'fun', summary: 'Rate a pairing out of 100.', usage: [`${prefix} ship and `], access: 'Public', permission: 'public' }, + rate: { category: 'fun', summary: 'Rate anything out of 10.', usage: [`${prefix} rate `], access: 'Public', permission: 'public' }, + uwu: { category: 'fun', summary: 'Ruin some text.', usage: [`${prefix} uwu `], access: 'Public', permission: 'public' }, + wanted: { category: 'fun', summary: 'Issue a wanted poster.', usage: [`${prefix} wanted `], access: 'Public', permission: 'public' }, + pet: { category: 'fun', summary: 'Pet a rover. Each rover keeps its own count.', usage: [`${prefix} pet [rover]`], access: 'Public', permission: 'public' }, + snitch: { category: 'fun', summary: 'Report who is driving what.', usage: [`${prefix} snitch`], access: 'Public', permission: 'public' }, + // honk and spin move hardware, so their handlers additionally require that the + // caller actually holds control of the rover they name. + honk: { category: 'fun', summary: 'Sound a short horn toot on a rover you control.', usage: [`${prefix} honk [rover]`], access: 'Public; requires control of the rover', permission: 'public' }, + boo: { category: 'fun', summary: 'Speak a taunt through the rover someone is driving.', usage: [`${prefix} boo `], access: 'Public', permission: 'public' }, + spin: { category: 'fun', summary: 'Make a rover you control do a spin.', usage: [`${prefix} spin [rover]`], access: 'Public; requires control of the rover', permission: 'public' }, + vibecheck: { category: 'fun', summary: 'Judge a rover\'s vibes and report its battery.', usage: [`${prefix} vibecheck [rover]`], access: 'Public', permission: 'public' }, + disco: { + category: 'fun', + summary: 'Strobe the room lights briefly.', + usage: [`${prefix} disco`], + access: 'Public unless server access is restricted; obeys the room-light lock', + permission: 'access-mode', + requiredFeature: 'homeAssistant', + unavailableLabel: 'Home Assistant', + }, }; }