mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better buttonboxing
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user