mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 18:10:47 -04:00
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:
co-authored by
Claude Opus 5
parent
eb0db3508f
commit
dc8267073b
@@ -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 { createAudioForwardWorkerEngine } = require('./workerEngine');
|
||||||
const { registerAudioForwardHooks } = require('./hooks');
|
const { registerAudioForwardHooks } = require('./hooks');
|
||||||
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
|
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
|
||||||
|
const { registerBonkSound } = require('./bonkSound');
|
||||||
|
|
||||||
const audioForwardEvents = new EventEmitter();
|
const audioForwardEvents = new EventEmitter();
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -151,6 +152,11 @@ registerChargeCompleteSound({
|
|||||||
playServerAudioFile,
|
playServerAudioFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerBonkSound({
|
||||||
|
logger,
|
||||||
|
playServerAudioFile,
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getAudioForwardState,
|
getAudioForwardState,
|
||||||
audioForwardEvents,
|
audioForwardEvents,
|
||||||
|
|||||||
@@ -203,6 +203,9 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
funStatsService,
|
funStatsService,
|
||||||
commandCooldowns,
|
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
|
Fun commands that move hardware need the sending socket so they can prove
|
||||||
the caller holds control. issueCommand is required lazily for the same
|
the caller holds control. issueCommand is required lazily for the same
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const {
|
|||||||
approveRequest: approvePrivateAccessRequest,
|
approveRequest: approvePrivateAccessRequest,
|
||||||
denyRequest: denyPrivateAccessRequest,
|
denyRequest: denyPrivateAccessRequest,
|
||||||
} = require('../privateRoverAccessRequestService');
|
} = require('../privateRoverAccessRequestService');
|
||||||
const { subscribe } = require('../eventBus');
|
const { subscribe, publishEvent } = require('../eventBus');
|
||||||
const funStatsService = require('../funStatsService');
|
const funStatsService = require('../funStatsService');
|
||||||
const { issueCommand } = require('../commandService');
|
const { issueCommand } = require('../commandService');
|
||||||
const { createPresenceManager } = require('./presence');
|
const { createPresenceManager } = require('./presence');
|
||||||
@@ -260,6 +260,9 @@ const commandDependencies = {
|
|||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
funStatsService,
|
funStatsService,
|
||||||
commandCooldowns,
|
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
|
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,
|
cannot prove drive control and decline with an explanation instead. The text,
|
||||||
|
|||||||
@@ -18,6 +18,14 @@ const {
|
|||||||
|
|
||||||
const TEXT_COOLDOWN_MS = 4 * 1000;
|
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 = [
|
const SLAP_ITEMS = [
|
||||||
'a large trout', 'a rolled-up service manual', 'a dead AA battery', 'a docking station',
|
'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',
|
'a suspiciously warm power brick', 'half a roll of duct tape', 'a decommissioned brush guard',
|
||||||
@@ -95,10 +103,42 @@ function uwuify(text) {
|
|||||||
.replace(/!+/g, ' !!');
|
.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 { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
|
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) {
|
function reply(message, content) {
|
||||||
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
|
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
|
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.
|
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 = []) {
|
return async function handleInteraction(message, tokens = []) {
|
||||||
const selector = tokens.join(' ').trim();
|
const selector = tokens.join(' ').trim();
|
||||||
if (!selector) {
|
if (!selector) {
|
||||||
@@ -148,6 +188,10 @@ function createFunTextCommands({ io, getNickname, sanitizeMentions, funStatsServ
|
|||||||
? funStatsService.bumpActorStats(resolved.actorKey, { label: resolved.label, [counterTaken]: 1 })
|
? funStatsService.bumpActorStats(resolved.actorKey, { label: resolved.label, [counterTaken]: 1 })
|
||||||
: null;
|
: 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({
|
return reply(message, render({
|
||||||
actor: gated.label,
|
actor: gated.label,
|
||||||
actorKey: gated.actorKey,
|
actorKey: gated.actorKey,
|
||||||
@@ -163,6 +207,7 @@ function createFunTextCommands({ io, getNickname, sanitizeMentions, funStatsServ
|
|||||||
counterGiven: 'bonksGiven',
|
counterGiven: 'bonksGiven',
|
||||||
counterTaken: 'bonksTaken',
|
counterTaken: 'bonksTaken',
|
||||||
selfReply: (label) => `${label} bonked themselves. That is between you and the rover.`,
|
selfReply: (label) => `${label} bonked themselves. That is between you and the rover.`,
|
||||||
|
onApplied: ({ actor, resolved }) => announceBonk(resolved.socket, resolved.label, actor),
|
||||||
render: ({ targetLabel, takenCount }) => {
|
render: ({ targetLabel, takenCount }) => {
|
||||||
const tally = takenCount ? ` That is their ${ordinal(takenCount)} bonk.` : '';
|
const tally = takenCount ? ` That is their ${ordinal(takenCount)} bonk.` : '';
|
||||||
return `🔨 Bonked ${targetLabel}.${tally}`;
|
return `🔨 Bonked ${targetLabel}.${tally}`;
|
||||||
|
|||||||
@@ -29,11 +29,14 @@ function createStatsDouble() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createHarness({ sockets = [] } = {}) {
|
function createHarness({ sockets = [], activeDrivers = {} } = {}) {
|
||||||
const stats = createStatsDouble();
|
const stats = createStatsDouble();
|
||||||
|
const events = [];
|
||||||
const handlers = createFunTextCommands({
|
const handlers = createFunTextCommands({
|
||||||
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
|
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
|
||||||
getNickname: (entry) => entry?.data?.nickname || '',
|
getNickname: (entry) => entry?.data?.nickname || '',
|
||||||
|
getActiveDrivers: () => activeDrivers,
|
||||||
|
publishEvent: (event) => events.push(event),
|
||||||
// Matches the real sanitizer so tests exercise the actual escaping rules.
|
// Matches the real sanitizer so tests exercise the actual escaping rules.
|
||||||
sanitizeMentions: (text) => String(text || '')
|
sanitizeMentions: (text) => String(text || '')
|
||||||
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
|
.replace(/<(@[!&]?\d+|#\d+)>/g, '[ping removed]')
|
||||||
@@ -43,7 +46,7 @@ function createHarness({ sockets = [] } = {}) {
|
|||||||
cooldowns: createCooldownGate(),
|
cooldowns: createCooldownGate(),
|
||||||
config: { commands: { prefix: 'rs' } },
|
config: { commands: { prefix: 'rs' } },
|
||||||
});
|
});
|
||||||
return { handlers, stats };
|
return { handlers, stats, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
|
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');
|
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 () => {
|
test('wanted includes prior bonks when the target has any on record', async () => {
|
||||||
const { handlers } = createHarness({ sockets: [bob] });
|
const { handlers } = createHarness({ sockets: [bob] });
|
||||||
await handlers.bonk(message(), ['bob']);
|
await handlers.bonk(message(), ['bob']);
|
||||||
|
|||||||
Reference in New Issue
Block a user