mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
buttonboxrewardses
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
#define WIFI_SSID "wifissid"
|
||||
#define WIFI_PASSWORD "wifipassword"
|
||||
#define SERVER_URL "http://192.168.0.86:8080/buttonbox/press"
|
||||
@@ -80,12 +80,14 @@ discord:
|
||||
guildId: "123456789012345678" # optional; bot works in any guild it's invited to
|
||||
siteUrl: "https://rover.example.com"
|
||||
channels:
|
||||
general: "123456789012345678"
|
||||
announcements: "123456789012345678"
|
||||
adminAlerts: "123456789012345678"
|
||||
# chat bridge is configured per guild via `rs bridge` commands
|
||||
replay: "123456789012345678"
|
||||
humanAlerts: "123456789012345678"
|
||||
roles:
|
||||
stalkerPing: "123456789012345678"
|
||||
announcementPing: "123456789012345678"
|
||||
adminPing: "123456789012345678"
|
||||
humanAlertPing: "123456789012345678"
|
||||
|
||||
@@ -34,6 +34,7 @@ require('./src/services/adminLogService');
|
||||
require('./src/services/homeAssistantService');
|
||||
require('./src/services/audioLevelsService');
|
||||
require('./src/services/audioForwardService');
|
||||
require('./src/services/buttonBoxService');
|
||||
require('./src/services/sessionService');
|
||||
require('./src/services/batteryManager');
|
||||
require('./src/services/replaySocketService');
|
||||
|
||||
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
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BVMt6HLR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CkLRtqYZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-B2Pp657C.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DVfgQd9L.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
module.exports = {
|
||||
id: 'assignmentRoulette',
|
||||
name: 'Rover Reassignment',
|
||||
goal: 70,
|
||||
async run(ctx) {
|
||||
const moved = ctx.rerollAssignments();
|
||||
ctx.sendAlert({
|
||||
color: '#8bc34a',
|
||||
title: 'Rover Reassignment',
|
||||
message: `Reassigned ${moved} user(s).`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
const STEP_MS = 220;
|
||||
const STEPS = 10;
|
||||
|
||||
module.exports = {
|
||||
id: 'cameraWhiplash',
|
||||
name: 'Camera Wiggle',
|
||||
goal: 35,
|
||||
async run(ctx) {
|
||||
const rovers = ctx
|
||||
.listOnlineRovers()
|
||||
.filter((rover) => Boolean(rover?.cameraServo?.enabled));
|
||||
if (!rovers.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let step = 0;
|
||||
const timer = setInterval(() => {
|
||||
step += 1;
|
||||
rovers.forEach((rover) => {
|
||||
const nudge = (Math.random() * 24 - 12).toFixed(2);
|
||||
try {
|
||||
ctx.issueCommand(String(rover.id), {
|
||||
type: 'servo',
|
||||
servo: { nudge: Number(nudge) },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('cameraWhiplash servo failed', { roverId: rover.id, error: err.message });
|
||||
}
|
||||
});
|
||||
if (step >= STEPS) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, STEP_MS);
|
||||
|
||||
ctx.sendAlert({
|
||||
color: '#00bcd4',
|
||||
title: 'Camera Wiggle',
|
||||
message: `Wobble running on ${rovers.length} rover(s).`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const LETTERS = 'abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
function randLetter() {
|
||||
return LETTERS[Math.floor(Math.random() * LETTERS.length)];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'chatSpam',
|
||||
name: 'Chat Spam',
|
||||
goal: 50,
|
||||
async run(ctx) {
|
||||
const nickname = randLetter();
|
||||
const messageCount = 8;
|
||||
for (let i = 0; i < messageCount; i += 1) {
|
||||
const len = 3 + Math.floor(Math.random() * 14);
|
||||
let text = '';
|
||||
for (let j = 0; j < len; j += 1) {
|
||||
text += randLetter();
|
||||
}
|
||||
try {
|
||||
ctx.sendExternalMessage({
|
||||
nickname,
|
||||
role: 'spectator',
|
||||
roverId: null,
|
||||
text,
|
||||
});
|
||||
} catch {
|
||||
// ignore spam errors
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
const DURATION_MS = 60 * 1000;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
async function stopDarkness(ctx, effect = {}) {
|
||||
if (activeTimer) {
|
||||
clearTimeout(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
|
||||
const prevLights = Array.isArray(effect.prevLights) ? effect.prevLights : [];
|
||||
await Promise.all(
|
||||
prevLights.map(async (entry) => {
|
||||
try {
|
||||
await ctx.setHomeAssistantEntityState(entry.id, entry.state === 'on' ? 'on' : 'off');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness restore light failed', { entityId: entry.id, error: err.message });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const prevNightVision = effect.prevNightVision && typeof effect.prevNightVision === 'object'
|
||||
? effect.prevNightVision
|
||||
: {};
|
||||
Object.entries(prevNightVision).forEach(([roverId, wasOn]) => {
|
||||
try {
|
||||
ctx.issueCommand(String(roverId), {
|
||||
type: 'nightVision',
|
||||
nightVision: { action: wasOn ? 'on' : 'off' },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness restore nightVision failed', { roverId, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
ctx.clearEffect('darkness');
|
||||
}
|
||||
|
||||
function startDarkness(ctx, effect) {
|
||||
if (activeTimer) {
|
||||
clearTimeout(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
|
||||
const remaining = Math.max(0, endsAt - Date.now());
|
||||
ctx.saveEffect('darkness', effect);
|
||||
activeTimer = setTimeout(() => {
|
||||
stopDarkness(ctx, effect).catch((err) => {
|
||||
ctx.logger.warn('darkness stop failed', { error: err.message });
|
||||
});
|
||||
}, remaining);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'darkness',
|
||||
name: 'Darkness',
|
||||
goal: 65,
|
||||
async run(ctx) {
|
||||
const entities = ctx.getHomeAssistantEntities();
|
||||
const prevLights = entities.map((entity) => ({ id: entity.id, state: entity.state === 'on' ? 'on' : 'off' }));
|
||||
await Promise.all(
|
||||
entities.map(async (entity) => {
|
||||
try {
|
||||
await ctx.setHomeAssistantEntityState(entity.id, 'off');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness light off failed', { entityId: entity.id, error: err.message });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const prevNightVision = {};
|
||||
ctx.listOnlineRovers().forEach((rover) => {
|
||||
const state = rover?.nightVision?.state;
|
||||
const nightVisionOn = Boolean(state && state.nightVisionOn === true);
|
||||
prevNightVision[String(rover.id)] = nightVisionOn;
|
||||
try {
|
||||
ctx.issueCommand(String(rover.id), {
|
||||
type: 'nightVision',
|
||||
nightVision: { action: 'off' },
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.logger.warn('darkness nightVision off failed', { roverId: rover.id, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
const effect = { endsAt: Date.now() + DURATION_MS, prevLights, prevNightVision };
|
||||
startDarkness(ctx, effect);
|
||||
ctx.sendAlert({ color: '#212121', title: 'Darkness', message: 'Darkness effect active for 60 seconds.' });
|
||||
},
|
||||
async recover(ctx, effect) {
|
||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||
await stopDarkness(ctx, effect || {});
|
||||
return;
|
||||
}
|
||||
startDarkness(ctx, effect);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
id: 'discordStalkerPing',
|
||||
name: 'Stalker Ping',
|
||||
goal: 150,
|
||||
async run(ctx) {
|
||||
ctx.publishEvent({
|
||||
source: 'buttonBoxReward',
|
||||
type: 'buttonBox.discordStalkerPing',
|
||||
payload: {
|
||||
message: 'Pinged by button box masher!!',
|
||||
},
|
||||
});
|
||||
ctx.sendAlert({ color: '#5865f2', title: 'Stalker Ping', message: 'Sent stalker ping to Discord.' });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
|
||||
|
||||
module.exports = {
|
||||
id: 'dockPanic',
|
||||
name: 'All Dock',
|
||||
goal: 30,
|
||||
async run(ctx) {
|
||||
const rovers = ctx.listOnlineRovers();
|
||||
rovers.forEach((rover) => {
|
||||
try {
|
||||
ctx.issueCommand(String(rover.id), { type: 'raw', raw: DOCK_COMMAND_BASE64 });
|
||||
} catch (err) {
|
||||
ctx.logger.warn('dockPanic failed', { roverId: rover.id, error: err.message });
|
||||
}
|
||||
});
|
||||
ctx.sendAlert({
|
||||
color: '#ff9800',
|
||||
title: 'All Dock',
|
||||
message: `Issued seek-dock to ${rovers.length} rover(s).`,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
const NAMES = ['x', 'q', 'z', 'm', 'n', 'p', 'k', 'r'];
|
||||
const BURSTS = 14;
|
||||
const TICK_MS = 180;
|
||||
|
||||
module.exports = {
|
||||
id: 'ghostTypingSpam',
|
||||
name: 'Typing Spam',
|
||||
goal: 40,
|
||||
async run(ctx) {
|
||||
let tick = 0;
|
||||
const active = new Set();
|
||||
const timer = setInterval(() => {
|
||||
tick += 1;
|
||||
const name = NAMES[Math.floor(Math.random() * NAMES.length)] + String(Math.floor(Math.random() * 10));
|
||||
const on = Math.random() > 0.35;
|
||||
try {
|
||||
ctx.sendExternalTyping({
|
||||
nickname: name,
|
||||
role: 'spectator',
|
||||
roverId: null,
|
||||
isTyping: on,
|
||||
});
|
||||
if (on) active.add(name);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (tick >= BURSTS) {
|
||||
clearInterval(timer);
|
||||
active.forEach((ghost) => {
|
||||
try {
|
||||
ctx.sendExternalTyping({ nickname: ghost, role: 'spectator', roverId: null, isTyping: false });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
}, TICK_MS);
|
||||
|
||||
ctx.sendAlert({ color: '#9c27b0', title: 'Typing Spam', message: 'Ghost typing burst started.' });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
const STROBE_MS = 10000;
|
||||
const TICK_MS = 350;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
async function applyAll(ctx, state) {
|
||||
const entities = ctx.getHomeAssistantEntities();
|
||||
await Promise.all(
|
||||
entities.map(async (entity) => {
|
||||
try {
|
||||
await ctx.setHomeAssistantEntityState(entity.id, state);
|
||||
} catch (err) {
|
||||
ctx.logger.warn('lightStrobe entity set failed', { entityId: entity.id, error: err.message });
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function startStrobe(ctx, effect = {}) {
|
||||
if (activeTimer) {
|
||||
clearInterval(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
|
||||
const endsAt = Number(effect.endsAt || Date.now() + STROBE_MS);
|
||||
let on = Boolean(effect.on);
|
||||
ctx.saveEffect('lightStrobe', { endsAt, on });
|
||||
|
||||
activeTimer = setInterval(async () => {
|
||||
if (Date.now() >= endsAt) {
|
||||
clearInterval(activeTimer);
|
||||
activeTimer = null;
|
||||
ctx.clearEffect('lightStrobe');
|
||||
return;
|
||||
}
|
||||
on = !on;
|
||||
await applyAll(ctx, on ? 'on' : 'off');
|
||||
ctx.saveEffect('lightStrobe', { endsAt, on });
|
||||
}, TICK_MS);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'lightStrobe',
|
||||
name: 'Light Strobe',
|
||||
goal: 55,
|
||||
async run(ctx) {
|
||||
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
||||
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'Room light strobe started.' });
|
||||
},
|
||||
async recover(ctx, effect) {
|
||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||
ctx.clearEffect('lightStrobe');
|
||||
return;
|
||||
}
|
||||
startStrobe(ctx, effect);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
const DURATION_MS = 25 * 1000;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
function clearTimer() {
|
||||
if (activeTimer) {
|
||||
clearTimeout(activeTimer);
|
||||
activeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(ctx, effect = {}) {
|
||||
clearTimer();
|
||||
const prevMode = effect.prevMode;
|
||||
const prevReason = effect.prevReason;
|
||||
|
||||
if (prevMode) {
|
||||
try {
|
||||
ctx.setMode(prevMode, 'buttonbox:modeJamRestore');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('modeJam restore mode failed', { error: err.message });
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (prevReason == null || prevReason === '') {
|
||||
ctx.clearAdminReason('buttonbox:modeJamRestore');
|
||||
} else {
|
||||
ctx.setAdminReason(prevReason, 'buttonbox:modeJamRestore');
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.logger.warn('modeJam restore reason failed', { error: err.message });
|
||||
}
|
||||
|
||||
ctx.clearEffect('modeJam');
|
||||
}
|
||||
|
||||
function scheduleRestore(ctx, effect = {}) {
|
||||
clearTimer();
|
||||
const endsAt = Number(effect.endsAt || Date.now() + DURATION_MS);
|
||||
const remaining = Math.max(0, endsAt - Date.now());
|
||||
const next = { ...effect, endsAt };
|
||||
ctx.saveEffect('modeJam', next);
|
||||
activeTimer = setTimeout(() => {
|
||||
restore(ctx, next).catch((err) => {
|
||||
ctx.logger.warn('modeJam restore failed', { error: err.message });
|
||||
});
|
||||
}, remaining);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
id: 'modeJam',
|
||||
name: 'Admin Mode',
|
||||
goal: 200,
|
||||
async run(ctx) {
|
||||
const prevMode = ctx.getMode();
|
||||
const prevReason = ctx.getAdminReasonText();
|
||||
const jamReason = `Button box chaos active until ${new Date(Date.now() + DURATION_MS).toLocaleTimeString()}`;
|
||||
|
||||
try {
|
||||
ctx.setMode('admin', 'buttonbox:modeJam');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('modeJam set mode failed', { error: err.message });
|
||||
}
|
||||
try {
|
||||
ctx.setAdminReason(jamReason, 'buttonbox:modeJam');
|
||||
} catch (err) {
|
||||
ctx.logger.warn('modeJam set reason failed', { error: err.message });
|
||||
}
|
||||
|
||||
scheduleRestore(ctx, {
|
||||
prevMode,
|
||||
prevReason,
|
||||
endsAt: Date.now() + DURATION_MS,
|
||||
});
|
||||
|
||||
ctx.sendAlert({ color: '#ff5722', title: 'Admin Mode', message: 'Server forced into admin mode temporarily.' });
|
||||
},
|
||||
async recover(ctx, effect) {
|
||||
if (!effect || Number(effect.endsAt || 0) <= Date.now()) {
|
||||
await restore(ctx, effect || {});
|
||||
return;
|
||||
}
|
||||
scheduleRestore(ctx, effect);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
const COLORS = ['#f44336', '#ff9800', '#ffeb3b', '#4caf50', '#2196f3', '#9c27b0', '#e91e63'];
|
||||
const TITLES = ['RARARARARARARARARE', 'CHIRPET CHIRPET CHIRPET', 'meowmowmoowmomwowm', 'theleash'];
|
||||
|
||||
module.exports = {
|
||||
id: 'rogueEventSpam',
|
||||
name: 'Event Flood',
|
||||
goal: 45,
|
||||
async run(ctx) {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
const title = TITLES[Math.floor(Math.random() * TITLES.length)];
|
||||
const message = `Event ${i + 1} / 10`;
|
||||
ctx.sendAlert({ color, title, message });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
const dockPanic = require('./definitions/dockPanic');
|
||||
const cameraWhiplash = require('./definitions/cameraWhiplash');
|
||||
const lightStrobe = require('./definitions/lightStrobe');
|
||||
const ghostTypingSpam = require('./definitions/ghostTypingSpam');
|
||||
const darkness = require('./definitions/darkness');
|
||||
const discordStalkerPing = require('./definitions/discordStalkerPing');
|
||||
const rogueEventSpam = require('./definitions/rogueEventSpam');
|
||||
const modeJam = require('./definitions/modeJam');
|
||||
const assignmentRoulette = require('./definitions/assignmentRoulette');
|
||||
const chatSpam = require('./definitions/chatSpam');
|
||||
|
||||
const orderedRewards = [
|
||||
dockPanic,
|
||||
cameraWhiplash,
|
||||
lightStrobe,
|
||||
ghostTypingSpam,
|
||||
darkness,
|
||||
discordStalkerPing,
|
||||
rogueEventSpam,
|
||||
modeJam,
|
||||
assignmentRoulette,
|
||||
chatSpam,
|
||||
];
|
||||
|
||||
const rewardById = new Map(orderedRewards.map((reward, idx) => [reward.id, { ...reward, number: idx + 1 }]));
|
||||
|
||||
function listRewards() {
|
||||
return orderedRewards.map((reward, idx) => ({ ...reward, number: idx + 1 }));
|
||||
}
|
||||
|
||||
function getRewardById(id) {
|
||||
return rewardById.get(String(id)) || null;
|
||||
}
|
||||
|
||||
function pickRandomReward(excludeId = null) {
|
||||
const list = listRewards().filter((reward) => !excludeId || reward.id !== excludeId);
|
||||
if (!list.length) return null;
|
||||
return list[Math.floor(Math.random() * list.length)] || null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRewards,
|
||||
getRewardById,
|
||||
pickRandomReward,
|
||||
};
|
||||
@@ -213,6 +213,17 @@ function pickRover(socket) {
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
function rerollAssignments() {
|
||||
const users = Array.from(socketRefs.values()).filter((socket) => socket && getRole(socket) === 'user');
|
||||
users.forEach((socket) => {
|
||||
unassignSocket(socket);
|
||||
});
|
||||
users.forEach((socket) => {
|
||||
assignSocket(socket);
|
||||
});
|
||||
return users.length;
|
||||
}
|
||||
|
||||
function describeAssignment(socketId) {
|
||||
const assignedRoverId = assignments.get(socketId) || null;
|
||||
const adminRoverId = assignedRoverId ? null : roverManager.getPrimaryRoverForSocket(socketId);
|
||||
@@ -230,6 +241,7 @@ module.exports = {
|
||||
assignmentEvents,
|
||||
describeAssignment,
|
||||
forceRelease,
|
||||
rerollAssignments,
|
||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||
if (!socket || !roverId) return;
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { app } = require('../globals/http');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('buttonBoxService');
|
||||
const { publishEvent } = require('./eventBus');
|
||||
const { getRewardById, pickRandomReward } = require('../rewards');
|
||||
const roverManager = require('./roverManager');
|
||||
const { issueCommand } = require('./commandService');
|
||||
const { sendAlert } = require('./alertService');
|
||||
const { sendExternalTyping, sendExternalMessage, sendSystemMessage } = require('./chatService');
|
||||
const { setMode, getMode } = require('./modeManager');
|
||||
const { getAdminReason, setAdminReason, clearAdminReason } = require('./adminReasonService');
|
||||
const assignmentService = require('./assignmentService');
|
||||
const {
|
||||
getState: getHomeAssistantState,
|
||||
setEntityState: setHomeAssistantEntityState,
|
||||
} = require('./homeAssistantService');
|
||||
const { getRequestIp, isLocalNetwork, normalizeIp } = require('../helpers/ipResolver');
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', '..', 'data');
|
||||
const STORE_PATH = path.join(DATA_DIR, 'buttonbox-state.json');
|
||||
const BUTTON_COUNT = 4;
|
||||
const STORE_VERSION = 1;
|
||||
|
||||
let state = null;
|
||||
|
||||
function createDefaultButton(id) {
|
||||
return {
|
||||
id,
|
||||
count: 0,
|
||||
rewardId: null,
|
||||
rewardName: null,
|
||||
rewardNumber: null,
|
||||
goal: null,
|
||||
lastIncrementAt: null,
|
||||
lastRewardAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultState() {
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
updatedAt: Date.now(),
|
||||
buttons: Array.from({ length: BUTTON_COUNT }, (_, idx) => createDefaultButton(idx + 1)),
|
||||
effects: {},
|
||||
};
|
||||
}
|
||||
|
||||
function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizeLoaded(raw = {}) {
|
||||
const base = createDefaultState();
|
||||
const sourceButtons = Array.isArray(raw.buttons) ? raw.buttons : [];
|
||||
const buttons = base.buttons.map((button, idx) => {
|
||||
const loaded = sourceButtons[idx] || {};
|
||||
return {
|
||||
...button,
|
||||
count: Number.isFinite(loaded.count) ? Math.max(0, Math.floor(loaded.count)) : 0,
|
||||
rewardId: typeof loaded.rewardId === 'string' ? loaded.rewardId : null,
|
||||
rewardName: typeof loaded.rewardName === 'string' ? loaded.rewardName : null,
|
||||
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null,
|
||||
goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null,
|
||||
lastIncrementAt: Number.isFinite(loaded.lastIncrementAt) ? loaded.lastIncrementAt : null,
|
||||
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
|
||||
};
|
||||
});
|
||||
|
||||
const effects = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
||||
buttons,
|
||||
effects,
|
||||
};
|
||||
}
|
||||
|
||||
function writeState() {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
const next = {
|
||||
...state,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||
fs.renameSync(tempPath, STORE_PATH);
|
||||
state = next;
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
if (state) return state;
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
state = normalizeLoaded(raw);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load button box store', err.message);
|
||||
}
|
||||
state = createDefaultState();
|
||||
}
|
||||
ensureRewardAssignments();
|
||||
writeState();
|
||||
return state;
|
||||
}
|
||||
|
||||
function ensureRewardAssignments() {
|
||||
if (!state) return;
|
||||
state.buttons.forEach((button) => {
|
||||
const reward = getRewardById(button.rewardId);
|
||||
if (reward) {
|
||||
button.rewardName = reward.name || null;
|
||||
button.rewardNumber = reward.number;
|
||||
button.goal = reward.goal;
|
||||
if (!Number.isFinite(button.count) || button.count < 0) {
|
||||
button.count = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
assignNewReward(button);
|
||||
});
|
||||
}
|
||||
|
||||
function assignNewReward(button) {
|
||||
const reward = pickRandomReward(button?.rewardId || null) || pickRandomReward(null);
|
||||
if (!reward) {
|
||||
throw new Error('No rewards configured');
|
||||
}
|
||||
button.rewardId = reward.id;
|
||||
button.rewardName = reward.name || null;
|
||||
button.rewardNumber = reward.number;
|
||||
button.goal = reward.goal;
|
||||
button.count = 0;
|
||||
}
|
||||
|
||||
function buildRewardContext() {
|
||||
return {
|
||||
logger,
|
||||
issueCommand,
|
||||
listOnlineRovers: () => roverManager.getRoster(),
|
||||
sendAlert,
|
||||
publishEvent,
|
||||
sendExternalTyping,
|
||||
sendExternalMessage,
|
||||
sendSystemMessage,
|
||||
getMode,
|
||||
setMode: (mode, source = 'buttonbox') =>
|
||||
setMode(
|
||||
mode,
|
||||
{ data: { role: 'admin', user: { username: source } } },
|
||||
{ force: true },
|
||||
),
|
||||
getAdminReasonText: () => getAdminReason()?.text || null,
|
||||
setAdminReason: (text, source = 'buttonbox') => setAdminReason(text, { by: source }),
|
||||
clearAdminReason: (source = 'buttonbox') => clearAdminReason({ by: source }),
|
||||
rerollAssignments: () => assignmentService.rerollAssignments(),
|
||||
getHomeAssistantEntities: () => {
|
||||
const entities = getHomeAssistantState()?.entities;
|
||||
return Array.isArray(entities) ? entities : [];
|
||||
},
|
||||
setHomeAssistantEntityState,
|
||||
saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }),
|
||||
clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }),
|
||||
};
|
||||
}
|
||||
|
||||
function getButtonBoxState() {
|
||||
loadState();
|
||||
return clone({ buttons: state.buttons });
|
||||
}
|
||||
|
||||
function publishUpdated() {
|
||||
publishEvent({
|
||||
source: 'buttonBox',
|
||||
type: 'buttonBox.updated',
|
||||
payload: { updatedAt: Date.now() },
|
||||
});
|
||||
}
|
||||
|
||||
function saveEffect(effectId, payload = {}, options = {}) {
|
||||
loadState();
|
||||
state.effects = state.effects && typeof state.effects === 'object' ? state.effects : {};
|
||||
state.effects[effectId] = payload;
|
||||
writeState();
|
||||
if (options.broadcast) {
|
||||
publishUpdated();
|
||||
}
|
||||
}
|
||||
|
||||
function clearEffect(effectId, options = {}) {
|
||||
loadState();
|
||||
if (state.effects && typeof state.effects === 'object' && state.effects[effectId]) {
|
||||
delete state.effects[effectId];
|
||||
writeState();
|
||||
if (options.broadcast) {
|
||||
publishUpdated();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseButtonId(body) {
|
||||
if (typeof body !== 'string') return null;
|
||||
const value = Number.parseInt(body.trim(), 10);
|
||||
if (Number.isFinite(value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
function denyIfNotLocal(req, res) {
|
||||
const ip = normalizeIp(getRequestIp(req));
|
||||
if (isLocalNetwork(ip)) {
|
||||
return false;
|
||||
}
|
||||
logger.warn('Rejected non-local button press request', { ip: ip || null });
|
||||
res.status(403).json({ error: 'Button presses must originate from local network' });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runRewardForButton(button) {
|
||||
const reward = getRewardById(button.rewardId);
|
||||
if (!reward) {
|
||||
assignNewReward(button);
|
||||
return;
|
||||
}
|
||||
const ctx = buildRewardContext();
|
||||
try {
|
||||
await reward.run(ctx);
|
||||
} catch (err) {
|
||||
logger.warn('Reward execution failed', { rewardId: reward.id, error: err.message });
|
||||
}
|
||||
button.lastRewardAt = Date.now();
|
||||
button.count = 0;
|
||||
assignNewReward(button);
|
||||
}
|
||||
|
||||
async function applyPress(buttonId) {
|
||||
loadState();
|
||||
const button = state.buttons.find((entry) => entry.id === buttonId);
|
||||
if (!button) {
|
||||
throw new Error('Unknown button');
|
||||
}
|
||||
|
||||
button.count += 1;
|
||||
button.lastIncrementAt = Date.now();
|
||||
writeState();
|
||||
|
||||
io.emit('buttonBox:increment', {
|
||||
buttonId,
|
||||
count: button.count,
|
||||
ts: button.lastIncrementAt,
|
||||
});
|
||||
|
||||
if (button.count >= button.goal) {
|
||||
await runRewardForButton(button);
|
||||
writeState();
|
||||
}
|
||||
|
||||
publishUpdated();
|
||||
return clone(button);
|
||||
}
|
||||
|
||||
async function recoverEffects() {
|
||||
loadState();
|
||||
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
|
||||
const ctx = buildRewardContext();
|
||||
|
||||
for (const [effectId, payload] of Object.entries(effects)) {
|
||||
const reward = getRewardById(effectId);
|
||||
if (!reward || typeof reward.recover !== 'function') {
|
||||
clearEffect(effectId, { broadcast: false });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await reward.recover(ctx, payload);
|
||||
} catch (err) {
|
||||
logger.warn('Effect recovery failed', { effectId, error: err.message });
|
||||
clearEffect(effectId, { broadcast: false });
|
||||
}
|
||||
}
|
||||
publishUpdated();
|
||||
}
|
||||
|
||||
app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => {
|
||||
if (denyIfNotLocal(req, res)) return;
|
||||
const buttonId = parseButtonId(req.body);
|
||||
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > BUTTON_COUNT) {
|
||||
res.status(400).json({ error: 'button must be 1-4' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const button = await applyPress(buttonId);
|
||||
res.json({ success: true, button });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message || 'Button processing failed' });
|
||||
}
|
||||
});
|
||||
|
||||
loadState();
|
||||
recoverEffects().catch((err) => {
|
||||
logger.warn('Button box effect recovery failed', err.message);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getButtonBoxState,
|
||||
};
|
||||
@@ -1492,6 +1492,21 @@ function handleBusEvent(event) {
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'buttonBox.discordStalkerPing': {
|
||||
const message = payload?.message ? String(payload.message) : 'Button box chaos reward triggered.';
|
||||
const stalkerRoleId = roles.stalkerPing || null;
|
||||
const content = stalkerRoleId ? `<@&${stalkerRoleId}> ${message}` : message;
|
||||
announce({
|
||||
channelId: channels.general,
|
||||
pingRoleId: stalkerRoleId,
|
||||
content,
|
||||
color: 0xe91e63,
|
||||
title: 'Button Box Reward',
|
||||
description: message,
|
||||
includeSiteUrl: false,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ const { subscribe } = require('./eventBus');
|
||||
const { getSocketIp, isLocalNetwork } = require('../helpers/ipResolver');
|
||||
const { getAudioForwardState, audioForwardEvents } = require('./audioForwardService');
|
||||
const { getAudioLevels, audioLevelsEvents } = require('./audioLevelsService');
|
||||
const { getButtonBoxState } = require('./buttonBoxService');
|
||||
|
||||
const config = loadConfig();
|
||||
const discordInvite = config.discord?.invite || null;
|
||||
@@ -136,6 +137,7 @@ function buildSession(socket) {
|
||||
isVerified: Boolean(socket?.data?.isVerified),
|
||||
audioForward: getAudioForwardState(),
|
||||
audioLevels: getAudioLevels(),
|
||||
buttonBox: getButtonBoxState(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -318,6 +320,10 @@ subscribe('adminReason.updated', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
subscribe('buttonBox.updated', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
audioForwardEvents.on('change', () => {
|
||||
syncAll();
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ import CommunityGoalBanner from './components/CommunityGoalBanner.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel.jsx';
|
||||
import VipPanel from './components/VipPanel.jsx';
|
||||
import { useSession } from './context/SessionContext.jsx';
|
||||
import ButtonBoxPanel from './components/ButtonBoxPanel.jsx';
|
||||
|
||||
function useLayoutMode() {
|
||||
const [mode, setMode] = useState(() => {
|
||||
@@ -143,6 +144,7 @@ function MobileFeatureTabs({
|
||||
<div className="space-y-0.5">
|
||||
{/* {showTelemetry ? <TelemetryPanel /> : null} */}
|
||||
<HomeAssistantControls />
|
||||
<ButtonBoxPanel />
|
||||
<RoomCameraPanel panelId={roomPanelId} />
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||
|
||||
const FLASH_MS = 420;
|
||||
const REWARD_FLASH_MS = 1200;
|
||||
const BUTTON_TONES = {
|
||||
1: 262,
|
||||
2: 330,
|
||||
3: 392,
|
||||
4: 523,
|
||||
};
|
||||
|
||||
export default function ButtonBoxPanel() {
|
||||
const { session } = useSession();
|
||||
const socket = useSocket();
|
||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
|
||||
? audioSettings.masterVolume
|
||||
: AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume)
|
||||
? audioSettings.alertVolume
|
||||
: AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
|
||||
const buttons = useMemo(() => {
|
||||
const list = Array.isArray(session?.buttonBox?.buttons) ? session.buttonBox.buttons : [];
|
||||
if (list.length === 4) return list;
|
||||
return [1, 2, 3, 4].map((id) => list.find((entry) => Number(entry?.id) === id) || {
|
||||
id,
|
||||
count: 0,
|
||||
goal: 0,
|
||||
rewardName: null,
|
||||
rewardId: null,
|
||||
rewardNumber: null,
|
||||
lastRewardAt: null,
|
||||
});
|
||||
}, [session?.buttonBox?.buttons]);
|
||||
|
||||
const [incFlash, setIncFlash] = useState({});
|
||||
const [rewardFlash, setRewardFlash] = useState({});
|
||||
const timersRef = useRef(new Map());
|
||||
const rewardTimersRef = useRef(new Map());
|
||||
const prevRewardAtRef = useRef({});
|
||||
const audioCtxRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextMap = {};
|
||||
buttons.forEach((button) => {
|
||||
nextMap[button.id] = Number(button.lastRewardAt || 0);
|
||||
});
|
||||
const prev = prevRewardAtRef.current || {};
|
||||
|
||||
buttons.forEach((button) => {
|
||||
const id = Number(button.id);
|
||||
const nextTs = nextMap[id] || 0;
|
||||
const prevTs = Number(prev[id] || 0);
|
||||
if (nextTs > 0 && nextTs !== prevTs) {
|
||||
setRewardFlash((current) => ({ ...current, [id]: true }));
|
||||
const oldTimer = rewardTimersRef.current.get(id);
|
||||
if (oldTimer) {
|
||||
clearTimeout(oldTimer);
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setRewardFlash((current) => ({ ...current, [id]: false }));
|
||||
rewardTimersRef.current.delete(id);
|
||||
}, REWARD_FLASH_MS);
|
||||
rewardTimersRef.current.set(id, timer);
|
||||
}
|
||||
});
|
||||
|
||||
prevRewardAtRef.current = nextMap;
|
||||
}, [buttons]);
|
||||
|
||||
useEffect(() => {
|
||||
function playTone(buttonId) {
|
||||
const freq = BUTTON_TONES[buttonId];
|
||||
if (!freq) return;
|
||||
if (effectiveAlertVolume <= 0) return;
|
||||
let ctx = audioCtxRef.current;
|
||||
if (!ctx) {
|
||||
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
ctx = new Ctor();
|
||||
audioCtxRef.current = ctx;
|
||||
}
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'square';
|
||||
osc.frequency.value = freq;
|
||||
const peakGain = 0.08 * effectiveAlertVolume;
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(Math.max(0.0001, peakGain), ctx.currentTime + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.11);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start();
|
||||
osc.stop(ctx.currentTime + 0.12);
|
||||
}
|
||||
|
||||
function onIncrement(payload = {}) {
|
||||
const buttonId = Number(payload.buttonId);
|
||||
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
|
||||
setIncFlash((current) => ({ ...current, [buttonId]: true }));
|
||||
const oldTimer = timersRef.current.get(buttonId);
|
||||
if (oldTimer) {
|
||||
clearTimeout(oldTimer);
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setIncFlash((current) => ({ ...current, [buttonId]: false }));
|
||||
timersRef.current.delete(buttonId);
|
||||
}, FLASH_MS);
|
||||
timersRef.current.set(buttonId, timer);
|
||||
playTone(buttonId);
|
||||
}
|
||||
|
||||
socket.on('buttonBox:increment', onIncrement);
|
||||
return () => {
|
||||
socket.off('buttonBox:increment', onIncrement);
|
||||
};
|
||||
}, [effectiveAlertVolume, socket]);
|
||||
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
<header className="flex items-center justify-between gap-0.5 text-sm text-slate-400">
|
||||
<p>Button Box</p>
|
||||
<p className="text-xs text-slate-500">Live from session</p>
|
||||
</header>
|
||||
<div className="grid grid-cols-4 gap-0.5">
|
||||
{buttons.map((button) => {
|
||||
const id = Number(button.id);
|
||||
const count = Number.isFinite(button.count) ? button.count : 0;
|
||||
const goal = Number.isFinite(button.goal) ? button.goal : 0;
|
||||
const rewardName = typeof button.rewardName === 'string' && button.rewardName.trim()
|
||||
? button.rewardName.trim()
|
||||
: 'Unassigned';
|
||||
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
|
||||
const incActive = Boolean(incFlash[id]);
|
||||
const rewardActive = Boolean(rewardFlash[id]);
|
||||
|
||||
return (
|
||||
<article
|
||||
key={id}
|
||||
className={[
|
||||
'rounded bg-zinc-950 p-0.5 shadow-inner shadow-black/40 transition-colors duration-200',
|
||||
incActive ? 'bg-cyan-900/70 ring-1 ring-cyan-400/70' : '',
|
||||
rewardActive ? 'bg-fuchsia-900/70 ring-1 ring-fuchsia-400/80' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<p className="text-xs text-slate-400">Button {id}</p>
|
||||
<p className="text-sm font-semibold text-white">{count} / {goal}</p>
|
||||
<p className="truncate text-[0.7rem] text-slate-300">#{rewardNumber} {rewardName}</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import CameraTiltControl from './CameraTiltControl.jsx';
|
||||
import VipPanel from './VipPanel.jsx';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import ButtonBoxPanel from './ButtonBoxPanel.jsx';
|
||||
|
||||
function TopDownMapPanel() {
|
||||
const {
|
||||
@@ -170,6 +171,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
||||
</div>
|
||||
</div>
|
||||
<HomeAssistantControls />
|
||||
<ButtonBoxPanel />
|
||||
<RoomCameraPanel defaultOrientation="horizontal" panelId="rightpane-telemetry" />
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
Reference in New Issue
Block a user