`rs gain grant Saul` failed with "Selector matched multiple records. Suggestions:
Saul, cu_a28...33ab5c, Saul, cu_5a5...b6add3." Nicknames are not unique — one
person re-verifying from a new browser produces a second verified record with the
same name — so an exact nickname match can legitimately return several records,
and the shared resolver refuses on ambiguity.
That refusal is correct for deter, kick, and verify, where acting on the wrong of
two plausible targets is a moderation mistake. It is wrong for gain: granting a
volume ceiling to the wrong account belonging to the same person is recoverable.
So the disambiguation is local to this command and resolvers.js is untouched.
Order of preference on an exact match with several hits:
1. The account that is currently online, since that is who the admin is reacting
to. Sockets are deduped to user ids first, so extra tabs do not matter.
2. Otherwise the first stored record.
Either way the reply says which happened and how many accounts shared the name.
A selector with no exact match still goes through the shared fuzzy resolver, so
typo tolerance and the existing error text are unchanged.
Also adds `rs gain help`, which lists the subcommands and explains the selector
and the online-wins rule. The previous unknown-subcommand fallback listed the
subcommands inline; it now shares the same help text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`rs bonk` now publishes a `fun.bonked` event carrying the rover the target is
currently driving, and a new audioForwardService listener plays a sound file on
it. Wired as an event rather than a direct call so the command layer stays
unaware of ffmpeg, matching how the charging-complete cue is already done.
The sound file goes at `server/assets/bonk.wav` and is NOT committed here. Note
that `server/assets` is the correct home rather than `server/public`: the webui
builds to `../server/public` with `emptyOutDir: true`, so anything stored there
is deleted by the next build.
Details:
- The audio is rate limited per rover on a 20s window, separate from the 4s text
cooldown. Playback interrupts whatever that rover is forwarding, including a
live microphone, so a group of people cannot chain it against one driver.
- No sound plays if the target is not currently driving, is not a real user, or
is the caller themselves. The text bonk and the tally still work in all cases.
- A missing sound file logs once and skips, so the command works on a server that
never installs one. A playback failure is caught and logged rather than
surfacing as a failed chat command.
- Discord bonks play the sound too; only commands needing the caller's own socket
are unavailable from there.
Server suite: 138 passing, 0 failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pair.join('\0')` was written where `pair.join(' ')` was meant. The NUL made git
classify funHelpers.js as a binary file, so it showed as `Bin 0 -> 6397 bytes`
instead of a reviewable diff. The seed stayed deterministic either way, so no
behavior changes and the tests were already passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
89 new node:test cases. The dispatcher suite is the important one: it pins the
behavior the registry-driven permission gate replaced a hardcoded action list
with, asserting that admin-only commands are still admin-only, that the
self-policing commands (goal/reason/verify/deter) still reach their own handlers
as a non-admin, that unknown actions are still not public, and that `rsvp` is
still not a command.
Also covered:
- cooldown boundaries, including that a refused call does not extend the window
- actor identity keying across transports, and that extra browser tabs do not
make a target ambiguous
- honk/spin refusing without drive control and being unreachable from Discord
- spin honouring applyPrivateDriveSafety instead of bypassing it
- boo speaking only canned text, never anything the caller typed
- disco obeying the room-light lock and restoring the lights when it ends
- mention sanitizing on replies and on stored nicknames rendered by bonkboard
- the stats store degrading to empty on a corrupt or wrong-shaped file
Full server suite: 126 passing, 0 failing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>