mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
217 lines
6.9 KiB
JavaScript
217 lines
6.9 KiB
JavaScript
// button Box Service store
|
|
// Purpose: Owns persisted button-box state shape, normalization, and atomic file read/write operations.
|
|
// Scope: Keeps runtime behavior unchanged by isolating state persistence mechanics from reward/HTTP orchestration.
|
|
const fs = require('fs');
|
|
|
|
function createButtonBoxStore(deps) {
|
|
const {
|
|
logger,
|
|
getRewardById,
|
|
listRewards,
|
|
dataDir,
|
|
storePath,
|
|
buttonCount,
|
|
storeVersion,
|
|
} = deps;
|
|
|
|
let state = null;
|
|
|
|
function createDefaultButton(id) {
|
|
return {
|
|
id,
|
|
count: 0,
|
|
rewardId: null,
|
|
rewardName: null,
|
|
rewardDescription: null,
|
|
dailyLimited: false,
|
|
rewardNumber: null,
|
|
goal: null,
|
|
dailyDate: null,
|
|
dailyCount: 0,
|
|
lastIncrementAt: null,
|
|
lastRewardAt: null,
|
|
};
|
|
}
|
|
|
|
function createDefaultState() {
|
|
return {
|
|
version: storeVersion,
|
|
updatedAt: Date.now(),
|
|
buttons: Array.from({ length: buttonCount }, (_, idx) => createDefaultButton(idx + 1)),
|
|
effects: {},
|
|
};
|
|
}
|
|
|
|
function clone(value) {
|
|
return JSON.parse(JSON.stringify(value));
|
|
}
|
|
|
|
function assignNewReward(button, options = {}) {
|
|
const allRewards = listRewards();
|
|
if (!allRewards.length) throw new Error('No rewards configured');
|
|
const excludeRewardId = typeof options.excludeRewardId === 'string' ? options.excludeRewardId : null;
|
|
|
|
const usedByOtherButtons = new Set(
|
|
state.buttons
|
|
.filter((entry) => entry.id !== button.id)
|
|
.map((entry) => entry.rewardId)
|
|
.filter((rewardId) => typeof rewardId === 'string' && rewardId.length > 0),
|
|
);
|
|
const uniqueCandidates = allRewards.filter(
|
|
(reward) => !usedByOtherButtons.has(reward.id) && (!excludeRewardId || reward.id !== excludeRewardId),
|
|
);
|
|
const fallbackUniqueCandidates = allRewards.filter((reward) => !usedByOtherButtons.has(reward.id));
|
|
const pool = uniqueCandidates.length
|
|
? uniqueCandidates
|
|
: fallbackUniqueCandidates.length
|
|
? fallbackUniqueCandidates
|
|
: allRewards.filter((reward) => !excludeRewardId || reward.id !== excludeRewardId);
|
|
const reward = pool[Math.floor(Math.random() * pool.length)] || null;
|
|
if (!reward) throw new Error('No rewards configured');
|
|
|
|
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() {
|
|
if (!state) return;
|
|
const seen = new Set();
|
|
state.buttons.forEach((button) => {
|
|
const reward = getRewardById(button.rewardId);
|
|
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);
|
|
if (button.rewardId) {
|
|
seen.add(button.rewardId);
|
|
}
|
|
});
|
|
}
|
|
|
|
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,
|
|
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,
|
|
};
|
|
});
|
|
|
|
const effects = raw.effects && typeof raw.effects === 'object' ? raw.effects : {};
|
|
return {
|
|
version: storeVersion,
|
|
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
|
buttons,
|
|
effects,
|
|
};
|
|
}
|
|
|
|
function writeState() {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
const next = {
|
|
...state,
|
|
updatedAt: Date.now(),
|
|
};
|
|
const tempPath = `${storePath}.${process.pid}.${Date.now()}.tmp`;
|
|
fs.writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
fs.renameSync(tempPath, storePath);
|
|
state = next;
|
|
}
|
|
|
|
function loadState() {
|
|
if (state) return state;
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(storePath, '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 getState() {
|
|
loadState();
|
|
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() {
|
|
const current = getState();
|
|
return clone({
|
|
buttons: current.buttons.map((button) => ({
|
|
...button,
|
|
dailyLimit: getDailyLimit(button),
|
|
})),
|
|
});
|
|
}
|
|
|
|
return {
|
|
loadState,
|
|
writeState,
|
|
getState,
|
|
getStateClone,
|
|
clone,
|
|
assignNewReward,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
createButtonBoxStore,
|
|
};
|