mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
better buttonboxing
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"unit": "millimeters",
|
||||||
|
"source": "roomba wheel encoders",
|
||||||
|
"mmPerCount": 0.444564998149499,
|
||||||
|
"rovers": {}
|
||||||
|
}
|
||||||
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
@@ -78,8 +78,8 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-eD5r_CFS.js"></script>
|
<script type="module" crossorigin src="/assets/index-BmIqHXVL.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C6I0gKp1.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DtN3aedy.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'assignmentRoulette',
|
id: 'assignmentRoulette',
|
||||||
name: 'Rover Reassignment',
|
name: 'Rover Reassignment',
|
||||||
|
description: 'Scrambles who is driving which rover.',
|
||||||
goal: 300,
|
goal: 300,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const moved = ctx.rerollAssignments();
|
const moved = ctx.rerollAssignments();
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
// Reward Definition: Camera Whiplash
|
// Reward Definition: Camera Whiplash
|
||||||
// Purpose: Defines the camera-whiplash deterrence reward and timing/strength settings. Scope: Supplies reusable reward metadata and execution parameters for chaos triggers.
|
// Purpose: Defines the camera-whiplash deterrence reward and timing/strength settings. Scope: Supplies reusable reward metadata and execution parameters for chaos triggers.
|
||||||
const STEP_MS = 220;
|
const STEP_MS = 220;
|
||||||
const DURATION_MS = 30 * 1000;
|
const DURATION_MS = 60 * 1000;
|
||||||
const STEPS = Math.ceil(DURATION_MS / STEP_MS);
|
const STEPS = Math.ceil(DURATION_MS / STEP_MS);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'cameraWhiplash',
|
id: 'cameraWhiplash',
|
||||||
name: 'Camera Wiggle',
|
name: 'Camera Wiggle',
|
||||||
|
description: 'Makes the rover cameras wiggle around.',
|
||||||
goal: 100,
|
goal: 100,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const rovers = ctx
|
const rovers = ctx
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ function sleep(ms) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'chatSpam',
|
id: 'chatSpam',
|
||||||
name: 'Chat Spam',
|
name: 'Chat Spam',
|
||||||
|
description: 'Floods chat with nonsense messages.',
|
||||||
goal: 320,
|
goal: 320,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const nickname = randLetter();
|
const nickname = randLetter();
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ async function startDarkness(ctx, effect) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'darkness',
|
id: 'darkness',
|
||||||
name: 'Darkness',
|
name: 'Darkness',
|
||||||
|
description: 'Disables room lights and headlights for 15 minutes.',
|
||||||
isHeadlightBlocked,
|
isHeadlightBlocked,
|
||||||
goal: 400,
|
goal: 400,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'discordPingEveryone',
|
id: 'discordPingEveryone',
|
||||||
name: 'PING @EVERYONE',
|
name: 'PING @EVERYONE',
|
||||||
goal: 20000,
|
description: 'Pings @everyone in Discord.',
|
||||||
|
dailyLimited: true,
|
||||||
|
goal: 4000,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
ctx.publishEvent({
|
ctx.publishEvent({
|
||||||
source: 'buttonBoxReward',
|
source: 'buttonBoxReward',
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'discordStalkerPing',
|
id: 'discordStalkerPing',
|
||||||
name: 'Discord Ping',
|
name: 'Discord Ping',
|
||||||
goal: 9000,
|
description: 'Pings the stalker role in Discord.',
|
||||||
|
dailyLimited: true,
|
||||||
|
goal: 2000,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
ctx.publishEvent({
|
ctx.publishEvent({
|
||||||
source: 'buttonBoxReward',
|
source: 'buttonBoxReward',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'dockPanic',
|
id: 'dockPanic',
|
||||||
name: 'All Dock',
|
name: 'All Dock',
|
||||||
|
description: 'Forces all rovers into docking mode.',
|
||||||
goal: 180,
|
goal: 180,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const rovers = ctx.listOnlineRovers();
|
const rovers = ctx.listOnlineRovers();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function sleep(ms) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'ghostTypingSpam',
|
id: 'ghostTypingSpam',
|
||||||
name: 'Typing Spam',
|
name: 'Typing Spam',
|
||||||
|
description: 'Fills chat with fake typing indicators.',
|
||||||
goal: 220,
|
goal: 220,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const active = new Set();
|
const active = new Set();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Reward Definition: Light Strobe
|
// Reward Definition: Light Strobe
|
||||||
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
// Purpose: Defines the light-strobe deterrence reward and activation contract. Scope: Encapsulates reward identity, labels, and effect parameters for runtime dispatch.
|
||||||
const STROBE_MS = 30 * 1000;
|
const STROBE_MS = 30 * 1000;
|
||||||
const TICK_MS = 200;
|
const TICK_MS = 500;
|
||||||
|
|
||||||
let activeTimer = null;
|
let activeTimer = null;
|
||||||
|
|
||||||
@@ -40,7 +40,8 @@ function startStrobe(ctx, effect = {}) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'lightStrobe',
|
id: 'lightStrobe',
|
||||||
name: 'Light Strobe',
|
name: 'Light Strobe',
|
||||||
goal: 300,
|
description: 'Makes the room lights flash rapidly for 30 seconds.',
|
||||||
|
goal: 400,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
||||||
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 30 seconds.' });
|
ctx.sendAlert({ color: '#ffc107', title: 'Light Strobe', message: 'All room controls strobing for 30 seconds.' });
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ function scheduleRestore(ctx, effect = {}) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
id: 'modeJam',
|
id: 'modeJam',
|
||||||
name: 'Admin Lock',
|
name: 'Admin Lock',
|
||||||
goal: 900,
|
description: 'Temporarily puts the server in admin mode.',
|
||||||
|
goal: 3000,
|
||||||
async run(ctx) {
|
async run(ctx) {
|
||||||
const durationMs =
|
const durationMs =
|
||||||
MIN_DURATION_MS + Math.floor(Math.random() * (MAX_DURATION_MS - MIN_DURATION_MS + 1));
|
MIN_DURATION_MS + Math.floor(Math.random() * (MAX_DURATION_MS - MIN_DURATION_MS + 1));
|
||||||
|
|||||||
@@ -25,6 +25,137 @@ function createButtonBoxCore(deps) {
|
|||||||
store,
|
store,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
|
function getTodayKey(now = Date.now()) {
|
||||||
|
/*
|
||||||
|
The daily cap is intentionally based on the server's local calendar day.
|
||||||
|
This matches the operational reality of the deployed server and avoids
|
||||||
|
storing per-user timezone state for a physical shared button box.
|
||||||
|
*/
|
||||||
|
const date = new Date(now);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDailyLimit(button) {
|
||||||
|
/*
|
||||||
|
Ceil lets a reward complete in five daily windows even when its goal is
|
||||||
|
not evenly divisible by five. The minimum of one keeps tiny goals usable.
|
||||||
|
*/
|
||||||
|
if (button?.dailyLimited !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const goal = Number.isFinite(button?.goal) ? Math.max(1, Math.floor(button.goal)) : 1;
|
||||||
|
return Math.max(1, Math.ceil(goal / 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDailyBucketIfNeeded(button, now = Date.now()) {
|
||||||
|
const today = getTodayKey(now);
|
||||||
|
if (button.dailyDate !== today) {
|
||||||
|
/*
|
||||||
|
Date rollover is handled lazily on the next press/add-count request.
|
||||||
|
That keeps the persisted state correct without needing a background
|
||||||
|
timer that would sit around on the development machine.
|
||||||
|
*/
|
||||||
|
button.dailyDate = today;
|
||||||
|
button.dailyCount = 0;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(button.dailyCount) || button.dailyCount < 0) {
|
||||||
|
button.dailyCount = 0;
|
||||||
|
}
|
||||||
|
return today;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIncrementDescription({ appliedCount, limited }) {
|
||||||
|
if (limited && appliedCount <= 0) {
|
||||||
|
return 'Daily limit reached';
|
||||||
|
}
|
||||||
|
return 'Progress added';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIncrementPayload({ button, requestedCount, appliedCount, limited, now }) {
|
||||||
|
const dailyLimit = getDailyLimit(button);
|
||||||
|
return {
|
||||||
|
buttonId: button.id,
|
||||||
|
count: button.count,
|
||||||
|
goal: button.goal,
|
||||||
|
dailyLimited: button.dailyLimited === true,
|
||||||
|
limited,
|
||||||
|
appliedCount,
|
||||||
|
requestedCount,
|
||||||
|
dailyCount: button.dailyCount,
|
||||||
|
dailyLimit,
|
||||||
|
description: buildIncrementDescription({ appliedCount, limited }),
|
||||||
|
ts: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneButtonForResponse(button) {
|
||||||
|
/*
|
||||||
|
HTTP callers and overseer tools receive a direct button response instead
|
||||||
|
of waiting for session sync. Include the derived daily limit there too so
|
||||||
|
every public button shape describes the same daily-cap state.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
...store.clone(button),
|
||||||
|
dailyLimit: getDailyLimit(button),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyProgress(buttonId, requestedCount = 1) {
|
||||||
|
const state = store.getState();
|
||||||
|
const button = state.buttons.find((entry) => entry.id === buttonId);
|
||||||
|
if (!button) {
|
||||||
|
throw new Error('Unknown button');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
resetDailyBucketIfNeeded(button, now);
|
||||||
|
|
||||||
|
const requested = Math.max(1, Math.floor(Number(requestedCount) || 0));
|
||||||
|
const dailyLimited = button.dailyLimited === true;
|
||||||
|
const dailyLimit = getDailyLimit(button);
|
||||||
|
/*
|
||||||
|
Most rewards are intentionally uncapped so button-box chaos can still be
|
||||||
|
built up quickly. The daily bucket only constrains rewards that opt into
|
||||||
|
it, currently the Discord pings that can bother people off-site.
|
||||||
|
*/
|
||||||
|
const dailyRemaining = dailyLimited ? Math.max(0, dailyLimit - button.dailyCount) : requested;
|
||||||
|
const appliedCount = dailyLimited ? Math.min(requested, dailyRemaining) : requested;
|
||||||
|
const limited = dailyLimited && appliedCount < requested;
|
||||||
|
|
||||||
|
if (appliedCount > 0) {
|
||||||
|
button.count += appliedCount;
|
||||||
|
if (dailyLimited) {
|
||||||
|
button.dailyCount += appliedCount;
|
||||||
|
}
|
||||||
|
button.lastIncrementAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.writeState();
|
||||||
|
|
||||||
|
io.emit('buttonBox:increment', buildIncrementPayload({
|
||||||
|
button,
|
||||||
|
requestedCount: requested,
|
||||||
|
appliedCount,
|
||||||
|
limited,
|
||||||
|
now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
while (button.count >= button.goal) {
|
||||||
|
await runRewardForButton(button);
|
||||||
|
/*
|
||||||
|
Reward assignment resets the daily bucket because the new reward should
|
||||||
|
start clean instead of inheriting the just-completed reward's cap usage.
|
||||||
|
*/
|
||||||
|
store.writeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
publishUpdated();
|
||||||
|
return cloneButtonForResponse(button);
|
||||||
|
}
|
||||||
|
|
||||||
function publishUpdated() {
|
function publishUpdated() {
|
||||||
publishEvent({
|
publishEvent({
|
||||||
source: 'buttonBox',
|
source: 'buttonBox',
|
||||||
@@ -117,55 +248,12 @@ function createButtonBoxCore(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function applyPress(buttonId) {
|
async function applyPress(buttonId) {
|
||||||
const state = store.getState();
|
return applyProgress(buttonId, 1);
|
||||||
const button = state.buttons.find((entry) => entry.id === buttonId);
|
|
||||||
if (!button) {
|
|
||||||
throw new Error('Unknown button');
|
|
||||||
}
|
|
||||||
|
|
||||||
button.count += 1;
|
|
||||||
button.lastIncrementAt = Date.now();
|
|
||||||
store.writeState();
|
|
||||||
|
|
||||||
io.emit('buttonBox:increment', {
|
|
||||||
buttonId,
|
|
||||||
count: button.count,
|
|
||||||
ts: button.lastIncrementAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (button.count >= button.goal) {
|
|
||||||
await runRewardForButton(button);
|
|
||||||
store.writeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
publishUpdated();
|
|
||||||
return store.clone(button);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addCount(buttonId, amount = 1) {
|
async function addCount(buttonId, amount = 1) {
|
||||||
const state = store.getState();
|
|
||||||
const button = state.buttons.find((entry) => entry.id === buttonId);
|
|
||||||
if (!button) {
|
|
||||||
throw new Error('Unknown button');
|
|
||||||
}
|
|
||||||
const inc = Math.max(1, Math.floor(Number(amount) || 0));
|
const inc = Math.max(1, Math.floor(Number(amount) || 0));
|
||||||
button.count += inc;
|
return applyProgress(buttonId, inc);
|
||||||
button.lastIncrementAt = Date.now();
|
|
||||||
store.writeState();
|
|
||||||
|
|
||||||
io.emit('buttonBox:increment', {
|
|
||||||
buttonId,
|
|
||||||
count: button.count,
|
|
||||||
ts: button.lastIncrementAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
while (button.count >= button.goal) {
|
|
||||||
await runRewardForButton(button);
|
|
||||||
store.writeState();
|
|
||||||
}
|
|
||||||
|
|
||||||
publishUpdated();
|
|
||||||
return store.clone(button);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recoverEffects() {
|
async function recoverEffects() {
|
||||||
|
|||||||
@@ -22,8 +22,12 @@ function createButtonBoxStore(deps) {
|
|||||||
count: 0,
|
count: 0,
|
||||||
rewardId: null,
|
rewardId: null,
|
||||||
rewardName: null,
|
rewardName: null,
|
||||||
|
rewardDescription: null,
|
||||||
|
dailyLimited: false,
|
||||||
rewardNumber: null,
|
rewardNumber: null,
|
||||||
goal: null,
|
goal: null,
|
||||||
|
dailyDate: null,
|
||||||
|
dailyCount: 0,
|
||||||
lastIncrementAt: null,
|
lastIncrementAt: null,
|
||||||
lastRewardAt: null,
|
lastRewardAt: null,
|
||||||
};
|
};
|
||||||
@@ -67,9 +71,18 @@ function createButtonBoxStore(deps) {
|
|||||||
|
|
||||||
button.rewardId = reward.id;
|
button.rewardId = reward.id;
|
||||||
button.rewardName = reward.name || null;
|
button.rewardName = reward.name || null;
|
||||||
|
button.rewardDescription = reward.description || null;
|
||||||
|
button.dailyLimited = reward.dailyLimited === true;
|
||||||
button.rewardNumber = reward.number;
|
button.rewardNumber = reward.number;
|
||||||
button.goal = reward.goal;
|
button.goal = reward.goal;
|
||||||
button.count = 0;
|
button.count = 0;
|
||||||
|
/*
|
||||||
|
A new reward starts a fresh earning window for the button. Resetting the
|
||||||
|
daily bucket here prevents leftover progress from the previous reward from
|
||||||
|
making the newly assigned reward appear capped before anyone presses it.
|
||||||
|
*/
|
||||||
|
button.dailyDate = null;
|
||||||
|
button.dailyCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureRewardAssignments() {
|
function ensureRewardAssignments() {
|
||||||
@@ -80,11 +93,19 @@ function createButtonBoxStore(deps) {
|
|||||||
if (reward && !seen.has(reward.id)) {
|
if (reward && !seen.has(reward.id)) {
|
||||||
seen.add(reward.id);
|
seen.add(reward.id);
|
||||||
button.rewardName = reward.name || null;
|
button.rewardName = reward.name || null;
|
||||||
|
button.rewardDescription = reward.description || null;
|
||||||
|
button.dailyLimited = reward.dailyLimited === true;
|
||||||
button.rewardNumber = reward.number;
|
button.rewardNumber = reward.number;
|
||||||
button.goal = reward.goal;
|
button.goal = reward.goal;
|
||||||
if (!Number.isFinite(button.count) || button.count < 0) {
|
if (!Number.isFinite(button.count) || button.count < 0) {
|
||||||
button.count = 0;
|
button.count = 0;
|
||||||
}
|
}
|
||||||
|
if (typeof button.dailyDate !== 'string' || !button.dailyDate) {
|
||||||
|
button.dailyDate = null;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(button.dailyCount) || button.dailyCount < 0) {
|
||||||
|
button.dailyCount = 0;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
assignNewReward(button);
|
assignNewReward(button);
|
||||||
@@ -104,8 +125,12 @@ function createButtonBoxStore(deps) {
|
|||||||
count: Number.isFinite(loaded.count) ? Math.max(0, Math.floor(loaded.count)) : 0,
|
count: Number.isFinite(loaded.count) ? Math.max(0, Math.floor(loaded.count)) : 0,
|
||||||
rewardId: typeof loaded.rewardId === 'string' ? loaded.rewardId : null,
|
rewardId: typeof loaded.rewardId === 'string' ? loaded.rewardId : null,
|
||||||
rewardName: typeof loaded.rewardName === 'string' ? loaded.rewardName : null,
|
rewardName: typeof loaded.rewardName === 'string' ? loaded.rewardName : null,
|
||||||
|
rewardDescription: typeof loaded.rewardDescription === 'string' ? loaded.rewardDescription : null,
|
||||||
|
dailyLimited: loaded.dailyLimited === true,
|
||||||
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null,
|
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null,
|
||||||
goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null,
|
goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null,
|
||||||
|
dailyDate: typeof loaded.dailyDate === 'string' && loaded.dailyDate ? loaded.dailyDate : null,
|
||||||
|
dailyCount: Number.isFinite(loaded.dailyCount) ? Math.max(0, Math.floor(loaded.dailyCount)) : 0,
|
||||||
lastIncrementAt: Number.isFinite(loaded.lastIncrementAt) ? loaded.lastIncrementAt : null,
|
lastIncrementAt: Number.isFinite(loaded.lastIncrementAt) ? loaded.lastIncrementAt : null,
|
||||||
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
|
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
|
||||||
};
|
};
|
||||||
@@ -153,8 +178,27 @@ function createButtonBoxStore(deps) {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDailyLimit(button) {
|
||||||
|
/*
|
||||||
|
The persisted file stores only the source values. The public snapshot adds
|
||||||
|
this derived limit so clients can render daily status without duplicating
|
||||||
|
button-box reward math in React components.
|
||||||
|
*/
|
||||||
|
if (button?.dailyLimited !== true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const goal = Number.isFinite(button?.goal) ? Math.max(1, Math.floor(button.goal)) : 1;
|
||||||
|
return Math.max(1, Math.ceil(goal / 5));
|
||||||
|
}
|
||||||
|
|
||||||
function getStateClone() {
|
function getStateClone() {
|
||||||
return clone({ buttons: getState().buttons });
|
const current = getState();
|
||||||
|
return clone({
|
||||||
|
buttons: current.buttons.map((button) => ({
|
||||||
|
...button,
|
||||||
|
dailyLimit: getDailyLimit(button),
|
||||||
|
})),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
1. fix rover request spam queue cheat
|
1. fix rover request spam queue cheat
|
||||||
2. remove dockIR stuff
|
2. remove dockIR stuff from webui
|
||||||
3. maybe add better camera control? a complete rework or maybe just a precision mode
|
3. maybe add better camera control? a complete rework or maybe just a precision mode
|
||||||
4. improve spectator page, options on what to see and what not to see
|
4. improve spectator page, options on what to see and what not to see
|
||||||
5. make it so the room camera panel stops when its out of view for bandwidth
|
5. make it so the room camera panel stops when its out of view for bandwidth
|
||||||
6. fix this:
|
6. clean up lockdown-admin or statements everywhere
|
||||||
|
7. fix this:
|
||||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
|
|||||||
@@ -127,17 +127,51 @@ export default function AlertFeed({ scale = 1 }) {
|
|||||||
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
|
const buttons = Array.isArray(buttonBoxButtons) ? buttonBoxButtons : [];
|
||||||
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
|
const button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
|
||||||
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
|
const count = Number.isFinite(payload.count) ? payload.count : Number(button.count) || 0;
|
||||||
const goal = Number.isFinite(button.goal) ? button.goal : 0;
|
/*
|
||||||
|
Event fields win over session fields because the button-box event is the
|
||||||
|
exact outcome of this press. Session sync follows immediately after, but
|
||||||
|
using it first can make fast toasts briefly show stale count/limit data.
|
||||||
|
*/
|
||||||
|
const goal = Number.isFinite(payload.goal) ? payload.goal : Number.isFinite(button.goal) ? button.goal : 0;
|
||||||
|
const dailyCount = Number.isFinite(payload.dailyCount)
|
||||||
|
? payload.dailyCount
|
||||||
|
: Number.isFinite(button.dailyCount)
|
||||||
|
? button.dailyCount
|
||||||
|
: 0;
|
||||||
|
const dailyLimit = Number.isFinite(payload.dailyLimit)
|
||||||
|
? payload.dailyLimit
|
||||||
|
: Number.isFinite(button.dailyLimit)
|
||||||
|
? button.dailyLimit
|
||||||
|
: null;
|
||||||
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
|
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
|
||||||
const rewardName =
|
const rewardName =
|
||||||
typeof button.rewardName === 'string' && button.rewardName.trim()
|
typeof button.rewardName === 'string' && button.rewardName.trim()
|
||||||
? button.rewardName.trim()
|
? button.rewardName.trim()
|
||||||
: 'Unassigned';
|
: 'Unassigned';
|
||||||
|
const rewardDescription =
|
||||||
|
typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
|
||||||
|
? button.rewardDescription.trim()
|
||||||
|
: null;
|
||||||
|
const description =
|
||||||
|
typeof payload.description === 'string' && payload.description.trim()
|
||||||
|
? payload.description.trim()
|
||||||
|
: null;
|
||||||
pushAlert({
|
pushAlert({
|
||||||
id: `buttonbox-active-${buttonId}`,
|
id: `buttonbox-active-${buttonId}`,
|
||||||
kind: 'buttonbox-active',
|
kind: 'buttonbox-active',
|
||||||
lifetimeMs: BUTTONBOX_LIFETIME_MS,
|
lifetimeMs: BUTTONBOX_LIFETIME_MS,
|
||||||
payload: { buttonId, count, goal, rewardNumber, rewardName },
|
payload: {
|
||||||
|
buttonId,
|
||||||
|
count,
|
||||||
|
goal,
|
||||||
|
dailyCount,
|
||||||
|
dailyLimit,
|
||||||
|
limited: Boolean(payload.limited),
|
||||||
|
description,
|
||||||
|
rewardNumber,
|
||||||
|
rewardName,
|
||||||
|
rewardDescription,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
socket.on('buttonBox:increment', onButtonIncrement);
|
socket.on('buttonBox:increment', onButtonIncrement);
|
||||||
@@ -458,8 +492,17 @@ function AlertToast({ alert }) {
|
|||||||
goal={payload.goal}
|
goal={payload.goal}
|
||||||
rewardNumber={payload.rewardNumber}
|
rewardNumber={payload.rewardNumber}
|
||||||
rewardName={payload.rewardName}
|
rewardName={payload.rewardName}
|
||||||
className="bg-cyan-900/45"
|
rewardDescription={payload.rewardDescription}
|
||||||
|
dailyCount={payload.dailyCount}
|
||||||
|
dailyLimit={payload.dailyLimit}
|
||||||
|
limited={payload.limited}
|
||||||
|
className={payload.limited ? 'bg-red-950/70 ring-1 ring-red-500/70' : 'bg-cyan-900/45'}
|
||||||
/>
|
/>
|
||||||
|
{payload.description ? (
|
||||||
|
<p className={['mt-1 text-center text-xs font-semibold', payload.limited ? 'text-red-200' : 'text-cyan-100'].join(' ')}>
|
||||||
|
{payload.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import CardFrame from '../CardFrame/index.jsx';
|
|||||||
|
|
||||||
const FLASH_MS = 420;
|
const FLASH_MS = 420;
|
||||||
const REWARD_FLASH_MS = 1200;
|
const REWARD_FLASH_MS = 1200;
|
||||||
|
const LIMIT_FLASH_MS = 800;
|
||||||
const BUTTON_TONES = {
|
const BUTTON_TONES = {
|
||||||
1: 262,
|
1: 262,
|
||||||
2: 330,
|
2: 330,
|
||||||
@@ -37,15 +38,20 @@ export default function ButtonBoxPanel() {
|
|||||||
count: 0,
|
count: 0,
|
||||||
goal: 0,
|
goal: 0,
|
||||||
rewardName: null,
|
rewardName: null,
|
||||||
|
rewardDescription: null,
|
||||||
rewardId: null,
|
rewardId: null,
|
||||||
rewardNumber: null,
|
rewardNumber: null,
|
||||||
|
dailyCount: 0,
|
||||||
|
dailyLimit: null,
|
||||||
lastRewardAt: null,
|
lastRewardAt: null,
|
||||||
});
|
});
|
||||||
}, [buttonBoxButtons]);
|
}, [buttonBoxButtons]);
|
||||||
|
|
||||||
const [incFlash, setIncFlash] = useState({});
|
const [incFlash, setIncFlash] = useState({});
|
||||||
|
const [limitFlash, setLimitFlash] = useState({});
|
||||||
const [rewardFlash, setRewardFlash] = useState({});
|
const [rewardFlash, setRewardFlash] = useState({});
|
||||||
const timersRef = useRef(new Map());
|
const timersRef = useRef(new Map());
|
||||||
|
const limitTimersRef = useRef(new Map());
|
||||||
const rewardTimersRef = useRef(new Map());
|
const rewardTimersRef = useRef(new Map());
|
||||||
const prevRewardAtRef = useRef({});
|
const prevRewardAtRef = useRef({});
|
||||||
const audioCtxRef = useRef(null);
|
const audioCtxRef = useRef(null);
|
||||||
@@ -107,6 +113,24 @@ export default function ButtonBoxPanel() {
|
|||||||
function onIncrement(payload = {}) {
|
function onIncrement(payload = {}) {
|
||||||
const buttonId = Number(payload.buttonId);
|
const buttonId = Number(payload.buttonId);
|
||||||
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
|
if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > 4) return;
|
||||||
|
if (payload.limited) {
|
||||||
|
/*
|
||||||
|
Capped presses are intentionally visible but not celebratory: red
|
||||||
|
feedback tells users the physical press was received, while skipping
|
||||||
|
the tone keeps it distinct from real reward progress.
|
||||||
|
*/
|
||||||
|
setLimitFlash((current) => ({ ...current, [buttonId]: true }));
|
||||||
|
const oldTimer = limitTimersRef.current.get(buttonId);
|
||||||
|
if (oldTimer) {
|
||||||
|
clearTimeout(oldTimer);
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setLimitFlash((current) => ({ ...current, [buttonId]: false }));
|
||||||
|
limitTimersRef.current.delete(buttonId);
|
||||||
|
}, LIMIT_FLASH_MS);
|
||||||
|
limitTimersRef.current.set(buttonId, timer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIncFlash((current) => ({ ...current, [buttonId]: true }));
|
setIncFlash((current) => ({ ...current, [buttonId]: true }));
|
||||||
const oldTimer = timersRef.current.get(buttonId);
|
const oldTimer = timersRef.current.get(buttonId);
|
||||||
if (oldTimer) {
|
if (oldTimer) {
|
||||||
@@ -126,6 +150,23 @@ export default function ButtonBoxPanel() {
|
|||||||
};
|
};
|
||||||
}, [effectiveAlertVolume, socket]);
|
}, [effectiveAlertVolume, socket]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
/*
|
||||||
|
The panel owns short visual timers for press feedback. Clearing them on
|
||||||
|
unmount avoids leaving stale timeout callbacks behind if the panel is
|
||||||
|
hidden during a burst of button-box activity.
|
||||||
|
*/
|
||||||
|
timersRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
|
timersRef.current.clear();
|
||||||
|
limitTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
|
limitTimersRef.current.clear();
|
||||||
|
rewardTimersRef.current.forEach((timer) => clearTimeout(timer));
|
||||||
|
rewardTimersRef.current.clear();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
|
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
|
||||||
<div className="grid grid-cols-4 gap-0.5">
|
<div className="grid grid-cols-4 gap-0.5">
|
||||||
@@ -133,11 +174,17 @@ export default function ButtonBoxPanel() {
|
|||||||
const id = Number(button.id);
|
const id = Number(button.id);
|
||||||
const count = Number.isFinite(button.count) ? button.count : 0;
|
const count = Number.isFinite(button.count) ? button.count : 0;
|
||||||
const goal = Number.isFinite(button.goal) ? button.goal : 0;
|
const goal = Number.isFinite(button.goal) ? button.goal : 0;
|
||||||
|
const dailyCount = Number.isFinite(button.dailyCount) ? button.dailyCount : 0;
|
||||||
|
const dailyLimit = Number.isFinite(button.dailyLimit) ? button.dailyLimit : null;
|
||||||
const rewardName = typeof button.rewardName === 'string' && button.rewardName.trim()
|
const rewardName = typeof button.rewardName === 'string' && button.rewardName.trim()
|
||||||
? button.rewardName.trim()
|
? button.rewardName.trim()
|
||||||
: 'Unassigned';
|
: 'Unassigned';
|
||||||
|
const rewardDescription = typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
|
||||||
|
? button.rewardDescription.trim()
|
||||||
|
: null;
|
||||||
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
|
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
|
||||||
const incActive = Boolean(incFlash[id]);
|
const incActive = Boolean(incFlash[id]);
|
||||||
|
const limitActive = Boolean(limitFlash[id]);
|
||||||
const rewardActive = Boolean(rewardFlash[id]);
|
const rewardActive = Boolean(rewardFlash[id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,9 +193,17 @@ export default function ButtonBoxPanel() {
|
|||||||
buttonId={id}
|
buttonId={id}
|
||||||
count={count}
|
count={count}
|
||||||
goal={goal}
|
goal={goal}
|
||||||
|
dailyCount={dailyCount}
|
||||||
|
dailyLimit={dailyLimit}
|
||||||
rewardNumber={rewardNumber}
|
rewardNumber={rewardNumber}
|
||||||
rewardName={rewardName}
|
rewardName={rewardName}
|
||||||
className={[incActive ? 'bg-cyan-900/45' : '', rewardActive ? 'bg-fuchsia-900/45' : ''].filter(Boolean).join(' ')}
|
rewardDescription={rewardDescription}
|
||||||
|
limited={limitActive}
|
||||||
|
className={[
|
||||||
|
incActive ? 'bg-cyan-900/45' : '',
|
||||||
|
limitActive ? 'bg-red-950/70 ring-1 ring-red-500/70' : '',
|
||||||
|
rewardActive ? 'bg-fuchsia-900/45' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -7,13 +7,37 @@ export default function ButtonBoxTile({
|
|||||||
goal,
|
goal,
|
||||||
rewardNumber,
|
rewardNumber,
|
||||||
rewardName,
|
rewardName,
|
||||||
|
rewardDescription,
|
||||||
|
dailyCount,
|
||||||
|
dailyLimit,
|
||||||
|
limited = false,
|
||||||
className = '',
|
className = '',
|
||||||
}) {
|
}) {
|
||||||
|
/*
|
||||||
|
The server owns the actual cap decision, but the tile still derives a
|
||||||
|
display-only "limit reached" state so normal session snapshots and immediate
|
||||||
|
capped press toasts render the same daily status text.
|
||||||
|
*/
|
||||||
|
const hasDailyLimit = Number.isFinite(dailyLimit) && dailyLimit > 0;
|
||||||
|
const safeDailyCount = Number.isFinite(dailyCount) ? dailyCount : 0;
|
||||||
|
const limitReached = Boolean(limited) || (hasDailyLimit && safeDailyCount >= dailyLimit);
|
||||||
|
const cleanDescription = typeof rewardDescription === 'string' && rewardDescription.trim()
|
||||||
|
? rewardDescription.trim()
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={['surface px-0.5 py-0.5 text-center', className].filter(Boolean).join(' ')}>
|
<article className={['surface px-1 py-0.5 text-center', className].filter(Boolean).join(' ')}>
|
||||||
<p className="text-xs text-slate-400">Button {buttonId}</p>
|
<p className="text-xs text-slate-400">Button {buttonId}</p>
|
||||||
<p className="text-sm font-semibold text-white">{count} / {goal}</p>
|
<p className="text-sm font-semibold text-white">{count} / {goal}</p>
|
||||||
<p className="truncate text-[0.7rem] text-slate-300">#{rewardNumber} {rewardName}</p>
|
<p className="truncate text-[0.7rem] text-slate-300">#{rewardNumber} {rewardName}</p>
|
||||||
|
{cleanDescription ? (
|
||||||
|
<p className="line-clamp-2 text-[0.65rem] leading-tight text-slate-400">{cleanDescription}</p>
|
||||||
|
) : null}
|
||||||
|
{hasDailyLimit ? (
|
||||||
|
<p className={['text-[0.65rem] font-semibold leading-tight', limitReached ? 'text-red-300' : 'text-cyan-200'].join(' ')}>
|
||||||
|
{limitReached ? 'Daily limit reached' : `Today ${safeDailyCount} / ${dailyLimit}`}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user