From dc8267073bfced50e107ecfc341a001230915a33 Mon Sep 17 00:00:00 2001 From: Saul5662 Date: Wed, 29 Jul 2026 05:05:36 +0100 Subject: [PATCH] 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) --- .../services/audioForwardService/bonkSound.js | 55 ++++++++++++ .../audioForwardService/bonkSound.test.js | 84 +++++++++++++++++++ .../src/services/audioForwardService/index.js | 6 ++ .../src/services/chatService/textCommands.js | 3 + .../src/services/discordBotService/index.js | 5 +- .../commands/funText.js | 49 ++++++++++- .../commands/funText.test.js | 74 +++++++++++++++- 7 files changed, 271 insertions(+), 5 deletions(-) create mode 100644 server/src/services/audioForwardService/bonkSound.js create mode 100644 server/src/services/audioForwardService/bonkSound.test.js diff --git a/server/src/services/audioForwardService/bonkSound.js b/server/src/services/audioForwardService/bonkSound.js new file mode 100644 index 00000000..33bd6e2b --- /dev/null +++ b/server/src/services/audioForwardService/bonkSound.js @@ -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, +}; diff --git a/server/src/services/audioForwardService/bonkSound.test.js b/server/src/services/audioForwardService/bonkSound.test.js new file mode 100644 index 00000000..70db57c6 --- /dev/null +++ b/server/src/services/audioForwardService/bonkSound.test.js @@ -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 }); +}); diff --git a/server/src/services/audioForwardService/index.js b/server/src/services/audioForwardService/index.js index 7778e32c..04a89404 100644 --- a/server/src/services/audioForwardService/index.js +++ b/server/src/services/audioForwardService/index.js @@ -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, diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index 0862ba52..98d0d21e 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -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 diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index a2f94112..f4456404 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -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, diff --git a/server/src/services/operatorCommandService/commands/funText.js b/server/src/services/operatorCommandService/commands/funText.js index bfbec41e..d3e856bf 100644 --- a/server/src/services/operatorCommandService/commands/funText.js +++ b/server/src/services/operatorCommandService/commands/funText.js @@ -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}`; diff --git a/server/src/services/operatorCommandService/commands/funText.test.js b/server/src/services/operatorCommandService/commands/funText.test.js index 946b2608..9e137171 100644 --- a/server/src/services/operatorCommandService/commands/funText.test.js +++ b/server/src/services/operatorCommandService/commands/funText.test.js @@ -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']);