feat(commands): play a bonk sound on the bonked user's rover

`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>
This commit is contained in:
Saul5662
2026-07-29 05:05:36 +01:00
co-authored by Claude Opus 5
parent eb0db3508f
commit dc8267073b
7 changed files with 271 additions and 5 deletions
@@ -0,0 +1,55 @@
// audio Forward Service bonk sound
// Purpose: Plays the built-in bonk sound effect on the rover a bonked user is driving.
// Scope: Keeps the fun commands and the audio pipeline decoupled by listening to the server event bus only.
const path = require('path');
const fs = require('fs');
const { subscribe } = require('../eventBus');
/*
Lives in server/assets rather than server/public because the webui build writes
to server/public with emptyOutDir enabled, which deletes anything else in there.
server/assets is a plain checked-in asset directory that no build step touches.
*/
const BONK_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'assets', 'bonk.wav');
function registerBonkSound(deps) {
const {
logger,
playServerAudioFile,
soundPath = BONK_SOUND_PATH,
} = deps;
subscribe('fun.bonked', (event = {}) => {
const roverId = String(event?.payload?.roverId || '').trim();
if (!roverId) return;
/*
The sound is optional. An operator who has not dropped a bonk.wav into
server/assets still gets a fully working `rs bonk` command, so a missing
file is reported once at debug volume rather than thrown at the caller.
*/
if (!fs.existsSync(soundPath)) {
logger.info('Bonk sound file is not installed; skipping playback', { soundPath });
return;
}
try {
playServerAudioFile(roverId, soundPath, { source: 'bonk' });
logger.info('Played bonk sound', { roverId, soundPath });
} catch (err) {
// Playback interrupts mic forwarding and spawns ffmpeg, so an offline rover
// or a missing encoder must not turn into a failed chat command. The bonk
// itself already happened; the sound is layered on top of it.
logger.warn('Failed to play bonk sound', {
roverId,
soundPath,
error: err?.message || String(err),
});
}
});
}
module.exports = {
registerBonkSound,
BONK_SOUND_PATH,
};
@@ -0,0 +1,84 @@
// audio Forward Service bonk sound tests
// Purpose: Verifies the bonk cue plays for a real event and stays contained when the file or rover is missing.
// Scope: Subscribes through the real event bus with a playback double; no ffmpeg runs.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { publishEvent } = require('../eventBus');
const { registerBonkSound, BONK_SOUND_PATH } = require('./bonkSound');
const soundDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bonk-sound-test-'));
const presentSound = path.join(soundDir, 'bonk.wav');
fs.writeFileSync(presentSound, 'not really audio, only the path is read here');
const missingSound = path.join(soundDir, 'absent.wav');
function harness({ soundPath = presentSound, playImpl = null } = {}) {
const played = [];
const warnings = [];
registerBonkSound({
logger: {
info: () => {},
warn: (message, meta) => warnings.push({ message, meta }),
},
playServerAudioFile: (roverId, filePath, options) => {
played.push({ roverId, filePath, options });
if (playImpl) playImpl();
},
soundPath,
});
return { played, warnings };
}
// Each registerBonkSound call adds another subscriber to the shared bus, so every
// test publishes a distinct rover id and asserts only on its own rover.
function bonk(roverId) {
publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId, targetLabel: 'bob' } });
}
test('a bonk event plays the sound on the named rover', () => {
const { played } = harness();
bonk('rover-play');
const mine = played.filter((entry) => entry.roverId === 'rover-play');
assert.equal(mine.length, 1);
assert.equal(mine[0].filePath, presentSound);
assert.equal(mine[0].options.source, 'bonk');
});
test('an event with no rover id is ignored', () => {
const { played } = harness();
publishEvent({ source: 'test', type: 'fun.bonked', payload: {} });
publishEvent({ source: 'test', type: 'fun.bonked', payload: { roverId: ' ' } });
assert.equal(played.length, 0);
});
test('a missing sound file skips playback instead of throwing', () => {
const { played, warnings } = harness({ soundPath: missingSound });
assert.doesNotThrow(() => bonk('rover-missing'));
assert.equal(played.filter((entry) => entry.roverId === 'rover-missing').length, 0);
assert.equal(warnings.length, 0, 'a not-installed sound is informational, not a warning');
});
test('a playback failure is contained and logged rather than thrown at the caller', () => {
const { warnings } = harness({
playImpl: () => {
throw new Error('Rover offline');
},
});
assert.doesNotThrow(() => bonk('rover-offline'));
assert.ok(warnings.some((entry) => entry.meta?.error === 'Rover offline'));
});
test('the default sound path lives in server/assets, which the webui build does not wipe', () => {
// webui/vite.config.js builds to ../server/public with emptyOutDir enabled, so a
// sound stored there would be deleted by the next build.
assert.match(BONK_SOUND_PATH, /server\/assets\/bonk\.wav$/);
assert.doesNotMatch(BONK_SOUND_PATH, /server\/public/);
});
test.after(() => {
fs.rmSync(soundDir, { recursive: true, force: true });
});
@@ -14,6 +14,7 @@ const { createAudioForwardPolicy } = require('./policy');
const { createAudioForwardWorkerEngine } = require('./workerEngine');
const { registerAudioForwardHooks } = require('./hooks');
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
const { registerBonkSound } = require('./bonkSound');
const audioForwardEvents = new EventEmitter();
const config = loadConfig();
@@ -151,6 +152,11 @@ registerChargeCompleteSound({
playServerAudioFile,
});
registerBonkSound({
logger,
playServerAudioFile,
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
@@ -203,6 +203,9 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
sanitizeMentions,
funStatsService,
commandCooldowns,
// Lets `rs bonk` announce itself so audioForwardService can play the bonk
// sound on the rover the target is driving.
publishEvent,
/*
Fun commands that move hardware need the sending socket so they can prove
the caller holds control. issueCommand is required lazily for the same
@@ -53,7 +53,7 @@ const {
approveRequest: approvePrivateAccessRequest,
denyRequest: denyPrivateAccessRequest,
} = require('../privateRoverAccessRequestService');
const { subscribe } = require('../eventBus');
const { subscribe, publishEvent } = require('../eventBus');
const funStatsService = require('../funStatsService');
const { issueCommand } = require('../commandService');
const { createPresenceManager } = require('./presence');
@@ -260,6 +260,9 @@ const commandDependencies = {
sanitizeMentions,
funStatsService,
commandCooldowns,
// A Discord bonk still plays the sound on the rover the target is driving; only
// the commands that need the caller's own socket are unavailable from here.
publishEvent,
/*
Discord has no socket behind a message, so the hardware-backed fun commands
cannot prove drive control and decline with an explanation instead. The text,
@@ -18,6 +18,14 @@ const {
const TEXT_COOLDOWN_MS = 4 * 1000;
/*
The bonk sound gets its own, much longer rover-scoped window. Playing it
interrupts whatever that rover is forwarding — including a live microphone — so
the audio must not be spammable even though the text bonk stays snappy, and a
group of people bonking one driver cannot chain it either.
*/
const BONK_SOUND_ROVER_COOLDOWN_MS = 20 * 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',
@@ -95,10 +103,42 @@ function uwuify(text) {
.replace(/!+/g, ' !!');
}
function createFunTextCommands({ io, getNickname, sanitizeMentions, funStatsService, cooldowns, config }) {
function createFunTextCommands({
io,
getNickname,
getActiveDrivers,
publishEvent,
sanitizeMentions,
funStatsService,
cooldowns,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
/*
Announces the bonk so audioForwardService can play the sound on the rover the
target is driving. Published as an event rather than calling the audio pipeline
directly, matching how the charging-complete cue is wired: the command layer
stays unaware of ffmpeg, and a server without the sound installed simply has
nothing listening that can do anything.
*/
function announceBonk(targetSocket, targetLabel, actorLabelText) {
if (!targetSocket || typeof publishEvent !== 'function') return;
const drivers = getActiveDrivers?.() || {};
const roverId = Object.keys(drivers).find((id) => drivers[id] === targetSocket.id) || null;
if (!roverId) return;
if (cooldowns.consume(`bonk:sound:${roverId}`, BONK_SOUND_ROVER_COOLDOWN_MS) > 0) return;
publishEvent({
source: 'funCommands',
type: 'fun.bonked',
payload: { roverId, targetLabel, actor: actorLabelText },
});
}
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
@@ -126,7 +166,7 @@ function createFunTextCommands({ io, getNickname, sanitizeMentions, funStatsServ
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 }) {
function createInteraction({ action, counterGiven, counterTaken, selfReply, render, onApplied }) {
return async function handleInteraction(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) {
@@ -148,6 +188,10 @@ function createFunTextCommands({ io, getNickname, sanitizeMentions, funStatsServ
? funStatsService.bumpActorStats(resolved.actorKey, { label: resolved.label, [counterTaken]: 1 })
: null;
// Side effects run after the tallies so a failure in an optional extra (the
// bonk sound) cannot cost the user their recorded bonk.
onApplied?.({ actor: gated.label, resolved });
return reply(message, render({
actor: gated.label,
actorKey: gated.actorKey,
@@ -163,6 +207,7 @@ function createFunTextCommands({ io, getNickname, sanitizeMentions, funStatsServ
counterGiven: 'bonksGiven',
counterTaken: 'bonksTaken',
selfReply: (label) => `${label} bonked themselves. That is between you and the rover.`,
onApplied: ({ actor, resolved }) => announceBonk(resolved.socket, resolved.label, actor),
render: ({ targetLabel, takenCount }) => {
const tally = takenCount ? ` That is their ${ordinal(takenCount)} bonk.` : '';
return `🔨 Bonked ${targetLabel}.${tally}`;
@@ -29,11 +29,14 @@ function createStatsDouble() {
};
}
function createHarness({ sockets = [] } = {}) {
function createHarness({ sockets = [], activeDrivers = {} } = {}) {
const stats = createStatsDouble();
const events = [];
const handlers = createFunTextCommands({
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
publishEvent: (event) => events.push(event),
// Matches the real sanitizer so tests exercise the actual escaping rules.
sanitizeMentions: (text) => String(text || '')
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
@@ -43,7 +46,7 @@ function createHarness({ sockets = [] } = {}) {
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
return { handlers, stats };
return { handlers, stats, events };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
@@ -223,6 +226,73 @@ test('uwu transforms text without dropping it', () => {
assert.equal(uwuify('nice'), 'nyice');
});
test('bonking someone who is driving announces the sound for their rover', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['bob']);
assert.equal(events.length, 1);
assert.equal(events[0].type, 'fun.bonked');
assert.equal(events[0].payload.roverId, 'rover-1');
assert.equal(events[0].payload.targetLabel, 'bob');
});
test('bonking someone who is not driving announces nothing', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: {} });
const msg = message();
await handlers.bonk(msg, ['bob']);
// The text bonk still lands and is still tallied; only the sound is skipped.
assert.match(msg.replies[0].content, /Bonked bob/);
assert.equal(events.length, 0);
});
test('bonking a name that is not a real user announces nothing', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['the dishwasher']);
assert.equal(events.length, 0);
});
test('the bonk sound is rate limited per rover so it cannot interrupt a mic repeatedly', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.bonk(message(), ['bob']);
// A different actor has their own text cooldown but must not get a second sound.
await handlers.bonk(message({ id: 's3', userId: 'u-carol', label: 'carol' }), ['bob']);
await handlers.bonk(message({ id: 's4', userId: 'u-erin', label: 'erin' }), ['bob']);
assert.equal(events.length, 1);
});
test('a self-bonk never announces a sound', async () => {
const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const { handlers, events } = createHarness({ sockets: [alice], activeDrivers: { 'rover-1': 's1' } });
await handlers.bonk(message(), ['alice']);
assert.equal(events.length, 0);
});
test('hug and slap do not announce a bonk sound', async () => {
const { handlers, events } = createHarness({ sockets: [bob], activeDrivers: { 'rover-1': 's2' } });
await handlers.hug(message(), ['bob']);
await handlers.slap(message(), ['bob']);
assert.equal(events.length, 0);
});
test('a transport with no publishEvent still bonks normally', async () => {
const stats = createStatsDouble();
const handlers = createFunTextCommands({
io: { sockets: { sockets: new Map([[bob.id, bob]]) } },
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => ({ 'rover-1': 's2' }),
publishEvent: undefined,
sanitizeMentions: (text) => String(text || ''),
funStatsService: stats,
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
const msg = message();
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Bonked bob/);
});
test('wanted includes prior bonks when the target has any on record', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);