Files
MultiRoombaRover/server/src/services/buttonBoxService/store.js
T
2026-06-29 02:18:53 -04:00

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,
};