`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>
The committed bundle was produced by a local `vite build`, which runs without
the injected analytics snippet. That stripped the page-wide `window.roverAnalytics`
Umami adapter and both analytics.otter.land script tags out of
server/public/index.html, and repointed the bundle hash at a local build.
Revert server/public to its origin/main state so this branch is source-only.
server/public should be rebuilt on deploy, where the analytics snippet exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The identity resolver's fuzzy-miss error was labelled "Selector", which is
internal jargon — the operator running `rs gain grant <vip>` or `rs kick
<user>` typed a username, not a "selector". Relabel that one message to
"User" so the chat reply reads plainly.
The sibling "Selector matched multiple records." and "Selector required."
messages are intentionally left alone for now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a server-backed Volume card to page settings with horn, TTS, and
microphone sliders. Each slider is a 0-100% share of the ceiling the
server resolved for that user, and the card shows the resulting
multiplier plus whether the user is on the normal global limit or a
raised VIP limit. A slider whose ceiling is zero renders disabled rather
than pretending to do something.
The admin section gains sliders for the VIP boost hard caps beside the
existing global gains. Both editors now render from one GAIN_FIELDS list
through a shared GainSlider instead of six copied slider blocks.
Includes the rebuilt bundle so the served UI matches the source.
Co-Authored-By: Claude <noreply@anthropic.com>
Admins can now run 'rs gain list|grant <vip>|revoke <vip>' from web chat or
Discord. Grant matches only against the verified list and revoke only
against current holders, so a nickname shared with an unverified visitor
reports not-found rather than resolving to someone ineligible. The action
joins the moderation set so lockdown narrows it to lockdown admins.
Also extracts the ceiling math into audioLevelsService/gainMath.js and
covers both it and the command with node:test suites.
Co-Authored-By: Claude <noreply@anthropic.com>
Every user now gets a personal 0-1 volume for horn, TTS, and mic forward.
The value is stored as a fraction of the ceiling that applies to them, so
lowering the global admin gain quiets everyone immediately instead of
leaving stale absolute values behind.
Ceilings resolve in three layers: the global admin gain is the default
ceiling; the audioGainBoost flag raises it to an admin-editable hard cap
(default 0.5x horn, 0.8x TTS, 0.4x forward); Math.max keeps the flag from
ever lowering a ceiling if the global gain is set higher than a cap.
Preferences live in identity feature state rather than a cookie so they
follow the user and cannot be raised client-side. The rover exposes gain
as three ALSA masters, so the resolved gains pushed to a rover are those
of the socket currently holding audio control -- re-pushed on driver
join/leave and every turn rotation.
Co-Authored-By: Claude <noreply@anthropic.com>
Adds an audio_gain_boost_enabled status column (plus _at/_by audit
fields) to user_status, exposed as user.audioGainBoost and copied onto
sockets as socket.data.hasAudioGainBoost. The flag marks VIPs allowed to
raise their personal horn/TTS/mic gain ceiling past the global admin gain
settings.
Grant/revoke goes through verificationService so socket flags refresh and
an event is published. Resolution is restricted to verified users, so an
unverified visitor sharing a nickname can never be matched.
Co-Authored-By: Claude <noreply@anthropic.com>