mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -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
@@ -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