mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -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/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>
|
||||
<script type="module" crossorigin src="/assets/index-eD5r_CFS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C6I0gKp1.css">
|
||||
<script type="module" crossorigin src="/assets/index-BmIqHXVL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DtN3aedy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
module.exports = {
|
||||
id: 'assignmentRoulette',
|
||||
name: 'Rover Reassignment',
|
||||
description: 'Scrambles who is driving which rover.',
|
||||
goal: 300,
|
||||
async run(ctx) {
|
||||
const moved = ctx.rerollAssignments();
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// 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.
|
||||
const STEP_MS = 220;
|
||||
const DURATION_MS = 30 * 1000;
|
||||
const DURATION_MS = 60 * 1000;
|
||||
const STEPS = Math.ceil(DURATION_MS / STEP_MS);
|
||||
|
||||
module.exports = {
|
||||
id: 'cameraWhiplash',
|
||||
name: 'Camera Wiggle',
|
||||
description: 'Makes the rover cameras wiggle around.',
|
||||
goal: 100,
|
||||
async run(ctx) {
|
||||
const rovers = ctx
|
||||
|
||||
@@ -22,6 +22,7 @@ function sleep(ms) {
|
||||
module.exports = {
|
||||
id: 'chatSpam',
|
||||
name: 'Chat Spam',
|
||||
description: 'Floods chat with nonsense messages.',
|
||||
goal: 320,
|
||||
async run(ctx) {
|
||||
const nickname = randLetter();
|
||||
|
||||
@@ -116,6 +116,7 @@ async function startDarkness(ctx, effect) {
|
||||
module.exports = {
|
||||
id: 'darkness',
|
||||
name: 'Darkness',
|
||||
description: 'Disables room lights and headlights for 15 minutes.',
|
||||
isHeadlightBlocked,
|
||||
goal: 400,
|
||||
async run(ctx) {
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
module.exports = {
|
||||
id: 'discordPingEveryone',
|
||||
name: 'PING @EVERYONE',
|
||||
goal: 20000,
|
||||
description: 'Pings @everyone in Discord.',
|
||||
dailyLimited: true,
|
||||
goal: 4000,
|
||||
async run(ctx) {
|
||||
ctx.publishEvent({
|
||||
source: 'buttonBoxReward',
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
module.exports = {
|
||||
id: 'discordStalkerPing',
|
||||
name: 'Discord Ping',
|
||||
goal: 9000,
|
||||
description: 'Pings the stalker role in Discord.',
|
||||
dailyLimited: true,
|
||||
goal: 2000,
|
||||
async run(ctx) {
|
||||
ctx.publishEvent({
|
||||
source: 'buttonBoxReward',
|
||||
|
||||
@@ -5,6 +5,7 @@ const DOCK_COMMAND_BASE64 = Buffer.from([143]).toString('base64');
|
||||
module.exports = {
|
||||
id: 'dockPanic',
|
||||
name: 'All Dock',
|
||||
description: 'Forces all rovers into docking mode.',
|
||||
goal: 180,
|
||||
async run(ctx) {
|
||||
const rovers = ctx.listOnlineRovers();
|
||||
|
||||
@@ -16,6 +16,7 @@ function sleep(ms) {
|
||||
module.exports = {
|
||||
id: 'ghostTypingSpam',
|
||||
name: 'Typing Spam',
|
||||
description: 'Fills chat with fake typing indicators.',
|
||||
goal: 220,
|
||||
async run(ctx) {
|
||||
const active = new Set();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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.
|
||||
const STROBE_MS = 30 * 1000;
|
||||
const TICK_MS = 200;
|
||||
const TICK_MS = 500;
|
||||
|
||||
let activeTimer = null;
|
||||
|
||||
@@ -40,7 +40,8 @@ function startStrobe(ctx, effect = {}) {
|
||||
module.exports = {
|
||||
id: 'lightStrobe',
|
||||
name: 'Light Strobe',
|
||||
goal: 300,
|
||||
description: 'Makes the room lights flash rapidly for 30 seconds.',
|
||||
goal: 400,
|
||||
async run(ctx) {
|
||||
startStrobe(ctx, { endsAt: Date.now() + STROBE_MS, on: false });
|
||||
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 = {
|
||||
id: 'modeJam',
|
||||
name: 'Admin Lock',
|
||||
goal: 900,
|
||||
description: 'Temporarily puts the server in admin mode.',
|
||||
goal: 3000,
|
||||
async run(ctx) {
|
||||
const durationMs =
|
||||
MIN_DURATION_MS + Math.floor(Math.random() * (MAX_DURATION_MS - MIN_DURATION_MS + 1));
|
||||
|
||||
@@ -25,6 +25,137 @@ function createButtonBoxCore(deps) {
|
||||
store,
|
||||
} = 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() {
|
||||
publishEvent({
|
||||
source: 'buttonBox',
|
||||
@@ -117,55 +248,12 @@ function createButtonBoxCore(deps) {
|
||||
}
|
||||
|
||||
async function applyPress(buttonId) {
|
||||
const state = store.getState();
|
||||
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);
|
||||
return applyProgress(buttonId, 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));
|
||||
button.count += 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);
|
||||
return applyProgress(buttonId, inc);
|
||||
}
|
||||
|
||||
async function recoverEffects() {
|
||||
|
||||
@@ -22,8 +22,12 @@ function createButtonBoxStore(deps) {
|
||||
count: 0,
|
||||
rewardId: null,
|
||||
rewardName: null,
|
||||
rewardDescription: null,
|
||||
dailyLimited: false,
|
||||
rewardNumber: null,
|
||||
goal: null,
|
||||
dailyDate: null,
|
||||
dailyCount: 0,
|
||||
lastIncrementAt: null,
|
||||
lastRewardAt: null,
|
||||
};
|
||||
@@ -67,9 +71,18 @@ function createButtonBoxStore(deps) {
|
||||
|
||||
button.rewardId = reward.id;
|
||||
button.rewardName = reward.name || null;
|
||||
button.rewardDescription = reward.description || null;
|
||||
button.dailyLimited = reward.dailyLimited === true;
|
||||
button.rewardNumber = reward.number;
|
||||
button.goal = reward.goal;
|
||||
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() {
|
||||
@@ -80,11 +93,19 @@ function createButtonBoxStore(deps) {
|
||||
if (reward && !seen.has(reward.id)) {
|
||||
seen.add(reward.id);
|
||||
button.rewardName = reward.name || null;
|
||||
button.rewardDescription = reward.description || null;
|
||||
button.dailyLimited = reward.dailyLimited === true;
|
||||
button.rewardNumber = reward.number;
|
||||
button.goal = reward.goal;
|
||||
if (!Number.isFinite(button.count) || 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;
|
||||
}
|
||||
assignNewReward(button);
|
||||
@@ -104,8 +125,12 @@ function createButtonBoxStore(deps) {
|
||||
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,
|
||||
rewardDescription: typeof loaded.rewardDescription === 'string' ? loaded.rewardDescription : null,
|
||||
dailyLimited: loaded.dailyLimited === true,
|
||||
rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : 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,
|
||||
lastRewardAt: Number.isFinite(loaded.lastRewardAt) ? loaded.lastRewardAt : null,
|
||||
};
|
||||
@@ -153,8 +178,27 @@ function createButtonBoxStore(deps) {
|
||||
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() {
|
||||
return clone({ buttons: getState().buttons });
|
||||
const current = getState();
|
||||
return clone({
|
||||
buttons: current.buttons.map((button) => ({
|
||||
...button,
|
||||
dailyLimit: getDailyLimit(button),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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
|
||||
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
|
||||
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]: cb({ error: err.message });
|
||||
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 button = buttons.find((entry) => Number(entry?.id) === buttonId) || {};
|
||||
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 rewardName =
|
||||
typeof button.rewardName === 'string' && button.rewardName.trim()
|
||||
? button.rewardName.trim()
|
||||
: '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({
|
||||
id: `buttonbox-active-${buttonId}`,
|
||||
kind: 'buttonbox-active',
|
||||
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);
|
||||
@@ -458,8 +492,17 @@ function AlertToast({ alert }) {
|
||||
goal={payload.goal}
|
||||
rewardNumber={payload.rewardNumber}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
const FLASH_MS = 420;
|
||||
const REWARD_FLASH_MS = 1200;
|
||||
const LIMIT_FLASH_MS = 800;
|
||||
const BUTTON_TONES = {
|
||||
1: 262,
|
||||
2: 330,
|
||||
@@ -37,15 +38,20 @@ export default function ButtonBoxPanel() {
|
||||
count: 0,
|
||||
goal: 0,
|
||||
rewardName: null,
|
||||
rewardDescription: null,
|
||||
rewardId: null,
|
||||
rewardNumber: null,
|
||||
dailyCount: 0,
|
||||
dailyLimit: null,
|
||||
lastRewardAt: null,
|
||||
});
|
||||
}, [buttonBoxButtons]);
|
||||
|
||||
const [incFlash, setIncFlash] = useState({});
|
||||
const [limitFlash, setLimitFlash] = useState({});
|
||||
const [rewardFlash, setRewardFlash] = useState({});
|
||||
const timersRef = useRef(new Map());
|
||||
const limitTimersRef = useRef(new Map());
|
||||
const rewardTimersRef = useRef(new Map());
|
||||
const prevRewardAtRef = useRef({});
|
||||
const audioCtxRef = useRef(null);
|
||||
@@ -107,6 +113,24 @@ export default function ButtonBoxPanel() {
|
||||
function onIncrement(payload = {}) {
|
||||
const buttonId = Number(payload.buttonId);
|
||||
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 }));
|
||||
const oldTimer = timersRef.current.get(buttonId);
|
||||
if (oldTimer) {
|
||||
@@ -126,6 +150,23 @@ export default function ButtonBoxPanel() {
|
||||
};
|
||||
}, [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 (
|
||||
<CardFrame title="Button Box" bodyClassName="space-y-0.5 text-base">
|
||||
<div className="grid grid-cols-4 gap-0.5">
|
||||
@@ -133,11 +174,17 @@ export default function ButtonBoxPanel() {
|
||||
const id = Number(button.id);
|
||||
const count = Number.isFinite(button.count) ? button.count : 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()
|
||||
? button.rewardName.trim()
|
||||
: 'Unassigned';
|
||||
const rewardDescription = typeof button.rewardDescription === 'string' && button.rewardDescription.trim()
|
||||
? button.rewardDescription.trim()
|
||||
: null;
|
||||
const rewardNumber = Number.isFinite(button.rewardNumber) ? button.rewardNumber : '?';
|
||||
const incActive = Boolean(incFlash[id]);
|
||||
const limitActive = Boolean(limitFlash[id]);
|
||||
const rewardActive = Boolean(rewardFlash[id]);
|
||||
|
||||
return (
|
||||
@@ -146,9 +193,17 @@ export default function ButtonBoxPanel() {
|
||||
buttonId={id}
|
||||
count={count}
|
||||
goal={goal}
|
||||
dailyCount={dailyCount}
|
||||
dailyLimit={dailyLimit}
|
||||
rewardNumber={rewardNumber}
|
||||
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,
|
||||
rewardNumber,
|
||||
rewardName,
|
||||
rewardDescription,
|
||||
dailyCount,
|
||||
dailyLimit,
|
||||
limited = false,
|
||||
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 (
|
||||
<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-sm font-semibold text-white">{count} / {goal}</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user