mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
buttonboxrewardses
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user