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