Compare commits

...
29 Commits
Author SHA1 Message Date
legop3 5ab62c3633 bebahba 2026-08-19 21:35:02 -04:00
legop3 fab0673d9e many small improvements 2026-08-19 19:08:06 -04:00
legop3 a5ec1dcb2d fixing pod collapse expandings 2026-08-19 14:20:57 -04:00
legop3 6ff60f7d0e slop board 2026-08-19 00:10:41 -04:00
legop3 ea16e2c67a slop board 2026-08-18 23:56:26 -04:00
legop3 9615423e06 slop board 2026-08-18 23:51:19 -04:00
legop3 93232e2a54 balance board slopping 2026-08-18 23:36:38 -04:00
legop3 271197f33c ui race condition fix 2026-08-18 23:15:24 -04:00
legop3 8e7d31dcdd better dock resolving 2026-08-18 22:51:39 -04:00
legop3 4f87a0eec2 change theme gap back to smallers 2026-08-18 22:42:38 -04:00
legop3 cc2e85d174 assignment adjustments and ui tweakings 2026-08-18 22:36:42 -04:00
legop3 737760ff56 big boy webui new new new new new 100 files changed 80 years 2026-08-18 22:01:25 -04:00
legop3 0f0f82e5f6 newdrive planning 2026-08-17 02:54:01 -04:00
legop3 3807b8bb13 Merge branch 'main' of https://github.com/legop3/MultiRoombaRover 2026-08-14 23:22:28 -04:00
legop3 9f2f819bbd neato alerts and better commands 2026-08-14 23:22:27 -04:00
legop3 60329e1036 Enhance MIDI player task with detailed research points
Expanded on the first task to improve the MIDI player with detailed research points and objectives.
2026-08-14 23:11:04 -04:00
legop3 4430517af5 neato updates 2026-08-14 20:25:07 -04:00
legop3 b24d453ad1 redo and move rover ranking a little 2026-08-11 21:34:29 -04:00
legop3 b5e8d775a2 home assistant always turn lights on at full brightness trying to fix weird bulb 2026-08-11 21:25:16 -04:00
legop3 e5830533ba arm powered steam link ?? 2026-08-11 20:50:15 -04:00
legop3 dfd674a447 arm powered steam deck 2026-08-11 20:36:55 -04:00
legop3 bd65f93756 green adjustment 2026-08-09 00:31:11 -04:00
legop3 0083c887f4 fix 2026-08-09 00:13:38 -04:00
legop3 70f71b2d1e green mode slop 1 2026-08-08 23:59:47 -04:00
legop3 c8742dbbd6 slop planning 2026-08-08 00:20:08 -04:00
legop3 6a6dec5540 crocs 2026-08-07 23:25:56 -04:00
legop3 6c69c583c5 moving audio gain perms around 2026-08-07 22:34:04 -04:00
legop3 9702cf0f82 gruh i hate fun!! 2026-08-07 15:54:28 -04:00
legop3 a55257dd51 Merge pull request #22 from legop3/transportswap
Transportswap merge
2026-08-05 14:55:30 -04:00
167 changed files with 6493 additions and 4357 deletions
+1
View File
@@ -21,6 +21,7 @@ server/data/admin-reason.json
server/data/buttonbox-state.json
server/data/barcode-tts-cache/
server/data/rover-odometers.json
server/data/mediamtx.yml
webui/package-lock.json
!server/data/
!server/data/barcode-registry.json
+134
View File
@@ -0,0 +1,134 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
New Drive corner-pod HUD sketch, second design pass
Purpose: Shows true edge-mounted pods, physically attached expansions, and circular controls.
Scope: Static design communication only; production geometry remains an implementation decision.
-->
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="900" viewBox="0 0 1200 900" role="img" aria-labelledby="title description">
<title id="title">Edge-mounted New Drive HUD pods</title>
<desc id="description">Four pods flow directly into the corners of a four by three rover video. Each has one inward rounded corner, and expansions attach directly along video edges.</desc>
<defs>
<linearGradient id="video" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#26343d" />
<stop offset="0.55" stop-color="#111827" />
<stop offset="1" stop-color="#1f2937" />
</linearGradient>
<linearGradient id="battery" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#22c55e" />
<stop offset="0.72" stop-color="#84cc16" />
<stop offset="1" stop-color="#eab308" />
</linearGradient>
<filter id="shadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="5" stdDeviation="8" flood-color="#000000" flood-opacity="0.5" />
</filter>
<style>
.pod { fill: #080a0f; fill-opacity: 0.86; stroke: #d1d5db; stroke-opacity: 0.26; stroke-width: 2; }
.expansion { fill: #080a0f; fill-opacity: 0.82; stroke: #d1d5db; stroke-opacity: 0.2; stroke-width: 2; }
.label { fill: #f8fafc; font-family: Inter, system-ui, sans-serif; font-weight: 700; }
.small { fill: #cbd5e1; font-family: Inter, system-ui, sans-serif; font-size: 17px; }
.tiny { fill: #94a3b8; font-family: Inter, system-ui, sans-serif; font-size: 14px; }
.arrow-button { fill: #1f2937; stroke: #e5e7eb; stroke-opacity: 0.55; stroke-width: 1.5; }
.arrow { fill: none; stroke: #f8fafc; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.track { fill: none; stroke: #334155; stroke-linecap: round; }
</style>
</defs>
<!-- The entire canvas is the shared 4:3 video/HUD coordinate space. -->
<rect width="1200" height="900" fill="url(#video)" />
<path d="M0 610 C235 510 390 590 600 515 C820 438 1000 520 1200 430 L1200 900 L0 900 Z" fill="#0b1516" opacity="0.76" />
<path d="M0 655 C240 555 425 635 630 560 C840 483 1020 560 1200 475" fill="none" stroke="#334155" stroke-width="5" opacity="0.42" />
<text x="600" y="450" text-anchor="middle" class="small" opacity="0.28">Rover video</text>
<!-- Top-left pod flows into the top and left edges; only its inward bottom-right corner rounds. -->
<g filter="url(#shadow)">
<path class="pod" d="M0 0 H190 V124 Q190 190 124 190 H0 Z" />
<circle class="track" cx="91" cy="91" r="55" stroke-width="14" />
<circle cx="91" cy="91" r="55" fill="none" stroke="#38bdf8" stroke-width="14" stroke-linecap="round" stroke-dasharray="255 346" transform="rotate(-90 91 91)" />
<text x="91" y="84" text-anchor="middle" class="tiny">Turn</text>
<text x="91" y="116" text-anchor="middle" class="label" font-size="30">0:42</text>
<circle class="arrow-button" cx="22" cy="22" r="16" />
<path class="arrow" d="M29 29 L16 16 M16 16 L25 16 M16 16 L16 25" />
<!-- This expansion begins exactly where the pod ends and continues directly into the top edge. -->
<path class="expansion" d="M190 0 H486 V50 Q486 78 458 78 H190 Z" />
<rect x="210" y="20" width="200" height="38" rx="8" fill="#7c3aed" opacity="0.72" />
<text x="310" y="45" text-anchor="middle" class="label" font-size="18">Rover name</text>
<circle class="arrow-button" cx="458" cy="39" r="14" />
<path class="arrow" d="M458 46 L458 32 M458 32 L452 38 M458 32 L464 38" />
</g>
<!-- Top-right pod and both expansions form one continuous edge-mounted cluster. -->
<g filter="url(#shadow)">
<path class="pod" d="M1010 0 H1200 V190 H1076 Q1010 190 1010 124 Z" />
<circle cx="1105" cy="91" r="59" fill="none" stroke="#334155" stroke-width="13" />
<circle cx="1105" cy="91" r="59" fill="none" stroke="url(#battery)" stroke-width="13" stroke-linecap="round" stroke-dasharray="300 371" transform="rotate(-90 1105 91)" />
<circle cx="1105" cy="91" r="42" fill="none" stroke="#334155" stroke-width="7" />
<circle cx="1105" cy="91" r="42" fill="none" stroke="#f59e0b" stroke-width="7" stroke-linecap="round" stroke-dasharray="112 264" transform="rotate(-90 1105 91)" />
<text x="1105" y="101" text-anchor="middle" class="label" font-size="30">81%</text>
<circle class="arrow-button" cx="1178" cy="22" r="16" />
<path class="arrow" d="M1171 29 L1184 16 M1184 16 L1175 16 M1184 16 L1184 25" />
<!-- Left expansion is attached to the pod at x=1010 and touches the top video edge. -->
<path class="expansion" d="M690 0 H1010 V78 H718 Q690 78 690 50 Z" />
<text x="718" y="31" class="label" font-size="18">Dock assist</text>
<text x="718" y="57" class="small">Dock rover</text>
<rect x="912" y="24" width="38" height="30" rx="6" fill="#312e81" stroke="#a5b4fc" />
<text x="931" y="45" text-anchor="middle" class="label" font-size="14">G</text>
<circle class="arrow-button" cx="980" cy="39" r="14" />
<path class="arrow" d="M980 46 L980 32 M980 32 L974 38 M980 32 L986 38" />
<!-- Lower expansion shares the pod's bottom edge and flows directly into the right edge. -->
<path class="expansion" d="M930 190 H1200 V420 H996 Q930 420 930 354 Z" />
<text x="958" y="225" class="label" font-size="18">Advanced power</text>
<text x="958" y="255" class="small">Voltage</text>
<rect x="958" y="266" width="214" height="8" rx="2" fill="#334155" />
<rect x="958" y="266" width="160" height="8" rx="2" fill="#38bdf8" />
<text x="958" y="306" class="small">Current</text>
<rect x="958" y="317" width="214" height="8" rx="2" fill="#334155" />
<rect x="958" y="317" width="90" height="8" rx="2" fill="#f59e0b" />
<text x="958" y="359" class="tiny">Computer 54 C</text>
<text x="958" y="383" class="tiny">Wi-Fi -58 dBm</text>
<circle class="arrow-button" cx="1174" cy="216" r="14" />
<path class="arrow" d="M1167 216 L1181 216 M1181 216 L1175 210 M1181 216 L1175 222" />
</g>
<!-- Bottom-left pod flows into the left and bottom edges with a compact triangular control group. -->
<g filter="url(#shadow)">
<path class="pod" d="M0 680 H220 Q300 680 300 760 V900 H0 Z" />
<circle cx="68" cy="758" r="38" fill="#172554" stroke="#60a5fa" stroke-width="2" />
<text x="68" y="754" text-anchor="middle" class="label" font-size="24"></text>
<text x="68" y="779" text-anchor="middle" class="tiny">E</text>
<circle cx="102" cy="850" r="38" fill="#3b2f0b" stroke="#facc15" stroke-width="2" />
<text x="102" y="846" text-anchor="middle" class="label" font-size="24"></text>
<text x="102" y="871" text-anchor="middle" class="tiny">R</text>
<circle cx="208" cy="798" r="57" fill="#3f1d2e" stroke="#fb7185" stroke-width="3" />
<text x="208" y="794" text-anchor="middle" class="label" font-size="25">Horn</text>
<text x="208" y="823" text-anchor="middle" class="tiny">H</text>
<circle class="arrow-button" cx="252" cy="754" r="14" />
<path class="arrow" d="M246 754 L258 754 M258 754 L253 749 M258 754 L253 759" />
<circle class="arrow-button" cx="22" cy="878" r="16" />
<path class="arrow" d="M29 871 L16 884 M16 884 L25 884 M16 884 L16 875" />
</g>
<!-- Bottom-right pod contains a circular tilt slider rather than a horizontal or pill track. -->
<g filter="url(#shadow)">
<path class="pod" d="M840 900 V760 Q840 680 920 680 H1200 V900 Z" />
<text x="1168" y="714" text-anchor="end" class="label" font-size="18">Camera tilt</text>
<circle class="track" cx="1030" cy="800" r="76" stroke-width="13" />
<circle cx="1030" cy="800" r="76" fill="none" stroke="#38bdf8" stroke-width="13" stroke-linecap="round" stroke-dasharray="285 478" transform="rotate(140 1030 800)" />
<circle cx="976" cy="746" r="13" fill="#e0f2fe" stroke="#0284c7" stroke-width="4" />
<text x="1030" y="808" text-anchor="middle" class="label" font-size="25">-12.5°</text>
<text x="1030" y="832" text-anchor="middle" class="tiny">Click for zero</text>
<circle cx="948" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
<text x="948" y="844" text-anchor="middle" class="label" font-size="14">J</text>
<circle cx="1112" cy="838" r="22" fill="#1e3a8a" stroke="#93c5fd" />
<text x="1112" y="844" text-anchor="middle" class="label" font-size="14">U</text>
<circle class="arrow-button" cx="1178" cy="878" r="16" />
<path class="arrow" d="M1171 871 L1184 884 M1184 884 L1175 884 M1184 884 L1184 875" />
</g>
<!-- Immediate sensor overlays remain separate and are shown only as faint context here. -->
<path d="M360 900 Q600 808 840 900" fill="none" stroke="#ef4444" stroke-width="13" stroke-linecap="round" opacity="0.25" />
<path d="M410 886 Q600 820 790 886" fill="none" stroke="#22c55e" stroke-width="5" stroke-dasharray="12 10" opacity="0.4" />
</svg>

After

Width:  |  Height:  |  Size: 9.0 KiB

+75
View File
@@ -0,0 +1,75 @@
- start work on new better ui layout, using components that already exist when possible
- centered rover video, full screen height
- rover HUD contains small but expandable rover telemetry UI and vis
- make newgen folder for new HUD elements. reuse old elements where possible
- make all new hud elements small and clean
- every hud element:
- is a nice small translucent thing with text icons or both
- can be expanded to show more relavent information
- is consistent. maybe make a reusable thing for this
- some specific hud elements:
- top bar:
- battery percentage that goes red and flashes and such
- turns hud that shows people in queue
- big in the middle
- left and right sides
- wheel drop indicators that show up when wheel drop is happening
- overcurrent and battery warnings
- bottom section:
- sensor elements that show up only when the sensor is "happening"
- bumpers
- front IR proximity sensors
- two sidebars
- sidebars contain all the stuff that isnt the rover
- left
- idk
- right
- chat, users, rovers list, and replay sources
- everything involving the rover is a video HUD, everything external is in the sidebars
## section 2
There will be corner mounted (one pod in each corner of the video), rounded pods in the HUD, which will contain gauges and controls for the rover
These pods will be collapsible, with a corner mounted arrow. the arrow points towards the corner when the pod is out, and points out of the corner when the pod is hidden.
There can also be "pod expansions" that will be in the corner of the pod and the side of the video. These are also collapsible, but they collapse into the side of the video that they are touching, instead of collapsing into the corner, with the same style arrow button as the pods.
For example, a pod in the top left is open. This pod has an expansion to it's right that is also open. I can collapse the pod into the corner, the expansion stays, it gets moved into the top left corner where the pod was.
- corner pods:
- top left
- pod
- turns timer
- round gauge circle that ticks down with time
- inside it, is the turn countdown
- this pod goes away when theres nothing to count
- right of pod expansion
- rover name with colored background
- expanded by default
- top right
- pod
- round rover battery bar gauge, based off how battery bar looks
- concentric to this bar is an unlabeled current gauge, styled after the current bar that the top down map contains
- inside the circle, is the battery percentage.
- left of pod expansion
- dock assist button and keybind
- expanded by default
- below pod expansion
- combined advanced power view for the roomba with other info from rover host stats
- bottom left
- pod
- has circular buttons for laser, horn, and headlight
- each button is a related icon and the keybind label for the feature
- arranged nicely
- horn button is larger, and contains an arrow to open the horn settings menu
- this pod disappears when none of these things are enabled
- if one of the button's features is not enabled, that button should go away
- bottom right
- pod
- rounded camera tilt slider, with keybind label on each end for up / down
- in the area inside the slider, show the tilt degrees
- clicking the degrees label should set camera tilt to 0
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -99,10 +99,15 @@ audioForward:
maxUploadBytes: 8388608
audioLevels:
# Gains are multipliers (0.0 - 4.0) applied globally to all rovers.
# Base multipliers (0.0 - 4.0) applied before any approved user's signed
# personal adjustment. The server clamps every final rover gain to this same
# hard multiplier range.
hornGain: 1.0
ttsGain: 1.0
forwardGain: 1.0
# Approved users may move each personal slider this far below or above the
# base multiplier. Browser cookies store percentages, never raw multipliers.
maxPersonalAdjustmentPercent: 50
homeAssistant:
enabled: false
-2
View File
@@ -300,8 +300,6 @@ Environment=SERVER_CONFIG=$CONFIG_PATH
Environment=ROVER_SNAPSHOT_DIR=$SNAPSHOT_DIR
Environment=REPLAY_SEGMENT_DIR=$REPLAY_SEGMENT_DIR
Environment=ROVER_SNAPSHOT_WRITER_BIN=$ROVER_SNAPSHOT_WRITER_BIN
RuntimeDirectory=multirover
RuntimeDirectoryMode=0750
ExecStart=$NODE_BIN $SERVER_DIR/index.js
Restart=on-failure
RestartSec=2
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject -->
<!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-C7V6I437.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7PpZTwSc.css">
<script type="module" crossorigin src="/assets/index-C0lpCbci.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BITzjVQo.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,75 @@
// Reward Definition: Green Mode
// Purpose: Enables the server-wide green theme and room effect for twenty minutes.
// Scope: Owns button-box timing/recovery while delegating the actual mode to greenModeService.
const DURATION_MS = 20 * 60 * 1000;
let activeTimer = null;
let unsubscribeGreenMode = null;
function clearRuntimeWatchers() {
if (activeTimer) {
clearTimeout(activeTimer);
activeTimer = null;
}
if (unsubscribeGreenMode) {
unsubscribeGreenMode();
unsubscribeGreenMode = null;
}
}
async function stopGreenMode(ctx) {
clearRuntimeWatchers();
await ctx.setGreenMode(false, { source: 'buttonbox:greenModeExpired' });
ctx.clearEffect('greenMode');
}
async function startGreenMode(ctx, effect = {}) {
clearRuntimeWatchers();
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
const remaining = Math.max(0, endsAt - Date.now());
if (remaining <= 0) {
await stopGreenMode(ctx);
return;
}
await ctx.setGreenMode(true, { source: 'buttonbox:greenMode' });
ctx.saveEffect('greenMode', { endsAt });
/*
Access-mode changes disable green mode through greenModeService. Watching
that shared state transition lets the reward discard its persisted effect
immediately, so a restart cannot accidentally revive a reward that was
intentionally ended early.
*/
unsubscribeGreenMode = ctx.onGreenModeChange((enabled) => {
if (enabled) return;
clearRuntimeWatchers();
ctx.clearEffect('greenMode');
});
activeTimer = setTimeout(() => {
stopGreenMode(ctx).catch((err) => {
ctx.logger.warn('green mode reward stop failed', { error: err.message });
});
}, remaining);
}
module.exports = {
id: 'greenMode',
name: 'Green mode',
description: 'Makes the room and server green for 20 minutes.',
goal: 5,
async run(ctx) {
await startGreenMode(ctx, { endsAt: Date.now() + DURATION_MS });
},
async recover(ctx, effect) {
// Recovery must never manufacture a fresh twenty-minute window from a
// missing or corrupt persisted deadline. Treat it as expired and clean up.
if (!Number.isFinite(Number(effect?.endsAt))) {
await stopGreenMode(ctx);
return;
}
await startGreenMode(ctx, effect);
},
};
@@ -0,0 +1,57 @@
// Green Mode Reward Tests
// Purpose: Pins the five-press metadata and persisted timed-effect lifecycle.
// Scope: Uses a small context double; greenModeService behavior is tested through its public contract.
const test = require('node:test');
const assert = require('node:assert/strict');
const reward = require('./greenMode');
function createContext() {
const calls = [];
let changeListener = null;
return {
calls,
logger: { warn: () => {} },
setGreenMode: async (enabled, options) => {
calls.push({ type: 'set', enabled, source: options?.source });
return enabled;
},
saveEffect: (id, payload) => calls.push({ type: 'save', id, payload }),
clearEffect: (id) => calls.push({ type: 'clear', id }),
onGreenModeChange: (listener) => {
changeListener = listener;
return () => {
changeListener = null;
};
},
emitGreenModeChange: (enabled) => changeListener?.(enabled),
};
}
test('green mode reward requires five presses and starts a persisted effect', async () => {
const ctx = createContext();
assert.equal(reward.goal, 5);
await reward.run(ctx);
assert.deepEqual(ctx.calls[0], { type: 'set', enabled: true, source: 'buttonbox:greenMode' });
const saved = ctx.calls.find((call) => call.type === 'save');
assert.equal(saved?.id, 'greenMode');
assert.ok(saved?.payload?.endsAt > Date.now());
// Simulate an access-mode shutdown so the test also clears the reward's
// twenty-minute timer instead of leaving background work in the test process.
ctx.emitGreenModeChange(false);
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
});
test('invalid recovery state is cleared instead of starting a new duration', async () => {
const ctx = createContext();
await reward.recover(ctx, {});
assert.deepEqual(ctx.calls[0], {
type: 'set',
enabled: false,
source: 'buttonbox:greenModeExpired',
});
assert.ok(ctx.calls.some((call) => call.type === 'clear' && call.id === 'greenMode'));
});
+2
View File
@@ -10,6 +10,7 @@ const discordPingEveryone = require('./definitions/discordPingEveryone');
const modeJam = require('./definitions/modeJam');
const assignmentRoulette = require('./definitions/assignmentRoulette');
const chatSpam = require('./definitions/chatSpam');
const greenMode = require('./definitions/greenMode');
const orderedRewards = [
dockPanic,
@@ -22,6 +23,7 @@ const orderedRewards = [
modeJam,
assignmentRoulette,
chatSpam,
greenMode,
];
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
+10 -27
View File
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('../modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
const roverManager = require('../roverManager');
const { compareRoversForAssignment } = require('./roverRanking');
const socketRefs = new Map(); // socketId -> socket
const assignments = new Map(); // socketId -> roverId
@@ -241,35 +242,17 @@ function pickRover(socket, options = {}) {
if (candidates.length === 0) {
return null;
}
const dockedRank = (rover) => {
if (!rover) return 0;
if (rover.docked === true) return -1;
if (rover.docked === false) return 1;
const sensors = rover.lastSensor?.decoded || rover.lastSensor?.sensors || null;
const docked = sensors?.chargingSources?.homeBase;
if (docked === true) return -1;
if (docked === false) return 1;
return 0;
};
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
const compare = (a, b) => {
const aEmpty = idleRank(a);
const bEmpty = idleRank(b);
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
const aDockRank = dockedRank(a);
const bDockRank = dockedRank(b);
if (aEmpty === 1 && aDockRank !== bDockRank) {
return bDockRank - aDockRank;
}
if (a.drivers.size !== b.drivers.size) {
return a.drivers.size - b.drivers.size;
}
return bDockRank - aDockRank;
};
candidates.sort(compare);
/*
Eligibility is resolved above, while this shared comparator owns only the
requested placement order: empty, undocked when empty, driver count, then
battery percentage.
Keeping those concerns separate prevents a ranking change from weakening
lock, private-rover, role, or mode access checks.
*/
candidates.sort(compareRoversForAssignment);
const best = candidates[0];
if (!best) return null;
const bestTier = candidates.filter((entry) => compare(entry, best) === 0);
const bestTier = candidates.filter((entry) => compareRoversForAssignment(entry, best) === 0);
if (!bestTier.length) return best;
return bestTier[Math.floor(Math.random() * bestTier.length)] || best;
}
@@ -0,0 +1,87 @@
// Rover assignment ranking
// Purpose: Ranks otherwise eligible rovers using the fleet's assignment priorities.
// Scope: Contains only deterministic comparison logic; access checks and the final random tie-break remain in assignmentService.
function readDockedState(rover) {
/*
The rover record normally exposes the server's canonical docked state. The
sensor fallback covers the short interval where telemetry has arrived but
the derived top-level field has not yet been synchronized. Unknown docking
state deliberately remains unknown instead of being treated as undocked.
*/
if (rover?.docked === true || rover?.docked === false) return rover.docked;
const sensors = rover?.lastSensor?.decoded || rover?.lastSensor?.sensors || null;
const homeBase = sensors?.chargingSources?.homeBase;
return homeBase === true || homeBase === false ? homeBase : null;
}
function driverCount(rover) {
/*
Production rover records use a Set. Returning a safe high-level count here
keeps ranking predictable for partially initialized records and makes the
comparator straightforward to exercise with small test fixtures.
*/
return Number.isFinite(rover?.drivers?.size) ? rover.drivers.size : 0;
}
function batteryPercentage(rover) {
/*
percentDisplay is the canonical server-normalized percentage used by the
rest of the application. Missing or invalid telemetry receives no invented
percentage; the comparator places unknown batteries after every known one.
*/
const percentage = rover?.batteryState?.percentDisplay;
return Number.isFinite(percentage) ? percentage : null;
}
function compareRoversForAssignment(left, right) {
/*
Spread drivers across the fleet before adding another person to an existing
rover queue. This comparison is deliberately independent of battery: a
small battery-percentage difference should never concentrate users on one
rover while another eligible rover has nobody assigned.
*/
const leftDrivers = driverCount(left);
const rightDrivers = driverCount(right);
const leftEmpty = leftDrivers === 0;
const rightEmpty = rightDrivers === 0;
if (leftEmpty !== rightEmpty) return leftEmpty ? -1 : 1;
/*
When both choices are empty, prefer the rover that is already away from its
dock. Docking state does not separate occupied rovers because queue balance
is more useful there, and an existing driver may already be handling the
rover's physical state. Unknown docking telemetry receives no undocked
preference rather than being guessed as ready.
*/
if (leftEmpty && rightEmpty) {
const leftUndocked = readDockedState(left) === false;
const rightUndocked = readDockedState(right) === false;
if (leftUndocked !== rightUndocked) return leftUndocked ? -1 : 1;
}
/*
For occupied rovers, queue length is the primary balancing signal. This is
intentionally evaluated before battery so a one-percent battery advantage
cannot cause every later user to pile onto the same rover.
*/
if (leftDrivers !== rightDrivers) return leftDrivers - rightDrivers;
const leftBattery = batteryPercentage(left);
const rightBattery = batteryPercentage(right);
const leftHasBattery = leftBattery != null;
const rightHasBattery = rightBattery != null;
if (leftHasBattery !== rightHasBattery) return leftHasBattery ? -1 : 1;
if (leftHasBattery && leftBattery !== rightBattery) return rightBattery - leftBattery;
/*
Returning zero is intentional. assignmentService randomly selects from the
complete best tier so stable Map insertion order cannot permanently favor a
rover whose emptiness, docking state, load, and battery are all equivalent.
*/
return 0;
}
module.exports = {
compareRoversForAssignment,
};
@@ -0,0 +1,77 @@
// Rover assignment ranking tests
// Purpose: Locks the operator-defined rover priority order against accidental comparator regressions.
// Scope: Tests pure ranking only; assignment side effects and access policy remain owned by their existing services.
const test = require('node:test');
const assert = require('node:assert/strict');
const { compareRoversForAssignment } = require('./roverRanking');
function rover({ id, docked, battery, drivers = 0 }) {
/*
Set size matches the production rover contract without introducing socket or
rover-manager dependencies into these focused ordering tests.
*/
return {
id,
docked,
batteryState: battery == null ? null : { percentDisplay: battery },
drivers: new Set(Array.from({ length: drivers }, (_, index) => `${id}-driver-${index}`)),
};
}
function rankedIds(entries) {
return entries.sort(compareRoversForAssignment).map((entry) => entry.id);
}
test('an empty rover outranks an occupied rover regardless of battery or docking state', () => {
const result = rankedIds([
rover({ id: 'occupied-high', docked: false, battery: 100, drivers: 1 }),
rover({ id: 'docked-empty', docked: true, battery: 20 }),
]);
assert.deepEqual(result, ['docked-empty', 'occupied-high']);
});
test('an undocked rover is preferred when both rovers are empty', () => {
const result = rankedIds([
rover({ id: 'docked-high', docked: true, battery: 100 }),
rover({ id: 'undocked-low', docked: false, battery: 20 }),
]);
assert.deepEqual(result, ['undocked-low', 'docked-high']);
});
test('lowest driver count ranks occupied rovers before battery percentage', () => {
const result = rankedIds([
rover({ id: 'busy-high', docked: false, battery: 100, drivers: 4 }),
rover({ id: 'quieter-low', docked: false, battery: 20, drivers: 1 }),
]);
assert.deepEqual(result, ['quieter-low', 'busy-high']);
});
test('battery percentage ranks rovers after availability and load are equal', () => {
const result = rankedIds([
rover({ id: 'low', docked: false, battery: 35, drivers: 1 }),
rover({ id: 'high', docked: false, battery: 90, drivers: 1 }),
rover({ id: 'middle', docked: false, battery: 60, drivers: 1 }),
]);
assert.deepEqual(result, ['high', 'middle', 'low']);
});
test('known battery percentage outranks missing battery telemetry', () => {
const result = rankedIds([
rover({ id: 'unknown', docked: true, battery: null }),
rover({ id: 'known', docked: true, battery: 5 }),
]);
assert.deepEqual(result, ['known', 'unknown']);
});
test('exactly equivalent rovers remain tied for random selection by assignmentService', () => {
const left = rover({ id: 'left', docked: false, battery: 80, drivers: 1 });
const right = rover({ id: 'right', docked: false, battery: 80, drivers: 1 });
assert.equal(compareRoversForAssignment(left, right), 0);
assert.equal(compareRoversForAssignment(right, left), 0);
});
@@ -1,55 +0,0 @@
// 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,
};
@@ -1,84 +0,0 @@
// 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,7 +14,6 @@ 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();
@@ -152,11 +151,6 @@ registerChargeCompleteSound({
playServerAudioFile,
});
registerBonkSound({
logger,
playServerAudioFile,
});
module.exports = {
getAudioForwardState,
audioForwardEvents,
@@ -1,79 +1,62 @@
// audio Levels Gain Math
// Purpose: Holds the pure clamping and ceiling rules shared by every gain layer.
// Scope: No IO, no state; keeps the volume policy independently reviewable and testable.
/*
The three gain keys are the same on every layer of this feature: the global
admin gains, the admin-editable VIP boost caps, and each user's personal
preference. Iterating one list keeps those layers from drifting apart.
*/
const GAIN_KEYS = ['hornGain', 'ttsGain', 'forwardGain'];
// Absolute gain limits accepted anywhere a multiplier is stored.
// Audio Adjustment Math
// Purpose: Converts signed browser percentages into server-enforced rover gain multipliers.
// Scope: Contains no IO or identity logic so the adjustment policy can be tested independently.
const ADJUSTMENT_FIELDS = [
{ gainKey: 'hornGain', percentKey: 'hornPercent' },
{ gainKey: 'ttsGain', percentKey: 'ttsPercent' },
{ gainKey: 'forwardGain', percentKey: 'forwardPercent' },
];
const MIN_GAIN = 0;
const MAX_GAIN = 4;
const MIN_ADJUSTMENT_PERCENT = -100;
const MAX_ADJUSTMENT_PERCENT = 100;
function clampGain(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, num));
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, number));
}
function clampFraction(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(1, num));
function clampMaximumAdjustmentPercent(value, fallback = 50) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.round(Math.max(0, Math.min(MAX_ADJUSTMENT_PERCENT, number)));
}
function normalizeUserGains(raw = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampFraction(raw?.[key], 1);
function clampAdjustmentPercent(value, maximum = 0) {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
const limit = clampMaximumAdjustmentPercent(maximum, 0);
return Math.round(Math.max(-limit, Math.min(limit, number)));
}
function normalizeAdjustments(raw = {}, maximum = 0) {
const normalized = {};
ADJUSTMENT_FIELDS.forEach(({ percentKey }) => {
normalized[percentKey] = clampAdjustmentPercent(raw?.[percentKey], maximum);
});
return out;
return normalized;
}
function normalizeGainSet(raw = {}, fallback = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1));
function applyAdjustments(baseLevels = {}, adjustments = {}) {
const effective = {};
ADJUSTMENT_FIELDS.forEach(({ gainKey, percentKey }) => {
const base = clampGain(baseLevels?.[gainKey], 0);
const percentage = Math.max(MIN_ADJUSTMENT_PERCENT, Math.min(MAX_ADJUSTMENT_PERCENT, Number(adjustments?.[percentKey]) || 0));
effective[gainKey] = clampGain(base * (1 + percentage / 100), 0);
});
return out;
}
/*
A user without the boost flag can never exceed the global admin gain. The flag
raises the ceiling to the admin-managed hard cap, and Math.max keeps the flag
from ever being a downgrade: if an admin runs the global gain higher than the
boost cap, a boosted user keeps the global ceiling instead of losing volume
for holding a permission.
*/
function resolveCeilings({ adminLimits = {}, boostCaps = {}, hasBoost = false } = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
const adminCeiling = clampGain(adminLimits?.[key], 0);
out[key] = hasBoost ? Math.max(adminCeiling, clampGain(boostCaps?.[key], 0)) : adminCeiling;
});
return out;
}
// Personal preferences are fractions of whichever ceiling applies to the user.
function applyCeilings(fractions = {}, ceilings = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0);
});
return out;
return effective;
}
module.exports = {
GAIN_KEYS,
ADJUSTMENT_FIELDS,
MIN_GAIN,
MAX_GAIN,
MIN_ADJUSTMENT_PERCENT,
MAX_ADJUSTMENT_PERCENT,
clampGain,
clampFraction,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
clampMaximumAdjustmentPercent,
clampAdjustmentPercent,
normalizeAdjustments,
applyAdjustments,
};
@@ -1,85 +1,37 @@
// audio Levels Gain Math Tests
// Purpose: Pins the ceiling rules that keep user volume inside admin limits.
// Scope: Pure math only; no store, socket, or rover involvement.
// Audio Adjustment Math Tests
// Purpose: Pins percentage clamping and conversion independently of sockets, identity, and rover IO.
// Scope: Covers only the pure rules used by audioLevelsService.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
clampFraction,
clampGain,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
} = require('./gainMath');
const { clampMaximumAdjustmentPercent, normalizeAdjustments, applyAdjustments } = require('./gainMath');
const ADMIN_LIMITS = { hornGain: 0.3, ttsGain: 0.2, forwardGain: 0.1 };
const BOOST_CAPS = { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 };
test('an unboosted user is capped by the global admin gains', () => {
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: false });
assert.deepEqual(ceilings, ADMIN_LIMITS);
test('the configured range is a whole percentage from zero through one hundred', () => {
assert.equal(clampMaximumAdjustmentPercent(-5), 0);
assert.equal(clampMaximumAdjustmentPercent(32.6), 33);
assert.equal(clampMaximumAdjustmentPercent(500), 100);
});
test('the boost flag raises the ceiling to the hard caps', () => {
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: true });
assert.deepEqual(ceilings, BOOST_CAPS);
});
test('the boost flag never lowers a ceiling when admin gains exceed the caps', () => {
const loud = { hornGain: 2, ttsGain: 1.5, forwardGain: 3 };
const ceilings = resolveCeilings({ adminLimits: loud, boostCaps: BOOST_CAPS, hasBoost: true });
assert.deepEqual(ceilings, loud);
});
test('a full personal slider resolves to exactly the ceiling', () => {
const effective = applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ADMIN_LIMITS);
assert.deepEqual(effective, ADMIN_LIMITS);
});
test('a personal slider scales the ceiling rather than replacing it', () => {
const effective = applyCeilings({ hornGain: 0.5, ttsGain: 0.5, forwardGain: 0.5 }, BOOST_CAPS);
assert.deepEqual(effective, { hornGain: 0.25, ttsGain: 0.4, forwardGain: 0.2 });
});
test('an out-of-range personal value cannot escape the ceiling', () => {
const effective = applyCeilings({ hornGain: 12, ttsGain: -4, forwardGain: 'loud' }, ADMIN_LIMITS);
assert.equal(effective.hornGain, ADMIN_LIMITS.hornGain);
assert.equal(effective.ttsGain, 0);
// A non-numeric value falls back to the full slider, still bounded by the ceiling.
assert.equal(effective.forwardGain, ADMIN_LIMITS.forwardGain);
});
test('a zero admin gain silences even a boosted user at full slider', () => {
const ceilings = resolveCeilings({
adminLimits: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
boostCaps: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
hasBoost: true,
});
assert.deepEqual(applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ceilings), {
hornGain: 0,
ttsGain: 0,
forwardGain: 0,
test('each browser percentage is clamped equally in both directions', () => {
assert.deepEqual(normalizeAdjustments({ hornPercent: -80, ttsPercent: 10, forwardPercent: 90 }, 40), {
hornPercent: -40,
ttsPercent: 10,
forwardPercent: 40,
});
});
test('personal values normalize into the 0..1 range with a full-volume default', () => {
assert.deepEqual(normalizeUserGains({ hornGain: 0.25, ttsGain: 9 }), {
hornGain: 0.25,
ttsGain: 1,
forwardGain: 1,
});
test('signed percentages adjust each server base gain', () => {
assert.deepEqual(
applyAdjustments(
{ hornGain: 1, ttsGain: 2, forwardGain: 0.5 },
{ hornPercent: -25, ttsPercent: 25, forwardPercent: 40 },
),
{ hornGain: 0.75, ttsGain: 2.5, forwardGain: 0.7 },
);
});
test('gain sets normalize into the 0..4 range and fall back per key', () => {
assert.deepEqual(normalizeGainSet({ hornGain: 9, ttsGain: 'x' }, BOOST_CAPS), {
hornGain: 4,
ttsGain: BOOST_CAPS.ttsGain,
forwardGain: BOOST_CAPS.forwardGain,
});
});
test('clamps reject non-finite input by returning the supplied fallback', () => {
assert.equal(clampGain(Number.NaN, 0.7), 0.7);
assert.equal(clampGain(Infinity, 0.7), 0.7);
assert.equal(clampFraction(undefined, 0.4), 0.4);
test('effective gains remain inside the rover hard bounds', () => {
assert.deepEqual(
applyAdjustments({ hornGain: 4, ttsGain: 0, forwardGain: 3 }, { hornPercent: 100, ttsPercent: -100, forwardPercent: 100 }),
{ hornGain: 4, ttsGain: 0, forwardGain: 4 },
);
});
+90 -109
View File
@@ -7,18 +7,16 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('audioLevelsService');
const { loadConfig } = require('../../helpers/configLoader');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { isAdmin } = require('../roleService');
const { isAdmin, roleEvents } = require('../roleService');
const roverManager = require('../roverManager');
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
const { identityEvents, getUserIdForSocket, hasUserPermission } = require('../identityService');
const { issueCommand } = require('../commandService');
const {
GAIN_KEYS,
ADJUSTMENT_FIELDS,
clampGain,
clampFraction,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
clampMaximumAdjustmentPercent,
normalizeAdjustments,
applyAdjustments,
} = require('./gainMath');
const audioLevelsEvents = new EventEmitter();
@@ -26,48 +24,32 @@ const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('audio-levels.json');
const config = loadConfig();
const configuredDefaults = config.audioLevels || {};
const configuredUserCaps = configuredDefaults.userGainCaps || {};
/*
Per-user preferences live in identity feature state so they follow the user
across browsers and cannot be raised by editing a client-side cookie. They are
stored as a 0..1 fraction of whatever ceiling currently applies rather than an
absolute gain, so lowering the global admin gain immediately quiets everyone
without having to rewrite every stored preference.
*/
const USER_GAINS_NAMESPACE = 'audioGains';
/*
Absolute ceilings for users holding the audioGainBoost flag. These are the
hard caps the flag cannot exceed; admins can retune them from the driver page.
*/
const USER_GAIN_CAP_DEFAULTS = {
hornGain: 0.5,
ttsGain: 0.8,
forwardGain: 0.4,
};
const PERSONAL_ADJUSTMENT_PERMISSION = 'audio.personalAdjustment';
const DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT = 50;
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
configuredDefaults.maxPersonalAdjustmentPercent,
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
),
};
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
return normalizeGainSet(raw, fallback);
}
function normalizeStore(raw = {}) {
return {
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
raw.maxPersonalAdjustmentPercent,
DEFAULTS.maxPersonalAdjustmentPercent,
),
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
capsUpdatedAt: Number.isFinite(raw.capsUpdatedAt) ? raw.capsUpdatedAt : null,
capsUpdatedBy: typeof raw.capsUpdatedBy === 'string' ? raw.capsUpdatedBy : null,
adjustmentRangeUpdatedAt: Number.isFinite(raw.adjustmentRangeUpdatedAt) ? raw.adjustmentRangeUpdatedAt : null,
adjustmentRangeUpdatedBy: typeof raw.adjustmentRangeUpdatedBy === 'string' ? raw.adjustmentRangeUpdatedBy : null,
};
}
@@ -78,6 +60,11 @@ function loadState() {
try {
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
state = normalizeStore(raw);
if (Object.prototype.hasOwnProperty.call(raw, 'userGainCaps')) {
// Rewrite once so the retired VIP-cap object does not linger beside the
// new percentage range and confuse future operator inspection.
persistState(state);
}
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load audio levels store', err.message);
@@ -103,18 +90,14 @@ function getAudioLevels() {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
userGainCaps: { ...current.userGainCaps },
maxPersonalAdjustmentPercent: current.maxPersonalAdjustmentPercent,
updatedAt: current.updatedAt,
updatedBy: current.updatedBy,
capsUpdatedAt: current.capsUpdatedAt,
capsUpdatedBy: current.capsUpdatedBy,
adjustmentRangeUpdatedAt: current.adjustmentRangeUpdatedAt,
adjustmentRangeUpdatedBy: current.adjustmentRangeUpdatedBy,
};
}
function getUserGainCaps() {
return { ...loadState().userGainCaps };
}
function emitChange(reason = 'update', extra = {}) {
audioLevelsEvents.emit('change', {
reason,
@@ -132,30 +115,19 @@ function getAdminLimits() {
};
}
function getGainCeilings(hasBoost) {
const current = loadState();
return resolveCeilings({
adminLimits: getAdminLimits(),
boostCaps: current.userGainCaps,
hasBoost,
});
function canUsePersonalAdjustments(socket) {
if (isAdmin(socket)) return true;
const userId = getUserIdForSocket(socket);
return Boolean(userId && hasUserPermission(userId, PERSONAL_ADJUSTMENT_PERMISSION));
}
function getGainCeilingsForSocket(socket) {
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
}
function getUserGains(userId) {
if (!userId) return normalizeUserGains({});
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
}
function getUserGainsForSocket(socket) {
return getUserGains(getUserIdForSocket(socket));
function getAdjustmentsForSocket(socket) {
if (!canUsePersonalAdjustments(socket)) return normalizeAdjustments({}, 0);
return normalizeAdjustments(socket?.data?.audioAdjustments, loadState().maxPersonalAdjustmentPercent);
}
function getEffectiveLevelsForSocket(socket) {
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
return applyAdjustments(getAdminLimits(), getAdjustmentsForSocket(socket));
}
/*
@@ -234,55 +206,49 @@ function setAudioLevels(input = {}, actor = null) {
return getAudioLevels();
}
function setUserGainCaps(input = {}, actor = null) {
function setMaxPersonalAdjustmentPercent(value, actor = null) {
const current = loadState();
const next = {
...current,
userGainCaps: normalizeUserGainCaps(input, current.userGainCaps),
capsUpdatedAt: Date.now(),
capsUpdatedBy: actor,
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(value, current.maxPersonalAdjustmentPercent),
adjustmentRangeUpdatedAt: Date.now(),
adjustmentRangeUpdatedBy: actor,
};
persistState(next);
/*
Lowering a cap has to take effect immediately for anyone already driving,
otherwise a boosted user keeps the louder gain until their next turn.
A narrower range must take effect immediately for current drivers rather
than leaving an out-of-range multiplier active until their next turn.
*/
pushLevelsToAllRovers();
emitChange('user_caps_set');
return getUserGainCaps();
emitChange('personal_adjustment_range_set');
return loadState().maxPersonalAdjustmentPercent;
}
function setUserGains(socket, input = {}) {
const userId = getUserIdForSocket(socket);
if (!userId) throw new Error('Identity required');
const current = getUserGains(userId);
const next = { ...current };
GAIN_KEYS.forEach((key) => {
if (input?.[key] === undefined) return;
next[key] = clampFraction(input[key], current[key]);
});
setFeatureState(userId, USER_GAINS_NAMESPACE, next);
function setSocketAdjustments(socket, input = {}) {
socket.data = socket.data || {};
// Store only server-normalized percentages on the transport. The cookie is a
// browser preference, while permission and range enforcement remain here.
socket.data.audioAdjustments = normalizeAdjustments(input, 100);
pushLevelsForSocket(socket);
emitChange('user_gains_set', { scope: 'user', userId });
return getAudioGainStateForSocket(socket);
emitChange('personal_adjustments_set', { scope: 'socket', socketId: socket.id });
return getAudioAdjustmentStateForSocket(socket);
}
/*
The client needs all three layers to render an honest slider: its own stored
fraction, the ceiling that fraction is measured against, and the resolved gain
so the UI can show what the rover will actually play.
The client receives the percentages the server accepted, the permitted range,
and the resulting multipliers. This keeps the UI honest even when a cookie was
edited or an administrator changed permission while the browser was online.
*/
function getAudioGainStateForSocket(socket) {
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
const values = getUserGainsForSocket(socket);
const ceilings = getGainCeilings(hasBoost);
function getAudioAdjustmentStateForSocket(socket) {
const allowed = canUsePersonalAdjustments(socket);
const maximum = loadState().maxPersonalAdjustmentPercent;
const values = allowed ? getAdjustmentsForSocket(socket) : normalizeAdjustments({}, 0);
return {
values,
ceilings,
effective: applyCeilings(values, ceilings),
boostGranted: hasBoost,
adminLimits: getAdminLimits(),
boostCaps: getUserGainCaps(),
allowed,
maxAdjustmentPercent: maximum,
effective: applyAdjustments(getAdminLimits(), values),
baseLevels: getAdminLimits(),
};
}
@@ -311,6 +277,23 @@ setImmediate(() => {
}
});
identityEvents.on('change', ({ reason, userId } = {}) => {
if (!userId || !['permission_granted', 'permission_revoked', 'identify'].includes(reason)) return;
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) !== userId) return;
pushLevelsForSocket(socket);
// Permission changes alter both effective rover output and the controls the
// browser may use, so each affected connection receives a fresh session.
emitChange('personal_adjustment_permission_changed', { scope: 'socket', socketId: socket.id });
});
});
roleEvents.on('change', ({ socket } = {}) => {
// Administrators implicitly have this capability, so login/logout can change
// the effective adjustment even though no database permission row changed.
if (socket) pushLevelsForSocket(socket);
});
io.on('connection', (socket) => {
socket.on('audioLevels:get', (_, cb = () => {}) => {
cb({ success: true, levels: getAudioLevels() });
@@ -329,30 +312,30 @@ io.on('connection', (socket) => {
}
});
socket.on('audioLevels:setUserCaps', (payload = {}, cb = () => {}) => {
socket.on('audioLevels:setPersonalAdjustmentRange', (payload = {}, cb = () => {}) => {
try {
if (!isAdmin(socket)) {
throw new Error('Not authorized');
}
const actor = socket?.data?.user?.username || null;
const userGainCaps = setUserGainCaps(payload || {}, actor);
cb({ success: true, userGainCaps });
const maxPersonalAdjustmentPercent = setMaxPersonalAdjustmentPercent(payload?.maxAdjustmentPercent, actor);
cb({ success: true, maxPersonalAdjustmentPercent });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audioLevels:getUserGains', (_, cb = () => {}) => {
socket.on('audioLevels:getPersonalAdjustments', (_, cb = () => {}) => {
try {
cb({ success: true, audioGains: getAudioGainStateForSocket(socket) });
cb({ success: true, audioAdjustments: getAudioAdjustmentStateForSocket(socket) });
} catch (err) {
cb({ error: err.message });
}
});
socket.on('audioLevels:setUserGains', (payload = {}, cb = () => {}) => {
socket.on('audioLevels:setPersonalAdjustments', (payload = {}, cb = () => {}) => {
try {
cb({ success: true, audioGains: setUserGains(socket, payload || {}) });
cb({ success: true, audioAdjustments: setSocketAdjustments(socket, payload || {}) });
} catch (err) {
cb({ error: err.message });
}
@@ -362,17 +345,15 @@ io.on('connection', (socket) => {
loadState();
module.exports = {
GAIN_KEYS,
USER_GAIN_CAP_DEFAULTS,
ADJUSTMENT_FIELDS,
PERSONAL_ADJUSTMENT_PERMISSION,
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
getAudioLevels,
setAudioLevels,
getUserGainCaps,
setUserGainCaps,
getUserGains,
setUserGains,
getGainCeilingsForSocket,
setMaxPersonalAdjustmentPercent,
setSocketAdjustments,
getEffectiveLevelsForSocket,
getAudioGainStateForSocket,
getAudioAdjustmentStateForSocket,
pushLevelsToRover,
audioLevelsEvents,
};
@@ -401,6 +401,15 @@ function handleWorkerMessage(message = {}) {
updateStatus('starting', 'Starting Bluetooth discovery.');
} else if (workerState === 'discovering') {
updateStatus('waiting-for-sync', 'Press the red Sync button underneath the board.');
} else if (workerState === 'device-detected') {
// Preserve the worker's exact identification stage instead of leaving the
// panel apparently unchanged when an adapter sees only the board's address.
// This is intentionally not a feed alert because ambient unresolved devices
// can appear during commissioning and the state is already visible locally.
updateStatus(
'identifying',
message.error || 'Bluetooth device detected; checking whether it is the Balance Board.',
);
} else if (workerState === 'pairing') {
updateStatus('pairing', 'Board found. Pairing now.');
} else if (workerState === 'connected') {
@@ -73,17 +73,25 @@ constexpr uint16_t kMgmtCommandCompleteEvent = 0x0001;
constexpr uint16_t kMgmtCommandStatusEvent = 0x0002;
constexpr uint16_t kMgmtNewSettingsEvent = 0x0006;
constexpr uint16_t kMgmtPinCodeRequestEvent = 0x000e;
constexpr uint16_t kMgmtDeviceFoundEvent = 0x0012;
constexpr uint16_t kMgmtDiscoveringEvent = 0x0013;
constexpr uint16_t kMgmtPinCodeReplyCommand = 0x0016;
constexpr uint16_t kMgmtSetConnectableCommand = 0x0007;
constexpr uint16_t kMgmtSetFastConnectableCommand = 0x0008;
constexpr uint16_t kMgmtStartDiscoveryCommand = 0x0023;
constexpr uint16_t kMgmtStopDiscoveryCommand = 0x0024;
constexpr uint16_t kPrimaryControllerIndex = 0;
constexpr uint8_t kBluetoothClassicAddressType = 0;
constexpr uint8_t kBluetoothClassicDiscoveryMask = 1U << 0;
constexpr uint32_t kDeviceFoundLegacyPairingFlag = 1U << 1;
constexpr uint8_t kEirClassOfDeviceType = 0x0d;
constexpr uint32_t kBalanceBoardClassOfDevice = 0x00002504;
constexpr uint32_t kControllerConnectableSetting = 1U << 1;
constexpr uint32_t kControllerFastConnectableSetting = 1U << 2;
constexpr int kManagementCommandTimeoutMs = 2000;
constexpr int kFrameIntervalMs = 50;
constexpr int kDiscoveryRestartDelayMs = 1000;
constexpr const char* kDiscoveryTimeoutSeconds = "86400";
constexpr int kDiscoveryStartDeadlineMs = 5000;
constexpr uint16_t kHidControlPsm = 0x0011;
constexpr uint16_t kHidInterruptPsm = 0x0013;
constexpr int kCommissioningConnectWindowMs = 15000;
@@ -107,6 +115,16 @@ struct PairingSharedState {
std::optional<BluetoothAddress> active_target;
std::optional<BluetoothAddress> active_pin;
std::optional<std::string> commissioned_address;
// Discovery commands and events use the same kernel management socket as
// raw Wii PIN replies. The main thread owns socket reads while the
// commissioning thread consumes this small synchronized state, avoiding a
// second reader that could steal PIN or controller-setting events.
std::optional<BluetoothAddress> discovery_candidate;
std::string discovery_error;
bool discovery_start_pending = false;
bool discovery_stop_pending = false;
bool discovery_session_started = false;
bool discovery_active = false;
bool commissioning = false;
bool outbound_connection_requested = false;
};
@@ -123,13 +141,6 @@ struct CommandResult {
std::string output;
};
struct RunningCommand {
pid_t pid = -1;
int output_fd = -1;
std::string pending_output;
std::string transcript;
};
struct ManagementRuntimeState {
// Runtime reassertions are asynchronous so a temporary controller setting
// change cannot block PIN or HID handling. Track each outstanding opcode to
@@ -215,6 +226,24 @@ std::optional<BluetoothAddress> parse_address(const std::string& raw) {
return address;
}
BluetoothAddress address_from_management_wire(const uint8_t* wire) {
BluetoothAddress address;
if (!wire) return address;
// Management packets carry Bluetooth addresses least-significant byte first,
// while every BlueZ command and user-facing status expects the conventional
// most-significant-byte-first representation. Preserve both forms because
// the original wire bytes are later compared with the kernel PIN request.
std::copy(wire, wire + address.wire.size(), address.wire.begin());
char address_buffer[18]{};
std::snprintf(
address_buffer, sizeof(address_buffer), "%02X:%02X:%02X:%02X:%02X:%02X",
address.wire[5], address.wire[4], address.wire[3],
address.wire[2], address.wire[1], address.wire[0]);
address.display = address_buffer;
return address;
}
CommandResult run_command(const std::vector<std::string>& args) {
CommandResult result;
if (args.empty()) return result;
@@ -260,127 +289,22 @@ CommandResult run_command(const std::vector<std::string>& args) {
return result;
}
RunningCommand start_command(const std::vector<std::string>& args) {
RunningCommand command;
if (args.empty()) return command;
bool candidate_is_balance_board(const BluetoothAddress& address) {
const CommandResult info = run_command({
"bluetoothctl", "--timeout", "2", "info", address.display});
if (info.output.find(kBoardBluetoothName) != std::string::npos) return true;
int pipe_fds[2]{};
if (pipe(pipe_fds) != 0) {
command.transcript = std::strerror(errno);
return command;
}
const pid_t pid = fork();
if (pid == 0) {
dup2(pipe_fds[1], STDOUT_FILENO);
dup2(pipe_fds[1], STDERR_FILENO);
close(pipe_fds[0]);
close(pipe_fds[1]);
std::vector<char*> argv;
argv.reserve(args.size() + 1);
for (const auto& arg : args) argv.push_back(const_cast<char*>(arg.c_str()));
argv.push_back(nullptr);
execvp(argv[0], argv.data());
_exit(127);
}
close(pipe_fds[1]);
if (pid < 0) {
command.transcript = std::strerror(errno);
close(pipe_fds[0]);
return command;
}
// Discovery has no predetermined completion time: it must remain active until
// the user wakes the board. A nonblocking pipe lets the commissioning thread
// consume BlueZ events while still honoring server shutdown and maintenance
// commands promptly.
const int current_flags = fcntl(pipe_fds[0], F_GETFL, 0);
if (current_flags >= 0) fcntl(pipe_fds[0], F_SETFL, current_flags | O_NONBLOCK);
command.pid = pid;
command.output_fd = pipe_fds[0];
return command;
}
bool collect_command_output(RunningCommand* command) {
if (!command || command->pid < 0) return false;
std::array<char, 1024> buffer{};
ssize_t count = 0;
while ((count = read(command->output_fd, buffer.data(), buffer.size())) > 0) {
const std::string chunk(buffer.data(), static_cast<std::size_t>(count));
command->pending_output += chunk;
command->transcript += chunk;
// A busy Bluetooth environment can produce an unbounded stream of RSSI
// updates. Retain only the most recent output instead of allowing a
// commissioning session left open for days to grow the worker indefinitely.
constexpr std::size_t max_transcript_size = 8192;
if (command->transcript.size() > max_transcript_size) {
command->transcript.erase(0, command->transcript.size() - max_transcript_size);
}
}
int status = 0;
const pid_t waited = waitpid(command->pid, &status, WNOHANG);
if (waited == 0) return true;
if (waited == command->pid) {
command->pid = -1;
}
return false;
}
void stop_command(RunningCommand* command) {
if (!command) return;
if (command->pid > 0) {
// bluetoothctl normally exits immediately on SIGTERM. Bound that grace
// period so a wedged D-Bus client cannot prevent the server from stopping.
kill(command->pid, SIGTERM);
for (int attempt = 0; attempt < 50 && command->pid > 0; ++attempt) {
collect_command_output(command);
if (command->pid > 0) usleep(10000);
}
if (command->pid > 0) {
kill(command->pid, SIGKILL);
int status = 0;
while (waitpid(command->pid, &status, 0) < 0 && errno == EINTR) {}
command->pid = -1;
}
}
if (command->output_fd >= 0) {
close(command->output_fd);
command->output_fd = -1;
}
}
std::optional<BluetoothAddress> take_discovered_board(RunningCommand* discovery,
bool* discovery_started) {
if (!discovery) return std::nullopt;
std::size_t newline = discovery->pending_output.find('\n');
while (newline != std::string::npos) {
const std::string line = discovery->pending_output.substr(0, newline);
discovery->pending_output.erase(0, newline + 1);
// bluetoothctl reports filter setup before StartDiscovery completes. Treat
// only this explicit event as proof that button presses can now be seen;
// `SetDiscoveryFilter success` alone is not an active Bluetooth scan.
if (discovery_started && line.find("Discovery started") != std::string::npos) {
*discovery_started = true;
}
// A Classic device is initially announced by address and receives its name
// in a later change event. Parse every complete scan line so either BlueZ
// form works, but require the exact Nintendo board name before accepting an
// address. A nearby Wiimote must never become eligible for the raw PIN.
if (line.find(kBoardBluetoothName) != std::string::npos) {
const std::size_t device_prefix = line.find("Device ");
if (device_prefix != std::string::npos && line.size() >= device_prefix + 24) {
if (auto address = parse_address(line.substr(device_prefix + 7, 17))) return address;
}
}
newline = discovery->pending_output.find('\n');
}
return std::nullopt;
// Original Wii input devices identify as legacy-pairing gaming peripherals.
// This fallback is deliberately applied only to an address delivered by the
// kernel's legacy-pairing Device Found event during active commissioning.
// That physical red-Sync action is the selection boundary when an adapter
// cannot resolve Nintendo's remote name in time.
const bool gaming_peripheral =
info.output.find("Class: 0x00002504") != std::string::npos &&
info.output.find("Icon: input-gaming") != std::string::npos;
const bool legacy_pairing =
info.output.find("LegacyPairing: yes") != std::string::npos;
return gaming_peripheral && legacy_pairing;
}
std::string command_error_summary(const std::string& raw, const std::string& fallback) {
@@ -438,7 +362,11 @@ std::optional<BluetoothAddress> find_default_controller() {
return std::nullopt;
}
void commissioning_loop(PairingSharedState* shared) {
bool start_management_discovery(int fd, PairingSharedState* shared,
std::string* error);
void stop_management_discovery(int fd, PairingSharedState* shared);
void commissioning_loop(PairingSharedState* shared, int management_fd) {
while (running.load()) {
bool should_commission = false;
{
@@ -452,57 +380,62 @@ void commissioning_loop(PairingSharedState* shared) {
}
emit_status("commissioning");
// Commissioning must be listening before the board's short red-Sync window
// begins. Keep one BlueZ discovery client alive continuously and consume its
// own event stream. The previous bounded scan exited for twelve seconds at a
// time and then queried a second client, making successful discovery depend
// on when the physical button happened to be pressed.
// BlueZ's command-line client exits after the SetDiscoveryFilter callback
// unless non-interactive mode has a timeout. A one-day timeout keeps the
// client alive for unattended commissioning; the worker normally stops it
// itself as soon as the board appears and restarts it if the day expires.
RunningCommand discovery = start_command({
"bluetoothctl", "--timeout", kDiscoveryTimeoutSeconds, "scan", "bredr"});
if (discovery.pid < 0) {
emit_status("error", "", "could not start Bluetooth discovery: " +
command_error_summary(discovery.transcript, "unknown process error"));
// Discovery is deliberately performed through the kernel management
// socket already required for Wii PIN replies. Long-running bluetoothctl
// output proved version- and terminal-dependent on the production server;
// MGMT Device Found events are the stable interface underneath BlueZ and
// arrive on this socket without parsing human-oriented terminal output.
std::string discovery_error;
if (!start_management_discovery(
management_fd, shared, &discovery_error)) {
emit_status("error", "", "Bluetooth discovery could not start: " +
discovery_error);
std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs));
continue;
}
std::optional<BluetoothAddress> address;
bool discovery_started = false;
while (running.load() && !address.has_value()) {
const bool discovery_running = collect_command_output(&discovery);
const bool was_started = discovery_started;
address = take_discovered_board(&discovery, &discovery_started);
if (!was_started && discovery_started) {
// This status clears any prior scanner error and tells the browser that
// the server is genuinely listening for the board's red Sync button.
emit_status("discovering");
}
if (address.has_value()) break;
if (!discovery_running) {
const std::string detail = command_error_summary(
discovery.transcript, "bluetoothctl exited unexpectedly");
emit_status("error", "", discovery_started
? "Bluetooth scanner stopped unexpectedly; retrying automatically: " + detail
: "Bluetooth scanner exited before discovery started; retrying automatically: " + detail);
break;
}
std::optional<BluetoothAddress> candidate;
bool still_commissioning = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
still_commissioning = shared->commissioning &&
!shared->commissioned_address.has_value();
candidate = shared->discovery_candidate;
shared->discovery_candidate.reset();
discovery_error = shared->discovery_error;
}
if (!still_commissioning) break;
std::this_thread::sleep_for(std::chrono::milliseconds(50));
if (!discovery_error.empty()) {
emit_status("error", "", discovery_error);
break;
}
if (candidate.has_value()) {
emit_status("device-detected", candidate->display,
"Classic Bluetooth device detected; checking whether it is the Balance Board.");
// Class, icon, and legacy-pairing properties can arrive just after the
// first raw inquiry result. Retry that bounded local property lookup at
// quarter-second intervals while the board is awake; this replaces the
// old dependence on a later human-readable bluetoothctl change line.
// The exact identity gate remains mandatory, so an unrelated controller
// can never arm the privileged Wii PIN response.
for (int attempt = 0; attempt < 5 && !address.has_value(); ++attempt) {
if (candidate_is_balance_board(*candidate)) {
address = candidate;
break;
}
if (attempt < 4) {
std::this_thread::sleep_for(std::chrono::milliseconds(250));
}
}
}
std::this_thread::sleep_for(std::chrono::milliseconds(25));
}
if (!address.has_value()) {
stop_command(&discovery);
stop_management_discovery(management_fd, shared);
if (running.load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs));
}
@@ -511,8 +444,8 @@ void commissioning_loop(PairingSharedState* shared) {
const auto controller = find_default_controller();
if (!controller.has_value()) {
stop_command(&discovery);
emit_status("commissioning", address->display,
stop_management_discovery(management_fd, shared);
emit_status("error", address->display,
"no powered Bluetooth controller is available for pairing");
std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs));
continue;
@@ -533,10 +466,10 @@ void commissioning_loop(PairingSharedState* shared) {
// charge of everything else.
const CommandResult pair_result = run_command({
"bluetoothctl", "--timeout", "12", "--agent", "NoInputNoOutput", "pair", address->display});
// Keep the discovery owner alive through Pair(). BlueZ documents pairing by
// address as requiring an active scan report, and the board may stop its
// Sync window before a new discovery client could be established.
stop_command(&discovery);
// Keep kernel discovery alive through Pair(). BlueZ pairing by address
// requires the fresh device record, and the board's Sync window is too
// short to stop and recreate discovery before bonding begins.
stop_management_discovery(management_fd, shared);
{
std::lock_guard<std::mutex> lock(shared->mutex);
@@ -545,7 +478,7 @@ void commissioning_loop(PairingSharedState* shared) {
}
if (!command_succeeded(pair_result)) {
emit_status("commissioning", address->display,
emit_status("error", address->display,
"pairing failed: " + command_error_summary(
pair_result.output, "BlueZ returned an unknown pairing error"));
std::this_thread::sleep_for(std::chrono::milliseconds(kDiscoveryRestartDelayMs));
@@ -608,6 +541,38 @@ uint32_t read_u32_le(const uint8_t* input) {
(static_cast<uint32_t>(input[3]) << 24);
}
bool management_event_has_balance_board_class(const uint8_t* payload,
uint16_t payload_size) {
// Device Found has a fixed 14-byte prefix followed by standard EIR fields.
// Each field begins with a byte count that includes its one-byte type. Parse
// defensively because this data originates over the radio and a malformed
// length must never let commissioning inspect beyond the management packet.
constexpr std::size_t fixed_size = 14;
if (!payload || payload_size < fixed_size) return false;
const uint16_t eir_size = read_u16_le(payload + 12);
if (eir_size > payload_size - fixed_size) return false;
const uint8_t* eir = payload + fixed_size;
std::size_t offset = 0;
while (offset < eir_size) {
const uint8_t field_size = eir[offset];
if (field_size == 0) break;
if (offset + 1 + field_size > eir_size) return false;
const uint8_t field_type = eir[offset + 1];
const std::size_t data_size = field_size - 1;
if (field_type == kEirClassOfDeviceType && data_size >= 3) {
const uint32_t device_class =
static_cast<uint32_t>(eir[offset + 2]) |
(static_cast<uint32_t>(eir[offset + 3]) << 8) |
(static_cast<uint32_t>(eir[offset + 4]) << 16);
return device_class == kBalanceBoardClassOfDevice;
}
offset += 1 + field_size;
}
return false;
}
std::string management_status_description(uint8_t status) {
// These are the management statuses that setting controller modes can
// realistically return. Retain the numeric value as well because it remains
@@ -642,6 +607,112 @@ bool write_management_boolean_command(int fd, uint16_t opcode, bool enabled) {
static_cast<ssize_t>(packet.size());
}
bool write_management_discovery_command(int fd, uint16_t opcode) {
if (fd < 0) return false;
constexpr std::size_t header_size = 6;
std::array<uint8_t, header_size + 1> packet{};
write_u16_le(packet.data(), opcode);
write_u16_le(packet.data() + 2, kPrimaryControllerIndex);
write_u16_le(packet.data() + 4, 1);
// The Balance Board is a Classic Bluetooth device. Restricting discovery to
// BR/EDR avoids irrelevant LE advertisements and ensures every Device Found
// event uses the address type expected by the Wii pairing path.
packet[header_size] = kBluetoothClassicDiscoveryMask;
return write(fd, packet.data(), packet.size()) ==
static_cast<ssize_t>(packet.size());
}
bool start_management_discovery(int fd, PairingSharedState* shared,
std::string* error) {
if (fd < 0 || !shared) {
if (error) *error = "Bluetooth management socket is unavailable";
return false;
}
{
std::lock_guard<std::mutex> lock(shared->mutex);
shared->discovery_candidate.reset();
shared->discovery_error.clear();
shared->discovery_start_pending = true;
shared->discovery_stop_pending = false;
shared->discovery_session_started = false;
shared->discovery_active = false;
}
if (!write_management_discovery_command(fd, kMgmtStartDiscoveryCommand)) {
const std::string detail = "could not send Start Discovery: " +
std::string(std::strerror(errno));
{
std::lock_guard<std::mutex> lock(shared->mutex);
shared->discovery_start_pending = false;
shared->discovery_error = detail;
}
if (error) *error = detail;
return false;
}
// Command Complete proves the kernel accepted the session, while the
// Discovering event proves inquiry is actually active on the controller.
// Require both so the UI can never repeat the earlier false "listening"
// state where a process existed but no radio scan was running.
const uint64_t deadline = monotonic_ms() + kDiscoveryStartDeadlineMs;
while (running.load() && monotonic_ms() < deadline) {
std::string discovery_error;
bool ready = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
discovery_error = shared->discovery_error;
ready = shared->discovery_session_started && shared->discovery_active;
}
if (!discovery_error.empty()) {
if (error) *error = discovery_error;
return false;
}
if (ready) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
if (error) *error = "kernel accepted no active BR/EDR discovery session within 5 seconds";
stop_management_discovery(fd, shared);
return false;
}
void stop_management_discovery(int fd, PairingSharedState* shared) {
if (fd < 0 || !shared) return;
bool should_stop = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
should_stop = shared->discovery_start_pending ||
shared->discovery_session_started || shared->discovery_active;
shared->discovery_candidate.reset();
if (should_stop) shared->discovery_stop_pending = true;
}
if (!should_stop) return;
if (!write_management_discovery_command(fd, kMgmtStopDiscoveryCommand)) {
std::lock_guard<std::mutex> lock(shared->mutex);
shared->discovery_stop_pending = false;
shared->discovery_error = "could not send Stop Discovery: " +
std::string(std::strerror(errno));
return;
}
// Pairing retries should not collide with a previous inquiry session. Wait
// briefly for the matching command response, but never let a misbehaving
// adapter hold server shutdown or commissioning indefinitely.
const uint64_t deadline = monotonic_ms() + kManagementCommandTimeoutMs;
while (running.load() && monotonic_ms() < deadline) {
bool stopped = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
stopped = !shared->discovery_stop_pending &&
!shared->discovery_session_started;
}
if (stopped) return;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
bool set_management_boolean_and_wait(int fd, uint16_t opcode,
const std::string& setting_name,
std::string* error) {
@@ -758,16 +829,44 @@ void process_management_events(int fd, PairingSharedState* shared,
if (count < 6 + payload_size) continue;
if ((event == kMgmtCommandCompleteEvent || event == kMgmtCommandStatusEvent) &&
payload_size >= 3 && management) {
payload_size >= 3) {
const uint16_t opcode = read_u16_le(buffer.data() + 6);
const uint8_t status = buffer[8];
if (opcode == kMgmtStartDiscoveryCommand ||
opcode == kMgmtStopDiscoveryCommand) {
std::lock_guard<std::mutex> lock(shared->mutex);
if (opcode == kMgmtStartDiscoveryCommand) {
shared->discovery_start_pending = false;
if (status == 0) {
shared->discovery_session_started = true;
} else {
shared->discovery_session_started = false;
shared->discovery_active = false;
shared->discovery_error = "Start Discovery was rejected: " +
management_status_description(status);
}
} else {
shared->discovery_stop_pending = false;
if (status == 0) {
shared->discovery_start_pending = false;
shared->discovery_session_started = false;
shared->discovery_active = false;
} else {
shared->discovery_error = "Stop Discovery was rejected: " +
management_status_description(status);
}
}
continue;
}
bool recognized = false;
std::string setting_name;
if (opcode == kMgmtSetConnectableCommand) {
if (management && opcode == kMgmtSetConnectableCommand) {
management->connectable_pending = false;
recognized = true;
setting_name = "connectable setting";
} else if (opcode == kMgmtSetFastConnectableCommand) {
} else if (management && opcode == kMgmtSetFastConnectableCommand) {
management->fast_connectable_pending = false;
recognized = true;
setting_name = "fast connectable setting";
@@ -779,6 +878,50 @@ void process_management_events(int fd, PairingSharedState* shared,
continue;
}
if (event == kMgmtDiscoveringEvent && payload_size >= 2 &&
adapter_index == kPrimaryControllerIndex) {
const uint8_t address_types = buffer[6];
const bool active = buffer[7] != 0;
bool announce_discovery = false;
{
std::lock_guard<std::mutex> lock(shared->mutex);
if (shared->commissioning &&
(address_types & kBluetoothClassicDiscoveryMask) != 0) {
announce_discovery = active && !shared->discovery_active;
shared->discovery_active = active;
}
}
if (announce_discovery) emit_status("discovering");
continue;
}
if (event == kMgmtDeviceFoundEvent && payload_size >= 14 &&
adapter_index == kPrimaryControllerIndex) {
const uint8_t* payload = buffer.data() + 6;
const uint8_t address_type = payload[6];
const uint32_t flags = read_u32_le(payload + 8);
const bool balance_board_class =
management_event_has_balance_board_class(payload, payload_size);
// Some controllers provide the gaming-device class in the first inquiry
// result and add Legacy Pairing only after name resolution; others do the
// reverse. Either radio-level signal is narrow enough to justify the
// bounded BlueZ property check, while ordinary Classic devices never
// disturb the panel or launch repeated identity commands.
if (address_type == kBluetoothClassicAddressType &&
(balance_board_class ||
(flags & kDeviceFoundLegacyPairingFlag) != 0)) {
const BluetoothAddress candidate =
address_from_management_wire(payload);
std::lock_guard<std::mutex> lock(shared->mutex);
if (shared->commissioning &&
!shared->commissioned_address.has_value()) {
shared->discovery_candidate = candidate;
}
}
continue;
}
if (event == kMgmtNewSettingsEvent && payload_size >= 4 &&
adapter_index == kPrimaryControllerIndex && management) {
const uint32_t settings = read_u32_le(buffer.data() + 6);
@@ -1319,7 +1462,8 @@ int main() {
std::thread commission_thread;
std::thread connection_thread;
if (bluetooth_startup_ready) {
commission_thread = std::thread(commissioning_loop, &pairing);
commission_thread = std::thread(
commissioning_loop, &pairing, management_fd);
connection_thread = std::thread(direct_connection_loop, &pairing, boards);
}
std::thread input_thread(stdin_loop, &pairing);
@@ -22,6 +22,9 @@ function createButtonBoxCore(deps) {
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
setGreenMode,
isGreenModeEnabled,
onGreenModeChange,
store,
} = deps;
@@ -215,6 +218,11 @@ function createButtonBoxCore(deps) {
setHomeAssistantEntityState(entityId, state, { source: 'buttonBoxReward' }),
setHomeAssistantLightsLockedOn: (next, options = {}) =>
setHomeAssistantLightsLockedOn(next, options),
// Rewards receive the standalone feature boundary rather than reaching
// into Home Assistant or duplicating green-mode state and alerts.
setGreenMode: (next, options = {}) => setGreenMode(next, options),
isGreenModeEnabled: () => isGreenModeEnabled(),
onGreenModeChange: (listener) => onGreenModeChange(listener),
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
};
@@ -24,6 +24,7 @@ const { isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver');
const { createButtonBoxStore } = require('./store');
const { createButtonBoxCore } = require('./core');
const { registerButtonBoxRoute } = require('./httpRoute');
const greenModeService = require('../greenModeService');
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('buttonbox-state.json');
@@ -61,6 +62,14 @@ const core = createButtonBoxCore({
getHomeAssistantState,
setHomeAssistantEntityState,
setHomeAssistantLightsLockedOn,
setGreenMode: greenModeService.setEnabled,
isGreenModeEnabled: greenModeService.isEnabled,
// Return an explicit cleanup function so timed rewards can stop observing
// the global service when they expire, rerun, or are recovered.
onGreenModeChange: (listener) => {
greenModeService.greenModeEvents.on('change', listener);
return () => greenModeService.greenModeEvents.off('change', listener);
},
store,
});
+12 -35
View File
@@ -10,6 +10,7 @@ const { getNickname } = require('../nicknameService');
const { getGlobalObjective, setGlobalObjective, clearGlobalObjective } = require('../globalObjectiveService');
const { getAdminReason, setAdminReason, clearAdminReason } = require('../adminReasonService');
const homeAssistantService = require('../homeAssistantService');
const greenModeService = require('../greenModeService');
const liftService = require('../liftService');
const neatoService = require('../neatoService');
const { isFeatureEnabled } = require('../../helpers/features');
@@ -22,16 +23,17 @@ const {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
} = require('../verificationService');
const {
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
} = require('../identityService');
const { publishEvent } = require('../eventBus');
const assignmentService = require('../assignmentService');
const funStatsService = require('../funStatsService');
const { loadConfig } = require('../../helpers/configLoader');
const { createCommandHandlers } = require('../operatorCommandService');
const { createCooldownGate } = require('../operatorCommandService/cooldowns');
const { parseCommandText } = require('../operatorCommandService/config');
const { createWebTransportHandlers } = require('../operatorCommandService/webTransport');
const { commandReplyToText } = require('./commandResultFormatter');
@@ -44,14 +46,6 @@ const {
const config = loadConfig();
const discordConfig = config.discord || {};
/*
Site chat builds a fresh command router for every message so each router can
close over the sending socket. Fun command cooldowns therefore have to live out
here: a gate created inside the router would be thrown away after one message
and would never actually rate limit anything.
*/
const commandCooldowns = createCooldownGate();
function isTextCommand(text) {
return parseCommandText(text, config).matched;
}
@@ -133,12 +127,6 @@ function createChatCommandRequest({ socket, text, sendSystemMessage }) {
actor: {
bot: false,
id: socket.id,
/*
Fun command tallies are keyed by identity rather than connection, so the
canonical user id is passed alongside the socket id. Without it a user's
bonk count would reset on every reconnect and split across browser tabs.
*/
userId: String(socket?.data?.userId || '').trim() || null,
label: nickname,
isAdmin: isAdmin(socket),
isLockdownAdmin: isLockdownAdmin(socket),
@@ -187,6 +175,7 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
// lights lock/unlock` from becoming transport-specific, and it preserves
// the existing session update path for all connected browsers.
homeAssistantService,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
@@ -203,23 +192,11 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
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
reason replayEngineV2 is: commandService registers socket handlers on load,
and chatService should not pull that forward in the boot order.
*/
getActorSocket: () => socket,
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
sendToChannel: null,
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
isLockdownAdminUser: (id) => String(id) === String(socket.id) && isLockdownAdmin(socket),
+13 -26
View File
@@ -46,23 +46,24 @@ const {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
} = require('../verificationService');
const {
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
} = require('../identityService');
const {
attachDmMessage: attachPrivateAccessDmMessage,
getRequestByMessageId: getPrivateAccessRequestByMessageId,
approveRequest: approvePrivateAccessRequest,
denyRequest: denyPrivateAccessRequest,
} = require('../privateRoverAccessRequestService');
const { subscribe, publishEvent } = require('../eventBus');
const funStatsService = require('../funStatsService');
const { issueCommand } = require('../commandService');
const { subscribe } = require('../eventBus');
const { createPresenceManager } = require('./presence');
const { createChannelIO } = require('./channelIO');
const { createCommandHandlers } = require('../operatorCommandService');
const { createCooldownGate } = require('../operatorCommandService/cooldowns');
const greenModeService = require('../greenModeService');
const { createDiscordTransportHandlers, createDiscordCommandRequest } = require('./commandAdapter');
const { createIntegrations } = require('./integrations');
const { createFleetDailyReports } = require('./fleetDailyReports');
@@ -212,10 +213,6 @@ if (discordConfig?.channels?.replay) {
});
}
// The Discord router is built once for the process, so one gate here covers every
// guild and channel this bot answers in.
const commandCooldowns = createCooldownGate();
const commandDependencies = {
logger,
client,
@@ -244,6 +241,7 @@ const commandDependencies = {
// service into the shared command router keeps Discord and mirrored web-chat
// command behavior aligned without duplicating Home Assistant calls here.
homeAssistantService,
greenModeService,
liftService,
neatoService,
isFeatureEnabled,
@@ -260,22 +258,11 @@ const commandDependencies = {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
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,
counter, and read-only fun commands work normally from here.
*/
getActorSocket: () => null,
issueCommand,
sendToChannel: channelIO.sendToChannel,
isAdminUser,
isLockdownAdminUser,
@@ -6,10 +6,12 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
/*
Every client-side BrowserRouter entry point must also be an explicit HTTP
entry point. Including /ptz here lets direct loads and browser refreshes
receive the same rendered index document as navigation from the driver page.
entry point. Keeping this list aligned with webui/src/main.jsx lets direct
loads and browser refreshes receive the same rendered index document as
in-app navigation. The retired desktop composition is intentionally exposed
at /old; the removed /newdrive route is intentionally absent.
*/
app.get(['/', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports'], async (req, res) => {
try {
const html = await renderIndexHtml(req);
res.type('html').send(html);
@@ -1,178 +0,0 @@
// Fun Stats Service
// Purpose: Persists the running counters behind the social `rs` fun commands.
// Scope: Owns storage and clamping only; command handlers decide what a counter means.
const fs = require('fs');
const path = require('path');
const logger = require('../../globals/logger').child('funStatsService');
const { resolveDataPath } = require('../../helpers/dataPaths');
const STORE_PATH = resolveDataPath('fun-stats.json');
// Counters are additive and never authoritative for anything but bragging
// rights, so the ceiling only exists to keep a runaway loop from writing an
// unbounded integer into the store.
const MAX_COUNT = 1_000_000;
const MAX_LABEL_LENGTH = 64;
const ACTOR_COUNTERS = [
'bonksGiven',
'bonksTaken',
'hugsGiven',
'hugsTaken',
'slapsGiven',
'slapsTaken',
];
/*
This service deliberately keeps its own tiny JSON store rather than reusing
identityService.createJsonStore. Fun counters are keyed by an actor key that
spans transports (`user:<id>` for site chat, `discord:<id>` for Discord), and
a Discord id has no row in `users`, so it cannot live in `user_feature_state`
without violating that table's foreign key. Keeping storage local also means
the counters can be unit tested without opening the identity database.
*/
let cache = null;
function clampCount(value) {
const count = Number(value);
if (!Number.isFinite(count) || count <= 0) return 0;
return Math.min(Math.floor(count), MAX_COUNT);
}
function normalizeLabel(value) {
const label = String(value || '').trim().replace(/\s+/g, ' ');
if (!label) return null;
return label.slice(0, MAX_LABEL_LENGTH);
}
function normalizeActor(raw = {}) {
const actor = { label: normalizeLabel(raw.label) };
ACTOR_COUNTERS.forEach((key) => {
actor[key] = clampCount(raw[key]);
});
actor.updatedAt = Number.isFinite(raw.updatedAt) ? raw.updatedAt : null;
return actor;
}
function normalizeStore(raw = {}) {
const actors = {};
const rawActors = raw && typeof raw.actors === 'object' && raw.actors ? raw.actors : {};
Object.keys(rawActors).forEach((key) => {
const actorKey = String(key || '').trim();
if (!actorKey) return;
actors[actorKey] = normalizeActor(rawActors[actorKey]);
});
const rovers = {};
const rawRovers = raw && typeof raw.rovers === 'object' && raw.rovers ? raw.rovers : {};
Object.keys(rawRovers).forEach((key) => {
const roverId = String(key || '').trim();
if (!roverId) return;
const entry = rawRovers[roverId] || {};
rovers[roverId] = {
pets: clampCount(entry.pets),
updatedAt: Number.isFinite(entry.updatedAt) ? entry.updatedAt : null,
};
});
return { actors, rovers };
}
function loadState() {
if (cache) return cache;
try {
cache = normalizeStore(JSON.parse(fs.readFileSync(STORE_PATH, 'utf8')));
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalizeStore({});
}
return cache;
}
function persistState(next) {
const normalized = normalizeStore(next);
try {
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8');
fs.renameSync(tempPath, STORE_PATH);
} catch (err) {
// A failed write must not break the command that triggered it. The joke
// still lands; only the tally is lost.
logger.warn('Failed to persist fun stats store', { path: STORE_PATH, error: err.message });
}
cache = normalized;
return cache;
}
function getActorStats(actorKey) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
return { ...(loadState().actors[key] || normalizeActor({})) };
}
/*
`patch` is a map of counter name to increment. Unknown counter names are
ignored rather than stored so a typo in a handler cannot quietly create a
parallel counter that never shows up on the leaderboard.
*/
function bumpActorStats(actorKey, { label = null, ...patch } = {}) {
const key = String(actorKey || '').trim();
if (!key) return normalizeActor({});
const state = loadState();
const current = state.actors[key] || normalizeActor({});
const next = { ...current };
const resolvedLabel = normalizeLabel(label);
if (resolvedLabel) next.label = resolvedLabel;
ACTOR_COUNTERS.forEach((counter) => {
const delta = Number(patch[counter]);
if (!Number.isFinite(delta) || delta === 0) return;
next[counter] = clampCount(current[counter] + delta);
});
next.updatedAt = Date.now();
persistState({ ...state, actors: { ...state.actors, [key]: next } });
return { ...next };
}
function listActorStats() {
const { actors } = loadState();
return Object.keys(actors).map((actorKey) => ({ actorKey, ...actors[actorKey] }));
}
function bumpRoverPets(roverId, by = 1) {
const id = String(roverId || '').trim();
if (!id) return 0;
const state = loadState();
const current = state.rovers[id] || { pets: 0, updatedAt: null };
const delta = Number(by);
const next = {
pets: clampCount(current.pets + (Number.isFinite(delta) ? delta : 0)),
updatedAt: Date.now(),
};
persistState({ ...state, rovers: { ...state.rovers, [id]: next } });
return next.pets;
}
function getRoverPets(roverId) {
const id = String(roverId || '').trim();
if (!id) return 0;
return loadState().rovers[id]?.pets || 0;
}
// Tests drive the store through a temporary SERVER_DATA_DIR, so they need a way
// to drop the module-level cache between cases.
function resetCacheForTests() {
cache = null;
}
module.exports = {
ACTOR_COUNTERS,
STORE_PATH,
getActorStats,
bumpActorStats,
listActorStats,
bumpRoverPets,
getRoverPets,
resetCacheForTests,
};
@@ -1,131 +0,0 @@
// Fun Stats Service Tests
// Purpose: Verifies counter persistence, clamping, and that a corrupt store degrades instead of throwing.
// Scope: Runs against a temporary SERVER_DATA_DIR so the real data directory is never touched.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fun-stats-test-'));
process.env.SERVER_DATA_DIR = dataDir;
const funStatsService = require('./index');
function reset() {
try {
fs.rmSync(funStatsService.STORE_PATH, { force: true });
} catch {
// A missing store is the normal starting state.
}
funStatsService.resetCacheForTests();
}
test('counters start at zero for an unknown actor', () => {
reset();
const stats = funStatsService.getActorStats('user:nobody');
assert.equal(stats.bonksGiven, 0);
assert.equal(stats.bonksTaken, 0);
assert.equal(stats.label, null);
});
test('bumping a counter accumulates and records the label', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
const stats = funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
assert.equal(stats.bonksGiven, 2);
assert.equal(stats.label, 'alice');
});
test('counters are independent of one another', () => {
reset();
funStatsService.bumpActorStats('user:alice', { bonksGiven: 3, hugsGiven: 1 });
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.bonksGiven, 3);
assert.equal(stats.hugsGiven, 1);
assert.equal(stats.slapsGiven, 0);
});
test('an unrecognized counter name is ignored rather than silently stored', () => {
reset();
funStatsService.bumpActorStats('user:alice', { notACounter: 5 });
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.notACounter, undefined);
});
test('state survives a cold read from disk', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 7 });
funStatsService.resetCacheForTests();
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 7);
});
test('an empty actor key is refused so anonymous bumps cannot share a bucket', () => {
reset();
funStatsService.bumpActorStats('', { bonksGiven: 1 });
assert.deepEqual(funStatsService.listActorStats(), []);
});
test('rover pets accumulate per rover', () => {
reset();
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 1);
assert.equal(funStatsService.bumpRoverPets('rover-1', 1), 2);
assert.equal(funStatsService.bumpRoverPets('rover-2', 1), 1);
assert.equal(funStatsService.getRoverPets('rover-1'), 2);
assert.equal(funStatsService.getRoverPets('unknown'), 0);
});
test('listActorStats returns every actor with their key', () => {
reset();
funStatsService.bumpActorStats('user:alice', { label: 'alice', bonksGiven: 1 });
funStatsService.bumpActorStats('discord:4242', { label: 'dave', bonksGiven: 2 });
const keys = funStatsService.listActorStats().map((row) => row.actorKey).sort();
assert.deepEqual(keys, ['discord:4242', 'user:alice']);
});
test('negative and non-numeric deltas cannot drive a counter below zero', () => {
reset();
funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 });
funStatsService.bumpActorStats('user:alice', { bonksGiven: -50 });
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
funStatsService.bumpActorStats('user:alice', { bonksGiven: Number.NaN });
assert.equal(funStatsService.getActorStats('user:alice').bonksGiven, 0);
});
test('labels are trimmed and length capped', () => {
reset();
const stats = funStatsService.bumpActorStats('user:alice', { label: ` ${'x'.repeat(200)} `, bonksGiven: 1 });
assert.equal(stats.label.length, 64);
});
test('a corrupt store file degrades to empty instead of throwing', () => {
reset();
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
fs.writeFileSync(funStatsService.STORE_PATH, '{not json at all', 'utf8');
funStatsService.resetCacheForTests();
assert.deepEqual(funStatsService.listActorStats(), []);
// And it must still be writable afterwards.
assert.equal(funStatsService.bumpActorStats('user:alice', { bonksGiven: 1 }).bonksGiven, 1);
});
test('a store with the wrong shape is normalized rather than trusted', () => {
reset();
fs.mkdirSync(path.dirname(funStatsService.STORE_PATH), { recursive: true });
fs.writeFileSync(
funStatsService.STORE_PATH,
JSON.stringify({ actors: { 'user:alice': { bonksGiven: 'lots', label: 42 } }, rovers: 'nope' }),
'utf8',
);
funStatsService.resetCacheForTests();
const stats = funStatsService.getActorStats('user:alice');
assert.equal(stats.bonksGiven, 0);
assert.equal(stats.label, '42');
assert.equal(funStatsService.getRoverPets('rover-1'), 0);
});
test.after(() => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
@@ -0,0 +1,101 @@
// Green Mode Service
// Purpose: Owns the temporary server-wide green visual mode and its tiny light workflow.
// Scope: Composes existing Home Assistant operations; it does not add policy to that service.
const EventEmitter = require('events');
const logger = require('../../globals/logger').child('greenModeService');
const { sendAlert } = require('../alertService');
const homeAssistantService = require('../homeAssistantService');
const { modeEvents } = require('../modeManager');
const GREEN_MODE_COLOR = '#00ff00';
const greenModeEvents = new EventEmitter();
let enabled = false;
function isEnabled() {
return enabled;
}
async function setEnabled(nextValue, options = {}) {
const next = Boolean(nextValue);
if (enabled === next) return enabled;
if (next && homeAssistantService.enabled) {
/*
Lock first because the existing locked-on transition sets lights white.
Recoloring RGB lights afterward leaves them green while retaining the
established room-control lock, idle protection, and laser safety rules.
*/
await homeAssistantService.setLightsLockedOn(true, {
source: String(options?.source || 'greenMode:enable'),
});
const entities = homeAssistantService.getState()?.entities || [];
/*
RGB-capable lights become the requested solid green. Every other
configured room control, including white-only bulbs and switches, is
explicitly turned off so the physical room has one unambiguous effect.
These remain generic Home Assistant calls; that service does not know
that the operations belong to green mode.
*/
const results = await Promise.allSettled(
entities.map((entity) => (
entity?.supportsColor
? homeAssistantService.setLightColor(entity.id, GREEN_MODE_COLOR)
: homeAssistantService.setEntityState(entity.id, 'off', {
source: 'greenMode:non-rgb-off',
})
)),
);
const failures = results
.map((result, index) => ({ result, entityId: entities[index].id }))
.filter(({ result }) => result.status === 'rejected')
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
if (failures.length) {
logger.warn('Some room controls failed to enter green mode', { failures });
}
} else if (!next && homeAssistantService.enabled) {
// Disabling the visual mode simply releases the lock it created. Bulb
// colors remain untouched, matching the existing one-shot light behavior.
await homeAssistantService.setLightsLockedOn(false, {
source: String(options?.source || 'greenMode:disable'),
});
}
/*
Home Assistant is deliberately optional here. When it is not configured,
skipping the physical-room operations still allows the session theme,
CardFrame styling, alerts, commands, and timed reward to work normally.
The integration's generic lock state is also left untouched because there
are no server-managed room controls to lock.
*/
enabled = next;
logger.info('Green mode changed', {
enabled,
source: options?.source || 'unknown',
});
// Emit one shared server alert for every completed transition. Automatic
// access-mode shutdown uses this same function, so clients also receive the
// inactive notice when green mode ends without an explicit chat command.
sendAlert({
color: GREEN_MODE_COLOR,
title: 'Green mode',
message: enabled ? 'Green mode is active.' : 'Green mode is inactive.',
});
greenModeEvents.emit('change', enabled);
return enabled;
}
modeEvents.on('change', () => {
if (!enabled) return;
setEnabled(false, { source: 'modeGateReset' }).catch((err) => {
logger.warn('Failed to disable green mode on access-mode change', err.message);
});
});
module.exports = {
isEnabled,
setEnabled,
greenModeEvents,
};
@@ -80,6 +80,27 @@ function normalizeRgbColor(color) {
function createRuntimeEngine(deps) {
const { logger, enabled, haConfig, callHomeAssistantService } = deps;
async function turnOnLightAtFullBrightness(entityId, serviceData = {}) {
/*
Every server-owned interaction that turns on or changes a light must
also restore it to full brightness. Home Assistant remembers a bulb's
previous brightness, so sending only a color or color temperature can
otherwise make a light appear unexpectedly dim even though this service
requested an on-state.
Keeping this rule in one helper makes it apply consistently to ordinary
on commands, RGB changes, white-temperature changes, bulk operations,
random scenes, and lock-on behavior. brightness_pct is deliberately
written after the caller's service data so future call sites cannot
accidentally override the service-wide 100 percent requirement.
*/
await callHomeAssistantService('light', 'turn_on', {
entity_id: entityId,
...serviceData,
brightness_pct: 100,
});
}
function emitUpdate(getState) {
events.emit('update', getState());
}
@@ -143,7 +164,14 @@ function createRuntimeEngine(deps) {
const domain = String(meta.domain || (meta.type === 'light' ? 'light' : 'switch')).toLowerCase();
const service = nextState === 'on' ? 'turn_on' : 'turn_off';
const source = String(options?.source || 'unknown');
await callHomeAssistantService(domain, service, { entity_id: entityId });
if (domain === 'light' && service === 'turn_on') {
await turnOnLightAtFullBrightness(entityId);
} else {
// Off commands and non-light domains do not accept a meaningful light
// brightness value, so their existing Home Assistant payload stays
// intentionally unchanged.
await callHomeAssistantService(domain, service, { entity_id: entityId });
}
logger.info('Issued Home Assistant command', { entityId, domain, service, source });
}
@@ -467,7 +495,7 @@ function createRuntimeEngine(deps) {
if (!runtime.connection) throw new Error('Home Assistant not connected');
const normalized = normalizeRgbColor(color);
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, rgb_color: normalized });
await turnOnLightAtFullBrightness(entityId, { rgb_color: normalized });
logger.info('Issued Home Assistant color command', { entityId, rgbColor: normalized });
}
@@ -481,7 +509,7 @@ function createRuntimeEngine(deps) {
const normalizedKelvin = Number.isFinite(nextKelvin)
? Math.max(2000, Math.min(6500, Math.round(nextKelvin)))
: DEFAULT_WHITE_KELVIN;
await callHomeAssistantService('light', 'turn_on', { entity_id: entityId, color_temp_kelvin: normalizedKelvin });
await turnOnLightAtFullBrightness(entityId, { color_temp_kelvin: normalizedKelvin });
logger.info('Issued Home Assistant white command', { entityId, colorTempKelvin: normalizedKelvin });
}
@@ -0,0 +1,83 @@
// Home Assistant Runtime Engine Tests
// Purpose: Verifies the service-wide full-brightness rule for light commands.
// Scope: Exercises injected Home Assistant calls without opening a real connection or starting the server.
const assert = require('node:assert/strict');
const test = require('node:test');
const { createRuntimeEngine } = require('./runtimeEngine');
const { entityConfig, entityState, runtime } = require('./state');
function createHarness() {
const calls = [];
const engine = createRuntimeEngine({
enabled: true,
haConfig: { whiteKelvin: 4000 },
callHomeAssistantService: async (domain, service, serviceData) => {
calls.push({ domain, service, serviceData });
},
// These tests only verify outbound service payloads. A no-op logger keeps
// the harness faithful to the runtime dependency contract without adding
// unrelated output to the test run.
logger: {
info() {},
warn() {},
},
});
return { calls, engine };
}
test('light interactions force full brightness without changing switches or off commands', async (t) => {
const { calls, engine } = createHarness();
/*
runtimeEngine uses the shared entity registry populated from configuration
in production. Seed the smallest representative registry here and restore
the shared state afterward so this focused unit test cannot leak state into
other Home Assistant tests added later.
*/
entityConfig.clear();
entityState.clear();
entityConfig.set('light.room', { id: 'light.room', type: 'light', domain: 'light' });
entityConfig.set('switch.lamp', { id: 'switch.lamp', type: 'switch', domain: 'switch' });
runtime.connection = {};
t.after(() => {
entityConfig.clear();
entityState.clear();
runtime.connection = null;
});
await engine.setEntityState('light.room', 'on');
await engine.setLightColor('light.room', [12, 34, 56]);
await engine.setLightWhite('light.room', 4500);
await engine.setEntityState('light.room', 'off');
await engine.setEntityState('switch.lamp', 'on');
assert.deepEqual(calls, [
{
domain: 'light',
service: 'turn_on',
serviceData: { entity_id: 'light.room', brightness_pct: 100 },
},
{
domain: 'light',
service: 'turn_on',
serviceData: { entity_id: 'light.room', rgb_color: [12, 34, 56], brightness_pct: 100 },
},
{
domain: 'light',
service: 'turn_on',
serviceData: { entity_id: 'light.room', color_temp_kelvin: 4500, brightness_pct: 100 },
},
{
domain: 'light',
service: 'turn_off',
serviceData: { entity_id: 'light.room' },
},
{
domain: 'switch',
service: 'turn_on',
serviceData: { entity_id: 'switch.lamp' },
},
]);
});
@@ -12,6 +12,8 @@ const {
setVerified,
setDeterrence,
setMuted,
setUserPermission,
listRegisteredPermissions,
setFeatureState,
deleteFeatureState,
} = require('../identityService');
@@ -71,6 +73,11 @@ function ackHandler(socket, eventName, handler) {
io.on('connection', (socket) => {
ackHandler(socket, 'identityAdmin:listUsers', () => ({
users: listUsersForAdmin(),
permissions: listRegisteredPermissions(),
}));
ackHandler(socket, 'identityAdmin:listPermissions', () => ({
permissions: listRegisteredPermissions(),
}));
ackHandler(socket, 'identityAdmin:getUser', ({ userId }) => {
@@ -112,6 +119,14 @@ io.on('connection', (socket) => {
}).id),
}));
ackHandler(socket, 'identityAdmin:setPermission', ({ userId, permissionKey, enabled }) => ({
user: getUserForAdmin(setUserPermission(userId, permissionKey, {
enabled: Boolean(enabled),
actor: socket?.data?.user?.username || socket.id,
at: Date.now(),
}).id),
}));
ackHandler(socket, 'identityAdmin:updateFeatureState', ({ userId, namespace, value }) => {
const normalized = normalizeFeaturePayload(namespace, value);
setFeatureState(userId, normalized.namespace, normalized.value);
+83 -50
View File
@@ -10,6 +10,7 @@ const Database = require('better-sqlite3');
const { getSocketIp, normalizeIp } = require('../../helpers/ipResolver');
const { resolveDataPath } = require('../../helpers/dataPaths');
const logger = require('../../globals/logger').child('identityService');
const { listRegisteredPermissions, requireRegisteredPermission } = require('./permissions');
const COOKIE_USER_ID_RE = /^cu_[a-f0-9]{32}$/;
const FINGERPRINT_ID_RE = /^tm_[a-z0-9_-]{8,256}$/;
@@ -17,7 +18,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
const DB_PATH = resolveDataPath('identity.sqlite');
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
const STORE_VERSION = 3;
const STORE_VERSION = 4;
const identityEvents = new EventEmitter();
let db = null;
@@ -169,10 +170,7 @@ function ensureSchema(conn) {
deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text,
audio_gain_boost_enabled integer not null default 0,
audio_gain_boost_at integer,
audio_gain_boost_by text
muted_by text
);
create table if not exists verification_requests (
@@ -208,6 +206,15 @@ function ensureSchema(conn) {
primary key (user_id, namespace)
);
create table if not exists user_permissions (
user_id text not null references users(id) on delete cascade,
permission_key text not null,
granted_at integer not null,
granted_by text,
primary key (user_id, permission_key)
);
create index if not exists idx_user_permissions_key on user_permissions(permission_key);
create table if not exists legacy_imports (
source text not null,
legacy_id text not null,
@@ -223,8 +230,9 @@ function ensureSchema(conn) {
/*
SQLite's `create table if not exists` leaves an existing table untouched.
Add the mute columns explicitly for installations created before store
version 2, and the audio gain boost columns for those created before store
version 3. The column-name check keeps every later startup idempotent.
version 2. Permission grants now live in their own normalized table, so the
obsolete audio-specific status columns are deliberately removed instead of
carrying old grants into the new capability system.
*/
const statusColumns = new Set(
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
@@ -238,15 +246,13 @@ function ensureSchema(conn) {
if (!statusColumns.has('muted_by')) {
conn.exec('alter table user_status add column muted_by text');
}
if (!statusColumns.has('audio_gain_boost_enabled')) {
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
}
if (!statusColumns.has('audio_gain_boost_at')) {
conn.exec('alter table user_status add column audio_gain_boost_at integer');
}
if (!statusColumns.has('audio_gain_boost_by')) {
conn.exec('alter table user_status add column audio_gain_boost_by text');
}
['audio_gain_boost_enabled', 'audio_gain_boost_at', 'audio_gain_boost_by'].forEach((column) => {
if (statusColumns.has(column)) conn.exec(`alter table user_status drop column ${column}`);
});
// Old personal fractions were identity-backed feature state. The replacement
// is intentionally browser-local, so retaining these unreachable rows would
// make the database page imply that they still control runtime behavior.
conn.prepare('delete from user_feature_state where namespace = ?').run('audioGains');
}
function createUser(conn = getDb(), ts = nowMs()) {
@@ -291,6 +297,13 @@ function mergeUsers(conn, targetUserId, sourceUserId) {
conn.prepare('delete from user_known_ips where user_id = ?').run(sourceUserId);
conn.prepare('update verification_requests set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
conn.prepare('update legacy_imports set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
/*
Permissions describe the person, not one browser signal. Merging identities
therefore unions their grants before the source user is deleted; a conflict
keeps the target row and its original audit metadata.
*/
conn.prepare('update or ignore user_permissions set user_id = ? where user_id = ?').run(targetUserId, sourceUserId);
conn.prepare('delete from user_permissions where user_id = ?').run(sourceUserId);
const sourceStatus = conn.prepare('select * from user_status where user_id = ?').get(sourceUserId);
ensureUserStatus(conn, targetUserId);
@@ -431,7 +444,6 @@ function setSocketIdentityState(socket, user, identity = {}) {
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
socket.data.isMuted = Boolean(user.deterrence?.muted);
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
}
function identifySocket(socket, payload = {}) {
@@ -523,11 +535,7 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
mutedAt: status.muted_at || null,
mutedBy: status.muted_by || null,
},
audioGainBoost: {
enabled: Boolean(status.audio_gain_boost_enabled),
at: status.audio_gain_boost_at || null,
by: status.audio_gain_boost_by || null,
},
permissions: getUserPermissions(id, { conn }),
features,
};
}
@@ -746,47 +754,76 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
}
/*
The audio gain boost flag lets a trusted VIP raise their personal horn/TTS/mic
gain ceiling past the global admin gain settings. It stays a status column
rather than feature state so it can be filtered in SQL alongside the other
moderation flags and copied onto the socket at identify time.
Positive capabilities use normalized rows rather than feature-specific status
columns. This keeps moderation state focused and gives future permissions the
same audited grant/revoke path without another schema alteration.
*/
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
function setUserPermission(userId, permissionKey, { enabled = true, actor = null, at = nowMs() } = {}) {
const id = String(userId || '').trim();
if (!id) throw new Error('userId required');
ensureUserStatus(getDb(), id);
getDb().prepare(`
update user_status
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
where user_id = ?
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
const permission = requireRegisteredPermission(permissionKey);
const conn = getDb();
if (!conn.prepare('select 1 from users where id = ?').get(id)) throw new Error('User not found.');
if (enabled) {
conn.prepare(`
insert into user_permissions (user_id, permission_key, granted_at, granted_by)
values (?, ?, ?, ?)
on conflict(user_id, permission_key) do update set granted_at = excluded.granted_at, granted_by = excluded.granted_by
`).run(id, permission.key, at, actor ? String(actor) : null);
} else {
conn.prepare('delete from user_permissions where user_id = ? and permission_key = ?').run(id, permission.key);
}
identityEvents.emit('change', {
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
reason: enabled ? 'permission_granted' : 'permission_revoked',
userId: id,
permissionKey: permission.key,
});
conn.prepare('update users set updated_at = ? where id = ?').run(at, id);
return getUserById(id);
}
function getUserPermissions(userId, { conn = getDb() } = {}) {
const id = String(userId || '').trim();
if (!id) return [];
return conn.prepare(`
select permission_key as key, granted_at as grantedAt, granted_by as grantedBy
from user_permissions
where user_id = ?
order by permission_key
`).all(id);
}
function hasUserPermission(userId, permissionKey, { conn = getDb() } = {}) {
const id = String(userId || '').trim();
const permission = requireRegisteredPermission(permissionKey);
if (!id) return false;
return Boolean(conn.prepare('select 1 from user_permissions where user_id = ? and permission_key = ?').get(id, permission.key));
}
function listUsersWithPermission(permissionKey) {
const permission = requireRegisteredPermission(permissionKey);
const conn = getDb();
return conn.prepare('select user_id from user_permissions where permission_key = ? order by granted_at desc')
.all(permission.key)
.map((row) => getUserById(row.user_id, { conn, includeFeatures: false }))
.filter(Boolean);
}
function isVerified(socket) {
return Boolean(socket?.data?.isVerified);
}
function hasAudioGainBoost(socket) {
return Boolean(socket?.data?.hasAudioGainBoost);
}
function isDeterred(socket) {
return Boolean(socket?.data?.isDeterred);
}
function listUsers({ verified = null, deterred = null, muted = null, audioGainBoost = null } = {}) {
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
const conn = getDb();
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
const where = [];
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
if (audioGainBoost !== null) where.push(`user_status.audio_gain_boost_enabled = ${audioGainBoost ? 1 : 0}`);
if (where.length) sql += ` where ${where.join(' and ')}`;
sql += ' order by users.updated_at desc';
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
@@ -808,9 +845,7 @@ function userToLegacyIdentityEntry(user) {
muted: Boolean(user.deterrence?.muted),
mutedAt: user.deterrence?.mutedAt || null,
mutedBy: user.deterrence?.mutedBy || null,
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
audioGainBoostAt: user.audioGainBoost?.at || null,
audioGainBoostBy: user.audioGainBoost?.by || null,
permissions: (user.permissions || []).map((permission) => permission.key),
};
}
@@ -826,10 +861,6 @@ function listMutedUsers() {
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
}
function listAudioGainBoostUsers() {
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
}
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
const value = String(selector || '').trim();
if (!value) return { error: 'selector_required' };
@@ -1084,14 +1115,16 @@ module.exports = {
setVerified,
setDeterrence,
setMuted,
setAudioGainBoost,
setUserPermission,
getUserPermissions,
hasUserPermission,
listUsersWithPermission,
listRegisteredPermissions,
isVerified,
isDeterred,
hasAudioGainBoost,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
listAudioGainBoostUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
createJsonStore,
@@ -0,0 +1,30 @@
// Identity Permission Registry
// Purpose: Defines every positive capability that can be granted to a canonical user.
// Scope: Keeps stable database keys and operator-facing descriptions centralized so services and admin tools cannot invent mismatched permission names.
const USER_PERMISSIONS = Object.freeze({
'audio.personalAdjustment': Object.freeze({
key: 'audio.personalAdjustment',
commandName: 'audio-adjustment',
label: 'Personal audio adjustment',
description: 'Allows personal horn, text-to-speech, and microphone volume adjustments.',
}),
});
function listRegisteredPermissions() {
return Object.values(USER_PERMISSIONS).map((permission) => ({ ...permission }));
}
function requireRegisteredPermission(permissionKey) {
const key = String(permissionKey || '').trim().toLowerCase();
const permission = Object.values(USER_PERMISSIONS).find((entry) => (
entry.key.toLowerCase() === key || entry.commandName.toLowerCase() === key
));
if (!permission) throw new Error('Unknown user permission.');
return permission;
}
module.exports = {
USER_PERMISSIONS,
listRegisteredPermissions,
requireRegisteredPermission,
};
@@ -0,0 +1,82 @@
// Identity Permission Storage Tests
// Purpose: Verifies normalized grants, registry validation, and the intentionally empty replacement for legacy audio boost flags.
// Scope: Uses an isolated temporary data directory and never opens the development identity database.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'rover-identity-permissions-'));
process.env.SERVER_DATA_DIR = testDataDir;
/*
Seed the exact legacy concern this redesign removes. Opening identityService
must preserve the real moderation fields while dropping all old boost grants
instead of translating them into the new permission table.
*/
const legacyDb = new Database(path.join(testDataDir, 'identity.sqlite'));
legacyDb.exec(`
create table users (
id text primary key,
created_at integer not null,
updated_at integer not null,
last_seen_at integer
);
create table user_status (
user_id text primary key references users(id) on delete cascade,
verified_enabled integer not null default 0,
verified_at integer,
verified_by text,
deterrence_enabled integer not null default 0,
deterrence_reason text,
deterrence_at integer,
deterrence_by text,
muted_enabled integer not null default 0,
muted_at integer,
muted_by text,
audio_gain_boost_enabled integer not null default 0,
audio_gain_boost_at integer,
audio_gain_boost_by text
);
`);
legacyDb.close();
const identityService = require('./index');
test.after(() => {
identityService.getDb().close();
fs.rmSync(testDataDir, { recursive: true, force: true });
});
test('legacy audio boost columns are removed and normalized permissions are created empty', () => {
const db = identityService.getDb();
const statusColumns = db.prepare('pragma table_info(user_status)').all().map((column) => column.name);
assert.doesNotMatch(statusColumns.join(','), /audio_gain_boost/);
assert.ok(db.prepare("select 1 from sqlite_master where type = 'table' and name = 'user_permissions'").get());
});
test('registered permissions can be granted, listed, queried, and revoked', () => {
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_11111111111111111111111111111111' });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
identityService.setUserPermission(userId, 'audio-adjustment', { enabled: true, actor: 'test-admin', at: 1234 });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), true);
assert.deepEqual(identityService.getUserPermissions(userId), [{
key: 'audio.personalAdjustment',
grantedAt: 1234,
grantedBy: 'test-admin',
}]);
assert.equal(identityService.listUsersWithPermission('audio-adjustment')[0].id, userId);
identityService.setUserPermission(userId, 'audio.personalAdjustment', { enabled: false });
assert.equal(identityService.hasUserPermission(userId, 'audio.personalAdjustment'), false);
});
test('unknown permission keys cannot be persisted', () => {
const userId = identityService.resolveUserIdForIdentity({ cookieUserId: 'cu_22222222222222222222222222222222' });
assert.throws(
() => identityService.setUserPermission(userId, 'made.up.permission', { enabled: true }),
/Unknown user permission/,
);
});
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const yaml = require('js-yaml');
const { resolveDataPath } = require('../../helpers/dataPaths');
const { buildMediaMtxConfig } = require('./config');
function createMediaMtxSupervisor(deps) {
@@ -13,7 +14,7 @@ function createMediaMtxSupervisor(deps) {
serverPort,
logger,
mediaMtxBin = process.env.MEDIAMTX_BIN || '/usr/local/bin/mediamtx',
runtimeDir = process.env.MULTIROVER_RUNTIME_DIR || '/run/multirover',
configPath = resolveDataPath('mediamtx.yml'),
snapshotWriterPath = process.env.ROVER_SNAPSHOT_WRITER_BIN || '/usr/local/bin/rover-snapshot-writer.sh',
spawnProcess = spawn,
} = deps;
@@ -39,10 +40,14 @@ function createMediaMtxSupervisor(deps) {
function start() {
if (child) return child;
const runtimeConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
const configPath = path.join(runtimeDir, 'mediamtx.yml');
fs.mkdirSync(runtimeDir, { recursive: true, mode: 0o750 });
fs.writeFileSync(configPath, yaml.dump(runtimeConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
const generatedConfig = buildMediaMtxConfig({ config, serverPort, snapshotWriterPath });
/*
Generated MediaMTX state belongs beside the server's other owned data. Using the shared
data-path helper honors SERVER_DATA_DIR as well as the normal server/data directory and
avoids introducing a systemd-created /run directory with separate permission rules.
*/
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, yaml.dump(generatedConfig, { noRefs: true, lineWidth: 120 }), { mode: 0o640 });
logger.info(`Starting MediaMTX with generated config ${configPath}`);
child = spawnProcess(mediaMtxBin, [configPath], {
+94
View File
@@ -9,6 +9,7 @@ const { isFeatureEnabled } = require('../../helpers/features');
const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager');
const { isAdmin, isLockdownAdmin } = require('../roleService');
const { sendAlert } = require('../alertService');
const {
homeAssistantEvents,
getRawEntitySnapshot,
@@ -31,6 +32,11 @@ function normalizeDeviceName(value) {
const device = normalizeDeviceName(neatoConfig.device);
const RESUME_DELAY_MS = 3000;
const ALERT_COLOR = '#a855f7';
// BrainSlug exposes these exact select values for Gen 3 robots. Keeping the
// allowlist on the server prevents arbitrary Home Assistant select options from
// being submitted by a modified browser while preserving BrainSlug's casing.
const NAVIGATION_MODES = Object.freeze(['Normal', 'Gentle', 'Deep', 'Quick']);
function entityId(domain, suffix) {
if (!device) return '';
@@ -61,8 +67,27 @@ const ENTITY_IDS = {
robotError: entityId('sensor', 'robot_error'),
robotAlert: entityId('sensor', 'robot_alert'),
},
selects: {
navigationMode: entityId('select', 'navigation_mode'),
},
};
// Alert Feed coverage is intentionally limited to the raw robot lifecycle and
// issue fields requested for Neato. Battery and charger telemetry poll often and
// would create noise without representing a useful robot status transition.
const ALERT_ENTITIES = Object.freeze([
{ title: 'Neato UI state', entityId: ENTITY_IDS.textSensors.uiState },
{ title: 'Neato robot state', entityId: ENTITY_IDS.textSensors.robotState },
{ title: 'Neato robot alert', entityId: ENTITY_IDS.textSensors.robotAlert },
{ title: 'Neato robot error', entityId: ENTITY_IDS.textSensors.robotError },
{ title: 'Neato external power', entityId: ENTITY_IDS.binarySensors.extPowerPresent },
]);
// Each entity establishes its own baseline because ESPHome entities can become
// available on different snapshots. A Map also distinguishes "not observed yet"
// from a legitimate raw state string without inventing a sentinel state value.
const alertBaselines = new Map();
function readRaw(entityIdValue) {
if (!entityIdValue) return null;
return getRawEntitySnapshot(entityIdValue);
@@ -94,6 +119,32 @@ function isEntityAvailable(entityIdValue) {
return state !== 'unavailable';
}
function emitRawStateAlerts() {
for (const { title, entityId: entityIdValue } of ALERT_ENTITIES) {
const raw = readState(entityIdValue);
const normalized = String(raw ?? '').trim().toLowerCase();
// Missing and unavailable values commonly occur while Home Assistant or the
// ESPHome device reconnects. Ignoring them preserves the last real baseline
// and prevents connection churn from becoming misleading Neato activity.
if (!normalized || normalized === 'unavailable' || normalized === 'unknown') continue;
const rawMessage = String(raw);
if (!alertBaselines.has(entityIdValue)) {
// The first real value is startup state, not a transition caused while the
// service was watching, so record it without creating an Alert Feed toast.
alertBaselines.set(entityIdValue, rawMessage);
continue;
}
if (alertBaselines.get(entityIdValue) === rawMessage) continue;
alertBaselines.set(entityIdValue, rawMessage);
// The title provides field context, while the message remains exactly the
// new Home Assistant state with no friendly translation or previous value.
sendAlert({ color: ALERT_COLOR, title, message: rawMessage });
}
}
function requiredEntityIds() {
return [
ENTITY_IDS.buttons.start,
@@ -146,6 +197,14 @@ function buildState() {
entityId: ENTITY_IDS.buttons.powerCycle,
available: hasEntity(ENTITY_IDS.buttons.powerCycle),
},
navigationMode: {
entityId: ENTITY_IDS.selects.navigationMode,
available: isEntityAvailable(ENTITY_IDS.selects.navigationMode),
value: readState(ENTITY_IDS.selects.navigationMode),
// The browser receives the supported choices through the session contract
// instead of duplicating BrainSlug-specific values in the presentation layer.
options: NAVIGATION_MODES,
},
};
const batteryPercentValue = parseNumber(readState(ENTITY_IDS.sensors.batteryPercent));
@@ -199,6 +258,7 @@ if (featureEnabled) {
*/
homeAssistantEvents.on('snapshot', () => {
emitUpdate();
emitRawStateAlerts();
});
homeAssistantEvents.on('status', () => {
@@ -255,6 +315,29 @@ async function powerCycle() {
await pressButton(ENTITY_IDS.buttons.powerCycle, 'powercucle');
}
async function setNavigationMode(mode) {
assertConfiguredAndConnected();
const normalizedMode = String(mode || '').trim();
if (!NAVIGATION_MODES.includes(normalizedMode)) {
throw new Error('Invalid Neato navigation mode');
}
if (!isEntityAvailable(ENTITY_IDS.selects.navigationMode)) {
throw new Error('Neato action unavailable: navigation_mode');
}
// ESPHome implements Navigation Mode as a Home Assistant select entity, so
// select_option is the native service call and avoids sending raw UART commands.
await callHomeAssistantService('select', 'select_option', {
entity_id: ENTITY_IDS.selects.navigationMode,
option: normalizedMode,
});
logger.info('Issued Neato action', {
action: 'set_navigation_mode',
entityId: ENTITY_IDS.selects.navigationMode,
option: normalizedMode,
});
}
function getState() {
cachedState = buildState();
return cachedState;
@@ -328,6 +411,16 @@ if (featureEnabled) {
cb({ error: err.message });
}
});
socket.on('neato:setNavigationMode', async ({ mode } = {}, cb = () => {}) => {
try {
assertFeatureAccess();
await setNavigationMode(mode);
cb({ success: true });
} catch (err) {
cb({ error: err.message });
}
});
});
} else {
logger.info('Neato disabled by config');
@@ -342,5 +435,6 @@ module.exports = {
locateRobot,
clearErrors,
powerCycle,
setNavigationMode,
neatoEvents: events,
};
@@ -1,174 +0,0 @@
// Operator Fun Command Helpers
// Purpose: Shared actor identity, target lookup, and deterministic randomness for the fun commands.
// Scope: No side effects; every function here is safe to call before permission checks pass.
const { normalizeSearchText, normalizeText, resolveRoverSelector } = require('./resolvers');
// Echoed user text is capped so a fun command cannot be used to shout a wall of
// text into every bridged Discord channel.
const MAX_ECHO_LENGTH = 180;
const PLAIN_MENTIONS = { parse: [], repliedUser: false };
/*
Fun counters have to survive across transports, so they are keyed by a stable
identity rather than a connection. Site chat resolves to the identity user id
that moderation already uses; Discord has no row in that database, so it gets
its own key space. An unidentified site socket falls back to its socket id,
which means its tally resets on reconnect acceptable for a joke counter, and
much better than crediting every anonymous visitor to one shared bucket.
*/
function buildActorKey(request) {
const transport = normalizeText(request?.transport) || 'unknown';
if (transport === 'discord') {
const discordId = normalizeText(request?.actor?.id);
return discordId ? `discord:${discordId}` : null;
}
const userId = normalizeText(request?.actor?.userId);
if (userId) return `user:${userId}`;
const socketId = normalizeText(request?.actor?.id);
return socketId ? `socket:${socketId}` : null;
}
function actorLabel(request) {
return normalizeText(request?.actor?.label) || 'someone';
}
/*
Collapses every connected socket for one person onto their canonical user id so
extra browser tabs cannot make a target look ambiguous. Mirrors the same
approach the deter command uses for moderation targets.
*/
function findOnlineUsers(io, getNickname, selector) {
const normalizedSelector = normalizeSearchText(selector);
if (!normalizedSelector) return [];
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return [];
const byUserId = new Map();
sockets.forEach((socket) => {
const nickname = getNickname?.(socket);
if (normalizeSearchText(nickname) !== normalizedSelector) return;
const userId = normalizeText(socket?.data?.userId);
const key = userId || `socket:${normalizeText(socket?.id)}`;
if (!key) return;
if (!byUserId.has(key)) {
byUserId.set(key, { userId: userId || null, nickname: normalizeText(nickname), socket });
}
});
return Array.from(byUserId.values());
}
/*
A fun command should still work when the target is not a real user bonking
"the dishwasher" is half the point. So an unmatched selector is not an error:
it becomes a plain label and simply credits nobody's tally. Ambiguity is
treated the same way, because guessing which of two identical nicknames took
the hit would be worse than crediting neither.
*/
function resolveFunTarget({ io, getNickname, selector }) {
const label = clampEcho(selector);
if (!label) return null;
const matches = findOnlineUsers(io, getNickname, selector);
if (matches.length === 1) {
const [match] = matches;
return {
label: match.nickname || label,
actorKey: match.userId ? `user:${match.userId}` : null,
socket: match.socket || null,
online: true,
};
}
return { label, actorKey: null, socket: null, online: false };
}
/*
Rover-scoped fun commands accept an explicit rover name and otherwise fall back
to whichever rover the caller is already attached to. Discord has no socket
behind it, so the fallback simply is not available there and the caller is asked
to name a rover rather than having one chosen for them.
*/
function createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix = 'rs' }) {
return function resolveTargetRover(selector, action = 'pet') {
const query = normalizeText(selector);
if (query) {
const resolved = resolveRoverSelector(query, rovers);
if (resolved.error) return { error: resolved.error };
return { id: resolved.id, name: resolved.label || resolved.id, record: resolved.record };
}
const socket = getActorSocket?.() || null;
if (!socket) return { error: `Name a rover: \`${commandPrefix} ${action} <rover>\`` };
// getPrimaryRoverForSocket takes a socket id and returns a rover id string.
const roverId = normalizeText(roverManager?.getPrimaryRoverForSocket?.(socket.id));
if (!roverId) return { error: 'You are not on a rover right now. Name one instead.' };
const record = rovers.get(roverId) || null;
return { id: roverId, name: record?.meta?.name || roverId, record, socket };
};
}
function clampEcho(value) {
const text = normalizeText(value).replace(/\s+/g, ' ');
if (!text) return '';
if (text.length <= MAX_ECHO_LENGTH) return text;
return `${text.slice(0, MAX_ECHO_LENGTH - 1)}`;
}
/*
FNV-1a. Fun commands that judge something `ship`, `rate`, `8ball` use a
hash of the input instead of Math.random so the same question always gets the
same answer. Re-rolling until you like the verdict is not funny; a server that
stubbornly insists your ship rating is 4% is.
*/
function hashSeed(value) {
const text = normalizeSearchText(value);
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash >>> 0;
}
function pickBySeed(list, seed) {
const items = Array.isArray(list) ? list : [];
if (!items.length) return null;
return items[seed % items.length];
}
// Order-independent so `rs ship a b` and `rs ship b a` agree with each other.
function pairSeed(left, right) {
const pair = [normalizeSearchText(left), normalizeSearchText(right)].sort();
return hashSeed(pair.join(' '));
}
function percentFromSeed(seed) {
return seed % 101;
}
function ordinal(count) {
const value = Number(count) || 0;
const mod100 = value % 100;
if (mod100 >= 11 && mod100 <= 13) return `${value}th`;
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[value % 10] || 'th';
return `${value}${suffix}`;
}
module.exports = {
MAX_ECHO_LENGTH,
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
clampEcho,
createRoverResolver,
findOnlineUsers,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
};
@@ -1,162 +0,0 @@
// Operator Fun Helper Tests
// Purpose: Locks down actor identity, target resolution, and the deterministic seeding the fun commands depend on.
// Scope: Pure helpers plus in-memory socket doubles; nothing here touches the fun stats store.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildActorKey,
clampEcho,
createRoverResolver,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
MAX_ECHO_LENGTH,
} = require('./funHelpers');
function socket(id, userId, nickname) {
return { id, data: { userId, nickname } };
}
function harness(sockets = []) {
return {
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
getNickname: (entry) => entry?.data?.nickname || '',
};
}
test('site chat keys on the identity user id, not the socket', () => {
assert.equal(
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1', userId: 'u-alice' } }),
'user:u-alice',
);
});
test('an unidentified site socket falls back to its socket id', () => {
assert.equal(
buildActorKey({ transport: 'web-chat', actor: { id: 'socket-1' } }),
'socket:socket-1',
);
});
test('discord actors get their own key space so ids cannot collide with identity ids', () => {
assert.equal(buildActorKey({ transport: 'discord', actor: { id: '4242' } }), 'discord:4242');
});
test('an actor with no usable id at all is rejected rather than sharing a bucket', () => {
assert.equal(buildActorKey({ transport: 'web-chat', actor: {} }), null);
assert.equal(buildActorKey({ transport: 'discord', actor: {} }), null);
});
test('a single online nickname resolves to that user and credits their tally', () => {
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'BOB' });
assert.equal(resolved.label, 'bob');
assert.equal(resolved.actorKey, 'user:u-bob');
assert.equal(resolved.online, true);
});
test('multiple tabs for one person do not make the target ambiguous', () => {
const { io, getNickname } = harness([
socket('s1', 'u-bob', 'bob'),
socket('s2', 'u-bob', 'bob'),
]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
assert.equal(resolved.actorKey, 'user:u-bob');
});
test('an unmatched selector still works but credits nobody', () => {
const { io, getNickname } = harness([socket('s1', 'u-bob', 'bob')]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'the dishwasher' });
assert.equal(resolved.label, 'the dishwasher');
assert.equal(resolved.actorKey, null);
assert.equal(resolved.online, false);
});
test('two different people sharing a nickname credit neither', () => {
const { io, getNickname } = harness([
socket('s1', 'u-bob', 'bob'),
socket('s2', 'u-other', 'bob'),
]);
const resolved = resolveFunTarget({ io, getNickname, selector: 'bob' });
assert.equal(resolved.actorKey, null);
});
test('echoed text is length capped so a fun command cannot shout a wall of text', () => {
const long = 'a'.repeat(500);
const clamped = clampEcho(long);
assert.equal(clamped.length, MAX_ECHO_LENGTH);
assert.ok(clamped.endsWith('…'));
});
test('ship is order independent so both spellings agree', () => {
assert.equal(pairSeed('alice', 'bob'), pairSeed('bob', 'alice'));
});
test('seeded verdicts are stable, so a rating cannot be rerolled by asking again', () => {
const first = percentFromSeed(pairSeed('alice', 'bob'));
const second = percentFromSeed(pairSeed('alice', 'bob'));
assert.equal(first, second);
assert.ok(first >= 0 && first <= 100);
});
test('hashSeed ignores case and surrounding whitespace', () => {
assert.equal(hashSeed(' Will It Dock '), hashSeed('will it dock'));
});
test('pickBySeed stays in range and tolerates an empty list', () => {
const list = ['a', 'b', 'c'];
for (let seed = 0; seed < 20; seed += 1) {
assert.ok(list.includes(pickBySeed(list, seed)));
}
assert.equal(pickBySeed([], 5), null);
});
test('ordinal handles the teens correctly', () => {
assert.equal(ordinal(1), '1st');
assert.equal(ordinal(2), '2nd');
assert.equal(ordinal(3), '3rd');
assert.equal(ordinal(4), '4th');
assert.equal(ordinal(11), '11th');
assert.equal(ordinal(12), '12th');
assert.equal(ordinal(13), '13th');
assert.equal(ordinal(21), '21st');
assert.equal(ordinal(111), '111th');
});
test('an explicit rover name wins over whatever the caller is attached to', () => {
const rovers = new Map([
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
]);
const resolve = createRoverResolver({
rovers,
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
getActorSocket: () => ({ id: 's1' }),
});
assert.equal(resolve('Roomba Two').id, 'rover-2');
});
test('with no rover named the caller\'s current rover is used', () => {
const rovers = new Map([['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }]]);
const resolve = createRoverResolver({
rovers,
roverManager: { getPrimaryRoverForSocket: (socketId) => (socketId === 's1' ? 'rover-1' : null) },
getActorSocket: () => ({ id: 's1' }),
});
const resolved = resolve('');
assert.equal(resolved.id, 'rover-1');
assert.equal(resolved.name, 'Roomba One');
});
test('without a socket the caller is asked to name a rover instead of one being chosen', () => {
const resolve = createRoverResolver({
rovers: new Map(),
roverManager: {},
getActorSocket: () => null,
commandPrefix: 'rs',
});
assert.match(resolve('', 'pet').error, /Name a rover/);
});
@@ -1,285 +0,0 @@
// Operator Fun Rover Commands
// Purpose: Implements the fun commands that actually make the fleet or the room do something (honk, boo, disco, spin, vibecheck).
// Scope: Every handler here re-checks control and feature gating itself, because issueCommand bypasses the socket command guards.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const {
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
createRoverResolver,
hashSeed,
pickBySeed,
resolveFunTarget,
} = require('./funHelpers');
// Durations are deliberately short and are also bounded rover-side: roverd
// enforces its own horn MaxDuration, so a lost stop command cannot leave a horn
// sounding forever.
const HONK_MS = 600;
const HONK_FREQ_HZ = 440;
const SPIN_MS = 1200;
const SPIN_SPEED = 120;
const DISCO_MS = 12 * 1000;
const DISCO_TICK_MS = 750;
const HONK_ACTOR_COOLDOWN_MS = 20 * 1000;
const HONK_ROVER_COOLDOWN_MS = 8 * 1000;
const BOO_COOLDOWN_MS = 30 * 1000;
const SPIN_COOLDOWN_MS = 25 * 1000;
const DISCO_COOLDOWN_MS = 2 * 60 * 1000;
const VIBECHECK_COOLDOWN_MS = 5 * 1000;
/*
Taunts are a fixed list rather than caller-supplied text on purpose. `boo` puts
audio out of a speaker in a room full of people, so letting it read arbitrary
input would turn a joke command into an unmoderated TTS channel aimed at
whoever is nearest the rover.
*/
const BOO_TAUNTS = [
'Boo.', 'Your driving is being reviewed.', 'That was a choice.',
'The wall was right there.', 'Someone in chat is laughing at you.',
'I have seen better parking from the Neato.', 'Boo. Respectfully.',
'This is a citizen\'s arrest.', 'Turn left. No, the other left.',
];
const VIBE_VERDICTS = [
'immaculate', 'acceptable', 'questionable', 'concerning', 'dire', 'unwell',
];
function describeBattery(batteryState) {
const display = Number(batteryState?.percentDisplay);
if (Number.isFinite(display)) return `${Math.round(display)}%`;
const percent = Number(batteryState?.percent);
if (Number.isFinite(percent)) return `${Math.round(percent * 100)}%`;
return 'unknown';
}
function createFunRoverCommands({
io,
rovers,
roverManager,
getNickname,
getActiveDrivers,
getActorSocket,
issueCommand,
homeAssistantService,
isFeatureEnabled,
sanitizeMentions,
cooldowns,
logger,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
/*
issueCommand is the raw rover transport: it performs none of the ownership,
deterrence, or private-safety checks that the socket `command` handler applies.
Any fun command that moves hardware therefore has to prove control here, which
also means these commands are inherently site-chat only a Discord message has
no socket and so can never satisfy canDrive.
*/
function requireDriveControl(action, selector) {
const socket = getActorSocket?.() || null;
if (!socket) {
return { error: `\`${commandPrefix} ${action}\` only works from site chat, where you can actually be driving.` };
}
const rover = resolveTargetRover(selector, action);
if (rover.error) return { error: rover.error };
if (!roverManager?.canDrive?.(rover.id, socket)) {
return { error: `You need control of ${rover.name} to do that.` };
}
return { rover, socket };
}
function safeIssue(roverId, payload, context) {
try {
issueCommand(roverId, payload);
return true;
} catch (err) {
// Deferred stop commands routinely land after a rover drops off. That is
// expected, not an incident, so it is logged at debug volume and swallowed.
logger?.warn?.('Fun command could not reach rover', { roverId, context, error: err.message });
return false;
}
}
async function handleHonk(message, tokens = []) {
const control = requireDriveControl('honk', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover } = control;
if (rover.record?.meta?.horn?.enabled === false) {
return reply(message, `${rover.name} has no horn fitted.`);
}
const gated = gate(message, 'honk', HONK_ACTOR_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
// A second, rover-scoped window stops a group of drivers taking turns to
// honk the same rover continuously while each stays inside their own limit.
const roverWait = cooldowns.consume(`honk:rover:${rover.id}`, HONK_ROVER_COOLDOWN_MS);
if (roverWait > 0) {
return reply(message, `${rover.name} was just honked. Give it ${describeWait(roverWait)}.`);
}
if (!safeIssue(rover.id, { type: 'horn', horn: { action: 'start', waveform: 'sine', freqs: [HONK_FREQ_HZ] } }, 'honk:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'horn', horn: { action: 'stop' } }, 'honk:stop'), HONK_MS);
return reply(message, `📢 HONK. (${rover.name})`);
}
async function handleSpin(message, tokens = []) {
const control = requireDriveControl('spin', tokens.join(' '));
if (control.error) return reply(message, control.error);
const { rover, socket } = control;
const gated = gate(message, 'spin', SPIN_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const maxWheelSpeed = Number(rover.record?.meta?.maxWheelSpeed);
const speed = Math.max(1, Math.min(SPIN_SPEED, Number.isFinite(maxWheelSpeed) && maxWheelSpeed > 0 ? maxWheelSpeed : SPIN_SPEED));
let driveDirect = { left: speed, right: -speed };
/*
Private rovers can carry a reduced speed ceiling that the socket path would
normally apply. Applying it explicitly keeps a fun command from being the one
way to exceed a limit an admin set for a specific rover.
*/
const safeDrive = roverManager?.applyPrivateDriveSafety?.(rover.id, socket, driveDirect);
if (safeDrive) driveDirect = safeDrive;
if (!safeIssue(rover.id, { type: 'drive', driveDirect }, 'spin:start')) {
return reply(message, `${rover.name} is offline.`);
}
setTimeout(() => safeIssue(rover.id, { type: 'drive', driveDirect: { left: 0, right: 0 } }, 'spin:stop'), SPIN_MS);
return reply(message, `🌀 ${rover.name} is doing a spin.`);
}
async function handleBoo(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
const gated = gate(message, 'boo', BOO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const resolved = resolveFunTarget({ io, getNickname, selector });
if (!resolved) return reply(message, `Usage: \`${commandPrefix} boo <user>\``);
if (!resolved.online || !resolved.socket) {
return reply(message, `${resolved.label} is not here to be booed.`);
}
// Boo lands on the rover the target is actually driving, so it needs the
// active-driver map rather than merely which rovers they are watching.
const drivers = getActiveDrivers?.() || {};
const roverId = Object.keys(drivers).find((id) => drivers[id] === resolved.socket.id) || null;
if (!roverId) return reply(message, `${resolved.label} is not driving anything right now.`);
const record = rovers.get(String(roverId));
const roverName = record?.meta?.name || roverId;
if (record?.meta?.audio?.ttsEnabled === false) {
return reply(message, `${roverName} cannot speak.`);
}
const taunt = pickBySeed(BOO_TAUNTS, hashSeed(`${gated.actorKey}:${resolved.label}`));
if (!safeIssue(roverId, { type: 'tts', tts: { text: taunt, speak: true, engine: 'chromegtts' } }, 'boo')) {
return reply(message, `${roverName} is offline.`);
}
return reply(message, `👻 Booed ${resolved.label} through ${roverName}.`);
}
async function handleDisco(message) {
if (!homeAssistantService || !isFeatureEnabled?.('homeAssistant')) {
return reply(message, 'Room light controls are unavailable.');
}
// An admin lock on the room lights is a policy boundary. Disco is a scene
// change like `rs lights on`, so it must not be the one command that ignores it.
const lightPolicy = homeAssistantService.getLightPolicyState?.() || {};
if (lightPolicy.locked) {
return reply(message, 'Room lights are locked. No disco.');
}
const gated = gate(message, 'disco', DISCO_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const setAll = homeAssistantService.setAllControllableEntitiesState;
if (typeof setAll !== 'function') {
return reply(message, 'Room light controls are unavailable.');
}
const endsAt = Date.now() + DISCO_MS;
let on = false;
/*
Held in a local interval rather than the rewards effect store because a
disco is short and disposable. Nothing needs to survive a restart, and the
final tick always restores the lights to on.
*/
const timer = setInterval(() => {
if (Date.now() >= endsAt) {
clearInterval(timer);
Promise.resolve(setAll('on')).catch((err) => {
logger?.warn?.('Disco could not restore lights', { error: err.message });
});
return;
}
on = !on;
Promise.resolve(setAll(on ? 'on' : 'off')).catch((err) => {
logger?.warn?.('Disco tick failed', { error: err.message });
});
}, DISCO_TICK_MS);
return reply(message, `🪩 Disco for ${Math.round(DISCO_MS / 1000)} seconds. Started by ${gated.label}.`);
}
async function handleVibecheck(message, tokens = []) {
const gated = gate(message, 'vibecheck', VIBECHECK_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'vibecheck');
if (rover.error) return reply(message, rover.error);
const record = rover.record || rovers.get(rover.id) || null;
const battery = describeBattery(record?.batteryState);
const offline = !record?.ws;
const locked = Boolean(record?.locked);
const urgent = Boolean(record?.batteryState?.urgentActive);
const warn = Boolean(record?.batteryState?.warnActive);
let verdict;
if (offline) verdict = 'nonexistent — it is offline';
else if (urgent) verdict = 'dying';
else if (warn) verdict = 'running low';
else if (locked) verdict = 'locked out and sulking';
else verdict = pickBySeed(VIBE_VERDICTS, hashSeed(`${rover.id}:${battery}`));
return reply(message, `🔍 ${rover.name}: vibes are **${verdict}**. Battery ${battery}.`);
}
return {
honk: handleHonk,
boo: handleBoo,
disco: handleDisco,
spin: handleSpin,
vibecheck: handleVibecheck,
};
}
module.exports = { createFunRoverCommands, describeBattery };
@@ -1,297 +0,0 @@
// Operator Fun Rover Command Tests
// Purpose: Verifies the control, feature, and lock checks the hardware-backed fun commands must make themselves.
// Scope: issueCommand, roverManager, and Home Assistant are all doubles; no real rover or timer is involved.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunRoverCommands, describeBattery } = require('./funRover');
const { createCooldownGate } = require('../cooldowns');
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
function createHarness({
canDrive = true,
socket = ALICE,
hornEnabled = true,
ttsEnabled = true,
online = true,
maxWheelSpeed = 300,
privateSafetyDrive = null,
activeDrivers = {},
homeAssistantService = null,
featureEnabled = false,
sockets = [ALICE, BOB],
} = {}) {
const issued = [];
const record = {
id: 'rover-1',
ws: online ? {} : null,
locked: false,
meta: {
name: 'Roomba One',
maxWheelSpeed,
horn: { enabled: hornEnabled },
audio: { ttsEnabled },
},
batteryState: { percentDisplay: 74, warnActive: false, urgentActive: false },
};
const rovers = new Map([['rover-1', record]]);
const handlers = createFunRoverCommands({
io: { sockets: { sockets: new Map(sockets.map((entry) => [entry.id, entry])) } },
rovers,
roverManager: {
canDrive: () => canDrive,
getPrimaryRoverForSocket: () => 'rover-1',
applyPrivateDriveSafety: () => privateSafetyDrive,
},
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
getActorSocket: () => socket,
issueCommand: (roverId, payload) => {
if (!record.ws) throw new Error('Rover offline');
issued.push({ roverId, ...payload });
return 'cmd-1';
},
homeAssistantService,
isFeatureEnabled: () => featureEnabled,
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
cooldowns: createCooldownGate(),
logger: { warn: () => {} },
config: { commands: { prefix: 'rs' } },
});
return { handlers, issued, record, rovers };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
test('honk starts the horn and schedules a stop', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { handlers, issued } = createHarness();
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /HONK/);
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start']);
// The stop is deferred, so nothing has released the horn yet.
t.mock.timers.tick(1000);
assert.deepEqual(issued.map((entry) => entry.horn.action), ['start', 'stop']);
});
test('honk is refused without drive control', async () => {
const { handlers, issued } = createHarness({ canDrive: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /need control of Roomba One/);
assert.equal(issued.length, 0);
});
test('honk is refused from a transport with no socket, so Discord cannot drive hardware', async () => {
const { handlers, issued } = createHarness({ socket: null });
const msg = message({ id: '4242', label: 'DiscordUser' });
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /only works from site chat/);
assert.equal(issued.length, 0);
});
test('honk is refused on a rover with no horn fitted', async () => {
const { handlers, issued } = createHarness({ hornEnabled: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /no horn fitted/);
assert.equal(issued.length, 0);
});
test('a second driver cannot bypass the rover cooldown with their own fresh actor window', async () => {
const { handlers, issued } = createHarness();
await handlers.honk(message(), []);
const other = message({ id: 's2', userId: 'u-bob', label: 'bob' });
await handlers.honk(other, []);
assert.match(other.replies[0].content, /was just honked/);
// Only the first honk reached the rover.
assert.equal(issued.filter((entry) => entry.horn?.action === 'start').length, 1);
});
test('an offline rover reports offline instead of claiming a honk happened', async () => {
const { handlers } = createHarness({ online: false });
const msg = message();
await handlers.honk(msg, []);
assert.match(msg.replies[0].content, /is offline/);
});
test('spin clamps to the rover wheel speed ceiling and always stops itself', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { handlers, issued } = createHarness({ maxWheelSpeed: 50 });
const msg = message();
await handlers.spin(msg, []);
assert.equal(issued[0].driveDirect.left, 50);
assert.equal(issued[0].driveDirect.right, -50);
t.mock.timers.tick(2000);
assert.deepEqual(issued[1].driveDirect, { left: 0, right: 0 });
});
test('spin honours a private rover safety override rather than bypassing it', async () => {
const { handlers, issued } = createHarness({ privateSafetyDrive: { left: 20, right: -20 } });
await handlers.spin(message(), []);
assert.deepEqual(issued[0].driveDirect, { left: 20, right: -20 });
});
test('spin is refused without drive control', async () => {
const { handlers, issued } = createHarness({ canDrive: false });
const msg = message();
await handlers.spin(msg, []);
assert.match(msg.replies[0].content, /need control/);
assert.equal(issued.length, 0);
});
test('boo speaks a canned taunt rather than any caller supplied text', async () => {
const { handlers, issued } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.equal(issued.length, 1);
assert.equal(issued[0].type, 'tts');
// The spoken text must not contain anything the caller typed.
assert.doesNotMatch(issued[0].tts.text, /bob/i);
assert.ok(issued[0].tts.text.length > 0);
});
test('boo is refused when the target is not driving anything', async () => {
const { handlers, issued } = createHarness({ activeDrivers: {} });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.match(msg.replies[0].content, /not driving anything/);
assert.equal(issued.length, 0);
});
test('boo is refused when the target is not online at all', async () => {
const { handlers, issued } = createHarness({ sockets: [ALICE] });
const msg = message();
await handlers.boo(msg, ['nobody-here']);
assert.match(msg.replies[0].content, /not here to be booed/);
assert.equal(issued.length, 0);
});
test('boo is refused on a rover that cannot speak', async () => {
const { handlers, issued } = createHarness({ ttsEnabled: false, activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.boo(msg, ['bob']);
assert.match(msg.replies[0].content, /cannot speak/);
assert.equal(issued.length, 0);
});
test('disco is unavailable when the Home Assistant feature is off', async () => {
const calls = [];
const { handlers } = createHarness({
featureEnabled: false,
homeAssistantService: {
getLightPolicyState: () => ({}),
setAllControllableEntitiesState: (state) => calls.push(state),
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /unavailable/);
assert.equal(calls.length, 0);
});
test('disco obeys the room light lock', async () => {
const calls = [];
const { handlers } = createHarness({
featureEnabled: true,
homeAssistantService: {
getLightPolicyState: () => ({ locked: true, lockState: 'on' }),
setAllControllableEntitiesState: (state) => calls.push(state),
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /locked/);
assert.equal(calls.length, 0);
});
test('disco strobes while unlocked and restores the lights on when it ends', async (t) => {
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'] });
const calls = [];
const { handlers } = createHarness({
featureEnabled: true,
homeAssistantService: {
getLightPolicyState: () => ({ locked: false }),
setAllControllableEntitiesState: (state) => {
calls.push(state);
return Promise.resolve();
},
},
});
const msg = message();
await handlers.disco(msg, []);
assert.match(msg.replies[0].content, /Disco/);
t.mock.timers.tick(3000);
assert.ok(calls.length >= 2, `expected several ticks, saw ${calls.length}`);
assert.ok(calls.includes('on') && calls.includes('off'));
// Past the end of the window the lights must be put back on and left alone.
t.mock.timers.tick(20 * 1000);
assert.equal(calls[calls.length - 1], 'on');
const settled = calls.length;
t.mock.timers.tick(20 * 1000);
assert.equal(calls.length, settled);
});
test('vibecheck reports the battery and never issues a command', async () => {
const { handlers, issued } = createHarness();
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /Roomba One/);
assert.match(msg.replies[0].content, /Battery 74%/);
assert.equal(issued.length, 0);
});
test('vibecheck leads with the real problem when the battery is urgent', async () => {
const { handlers, record } = createHarness();
record.batteryState = { percentDisplay: 4, warnActive: true, urgentActive: true };
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /dying/);
});
test('vibecheck reports an offline rover as offline', async () => {
const { handlers, record } = createHarness();
record.ws = null;
const msg = message();
await handlers.vibecheck(msg, []);
assert.match(msg.replies[0].content, /offline/);
});
test('describeBattery falls back through the available fields', () => {
assert.equal(describeBattery({ percentDisplay: 55.4 }), '55%');
assert.equal(describeBattery({ percent: 0.42 }), '42%');
assert.equal(describeBattery({}), 'unknown');
assert.equal(describeBattery(null), 'unknown');
});
@@ -1,118 +0,0 @@
// Operator Fun Stats Commands
// Purpose: Implements the fun commands that read or extend persistent counters (bonkboard, pet, snitch).
// Scope: Reads the roster and the fun stats store; issues no rover commands.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const { PLAIN_MENTIONS, actorLabel, buildActorKey, createRoverResolver } = require('./funHelpers');
const PET_COOLDOWN_MS = 10 * 1000;
const READ_COOLDOWN_MS = 5 * 1000;
const LEADERBOARD_SIZE = 10;
function formatLeaderboard(title, rows, counter) {
const ranked = rows
.filter((row) => Number(row[counter]) > 0)
.sort((left, right) => Number(right[counter]) - Number(left[counter]))
.slice(0, LEADERBOARD_SIZE);
if (!ranked.length) return null;
const lines = ranked.map((row, index) => `${index + 1}. ${row.label || 'unknown'}${row[counter]}`);
return [`**${title}**`, ...lines].join('\n');
}
function createFunStatsCommands({
io,
rovers,
getNickname,
getActiveDrivers,
getActorSocket,
roverManager,
sanitizeMentions,
funStatsService,
cooldowns,
config,
}) {
const { prefix: commandPrefix } = getCommandConfig(config);
const safe = (text) => (sanitizeMentions ? sanitizeMentions(text) : String(text || ''));
function reply(message, content) {
return message.reply({ content: safe(content), allowedMentions: PLAIN_MENTIONS });
}
function gate(message, action, windowMs) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
const resolveTargetRover = createRoverResolver({ rovers, roverManager, getActorSocket, commandPrefix });
async function handleBonkboard(message) {
const gated = gate(message, 'bonkboard', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rows = funStatsService.listActorStats();
const sections = [
formatLeaderboard('Most bonks dealt', rows, 'bonksGiven'),
formatLeaderboard('Most bonks taken', rows, 'bonksTaken'),
formatLeaderboard('Most hugs given', rows, 'hugsGiven'),
].filter(Boolean);
if (!sections.length) {
return reply(message, `Nobody has been bonked yet. Fix that with \`${commandPrefix} bonk <user>\`.`);
}
return reply(message, sections.join('\n\n').slice(0, 1900));
}
async function handlePet(message, tokens = []) {
const gated = gate(message, 'pet', PET_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const rover = resolveTargetRover(tokens.join(' '), 'pet');
if (rover.error) return reply(message, rover.error);
const pets = funStatsService.bumpRoverPets(rover.id, 1);
return reply(message, `🤖 ${gated.label} pets ${rover.name}. It has now been petted ${pets} time${pets === 1 ? '' : 's'}.`);
}
/*
Reads the same active-driver map the turn system uses, so it reports real
control rather than who merely has the page open. Rovers with nobody driving
are listed too an empty fleet is exactly what a snitch should report.
*/
async function handleSnitch(message) {
const gated = gate(message, 'snitch', READ_COOLDOWN_MS);
if (gated.error) return reply(message, gated.error);
const drivers = getActiveDrivers?.() || {};
const sockets = io?.sockets?.sockets;
const lines = [];
rovers.forEach((record, roverId) => {
const id = String(roverId);
const name = record?.meta?.name || id;
const socketId = drivers[id];
const socket = socketId && sockets?.get ? sockets.get(socketId) : null;
const nickname = socket ? getNickname?.(socket) : null;
if (nickname) {
lines.push(`${name}${nickname}`);
} else if (socketId) {
lines.push(`${name} — someone who will not say their name`);
} else {
lines.push(`${name} — nobody`);
}
});
if (!lines.length) return reply(message, 'No rovers are online to snitch about.');
return reply(message, ['🕵️ Currently driving:', ...lines].join('\n').slice(0, 1900));
}
return {
bonkboard: handleBonkboard,
pet: handlePet,
snitch: handleSnitch,
};
}
module.exports = { createFunStatsCommands, formatLeaderboard };
@@ -1,166 +0,0 @@
// Operator Fun Stats Command Tests
// Purpose: Verifies the leaderboard ordering, rover pet counting, and what snitch reports.
// Scope: In-memory stats and roster doubles only.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunStatsCommands, formatLeaderboard } = require('./funStats');
const { createCooldownGate } = require('../cooldowns');
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const BOB = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
function createHarness({
actorRows = [],
activeDrivers = {},
socket = ALICE,
rovers = new Map([
['rover-1', { id: 'rover-1', meta: { name: 'Roomba One' } }],
['rover-2', { id: 'rover-2', meta: { name: 'Roomba Two' } }],
]),
} = {}) {
const pets = new Map();
const handlers = createFunStatsCommands({
io: { sockets: { sockets: new Map([[ALICE.id, ALICE], [BOB.id, BOB]]) } },
rovers,
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => activeDrivers,
getActorSocket: () => socket,
roverManager: { getPrimaryRoverForSocket: () => 'rover-1' },
sanitizeMentions: (text) => String(text || '').replace(/@everyone/gi, '[everyone]'),
funStatsService: {
listActorStats: () => actorRows,
bumpRoverPets: (roverId, by) => {
const next = (pets.get(roverId) || 0) + by;
pets.set(roverId, next);
return next;
},
},
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
return { handlers, pets };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
test('the leaderboard sorts descending and drops zero scores', () => {
const rows = [
{ label: 'alice', bonksGiven: 2 },
{ label: 'bob', bonksGiven: 9 },
{ label: 'carol', bonksGiven: 0 },
];
const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven');
const lines = rendered.split('\n');
assert.equal(lines[1], '1. bob — 9');
assert.equal(lines[2], '2. alice — 2');
assert.equal(lines.length, 3, 'carol should not appear with a zero score');
});
test('the leaderboard is capped at ten entries', () => {
const rows = Array.from({ length: 25 }, (_, index) => ({ label: `user${index}`, bonksGiven: index + 1 }));
const rendered = formatLeaderboard('Most bonks dealt', rows, 'bonksGiven');
assert.equal(rendered.split('\n').length - 1, 10);
});
test('an all-zero counter renders no section at all', () => {
assert.equal(formatLeaderboard('Most bonks dealt', [{ label: 'alice', bonksGiven: 0 }], 'bonksGiven'), null);
});
test('bonkboard says so when nothing has happened yet', async () => {
const { handlers } = createHarness({ actorRows: [] });
const msg = message();
await handlers.bonkboard(msg, []);
assert.match(msg.replies[0].content, /Nobody has been bonked yet/);
});
test('bonkboard renders each populated section', async () => {
const { handlers } = createHarness({
actorRows: [
{ label: 'alice', bonksGiven: 3, bonksTaken: 0, hugsGiven: 1 },
{ label: 'bob', bonksGiven: 0, bonksTaken: 3, hugsGiven: 0 },
],
});
const msg = message();
await handlers.bonkboard(msg, []);
assert.match(msg.replies[0].content, /Most bonks dealt/);
assert.match(msg.replies[0].content, /Most bonks taken/);
assert.match(msg.replies[0].content, /Most hugs given/);
});
test('bonkboard sanitizes stored labels, so a hostile nickname cannot ping a guild', async () => {
const { handlers } = createHarness({ actorRows: [{ label: '@everyone', bonksGiven: 1 }] });
const msg = message();
await handlers.bonkboard(msg, []);
assert.doesNotMatch(msg.replies[0].content, /@everyone/);
});
test('pet counts against the rover the caller is on when none is named', async () => {
const { handlers, pets } = createHarness();
const msg = message();
await handlers.pet(msg, []);
assert.match(msg.replies[0].content, /pets Roomba One/);
assert.match(msg.replies[0].content, /petted 1 time\./);
assert.equal(pets.get('rover-1'), 1);
});
test('pet accepts an explicit rover and keeps a separate count per rover', async () => {
const { handlers, pets } = createHarness();
await handlers.pet(message(), ['Roomba Two']);
await handlers.pet(message({ id: 's2', userId: 'u-bob', label: 'bob' }), ['Roomba Two']);
assert.equal(pets.get('rover-2'), 2);
assert.equal(pets.get('rover-1'), undefined);
});
test('pet pluralizes the running total', async () => {
const { handlers } = createHarness();
await handlers.pet(message(), []);
const second = message({ id: 's2', userId: 'u-bob', label: 'bob' });
await handlers.pet(second, []);
assert.match(second.replies[0].content, /petted 2 times\./);
});
test('pet from a transport with no socket asks for a rover name', async () => {
const { handlers, pets } = createHarness({ socket: null });
const msg = message({ id: '4242', label: 'DiscordUser' });
await handlers.pet(msg, []);
assert.match(msg.replies[0].content, /Name a rover/);
assert.equal(pets.size, 0);
});
test('snitch names the active driver and reports idle rovers as nobody', async () => {
const { handlers } = createHarness({ activeDrivers: { 'rover-1': 's2' } });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /Roomba One — bob/);
assert.match(msg.replies[0].content, /Roomba Two — nobody/);
});
test('snitch handles a driver socket that has already gone away', async () => {
const { handlers } = createHarness({ activeDrivers: { 'rover-1': 'ghost-socket' } });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /Roomba One — someone who will not say their name/);
});
test('snitch reports an empty fleet rather than an empty message', async () => {
const { handlers } = createHarness({ rovers: new Map() });
const msg = message();
await handlers.snitch(msg, []);
assert.match(msg.replies[0].content, /No rovers are online/);
});
@@ -1,361 +0,0 @@
// Operator Fun Text Commands
// Purpose: Implements the social, text-only `rs` commands (bonk, hug, slap, 8ball, roll, coin, ship, rate, uwu, wanted).
// Scope: Text and counters only; nothing here touches rover hardware.
const { getCommandConfig } = require('../../operatorCommandService/config');
const { describeWait } = require('../cooldowns');
const {
PLAIN_MENTIONS,
actorLabel,
buildActorKey,
clampEcho,
hashSeed,
ordinal,
pairSeed,
percentFromSeed,
pickBySeed,
resolveFunTarget,
} = require('./funHelpers');
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',
'a bag of loose screws', 'an unlabelled USB cable', 'a soggy floor sensor',
'the heaviest available wrench', 'a stack of unread pull requests',
];
const EIGHT_BALL_ANSWERS = [
'Yes.', 'No.', 'Absolutely.', 'Absolutely not.', 'Ask again once the battery is charged.',
'Signs point to the docking station.', 'The overseer says no.', 'Almost certainly.',
'Not while anyone is watching.', 'Outlook cloudy, sensors dirty.', 'Try it and find out.',
'That is a maintenance window problem.', 'Only on a Tuesday.', 'The rover has already decided.',
];
const HUG_FLAVOURS = [
'gently', 'aggressively', 'with both brush guards', 'at full wheel speed',
'for slightly too long', 'while beeping softly', 'without asking first',
];
const WANTED_CRIMES = [
'reckless docking', 'driving with the brush on indoors', 'excessive honking',
'unauthorised carpet donuts', 'battery hoarding', 'ignoring the global objective',
'parking in the doorway', 'nine consecutive turn skips', 'talking to the Neato',
'strobing the room lights for fun', 'stealing another rover\'s charger',
];
const RATE_SUFFIXES = [
'No further questions.', 'I stand by this.', 'Do not appeal.', 'Take it or leave it.',
'The sensors agree.', 'This rating is final.',
];
/*
A dice roll is one of the few places a fun command should be genuinely random:
the whole point is that nobody can predict it. Everything that passes judgement
on a thing (`ship`, `rate`, `8ball`, `wanted`) is seeded from the input instead,
so re-running it cannot reroll a verdict somebody disliked.
*/
function rollDice(count, sides) {
let total = 0;
const rolls = [];
for (let index = 0; index < count; index += 1) {
const value = 1 + Math.floor(Math.random() * sides);
rolls.push(value);
total += value;
}
return { rolls, total };
}
function parseDiceSpec(spec) {
const text = String(spec || '').trim().toLowerCase() || '1d6';
const match = /^(\d*)d(\d+)$/.exec(text);
if (!match) {
// A bare number is read as a single die with that many sides so `rs roll 20`
// does the obvious thing instead of erroring.
const bare = /^(\d+)$/.exec(text);
if (!bare) return { error: 'Roll format is `NdN`, for example `2d6`.' };
const sides = Number(bare[1]);
if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' };
return { count: 1, sides };
}
const count = match[1] === '' ? 1 : Number(match[1]);
const sides = Number(match[2]);
if (count < 1 || count > 20) return { error: 'Roll between 1 and 20 dice.' };
if (sides < 2 || sides > 1000) return { error: 'Dice need between 2 and 1000 sides.' };
return { count, sides };
}
function uwuify(text) {
return String(text || '')
.replace(/[rl]/g, 'w')
.replace(/[RL]/g, 'W')
.replace(/n([aeiou])/g, 'ny$1')
.replace(/N([aeiou])/g, 'Ny$1')
.replace(/ove/g, 'uv')
.replace(/!+/g, ' !!');
}
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 });
}
/*
Every fun command runs through one gate so the cooldown, the actor identity,
and the "who am I talking about" resolution cannot drift apart between
commands. `needsTarget` commands reply with their own usage line when the
selector is missing rather than silently acting on nothing.
*/
function gate(message, action, { windowMs = TEXT_COOLDOWN_MS } = {}) {
const actorKey = buildActorKey(message);
if (!actorKey) return { error: 'Could not identify you well enough to do that.' };
const wait = cooldowns.consume(`${action}:${actorKey}`, windowMs);
if (wait > 0) return { error: `Slow down — try \`${commandPrefix} ${action}\` again in ${describeWait(wait)}.` };
return { actorKey, label: actorLabel(message) };
}
function target(selector) {
return resolveFunTarget({ io, getNickname, selector });
}
/*
Shared shape for the three "do a thing to someone" commands. Only the verb,
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, onApplied }) {
return async function handleInteraction(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) {
return reply(message, `Usage: \`${commandPrefix} ${action} <user>\``);
}
const gated = gate(message, action);
if (gated.error) return reply(message, gated.error);
const resolved = target(selector);
if (!resolved) return reply(message, `Usage: \`${commandPrefix} ${action} <user>\``);
if (resolved.actorKey && resolved.actorKey === gated.actorKey) {
return reply(message, selfReply(gated.label));
}
funStatsService.bumpActorStats(gated.actorKey, { label: gated.label, [counterGiven]: 1 });
const targetStats = resolved.actorKey
? 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,
targetLabel: resolved.label,
targetOnline: resolved.online,
takenCount: targetStats ? targetStats[counterTaken] : null,
}));
};
}
const handleBonk = createInteraction({
action: 'bonk',
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}`;
},
});
const handleHug = createInteraction({
action: 'hug',
counterGiven: 'hugsGiven',
counterTaken: 'hugsTaken',
selfReply: (label) => `${label} hugged themselves. Genuinely fine. No notes.`,
render: ({ actor, targetLabel, takenCount }) => {
const flavour = pickBySeed(HUG_FLAVOURS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
const tally = takenCount ? ` (${takenCount} total)` : '';
return `🫂 ${actor} hugs ${targetLabel} ${flavour}.${tally}`;
},
});
const handleSlap = createInteraction({
action: 'slap',
counterGiven: 'slapsGiven',
counterTaken: 'slapsTaken',
selfReply: (label) => `${label} slapped themselves with a large trout. Bold.`,
render: ({ actor, targetLabel, takenCount }) => {
// Seeding on the running count means the weapon changes every time without
// being unpredictable for the same repeat number.
const item = pickBySeed(SLAP_ITEMS, hashSeed(`${actor}:${targetLabel}:${takenCount || 0}`));
return `🐟 ${actor} slaps ${targetLabel} around a bit with ${item}.`;
},
});
async function handleEightBall(message, tokens = []) {
const question = clampEcho(tokens.join(' '));
if (!question) return reply(message, `Usage: \`${commandPrefix} 8ball <question>\``);
const gated = gate(message, '8ball');
if (gated.error) return reply(message, gated.error);
const answer = pickBySeed(EIGHT_BALL_ANSWERS, hashSeed(question));
return reply(message, `🎱 ${question}\n${answer}`);
}
async function handleRoll(message, tokens = []) {
const gated = gate(message, 'roll');
if (gated.error) return reply(message, gated.error);
const spec = parseDiceSpec(tokens.join(''));
if (spec.error) return reply(message, spec.error);
const { rolls, total } = rollDice(spec.count, spec.sides);
const detail = rolls.length > 1 ? ` (${rolls.join(' + ')})` : '';
return reply(message, `🎲 ${gated.label} rolled ${spec.count}d${spec.sides}: **${total}**${detail}`);
}
async function handleCoin(message) {
const gated = gate(message, 'coin');
if (gated.error) return reply(message, gated.error);
const side = Math.random() < 0.5 ? 'Heads' : 'Tails';
return reply(message, `🪙 ${side}.`);
}
async function handleShip(message, tokens = []) {
const parts = tokens.join(' ').split(/\s+(?:and|\+|&)\s+|\s*,\s*/i).map((part) => clampEcho(part)).filter(Boolean);
const [left, right] = parts.length >= 2 ? parts : [parts[0], null];
if (!left || !right) {
return reply(message, `Usage: \`${commandPrefix} ship <a> and <b>\``);
}
const gated = gate(message, 'ship');
if (gated.error) return reply(message, gated.error);
const score = percentFromSeed(pairSeed(left, right));
const verdict = score >= 90 ? 'Get them a shared charging dock.'
: score >= 65 ? 'Promising.'
: score >= 35 ? 'Needs work.'
: score >= 10 ? 'The sensors are not hopeful.'
: 'Absolutely not.';
return reply(message, `💞 ${left} + ${right} = **${score}%**. ${verdict}`);
}
async function handleRate(message, tokens = []) {
const thing = clampEcho(tokens.join(' '));
if (!thing) return reply(message, `Usage: \`${commandPrefix} rate <thing>\``);
const gated = gate(message, 'rate');
if (gated.error) return reply(message, gated.error);
const seed = hashSeed(thing);
const score = seed % 11;
const suffix = pickBySeed(RATE_SUFFIXES, seed);
return reply(message, `📊 I rate ${thing} **${score}/10**. ${suffix}`);
}
async function handleUwu(message, tokens = []) {
const text = clampEcho(tokens.join(' '));
if (!text) return reply(message, `Usage: \`${commandPrefix} uwu <text>\``);
const gated = gate(message, 'uwu');
if (gated.error) return reply(message, gated.error);
return reply(message, uwuify(text));
}
async function handleWanted(message, tokens = []) {
const selector = tokens.join(' ').trim();
if (!selector) return reply(message, `Usage: \`${commandPrefix} wanted <user>\``);
const gated = gate(message, 'wanted');
if (gated.error) return reply(message, gated.error);
const resolved = target(selector);
if (!resolved) return reply(message, `Usage: \`${commandPrefix} wanted <user>\``);
const seed = hashSeed(resolved.label);
const crime = pickBySeed(WANTED_CRIMES, seed);
// Bounty is seeded so a given name always carries the same price. Somebody
// being permanently worth 12 credits is funnier than a fresh number each time.
const bounty = 25 + (seed % 4776);
const stats = resolved.actorKey ? funStatsService.getActorStats(resolved.actorKey) : null;
const priors = stats && stats.bonksTaken ? `\nPrior bonks on record: ${stats.bonksTaken}.` : '';
return reply(message, [
'```',
' WANTED',
` ${resolved.label}`,
` for ${crime}`,
` reward: ${bounty} credits`,
'```',
].join('\n') + priors);
}
return {
bonk: handleBonk,
hug: handleHug,
slap: handleSlap,
'8ball': handleEightBall,
roll: handleRoll,
coin: handleCoin,
ship: handleShip,
rate: handleRate,
uwu: handleUwu,
wanted: handleWanted,
};
}
module.exports = {
createFunTextCommands,
// Exported for tests: the parsing and transform rules are the parts most
// likely to regress, and they are pure.
parseDiceSpec,
uwuify,
};
@@ -1,304 +0,0 @@
// Operator Fun Text Command Tests
// Purpose: Verifies tally credit, self-targeting, cooldown refusal, mention sanitizing, and dice parsing.
// Scope: Uses an in-memory stats double so no test touches the fun stats file.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createFunTextCommands, parseDiceSpec, uwuify } = require('./funText');
const { createCooldownGate } = require('../cooldowns');
function createStatsDouble() {
const store = new Map();
return {
calls: [],
bumpActorStats(actorKey, { label = null, ...patch } = {}) {
this.calls.push({ actorKey, label, patch });
const current = store.get(actorKey) || {};
const next = { ...current, label: label || current.label };
Object.keys(patch).forEach((key) => {
next[key] = (Number(current[key]) || 0) + Number(patch[key] || 0);
});
store.set(actorKey, next);
return next;
},
getActorStats(actorKey) {
return store.get(actorKey) || {};
},
listActorStats() {
return Array.from(store.entries()).map(([actorKey, value]) => ({ actorKey, ...value }));
},
};
}
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]')
.replace(/@everyone/gi, '[everyone]')
.replace(/@here/gi, '[here]'),
funStatsService: stats,
cooldowns: createCooldownGate(),
config: { commands: { prefix: 'rs' } },
});
return { handlers, stats, events };
}
function message(actor = { id: 's1', userId: 'u-alice', label: 'alice' }) {
const replies = [];
return {
transport: 'web-chat',
actor,
replies,
reply: async (payload) => {
replies.push(payload);
return null;
},
};
}
const bob = { id: 's2', data: { userId: 'u-bob', nickname: 'bob' } };
test('bonk credits both sides and reports the running tally', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message();
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Bonked bob\./);
assert.match(msg.replies[0].content, /1st bonk/);
assert.deepEqual(
stats.calls.map((call) => [call.actorKey, Object.keys(call.patch)[0]]),
[['user:u-alice', 'bonksGiven'], ['user:u-bob', 'bonksTaken']],
);
});
test('the tally ordinal advances across repeat bonks', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
// A second actor avoids the first actor's cooldown while still hitting bob.
const second = message({ id: 's3', userId: 'u-carol', label: 'carol' });
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /2nd bonk/);
});
test('bonking an offline name still replies but credits nobody', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message();
await handlers.bonk(msg, ['the', 'dishwasher']);
assert.match(msg.replies[0].content, /Bonked the dishwasher\./);
assert.doesNotMatch(msg.replies[0].content, /bonk\b.*\dst|\dnd|\drd|\dth/);
assert.deepEqual(stats.calls.map((call) => call.actorKey), ['user:u-alice']);
});
test('self-bonking is a special case and records nothing', async () => {
const alice = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
const { handlers, stats } = createHarness({ sockets: [alice] });
const msg = message();
await handlers.bonk(msg, ['alice']);
assert.match(msg.replies[0].content, /themselves/);
assert.equal(stats.calls.length, 0);
});
test('a repeat inside the cooldown window is refused and records nothing extra', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
const countAfterFirst = stats.calls.length;
const second = message();
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /Slow down/);
assert.equal(stats.calls.length, countAfterFirst);
});
test('cooldowns are per command, so a bonk does not block a hug', async () => {
const { handlers } = createHarness({ sockets: [bob] });
await handlers.bonk(message(), ['bob']);
const hug = message();
await handlers.hug(hug, ['bob']);
assert.doesNotMatch(hug.replies[0].content, /Slow down/);
});
test('a missing target replies with usage and does not burn the cooldown', async () => {
const { handlers } = createHarness({ sockets: [bob] });
const first = message();
await handlers.bonk(first, []);
assert.match(first.replies[0].content, /Usage: `rs bonk <user>`/);
const second = message();
await handlers.bonk(second, ['bob']);
assert.match(second.replies[0].content, /Bonked bob/);
});
test('every reply is sanitized so a fun command cannot ping a whole guild', async () => {
const { handlers } = createHarness();
const msg = message();
await handlers.bonk(msg, ['@everyone']);
assert.doesNotMatch(msg.replies[0].content, /@everyone/);
assert.match(msg.replies[0].content, /\[everyone\]/);
const roleMsg = message({ id: 's9', userId: 'u-dave', label: 'dave' });
await handlers.slap(roleMsg, ['<@&123456>']);
assert.match(roleMsg.replies[0].content, /\[ping removed\]/);
});
test('an actor with no identity at all is refused rather than sharing a tally', async () => {
const { handlers, stats } = createHarness({ sockets: [bob] });
const msg = message({ label: 'ghost' });
await handlers.bonk(msg, ['bob']);
assert.match(msg.replies[0].content, /Could not identify you/);
assert.equal(stats.calls.length, 0);
});
test('ship agrees with itself regardless of argument order', async () => {
const { handlers } = createHarness();
const forward = message();
await handlers.ship(forward, ['alice', 'and', 'bob']);
const { handlers: other } = createHarness();
const backward = message();
await other.ship(backward, ['bob', 'and', 'alice']);
const score = (text) => /\*\*(\d+)%\*\*/.exec(text)[1];
assert.equal(score(forward.replies[0].content), score(backward.replies[0].content));
});
test('ship needs two sides', async () => {
const { handlers } = createHarness();
const msg = message();
await handlers.ship(msg, ['alice']);
assert.match(msg.replies[0].content, /Usage: `rs ship/);
});
test('8ball gives the same answer to the same question', async () => {
const first = createHarness();
const a = message();
await first.handlers['8ball'](a, ['will', 'it', 'dock']);
const second = createHarness();
const b = message();
await second.handlers['8ball'](b, ['WILL', 'IT', 'DOCK']);
assert.equal(a.replies[0].content.split('\n')[1], b.replies[0].content.split('\n')[1]);
});
test('rate stays inside 0 to 10', async () => {
for (const thing of ['carpet', 'the dock', 'a', 'zzzzzz', 'rover 3']) {
const { handlers } = createHarness();
const msg = message();
await handlers.rate(msg, [thing]);
const score = Number(/\*\*(\d+)\/10\*\*/.exec(msg.replies[0].content)[1]);
assert.ok(score >= 0 && score <= 10, `${thing} scored ${score}`);
}
});
test('dice specs parse the accepted forms and reject the rest', () => {
assert.deepEqual(parseDiceSpec('2d6'), { count: 2, sides: 6 });
assert.deepEqual(parseDiceSpec('d20'), { count: 1, sides: 20 });
assert.deepEqual(parseDiceSpec(''), { count: 1, sides: 6 });
// A bare number is read as one die of that many sides.
assert.deepEqual(parseDiceSpec('20'), { count: 1, sides: 20 });
assert.match(parseDiceSpec('21d6').error, /between 1 and 20 dice/);
assert.match(parseDiceSpec('1d1').error, /2 and 1000 sides/);
assert.match(parseDiceSpec('1d2000').error, /2 and 1000 sides/);
assert.match(parseDiceSpec('banana').error, /NdN/);
});
test('roll totals stay within the possible range for the spec', async () => {
for (let attempt = 0; attempt < 25; attempt += 1) {
const { handlers } = createHarness();
const msg = message();
await handlers.roll(msg, ['3d6']);
const total = Number(/\*\*(\d+)\*\*/.exec(msg.replies[0].content)[1]);
assert.ok(total >= 3 && total <= 18, `rolled ${total}`);
}
});
test('uwu transforms text without dropping it', () => {
assert.equal(uwuify('hello world'), 'hewwo wowwd');
assert.equal(uwuify('love'), 'wuv');
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']);
const msg = message({ id: 's4', userId: 'u-erin', label: 'erin' });
await handlers.wanted(msg, ['bob']);
assert.match(msg.replies[0].content, /WANTED/);
assert.match(msg.replies[0].content, /Prior bonks on record: 1/);
});
@@ -1,180 +0,0 @@
// Operator Gain Command
// Purpose: Handles the audio gain boost permission for VIPs.
// Scope: Supports list, grant, and revoke subcommands; resolution stays VIP-only.
const { mask, normalizeSearchText, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../../operatorCommandService/config');
/*
Every connected socket's canonical user id. One person can hold several sockets
across tabs, so this is a set of identities rather than a count of connections.
*/
function collectOnlineUserIds(io) {
const online = new Set();
const sockets = io?.sockets?.sockets;
if (!sockets || typeof sockets.forEach !== 'function') return online;
sockets.forEach((socket) => {
const userId = String(socket?.data?.userId || '').trim();
if (userId) online.add(userId);
});
return online;
}
/*
Candidates whose identity fields equal the selector outright. Nicknames are not
unique the same person re-verifying from a new browser produces a second
verified record with the same name so an exact nickname match can legitimately
return several records.
*/
function findExactMatches(selector, candidates) {
const needle = normalizeSearchText(selector);
if (!needle) return [];
return (Array.isArray(candidates) ? candidates : []).filter((record) => (
normalizeSearchText(record?.nickname) === needle
|| normalizeSearchText(record?.userId) === needle
|| normalizeSearchText(record?.id) === needle
|| normalizeSearchText(record?.cookieUserId) === needle
|| normalizeSearchText(record?.fingerprintId) === needle
));
}
function createGainCommand({
io,
listVerifiedUsers,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
sanitizeMentions,
config,
}) {
// Usage text comes from the same core prefix that both transports parse.
const { prefix: commandPrefix } = getCommandConfig(config);
const plain = { parse: [], repliedUser: false };
function usage(subcommand) {
return `Usage: \`${commandPrefix} gain ${subcommand} <nickname|userId|cookieUserId>\``;
}
/*
Duplicate nicknames used to make `gain grant <name>` unusable: the shared
resolver refuses on ambiguity, which is right for destructive commands like
deter and kick but wrong here. Granting a volume ceiling to the wrong one of
two accounts belonging to the same person is recoverable, so this command
picks one and says which.
The online account wins, because that is who the admin is reacting to. With
nobody online the first stored record is used. The shared fuzzy resolver still
handles the no-exact-match case so typo tolerance and its error text are
unchanged.
*/
function resolveBoostTarget(selector, candidates) {
const exact = findExactMatches(selector, candidates);
if (exact.length === 1) return { record: exact[0] };
if (exact.length > 1) {
const onlineUserIds = collectOnlineUserIds(io);
const onlineMatches = exact.filter((record) => {
const userId = String(record?.userId || '').trim();
return userId && onlineUserIds.has(userId);
});
if (onlineMatches.length) {
return { record: onlineMatches[0], duplicates: exact.length, picked: 'online' };
}
return { record: exact[0], duplicates: exact.length, picked: 'first' };
}
return resolveIdentitySelector(selector, candidates, { includeId: false });
}
function describePick(resolved) {
if (!resolved.duplicates) return '';
if (resolved.picked === 'online') {
return ` ${resolved.duplicates} accounts share that name; picked the one that is online.`;
}
return ` ${resolved.duplicates} accounts share that name and none are online; picked the first.`;
}
function helpText() {
return [
'**Audio gain boost**',
'Raises a user\'s volume ceiling past the global gains, still bounded by the hard caps.',
'',
`\`${commandPrefix} gain list\` — show everyone who holds the boost.`,
`\`${commandPrefix} gain grant <vip>\` — give the boost to a verified user.`,
`\`${commandPrefix} gain revoke <vip>\` — take the boost away.`,
`\`${commandPrefix} gain help\` — show this.`,
'',
'A user can be named by nickname, userId, or cookieUserId. Only verified (VIP)',
'users can be granted the boost. If several accounts share a nickname, the one',
'that is currently online is used.',
].join('\n');
}
/*
The boost is a VIP-only permission, so candidate matching runs against the
verified list rather than every known identity. A nickname that only belongs
to an unverified visitor therefore reports "not found" instead of resolving
to someone who cannot hold the flag anyway.
*/
async function applyBoost(message, tokens, enabled) {
const selector = tokens.join(' ').trim();
if (!selector) {
return message.reply({ content: usage(enabled ? 'grant' : 'revoke'), allowedMentions: plain });
}
const candidates = enabled ? listVerifiedUsers() : listAudioGainBoostUsers();
const resolved = resolveBoostTarget(selector, candidates);
if (resolved.error) {
return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: plain });
}
const target = resolved.record.userId || resolved.record.id || resolved.record.cookieUserId;
try {
const actor = message.actor?.id || null;
const user = enabled ? grantAudioGainBoost(target, actor) : revokeAudioGainBoost(target, actor);
const verb = enabled ? 'Granted' : 'Revoked';
return message.reply({
content: sanitizeMentions(`${verb} audio gain boost for ${user.nickname || 'unknown'} (${mask(user.cookieUserId)}).${describePick(resolved)}`),
allowedMentions: plain,
});
} catch (err) {
return message.reply({
content: sanitizeMentions(`Failed to update audio gain boost: ${err.message}`),
allowedMentions: plain,
});
}
}
return async function handleGainCommand(message, tokens) {
if (!message.actor?.isAdmin) {
await message.reply({ content: 'Only admins can manage audio gain boosts.', allowedMentions: plain });
return;
}
const action = (tokens.shift() || 'list').toLowerCase();
if (action === 'help') {
return message.reply({ content: helpText(), allowedMentions: plain });
}
if (action === 'list') {
const users = listAudioGainBoostUsers();
if (!users.length) {
return message.reply({ content: 'No users hold an audio gain boost.', allowedMentions: plain });
}
const lines = users.map((entry, idx) => (
`${idx + 1}. ${entry.nickname || 'unknown'} | ${entry.userId || entry.id} | ${mask(entry.cookieUserId)}`
));
return message.reply({
content: sanitizeMentions(['Audio gain boost holders:', ...lines].join('\n').slice(0, 1900)),
allowedMentions: plain,
});
}
if (action === 'grant') return applyBoost(message, tokens, true);
if (action === 'revoke') return applyBoost(message, tokens, false);
return message.reply({
content: `Unknown gain command.\n${helpText()}`,
allowedMentions: plain,
});
};
}
module.exports = { createGainCommand };
@@ -1,258 +0,0 @@
// Operator Gain Command Tests
// Purpose: Verifies the audio gain boost command stays admin-only and VIP-only.
// Scope: Exercises command target resolution with in-memory identity doubles.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createGainCommand } = require('./gain');
const VIPS = [
{ userId: 'usr-vip', nickname: 'Croissant', cookieUserId: 'cookie-croissant' },
{ userId: 'usr-other', nickname: 'Baguette', cookieUserId: 'cookie-baguette' },
];
// Two verified records sharing one nickname. This is the real shape behind the
// "matched multiple records" failure: one person re-verifying from a new browser
// produces a second record with the same name and a different cookie id.
const DUPLICATE_SAULS = [
{ userId: 'usr-saul-a', nickname: 'Saul', cookieUserId: 'cu_a28ffffffff33ab5c' },
{ userId: 'usr-saul-b', nickname: 'Saul', cookieUserId: 'cu_5a5ffffffffb6add3' },
];
function createSocketRegistry(onlineUserIds = []) {
const sockets = new Map();
onlineUserIds.forEach((userId, index) => {
// Two sockets per identity, so the resolver must dedupe rather than count
// connections.
sockets.set(`s${index}a`, { id: `s${index}a`, data: { userId } });
sockets.set(`s${index}b`, { id: `s${index}b`, data: { userId } });
});
return { sockets: { sockets } };
}
function createHarness({ verified = VIPS, boosted = [], isAdmin = true, online = [] } = {}) {
const calls = [];
const replies = [];
const handler = createGainCommand({
io: createSocketRegistry(online),
listVerifiedUsers: () => verified,
listAudioGainBoostUsers: () => boosted,
grantAudioGainBoost: (selector, actor) => {
calls.push({ action: 'grant', selector, actor });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
revokeAudioGainBoost: (selector, actor) => {
calls.push({ action: 'revoke', selector, actor });
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
},
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
const message = {
actor: { id: 'admin', isAdmin },
reply: async (payload) => {
replies.push(payload);
return payload;
},
};
return { handler, message, calls, replies };
}
test('non-admins cannot manage the boost', async () => {
const { handler, message, calls, replies } = createHarness({ isAdmin: false });
await handler(message, ['grant', 'Croissant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /Only admins/);
});
test('grant resolves a VIP nickname to its stable user id', async () => {
const { handler, message, calls } = createHarness();
await handler(message, ['grant', 'croissant']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-vip', actor: 'admin' }]);
});
test('grant refuses a nickname that belongs to no VIP', async () => {
const { handler, message, calls, replies } = createHarness({ verified: [] });
await handler(message, ['grant', 'Stranger']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /not found/i);
});
test('revoke only matches users who currently hold the boost', async () => {
const { handler, message, calls, replies } = createHarness({ boosted: [] });
await handler(message, ['revoke', 'Croissant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /not found/i);
});
test('revoke resolves against the boosted list', async () => {
const { handler, message, calls } = createHarness({ boosted: [VIPS[0]] });
await handler(message, ['revoke', 'Croissant']);
assert.deepEqual(calls, [{ action: 'revoke', selector: 'usr-vip', actor: 'admin' }]);
});
test('grant without a target prints usage instead of acting', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['grant']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /rs gain grant/);
});
test('list defaults when no subcommand is given', async () => {
const { handler, message, replies } = createHarness({ boosted: [VIPS[0]] });
await handler(message, []);
assert.match(replies[0].content, /Croissant/);
assert.match(replies[0].content, /usr-vip/);
});
test('list reports an empty holder set', async () => {
const { handler, message, replies } = createHarness({ boosted: [] });
await handler(message, ['list']);
assert.match(replies[0].content, /No users hold/);
});
test('duplicate nicknames resolve to the account that is online', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-saul-b'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-b', actor: 'admin' }]);
assert.doesNotMatch(replies[0].content, /matched multiple records/i);
assert.match(replies[0].content, /2 accounts share that name; picked the one that is online/);
});
test('duplicate nicknames fall back to the first record when nobody is online', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: [],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
assert.match(replies[0].content, /none are online; picked the first/);
});
test('an unrelated online user does not influence the pick', async () => {
const { handler, message, calls } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-somebody-else'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
});
test('several duplicates online pick one deterministically rather than refusing', async () => {
const { handler, message, calls, replies } = createHarness({
verified: DUPLICATE_SAULS,
online: ['usr-saul-a', 'usr-saul-b'],
});
await handler(message, ['grant', 'Saul']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-a', actor: 'admin' }]);
assert.match(replies[0].content, /picked the one that is online/);
});
test('revoke disambiguates the same way against the boosted list', async () => {
const { handler, message, calls } = createHarness({
boosted: DUPLICATE_SAULS,
online: ['usr-saul-b'],
});
await handler(message, ['revoke', 'Saul']);
assert.deepEqual(calls, [{ action: 'revoke', selector: 'usr-saul-b', actor: 'admin' }]);
});
test('a unique nickname reports no disambiguation note', async () => {
const { handler, message, replies } = createHarness();
await handler(message, ['grant', 'Croissant']);
assert.doesNotMatch(replies[0].content, /accounts share that name/);
});
test('an exact cookieUserId still selects one record out of a duplicate pair', async () => {
const { handler, message, calls } = createHarness({ verified: DUPLICATE_SAULS });
await handler(message, ['grant', 'cu_5a5ffffffffb6add3']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-saul-b', actor: 'admin' }]);
});
test('a typo still resolves through the fuzzy matcher', async () => {
const { handler, message, calls } = createHarness();
await handler(message, ['grant', 'Croissnat']);
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-vip', actor: 'admin' }]);
});
test('help lists every subcommand', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['help']);
assert.deepEqual(calls, [], 'help must not change anything');
for (const fragment of ['rs gain list', 'rs gain grant <vip>', 'rs gain revoke <vip>', 'rs gain help']) {
assert.ok(replies[0].content.includes(fragment), `help should mention ${fragment}`);
}
});
test('an unknown subcommand falls back to the same help text', async () => {
const { handler, message, calls, replies } = createHarness();
await handler(message, ['sideways']);
assert.deepEqual(calls, []);
assert.match(replies[0].content, /Unknown gain command/);
assert.ok(replies[0].content.includes('rs gain grant <vip>'));
});
test('help stays admin-only like the rest of the command', async () => {
const { handler, message, replies } = createHarness({ isAdmin: false });
await handler(message, ['help']);
assert.match(replies[0].content, /Only admins/);
});
test('a service rejection is surfaced instead of thrown', async () => {
const { handler, message, replies } = createHarness();
const failing = createGainCommand({
io: createSocketRegistry([]),
listVerifiedUsers: () => VIPS,
listAudioGainBoostUsers: () => [],
grantAudioGainBoost: () => {
throw new Error('Only verified VIPs can be granted an audio gain boost.');
},
revokeAudioGainBoost: () => null,
sanitizeMentions: (value) => value,
config: { commands: { prefix: 'rs' } },
});
await failing(message, ['grant', 'Croissant']);
assert.match(replies[0].content, /Only verified VIPs/);
});
@@ -0,0 +1,37 @@
// Operator Green Command
// Purpose: Toggles the intentionally silly server-wide green visual and room-light mode.
// Scope: Keeps command presentation here while Home Assistant owns the runtime policy.
const { getCommandConfig } = require('../../operatorCommandService/config');
function createGreenCommand({ greenModeService, sanitizeMentions, config }) {
const { prefix: commandPrefix } = getCommandConfig(config);
return async function handleGreenCommand(message, tokens = []) {
const action = String(tokens.shift() || '').trim().toLowerCase();
if (action !== 'on' && action !== 'off') {
await message.reply({
content: `Invalid green command. Use \`${commandPrefix} green on\` or \`${commandPrefix} green off\`.`,
allowedMentions: { parse: [], repliedUser: false },
});
return;
}
try {
const enabled = action === 'on';
const result = await greenModeService.setEnabled(enabled, {
source: `bot-command:green:${action}`,
});
await message.reply({
content: sanitizeMentions(result ? 'Green mode enabled.' : 'Green mode disabled.'),
allowedMentions: { parse: [], repliedUser: false },
});
} catch (err) {
await message.reply({
content: sanitizeMentions(`Failed to update green mode: ${err.message}`),
allowedMentions: { parse: [], repliedUser: false },
});
}
};
}
module.exports = { createGreenCommand };
@@ -1,26 +1,76 @@
// Neato Feature Command
// Purpose: Exposes Neato state and supported actions through the shared text command route.
// Scope: Delegates device availability, Home Assistant calls, and operational errors to neatoService.
const NAVIGATION_MODES = Object.freeze({
normal: 'Normal',
gentle: 'Gentle',
deep: 'Deep',
quick: 'Quick',
});
function rawValue(value) {
// The command mirrors the Neato card's raw status contract. Only genuinely
// absent values receive a placeholder; known BrainSlug strings are not
// shortened, humanized, or interpreted by the command layer.
if (value == null || value === '') return 'unknown';
return String(value);
}
function describeState(state = {}) {
const telemetry = state.telemetry || {};
const connection = state.connected ? 'connected' : 'offline';
return `Neato: ${connection}; state ${telemetry.robotState || 'unknown'}; battery ${telemetry.batteryLevel ?? 'unknown'}%.`;
// batteryPercent is the canonical neatoService field. The old command read a
// nonexistent batteryLevel property, which made every status report unknown.
const battery = Number.isFinite(telemetry.batteryPercent)
? `${telemetry.batteryPercent}%`
: 'unknown';
const voltage = Number.isFinite(telemetry.batteryVoltage)
? `${telemetry.batteryVoltage.toFixed(2)} V`
: 'unknown';
return [
`Neato: ${connection}`,
`Battery: ${battery}`,
`Battery voltage: ${voltage}`,
`Robot alert: ${rawValue(telemetry.robotAlert)}`,
`Robot error: ${rawValue(telemetry.robotError)}`,
`Robot state: ${rawValue(telemetry.robotState)}`,
`UI state: ${rawValue(telemetry.uiState)}`,
].join('\n');
}
function createNeatoCommand({ neatoService, sanitizeMentions }) {
return async function handleNeatoCommand(message, tokens = []) {
const action = String(tokens.shift() || 'status').toLowerCase();
if (action === 'status') return message.reply({ content: describeState(neatoService.getState()) });
if (action === 'status') {
// Raw device strings still pass through the transport's mention sanitizer
// so Home Assistant state cannot create an accidental Discord mention.
return message.reply({ content: sanitizeMentions(describeState(neatoService.getState())) });
}
if (action === 'navigation') {
const requestedMode = String(tokens.shift() || '').toLowerCase();
const navigationMode = NAVIGATION_MODES[requestedMode];
if (!navigationMode || tokens.length > 0) {
return message.reply({ content: 'Invalid Neato navigation mode. Use `neato navigation normal`, `neato navigation gentle`, `neato navigation deep`, or `neato navigation quick`.' });
}
try {
await neatoService.setNavigationMode(navigationMode);
return message.reply({ content: `Neato navigation mode set to ${navigationMode}.` });
} catch (err) {
return message.reply({ content: sanitizeMentions(`Neato command failed: ${err.message}`) });
}
}
const actions = {
start: ['now cleaning', neatoService.startCleaning],
home: ['returning home', neatoService.sendHome],
locate: ['playing locate sound', neatoService.locateRobot],
'clear-errors': ['clearing errors', neatoService.clearErrors],
sound: ['playing sound', neatoService.locateRobot],
clear: ['clearing errors', neatoService.clearErrors],
};
const selected = actions[action];
if (!selected) {
return message.reply({ content: 'Invalid Neato command. Use `neato status`, `neato start`, `neato home`, `neato locate`, or `neato clear-errors`.' });
return message.reply({ content: 'Invalid Neato command. Use `neato status`, `neato start`, `neato home`, `neato sound`, `neato clear`, or `neato navigation <normal|gentle|deep|quick>`.' });
}
try {
@@ -0,0 +1,96 @@
// Neato Feature Command Tests
// Purpose: Pins the public Neato status report and its intentionally small control vocabulary.
// Scope: Uses a service double so hardware, Home Assistant, and access-mode behavior remain in their owning tests.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createNeatoCommand } = require('./neato');
function createHarness(state = {}) {
const replies = [];
const calls = [];
const neatoService = {
getState: () => state,
startCleaning: async () => calls.push(['start']),
sendHome: async () => calls.push(['home']),
locateRobot: async () => calls.push(['sound']),
clearErrors: async () => calls.push(['clear']),
setNavigationMode: async (mode) => calls.push(['navigation', mode]),
};
const handler = createNeatoCommand({ neatoService, sanitizeMentions: String });
const message = {
actor: { id: 'test-user' },
reply: async (payload) => replies.push(payload.content),
};
return { handler, message, replies, calls };
}
test('status reports the canonical battery fields and every raw UI status value', async () => {
const state = {
connected: true,
telemetry: {
batteryPercent: 82,
batteryVoltage: 14.671,
robotAlert: '200 (UI_ALERT_NONE)',
robotError: '200 (UI_ERROR_NONE)',
robotState: 'ROBOT_STATE_HOUSECLEANING',
uiState: 'UIMGR_STATE_HOUSECLEANINGRUNNING',
},
};
const { handler, message, replies } = createHarness(state);
await handler(message, ['status']);
assert.equal(replies[0], [
'Neato: connected',
'Battery: 82%',
'Battery voltage: 14.67 V',
'Robot alert: 200 (UI_ALERT_NONE)',
'Robot error: 200 (UI_ERROR_NONE)',
'Robot state: ROBOT_STATE_HOUSECLEANING',
'UI state: UIMGR_STATE_HOUSECLEANINGRUNNING',
].join('\n'));
});
test('bare neato status uses unknown only for values the service did not provide', async () => {
const { handler, message, replies } = createHarness({ connected: false, telemetry: {} });
await handler(message, []);
assert.match(replies[0], /^Neato: offline\nBattery: unknown\nBattery voltage: unknown/m);
assert.match(replies[0], /Robot alert: unknown/);
assert.match(replies[0], /UI state: unknown/);
});
test('sound and clear are the only names for their renamed actions', async () => {
const { handler, message, replies, calls } = createHarness();
await handler(message, ['sound']);
await handler(message, ['clear']);
await handler(message, ['locate']);
await handler(message, ['clear-errors']);
assert.deepEqual(calls, [['sound'], ['clear']]);
assert.match(replies[2], /Invalid Neato command/);
assert.match(replies[3], /Invalid Neato command/);
});
test('navigation normalizes command input to the exact service option', async () => {
const { handler, message, replies, calls } = createHarness();
await handler(message, ['navigation', 'gEnTlE']);
assert.deepEqual(calls, [['navigation', 'Gentle']]);
assert.equal(replies[0], 'Neato navigation mode set to Gentle.');
});
test('navigation rejects missing, unknown, and extra arguments', async () => {
const { handler, message, replies, calls } = createHarness();
await handler(message, ['navigation']);
await handler(message, ['navigation', 'turbo']);
await handler(message, ['navigation', 'normal', 'extra']);
assert.deepEqual(calls, []);
assert.equal(replies.length, 3);
for (const reply of replies) assert.match(reply, /navigation normal/);
});
@@ -0,0 +1,108 @@
// Operator Permissions Command
// Purpose: Lets administrators inspect and change registered positive user capabilities.
// Scope: Resolves canonical users and delegates persistence to identityService without embedding feature-specific permission logic.
const { mask, resolveIdentitySelector } = require('./resolvers');
const { getCommandConfig } = require('../config');
function commandCandidates(users = []) {
return users.map((user) => ({
...user,
userId: user.id,
cookieUserId: user.cookieUserIds?.[0] || null,
fingerprintId: user.fingerprintIds?.[0] || null,
}));
}
function createPermissionsCommand({
listUsersForAdmin,
listUsersWithPermission,
listRegisteredPermissions,
setUserPermission,
sanitizeMentions,
config,
}) {
const { prefix } = getCommandConfig(config);
const plain = { parse: [], repliedUser: false };
function helpText() {
return [
'**User permissions**',
`- \`${prefix} permissions\`: list available permissions.`,
`- \`${prefix} permissions list <permission>\`: list users with a permission.`,
`- \`${prefix} permissions grant <permission> <user>\`: grant a permission.`,
`- \`${prefix} permissions revoke <permission> <user>\`: revoke a permission.`,
].join('\n');
}
function resolvePermission(selector) {
const needle = String(selector || '').trim().toLowerCase();
return listRegisteredPermissions().find((permission) => (
permission.key.toLowerCase() === needle || permission.commandName.toLowerCase() === needle
)) || null;
}
return async function handlePermissionsCommand(message, tokens = []) {
if (!message.actor?.isAdmin) {
return message.reply({ content: 'Only admins can manage user permissions.', allowedMentions: plain });
}
const action = (tokens.shift() || 'help').toLowerCase();
if (action === 'help') return message.reply({ content: helpText(), allowedMentions: plain });
if (action !== 'list' && action !== 'grant' && action !== 'revoke') {
return message.reply({ content: `Unknown permissions command.\n${helpText()}`, allowedMentions: plain });
}
const permissionSelector = tokens.shift();
if (!permissionSelector && action === 'list') {
const lines = listRegisteredPermissions().map((permission) => (
`- \`${permission.commandName}\`: ${permission.description}`
));
return message.reply({ content: ['Registered user permissions:', ...lines].join('\n'), allowedMentions: plain });
}
const permission = resolvePermission(permissionSelector);
if (!permission) {
return message.reply({ content: `Unknown permission. Use \`${prefix} permissions list\` to see valid names.`, allowedMentions: plain });
}
if (action === 'list') {
const users = listUsersWithPermission(permission.key);
if (!users.length) return message.reply({ content: `No users have ${permission.label}.`, allowedMentions: plain });
const lines = users.map((user, index) => (
`${index + 1}. ${user.nickname || 'unknown'} | ${user.id} | ${mask(user.cookieUserIds?.[0])}`
));
return message.reply({
content: sanitizeMentions([`${permission.label}:`, ...lines].join('\n').slice(0, 1900)),
allowedMentions: plain,
});
}
const selector = tokens.join(' ').trim();
if (!selector) {
return message.reply({
content: `Usage: \`${prefix} permissions ${action} ${permission.commandName} <user>\``,
allowedMentions: plain,
});
}
const resolved = resolveIdentitySelector(selector, commandCandidates(listUsersForAdmin()));
if (resolved.error) return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: plain });
try {
const user = setUserPermission(resolved.record.id, permission.key, {
enabled: action === 'grant',
actor: message.actor?.id || null,
at: Date.now(),
});
return message.reply({
content: sanitizeMentions(`${action === 'grant' ? 'Granted' : 'Revoked'} ${permission.label} for ${user.nickname || 'unknown'} (${user.id}).`),
allowedMentions: plain,
});
} catch (err) {
return message.reply({ content: sanitizeMentions(`Permission update failed: ${err.message}`), allowedMentions: plain });
}
};
}
module.exports = { createPermissionsCommand };
@@ -0,0 +1,64 @@
// Operator Permissions Command Tests
// Purpose: Pins admin authorization and the universal grant, revoke, and list command contract.
// Scope: Uses in-memory identity doubles; database persistence is tested by identityService.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createPermissionsCommand } = require('./permissions');
const USER = {
id: 'usr_11111111111111111111111111111111',
nickname: 'alice',
cookieUserIds: ['cu_11111111111111111111111111111111'],
fingerprintIds: [],
knownIps: [],
};
const PERMISSION = {
key: 'audio.personalAdjustment',
commandName: 'audio-adjustment',
label: 'Personal audio adjustment',
description: 'Allows personal volume adjustments.',
};
function harness({ isAdmin = true, granted = [] } = {}) {
const replies = [];
const changes = [];
const handler = createPermissionsCommand({
listUsersForAdmin: () => [USER],
listUsersWithPermission: () => granted,
listRegisteredPermissions: () => [PERMISSION],
setUserPermission: (userId, permissionKey, options) => {
changes.push({ userId, permissionKey, options });
return USER;
},
sanitizeMentions: String,
config: { commands: { prefix: 'rs' } },
});
const message = {
actor: { id: 'admin-1', isAdmin },
reply: async (payload) => replies.push(payload.content),
};
return { handler, message, replies, changes };
}
test('non-admin users cannot inspect or change grants', async () => {
const { handler, message, replies } = harness({ isAdmin: false });
await handler(message, ['list']);
assert.match(replies[0], /Only admins/);
});
test('grant resolves a user and writes the registered permission key', async () => {
const { handler, message, changes, replies } = harness();
await handler(message, ['grant', 'audio-adjustment', 'alice']);
assert.equal(changes[0].userId, USER.id);
assert.equal(changes[0].permissionKey, PERMISSION.key);
assert.equal(changes[0].options.enabled, true);
assert.match(replies[0], /Granted Personal audio adjustment/);
});
test('list shows users holding a permission', async () => {
const { handler, message, replies } = harness({ granted: [USER] });
await handler(message, ['list', 'audio-adjustment']);
assert.match(replies[0], /alice/);
assert.match(replies[0], new RegExp(USER.id));
});
@@ -1,70 +0,0 @@
// Operator Command Cooldowns
// Purpose: Rate limits individual commands per actor without coupling to a transport.
// Scope: In-memory only; a restart clears every cooldown by design.
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000;
/*
Fun commands are reachable from both site chat and Discord, and site chat's own
rate limit only bounds messages per socket rather than a specific command. A
per-actor, per-command gate is what stops one person turning `rs honk` into a
siren, and it is deliberately in-memory: a cooldown that survives a restart
would be a moderation feature, not a spam guard.
*/
function createCooldownGate({ sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS } = {}) {
const expiries = new Map();
let lastSweep = 0;
function sweep(now) {
if (now - lastSweep < sweepIntervalMs) return;
lastSweep = now;
expiries.forEach((expiresAt, key) => {
if (expiresAt <= now) expiries.delete(key);
});
}
function remaining(key, now = Date.now()) {
const expiresAt = expiries.get(String(key));
if (!expiresAt) return 0;
return Math.max(0, expiresAt - now);
}
/*
Returns the remaining wait when the gate is closed, or 0 after arming the
next window. Callers therefore treat any non-zero result as a refusal, and a
refused call never extends the existing window.
*/
function consume(key, windowMs, now = Date.now()) {
const normalizedKey = String(key || '').trim();
if (!normalizedKey) return 0;
const window = Number(windowMs);
if (!Number.isFinite(window) || window <= 0) return 0;
sweep(now);
const wait = remaining(normalizedKey, now);
if (wait > 0) return wait;
expiries.set(normalizedKey, now + window);
return 0;
}
function clear(key) {
expiries.delete(String(key || '').trim());
}
function reset() {
expiries.clear();
lastSweep = 0;
}
return { consume, remaining, clear, reset };
}
function describeWait(waitMs) {
const seconds = Math.ceil(Number(waitMs || 0) / 1000);
if (seconds <= 1) return '1s';
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
return rest ? `${minutes}m ${rest}s` : `${minutes}m`;
}
module.exports = { createCooldownGate, describeWait };
@@ -1,53 +0,0 @@
// Operator Command Cooldown Tests
// Purpose: Verifies the per-actor rate limit opens and closes on the boundaries callers rely on.
// Scope: Pure; the gate takes an injected clock so no test needs to sleep.
const test = require('node:test');
const assert = require('node:assert/strict');
const { createCooldownGate, describeWait } = require('./cooldowns');
test('first use passes and an immediate repeat is refused', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('bonk:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 1000, 0), 1000);
assert.equal(gate.consume('bonk:alice', 1000, 400), 600);
});
test('the window reopens exactly when it expires', () => {
const gate = createCooldownGate();
gate.consume('honk:alice', 1000, 0);
assert.equal(gate.consume('honk:alice', 1000, 999), 1);
assert.equal(gate.consume('honk:alice', 1000, 1000), 0);
});
test('a refused call does not extend the existing window', () => {
const gate = createCooldownGate();
gate.consume('honk:alice', 1000, 0);
// Hammering the gate at t=500 must not push the reopen time out to t=1500.
gate.consume('honk:alice', 1000, 500);
gate.consume('honk:alice', 1000, 900);
assert.equal(gate.consume('honk:alice', 1000, 1000), 0);
});
test('cooldowns are scoped per key so different actors and commands do not collide', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('bonk:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:bob', 1000, 0), 0);
assert.equal(gate.consume('hug:alice', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 1000, 0), 1000);
});
test('a missing key or non-positive window never gates', () => {
const gate = createCooldownGate();
assert.equal(gate.consume('', 1000, 0), 0);
assert.equal(gate.consume('bonk:alice', 0, 0), 0);
assert.equal(gate.consume('bonk:alice', -5, 0), 0);
// None of the above should have armed anything.
assert.equal(gate.remaining('bonk:alice', 0), 0);
});
test('describeWait rounds up and switches to minutes', () => {
assert.equal(describeWait(1), '1s');
assert.equal(describeWait(4200), '5s');
assert.equal(describeWait(60 * 1000), '1m');
assert.equal(describeWait(95 * 1000), '1m 35s');
});
@@ -25,7 +25,7 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = ''
const requestedCategory = normalizedTopic === 'feature' ? 'features' : normalizedTopic;
const categoryNames = requestedCategory && CATEGORIES[requestedCategory]
? [requestedCategory]
: ['system', 'admin', 'features', 'fun', ...(includeDiscord ? ['discord'] : [])];
: ['system', 'admin', 'features', ...(includeDiscord ? ['discord'] : [])];
const output = ['**Rover Bot Commands**'];
for (const categoryName of categoryNames) {
@@ -36,7 +36,9 @@ function formatHelp({ commandPrefix = 'rs', timeStatusCommand = 'ts', topic = ''
const entry = entries[name];
if (!entry?.usage?.length) continue;
const availability = entry.requiredFeature && !isFeatureEnabled(entry.requiredFeature) ? ' *(unavailable)*' : '';
output.push(`\`${entry.usage[0]}\`${entry.summary}${availability}`);
// A colon stays readable in both Discord and site chat while avoiding the
// typographic punctuation that made command help awkward to copy or edit.
output.push(`- \`${entry.usage[0]}\`: ${entry.summary}${availability}`);
}
}
output.push('', `Use \`${prefix} help <command|category>\` for details.`);
@@ -0,0 +1,37 @@
// Operator Command Help Tests
// Purpose: Protects the shared chat-help layout and Discord's hard message-size boundary.
// Scope: Tests rendered help text only; command execution and permissions remain dispatcher concerns.
const test = require('node:test');
const assert = require('node:assert/strict');
const { formatHelp } = require('./help');
// Discord rejects a normal message above 2,000 characters instead of silently
// splitting it. Keeping this assertion beside the help renderer prevents a new
// command description from making `rs help` fail only on the Discord transport.
const DISCORD_MESSAGE_LIMIT = 2000;
test('complete Discord help fits in one message and uses simple punctuation', () => {
const help = formatHelp({ includeDiscord: true, isFeatureEnabled: () => true });
assert.ok(help.length <= DISCORD_MESSAGE_LIMIT, `help is ${help.length} characters`);
assert.doesNotMatch(help, /—/);
assert.match(help, /- `rs status \[rover\]`: Show rover status/);
assert.match(help, /\*\*Discord\*\*/);
});
test('removed fun commands and their category are absent from help', () => {
const help = formatHelp({ includeDiscord: true, isFeatureEnabled: () => true });
assert.doesNotMatch(help, /\*\*Fun\*\*/);
for (const command of ['bonk', 'hug', 'slap', 'bonkboard', '8ball', 'roll', 'coin', 'ship', 'rate', 'uwu', 'wanted', 'pet', 'snitch', 'honk', 'boo', 'spin', 'disco', 'vibecheck']) {
assert.doesNotMatch(help, new RegExp(`rs ${command}(?:\\s|\\x60)`), `${command} should not be advertised`);
}
});
test('detailed help keeps usage readable without em dashes', () => {
const help = formatHelp({ topic: 'deter', includeDiscord: true, isFeatureEnabled: () => true });
assert.match(help, /\*\*deter\*\*/);
assert.match(help, /Usage:\n- `rs deter list`/);
assert.doesNotMatch(help, /—/);
});
@@ -8,15 +8,12 @@ const { createReasonCommand } = require('./commands/reason');
const { createGoalCommand } = require('./commands/goal');
const { createVerifyCommand } = require('./commands/verify');
const { createDeterCommand } = require('./commands/deter');
const { createGainCommand } = require('./commands/gain');
const { createPermissionsCommand } = require('./commands/permissions');
const { createLightsCommand } = require('./commands/lights');
const { createGreenCommand } = require('./commands/green');
const { createKickCommand } = require('./commands/kick');
const { createLiftCommand } = require('./commands/lift');
const { createNeatoCommand } = require('./commands/neato');
const { createFunTextCommands } = require('./commands/funText');
const { createFunStatsCommands } = require('./commands/funStats');
const { createFunRoverCommands } = require('./commands/funRover');
const { createCooldownGate } = require('./cooldowns');
const { getCommandConfig } = require('./config');
const { buildCommandRegistry } = require('./registry');
@@ -61,28 +58,15 @@ function createCommandHandlers(deps) {
const handleGoalCommand = createGoalCommand(deps);
const handleVerifyCommand = createVerifyCommand(deps);
const handleDeterCommand = createDeterCommand(deps);
const handleGainCommand = createGainCommand(deps);
const handlePermissionsCommand = createPermissionsCommand(deps);
const handleBridgeCommand = transportHandlers.bridge;
const handleTimeStatusCommand = transportHandlers.timeStatus;
const handleLightsCommand = createLightsCommand(deps);
const handleGreenCommand = createGreenCommand(deps);
const handleKickCommand = createKickCommand(deps);
const handleLiftCommand = createLiftCommand(deps);
const handleNeatoCommand = createNeatoCommand(deps);
/*
Fun commands share one cooldown gate. Web chat rebuilds this router per
message, so an injected gate is what makes the cooldowns actually shared
across a user's messages; a gate created here would be discarded every time
and rate limit nothing. Falling back to a local gate keeps the router usable
on its own (and in tests) without making every caller supply one.
*/
const cooldowns = deps.commandCooldowns || createCooldownGate();
const funHandlers = {
...createFunTextCommands({ ...deps, cooldowns }),
...createFunStatsCommands({ ...deps, cooldowns }),
...createFunRoverCommands({ ...deps, cooldowns }),
};
function stripCommandPrefix(content) {
const trimmed = String(content || '').trim();
const lower = trimmed.toLowerCase();
@@ -126,10 +110,8 @@ function createCommandHandlers(deps) {
// is included because its lock/unlock subcommands change room policy. Its
// ordinary on/off/color actions are also intentionally restricted to a
// lockdown admin while the entire server is in lockdown.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'gain', 'lights', 'kick', 'lift', 'neato']);
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'permissions', 'lights', 'green', 'kick', 'lift', 'neato']);
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
const isPublicCommand = commandDefinition?.permission === 'public';
const isFunCommand = commandDefinition?.category === 'fun';
// Feature commands are public activities while access is open or managed
// by turns. In admin mode they follow the same admin-only boundary as rover
@@ -141,17 +123,12 @@ function createCommandHandlers(deps) {
return;
}
if (!isAccessModeCommand && !isPublicCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) {
if (!isAccessModeCommand && !isAdmin && !SELF_GATED_ACTIONS.has(action)) {
await request.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
/*
Lockdown exists to make the server quiet and controlled, so the whole fun
category is suspended alongside the moderation-sensitive actions rather than
leaving a horn command reachable by anyone while the fleet is locked down.
*/
if (mode === MODES.LOCKDOWN && (moderationActions.has(action) || isFunCommand) && !isLockdownAdmin) {
if (mode === MODES.LOCKDOWN && moderationActions.has(action) && !isLockdownAdmin) {
await request.reply({ content: 'Lockdown mode: only lockdown admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
return;
}
@@ -169,6 +146,8 @@ function createCommandHandlers(deps) {
return handleBridgeCommand(request, tokens);
case 'lights':
return handleLightsCommand(request, tokens);
case 'green':
return handleGreenCommand(request, tokens);
case 'kick':
return handleKickCommand(request, rest);
case 'lift':
@@ -189,10 +168,9 @@ function createCommandHandlers(deps) {
return handleVerifyCommand(request, tokens);
case 'deter':
return handleDeterCommand(request, tokens);
case 'gain':
return handleGainCommand(request, tokens);
case 'permissions':
return handlePermissionsCommand(request, tokens);
default:
if (funHandlers[action]) return funHandlers[action](request, tokens);
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
}
}
@@ -1,5 +1,5 @@
// Operator Command Dispatcher Tests
// Purpose: Pins the permission, mode, and prefix policy that the registry-driven gate replaced a hardcoded action list with.
// Purpose: Pins shared command permissions, access modes, and prefix parsing.
// Scope: Exercises the router with doubles; individual command behavior is covered by each command's own tests.
const test = require('node:test');
const assert = require('node:assert/strict');
@@ -10,8 +10,6 @@ const ADMIN_DENIAL = /Only admins can run that command/;
const LOCKDOWN_DENIAL = /Lockdown mode: only lockdown admins/;
const FEATURE_DENIAL = /Admin mode: only admins can run feature commands/;
const ALICE = { id: 's1', data: { userId: 'u-alice', nickname: 'alice' } };
function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
const rovers = new Map([['rover-1', {
id: 'rover-1',
@@ -22,34 +20,33 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
const { handleCommand } = createCommandHandlers({
logger: { warn: () => {}, info: () => {} },
io: { sockets: { sockets: new Map([[ALICE.id, ALICE]]) } },
io: { sockets: { sockets: new Map() } },
rovers,
roverManager: {
canDrive: () => true,
getPrimaryRoverForSocket: () => 'rover-1',
applyPrivateDriveSafety: () => null,
},
roverManager: {},
getMode: () => mode,
MODES,
getNickname: (entry) => entry?.data?.nickname || '',
getActiveDrivers: () => ({}),
getActorSocket: () => ALICE,
issueCommand: () => 'cmd-1',
isFeatureEnabled: () => featureEnabled,
sanitizeMentions: (text) => String(text || ''),
funStatsService: {
bumpActorStats: () => ({}),
getActorStats: () => ({}),
listActorStats: () => [],
bumpRoverPets: () => 1,
getRoverPets: () => 0,
},
homeAssistantService: { getLightPolicyState: () => ({}), setAllControllableEntitiesState: () => Promise.resolve() },
// Green mode is admin-only at the dispatcher, so this double only needs to
// prove that an authorized command reaches the small standalone service.
greenModeService: { setEnabled: (enabled) => Promise.resolve(Boolean(enabled)) },
liftService: null,
neatoService: null,
listVerifiedUsers: () => [],
listDeterredUsers: () => [],
listMutedUsers: () => [],
listUsersForAdmin: () => [],
listUsersWithPermission: () => [],
listRegisteredPermissions: () => [{
key: 'audio.personalAdjustment',
commandName: 'audio-adjustment',
label: 'Personal audio adjustment',
description: 'Allows personal volume adjustments.',
}],
setUserPermission: () => null,
getGlobalObjective: () => null,
getAdminReason: () => null,
config: { commands: { prefix: 'rs' } },
@@ -75,20 +72,10 @@ function createRouter({ mode = MODES.OPEN, featureEnabled = true } = {}) {
const nonAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: false, isLockdownAdmin: false };
const admin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: false };
const lockdownAdmin = { id: 's1', userId: 'u-alice', label: 'alice', isAdmin: true, isLockdownAdmin: true };
test('a non-admin can run fun commands', async () => {
const run = createRouter();
for (const command of ['rs coin', 'rs rate carpet', 'rs uwu hi', 'rs bonkboard', 'rs vibecheck', 'rs snitch']) {
const reply = await run(command, nonAdmin);
assert.doesNotMatch(reply, ADMIN_DENIAL, `${command} should be public`);
assert.ok(reply.length > 0, `${command} should reply`);
}
});
test('admin-only commands stay admin-only for a non-admin', async () => {
const run = createRouter();
for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs kick alice']) {
for (const command of ['rs lock rover-1', 'rs unlock rover-1', 'rs mode open', 'rs green on', 'rs kick alice', 'rs permissions list']) {
assert.match(await run(command, nonAdmin), ADMIN_DENIAL, `${command} must stay admin-only`);
}
});
@@ -109,39 +96,29 @@ test('system commands remain reachable by anyone', async () => {
assert.match(await run('rs help', nonAdmin), /Rover Bot Commands/);
});
test('the fun category appears in help', async () => {
const run = createRouter();
const help = await run('rs help fun', nonAdmin);
assert.match(help, /\*\*Fun\*\*/);
for (const command of ['bonk', 'honk', 'disco', 'vibecheck', 'bonkboard']) {
assert.match(help, new RegExp(`rs ${command}`), `help should list ${command}`);
}
});
test('admin mode restricts access-mode feature commands but not the public fun ones', async () => {
test('admin mode restricts access-mode feature commands', async () => {
const run = createRouter({ mode: MODES.ADMIN });
assert.match(await run('rs lights on', nonAdmin), FEATURE_DENIAL);
assert.match(await run('rs disco', nonAdmin), FEATURE_DENIAL);
assert.doesNotMatch(await run('rs coin', nonAdmin), FEATURE_DENIAL);
});
test('lockdown suspends the whole fun category for anyone but a lockdown admin', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
for (const command of ['rs coin', 'rs bonk bob', 'rs honk', 'rs disco', 'rs vibecheck']) {
assert.match(await run(command, nonAdmin), LOCKDOWN_DENIAL, `${command} should be suspended in lockdown`);
}
// A plain admin is not enough during lockdown.
assert.match(await run('rs coin', admin), LOCKDOWN_DENIAL);
assert.doesNotMatch(await run('rs coin', lockdownAdmin), LOCKDOWN_DENIAL);
});
test('lockdown still suspends the pre-existing moderation-sensitive commands', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
for (const command of ['rs lock rover-1', 'rs mode open', 'rs lights on', 'rs goal', 'rs kick alice']) {
for (const command of ['rs lock rover-1', 'rs mode open', 'rs lights on', 'rs green on', 'rs goal', 'rs kick alice']) {
assert.match(await run(command, admin), LOCKDOWN_DENIAL, `${command} should stay lockdown-gated`);
}
});
test('admins can toggle green mode through the shared command path', async () => {
const run = createRouter();
assert.match(await run('rs green on', admin), /Green mode enabled/);
assert.match(await run('rs green off', admin), /Green mode disabled/);
});
test('green mode rejects unknown actions with focused usage guidance', async () => {
const run = createRouter();
assert.match(await run('rs green maybe', admin), /rs green on.*rs green off/);
});
test('status and help survive lockdown', async () => {
const run = createRouter({ mode: MODES.LOCKDOWN });
assert.match(await run('rs status', nonAdmin), /\[status handler\]/);
@@ -150,10 +127,14 @@ test('status and help survive lockdown', async () => {
test('a disabled required feature is reported before any permission check', async () => {
const run = createRouter({ featureEnabled: false });
assert.match(await run('rs disco', nonAdmin), /Home Assistant feature is not configured/);
assert.match(await run('rs lights on', nonAdmin), /Home Assistant feature is not configured/);
});
test('green mode remains available without optional Home Assistant features', async () => {
const run = createRouter({ featureEnabled: false });
assert.match(await run('rs green on', admin), /Green mode enabled/);
});
test('ordinary words that merely start with the prefix are not commands', async () => {
const run = createRouter();
assert.equal(await run('rsvp', nonAdmin), '');
@@ -171,11 +152,10 @@ test('an unknown command is not treated as public', async () => {
test('bot actors are ignored entirely', async () => {
const run = createRouter();
assert.equal(await run('rs coin', { ...nonAdmin, bot: true }), '');
assert.equal(await run('rs status', { ...nonAdmin, bot: true }), '');
});
test('command matching is case insensitive', async () => {
const run = createRouter();
assert.doesNotMatch(await run('RS COIN', nonAdmin), ADMIN_DENIAL);
assert.match(await run('Rs Status', nonAdmin), /\[status handler\]/);
});
@@ -3,15 +3,8 @@
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
const CATEGORIES = {
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'gain'] },
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'green', 'kick', 'verify', 'deter', 'permissions'] },
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
fun: {
title: 'Fun',
names: [
'bonk', 'hug', 'slap', 'bonkboard', '8ball', 'roll', 'coin', 'ship', 'rate', 'uwu',
'wanted', 'pet', 'snitch', 'honk', 'boo', 'spin', 'disco', 'vibecheck',
],
},
discord: { title: 'Discord', names: ['bridge'] },
};
@@ -26,6 +19,10 @@ function buildCommandRegistry(prefix, timeCommand) {
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
// Green mode is fundamentally a server theme. Home Assistant lighting is
// an optional enhancement, so the command must remain available when that
// integration is absent.
green: { category: 'admin', summary: 'Toggle green room and page mode.', usage: [`${prefix} green <on|off>`], access: 'Admin', permission: 'admin' },
lights: {
category: 'features',
summary: 'Control room lights or manage the admin light lock.',
@@ -53,55 +50,31 @@ function buildCommandRegistry(prefix, timeCommand) {
access: 'Lockdown admin',
permission: 'lockdown-admin',
},
gain: {
permissions: {
category: 'admin',
summary: 'Manage the VIP audio gain boost that raises a user\'s volume ceiling past the global gains.',
summary: 'Manage registered user permissions.',
usage: [
`${prefix} gain list`,
`${prefix} gain grant <vip>`,
`${prefix} gain revoke <vip>`,
`${prefix} gain help`,
`${prefix} permissions list [permission]`,
`${prefix} permissions grant <permission> <user>`,
`${prefix} permissions revoke <permission> <user>`,
],
access: 'Admin',
permission: 'admin',
},
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
/*
Fun commands are the first entries to use `permission: 'public'`. Before
they existed, every non-admin-reachable command was named in a hardcoded
allowlist in the dispatcher; declaring the permission here instead means a
new fun command does not need a dispatcher edit to be usable.
*/
bonk: { category: 'fun', summary: 'Bonk someone. Keeps a running tally.', usage: [`${prefix} bonk <user>`], access: 'Public', permission: 'public' },
hug: { category: 'fun', summary: 'Hug someone.', usage: [`${prefix} hug <user>`], access: 'Public', permission: 'public' },
slap: { category: 'fun', summary: 'Slap someone with a random object.', usage: [`${prefix} slap <user>`], access: 'Public', permission: 'public' },
bonkboard: { category: 'fun', summary: 'Show the bonk and hug leaderboards.', usage: [`${prefix} bonkboard`], access: 'Public', permission: 'public' },
'8ball': { category: 'fun', summary: 'Ask the magic 8 ball. Same question always gets the same answer.', usage: [`${prefix} 8ball <question>`], access: 'Public', permission: 'public' },
roll: { category: 'fun', summary: 'Roll dice.', usage: [`${prefix} roll [NdN]`], access: 'Public', permission: 'public' },
coin: { category: 'fun', summary: 'Flip a coin.', usage: [`${prefix} coin`], access: 'Public', permission: 'public' },
ship: { category: 'fun', summary: 'Rate a pairing out of 100.', usage: [`${prefix} ship <a> and <b>`], access: 'Public', permission: 'public' },
rate: { category: 'fun', summary: 'Rate anything out of 10.', usage: [`${prefix} rate <thing>`], access: 'Public', permission: 'public' },
uwu: { category: 'fun', summary: 'Ruin some text.', usage: [`${prefix} uwu <text>`], access: 'Public', permission: 'public' },
wanted: { category: 'fun', summary: 'Issue a wanted poster.', usage: [`${prefix} wanted <user>`], access: 'Public', permission: 'public' },
pet: { category: 'fun', summary: 'Pet a rover. Each rover keeps its own count.', usage: [`${prefix} pet [rover]`], access: 'Public', permission: 'public' },
snitch: { category: 'fun', summary: 'Report who is driving what.', usage: [`${prefix} snitch`], access: 'Public', permission: 'public' },
// honk and spin move hardware, so their handlers additionally require that the
// caller actually holds control of the rover they name.
honk: { category: 'fun', summary: 'Sound a short horn toot on a rover you control.', usage: [`${prefix} honk [rover]`], access: 'Public; requires control of the rover', permission: 'public' },
boo: { category: 'fun', summary: 'Speak a taunt through the rover someone is driving.', usage: [`${prefix} boo <user>`], access: 'Public', permission: 'public' },
spin: { category: 'fun', summary: 'Make a rover you control do a spin.', usage: [`${prefix} spin [rover]`], access: 'Public; requires control of the rover', permission: 'public' },
vibecheck: { category: 'fun', summary: 'Judge a rover\'s vibes and report its battery.', usage: [`${prefix} vibecheck [rover]`], access: 'Public', permission: 'public' },
disco: {
category: 'fun',
summary: 'Strobe the room lights briefly.',
usage: [`${prefix} disco`],
access: 'Public unless server access is restricted; obeys the room-light lock',
neato: {
category: 'features',
summary: 'Show or control Neato.',
usage: [
`${prefix} neato <status|start|home|sound|clear>`,
`${prefix} neato navigation <normal|gentle|deep|quick>`,
],
access: 'Public unless server access is restricted',
permission: 'access-mode',
requiredFeature: 'homeAssistant',
unavailableLabel: 'Home Assistant',
requiredFeature: 'neato',
unavailableLabel: 'Neato',
},
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
};
}
@@ -84,10 +84,29 @@ function listGrantedRoversForRequester(requesterKey) {
}
function applySocketGrantCache(socket) {
if (!socket) return;
if (!socket) return false;
socket.data = socket.data || {};
const requesterKey = buildRequesterKey(socket);
socket.data.privateClosedAccessRovers = listGrantedRoversForRequester(requesterKey);
const nextRoverIds = listGrantedRoversForRequester(requesterKey).sort();
const previousRoverIds = Array.isArray(socket.data.privateClosedAccessRovers)
? socket.data.privateClosedAccessRovers
: [];
const requesterChanged = socket.data.privateAccessRequesterKey !== requesterKey;
const grantsChanged = previousRoverIds.length !== nextRoverIds.length
|| previousRoverIds.some((roverId, index) => String(roverId) !== String(nextRoverIds[index]));
/*
Remember both inputs that determine the socket's private-access projection.
The requester key matters even when both identities currently have no grants,
because pending requests in the session payload are also keyed by requester.
Returning whether either input changed lets transport hooks avoid announcing
a false state change for ordinary live identity-setting updates.
*/
socket.data.privateAccessRequesterKey = requesterKey;
if (grantsChanged) {
socket.data.privateClosedAccessRovers = nextRoverIds;
}
return requesterChanged || grantsChanged;
}
function refreshAllSocketGrantCaches() {
@@ -71,8 +71,15 @@ function registerPrivateRoverAccessHooks(deps) {
socket.on('privateRover:requestAccess', handleRequest);
socket.on('session:privateRover:requestAccess', handleRequest);
socket.on('session:identify', () => {
applySocketGrantCache(socket);
requestEvents.emit('change', { reason: 'identify', socketId: socket.id });
/*
session:identify also carries unrelated live preferences such as audio
adjustments. Only publish a private-access change when identification
actually moved this socket to a different requester or grant set; a
no-op event must not fan out a full session sync to every connection.
*/
if (applySocketGrantCache(socket)) {
requestEvents.emit('change', { reason: 'identify', socketId: socket.id });
}
});
});
}
+17 -10
View File
@@ -17,6 +17,7 @@ const {
ptzCameraEvents,
} = require('../ptzCameraService');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
const { isEnabled: isGreenModeEnabled, greenModeEvents } = require('../greenModeService');
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService');
const { getState: getKinectState, kinectEvents } = require('../kinectService');
@@ -53,7 +54,7 @@ const {
getUserIdForSocket,
} = require('../identityService');
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
const { getAudioLevels, getAudioGainStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
const { getAudioLevels, getAudioAdjustmentStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
const { getButtonBoxState } = require('../buttonBoxService');
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
const {
@@ -199,6 +200,9 @@ function buildSession(socket) {
roomCameras: getRoomCameras(),
ptzCamera: getPtzCameraState(socket),
homeAssistant: getHomeAssistantState(),
// Green mode is a server-wide visual feature. It stays separate from Home
// Assistant state because HA only supplies the generic light operations.
greenMode: isGreenModeEnabled(),
neato: getNeatoState(),
lift: getLiftState(),
kinect: getKinectState(),
@@ -231,7 +235,7 @@ function buildSession(socket) {
isVerified: Boolean(socket?.data?.isVerified),
audioForward: getAudioForwardState(),
audioLevels: getAudioLevels(),
audioGains: getAudioGainStateForSocket(socket),
audioAdjustments: getAudioAdjustmentStateForSocket(socket),
buttonBox: getButtonBoxState(),
/*
Inter-instance state is a read-only directory snapshot. It is included in
@@ -391,6 +395,11 @@ homeAssistantEvents.on('update', () => {
syncAll();
});
greenModeEvents.on('change', () => {
logger.info('Green mode change detected; syncing all clients');
syncAll();
});
homeAssistantEvents.on('status', () => {
logger.info('Home Assistant status change; syncing all clients');
syncAll();
@@ -463,16 +472,14 @@ audioForwardEvents.on('change', () => {
syncAll();
});
audioLevelsEvents.on('change', ({ scope, userId } = {}) => {
audioLevelsEvents.on('change', ({ scope, socketId } = {}) => {
/*
A user dragging their own volume slider only changes their own payload, so
it resyncs just that user's tabs. Admin gain and cap edits still change
everyone's ceiling and keep the full broadcast.
A browser dragging its own volume slider changes only that connection's
payload. Base-level and allowed-range changes still affect everyone and use
the full broadcast path.
*/
if (scope === 'user' && userId) {
io.sockets.sockets.forEach((socket) => {
if (getUserIdForSocket(socket) === userId) syncSocket(socket);
});
if (scope === 'socket' && socketId) {
syncSocket(io.sockets.sockets.get(socketId));
return;
}
syncAll();
@@ -27,11 +27,9 @@ const {
setVerified,
setDeterrence,
setMuted,
setAudioGainBoost,
listVerifiedUsers,
listDeterredUsers,
listMutedUsers,
listAudioGainBoostUsers,
resolveUserBySelector,
userToLegacyIdentityEntry,
} = require('../identityService');
@@ -113,17 +111,10 @@ function refreshSocketIdentityFlags(socket) {
operational chat/audio tools while preserving the flag for normal roles.
*/
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
/*
Admins already control the global gain settings, so they carry the raised
audio ceiling implicitly for the same reason they carry verification: a
stored per-user grant should never be what gates an operator's own tools.
*/
socket.data.hasAudioGainBoost = verifiedByRole || Boolean(user.audioGainBoost?.enabled);
return {
isVerified: socket.data.isVerified,
isDeterred: socket.data.isDeterred,
isMuted: socket.data.isMuted,
hasAudioGainBoost: socket.data.hasAudioGainBoost,
matchedRecordId: user.id,
reason: socket.data.isVerified ? 'matched' : 'no_match',
userId: user.id,
@@ -133,6 +124,17 @@ function refreshSocketIdentityFlags(socket) {
function identifySocket(socket, payload = {}) {
if (!socket) throw new Error('Socket required');
/*
Personal audio percentages travel with portable browser identity but are not
identity database state. Keeping the raw object on this connection lets the
audio service apply its current permission and range policy after canonical
identity resolution, including during handshake authentication.
*/
socket.data = socket.data || {};
socket.data.audioAdjustments = payload?.audioAdjustments && typeof payload.audioAdjustments === 'object'
? { ...payload.audioAdjustments }
: {};
const incomingNickname = sanitizeNickname(payload.nickname);
if (incomingNickname) {
try {
@@ -572,53 +574,6 @@ function setUserMute(selector, enabled, actor = null) {
return userToLegacyIdentityEntry(user);
}
/*
Audio gain boost is a VIP-only grant. resolveUserBySelector's `includeVerified`
flag reads as an inclusion toggle but actually relaxes the verified filter, so
passing `false` is what restricts the nickname lookup to verified users. That
keeps an unverified visitor from being matched by a shared nickname and handed
a raised gain ceiling they are not eligible to use.
*/
function setUserAudioGainBoost(selector, enabled, actor = null) {
const resolved = resolveUserBySelector(selector, { includeVerified: false, includeDeterred: true });
if (resolved.error || !resolved.user) {
throw new Error(resolved.error === 'ambiguous_nickname'
? 'Nickname matches multiple VIPs.'
: 'VIP not found. Only verified users can be granted an audio gain boost.');
}
if (!resolved.user.verified?.enabled) {
throw new Error('Only verified VIPs can be granted an audio gain boost.');
}
const user = setAudioGainBoost(resolved.user.id, {
enabled,
actor: actor ? String(actor) : null,
at: Date.now(),
});
refreshSocketsForUser(user.id);
publishEvent({
source: 'moderation',
type: enabled ? 'audio.gainBoostGranted' : 'audio.gainBoostRevoked',
payload: {
userId: user.id,
cookieUserId: user.cookieUserIds[0] || null,
nickname: user.nickname,
actor: actor ? String(actor) : null,
ts: Date.now(),
},
});
emitChange(enabled ? 'audio_gain_boost_grant' : 'audio_gain_boost_revoke', { userId: user.id });
return userToLegacyIdentityEntry(user);
}
function grantAudioGainBoost(selector, actor = null) {
return setUserAudioGainBoost(selector, true, actor);
}
function revokeAudioGainBoost(selector, actor = null) {
return setUserAudioGainBoost(selector, false, actor);
}
function muteUser(selector, actor = null) {
return setUserMute(selector, true, actor);
}
@@ -754,13 +709,9 @@ module.exports = {
undeterUser,
muteUser,
unmuteUser,
listAudioGainBoostUsers,
grantAudioGainBoost,
revokeAudioGainBoost,
isVerified: (socket) => Boolean(socket?.data?.isVerified),
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
isMuted: (socket) => Boolean(socket?.data?.isMuted),
hasAudioGainBoost: (socket) => Boolean(socket?.data?.hasAudioGainBoost),
reevaluateSocketVerification,
reevaluateSocketDeterrence,
verificationEvents,
+5
View File
@@ -1,3 +1,8 @@
1. improve midi player
```
research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
```
1. assign rovers based on battery percentage, give people highest one
2. setting to disable replay popups in spectator settings menu
3. add admin ui for VIP and private requests instead of only through discord
+11
View File
@@ -22,6 +22,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/container-queries": "^0.1.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
@@ -1421,6 +1422,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@tailwindcss/container-queries": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/container-queries/-/container-queries-0.1.1.tgz",
"integrity": "sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.2.0"
}
},
"node_modules/@thumbmarkjs/thumbmarkjs": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@thumbmarkjs/thumbmarkjs/-/thumbmarkjs-1.10.0.tgz",
+1
View File
@@ -24,6 +24,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/container-queries": "^0.1.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
@@ -17,10 +17,8 @@ const MODES = [
{ key: 'lockdown', label: 'Lockdown' },
];
/*
The same three gain keys drive both the global levels and the VIP boost hard
caps, so both editors render from one list instead of six copied sliders.
*/
// These are the physical rover gain channels. Personal settings remain signed
// percentages and never replace these server-owned base multipliers.
const GAIN_FIELDS = [
{ key: 'hornGain', label: 'Horn gain' },
{ key: 'ttsGain', label: 'TTS gain' },
@@ -109,7 +107,7 @@ export default function AdminPanelContent() {
updateAllRovers,
rebootServer,
setAudioLevels,
setUserAudioGainCaps,
setPersonalAudioAdjustmentRange,
setPrivateSafety,
llmControl,
overseerControl,
@@ -132,12 +130,11 @@ export default function AdminPanelContent() {
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
const [reasonDraft, setReasonDraft] = useState(currentReason);
const currentAudioLevels = session?.audioLevels || {};
const currentUserGainCaps = currentAudioLevels.userGainCaps || {};
const [audioLevelDraft, setAudioLevelDraft] = useState(
() => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
);
const [userGainCapDraft, setUserGainCapDraft] = useState(
() => normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }),
const [maxAdjustmentDraft, setMaxAdjustmentDraft] = useState(
() => Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0,
);
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
@@ -317,14 +314,9 @@ export default function AdminPanelContent() {
}
};
const handleUserGainCapDraft = (key) => (event) => {
const next = Number(event.target.value);
setUserGainCapDraft((current) => ({ ...(current || {}), [key]: Number.isFinite(next) ? next : 0 }));
};
const handleUserGainCapsSave = async () => {
const handlePersonalAdjustmentRangeSave = async () => {
try {
await setUserAudioGainCaps(userGainCapDraft);
await setPersonalAudioAdjustmentRange(maxAdjustmentDraft);
} catch (err) {
alert(err.message);
}
@@ -356,9 +348,8 @@ export default function AdminPanelContent() {
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
useEffect(() => {
setUserGainCapDraft(normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUserGainCaps.forwardGain, currentUserGainCaps.hornGain, currentUserGainCaps.ttsGain]);
setMaxAdjustmentDraft(Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0);
}, [currentAudioLevels.maxPersonalAdjustmentPercent]);
useEffect(() => {
@@ -462,7 +453,7 @@ export default function AdminPanelContent() {
/>
))}
<p className="text-xs text-slate-500">
These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them.
These are the base multipliers. Approved personal adjustments are calculated around these values.
</p>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
@@ -472,26 +463,32 @@ export default function AdminPanelContent() {
</div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>VIP gain boost hard caps</span>
{session?.audioLevels?.capsUpdatedAt ? (
<span>Updated {new Date(session.audioLevels.capsUpdatedAt).toLocaleString()}</span>
<span>Maximum personal adjustment</span>
{session?.audioLevels?.adjustmentRangeUpdatedAt ? (
<span>Updated {new Date(session.audioLevels.adjustmentRangeUpdatedAt).toLocaleString()}</span>
) : null}
</div>
{GAIN_FIELDS.map(({ key, label }) => (
<GainSlider
key={key}
label={label}
value={userGainCapDraft[key]}
onChange={handleUserGainCapDraft(key)}
<label className="grid gap-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>Allowed range in both directions</span>
<span>±{Math.round(Number(maxAdjustmentDraft) || 0)}%</span>
</div>
<input
type="range"
min="0"
max="100"
step="1"
value={maxAdjustmentDraft}
onChange={(event) => setMaxAdjustmentDraft(Number(event.target.value) || 0)}
className="w-full accent-emerald-500"
/>
))}
</label>
<p className="text-xs text-slate-500">
Ceilings for VIPs granted the boost with <code>rs gain grant &lt;vip&gt;</code>. A boost never lowers
someone&apos;s limit, so a cap below the global gain above has no effect.
Users with the personal audio adjustment permission can reduce or increase each base level by this percentage.
</p>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleUserGainCapsSave} className="button-dark">
Apply boost caps
<button type="button" onClick={handlePersonalAdjustmentRangeSave} className="button-dark">
Apply adjustment range
</button>
</div>
</div>
+25
View File
@@ -71,6 +71,7 @@ function isGenericAlert(alert) {
if (alert?.kind === 'chat' && alert.payload) return false;
if (alert?.kind === 'chat-typing' && alert.payload) return false;
if (alert?.kind === 'barcode-scan' && alert.payload) return false;
if (alert?.kind === 'undocked-exit-warning') return false;
return true;
}
@@ -481,6 +482,27 @@ function BarcodeScanToast({ payload }) {
);
}
function UndockedExitWarningToast() {
return (
<div
/*
This warning deliberately keeps AlertFeed's existing dark shell and
compact rounded geometry while giving a safety-critical instruction
enough type size and breathing room to be unmistakable. The responsive
width leaves space for the feed shell's separate dismiss column on
narrow screens instead of allowing the card to overflow the viewport.
*/
className="w-[min(32rem,calc(80vw-1.25rem))] border-l-4 border-amber-400 bg-amber-950/90 px-4 py-3 text-left text-amber-50"
role="alert"
>
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
Your rover is still undocked. Please dock it before leaving the page.
</p>
</div>
);
}
function AlertToast({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload;
@@ -515,6 +537,9 @@ function AlertToast({ alert }) {
if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />;
}
if (alert.kind === 'undocked-exit-warning') {
return <UndockedExitWarningToast />;
}
return (
<div
/*
@@ -101,7 +101,7 @@ function GameChoice({ game, disabled, onVote }) {
function StatGrid({ stats, theme }) {
if (!Array.isArray(stats) || !stats.length) return null;
return (
<div className="grid gap-0.5 sm:grid-cols-3">
<div className="grid gap-0.5 @sm:grid-cols-3">
{stats.slice(0, 6).map((stat) => (
<div key={stat.label} className="surface px-1 py-0.75 text-center">
<p className="truncate text-[0.68rem] text-neutral-400">{stat.label}</p>
@@ -215,7 +215,7 @@ function Leaderboard({ players, ownPlayer }) {
const hasOwnPoints = ownPlayer && Number.isFinite(ownPlayer.totalPoints);
return (
<div className="space-y-1">
<div className="grid gap-0.5 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="grid gap-0.5 @sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="surface grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 px-1 py-0.75">
<div className="min-w-0">
<p className="text-xs font-semibold text-neutral-300">Your points</p>
@@ -286,7 +286,7 @@ function BarcodeGamesPanelContent() {
return (
<CardFrame title="Barcode games" bodyClassName="space-y-1 p-0.5 text-sm">
<div className="grid gap-0.5 sm:grid-cols-2">
<div className="grid gap-0.5 @sm:grid-cols-2">
{games.map((game) => (
<GameChoice
key={game.id}
@@ -298,7 +298,9 @@ function BarcodeGamesPanelContent() {
</div>
<section className="space-y-1 border-t border-neutral-700 pt-1">
<div className="grid items-stretch gap-0.5 lg:grid-cols-[minmax(0,1fr)_9rem_10rem]">
{/* The game summary becomes a three-part dashboard only when the card can
fit its fixed timer and participant blocks without crushing the title. */}
<div className="grid items-stretch gap-0.5 @[28rem]:grid-cols-[minmax(0,1fr)_9rem_10rem]">
<div className="surface min-w-0 border-l-4 border-neutral-700 px-1 py-0.75" style={activeTheme.titleBoxStyle}>
<p className="break-words text-xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
{display.title || activeGame?.title || 'No game active'}
@@ -311,7 +313,7 @@ function BarcodeGamesPanelContent() {
) : null}
<DisplaySections sections={display.sections} />
</div>
<div className="surface px-1 py-0.75 text-left lg:text-right">
<div className="surface px-1 py-0.75 text-left @[28rem]:text-right">
<p className="text-xs font-semibold text-neutral-400">{display.timer?.label || 'Time'}</p>
<p className="font-mono text-xl font-semibold leading-tight text-neutral-50" style={activeTheme.textStyle}>
{timerText || '--'}
@@ -327,7 +329,7 @@ function BarcodeGamesPanelContent() {
<Leaderboard players={state.leaderboard} ownPlayer={state.ownPlayer} />
<div className="grid gap-0.5 border-t border-neutral-700 pt-1 sm:grid-cols-2">
<div className="grid gap-0.5 border-t border-neutral-700 pt-1 @sm:grid-cols-2">
<div className="surface px-1 py-0.75">
<CounterList title="Most scanned objects" entries={state.counters?.objects} />
</div>
+22 -4
View File
@@ -42,6 +42,7 @@ export default function CardFrame({
children,
}) {
const showHeader = !hideHeader && (title || meta != null || actions);
const greenMode = useSessionSelector((state) => Boolean(state.session?.greenMode));
const ownRoverColor = useSessionSelector((state) => {
const roverId = String(state.session?.assignment?.roverId || '').trim();
if (!roverId) return null;
@@ -52,11 +53,20 @@ export default function CardFrame({
const accentRgb = hexToRgb(ownRoverColor);
// swap these to toggle rover card border colors stuff
const cardStyle = accentRgb ? { borderColor: rgba(accentRgb, 0.3) } : undefined;
// Green mode is global server chrome, so it wins over the assigned rover's
// personal accent while active. Keeping this override in CardFrame makes all
// present and future cards participate without sprinkling mode checks around.
const cardStyle = greenMode
? { borderColor: '#008a35' }
: accentRgb
? { borderColor: rgba(accentRgb, 0.3) }
: undefined;
// const cardStyle = undefined;
const headerStyle = accentRgb
const headerStyle = greenMode
? { borderColor: '#008a35' }
: accentRgb
? {
backgroundImage: `linear-gradient(90deg, rgba(23,23,23,0.96) 0%, rgba(38,38,38,0.94) 0%, ${rgba(accentRgb, 0.1)} 100%)`,
// backgroundImage: `linear-gradient(90deg, ${rgba(accentRgb, 0.1)} 100%)`,
@@ -67,7 +77,11 @@ export default function CardFrame({
return (
<section
className={cx(
'panel-section border border-neutral-500/60 bg-neutral-900/100',
// Cards are reused in full-width pages, mobile stacks, and narrow desktop
// sidebars. Making the card itself a query container lets its contents
// respond to the space they actually receive instead of the viewport,
// which may be wide while a sidebar card is only a few hundred pixels.
'panel-section @container border border-neutral-500/60 bg-neutral-900/100',
clipOverflow ? 'overflow-hidden' : 'overflow-visible',
fillHeight && 'flex h-full min-h-0 flex-col',
className,
@@ -85,7 +99,11 @@ export default function CardFrame({
style={headerStyle}
>
<div className="flex min-w-0 items-center gap-0.5">
{title ? <p className="m-0 text-[0.78rem] font-semibold leading-none text-neutral-50">{title}</p> : null}
{title ? (
<p className={cx('m-0 text-[0.78rem] font-semibold leading-none', greenMode ? 'text-lime-400' : 'text-neutral-50')}>
{title}
</p>
) : null}
{meta != null ? <span className="text-[0.68rem] font-medium leading-none text-neutral-200">{meta}</span> : null}
</div>
{actions ? <div className="flex flex-wrap items-center justify-end gap-0.5">{actions}</div> : null}
+7 -2
View File
@@ -1,11 +1,16 @@
.chat-composer { container-type: inline-size; display: flex; flex-wrap: wrap; align-items: stretch; gap: 0.125rem; min-width: 0; overflow: hidden; }
.chat-composer-nickname { flex: 0 0 5rem; min-width: 0; order: 1; }
/* Narrow sidebars need two deliberate control rows. Giving the nickname
control a full flex row prevents the nickname, message, and send controls
from technically fitting by crushing all three into unreadable widths. */
.chat-composer-nickname { flex: 1 1 100%; min-width: 0; order: 1; }
.chat-composer-input { flex: 1 1 0%; min-width: 0; order: 2; }
.chat-composer-send { flex: 0 0 auto; align-self: stretch; order: 3; }
.chat-composer-tts { display: flex; flex: 1 1 100%; align-items: center; gap: 0.125rem; min-width: 0; overflow: hidden; order: 4; }
@container (min-width: 44rem) {
.chat-composer { flex-wrap: nowrap; }
.chat-composer-nickname { flex-basis: 7rem; order: 1; }
/* Wide panels restore the original fixed nickname slot so the message input
receives the remaining row width instead of competing with that form. */
.chat-composer-nickname { flex: 0 0 7rem; order: 1; }
.chat-composer-input { order: 2; }
.chat-composer-tts { flex: 0 1 auto; order: 3; }
.chat-composer-send { order: 4; }
@@ -0,0 +1,48 @@
// Empty Driver Video Notice
// Purpose: Explains either an ordinary unassigned state or the recent reason a rover assignment was removed.
// Scope: Owns only the centered notice content; each video layout remains responsible for its own stage dimensions and framing.
import { useSessionSelector } from '../../context/SessionContext.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
const REMOVAL_NOTICE_VISIBLE_MS = 2 * 60 * 1000;
export default function EmptyDriverVideoNotice() {
const removalNotice = useSessionSelector((state) => state.roverRemovalNotice || null);
const now = useSharedClock(1000, Boolean(removalNotice?.receivedAt));
const noticeAgeMs = removalNotice?.receivedAt ? now - removalNotice.receivedAt : Infinity;
/*
A forced removal needs to explain itself where the video was, because the
following session sync can only report that no rover is assigned. Keeping
the message for a bounded period preserves that context without allowing an
old moderation or safety notice to describe an unrelated later wait.
*/
const showRemovalNotice = Boolean(
removalNotice?.message && noticeAgeMs <= REMOVAL_NOTICE_VISIBLE_MS,
);
const title = showRemovalNotice
? removalNotice.title || 'Removed from rover'
: 'No rover assigned';
const message = showRemovalNotice
? removalNotice.message
: 'You are not currently assigned to a rover.';
return (
<div className="flex h-full w-full items-center justify-center p-4 text-center">
<div
className={`mx-auto flex max-w-md flex-col gap-1 rounded border px-4 py-3 ${
showRemovalNotice
? 'border-amber-300/60 bg-amber-950/35 text-amber-50'
: 'border-slate-700/70 bg-slate-950/35 text-slate-300'
}`}
>
<div className={showRemovalNotice ? 'text-sm font-semibold text-amber-100' : 'text-sm font-semibold text-slate-200'}>
{title}
</div>
<div className={showRemovalNotice ? 'text-sm text-amber-50/90' : 'text-sm text-slate-400'}>
{message}
</div>
</div>
</div>
);
}
+10 -41
View File
@@ -11,46 +11,7 @@ import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
import CardFrame from '../CardFrame/index.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
const REMOVAL_NOTICE_VISIBLE_MS = 2 * 60 * 1000;
function EmptyDriverVideoNotice() {
const removalNotice = useSessionSelector((state) => state.roverRemovalNotice || null);
const now = useSharedClock(1000, Boolean(removalNotice?.receivedAt));
const noticeAgeMs = removalNotice?.receivedAt ? now - removalNotice.receivedAt : Infinity;
/*
Removal explanations should feel immediate and contextual. After a short
window, falling back to the neutral no-rover state avoids showing an old
moderation/safety message during unrelated later waiting periods.
*/
const showRemovalNotice = Boolean(removalNotice?.message && noticeAgeMs <= REMOVAL_NOTICE_VISIBLE_MS);
const title = showRemovalNotice ? removalNotice.title || 'Removed from rover' : 'No rover assigned';
const message = showRemovalNotice
? removalNotice.message
: 'You are not currently assigned to a rover.';
return (
<CardFrame hideHeader className="shrink-0">
<div className="panel-muted flex aspect-[4/3] items-center justify-center p-4 text-center">
<div
className={`mx-auto flex max-w-md flex-col gap-1 rounded border px-4 py-3 ${
showRemovalNotice
? 'border-amber-300/60 bg-amber-950/35 text-amber-50'
: 'border-slate-700/70 bg-slate-950/35 text-slate-300'
}`}
>
<div className={showRemovalNotice ? 'text-sm font-semibold text-amber-100' : 'text-sm font-semibold text-slate-200'}>
{title}
</div>
<div className={showRemovalNotice ? 'text-sm text-amber-50/90' : 'text-sm text-slate-400'}>
{message}
</div>
</div>
</div>
</CardFrame>
);
}
import EmptyDriverVideoNotice from './EmptyDriverVideoNotice.jsx';
export default function DriverVideo({ layoutFormat = 'desktop' }) {
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
@@ -58,7 +19,15 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
if (!roverId) {
return <EmptyDriverVideoNotice />;
return (
<CardFrame hideHeader className="shrink-0">
{/* The legacy page keeps its existing muted 4:3 frame while sharing
the removal-message behavior with the current video surface. */}
<div className="panel-muted aspect-[4/3]">
<EmptyDriverVideoNotice />
</div>
</CardFrame>
);
}
const mobileHud = layoutFormat !== 'desktop';
@@ -11,6 +11,7 @@ function isBoolean(value) {
export default function GPIOToggleControl({
label,
icon: Icon,
on,
disabled,
onToggle,
@@ -125,6 +126,9 @@ export default function GPIOToggleControl({
className={buttonClasses}
>
<span className="flex items-center gap-0.5">
{/* Callers opt into an icon so shared desktop GPIO controls retain their
established density while narrow mobile controls gain faster recognition. */}
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span className="text-sm font-semibold">{label}</span>
{keyLabel ? (
<span className="rounded bg-slate-800 px-1 py-0.5 text-[0.6rem] font-semibold text-slate-200">
@@ -80,6 +80,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
if (!textContainer || !text) return;
const available = textContainer.clientWidth;
if (!available) return;
/* A narrow desktop sidebar benefits from a stable readable size and
natural wrapping. Mobile deliberately keeps its existing timed,
single-line behavior, while wider desktop banners still use fitting. */
if (!isMobile && available < 384) {
setFontSize(16);
return;
}
const needed = text.scrollWidth;
if (!needed) return;
const scale = Math.min(1, available / needed);
@@ -96,12 +103,13 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
window.cancelAnimationFrame(rafId);
observer.disconnect();
};
}, [goalText]);
}, [goalText, isMobile]);
const containerClass = useMemo(
() =>
[
'panel-section flex w-full items-center justify-center border border-neutral-500/60 shadow-[0_1px_0_rgba(255,255,255,0.05)_inset,0_10px_24px_rgba(0,0,0,0.28)]',
!isMobile ? '@container' : '',
isMobile ? 'rounded-none' : 'rounded',
'px-1 py-1 text-center font-semibold tracking-tight',
className,
@@ -133,12 +141,31 @@ export default function GlobalObjectiveBanner({ layout = 'desktop', className =
style={{ ...(frameStyle || {}), fontSize: `${fontSize}px`, lineHeight: 1.1 }}
{...dismissProps}
>
<span className="flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md">
<span className="flex flex-col justify-center border-r border-slate-700/60 px-0.5 text-[0.55em] font-semibold leading-tight text-slate-400">
<span
className={
isMobile
? 'flex w-full items-stretch gap-0.5 whitespace-nowrap rounded-md'
: 'flex w-full flex-col items-stretch gap-0.5 whitespace-normal rounded-md @sm:flex-row @sm:whitespace-nowrap'
}
>
<span
className={
isMobile
? 'flex flex-col justify-center border-r border-slate-700/60 px-0.5 text-[0.55em] font-semibold leading-tight text-slate-400'
: 'flex flex-row items-center justify-center gap-1 border-b border-slate-700/60 px-0.5 py-0.5 text-[0.65em] font-semibold leading-tight text-slate-400 @sm:flex-col @sm:gap-0 @sm:border-b-0 @sm:border-r @sm:py-0 @sm:text-[0.55em]'
}
>
<span>Global</span>
<span>Objective</span>
</span>
<span ref={textContainerRef} className="flex-1 overflow-hidden text-slate-100">
<span
ref={textContainerRef}
className={
isMobile
? 'flex-1 overflow-hidden text-slate-100'
: 'flex-1 whitespace-normal break-words text-slate-100 @sm:overflow-hidden @sm:whitespace-nowrap'
}
>
<span ref={textRef} className="block">
{goalText}
</span>
+19 -11
View File
@@ -29,7 +29,7 @@ function renderSegments(segments, keymap) {
});
}
function renderLine(line, keymap, idx) {
function renderLine(line, keymap) {
if (typeof line === 'string') return line;
if (Array.isArray(line?.segments)) return renderSegments(line.segments, keymap);
if (line && typeof line === 'object') {
@@ -65,7 +65,7 @@ function Hero({ hero, keymap }) {
const key = Array.isArray(line?.segments) ? `hero-${idx}` : `hero-${idx}`;
return (
<li key={key} className="surface-muted px-0.5 py-0.25">
{renderLine(line, keymap, idx)}
{renderLine(line, keymap)}
</li>
);
})}
@@ -84,7 +84,7 @@ function ListBlock({ block, keymap }) {
const key = `item-${idx}`;
return (
<li key={key} className="surface-muted flex flex-wrap items-center gap-0.5 px-0.5 py-0.25">
{renderLine(item, keymap, idx)}
{renderLine(item, keymap)}
</li>
);
})}
@@ -117,8 +117,10 @@ function KeyboardGroup({ group, keymap }) {
<p className="px-0.5 py-0.25 text-[0.75rem] font-semibold text-slate-200">{group.title}</p>
<div className="space-y-0.5 px-0.5 pb-0.25">
{group.items.map((item) => (
<div key={item.action} className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
<span className="text-slate-200">{item.label}</span>
<div key={item.action} className="surface-muted flex flex-wrap items-center justify-between gap-0.5 px-0.5 py-0.25 text-[0.8rem]">
{/* A binding label may wrap in a slim sidebar, while the key remains
a compact complete token instead of forcing horizontal overflow. */}
<span className="min-w-0 flex-1 text-slate-200">{item.label}</span>
<KeyPill actionId={item.action} keymap={keymap} />
</div>
))}
@@ -131,11 +133,16 @@ function KeyboardBlock({ block, keymap }) {
if (!block) return null;
return (
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-200">
{/* Heading and footnote share a row when possible and wrap independently
when the Help card is mounted in a narrow desktop column. */}
<div className="flex flex-wrap items-center justify-between gap-0.5 text-xs text-slate-200">
<span className="font-semibold">{block.title}</span>
{block.footnote && <span className="text-[0.7rem] text-slate-400">{block.footnote}</span>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-0.5">
{/* Two keyboard groups fit comfortably once the Help surface reaches 32rem.
Using the real content threshold restores the established old-page layout
without forcing two crushed columns inside the narrow new-drive sidebar. */}
<div className="grid grid-cols-1 gap-0.5 @[32rem]:grid-cols-2">
{block.groups?.map((group) => (
<KeyboardGroup key={group.id} group={group} keymap={keymap} />
))}
@@ -185,10 +192,11 @@ export function HelpContentView({ layout, keymap }) {
return (
<div className="space-y-0.5">
<Hero hero={content.hero} keymap={bindings} />
<div
className="grid gap-0.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))' }}
>
{/* The previous fixed 360px minimum was wider than the new-drive sidebar
and forced the entire Help card to overflow. This intrinsic minimum
uses one full-width column when 22.5rem cannot physically fit, while
preserving the multi-column presentation in genuinely wide panels. */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(22.5rem,100%),1fr))] gap-0.5">
<div className="space-y-0.5">
{mainBlocks.map((block, idx) => (
<BlockRenderer key={block.title || idx} block={block} keymap={bindings} />
@@ -134,8 +134,9 @@ function LampTile({ entity, connected, controlsLocked, onToggle, onSetColor, onS
className={cx(
// The parent grid owns row and column sizing so the room-controls panel
// never sprawls wider than three lamps per row. The tile only keeps a
// small minimum width so names and swatches remain readable.
'flex min-w-[9.5rem] flex-col gap-0.5 rounded border px-0.5 py-0.5 transition-colors',
// zero minimum lets a pair of tiles share a narrow desktop card. The
// internal title truncation and wrapping swatches preserve readability.
'flex min-w-0 flex-col gap-0.5 rounded border px-0.5 py-0.5 transition-colors',
disableToggle ? 'cursor-not-allowed opacity-70' : 'cursor-pointer hover:border-white/60',
tileTone,
)}
@@ -246,7 +247,10 @@ function HomeAssistantControlsContent() {
: 'Lights are locked on. Room controls are disabled.'}
</p>
) : null}
<div className="grid grid-cols-1 gap-0.5 sm:grid-cols-2 lg:grid-cols-3">
{/* Each lamp declares only the smallest width at which its title, status,
and swatches remain useful. Auto-fit then derives the column and row
count directly from the card width without layout-specific breakpoints. */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(min(9rem,100%),1fr))] gap-0.5">
{entities.map((entity) => (
<LampTile
key={entity.id}
+11 -2
View File
@@ -17,6 +17,7 @@ function clampFreq(value) {
export default function HornControl({
disabled,
icon: Icon,
onStart,
onStop,
keyLabel,
@@ -56,10 +57,14 @@ export default function HornControl({
'mobile-touch-control group relative flex w-full flex-col gap-0.5 overflow-hidden rounded-xl border-2 px-1 py-1.5 text-xs font-semibold select-none no-touch-select';
const active = 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50';
const inactive = 'border-cyan-300/70 bg-cyan-900 text-cyan-50 hover:bg-cyan-800';
return [base, isActive ? active : inactive, 'disabled:opacity-50', className]
// This is a role-based held control rather than a native button, so CSS's
// :disabled pseudo-class can never match it. Reflect disabled state directly
// while leaving the nested settings button available.
const disabledClass = disabled ? 'cursor-not-allowed opacity-50' : '';
return [base, isActive ? active : inactive, disabledClass, className]
.filter(Boolean)
.join(' ');
}, [className, isActive]);
}, [className, disabled, isActive]);
const formattedWaveform = waveform === 'sine' ? 'sine' : 'saw';
@@ -170,6 +175,7 @@ export default function HornControl({
role="button"
tabIndex={0}
aria-pressed={isActive}
aria-disabled={disabled}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
@@ -201,6 +207,9 @@ export default function HornControl({
/>
<div className="relative z-10 flex items-center justify-between gap-0.5">
<span className="flex items-center gap-0.5 text-sm font-semibold">
{/* Keep icon ownership with the caller so adding mobile affordance does
not silently change every existing desktop horn presentation. */}
{Icon ? <Icon className="shrink-0 text-base" aria-hidden="true" /> : null}
<span>Horn</span>
{keyLabel ? (
<span className="rounded bg-black/40 px-1 py-0.5 text-[0.6rem] font-semibold text-white">
@@ -1,7 +1,7 @@
// Hud Chat Input
// Purpose: Defines the Hud Chat Input module and the local helpers/components used in this file.
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
import { memo, useMemo, useState } from 'react';
import { memo, useMemo, useRef, useState } from 'react';
import './styles.css';
import '../../MobileControls/mobileControls.css';
import { useChatActions } from '../../../context/ChatContext.jsx';
@@ -25,7 +25,7 @@ function detectSafari() {
!/crios|fxios|edgios|opr|chrome|android/i.test(userAgent);
}
function HudChatInput({ compact = false }) {
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
const role = useSessionSelector((state) => state.session?.role || null);
const socketId = useSessionSelector((state) => state.session?.socketId || null);
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
@@ -45,6 +45,7 @@ function HudChatInput({ compact = false }) {
});
const [draft, setDraft] = useState('');
const [sending, setSending] = useState(false);
const formRef = useRef(null);
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
const canChat = role !== 'spectator';
const hideHudChat = role === 'spectator';
@@ -96,22 +97,37 @@ function HudChatInput({ compact = false }) {
ttsSettings?.voice,
ttsSupported,
]);
const containerClass = compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
const newDrivePresentation = variant === 'newdrive';
const containerClass = newDrivePresentation
? `absolute left-1/2 top-1/2 z-[70] flex w-[min(36rem,82%)] -translate-x-1/2 -translate-y-1/2 items-stretch gap-1 rounded-md border border-neutral-700 bg-neutral-900 p-1 transition-opacity duration-100 ${open ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0'}`
: compact
? 'pointer-events-auto absolute bottom-0.5 right-0.5 flex w-[9rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.4 py-0.2'
: 'pointer-events-auto absolute bottom-1 right-1 flex w-[12rem] max-w-[70vw] items-center gap-0.5 rounded bg-black/70 px-0.5 py-0.25';
const isSafari = useMemo(() => detectSafari(), []);
/*
Safari zooms focused inputs below 16px. Keep that workaround Safari-only so
other mobile browsers keep the deliberately tiny HUD typography.
*/
const inputTextClass = isSafari ? 'mobile-text-entry' : compact ? 'text-[0.55rem]' : 'text-[0.7rem]';
const inputClass = `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`;
const inputTextClass = newDrivePresentation
? 'text-base'
: isSafari
? 'mobile-text-entry'
: compact
? 'text-[0.55rem]'
: 'text-[0.7rem]';
// The current HUD composer is a physically larger instance of the normal
// chat-panel entry field, not a separately styled translucent overlay.
const inputClass = newDrivePresentation
? `field-input h-12 min-w-0 flex-1 px-2 ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`
: `min-w-0 flex-1 bg-transparent ${inputTextClass} text-slate-100 placeholder:text-slate-400 focus:outline-none`;
// The submit button is still a touch target even though the adjacent input must
// remain editable, so it gets press suppression without inheriting input text
// selection behavior.
const buttonClass = compact
? 'mobile-touch-control rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'mobile-touch-control rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
const buttonClass = newDrivePresentation
? 'button-dark min-h-12 shrink-0 px-4 text-base font-semibold disabled:opacity-50'
: compact
? 'mobile-touch-control rounded bg-cyan-500/80 px-0.35 py-0.2 text-[0.55rem] font-semibold text-black disabled:opacity-50'
: 'mobile-touch-control rounded bg-cyan-500/80 px-0.5 py-0.25 text-[0.7rem] font-semibold text-black disabled:opacity-50';
async function handleSend(event) {
event.preventDefault();
@@ -125,6 +141,9 @@ function HudChatInput({ compact = false }) {
resetHistoryNavigation();
blurChat();
setTypingActive(false);
// The new-drive composer is intentionally transient. Sending returns the
// HUD to its small corner icon while legacy HUD inputs remain unchanged.
if (newDrivePresentation) onOpenChange?.(false);
} catch (err) {
alert(err.message);
} finally {
@@ -135,7 +154,7 @@ function HudChatInput({ compact = false }) {
if (hideHudChat) return null;
return (
<form onSubmit={handleSend} className={containerClass}>
<form ref={formRef} onSubmit={handleSend} className={containerClass}>
<input
className={inputClass}
value={draft}
@@ -150,12 +169,32 @@ function HudChatInput({ compact = false }) {
onFocus={(event) => {
onInputFocus(event);
setTypingActive(Boolean(draft.trim()));
// The chat key focuses the always-mounted HUD input through ChatContext.
// Opening from this focus event avoids a second keyboard listener and
// preserves HUD-over-sidebar focus priority.
if (newDrivePresentation) onOpenChange?.(true);
}}
onBlur={(event) => {
onInputBlur(event);
setTypingActive(false);
if (newDrivePresentation) {
// Defer the containment check until focus has reached a submit or
// close button; moving within the composer must not dismiss it.
window.requestAnimationFrame(() => {
if (!formRef.current?.contains(document.activeElement)) {
onOpenChange?.(false);
}
});
}
}}
onKeyDown={(event) => {
if (newDrivePresentation && event.key === 'Escape') {
event.preventDefault();
onOpenChange?.(false);
blurChat();
setTypingActive(false);
return;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
if (recalledDraft !== null) {
@@ -172,12 +211,27 @@ function HudChatInput({ compact = false }) {
}
}}
ref={(el) => registerInputRef(el, { target: 'hud' })}
placeholder={canChat ? 'Chat (tts)' : 'Spectator'}
tabIndex={newDrivePresentation && !open ? -1 : undefined}
placeholder={canChat ? (newDrivePresentation ? 'Type a message…' : 'Chat (tts)') : 'Spectator'}
disabled={!canChat}
/>
<button type="submit" disabled={!canChat || sending} className={buttonClass}>
Speak
{sending ? '...' : newDrivePresentation ? 'Send' : 'Speak'}
</button>
{newDrivePresentation ? (
<button
type="button"
aria-label="Close chat"
onClick={() => {
onOpenChange?.(false);
blurChat();
setTypingActive(false);
}}
className="flex min-h-12 w-10 shrink-0 items-center justify-center rounded border border-neutral-600 bg-neutral-800 text-xl leading-none text-slate-200 hover:bg-neutral-700 hover:text-white"
>
×
</button>
) : null}
</form>
);
}
@@ -0,0 +1,70 @@
// Bottom Sensor HUD Geometry
// Purpose: Defines one physical front-rover curve shared by bumpers, IR sensors, and cliffs.
// Scope: Contains static display geometry only; telemetry components decide which shapes are visible.
export const SENSOR_HUD_VIEW_BOX = '0 0 1000 140';
export const LEFT_BUMPER_PATH = 'M 8 128 Q 248 60 488 60';
export const RIGHT_BUMPER_PATH = 'M 512 60 Q 752 60 992 128';
/* Horizontal center tangents keep the mirrored halves circular instead of forming a pointed crown. */
const LEFT_CURVE = Object.freeze({ x0: 8, x1: 488, y0: 128, controlY: 60, y1: 60 });
const RIGHT_CURVE = Object.freeze({ x0: 512, x1: 992, y0: 60, controlY: 60, y1: 128 });
function curveForX(x) {
return x < 500 ? LEFT_CURVE : RIGHT_CURVE;
}
export function bumperCurvePoint(x) {
const curve = curveForX(x);
const t = Math.max(0, Math.min(1, (x - curve.x0) / (curve.x1 - curve.x0)));
const oneMinusT = 1 - t;
const y =
oneMinusT * oneMinusT * curve.y0 +
2 * oneMinusT * t * curve.controlY +
t * t * curve.y1;
const dyDt =
2 * oneMinusT * (curve.controlY - curve.y0) +
2 * t * (curve.y1 - curve.controlY);
const slope = dyDt / (curve.x1 - curve.x0);
return { x, y, slope };
}
function segmentPath(startX, endX, verticalOffset) {
const start = bumperCurvePoint(startX);
const end = bumperCurvePoint(endX);
const middle = bumperCurvePoint((startX + endX) / 2);
/* Solve the quadratic control height so the segment passes through the shared curve midpoint. */
const controlY = 2 * (middle.y + verticalOffset) - (start.y + end.y) / 2 - verticalOffset;
return `M ${startX} ${start.y + verticalOffset} Q ${(startX + endX) / 2} ${controlY} ${endX} ${end.y + verticalOffset}`;
}
const IR_SENSOR_X = [75, 225, 400, 600, 775, 925];
const IR_SENSOR_WIDTHS = [78, 70, 64, 64, 70, 78];
const IR_OFFSET_ABOVE_BUMPER = 10;
export const IR_SENSOR_GEOMETRY = IR_SENSOR_X.map((x, index) => {
const point = bumperCurvePoint(x);
/* The upward normal makes outer sensors fan sideways and center sensors project upward. */
const normalLength = Math.hypot(point.slope, 1);
return Object.freeze({
key: ['left', 'front-left', 'center-left', 'center-right', 'front-right', 'right'][index],
tipX: x,
tipY: point.y - IR_OFFSET_ABOVE_BUMPER,
directionX: point.slope / normalLength,
directionY: -1 / normalLength,
width: IR_SENSOR_WIDTHS[index],
});
});
const CLIFF_OFFSET_BELOW_BUMPER = 21;
const CLIFF_RANGES = [
['left', 185, 315],
['frontLeft', 345, 485],
['frontRight', 515, 655],
['right', 685, 815],
];
export const CLIFF_ARCS = CLIFF_RANGES.map(([key, startX, endX]) => Object.freeze({
key,
path: segmentPath(startX, endX, CLIFF_OFFSET_BELOW_BUMPER),
}));
@@ -0,0 +1,24 @@
// New Generation Bottom Sensor HUD
// Purpose: Gives all bottom sensor visuals one SVG coordinate space and one physical curve.
// Scope: Owns shared placement only; child components retain independent telemetry subscriptions.
import BumperIndicators from '../BumperIndicators/index.jsx';
import IrProximityHud from '../IrProximityHud/index.jsx';
import CliffIndicators from '../CliffIndicators/index.jsx';
import { SENSOR_HUD_VIEW_BOX } from './geometry.js';
import './styles.css';
export default function BottomSensorHud({ roverId }) {
return (
<svg
className="newgen-bottom-sensor-hud"
viewBox={SENSOR_HUD_VIEW_BOX}
preserveAspectRatio="none"
role="img"
aria-label="Rover front sensors"
>
<IrProximityHud roverId={roverId} />
<BumperIndicators roverId={roverId} />
<CliffIndicators roverId={roverId} />
</svg>
);
}
@@ -0,0 +1,11 @@
.newgen-bottom-sensor-hud {
pointer-events: none;
position: absolute;
z-index: 20;
right: 0;
bottom: -2.5rem;
left: 0;
width: 100%;
height: clamp(7rem, 18vh, 11rem);
overflow: visible;
}
@@ -0,0 +1,56 @@
// New Generation Bumper Indicators
// Purpose: Projects the rover's two physical bumper halves across the bottom of the video.
// Scope: Visualizes server telemetry only; it does not infer collisions or change control behavior.
import { useId } from 'react';
import {
shallowObjectEqual,
useVisualTelemetrySelector,
} from '../../../../context/TelemetryContext.jsx';
import { LEFT_BUMPER_PATH, RIGHT_BUMPER_PATH } from '../BottomSensorHud/geometry.js';
import './styles.css';
const EMPTY_BUMPERS = Object.freeze({ bumpLeft: false, bumpRight: false });
function selectBumpers(frame) {
const contact = frame?.sensors?.bumpsAndWheelDrops;
if (!contact) return EMPTY_BUMPERS;
return {
bumpLeft: Boolean(contact.bumpLeft),
bumpRight: Boolean(contact.bumpRight),
};
}
function ActiveBumperArc({ pathId, path, label }) {
return (
<g className="newgen-bumper-arc">
{/*
The broad rounded stroke uses the same visual idea as TopDownMap's
ArcSegment, while this wider quadratic path is shaped for the camera
viewport instead of the circular top-down rover geometry.
*/}
<path id={pathId} d={path} className="newgen-bumper-path" />
<text className="newgen-bumper-label" dy="0.35em">
<textPath href={`#${pathId}`} startOffset="50%" textAnchor="middle">
{label}
</textPath>
</text>
</g>
);
}
export default function BumperIndicators({ roverId }) {
const telemetry = useVisualTelemetrySelector(roverId, selectBumpers, shallowObjectEqual);
const instanceId = useId().replaceAll(':', '');
if (!telemetry.bumpLeft && !telemetry.bumpRight) return null;
return (
<g className="newgen-bumper-visual" aria-label="Active rover bumper sensors">
{telemetry.bumpLeft ? (
<ActiveBumperArc pathId={`${instanceId}-left-bumper`} path={LEFT_BUMPER_PATH} label="BUMPER" />
) : null}
{telemetry.bumpRight ? (
<ActiveBumperArc pathId={`${instanceId}-right-bumper`} path={RIGHT_BUMPER_PATH} label="BUMPER" />
) : null}
</g>
);
}
@@ -0,0 +1,18 @@
.newgen-bumper-path {
fill: none;
stroke: rgb(239 68 68 / 0.48);
stroke-width: 26;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.newgen-bumper-label {
fill: white;
font-family: system-ui, sans-serif;
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
paint-order: stroke;
stroke: rgb(0 0 0 / 0.65);
stroke-width: 1.5px;
}
@@ -0,0 +1,61 @@
// New Generation Cliff Indicators
// Purpose: Projects the four underside cliff sensors just below the front bumper curve.
// Scope: Uses the rover's cliff booleans directly without interpreting raw cliff signal values.
import { useId } from 'react';
import {
shallowObjectEqual,
useVisualTelemetrySelector,
} from '../../../../context/TelemetryContext.jsx';
import { CLIFF_ARCS } from '../BottomSensorHud/geometry.js';
import './styles.css';
const EMPTY_CLIFFS = Object.freeze({
left: false,
frontLeft: false,
frontRight: false,
right: false,
});
function selectCliffs(frame) {
const sensors = frame?.sensors;
if (!sensors) return EMPTY_CLIFFS;
return {
left: Boolean(sensors.cliffLeft),
frontLeft: Boolean(sensors.cliffFrontLeft),
frontRight: Boolean(sensors.cliffFrontRight),
right: Boolean(sensors.cliffRight),
};
}
function ActiveCliffArc({ pathId, path }) {
return (
<g>
<path id={pathId} d={path} className="newgen-cliff-path" />
<text className="newgen-cliff-label" dy="0.35em">
<textPath href={`#${pathId}`} startOffset="50%" textAnchor="middle">
CLIFF
</textPath>
</text>
</g>
);
}
export default function CliffIndicators({ roverId }) {
const cliffs = useVisualTelemetrySelector(roverId, selectCliffs, shallowObjectEqual);
const instanceId = useId().replaceAll(':', '');
if (!Object.values(cliffs).some(Boolean)) return null;
return (
<g className="newgen-cliff-visual" aria-label="Active rover cliff sensors">
{CLIFF_ARCS.map((sensor) => (
cliffs[sensor.key] ? (
<ActiveCliffArc
key={sensor.key}
pathId={`${instanceId}-${sensor.key}-cliff`}
path={sensor.path}
/>
) : null
))}
</g>
);
}
@@ -0,0 +1,18 @@
.newgen-cliff-path {
fill: none;
stroke: rgb(245 158 11 / 0.68);
stroke-width: 22;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.newgen-cliff-label {
fill: white;
font-family: system-ui, sans-serif;
font-size: 15px;
font-weight: 700;
letter-spacing: 1px;
paint-order: stroke;
stroke: rgb(0 0 0 / 0.65);
stroke-width: 1.5px;
}
@@ -0,0 +1,98 @@
// Bottom-left Corner Pod
// Purpose: Presents only the assigned rover's available physical light, laser, and horn controls.
import { useRef } from 'react';
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '' }) {
const ControlIcon = icon;
const toneClass = tone === 'horn'
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
>
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
<KeyPill label={keyLabel} />
</button>
);
}
export default function BottomLeftPod({ roverId }) {
const [open, setOpen] = usePodVisibility('peripherals', true);
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
const roomLightsLockedOn = useSessionSelector(
(state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn),
);
const headlight = useControlSelector((control) => control.pipeline?.headlight);
const laser = useControlSelector((control) => control.pipeline?.laser);
const hornDevice = useControlSelector((control) => control.pipeline?.horn);
const headlightOn = useControlSelector((control) => Boolean(control.pipeline?.headlightState?.headlightOn));
const laserOn = useControlSelector((control) => Boolean(control.pipeline?.laserState?.laserOn));
const hornActive = useControlSelector((control) => Boolean(control.state.horn?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setHeadlight, setLaser, startHorn, stopHorn } = useControlActions();
const canControl = useCanControlRover(roverId);
const hornPointerRef = useRef(null);
const available = Boolean(headlight || laser || hornDevice);
if (!available) return null;
const startHornPointer = (event) => {
if (!canControl) return;
if (hornPointerRef.current != null) return;
event.preventDefault();
hornPointerRef.current = event.pointerId;
event.currentTarget.setPointerCapture?.(event.pointerId);
startHorn();
};
const stopHornPointer = (event) => {
if (hornPointerRef.current !== event.pointerId) return;
hornPointerRef.current = null;
stopHorn();
};
return (
<>
{open ? (
<div className="pointer-events-auto absolute bottom-0 left-0 z-20 h-40 w-40 rounded-tr-[4.25rem] bg-black/60">
{/*
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
sixty-degree separation leaves a visible gap between the small circles while keeping
both controls equally distant from the horn. These explicit positions are the
rendered result of that geometry, not unrelated visual nudges.
*/}
{/* Physical rover actions become visibly and behaviorally unavailable
while another queued driver owns the turn. Pod/settings controls
remain interactive because they do not mutate rover hardware. */}
{hornDevice ? <RoundControl label="Horn" icon={FaBullhorn} keyLabel={formatKeyLabel(keymap?.hornHonk?.[0])} active={hornActive} tone="horn" disabled={!canControl} large onPointerDown={startHornPointer} onPointerUp={stopHornPointer} className="absolute bottom-1 left-1" /> : null}
{headlight ? <RoundControl label="Headlight" icon={FaLightbulb} keyLabel={formatKeyLabel(keymap?.headlightToggle?.[0])} active={headlightOn} disabled={!canControl} onClick={() => setHeadlight(!headlightOn)} className="absolute left-[1.979rem] top-[0.662rem]" /> : null}
{/* The room-light lock deliberately blocks laser activation because
the laser is only intended for use while the room is dark. This
mirrors the old desktop control's visible disabled state; turn
ownership remains the other independent control restriction. */}
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={formatKeyLabel(keymap?.laserToggle?.[0])} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
</div>
) : (
<CornerPodToggle corner="bottom-left" expanded={false} label="Show rover controls" onClick={() => setOpen(true)} />
)}
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} onOpenChange={setHornSettingsOpen} /> : null}
</>
);
}
@@ -0,0 +1,101 @@
// Bottom-right Corner Pod
// Purpose: Provides a compact circular camera-tilt control using the existing servo command path.
import { useCallback } from 'react';
import { FaVideo } from 'react-icons/fa';
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
import ChatExpansion from './ChatExpansion.jsx';
import CornerPodToggle from './CornerPodToggle.jsx';
import usePodVisibility from './usePodVisibility.js';
const ARC_CENTER = 72;
const ARC_RADIUS = 60;
const ARC_START_DEGREES = 105;
const ARC_SWEEP_DEGREES = 240;
function pointOnArc(fraction) {
// The unused third is centered on the physical bottom-right screen corner. Starting at
// 105 degrees and sweeping clockwise to 345 degrees leaves that exact corner-facing gap.
const degrees = ARC_START_DEGREES + fraction * ARC_SWEEP_DEGREES;
const radians = (degrees * Math.PI) / 180;
return {
x: ARC_CENTER + Math.cos(radians) * ARC_RADIUS,
y: ARC_CENTER + Math.sin(radians) * ARC_RADIUS,
};
}
export default function BottomRightPod({ roverId }) {
const layout = useDriverLayout();
const [open, setOpen] = usePodVisibility('camera', true);
const camera = useControlSelector((control) => control.state.camera);
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
const keymap = useControlSelector((control) => control.state.keymap);
const { setServoAngle } = useControlActions();
const canControl = useCanControlRover(roverId);
const config = camera?.config;
const enabled = Boolean(roverId && camera?.enabled && config);
const min = Number(config?.minAngle);
const max = Number(config?.maxAngle);
const value = Number.isFinite(camera?.angle) ? camera.angle : Number(config?.homeAngle) || 0;
const fraction = max > min ? Math.max(0, Math.min(1, (value - min) / (max - min))) : 0.5;
const knob = pointOnArc(fraction);
// Keep this derived state in one place because it also tells the independent chat
// expansion whether it must offset itself above a visible camera-control pod.
const showCameraControls = layout === 'desktop';
const cameraPodOpen = showCameraControls && enabled && open;
const updateFromPointer = useCallback((event) => {
if (!canControl || !enabled || dockAssistActive || !(max > min)) return;
const bounds = event.currentTarget.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width) * 144;
const y = ((event.clientY - bounds.top) / bounds.height) * 144;
const pointerDegrees = ((Math.atan2(y - ARC_CENTER, x - ARC_CENTER) * 180) / Math.PI + 360) % 360;
let arcDegrees = (pointerDegrees - ARC_START_DEGREES + 360) % 360;
if (arcDegrees > ARC_SWEEP_DEGREES) {
/*
Pointer input inside the open corner-facing third is outside the slider. Snap it to
whichever visible endpoint is closer so the gap remains visually and behaviorally open.
*/
const distanceFromEnd = arcDegrees - ARC_SWEEP_DEGREES;
const distanceFromStart = 360 - arcDegrees;
arcDegrees = distanceFromStart < distanceFromEnd ? 0 : ARC_SWEEP_DEGREES;
}
const nextFraction = arcDegrees / ARC_SWEEP_DEGREES;
setServoAngle(min + nextFraction * (max - min));
}, [canControl, dockAssistActive, enabled, max, min, setServoAngle]);
return (
<>
{cameraPodOpen ? (
<div className="pointer-events-auto absolute bottom-0 right-0 z-20 flex h-[8.5rem] w-[8.5rem] items-center justify-center rounded-tl-[4.25rem] bg-black/60">
{/* The pod shell and its visibility toggle stay usable while waiting, but
every camera mutation is blocked and visibly muted until control returns. */}
<svg viewBox="0 0 144 144" aria-disabled={!canControl} className={`h-[8.5rem] w-[8.5rem] touch-none ${canControl ? '' : 'pointer-events-none opacity-40'}`} onPointerDown={updateFromPointer} onPointerMove={(event) => { if (event.buttons) updateFromPointer(event); }}>
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#064e3b" strokeWidth="12" strokeLinecap="round" />
<path d="M 56.47 129.96 A 60 60 0 1 1 129.96 56.47" pathLength="1" fill="none" stroke="#34d399" strokeWidth="12" strokeLinecap="round" strokeDasharray={`${fraction} 1`} />
<circle cx={knob.x} cy={knob.y} r="7" fill="#ecfdf5" stroke="#059669" strokeWidth="3" />
</svg>
<button type="button" aria-label="Reset camera tilt" disabled={!canControl} onClick={() => setServoAngle(0)} className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-0.5 rounded bg-black/55 px-1.5 py-1 font-bold text-white disabled:cursor-not-allowed disabled:opacity-40">
{/* The icon/value stack mirrors the battery pod and clarifies that this
circular gauge changes the live rover camera's tilt angle. */}
<FaVideo className="text-base" aria-hidden="true" />
<span className="text-sm leading-none">{value.toFixed(1)}°</span>
</button>
{/* These positions continue around the same circle just beyond the two slider endpoints.
Together they occupy the open third facing the corner without enlarging the pod. */}
<div className="absolute left-[61%] top-[90%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraDown?.[0])} /></div>
<div className="absolute left-[90%] top-[61%] -translate-x-1/2 -translate-y-1/2"><KeyPill label={formatKeyLabel(keymap?.cameraUp?.[0])} /></div>
<CornerPodToggle corner="bottom-right" expanded label="Hide camera tilt" onClick={() => setOpen(false)} />
</div>
) : showCameraControls && enabled ? (
<CornerPodToggle corner="bottom-right" expanded={false} label="Show camera tilt" onClick={() => setOpen(true)} />
) : null}
{/* Chat is an independent expansion. It remains usable even when this rover
has no camera-servo configuration or the camera pod is collapsed. */}
<ChatExpansion podOpen={cameraPodOpen} />
</>
);
}
@@ -0,0 +1,59 @@
// New-drive Chat Expansion
// Purpose: Keeps chat collapsed to a corner icon while reusing the established HUD composer behavior.
import { useCallback, useState } from 'react';
import { FaComment } from 'react-icons/fa';
import { useChatActions } from '../../../../context/ChatContext.jsx';
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
import { useControlSelector } from '../../../../controls/index.js';
import { formatKeyLabel } from '../../../../controls/keymapUtils.js';
import HudChatInput from '../../HudChatInput/index.jsx';
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
export default function ChatExpansion({ podOpen }) {
const role = useSessionSelector((state) => state.session?.role || null);
const chatKeyLabel = useControlSelector((control) => formatKeyLabel(control.state.keymap?.chatFocus?.[0]));
const { blurChat, focusChat } = useChatActions();
const [open, setOpen] = useState(false);
const setChatOpen = useCallback((nextOpen) => {
const next = Boolean(nextOpen);
setOpen(next);
if (!next) blurChat();
}, [blurChat]);
const toggleChat = useCallback(() => {
if (open) {
setChatOpen(false);
return;
}
setOpen(true);
/* HudChatInput remains mounted while visually collapsed, so the existing
ChatContext HUD ref is ready immediately. Waiting one animation frame lets
the opening presentation commit before the browser paints the focus ring. */
window.requestAnimationFrame(focusChat);
}, [focusChat, open, setChatOpen]);
// Spectators use the sidebar transcript but cannot send through the legacy HUD
// composer, so hiding this expansion avoids presenting an inert control.
if (role === 'spectator') return null;
return (
<>
<button
type="button"
aria-label={open ? 'Close chat' : 'Open chat'}
aria-pressed={open}
onClick={toggleChat}
className={`pointer-events-auto absolute z-20 flex h-8 items-center justify-center gap-1 bg-black/60 px-1.5 text-sm text-white transition hover:bg-black/80 ${podOpen ? 'bottom-[8.5rem] right-0 rounded-tl-lg' : 'bottom-0 right-10 rounded-t-lg'}`}
>
<FaComment aria-hidden="true" />
{/* The pill reflects the live keymap so remapping chat focus updates this
compact HUD hint without duplicating or hardcoding the default key. */}
{chatKeyLabel ? <KeyPill label={chatKeyLabel} /> : null}
</button>
<HudChatInput variant="newdrive" open={open} onOpenChange={setChatOpen} />
</>
);
}
@@ -0,0 +1,49 @@
// Corner Pod Toggle
// Purpose: Reserves each exact video corner for a pod's always-reachable collapse control.
import { FaArrowUp } from 'react-icons/fa';
const CORNERS = {
'top-left': {
button: 'left-0 top-0 [clip-path:polygon(0_0,100%_0,0_100%)]',
icon: 'left-1 top-1',
collapseRotation: '-rotate-45',
expandRotation: 'rotate-[135deg]',
},
'top-right': {
button: 'right-0 top-0 [clip-path:polygon(0_0,100%_0,100%_100%)]',
icon: 'right-1 top-1',
collapseRotation: 'rotate-45',
expandRotation: '-rotate-[135deg]',
},
'bottom-left': {
button: 'bottom-0 left-0 [clip-path:polygon(0_0,0_100%,100%_100%)]',
icon: 'bottom-1 left-1',
collapseRotation: '-rotate-[135deg]',
expandRotation: 'rotate-45',
},
'bottom-right': {
button: 'bottom-0 right-0 [clip-path:polygon(100%_0,0_100%,100%_100%)]',
icon: 'bottom-1 right-1',
collapseRotation: 'rotate-[135deg]',
expandRotation: '-rotate-45',
},
};
export default function CornerPodToggle({ corner, expanded, label, onClick }) {
const placement = CORNERS[corner] || CORNERS['top-left'];
return (
<button
type="button"
aria-label={label}
onClick={onClick}
className={`pointer-events-auto absolute z-40 h-10 w-10 bg-black/90 text-white/90 hover:bg-black hover:text-white ${placement.button}`}
>
{/* One arrow icon is rotated toward the physical corner for collapse and directly away
from it for expansion. The triangular hit target itself never moves or disappears. */}
<FaArrowUp
className={`absolute text-[0.65rem] ${placement.icon} ${expanded ? placement.collapseRotation : placement.expandRotation}`}
aria-hidden="true"
/>
</button>
);
}
@@ -0,0 +1,39 @@
// HUD Expansion Panel
// Purpose: Gives every independent HUD expansion one fixed toggle anchor and identical open/closed behavior.
import ExpansionToggle from './ExpansionToggle.jsx';
export default function ExpansionPanel({
open,
onOpenChange,
anchorClassName = '',
panelClassName = '',
panelAlign = 'left',
panelVerticalAlign = 'top',
openDirection,
closeDirection,
openLabel,
closeLabel,
children,
}) {
const panelAlignmentClass = panelAlign === 'right' ? 'right-0' : 'left-0';
const panelVerticalAlignmentClass = panelVerticalAlign === 'bottom' ? 'bottom-0' : 'top-0';
return (
<div className={`pointer-events-auto z-20 h-3 w-8 ${anchorClassName}`}>
{/* The content is positioned behind the fixed 32-by-12 anchor. Opening or
closing changes only whether this panel exists; it never relocates or
restyles the toggle that the user just clicked. */}
{open ? (
<div className={`absolute z-0 ${panelAlignmentClass} ${panelVerticalAlignmentClass} ${panelClassName}`}>
{children}
</div>
) : null}
<ExpansionToggle
direction={open ? closeDirection : openDirection}
label={open ? closeLabel : openLabel}
onClick={() => onOpenChange(!open)}
className="absolute left-0 top-0"
/>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More