diff --git a/rulesdocs/refector_rules_and_tracking.md b/rulesdocs/refector_rules_and_tracking.md index 8dce9844..c20962e9 100644 --- a/rulesdocs/refector_rules_and_tracking.md +++ b/rulesdocs/refector_rules_and_tracking.md @@ -80,6 +80,7 @@ - Finished major `llmCommentaryService` decomposition by extracting tick scheduling, run-loop orchestration, and history-reset behavior into `llmCommentaryService/runner.js`; `llmCommentaryService/index.js` is now a thin composition layer. - Began `audioForwardService` decomposition by extracting permission/path policy helpers to `audioForwardService/policy.js` and rover/turn/socket event wiring to `audioForwardService/hooks.js`; rewired service entrypoint to use extracted modules. - Continued `audioForwardService` decomposition by extracting ffmpeg worker lifecycle, upload playback, and WHIP ownership/session control into `audioForwardService/workerEngine.js`; `audioForwardService/index.js` is now a thin composition layer. +- Finished `buttonBoxService` decomposition by extracting persisted state management to `buttonBoxService/store.js`, reward/effect workflows to `buttonBoxService/core.js`, and HTTP transport wiring to `buttonBoxService/httpRoute.js`; `buttonBoxService/index.js` is now a thin composition layer. ## WebUI frontend ### BIGGEST OFFENDERS diff --git a/server/src/services/buttonBoxService/core.js b/server/src/services/buttonBoxService/core.js new file mode 100644 index 00000000..9750af66 --- /dev/null +++ b/server/src/services/buttonBoxService/core.js @@ -0,0 +1,173 @@ +// button Box Service core +// Purpose: Handles reward execution, effect persistence/recovery, and press application workflows. +// Scope: Keeps runtime behavior unchanged while isolating domain logic from HTTP transport wiring. +function createButtonBoxCore(deps) { + const { + io, + logger, + getRewardById, + roverManager, + issueCommand, + sendAlert, + publishEvent, + sendExternalTyping, + sendExternalMessage, + sendSystemMessage, + getMode, + setMode, + getAdminReason, + setAdminReason, + clearAdminReason, + assignmentService, + getHomeAssistantState, + setHomeAssistantEntityState, + setHomeAssistantLightsLockedOn, + store, + } = deps; + + function publishUpdated() { + publishEvent({ + source: 'buttonBox', + type: 'buttonBox.updated', + payload: { updatedAt: Date.now() }, + }); + } + + function saveEffect(effectId, payload = {}, options = {}) { + const state = store.getState(); + state.effects = state.effects && typeof state.effects === 'object' ? state.effects : {}; + state.effects[effectId] = payload; + store.writeState(); + if (options.broadcast) { + publishUpdated(); + } + } + + function clearEffect(effectId, options = {}) { + const state = store.getState(); + if (state.effects && typeof state.effects === 'object' && state.effects[effectId]) { + delete state.effects[effectId]; + store.writeState(); + if (options.broadcast) { + publishUpdated(); + } + } + } + + function buildRewardContext() { + return { + logger, + issueCommand, + listOnlineRovers: () => roverManager.getRoster(), + sendAlert, + publishEvent, + sendExternalTyping, + sendExternalMessage, + sendSystemMessage, + getMode, + setMode: (mode, source = 'buttonbox') => + setMode( + mode, + { data: { role: 'admin', user: { username: source } } }, + { force: true }, + ), + getAdminReasonText: () => getAdminReason()?.text || null, + setAdminReason: (text, source = 'buttonbox') => setAdminReason(text, { by: source }), + clearAdminReason: (source = 'buttonbox') => clearAdminReason({ by: source }), + rerollAssignments: () => assignmentService.rerollAssignments(), + getHomeAssistantEntities: () => { + const entities = getHomeAssistantState()?.entities; + return Array.isArray(entities) ? entities : []; + }, + getHomeAssistantLightPolicy: () => getHomeAssistantState()?.lightPolicy || null, + setHomeAssistantEntityState, + setHomeAssistantLightsLockedOn: (next, options = {}) => + setHomeAssistantLightsLockedOn(next, options), + saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }), + clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }), + }; + } + + async function runRewardForButton(button) { + const reward = getRewardById(button.rewardId); + if (!reward) { + store.assignNewReward(button); + return; + } + const completedCount = Number.isFinite(button.count) ? button.count : 0; + const completedGoal = Number.isFinite(button.goal) ? button.goal : 0; + const ctx = buildRewardContext(); + try { + await reward.run(ctx); + } catch (err) { + logger.warn('Reward execution failed', { rewardId: reward.id, error: err.message }); + } + io.emit('buttonBox:rewardRun', { + buttonId: button.id, + rewardId: reward.id, + rewardName: reward.name || reward.id, + count: completedCount, + goal: completedGoal, + ts: Date.now(), + }); + button.lastRewardAt = Date.now(); + button.count = 0; + store.assignNewReward(button, { excludeRewardId: reward.id }); + } + + 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); + } + + async function recoverEffects() { + const state = store.getState(); + const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {}; + const ctx = buildRewardContext(); + + for (const [effectId, payload] of Object.entries(effects)) { + const reward = getRewardById(effectId); + if (!reward || typeof reward.recover !== 'function') { + clearEffect(effectId, { broadcast: false }); + continue; + } + try { + await reward.recover(ctx, payload); + } catch (err) { + logger.warn('Effect recovery failed', { effectId, error: err.message }); + clearEffect(effectId, { broadcast: false }); + } + } + publishUpdated(); + } + + return { + applyPress, + recoverEffects, + }; +} + +module.exports = { + createButtonBoxCore, +}; diff --git a/server/src/services/buttonBoxService/httpRoute.js b/server/src/services/buttonBoxService/httpRoute.js new file mode 100644 index 00000000..3d49ffbf --- /dev/null +++ b/server/src/services/buttonBoxService/httpRoute.js @@ -0,0 +1,52 @@ +// button Box Service HTTP route +// Purpose: Registers button-box press endpoint and request validation against local-network policy. +// Scope: Keeps runtime behavior unchanged while isolating transport/validation from domain logic. +const express = require('express'); + +function registerButtonBoxRoute(deps) { + const { + app, + logger, + buttonCount, + getRequestIp, + normalizeIp, + isLocalNetwork, + applyPress, + } = deps; + + function parseButtonId(body) { + if (typeof body !== 'string') return null; + const value = Number.parseInt(body.trim(), 10); + if (Number.isFinite(value)) return value; + return null; + } + + function denyIfNotLocal(req, res) { + const ip = normalizeIp(getRequestIp(req)); + if (isLocalNetwork(ip)) { + return false; + } + logger.warn('Rejected non-local button press request', { ip: ip || null }); + res.status(403).json({ error: 'Button presses must originate from local network' }); + return true; + } + + app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => { + if (denyIfNotLocal(req, res)) return; + const buttonId = parseButtonId(req.body); + if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > buttonCount) { + res.status(400).json({ error: 'button must be 1-4' }); + return; + } + try { + const button = await applyPress(buttonId); + res.json({ success: true, button }); + } catch (err) { + res.status(500).json({ error: err.message || 'Button processing failed' }); + } + }); +} + +module.exports = { + registerButtonBoxRoute, +}; diff --git a/server/src/services/buttonBoxService/index.js b/server/src/services/buttonBoxService/index.js index cd500613..331cc82b 100644 --- a/server/src/services/buttonBoxService/index.js +++ b/server/src/services/buttonBoxService/index.js @@ -1,8 +1,6 @@ // button Box Service -// Purpose: Defines the button Box Service module and the helpers/state used by this service unit. -// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. -const fs = require('fs'); -const express = require('express'); +// Purpose: Composes button-box storage, core reward workflows, and HTTP route wiring. +// Scope: Keeps runtime behavior unchanged while making this entrypoint a thin composition layer. const { app } = require('../../globals/http'); const io = require('../../globals/io'); const logger = require('../../globals/logger').child('buttonBoxService'); @@ -22,325 +20,63 @@ const { setLightsLockedOn: setHomeAssistantLightsLockedOn, } = require('../homeAssistantService'); const { getRequestIp, isLocalNetwork, normalizeIp } = require('../../helpers/ipResolver'); +const { createButtonBoxStore } = require('./store'); +const { createButtonBoxCore } = require('./core'); +const { registerButtonBoxRoute } = require('./httpRoute'); const DATA_DIR = resolveDataDir(); const STORE_PATH = resolveDataPath('buttonbox-state.json'); const BUTTON_COUNT = 4; const STORE_VERSION = 1; -let state = null; - -function createDefaultButton(id) { - return { - id, - count: 0, - rewardId: null, - rewardName: null, - rewardNumber: null, - goal: null, - lastIncrementAt: null, - lastRewardAt: null, - }; -} - -function createDefaultState() { - return { - version: STORE_VERSION, - updatedAt: Date.now(), - buttons: Array.from({ length: BUTTON_COUNT }, (_, idx) => createDefaultButton(idx + 1)), - effects: {}, - }; -} - -function clone(value) { - return JSON.parse(JSON.stringify(value)); -} - -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, - rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null, - goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null, - 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: STORE_VERSION, - updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(), - buttons, - effects, - }; -} - -function writeState() { - fs.mkdirSync(DATA_DIR, { recursive: true }); - const next = { - ...state, - updatedAt: Date.now(), - }; - const tempPath = `${STORE_PATH}.${process.pid}.${Date.now()}.tmp`; - fs.writeFileSync(tempPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); - fs.renameSync(tempPath, STORE_PATH); - state = next; -} - -function loadState() { - if (state) return state; - try { - const raw = JSON.parse(fs.readFileSync(STORE_PATH, '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 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.rewardNumber = reward.number; - button.goal = reward.goal; - if (!Number.isFinite(button.count) || button.count < 0) { - button.count = 0; - } - return; - } - assignNewReward(button); - if (button.rewardId) { - seen.add(button.rewardId); - } - }); -} - -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.rewardNumber = reward.number; - button.goal = reward.goal; - button.count = 0; -} - -function buildRewardContext() { - return { - logger, - issueCommand, - listOnlineRovers: () => roverManager.getRoster(), - sendAlert, - publishEvent, - sendExternalTyping, - sendExternalMessage, - sendSystemMessage, - getMode, - setMode: (mode, source = 'buttonbox') => - setMode( - mode, - { data: { role: 'admin', user: { username: source } } }, - { force: true }, - ), - getAdminReasonText: () => getAdminReason()?.text || null, - setAdminReason: (text, source = 'buttonbox') => setAdminReason(text, { by: source }), - clearAdminReason: (source = 'buttonbox') => clearAdminReason({ by: source }), - rerollAssignments: () => assignmentService.rerollAssignments(), - getHomeAssistantEntities: () => { - const entities = getHomeAssistantState()?.entities; - return Array.isArray(entities) ? entities : []; - }, - getHomeAssistantLightPolicy: () => getHomeAssistantState()?.lightPolicy || null, - setHomeAssistantEntityState, - setHomeAssistantLightsLockedOn: (next, options = {}) => - setHomeAssistantLightsLockedOn(next, options), - saveEffect: (effectId, payload = {}) => saveEffect(effectId, payload, { broadcast: false }), - clearEffect: (effectId) => clearEffect(effectId, { broadcast: false }), - }; -} - -function getButtonBoxState() { - loadState(); - return clone({ buttons: state.buttons }); -} - -function publishUpdated() { - publishEvent({ - source: 'buttonBox', - type: 'buttonBox.updated', - payload: { updatedAt: Date.now() }, - }); -} - -function saveEffect(effectId, payload = {}, options = {}) { - loadState(); - state.effects = state.effects && typeof state.effects === 'object' ? state.effects : {}; - state.effects[effectId] = payload; - writeState(); - if (options.broadcast) { - publishUpdated(); - } -} - -function clearEffect(effectId, options = {}) { - loadState(); - if (state.effects && typeof state.effects === 'object' && state.effects[effectId]) { - delete state.effects[effectId]; - writeState(); - if (options.broadcast) { - publishUpdated(); - } - } -} - -function parseButtonId(body) { - if (typeof body !== 'string') return null; - const value = Number.parseInt(body.trim(), 10); - if (Number.isFinite(value)) return value; - return null; -} - -function denyIfNotLocal(req, res) { - const ip = normalizeIp(getRequestIp(req)); - if (isLocalNetwork(ip)) { - return false; - } - logger.warn('Rejected non-local button press request', { ip: ip || null }); - res.status(403).json({ error: 'Button presses must originate from local network' }); - return true; -} - -async function runRewardForButton(button) { - const reward = getRewardById(button.rewardId); - if (!reward) { - assignNewReward(button); - return; - } - const completedCount = Number.isFinite(button.count) ? button.count : 0; - const completedGoal = Number.isFinite(button.goal) ? button.goal : 0; - const ctx = buildRewardContext(); - try { - await reward.run(ctx); - } catch (err) { - logger.warn('Reward execution failed', { rewardId: reward.id, error: err.message }); - } - io.emit('buttonBox:rewardRun', { - buttonId: button.id, - rewardId: reward.id, - rewardName: reward.name || reward.id, - count: completedCount, - goal: completedGoal, - ts: Date.now(), - }); - button.lastRewardAt = Date.now(); - button.count = 0; - assignNewReward(button, { excludeRewardId: reward.id }); -} - -async function applyPress(buttonId) { - loadState(); - const button = state.buttons.find((entry) => entry.id === buttonId); - if (!button) { - throw new Error('Unknown button'); - } - - button.count += 1; - button.lastIncrementAt = Date.now(); - writeState(); - - io.emit('buttonBox:increment', { - buttonId, - count: button.count, - ts: button.lastIncrementAt, - }); - - if (button.count >= button.goal) { - await runRewardForButton(button); - writeState(); - } - - publishUpdated(); - return clone(button); -} - -async function recoverEffects() { - loadState(); - const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {}; - const ctx = buildRewardContext(); - - for (const [effectId, payload] of Object.entries(effects)) { - const reward = getRewardById(effectId); - if (!reward || typeof reward.recover !== 'function') { - clearEffect(effectId, { broadcast: false }); - continue; - } - try { - await reward.recover(ctx, payload); - } catch (err) { - logger.warn('Effect recovery failed', { effectId, error: err.message }); - clearEffect(effectId, { broadcast: false }); - } - } - publishUpdated(); -} - -app.post('/buttonbox/press', express.text({ type: 'text/plain' }), async (req, res) => { - if (denyIfNotLocal(req, res)) return; - const buttonId = parseButtonId(req.body); - if (!Number.isFinite(buttonId) || buttonId < 1 || buttonId > BUTTON_COUNT) { - res.status(400).json({ error: 'button must be 1-4' }); - return; - } - try { - const button = await applyPress(buttonId); - res.json({ success: true, button }); - } catch (err) { - res.status(500).json({ error: err.message || 'Button processing failed' }); - } +const store = createButtonBoxStore({ + logger, + getRewardById, + listRewards, + dataDir: DATA_DIR, + storePath: STORE_PATH, + buttonCount: BUTTON_COUNT, + storeVersion: STORE_VERSION, }); -loadState(); -recoverEffects().catch((err) => { +const core = createButtonBoxCore({ + io, + logger, + getRewardById, + roverManager, + issueCommand, + sendAlert, + publishEvent, + sendExternalTyping, + sendExternalMessage, + sendSystemMessage, + getMode, + setMode, + getAdminReason, + setAdminReason, + clearAdminReason, + assignmentService, + getHomeAssistantState, + setHomeAssistantEntityState, + setHomeAssistantLightsLockedOn, + store, +}); + +registerButtonBoxRoute({ + app, + logger, + buttonCount: BUTTON_COUNT, + getRequestIp, + normalizeIp, + isLocalNetwork, + applyPress: core.applyPress, +}); + +store.loadState(); +core.recoverEffects().catch((err) => { logger.warn('Button box effect recovery failed', err.message); }); module.exports = { - getButtonBoxState, + getButtonBoxState: store.getStateClone, }; diff --git a/server/src/services/buttonBoxService/store.js b/server/src/services/buttonBoxService/store.js new file mode 100644 index 00000000..f53987b5 --- /dev/null +++ b/server/src/services/buttonBoxService/store.js @@ -0,0 +1,172 @@ +// 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, + rewardNumber: null, + goal: null, + 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.rewardNumber = reward.number; + button.goal = reward.goal; + button.count = 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.rewardNumber = reward.number; + button.goal = reward.goal; + if (!Number.isFinite(button.count) || button.count < 0) { + button.count = 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, + rewardNumber: Number.isFinite(loaded.rewardNumber) ? Math.floor(loaded.rewardNumber) : null, + goal: Number.isFinite(loaded.goal) ? Math.max(1, Math.floor(loaded.goal)) : null, + 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 getStateClone() { + return clone({ buttons: getState().buttons }); + } + + return { + loadState, + writeState, + getState, + getStateClone, + clone, + assignNewReward, + }; +} + +module.exports = { + createButtonBoxStore, +};