mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
gruh i hate fun!!
This commit is contained in:
@@ -21,6 +21,7 @@ server/data/admin-reason.json
|
||||
server/data/buttonbox-state.json
|
||||
server/data/barcode-tts-cache/
|
||||
server/data/rover-odometers.json
|
||||
server/data/mediamtx.yml
|
||||
webui/package-lock.json
|
||||
!server/data/
|
||||
!server/data/barcode-registry.json
|
||||
|
||||
@@ -300,8 +300,6 @@ Environment=SERVER_CONFIG=$CONFIG_PATH
|
||||
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
|
||||
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
|
||||
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
|
||||
RuntimeDirectory=multirover
|
||||
RuntimeDirectoryMode=0750
|
||||
ExecStart=$NODE_BIN $SERVER_DIR/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=2
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -14,7 +14,6 @@ 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();
|
||||
@@ -152,11 +151,6 @@ registerChargeCompleteSound({
|
||||
playServerAudioFile,
|
||||
});
|
||||
|
||||
registerBonkSound({
|
||||
logger,
|
||||
playServerAudioFile,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
audioForwardEvents,
|
||||
|
||||
@@ -28,10 +28,8 @@ 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');
|
||||
@@ -44,14 +42,6 @@ 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;
|
||||
}
|
||||
@@ -133,12 +123,6 @@ 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),
|
||||
@@ -207,19 +191,6 @@ 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),
|
||||
|
||||
@@ -56,13 +56,10 @@ const {
|
||||
approveRequest: approvePrivateAccessRequest,
|
||||
denyRequest: denyPrivateAccessRequest,
|
||||
} = require('../privateRoverAccessRequestService');
|
||||
const { subscribe, publishEvent } = require('../eventBus');
|
||||
const funStatsService = require('../funStatsService');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const { subscribe } = require('../eventBus');
|
||||
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');
|
||||
@@ -212,10 +209,6 @@ 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,
|
||||
@@ -264,18 +257,6 @@ 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,
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
// 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:<id>` for site chat, `discord:<id>` 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,
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
// 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 });
|
||||
});
|
||||
@@ -5,6 +5,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const yaml = require('js-yaml');
|
||||
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { buildMediaMtxConfig } = require('./config');
|
||||
|
||||
function createMediaMtxSupervisor(deps) {
|
||||
@@ -13,7 +14,7 @@ function createMediaMtxSupervisor(deps) {
|
||||
serverPort,
|
||||
logger,
|
||||
mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx',
|
||||
runtimeDir = process.env.MULTIROVER_RUNTIME_DIR || '/run/multirover',
|
||||
configPath = resolveDataPath('mediamtx.yml'),
|
||||
snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
|
||||
spawnProcess = spawn,
|
||||
} = deps;
|
||||
@@ -39,10 +40,14 @@ function createMediaMtxSupervisor(deps) {
|
||||
function start() {
|
||||
if (child) return child;
|
||||
|
||||
const runtimeConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
|
||||
const configPath = path.join(runtimeDir, 'mediamtx.yml');
|
||||
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o750 });
|
||||
fs.writeFileSync(configPath, yaml.dump(runtimeConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
|
||||
const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
|
||||
/*
|
||||
Generated MediaMTX state belongs beside the server's other owned data. Using the shared
|
||||
data-path helper honors SERVER_DATA_DIR as well as the normal server/data directory and
|
||||
avoids introducing a systemd-created /run directory with separate permission rules.
|
||||
*/
|
||||
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
||||
fs.writeFileSync(configPath, yaml.dump(generatedConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
|
||||
|
||||
logger.info(`Starting MediaMTX with generated config ${configPath}`);
|
||||
child = spawnProcess(mediaMtxBin, [configPath], {
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
// 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} <rover>\`` };
|
||||
|
||||
// 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,
|
||||
};
|
||||
@@ -1,162 +0,0 @@
|
||||
// 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/);
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
// 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 <user>\``);
|
||||
|
||||
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 <user>\``);
|
||||
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 };
|
||||
@@ -1,297 +0,0 @@
|
||||
// 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');
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
// 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 <user>\`.`);
|
||||
}
|
||||
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 };
|
||||
@@ -1,166 +0,0 @@
|
||||
// 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/);
|
||||
});
|
||||
@@ -1,361 +0,0 @@
|
||||
// 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} <user>\``);
|
||||
}
|
||||
|
||||
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} <user>\``);
|
||||
|
||||
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 <question>\``);
|
||||
|
||||
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 <a> and <b>\``);
|
||||
}
|
||||
|
||||
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 <thing>\``);
|
||||
|
||||
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 <text>\``);
|
||||
|
||||
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 <user>\``);
|
||||
|
||||
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 <user>\``);
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -1,304 +0,0 @@
|
||||
// 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 <user>`/);
|
||||
|
||||
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/);
|
||||
});
|
||||
@@ -98,10 +98,10 @@ function createGainCommand({
|
||||
'**Audio gain boost**',
|
||||
'Raises a user\'s volume ceiling past the global gains, still bounded by the hard caps.',
|
||||
'',
|
||||
`\`${commandPrefix} gain list\` — show everyone who holds the boost.`,
|
||||
`\`${commandPrefix} gain grant <vip>\` — give the boost to a verified user.`,
|
||||
`\`${commandPrefix} gain revoke <vip>\` — take the boost away.`,
|
||||
`\`${commandPrefix} gain help\` — show this.`,
|
||||
`- \`${commandPrefix} gain list\`: show everyone who holds the boost.`,
|
||||
`- \`${commandPrefix} gain grant <vip>\`: give the boost to a verified user.`,
|
||||
`- \`${commandPrefix} gain revoke <vip>\`: take the boost away.`,
|
||||
`- \`${commandPrefix} gain help\`: show this.`,
|
||||
'',
|
||||
'A user can be named by nickname, userId, or cookieUserId. Only verified (VIP)',
|
||||
'users can be granted the boost. If several accounts share a nickname, the one',
|
||||
|
||||
@@ -218,6 +218,9 @@ test('help lists every subcommand', async () => {
|
||||
for (const fragment of ['rs gain list', 'rs gain grant <vip>', 'rs gain revoke <vip>', 'rs gain help']) {
|
||||
assert.ok(replies[0].content.includes(fragment), `help should mention ${fragment}`);
|
||||
}
|
||||
// Subcommand help follows the same copy-friendly punctuation rule as the
|
||||
// shared `rs help` renderer instead of quietly reintroducing em dashes.
|
||||
assert.doesNotMatch(replies[0].content, /—/);
|
||||
});
|
||||
|
||||
test('an unknown subcommand falls back to the same help text', async () => {
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
// 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 };
|
||||
@@ -1,53 +0,0 @@
|
||||
// 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');
|
||||
});
|
||||
@@ -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', 'fun', ...(includeDiscord ? ['discord'] : [])];
|
||||
: ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])];
|
||||
|
||||
const output = ['**Rover Bot Commands**'];
|
||||
for (const categoryName of categoryNames) {
|
||||
@@ -36,7 +36,9 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = ''
|
||||
const entry = entries[name];
|
||||
if (!entry?.usage?.length) continue;
|
||||
const availability = entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature) ? ' *(unavailable)*' : '';
|
||||
output.push(`\`${entry.usage[0]}\` — ${entry.summary}${availability}`);
|
||||
// A colon stays readable in both Discord and site chat while avoiding the
|
||||
// typographic punctuation that made command help awkward to copy or edit.
|
||||
output.push(`- \`${entry.usage[0]}\`: ${entry.summary}${availability}`);
|
||||
}
|
||||
}
|
||||
output.push('', `Use \`${prefix} help <command|category>\` for details.`);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Operator Command Help Tests
|
||||
// Purpose: Protects the shared chat-help layout and Discord's hard message-size boundary.
|
||||
// Scope: Tests rendered help text only; command execution and permissions remain dispatcher concerns.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { formatHelp } = require('./help');
|
||||
|
||||
// Discord rejects a normal message above 2,000 characters instead of silently
|
||||
// splitting it. Keeping this assertion beside the help renderer prevents a new
|
||||
// command description from making `rs help` fail only on the Discord transport.
|
||||
const DISCORD_MESSAGE_LIMIT = 2000;
|
||||
|
||||
test('complete Discord help fits in one message and uses simple punctuation', () => {
|
||||
const help = formatHelp({ includeDiscord: true, isFeatureEnabled: () => true });
|
||||
|
||||
assert.ok(help.length <= DISCORD_MESSAGE_LIMIT, `help is ${help.length} characters`);
|
||||
assert.doesNotMatch(help, /—/);
|
||||
assert.match(help, /- `rs status \[rover\]`: Show rover status/);
|
||||
assert.match(help, /\*\*Discord\*\*/);
|
||||
});
|
||||
|
||||
test('removed fun commands and their category are absent from help', () => {
|
||||
const help = formatHelp({ includeDiscord: true, isFeatureEnabled: () => true });
|
||||
|
||||
assert.doesNotMatch(help, /\*\*Fun\*\*/);
|
||||
for (const command of ['bonk', 'hug', 'slap', 'bonkboard', '8ball', 'roll', 'coin', 'ship', 'rate', 'uwu', 'wanted', 'pet', 'snitch', 'honk', 'boo', 'spin', 'disco', 'vibecheck']) {
|
||||
assert.doesNotMatch(help, new RegExp(`rs ${command}(?:\\s|\\x60)`), `${command} should not be advertised`);
|
||||
}
|
||||
});
|
||||
|
||||
test('detailed help keeps usage readable without em dashes', () => {
|
||||
const help = formatHelp({ topic: 'deter', includeDiscord: true, isFeatureEnabled: () => true });
|
||||
|
||||
assert.match(help, /\*\*deter\*\*/);
|
||||
assert.match(help, /Usage:\n- `rs deter list`/);
|
||||
assert.doesNotMatch(help, /—/);
|
||||
});
|
||||
@@ -13,10 +13,6 @@ 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');
|
||||
|
||||
@@ -69,20 +65,6 @@ 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();
|
||||
@@ -128,8 +110,6 @@ 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
|
||||
@@ -141,17 +121,12 @@ function createCommandHandlers(deps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isAccessModeCommand && !isPublicCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) {
|
||||
if (!isAccessModeCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) {
|
||||
await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
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) {
|
||||
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
|
||||
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
|
||||
return;
|
||||
}
|
||||
@@ -192,7 +167,6 @@ 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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Operator Command Dispatcher Tests
|
||||
// Purpose: Pins the permission, mode, and prefix policy that the registry-driven gate replaced a hardcoded action list with.
|
||||
// Purpose: Pins shared command permissions, access modes, and prefix parsing.
|
||||
// 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');
|
||||
@@ -10,8 +10,6 @@ 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',
|
||||
@@ -22,28 +20,15 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
|
||||
|
||||
const { handleCommand } = createCommandHandlers({
|
||||
logger: { warn: () => {}, info: () => {} },
|
||||
io: { sockets: { sockets: new Map([[ALICE.id, ALICE]]) } },
|
||||
io: { sockets: { sockets: new Map() } },
|
||||
rovers,
|
||||
roverManager: {
|
||||
canDrive: () => true,
|
||||
getPrimaryRoverForSocket: () => 'rover-1',
|
||||
applyPrivateDriveSafety: () => null,
|
||||
},
|
||||
roverManager: {},
|
||||
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,
|
||||
@@ -75,16 +60,6 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
|
||||
|
||||
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();
|
||||
@@ -109,30 +84,9 @@ test('system commands remain reachable by anyone', async () => {
|
||||
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 () => {
|
||||
test('admin mode restricts access-mode feature commands', 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 () => {
|
||||
@@ -150,7 +104,6 @@ test('status and help survive lockdown', async () => {
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -171,11 +124,10 @@ test('an unknown command is not treated as public', async () => {
|
||||
|
||||
test('bot actors are ignored entirely', async () => {
|
||||
const run = createRouter();
|
||||
assert.equal(await run('rs coin', { ...nonAdmin, bot: true }), '');
|
||||
assert.equal(await run('rs status', { ...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\]/);
|
||||
});
|
||||
|
||||
@@ -5,13 +5,6 @@ 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'] },
|
||||
};
|
||||
|
||||
@@ -68,40 +61,6 @@ function buildCommandRegistry(prefix, timeCommand) {
|
||||
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], 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 <status|start|home|locate|clear-errors>`], 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 <global|private>`, `${prefix} bridge mode <global|private>`, `${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 <user>`], access: 'Public', permission: 'public' },
|
||||
hug: { category: 'fun', summary: 'Hug someone.', usage: [`${prefix} hug <user>`], access: 'Public', permission: 'public' },
|
||||
slap: { category: 'fun', summary: 'Slap someone with a random object.', usage: [`${prefix} slap <user>`], 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 <question>`], 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 <a> and <b>`], access: 'Public', permission: 'public' },
|
||||
rate: { category: 'fun', summary: 'Rate anything out of 10.', usage: [`${prefix} rate <thing>`], access: 'Public', permission: 'public' },
|
||||
uwu: { category: 'fun', summary: 'Ruin some text.', usage: [`${prefix} uwu <text>`], access: 'Public', permission: 'public' },
|
||||
wanted: { category: 'fun', summary: 'Issue a wanted poster.', usage: [`${prefix} wanted <user>`], 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 <user>`], 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',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user