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,