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
+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(),
};
}