diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index 1776282d..0862ba52 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -25,8 +25,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'); @@ -39,6 +41,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; } @@ -120,6 +130,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), @@ -185,6 +201,16 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) { muteUser, unmuteUser, sanitizeMentions, + funStatsService, + commandCooldowns, + /* + 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 fa2ae6ac..a2f94112 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -54,9 +54,12 @@ const { denyRequest: denyPrivateAccessRequest, } = require('../privateRoverAccessRequestService'); const { subscribe } = 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'); @@ -206,6 +209,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, @@ -251,6 +258,15 @@ const commandDependencies = { muteUser, unmuteUser, sanitizeMentions, + funStatsService, + commandCooldowns, + /* + 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/operatorCommandService/commands/funHelpers.js b/server/src/services/operatorCommandService/commands/funHelpers.js new file mode 100644 index 00000000..43613c07 Binary files /dev/null and b/server/src/services/operatorCommandService/commands/funHelpers.js differ 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/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/funText.js b/server/src/services/operatorCommandService/commands/funText.js new file mode 100644 index 00000000..bfbec41e --- /dev/null +++ b/server/src/services/operatorCommandService/commands/funText.js @@ -0,0 +1,316 @@ +// 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; + +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, 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 }); + } + + /* + 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 }) { + 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; + + 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.`, + 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/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/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 8a0b63d5..c2f08a0d 100644 --- a/server/src/services/operatorCommandService/index.js +++ b/server/src/services/operatorCommandService/index.js @@ -12,9 +12,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, @@ -54,6 +67,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(); @@ -99,6 +126,8 @@ function createCommandHandlers(deps) { // lockdown admin while the entire server is in lockdown. const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', '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 @@ -110,12 +139,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; } @@ -154,6 +188,7 @@ function createCommandHandlers(deps) { case 'deter': return handleDeterCommand(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/registry.js b/server/src/services/operatorCommandService/registry.js index 883dcd28..db0ab7c3 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'] }, 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'] }, }; @@ -49,6 +56,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', + }, }; }