`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>
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>