feat(commands): add 'rs gain' to grant the VIP audio gain boost

Admins can now run 'rs gain list|grant <vip>|revoke <vip>' from web chat or
Discord. Grant matches only against the verified list and revoke only
against current holders, so a nickname shared with an unverified visitor
reports not-found rather than resolving to someone ineligible. The action
joins the moderation set so lockdown narrows it to lockdown admins.

Also extracts the ceiling math into audioLevelsService/gainMath.js and
covers both it and the command with node:test suites.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Saul5662
2026-07-29 04:07:44 +01:00
co-authored by Claude
parent f3b349bb4a
commit 09c1257578
9 changed files with 432 additions and 76 deletions
@@ -0,0 +1,79 @@
// 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.
const MIN_GAIN = 0;
const MAX_GAIN = 4;
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));
}
function clampFraction(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(1, num));
}
function normalizeUserGains(raw = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampFraction(raw?.[key], 1);
});
return out;
}
function normalizeGainSet(raw = {}, fallback = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1));
});
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;
}
module.exports = {
GAIN_KEYS,
MIN_GAIN,
MAX_GAIN,
clampGain,
clampFraction,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
};
@@ -0,0 +1,85 @@
// 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.
const test = require('node:test');
const assert = require('node:assert/strict');
const {
clampFraction,
clampGain,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
} = 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 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('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('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);
});
+27 -74
View File
@@ -11,6 +11,15 @@ const { isAdmin } = require('../roleService');
const roverManager = require('../roverManager');
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
const { issueCommand } = require('../commandService');
const {
GAIN_KEYS,
clampGain,
clampFraction,
normalizeUserGains,
normalizeGainSet,
resolveCeilings,
applyCeilings,
} = require('./gainMath');
const audioLevelsEvents = new EventEmitter();
const DATA_DIR = resolveDataDir();
@@ -19,13 +28,6 @@ const config = loadConfig();
const configuredDefaults = config.audioLevels || {};
const configuredUserCaps = configuredDefaults.userGainCaps || {};
/*
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'];
/*
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
@@ -45,35 +47,15 @@ const USER_GAIN_CAP_DEFAULTS = {
forwardGain: 0.4,
};
function clampGain(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(4, num));
}
function clampFraction(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(1, num));
}
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
userGainCaps: {
hornGain: clampGain(configuredUserCaps.hornGain, USER_GAIN_CAP_DEFAULTS.hornGain),
ttsGain: clampGain(configuredUserCaps.ttsGain, USER_GAIN_CAP_DEFAULTS.ttsGain),
forwardGain: clampGain(configuredUserCaps.forwardGain, USER_GAIN_CAP_DEFAULTS.forwardGain),
},
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
};
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
return {
hornGain: clampGain(raw?.hornGain, fallback.hornGain),
ttsGain: clampGain(raw?.ttsGain, fallback.ttsGain),
forwardGain: clampGain(raw?.forwardGain, fallback.forwardGain),
};
return normalizeGainSet(raw, fallback);
}
function normalizeStore(raw = {}) {
@@ -141,36 +123,28 @@ function emitChange(reason = 'update', extra = {}) {
});
}
/*
Ceiling resolution. 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 simply keeps the global
ceiling instead of losing volume for holding a permission.
*/
function getAdminLimits() {
const current = loadState();
return {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
};
}
function getGainCeilings(hasBoost) {
const current = loadState();
const caps = current.userGainCaps;
const ceilings = {};
GAIN_KEYS.forEach((key) => {
const adminCeiling = clampGain(current[key], 0);
ceilings[key] = hasBoost ? Math.max(adminCeiling, clampGain(caps[key], 0)) : adminCeiling;
return resolveCeilings({
adminLimits: getAdminLimits(),
boostCaps: current.userGainCaps,
hasBoost,
});
return ceilings;
}
function getGainCeilingsForSocket(socket) {
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
}
function normalizeUserGains(raw = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampFraction(raw?.[key], 1);
});
return out;
}
function getUserGains(userId) {
if (!userId) return normalizeUserGains({});
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
@@ -180,14 +154,6 @@ function getUserGainsForSocket(socket) {
return getUserGains(getUserIdForSocket(socket));
}
function applyCeilings(fractions, ceilings) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0);
});
return out;
}
function getEffectiveLevelsForSocket(socket) {
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
}
@@ -222,15 +188,7 @@ function resolveAudioOwnerSocket(roverId) {
function resolveLevelsForRover(roverId) {
const owner = resolveAudioOwnerSocket(roverId);
if (!owner) {
const current = loadState();
return {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
};
}
return getEffectiveLevelsForSocket(owner);
return owner ? getEffectiveLevelsForSocket(owner) : getAdminLimits();
}
function pushLevelsToRover(roverId) {
@@ -315,7 +273,6 @@ function setUserGains(socket, input = {}) {
so the UI can show what the rover will actually play.
*/
function getAudioGainStateForSocket(socket) {
const current = loadState();
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
const values = getUserGainsForSocket(socket);
const ceilings = getGainCeilings(hasBoost);
@@ -324,12 +281,8 @@ function getAudioGainStateForSocket(socket) {
ceilings,
effective: applyCeilings(values, ceilings),
boostGranted: hasBoost,
adminLimits: {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
},
boostCaps: { ...current.userGainCaps },
adminLimits: getAdminLimits(),
boostCaps: getUserGainCaps(),
};
}