barcode games!

This commit is contained in:
legop3
2026-06-18 19:40:09 -04:00
parent 0c16e5cd11
commit 0af9d560e4
15 changed files with 1147 additions and 15 deletions
+1
View File
@@ -41,6 +41,7 @@ require('./src/services/audioLevelsService');
require('./src/services/audioForwardService');
require('./src/services/buttonBoxService');
require('./src/services/barcodeScannerService');
require('./src/services/barcodeGameService');
require('./src/services/kinectService');
require('./src/services/sessionService');
require('./src/services/batteryManager');
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
+2 -2
View File
@@ -18,8 +18,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-D7L_pr9D.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjmiVAqR.css">
<script type="module" crossorigin src="/assets/index-BUN5_4YP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DrszynNM.css">
</head>
<body>
<div id="root"></div>
@@ -0,0 +1,190 @@
// Scan Quest Game
// Purpose: Implements the ordered object-scanning quest game.
// Scope: Keeps quest selection, scoring, and quest-specific display state out of
// the shared barcode game service.
const GAME_ID = 'scanQuest';
const QUEST_LENGTH_OPTIONS = [1, 2];
function createInitialState() {
return {
currentQuest: null,
progressIndex: 0,
scores: {},
completedQuests: 0,
recentEvents: [],
lastMessage: 'vote to start scan quest',
};
}
function normalizeState(rawState = {}) {
const base = createInitialState();
return {
...base,
currentQuest: rawState.currentQuest && typeof rawState.currentQuest === 'object' ? rawState.currentQuest : null,
progressIndex: Number.isFinite(rawState.progressIndex) ? Math.max(0, Math.floor(rawState.progressIndex)) : 0,
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
completedQuests: Number.isFinite(rawState.completedQuests) ? Math.max(0, Math.floor(rawState.completedQuests)) : 0,
recentEvents: Array.isArray(rawState.recentEvents) ? rawState.recentEvents.slice(-12) : [],
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
};
}
function pickQuest(objects = []) {
const candidates = objects.filter((entry) => entry?.code && entry?.label);
if (!candidates.length) return null;
const length = QUEST_LENGTH_OPTIONS[Math.floor(Math.random() * QUEST_LENGTH_OPTIONS.length)];
const steps = [];
for (let idx = 0; idx < length; idx += 1) {
// Reusing objects is allowed because physical rooms often start with only a
// small number of labeled props. The game still stays readable because the
// sequence is short and order-specific.
const entry = candidates[Math.floor(Math.random() * candidates.length)];
steps.push({
code: entry.code,
entityId: entry.entityId,
label: entry.label,
});
}
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
steps,
createdAt: Date.now(),
};
}
function formatQuestPrompt(state) {
const quest = state.currentQuest;
if (!quest?.steps?.length) return 'scan quest needs objects';
const step = quest.steps[state.progressIndex] || quest.steps[0];
if (quest.steps.length === 1) return `scan ${step.label}`;
return `scan ${step.label} ${state.progressIndex + 1} of ${quest.steps.length}`;
}
function ensureQuest(state, context = {}) {
if (state.currentQuest?.steps?.length) return state;
const nextQuest = pickQuest(context.objects || []);
state.currentQuest = nextQuest;
state.progressIndex = 0;
state.lastMessage = nextQuest ? formatQuestPrompt(state) : 'scan quest needs objects';
return state;
}
function addRecentEvent(state, event) {
state.recentEvents = [
{
...event,
at: Date.now(),
},
...(Array.isArray(state.recentEvents) ? state.recentEvents : []),
].slice(0, 12);
}
function addScore(state, participants = [], points) {
participants.forEach((participant) => {
const key = participant?.playerKey;
if (!key) return;
const previous = state.scores[key] || {};
state.scores[key] = {
playerKey: key,
nickname: participant.nickname || previous.nickname || null,
roverId: participant.roverId || previous.roverId || null,
points: (Number.isFinite(previous.points) ? previous.points : 0) + points,
lastScoredAt: Date.now(),
};
});
}
function onActivated(rawState, context = {}) {
const state = normalizeState(rawState);
ensureQuest(state, context);
return state;
}
function onScan(rawState, scan, context = {}) {
const state = normalizeState(rawState);
ensureQuest(state, context);
if (!state.currentQuest?.steps?.length) return state;
if (!scan?.known || scan.type !== 'object') return state;
const expected = state.currentQuest.steps[state.progressIndex];
const matched = Boolean(expected && scan.code === expected.code);
if (!matched) {
// Wrong scans do not erase the whole quest. That keeps the game forgiving
// for rover driving mistakes while still making the ordered target clear on
// the large scanner display.
state.lastMessage = `try ${expected.label}`;
addRecentEvent(state, {
kind: 'miss',
label: scan.label,
expected: expected.label,
});
return state;
}
state.progressIndex += 1;
addRecentEvent(state, {
kind: 'hit',
label: scan.label,
});
if (state.progressIndex < state.currentQuest.steps.length) {
state.lastMessage = formatQuestPrompt(state);
return state;
}
const points = state.currentQuest.steps.length;
state.completedQuests += 1;
addScore(state, context.participants || [], points);
addRecentEvent(state, {
kind: 'complete',
points,
participants: (context.participants || []).map((participant) => participant.nickname || participant.roverId).filter(Boolean),
});
state.currentQuest = pickQuest(context.objects || []);
state.progressIndex = 0;
state.lastMessage = state.currentQuest ? `scored ${points}. ${formatQuestPrompt(state)}` : `scored ${points}`;
return state;
}
function getTopScores(state) {
return Object.values(state.scores || {})
.sort((a, b) => (b.points || 0) - (a.points || 0))
.slice(0, 5);
}
function getPublicState(rawState, context = {}) {
const state = normalizeState(rawState);
ensureQuest(state, context);
return {
id: GAME_ID,
title: 'Scan quest',
status: state.currentQuest ? 'running' : 'needs_objects',
headline: state.lastMessage || formatQuestPrompt(state),
detail: state.currentQuest?.steps?.length
? state.currentQuest.steps.map((step) => step.label).join(' then ')
: 'add object barcodes to the registry',
progress: {
current: state.progressIndex,
total: state.currentQuest?.steps?.length || 0,
},
scores: getTopScores(state),
completedQuests: state.completedQuests,
recentEvents: state.recentEvents,
};
}
module.exports = {
id: GAME_ID,
title: 'Scan quest',
description: 'Scan one or two requested objects in order.',
createInitialState,
normalizeState,
onActivated,
onScan,
getPublicState,
};
@@ -0,0 +1,201 @@
// Scans Per Second Game
// Purpose: Implements a timed five-minute scan-rate challenge.
// Scope: Counts every scan event for this game, including unknown and invalid
// codes, while preserving persistent round results and the world record.
const GAME_ID = 'scansPerSecond';
const ROUND_DURATION_MS = 5 * 60 * 1000;
function createInitialState() {
return {
status: 'idle',
roundId: null,
startedAt: null,
endsAt: null,
scans: [],
finalResult: null,
worldRecord: null,
recentRounds: [],
participantCounts: {},
lastMessage: 'vote to start scans per second',
};
}
function normalizeState(rawState = {}) {
const base = createInitialState();
return {
...base,
status: rawState.status === 'running' || rawState.status === 'ended' ? rawState.status : 'idle',
roundId: typeof rawState.roundId === 'string' ? rawState.roundId : null,
startedAt: Number.isFinite(rawState.startedAt) ? rawState.startedAt : null,
endsAt: Number.isFinite(rawState.endsAt) ? rawState.endsAt : null,
scans: Array.isArray(rawState.scans) ? rawState.scans.filter((entry) => Number.isFinite(entry?.at)) : [],
finalResult: rawState.finalResult && typeof rawState.finalResult === 'object' ? rawState.finalResult : null,
worldRecord: rawState.worldRecord && typeof rawState.worldRecord === 'object' ? rawState.worldRecord : null,
recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [],
participantCounts:
rawState.participantCounts && typeof rawState.participantCounts === 'object' ? rawState.participantCounts : {},
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
};
}
function calculateRate(scanCount, startedAt, endedAt) {
if (!scanCount || !startedAt || !endedAt || endedAt <= startedAt) return 0;
return scanCount / ((endedAt - startedAt) / 1000);
}
function buildResult(state, endedAt = Date.now()) {
const scanCount = state.scans.length;
const rate = calculateRate(scanCount, state.startedAt, endedAt);
return {
roundId: state.roundId,
scanCount,
durationMs: Math.max(0, endedAt - (state.startedAt || endedAt)),
scansPerSecond: Number(rate.toFixed(3)),
startedAt: state.startedAt,
endedAt,
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
};
}
function finishRound(state, endedAt = Date.now()) {
if (state.status !== 'running') return state;
const result = buildResult(state, endedAt);
const previousRecord = state.worldRecord;
const isWorldRecord = !previousRecord || result.scansPerSecond > (previousRecord.scansPerSecond || 0);
state.status = 'ended';
state.finalResult = {
...result,
isWorldRecord,
};
state.worldRecord = isWorldRecord ? result : previousRecord;
state.recentRounds = [state.finalResult, ...(state.recentRounds || [])].slice(0, 10);
state.lastMessage = isWorldRecord
? `new record ${result.scansPerSecond} scans per second`
: `finished ${result.scansPerSecond} scans per second`;
return state;
}
function startRound(rawState, now = Date.now()) {
const previous = normalizeState(rawState);
return {
...previous,
status: 'running',
roundId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
startedAt: now,
endsAt: now + ROUND_DURATION_MS,
scans: [],
finalResult: null,
participantCounts: {},
lastMessage: 'scan anything',
};
}
function onActivated(rawState, context = {}) {
const state = normalizeState(rawState);
const now = context.now || Date.now();
// Voting for an ended or idle challenge starts a fresh five-minute round. If
// the round is already running, activation is a no-op so vote churn does not
// accidentally reset an active challenge.
return state.status === 'running' ? state : startRound(state, now);
}
function addParticipants(state, participants = []) {
participants.forEach((participant) => {
const key = participant?.playerKey;
if (!key) return;
const previous = state.participantCounts[key] || {};
state.participantCounts[key] = {
playerKey: key,
nickname: participant.nickname || previous.nickname || null,
roverId: participant.roverId || previous.roverId || null,
scanCount: (Number.isFinite(previous.scanCount) ? previous.scanCount : 0) + 1,
lastSeenAt: Date.now(),
};
});
}
function onScan(rawState, scan, context = {}) {
const now = context.now || Date.now();
let state = normalizeState(rawState);
if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
state = finishRound(state, state.endsAt);
}
if (state.status !== 'running') return state;
// This game deliberately counts every submitted scan, including unknown and
// invalid barcodes, because the challenge is about physically getting scans
// through the station rather than finding specific registry entries.
state.scans = [
...(state.scans || []),
{
code: scan?.code || '',
known: Boolean(scan?.known),
at: now,
},
];
addParticipants(state, context.participants || []);
if (state.endsAt && now >= state.endsAt) {
return finishRound(state, state.endsAt);
}
const currentRate = calculateRate(state.scans.length, state.startedAt, now);
state.lastMessage = `${currentRate.toFixed(2)} scans per second`;
return state;
}
function onTick(rawState, context = {}) {
const now = context.now || Date.now();
const state = normalizeState(rawState);
if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
return finishRound(state, state.endsAt);
}
return state;
}
function getPublicState(rawState, context = {}) {
let state = normalizeState(rawState);
const now = context.now || Date.now();
if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
state = finishRound(state, state.endsAt);
}
const elapsedEnd = state.status === 'running' ? now : state.finalResult?.endedAt || now;
const currentRate = state.status === 'running'
? calculateRate(state.scans.length, state.startedAt, elapsedEnd)
: state.finalResult?.scansPerSecond || 0;
const remainingMs = state.status === 'running' ? Math.max(0, (state.endsAt || now) - now) : 0;
return {
id: GAME_ID,
title: 'Scans per second',
status: state.status,
headline: state.lastMessage,
detail: state.status === 'running'
? `${state.scans.length} scans, ${Math.ceil(remainingMs / 1000)} seconds left`
: state.finalResult
? `${state.finalResult.scanCount} scans in last round`
: 'five minute scan challenge',
scanCount: state.scans.length,
scansPerSecond: Number(currentRate.toFixed(3)),
remainingMs,
finalResult: state.finalResult,
worldRecord: state.worldRecord,
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
recentRounds: state.recentRounds,
};
}
module.exports = {
id: GAME_ID,
title: 'Scans per second',
description: 'Count every scan for five minutes and save the world record.',
createInitialState,
normalizeState,
onActivated,
onScan,
onTick,
getPublicState,
};
@@ -0,0 +1,399 @@
// Barcode Game Service
// Purpose: Coordinates global barcode games, voting, player attribution, and
// persistent scan counters for the scanner station.
// Scope: Keeps game orchestration server-side while the scanner and driver pages
// remain thin IO surfaces that subscribe to state and send votes/scans.
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeGameService');
const { subscribe } = require('../eventBus');
const { getActiveDrivers } = require('../turnService');
const { getIdentitySummary } = require('../verificationService');
const { getRegistrySnapshot } = require('../barcodeScannerService');
const { loadStore, withGameStore } = require('./store');
const scanQuest = require('./games/scanQuest');
const scansPerSecond = require('./games/scansPerSecond');
const GAME_SOCKET_ROOM = 'barcode-game';
const RECENT_EVENT_LIMIT = 20;
const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000;
const GAME_DEFINITIONS = [scanQuest, scansPerSecond];
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
function getGameDefinition(gameId) {
return GAMES_BY_ID[String(gameId || '')] || null;
}
function getKnownObjects() {
try {
const snapshot = getRegistrySnapshot();
const codes = snapshot?.registry?.codes || {};
return Object.entries(codes)
.filter(([, entry]) => entry?.type === 'object')
.map(([code, entry]) => ({
code,
entityId: entry.entityId,
label: entry.label,
}));
} catch (err) {
logger.warn('Failed to read barcode registry for game object list', { error: err.message });
return [];
}
}
function normalizePlayerKey(identity = {}, socketId = '', roverId = '') {
if (identity.cookieUserId) return `identity:${identity.cookieUserId}`;
if (socketId) return `socket:${socketId}`;
if (roverId) return `rover:${roverId}`;
return null;
}
function resolveRoverParticipant(roverId) {
const normalizedRoverId = String(roverId || '').trim();
if (!normalizedRoverId) return null;
const activeDrivers = getActiveDrivers();
const socketId = activeDrivers?.[normalizedRoverId] || null;
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
const identity = socket ? getIdentitySummary(socket) : {};
const playerKey = normalizePlayerKey(identity, socketId, normalizedRoverId);
return {
playerKey,
roverId: normalizedRoverId,
socketId,
cookieUserId: identity.cookieUserId || null,
nickname: identity.nickname || normalizedRoverId,
};
}
function pruneRecentRoverSightings(draft, now) {
const sightings = draft.recentRoverSightings || {};
Object.entries(sightings).forEach(([roverId, sighting]) => {
if (!Number.isFinite(sighting?.scannedAt) || now - sighting.scannedAt > ROVER_ATTRIBUTION_WINDOW_MS) {
delete sightings[roverId];
}
});
draft.recentRoverSightings = sightings;
}
function recordRoverSighting(draft, scan, now) {
if (!scan?.known || scan.type !== 'rover' || !scan.entityId) return null;
const participant = resolveRoverParticipant(scan.entityId);
if (!participant?.playerKey) return null;
draft.recentRoverSightings = {
...(draft.recentRoverSightings || {}),
[participant.roverId]: {
...participant,
scannedAt: now,
},
};
return participant;
}
function getProximityParticipants(draft, now) {
pruneRecentRoverSightings(draft, now);
return Object.values(draft.recentRoverSightings || {})
.filter((sighting) => sighting?.playerKey && now - sighting.scannedAt <= ROVER_ATTRIBUTION_WINDOW_MS)
.map((sighting) => ({
playerKey: sighting.playerKey,
roverId: sighting.roverId,
socketId: sighting.socketId || null,
cookieUserId: sighting.cookieUserId || null,
nickname: sighting.nickname || sighting.roverId,
scannedAt: sighting.scannedAt,
}));
}
function incrementCounter(bucket, key, scan, now) {
if (!key) return;
const previous = bucket[key] || {};
bucket[key] = {
code: scan.code || previous.code || null,
entityId: scan.entityId || previous.entityId || key,
label: scan.label || previous.label || key,
type: scan.type || previous.type || null,
count: (Number.isFinite(previous.count) ? previous.count : 0) + 1,
lastScannedAt: now,
};
}
function updateGlobalCounters(draft, scan, now) {
if (!scan?.known) return;
const counters = draft.globalCounters || {};
counters.codes = counters.codes || {};
counters.objects = counters.objects || {};
counters.rovers = counters.rovers || {};
// Global counters intentionally ignore unknown scans. They are meant to be a
// useful room/object popularity board, while game-specific rules can still
// choose to count unknown codes when that makes sense.
incrementCounter(counters.codes, scan.code, scan, now);
if (scan.type === 'object') {
incrementCounter(counters.objects, scan.entityId, scan, now);
} else if (scan.type === 'rover') {
incrementCounter(counters.rovers, scan.entityId, scan, now);
}
draft.globalCounters = counters;
}
function addRecentEvent(draft, event) {
draft.recentEvents = [
{
...event,
at: Date.now(),
},
...(Array.isArray(draft.recentEvents) ? draft.recentEvents : []),
].slice(0, RECENT_EVENT_LIMIT);
}
function recordPlayerParticipation(draft, gameId, participants, now) {
if (!gameId || !Array.isArray(participants) || !participants.length) return;
draft.players = draft.players || {};
participants.forEach((participant) => {
if (!participant?.playerKey) return;
const previous = draft.players[participant.playerKey] || {};
const previousGames = previous.games || {};
const previousGame = previousGames[gameId] || {};
draft.players[participant.playerKey] = {
playerKey: participant.playerKey,
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
nickname: participant.nickname || previous.nickname || null,
lastRoverId: participant.roverId || previous.lastRoverId || null,
lastSeenAt: now,
games: {
...previousGames,
[gameId]: {
gameId,
scanCount: (Number.isFinite(previousGame.scanCount) ? previousGame.scanCount : 0) + 1,
lastPlayedAt: now,
},
},
};
});
}
function ensureGameState(draft, gameId) {
const definition = getGameDefinition(gameId);
if (!definition) return null;
draft.games = draft.games || {};
draft.games[gameId] = definition.normalizeState
? definition.normalizeState(draft.games[gameId])
: draft.games[gameId] || definition.createInitialState();
return draft.games[gameId];
}
function buildGameContext(draft, extras = {}) {
return {
now: extras.now || Date.now(),
objects: getKnownObjects(),
participants: extras.participants || [],
};
}
function activateGame(draft, gameId, now) {
const definition = getGameDefinition(gameId);
if (!definition) return false;
const currentState = ensureGameState(draft, gameId);
const nextState = definition.onActivated
? definition.onActivated(currentState, buildGameContext(draft, { now }))
: currentState;
draft.games[gameId] = nextState;
draft.activeGameId = gameId;
addRecentEvent(draft, {
kind: 'gameActivated',
gameId,
title: definition.title,
});
return true;
}
function countVotes(votes = {}) {
const counts = {};
Object.values(votes || {}).forEach((vote) => {
if (!getGameDefinition(vote?.gameId)) return;
counts[vote.gameId] = (counts[vote.gameId] || 0) + 1;
});
return counts;
}
function chooseVoteWinner(draft) {
const counts = countVotes(draft.votes);
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
if (!entries.length) return null;
const [topGameId, topCount] = entries[0];
const activeCount = draft.activeGameId ? counts[draft.activeGameId] || 0 : 0;
// Ties keep the current game so a single equalizing vote does not cause the
// room display to flicker back and forth between games.
if (draft.activeGameId && activeCount === topCount) return draft.activeGameId;
return topGameId;
}
function getVoterKey(socket) {
const identity = getIdentitySummary(socket);
return normalizePlayerKey(identity, socket?.id || '', '') || `socket:${socket?.id || 'unknown'}`;
}
function setVote(socket, gameId) {
const definition = getGameDefinition(gameId);
if (!definition) {
return { error: 'unknown barcode game' };
}
const now = Date.now();
const voterKey = getVoterKey(socket);
const identity = getIdentitySummary(socket);
withGameStore((draft) => {
draft.votes = draft.votes || {};
draft.votes[voterKey] = {
gameId: definition.id,
voterKey,
socketId: socket.id,
nickname: identity.nickname || null,
votedAt: now,
};
const winner = chooseVoteWinner(draft);
const existingWinnerState = winner ? draft.games?.[winner] : null;
const shouldActivateWinner = Boolean(
winner &&
(!draft.activeGameId ||
winner !== draft.activeGameId ||
existingWinnerState?.status === 'ended' ||
existingWinnerState?.status === 'idle'),
);
if (shouldActivateWinner) {
activateGame(draft, winner, now);
}
});
broadcastState();
return { success: true, state: buildStatePayload() };
}
function settleActiveGameIfNeeded() {
const store = loadStore();
const activeGameId = store.activeGameId;
const definition = getGameDefinition(activeGameId);
const currentState = activeGameId ? store.games?.[activeGameId] : null;
const now = Date.now();
if (!definition?.onTick || currentState?.status !== 'running' || !currentState?.endsAt || now < currentState.endsAt) {
return;
}
withGameStore((draft) => {
const state = ensureGameState(draft, activeGameId);
draft.games[activeGameId] = definition.onTick(state, buildGameContext(draft, { now }));
});
}
function handleScan(scan) {
const now = Number.isFinite(scan?.scannedAt) ? scan.scannedAt : Date.now();
withGameStore((draft) => {
updateGlobalCounters(draft, scan, now);
recordRoverSighting(draft, scan, now);
const participants = getProximityParticipants(draft, now);
const activeGameId = draft.activeGameId;
const definition = getGameDefinition(activeGameId);
if (definition) {
const currentState = ensureGameState(draft, activeGameId);
const nextState = definition.onScan
? definition.onScan(currentState, scan, buildGameContext(draft, { now, participants }))
: currentState;
draft.games[activeGameId] = nextState;
recordPlayerParticipation(draft, activeGameId, participants, now);
}
addRecentEvent(draft, {
kind: 'scan',
code: scan?.code || '',
label: scan?.label || scan?.code || 'unknown',
known: Boolean(scan?.known),
type: scan?.type || null,
participants: participants.map((participant) => participant.nickname || participant.roverId).filter(Boolean),
});
});
broadcastState();
}
function topCounters(bucket = {}, limit = 5) {
return Object.values(bucket || {})
.sort((a, b) => (b.count || 0) - (a.count || 0))
.slice(0, limit);
}
function buildStatePayload() {
settleActiveGameIfNeeded();
const store = loadStore();
const now = Date.now();
const voteCounts = countVotes(store.votes);
const activeDefinition = getGameDefinition(store.activeGameId);
const context = buildGameContext(store, { now });
const activeGame = activeDefinition
? activeDefinition.getPublicState(ensureReadonlyGameState(store, activeDefinition.id), context)
: null;
return {
activeGameId: store.activeGameId,
games: GAME_DEFINITIONS.map((game) => ({
id: game.id,
title: game.title,
description: game.description,
voteCount: voteCounts[game.id] || 0,
active: game.id === store.activeGameId,
})),
activeGame,
counters: {
objects: topCounters(store.globalCounters?.objects),
rovers: topCounters(store.globalCounters?.rovers),
codes: topCounters(store.globalCounters?.codes),
},
recentEvents: Array.isArray(store.recentEvents) ? store.recentEvents.slice(0, 8) : [],
};
}
function ensureReadonlyGameState(store, gameId) {
const definition = getGameDefinition(gameId);
if (!definition) return null;
return definition.normalizeState ? definition.normalizeState(store.games?.[gameId]) : store.games?.[gameId] || null;
}
function broadcastState() {
io.to(GAME_SOCKET_ROOM).emit('barcodeGame:state', buildStatePayload());
}
io.on('connection', (socket) => {
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(GAME_SOCKET_ROOM);
const state = buildStatePayload();
socket.emit('barcodeGame:state', state);
cb({ success: true, state });
});
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
try {
cb(setVote(socket, gameId));
} catch (err) {
logger.warn('Barcode game vote failed', { error: err.message, gameId });
cb({ error: err.message || 'barcode game vote failed' });
}
});
});
subscribe('barcode.scanned', (event) => {
try {
handleScan(event.payload);
} catch (err) {
// Scanner input should never be able to take down the server. Game failures
// are logged and skipped so the scanner page can keep resolving barcodes.
logger.warn('Barcode game scan handling failed', { error: err.message });
}
});
module.exports = {
buildStatePayload,
handleScan,
setVote,
};
@@ -0,0 +1,124 @@
// Barcode Game Store
// Purpose: Owns persisted barcode game state and normalizes it on every load.
// Scope: Keeps file IO and state-shape repair separate from the game engine so
// individual games can focus on rules instead of persistence details.
const { createJsonStore } = require('../identityService');
const { resolveDataPath } = require('../../helpers/dataPaths');
const logger = require('../../globals/logger').child('barcodeGameStore');
const STORE_VERSION = 1;
const STORE_PATH = resolveDataPath('barcode-games.json');
function createDefaultStore() {
return {
version: STORE_VERSION,
updatedAt: Date.now(),
activeGameId: null,
votes: {},
globalCounters: {
codes: {},
objects: {},
rovers: {},
},
recentRoverSightings: {},
players: {},
games: {},
recentEvents: [],
};
}
function cloneStore(store) {
// The game state is intentionally JSON-shaped because it must survive server
// restarts without custom serializers. JSON cloning is sufficient and keeps
// accidental mutable references from leaking between callers.
return JSON.parse(JSON.stringify(store || createDefaultStore()));
}
function normalizeCounterBucket(rawBucket = {}) {
const bucket = {};
Object.entries(rawBucket && typeof rawBucket === 'object' ? rawBucket : {}).forEach(([key, rawEntry]) => {
if (!key || !rawEntry || typeof rawEntry !== 'object') return;
const count = Number.isFinite(rawEntry.count) ? Math.max(0, Math.floor(rawEntry.count)) : 0;
if (!count) return;
bucket[key] = {
code: typeof rawEntry.code === 'string' ? rawEntry.code : null,
entityId: typeof rawEntry.entityId === 'string' ? rawEntry.entityId : key,
label: typeof rawEntry.label === 'string' ? rawEntry.label : key,
type: typeof rawEntry.type === 'string' ? rawEntry.type : null,
count,
lastScannedAt: Number.isFinite(rawEntry.lastScannedAt) ? rawEntry.lastScannedAt : null,
};
});
return bucket;
}
function normalizeVote(rawVote = {}) {
const gameId = typeof rawVote.gameId === 'string' ? rawVote.gameId : null;
if (!gameId) return null;
return {
gameId,
voterKey: typeof rawVote.voterKey === 'string' ? rawVote.voterKey : null,
nickname: typeof rawVote.nickname === 'string' ? rawVote.nickname : null,
socketId: typeof rawVote.socketId === 'string' ? rawVote.socketId : null,
votedAt: Number.isFinite(rawVote.votedAt) ? rawVote.votedAt : Date.now(),
};
}
function normalizeStoreShape(raw = {}) {
const base = createDefaultStore();
const votes = {};
Object.entries(raw.votes && typeof raw.votes === 'object' ? raw.votes : {}).forEach(([key, rawVote]) => {
const vote = normalizeVote(rawVote);
if (vote) votes[key] = { ...vote, voterKey: vote.voterKey || key };
});
return {
...base,
version: STORE_VERSION,
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
activeGameId: typeof raw.activeGameId === 'string' ? raw.activeGameId : null,
votes,
globalCounters: {
codes: normalizeCounterBucket(raw.globalCounters?.codes),
objects: normalizeCounterBucket(raw.globalCounters?.objects),
rovers: normalizeCounterBucket(raw.globalCounters?.rovers),
},
recentRoverSightings:
raw.recentRoverSightings && typeof raw.recentRoverSightings === 'object'
? raw.recentRoverSightings
: {},
players: raw.players && typeof raw.players === 'object' ? raw.players : {},
games: raw.games && typeof raw.games === 'object' ? raw.games : {},
recentEvents: Array.isArray(raw.recentEvents) ? raw.recentEvents.slice(-25) : [],
};
}
const storeApi = createJsonStore({
path: STORE_PATH,
normalizeStoreShape,
cloneStore,
logger,
});
function writeStore(next) {
return storeApi.writeStore({
...next,
updatedAt: Date.now(),
});
}
function withGameStore(mutator) {
const current = storeApi.loadStore();
const draft = cloneStore(current);
const result = mutator(draft);
writeStore(draft);
return result;
}
module.exports = {
STORE_PATH,
loadStore: storeApi.loadStore,
writeStore,
withGameStore,
cloneStore,
};
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('barcodeScannerService');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { sendAlert } = require('../alertService');
const { publishEvent } = require('../eventBus');
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
const DATA_DIR = resolveDataDir();
@@ -139,6 +140,14 @@ function loadRegistryForScan() {
}
}
function getRegistrySnapshot() {
const loaded = loadRegistryForScan();
return {
registry: loaded.registry,
error: loaded.error || null,
};
}
function buildStatePayload() {
return {
mode: getMode(),
@@ -257,6 +266,14 @@ async function applyScan(rawCode) {
registryError: result.registryError || null,
};
broadcastState();
// Barcode games listen to the normalized scan event instead of being called
// directly from this service. That keeps the scanner station's IO concerns
// separate from optional game rules, scoring, voting, and player attribution.
publishEvent({
source: 'barcodeScanner',
type: 'barcode.scanned',
payload: result,
});
buildScanAudio(result)
.then((audio) => {
io.to(SCANNER_SOCKET_ROOM).emit('barcode:scanAudio', {
@@ -308,4 +325,5 @@ module.exports = {
REGISTRY_PATH,
applyScan,
buildStatePayload,
getRegistrySnapshot,
};