mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-18 18:40:47 -04:00
feat(commands): add a fun command category with 18 public rs commands
Adds a `fun` category to the operator command registry, reachable identically from site chat and Discord: - text: bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted - counters: bonkboard, pet, snitch - hardware: honk, boo, spin, disco, vibecheck Supporting pieces: - `permission: 'public'` in the registry. The dispatcher previously decided non-admin access with a hardcoded chain of `action !== '...'` comparisons, so every new public command needed a dispatcher edit. That chain is replaced by a registry lookup plus SELF_GATED_ACTIONS, which names the commands that enforce their own permissions internally (goal/reason are read-public write-admin; verify/deter reject non-lockdown-admins themselves). Existing behavior for every pre-existing command is unchanged. - `cooldowns.js`, a per-actor per-command in-memory gate. Site chat's own rate limit is per-socket-per-message and does not bound a specific command, so without this one person could turn `rs honk` into a siren. Site chat rebuilds its router per message, so the gate is created at module scope there and injected. - `funStatsService`, a small JSON store for the persistent tallies. Counters are keyed by an actor key spanning transports (`user:<id>` / `discord:<id>`), and a Discord id has no row in `users`, so `user_feature_state` could not hold them without violating its foreign key. Safety notes: - `issueCommand` is the raw rover transport and performs none of the ownership, deterrence, or private-safety checks the socket `command` handler applies, so honk and spin re-check `canDrive` themselves and spin re-applies `applyPrivateDriveSafety`. Both are therefore site-chat only: a Discord message has no socket and can never satisfy those checks. - `boo` speaks a canned taunt rather than caller-supplied text, so it cannot become an unmoderated TTS channel aimed at whoever is nearest a rover. - `disco` obeys the existing room-light lock and the homeAssistant feature gate. - The whole fun category is suspended in lockdown mode. - Mute and deterrence already stop command-shaped chat before the router runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d000b8f4f8
commit
a2fbbc100e
Binary file not shown.
@@ -0,0 +1,285 @@
|
||||
// Operator Fun Rover Commands
|
||||
// Purpose: Implements the fun commands that actually make the fleet or the room do something (honk, boo, disco, spin, vibecheck).
|
||||
// Scope: Every handler here re-checks control and feature gating itself, because issueCommand bypasses the socket command guards.
|
||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||
const { describeWait } = require('../cooldowns');
|
||||
const {
|
||||
PLAIN_MENTIONS,
|
||||
actorLabel,
|
||||
buildActorKey,
|
||||
createRoverResolver,
|
||||
hashSeed,
|
||||
pickBySeed,
|
||||
resolveFunTarget,
|
||||
} = require('./funHelpers');
|
||||
|
||||
// Durations are deliberately short and are also bounded rover-side: roverd
|
||||
// enforces its own horn MaxDuration, so a lost stop command cannot leave a horn
|
||||
// sounding forever.
|
||||
const HONK_MS = 600;
|
||||
const HONK_FREQ_HZ = 440;
|
||||
const SPIN_MS = 1200;
|
||||
const SPIN_SPEED = 120;
|
||||
const DISCO_MS = 12 * 1000;
|
||||
const DISCO_TICK_MS = 750;
|
||||
|
||||
const HONK_ACTOR_COOLDOWN_MS = 20 * 1000;
|
||||
const HONK_ROVER_COOLDOWN_MS = 8 * 1000;
|
||||
const BOO_COOLDOWN_MS = 30 * 1000;
|
||||
const SPIN_COOLDOWN_MS = 25 * 1000;
|
||||
const DISCO_COOLDOWN_MS = 2 * 60 * 1000;
|
||||
const VIBECHECK_COOLDOWN_MS = 5 * 1000;
|
||||
|
||||
/*
|
||||
Taunts are a fixed list rather than caller-supplied text on purpose. `boo` puts
|
||||
audio out of a speaker in a room full of people, so letting it read arbitrary
|
||||
input would turn a joke command into an unmoderated TTS channel aimed at
|
||||
whoever is nearest the rover.
|
||||
*/
|
||||
const BOO_TAUNTS = [
|
||||
'Boo.', 'Your driving is being reviewed.', 'That was a choice.',
|
||||
'The wall was right there.', 'Someone in chat is laughing at you.',
|
||||
'I have seen better parking from the Neato.', 'Boo. Respectfully.',
|
||||
'This is a citizen\'s arrest.', 'Turn left. No, the other left.',
|
||||
];
|
||||
|
||||
const VIBE_VERDICTS = [
|
||||
'immaculate', 'acceptable', 'questionable', 'concerning', 'dire', 'unwell',
|
||||
];
|
||||
|
||||
function describeBattery(batteryState) {
|
||||
const display = Number(batteryState?.percentDisplay);
|
||||
if (Number.isFinite(display)) return `${Math.round(display)}%`;
|
||||
const percent = Number(batteryState?.percent);
|
||||
if (Number.isFinite(percent)) return `${Math.round(percent * 100)}%`;
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function createFunRoverCommands({
|
||||
io,
|
||||
rovers,
|
||||
roverManager,
|
||||
getNickname,
|
||||
getActiveDrivers,
|
||||
getActorSocket,
|
||||
issueCommand,
|
||||
homeAssistantService,
|
||||
isFeatureEnabled,
|
||||
sanitizeMentions,
|
||||
cooldowns,
|
||||
logger,
|
||||
config,
|
||||
}) {
|
||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
|
||||
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
|
||||
|
||||
function reply(message, content) {
|
||||
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
|
||||
}
|
||||
|
||||
function gate(message, action, windowMs) {
|
||||
const actorKey = buildActorKey(message);
|
||||
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
|
||||
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
|
||||
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
|
||||
return { actorKey, label: actorLabel(message) };
|
||||
}
|
||||
|
||||
/*
|
||||
issueCommand is the raw rover transport: it performs none of the ownership,
|
||||
deterrence, or private-safety checks that the socket `command` handler applies.
|
||||
Any fun command that moves hardware therefore has to prove control here, which
|
||||
also means these commands are inherently site-chat only — a Discord message has
|
||||
no socket and so can never satisfy canDrive.
|
||||
*/
|
||||
function requireDriveControl(action, selector) {
|
||||
const socket = getActorSocket?.() || null;
|
||||
if (!socket) {
|
||||
return { error: `\`${commandPrefix} ${action}\` only works from site chat, where you can actually be driving.` };
|
||||
}
|
||||
const rover = resolveTargetRover(selector, action);
|
||||
if (rover.error) return { error: rover.error };
|
||||
if (!roverManager?.canDrive?.(rover.id, socket)) {
|
||||
return { error: `You need control of ${rover.name} to do that.` };
|
||||
}
|
||||
return { rover, socket };
|
||||
}
|
||||
|
||||
function safeIssue(roverId, payload, context) {
|
||||
try {
|
||||
issueCommand(roverId, payload);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Deferred stop commands routinely land after a rover drops off. That is
|
||||
// expected, not an incident, so it is logged at debug volume and swallowed.
|
||||
logger?.warn?.('Fun command could not reach rover', { roverId, context, error: err.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHonk(message, tokens = []) {
|
||||
const control = requireDriveControl('honk', tokens.join(' '));
|
||||
if (control.error) return reply(message, control.error);
|
||||
const { rover } = control;
|
||||
|
||||
if (rover.record?.meta?.horn?.enabled === false) {
|
||||
return reply(message, `${rover.name} has no horn fitted.`);
|
||||
}
|
||||
|
||||
const gated = gate(message, 'honk', HONK_ACTOR_COOLDOWN_MS);
|
||||
if (gated.error) return reply(message, gated.error);
|
||||
|
||||
// A second, rover-scoped window stops a group of drivers taking turns to
|
||||
// honk the same rover continuously while each stays inside their own limit.
|
||||
const roverWait = cooldowns.consume(`honk:rover:${rover.id}`, HONK_ROVER_COOLDOWN_MS);
|
||||
if (roverWait > 0) {
|
||||
return reply(message, `${rover.name} was just honked. Give it ${describeWait(roverWait)}.`);
|
||||
}
|
||||
|
||||
if (!safeIssue(rover.id, { type: 'horn', horn: { action: 'start', waveform: 'sine', freqs: [HONK_FREQ_HZ] } }, 'honk:start')) {
|
||||
return reply(message, `${rover.name} is offline.`);
|
||||
}
|
||||
setTimeout(() => safeIssue(rover.id, { type: 'horn', horn: { action: 'stop' } }, 'honk:stop'), HONK_MS);
|
||||
|
||||
return reply(message, `📢 HONK. (${rover.name})`);
|
||||
}
|
||||
|
||||
async function handleSpin(message, tokens = []) {
|
||||
const control = requireDriveControl('spin', tokens.join(' '));
|
||||
if (control.error) return reply(message, control.error);
|
||||
const { rover, socket } = control;
|
||||
|
||||
const gated = gate(message, 'spin', SPIN_COOLDOWN_MS);
|
||||
if (gated.error) return reply(message, gated.error);
|
||||
|
||||
const maxWheelSpeed = Number(rover.record?.meta?.maxWheelSpeed);
|
||||
const speed = Math.max(1, Math.min(SPIN_SPEED, Number.isFinite(maxWheelSpeed) && maxWheelSpeed > 0 ? maxWheelSpeed : SPIN_SPEED));
|
||||
let driveDirect = { left: speed, right: -speed };
|
||||
/*
|
||||
Private rovers can carry a reduced speed ceiling that the socket path would
|
||||
normally apply. Applying it explicitly keeps a fun command from being the one
|
||||
way to exceed a limit an admin set for a specific rover.
|
||||
*/
|
||||
const safeDrive = roverManager?.applyPrivateDriveSafety?.(rover.id, socket, driveDirect);
|
||||
if (safeDrive) driveDirect = safeDrive;
|
||||
|
||||
if (!safeIssue(rover.id, { type: 'drive', driveDirect }, 'spin:start')) {
|
||||
return reply(message, `${rover.name} is offline.`);
|
||||
}
|
||||
setTimeout(() => safeIssue(rover.id, { type: 'drive', driveDirect: { left: 0, right: 0 } }, 'spin:stop'), SPIN_MS);
|
||||
|
||||
return reply(message, `🌀 ${rover.name} is doing a spin.`);
|
||||
}
|
||||
|
||||
async function handleBoo(message, tokens = []) {
|
||||
const selector = tokens.join(' ').trim();
|
||||
if (!selector) return reply(message, `Usage: \`${commandPrefix} boo <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 };
|
||||
@@ -0,0 +1,118 @@
|
||||
// Operator Fun Stats Commands
|
||||
// Purpose: Implements the fun commands that read or extend persistent counters (bonkboard, pet, snitch).
|
||||
// Scope: Reads the roster and the fun stats store; issues no rover commands.
|
||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||
const { describeWait } = require('../cooldowns');
|
||||
const { PLAIN_MENTIONS, actorLabel, buildActorKey, createRoverResolver } = require('./funHelpers');
|
||||
|
||||
const PET_COOLDOWN_MS = 10 * 1000;
|
||||
const READ_COOLDOWN_MS = 5 * 1000;
|
||||
const LEADERBOARD_SIZE = 10;
|
||||
|
||||
function formatLeaderboard(title, rows, counter) {
|
||||
const ranked = rows
|
||||
.filter((row) => Number(row[counter]) > 0)
|
||||
.sort((left, right) => Number(right[counter]) - Number(left[counter]))
|
||||
.slice(0, LEADERBOARD_SIZE);
|
||||
if (!ranked.length) return null;
|
||||
const lines = ranked.map((row, index) => `${index + 1}. ${row.label || 'unknown'} — ${row[counter]}`);
|
||||
return [`**${title}**`, ...lines].join('\n');
|
||||
}
|
||||
|
||||
function createFunStatsCommands({
|
||||
io,
|
||||
rovers,
|
||||
getNickname,
|
||||
getActiveDrivers,
|
||||
getActorSocket,
|
||||
roverManager,
|
||||
sanitizeMentions,
|
||||
funStatsService,
|
||||
cooldowns,
|
||||
config,
|
||||
}) {
|
||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
|
||||
|
||||
function reply(message, content) {
|
||||
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
|
||||
}
|
||||
|
||||
function gate(message, action, windowMs) {
|
||||
const actorKey = buildActorKey(message);
|
||||
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
|
||||
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
|
||||
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
|
||||
return { actorKey, label: actorLabel(message) };
|
||||
}
|
||||
|
||||
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
|
||||
|
||||
async function handleBonkboard(message) {
|
||||
const gated = gate(message, 'bonkboard', READ_COOLDOWN_MS);
|
||||
if (gated.error) return reply(message, gated.error);
|
||||
|
||||
const rows = funStatsService.listActorStats();
|
||||
const sections = [
|
||||
formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'),
|
||||
formatLeaderboard('Most bonks taken', rows, 'bonksTaken'),
|
||||
formatLeaderboard('Most hugs given', rows, 'hugsGiven'),
|
||||
].filter(Boolean);
|
||||
|
||||
if (!sections.length) {
|
||||
return reply(message, `Nobody has been bonked yet. Fix that with \`${commandPrefix} bonk <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 };
|
||||
@@ -0,0 +1,316 @@
|
||||
// Operator Fun Text Commands
|
||||
// Purpose: Implements the social, text-only `rs` commands (bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted).
|
||||
// Scope: Text and counters only; nothing here touches rover hardware.
|
||||
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||
const { describeWait } = require('../cooldowns');
|
||||
const {
|
||||
PLAIN_MENTIONS,
|
||||
actorLabel,
|
||||
buildActorKey,
|
||||
clampEcho,
|
||||
hashSeed,
|
||||
ordinal,
|
||||
pairSeed,
|
||||
percentFromSeed,
|
||||
pickBySeed,
|
||||
resolveFunTarget,
|
||||
} = require('./funHelpers');
|
||||
|
||||
const TEXT_COOLDOWN_MS = 4 * 1000;
|
||||
|
||||
const SLAP_ITEMS = [
|
||||
'a large trout', 'a rolled-up service manual', 'a dead AA battery', 'a docking station',
|
||||
'a suspiciously warm power brick', 'half a roll of duct tape', 'a decommissioned brush guard',
|
||||
'a bag of loose screws', 'an unlabelled USB cable', 'a soggy floor sensor',
|
||||
'the heaviest available wrench', 'a stack of unread pull requests',
|
||||
];
|
||||
|
||||
const EIGHT_BALL_ANSWERS = [
|
||||
'Yes.', 'No.', 'Absolutely.', 'Absolutely not.', 'Ask again once the battery is charged.',
|
||||
'Signs point to the docking station.', 'The overseer says no.', 'Almost certainly.',
|
||||
'Not while anyone is watching.', 'Outlook cloudy, sensors dirty.', 'Try it and find out.',
|
||||
'That is a maintenance window problem.', 'Only on a Tuesday.', 'The rover has already decided.',
|
||||
];
|
||||
|
||||
const HUG_FLAVOURS = [
|
||||
'gently', 'aggressively', 'with both brush guards', 'at full wheel speed',
|
||||
'for slightly too long', 'while beeping softly', 'without asking first',
|
||||
];
|
||||
|
||||
const WANTED_CRIMES = [
|
||||
'reckless docking', 'driving with the brush on indoors', 'excessive honking',
|
||||
'unauthorised carpet donuts', 'battery hoarding', 'ignoring the global objective',
|
||||
'parking in the doorway', 'nine consecutive turn skips', 'talking to the Neato',
|
||||
'strobing the room lights for fun', 'stealing another rover\'s charger',
|
||||
];
|
||||
|
||||
const RATE_SUFFIXES = [
|
||||
'No further questions.', 'I stand by this.', 'Do not appeal.', 'Take it or leave it.',
|
||||
'The sensors agree.', 'This rating is final.',
|
||||
];
|
||||
|
||||
/*
|
||||
A dice roll is one of the few places a fun command should be genuinely random:
|
||||
the whole point is that nobody can predict it. Everything that passes judgement
|
||||
on a thing (`ship`, `rate`, `8ball`, `wanted`) is seeded from the input instead,
|
||||
so re-running it cannot reroll a verdict somebody disliked.
|
||||
*/
|
||||
function rollDice(count, sides) {
|
||||
let total = 0;
|
||||
const rolls = [];
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const value = 1 + Math.floor(Math.random() * sides);
|
||||
rolls.push(value);
|
||||
total += value;
|
||||
}
|
||||
return { rolls, total };
|
||||
}
|
||||
|
||||
function parseDiceSpec(spec) {
|
||||
const text = String(spec || '').trim().toLowerCase() || '1d6';
|
||||
const match = /^(\d*)d(\d+)$/.exec(text);
|
||||
if (!match) {
|
||||
// A bare number is read as a single die with that many sides so `rs roll 20`
|
||||
// does the obvious thing instead of erroring.
|
||||
const bare = /^(\d+)$/.exec(text);
|
||||
if (!bare) return { error: 'Roll format is `NdN`, for example `2d6`.' };
|
||||
const sides = Number(bare[1]);
|
||||
if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' };
|
||||
return { count: 1, sides };
|
||||
}
|
||||
const count = match[1] === '' ? 1 : Number(match[1]);
|
||||
const sides = Number(match[2]);
|
||||
if (count < 1 || count > 20) return { error: 'Roll between 1 and 20 dice.' };
|
||||
if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' };
|
||||
return { count, sides };
|
||||
}
|
||||
|
||||
function uwuify(text) {
|
||||
return String(text || '')
|
||||
.replace(/[rl]/g, 'w')
|
||||
.replace(/[RL]/g, 'W')
|
||||
.replace(/n([aeiou])/g, 'ny$1')
|
||||
.replace(/N([aeiou])/g, 'Ny$1')
|
||||
.replace(/ove/g, 'uv')
|
||||
.replace(/!+/g, ' !!');
|
||||
}
|
||||
|
||||
function createFunTextCommands({ io, getNickname, sanitizeMentions, funStatsService, cooldowns, config }) {
|
||||
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
|
||||
|
||||
function reply(message, content) {
|
||||
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
|
||||
}
|
||||
|
||||
/*
|
||||
Every fun command runs through one gate so the cooldown, the actor identity,
|
||||
and the "who am I talking about" resolution cannot drift apart between
|
||||
commands. `needsTarget` commands reply with their own usage line when the
|
||||
selector is missing rather than silently acting on nothing.
|
||||
*/
|
||||
function gate(message, action, { windowMs = TEXT_COOLDOWN_MS } = {}) {
|
||||
const actorKey = buildActorKey(message);
|
||||
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
|
||||
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
|
||||
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
|
||||
return { actorKey, label: actorLabel(message) };
|
||||
}
|
||||
|
||||
function target(selector) {
|
||||
return resolveFunTarget({ io, getNickname, selector });
|
||||
}
|
||||
|
||||
/*
|
||||
Shared shape for the three "do a thing to someone" commands. Only the verb,
|
||||
the counter names, and the flavour text differ, and keeping them in one place
|
||||
means a fix to self-targeting or tally credit applies to all of them.
|
||||
*/
|
||||
function createInteraction({ action, counterGiven, counterTaken, selfReply, render }) {
|
||||
return async function handleInteraction(message, tokens = []) {
|
||||
const selector = tokens.join(' ').trim();
|
||||
if (!selector) {
|
||||
return reply(message, `Usage: \`${commandPrefix} ${action} <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;
|
||||
|
||||
return reply(message, render({
|
||||
actor: gated.label,
|
||||
actorKey: gated.actorKey,
|
||||
targetLabel: resolved.label,
|
||||
targetOnline: resolved.online,
|
||||
takenCount: targetStats ? targetStats[counterTaken] : null,
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
const handleBonk = createInteraction({
|
||||
action: 'bonk',
|
||||
counterGiven: 'bonksGiven',
|
||||
counterTaken: 'bonksTaken',
|
||||
selfReply: (label) => `${label} bonked themselves. That is between you and the rover.`,
|
||||
render: ({ targetLabel, takenCount }) => {
|
||||
const tally = takenCount ? ` That is their ${ordinal(takenCount)} bonk.` : '';
|
||||
return `🔨 Bonked ${targetLabel}.${tally}`;
|
||||
},
|
||||
});
|
||||
|
||||
const handleHug = createInteraction({
|
||||
action: 'hug',
|
||||
counterGiven: 'hugsGiven',
|
||||
counterTaken: 'hugsTaken',
|
||||
selfReply: (label) => `${label} hugged themselves. Genuinely fine. No notes.`,
|
||||
render: ({ actor, targetLabel, takenCount }) => {
|
||||
const flavour = pickBySeed(HUG_FLAVOURS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
|
||||
const tally = takenCount ? ` (${takenCount} total)` : '';
|
||||
return `🫂 ${actor} hugs ${targetLabel} ${flavour}.${tally}`;
|
||||
},
|
||||
});
|
||||
|
||||
const handleSlap = createInteraction({
|
||||
action: 'slap',
|
||||
counterGiven: 'slapsGiven',
|
||||
counterTaken: 'slapsTaken',
|
||||
selfReply: (label) => `${label} slapped themselves with a large trout. Bold.`,
|
||||
render: ({ actor, targetLabel, takenCount }) => {
|
||||
// Seeding on the running count means the weapon changes every time without
|
||||
// being unpredictable for the same repeat number.
|
||||
const item = pickBySeed(SLAP_ITEMS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
|
||||
return `🐟 ${actor} slaps ${targetLabel} around a bit with ${item}.`;
|
||||
},
|
||||
});
|
||||
|
||||
async function handleEightBall(message, tokens = []) {
|
||||
const question = clampEcho(tokens.join(' '));
|
||||
if (!question) return reply(message, `Usage: \`${commandPrefix} 8ball <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,
|
||||
};
|
||||
Reference in New Issue
Block a user