mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
1176 lines
44 KiB
JavaScript
1176 lines
44 KiB
JavaScript
// 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 { loadConfig } = require('../../configuration');
|
|
const { subscribe } = require('../eventBus');
|
|
const { sendSystemMessage } = require('../chatService');
|
|
const { getActiveDrivers } = require('../turnService');
|
|
const { getIdentitySummary } = require('../verificationService');
|
|
const { getFeatureState, listFeatureStates, updateFeatureState } = require('../identityService');
|
|
const { getRegistrySnapshot } = require('../barcodeScannerService');
|
|
const { loadStore, withGameStore } = require('./store');
|
|
const scanQuest = require('./games/scanQuest');
|
|
const scansPerSecond = require('./games/scansPerSecond');
|
|
const mostItems = require('./games/mostItems');
|
|
|
|
const GAME_SOCKET_ROOM = 'barcode-game';
|
|
const RECENT_EVENT_LIMIT = 20;
|
|
const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000;
|
|
const GAME_TICK_MS = 5 * 1000;
|
|
const VOTING_WINDOW_MS = 10 * 1000;
|
|
const JOIN_WINDOW_MS = 30 * 1000;
|
|
const STARTING_WINDOW_MS = 5 * 1000;
|
|
const RESULTS_WINDOW_MS = 45 * 1000;
|
|
|
|
const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems];
|
|
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
|
const config = loadConfig();
|
|
const barcodeGamesConfig = config.barcodeGames || {};
|
|
const enabled = Boolean(barcodeGamesConfig.enabled);
|
|
const botName = String(barcodeGamesConfig.botName || 'Barcode Games').trim() || 'Barcode Games';
|
|
const botProfileImageUrl = String(barcodeGamesConfig.profileImageUrl || '').trim() || null;
|
|
|
|
function sendBarcodeGameChat(text) {
|
|
const message = String(text || '').trim();
|
|
if (!message) return null;
|
|
// Barcode game lifecycle messages use the same internal chat path as the
|
|
// Overseer bot. Keeping this as a local helper makes the transition code
|
|
// explicit while preserving the chat service's normal bot/profile handling.
|
|
return sendSystemMessage(message, {
|
|
nickname: botName,
|
|
bot: true,
|
|
profileImage: botProfileImageUrl,
|
|
});
|
|
}
|
|
|
|
function formatList(items = [], limit = 5) {
|
|
// Chat messages should stay readable in the normal feed. This helper keeps
|
|
// every lifecycle message to a short list while still making it obvious when
|
|
// more players or stats existed than could fit comfortably in one message.
|
|
const values = items
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean);
|
|
if (!values.length) return '';
|
|
|
|
const visible = values.slice(0, limit);
|
|
const extraCount = values.length - visible.length;
|
|
if (extraCount === 1) {
|
|
// A single hidden item reads badly as "and 1 more" in chat, especially for
|
|
// result summaries where the omitted item is usually just one normal stat.
|
|
// Including it is still short and avoids making the bot sound confused.
|
|
return values.slice(0, limit + 1).join(', ');
|
|
}
|
|
const suffix = extraCount > 1 ? `, plus ${extraCount} more` : '';
|
|
return `${visible.join(', ')}${suffix}`;
|
|
}
|
|
|
|
function getParticipantName(participant = {}) {
|
|
return participant.nickname || participant.roverId || participant.participantKey || '';
|
|
}
|
|
|
|
function formatParticipantSummary(participants = []) {
|
|
const names = formatList(participants.map(getParticipantName));
|
|
return names || 'no counted rovers yet';
|
|
}
|
|
|
|
function formatVoteSummary(votes = {}, selectedGameId = null) {
|
|
const counts = countVotes(votes);
|
|
const selectedVotes = selectedGameId ? counts[selectedGameId] || 0 : 0;
|
|
const totalVotes = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
|
|
// The selected vote count matters more than a full per-game breakdown in
|
|
// chat. The detailed vote buttons still show every game, while the bot gives
|
|
// enough context to explain why this specific game moved to joining.
|
|
if (!totalVotes) return 'No votes were counted';
|
|
if (selectedVotes === totalVotes) {
|
|
return `${selectedVotes} ${selectedVotes === 1 ? 'vote' : 'votes'}`;
|
|
}
|
|
return `${selectedVotes} of ${totalVotes} ${totalVotes === 1 ? 'vote' : 'votes'}`;
|
|
}
|
|
|
|
function normalizeAwardSummaries(awards = []) {
|
|
const totalsByPlayer = {};
|
|
|
|
awards.forEach((award) => {
|
|
const playerKey = award?.playerKey;
|
|
const points = Number.isFinite(award?.points) ? Math.max(0, Math.floor(award.points)) : 0;
|
|
// applyPointAwards uses this same identity-only rule. Repeating it here
|
|
// prevents the bot from claiming that a rover-only participant earned
|
|
// leaderboard points when the persistent ledger intentionally ignored it.
|
|
if (!playerKey || !String(playerKey).startsWith('identity:') || !points) return;
|
|
const previous = totalsByPlayer[playerKey] || {};
|
|
totalsByPlayer[playerKey] = {
|
|
playerKey,
|
|
nickname: award.nickname || previous.nickname || award.roverId || 'unknown player',
|
|
roverId: award.roverId || previous.roverId || null,
|
|
points: (Number.isFinite(previous.points) ? previous.points : 0) + points,
|
|
};
|
|
});
|
|
|
|
return Object.values(totalsByPlayer).sort((a, b) => (b.points || 0) - (a.points || 0));
|
|
}
|
|
|
|
function formatAwardSummary(awards = []) {
|
|
const summaries = normalizeAwardSummaries(awards);
|
|
if (!summaries.length) return '';
|
|
|
|
// Award packets are the most authoritative source for point changes because
|
|
// they are what the shared ledger actually applies. The formatter groups
|
|
// multiple awards per player so chat does not spam one line per scan.
|
|
return formatList(
|
|
summaries.map((award) => `${award.nickname || award.roverId} scored ${award.points} ${award.points === 1 ? 'point' : 'points'}`),
|
|
5,
|
|
);
|
|
}
|
|
|
|
function formatPublicScoreSummary(publicState = {}) {
|
|
const scoreEntries = Array.isArray(publicState?.scores)
|
|
? publicState.scores
|
|
: Array.isArray(publicState?.finalResult?.participants)
|
|
? publicState.finalResult.participants
|
|
: [];
|
|
|
|
const summaries = scoreEntries
|
|
.map((entry) => {
|
|
// Public game state is less authoritative than award packets, but it is
|
|
// still valuable for games that award throughout the round. The formatter
|
|
// accepts both point-shaped and scan-count-shaped entries so game modules
|
|
// can expose natural result data without chat-specific contracts.
|
|
const points = Number.isFinite(entry?.points) ? Math.max(0, Math.floor(entry.points)) : null;
|
|
const scanCount = Number.isFinite(entry?.scanCount) ? Math.max(0, Math.floor(entry.scanCount)) : null;
|
|
const name = entry?.nickname || entry?.roverId || entry?.playerKey || '';
|
|
|
|
if (!name) return null;
|
|
if (points !== null && points > 0) {
|
|
return `${name} scored ${points} ${points === 1 ? 'point' : 'points'}`;
|
|
}
|
|
if (scanCount !== null && scanCount > 0) {
|
|
return `${name} made ${scanCount} ${scanCount === 1 ? 'scan' : 'scans'}`;
|
|
}
|
|
return null;
|
|
})
|
|
.filter(Boolean);
|
|
|
|
// Some games, like scan quest, award during the round instead of returning a
|
|
// final award packet. Their public state still carries round scores, so this
|
|
// gives the bot a useful end summary without making each game hand-write chat.
|
|
return formatList(summaries, 5);
|
|
}
|
|
|
|
function formatResultSummary(display = {}) {
|
|
const primary = String(display?.primary || '').trim();
|
|
const usefulStats = Array.isArray(display?.stats)
|
|
? display.stats
|
|
.map((stat) => {
|
|
const label = String(stat?.label || '').trim();
|
|
const value = String(stat?.value ?? '').trim();
|
|
if (!label || !value) return '';
|
|
return `${label}: ${value}`;
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
// Result summaries are already compact because games normally publish one
|
|
// primary line and a few stats. Joining them all avoids vague bot messages
|
|
// like "and 1 more" while still leaving the heavier participant lists to the
|
|
// bounded formatList helper above.
|
|
return [primary, ...usefulStats].filter(Boolean).join('. ');
|
|
}
|
|
|
|
function sendGameStartChat(definition, participants = []) {
|
|
const title = definition?.title || 'Barcode game';
|
|
const participantSummary = formatParticipantSummary(participants);
|
|
sendBarcodeGameChat(`${title} has started with ${participantSummary}.`);
|
|
}
|
|
|
|
function sendGameEndChat(definition, { display = null, publicState = null, awards = [], participants = [] } = {}) {
|
|
const title = definition?.title || 'Barcode game';
|
|
const awardSummary = formatAwardSummary(awards) || formatPublicScoreSummary(publicState);
|
|
const resultSummary = formatResultSummary(display || publicState?.display || {});
|
|
const participantSummary = formatParticipantSummary(participants);
|
|
const details = [
|
|
resultSummary,
|
|
awardSummary || `Played by ${participantSummary}`,
|
|
].filter(Boolean);
|
|
|
|
// End messages intentionally combine game-provided results with shared ledger
|
|
// awards. That keeps the bot useful for both end-scored games and games that
|
|
// score during play while still avoiding game-specific chat code.
|
|
sendBarcodeGameChat(`${title} ended. ${details.join(' ') || 'Results are now showing.'}`);
|
|
}
|
|
|
|
function getGameDefinition(gameId) {
|
|
return GAMES_BY_ID[String(gameId || '')] || null;
|
|
}
|
|
|
|
function normalizeRgbChannel(value) {
|
|
if (!Number.isFinite(value)) return null;
|
|
return Math.max(0, Math.min(255, Math.round(value)));
|
|
}
|
|
|
|
function getGameThemeColor(definition) {
|
|
const raw = definition?.themeColor;
|
|
const r = normalizeRgbChannel(raw?.r);
|
|
const g = normalizeRgbChannel(raw?.g);
|
|
const b = normalizeRgbChannel(raw?.b);
|
|
|
|
// Games own their visual identity, but the socket payload should always be a
|
|
// small safe RGB object. Invalid or missing values fall back to neutral text
|
|
// colors in the browser instead of leaking arbitrary styling data into React.
|
|
if (r === null || g === null || b === null) return null;
|
|
return { r, g, b };
|
|
}
|
|
|
|
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.userId) return `identity:${identity.userId}`;
|
|
if (socketId) return `socket:${socketId}`;
|
|
if (roverId) return `rover:${roverId}`;
|
|
return null;
|
|
}
|
|
|
|
function normalizeIdentityPlayerKey(identity = {}) {
|
|
// Permanent scoring is identity-only. Socket and rover IDs are useful runtime
|
|
// evidence, but they are unstable and should not create leaderboard entries.
|
|
return identity.userId ? `identity:${identity.userId}` : 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 = normalizeIdentityPlayerKey(identity);
|
|
|
|
return {
|
|
// participantKey is the runtime round key. It can fall back to the rover ID
|
|
// so the UI can show that a physical rover joined even if the driver has no
|
|
// verified identity yet. playerKey stays identity-only because persistent
|
|
// scoring should not create separate leaderboard rows for sockets or rovers.
|
|
participantKey: playerKey || `rover:${normalizedRoverId}`,
|
|
playerKey,
|
|
roverId: normalizedRoverId,
|
|
socketId,
|
|
userId: identity.userId || null,
|
|
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?.participantKey) return null;
|
|
|
|
draft.recentRoverSightings = {
|
|
...(draft.recentRoverSightings || {}),
|
|
[participant.roverId]: {
|
|
...participant,
|
|
scannedAt: now,
|
|
},
|
|
};
|
|
return participant;
|
|
}
|
|
|
|
function recordRoundParticipant(draft, participant, now) {
|
|
if (!participant?.participantKey) return null;
|
|
const previous = draft.roundParticipants?.[participant.participantKey] || {};
|
|
|
|
draft.roundParticipants = {
|
|
...(draft.roundParticipants || {}),
|
|
[participant.participantKey]: {
|
|
participantKey: participant.participantKey,
|
|
playerKey: participant.playerKey || previous.playerKey || null,
|
|
roverId: participant.roverId || previous.roverId || null,
|
|
socketId: participant.socketId || previous.socketId || null,
|
|
userId: participant.userId || previous.userId || null,
|
|
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
|
nickname: participant.nickname || previous.nickname || participant.roverId || 'unknown player',
|
|
joinedAt: Number.isFinite(previous.joinedAt) ? previous.joinedAt : now,
|
|
lastSeenAt: now,
|
|
scanCount: (Number.isFinite(previous.scanCount) ? previous.scanCount : 0) + 1,
|
|
},
|
|
};
|
|
|
|
return draft.roundParticipants[participant.participantKey];
|
|
}
|
|
|
|
function getRoundParticipants(draft) {
|
|
return Object.values(draft.roundParticipants || {})
|
|
.filter((participant) => participant?.participantKey)
|
|
.sort((a, b) => (a.joinedAt || 0) - (b.joinedAt || 0))
|
|
.map((participant) => ({
|
|
participantKey: participant.participantKey,
|
|
playerKey: participant.playerKey || null,
|
|
roverId: participant.roverId || null,
|
|
socketId: participant.socketId || null,
|
|
userId: participant.userId || null,
|
|
cookieUserId: participant.cookieUserId || null,
|
|
nickname: participant.nickname || participant.roverId || 'unknown player',
|
|
joinedAt: Number.isFinite(participant.joinedAt) ? participant.joinedAt : null,
|
|
lastSeenAt: Number.isFinite(participant.lastSeenAt) ? participant.lastSeenAt : null,
|
|
scanCount: Number.isFinite(participant.scanCount) ? participant.scanCount : 0,
|
|
}));
|
|
}
|
|
|
|
function getProximityParticipants(draft, now) {
|
|
pruneRecentRoverSightings(draft, now);
|
|
return Object.values(draft.recentRoverSightings || {})
|
|
.filter((sighting) => sighting?.participantKey && now - sighting.scannedAt <= ROVER_ATTRIBUTION_WINDOW_MS)
|
|
.map((sighting) => ({
|
|
participantKey: sighting.participantKey,
|
|
playerKey: sighting.playerKey,
|
|
roverId: sighting.roverId,
|
|
socketId: sighting.socketId || null,
|
|
userId: sighting.userId || 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 || !String(participant.playerKey).startsWith('identity:')) return;
|
|
const previous = draft.players[participant.playerKey] || {};
|
|
const previousGames = previous.games || {};
|
|
const previousGame = previousGames[gameId] || {};
|
|
draft.players[participant.playerKey] = {
|
|
playerKey: participant.playerKey,
|
|
userId: participant.userId || previous.userId || null,
|
|
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
|
nickname: participant.nickname || previous.nickname || null,
|
|
lastRoverId: participant.roverId || previous.lastRoverId || null,
|
|
totalPoints: Number.isFinite(previous.totalPoints) ? previous.totalPoints : 0,
|
|
lastSeenAt: now,
|
|
games: {
|
|
...previousGames,
|
|
[gameId]: {
|
|
gameId,
|
|
scanCount: (Number.isFinite(previousGame.scanCount) ? previousGame.scanCount : 0) + 1,
|
|
lastPlayedAt: now,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
function userIdFromPlayerKey(playerKey) {
|
|
const value = String(playerKey || '').trim();
|
|
return value.startsWith('identity:usr_') ? value.slice('identity:'.length) : null;
|
|
}
|
|
|
|
function applyPointAwards(draft, gameId, awards = [], now) {
|
|
if (!gameId || !Array.isArray(awards) || !awards.length) return;
|
|
draft.players = draft.players || {};
|
|
|
|
awards.forEach((award) => {
|
|
const playerKey = award?.playerKey;
|
|
const points = Number.isFinite(award?.points) ? Math.max(0, Math.floor(award.points)) : 0;
|
|
if (!playerKey || !String(playerKey).startsWith('identity:') || !points) return;
|
|
|
|
const previous = draft.players[playerKey] || {};
|
|
const previousGames = previous.games || {};
|
|
const previousGame = previousGames[gameId] || {};
|
|
|
|
// Global points are applied only here so individual game files cannot drift
|
|
// into different player-ledger formats. A game simply returns awards, and
|
|
// the shared service records identity, total points, and per-game totals in
|
|
// one persistent place.
|
|
draft.players[playerKey] = {
|
|
playerKey,
|
|
userId: award.userId || previous.userId || userIdFromPlayerKey(playerKey),
|
|
cookieUserId: award.cookieUserId || previous.cookieUserId || null,
|
|
nickname: award.nickname || previous.nickname || null,
|
|
lastRoverId: award.roverId || previous.lastRoverId || null,
|
|
totalPoints: (Number.isFinite(previous.totalPoints) ? previous.totalPoints : 0) + points,
|
|
lastSeenAt: now,
|
|
games: {
|
|
...previousGames,
|
|
[gameId]: {
|
|
...previousGame,
|
|
gameId,
|
|
points: (Number.isFinite(previousGame.points) ? previousGame.points : 0) + points,
|
|
awards: (Number.isFinite(previousGame.awards) ? previousGame.awards : 0) + 1,
|
|
lastAwardAt: now,
|
|
lastReason: award.reason || null,
|
|
},
|
|
},
|
|
};
|
|
|
|
const userId = draft.players[playerKey].userId;
|
|
if (userId) {
|
|
/*
|
|
The game store still owns current game state, but long-lived per-person
|
|
scoring is mirrored into identityService so future features can read one
|
|
canonical user object instead of scraping barcode-games.json.
|
|
*/
|
|
updateFeatureState(userId, 'barcodeGames', (current) => {
|
|
const currentGames = current?.games || {};
|
|
const currentGame = currentGames[gameId] || {};
|
|
return {
|
|
...(current || {}),
|
|
playerKeys: Array.from(new Set([...(current?.playerKeys || []), playerKey])),
|
|
nickname: draft.players[playerKey].nickname || current?.nickname || null,
|
|
lastRoverId: draft.players[playerKey].lastRoverId || current?.lastRoverId || null,
|
|
totalPoints: (Number.isFinite(current?.totalPoints) ? current.totalPoints : 0) + points,
|
|
lastSeenAt: now,
|
|
games: {
|
|
...currentGames,
|
|
[gameId]: {
|
|
...currentGame,
|
|
gameId,
|
|
points: (Number.isFinite(currentGame.points) ? currentGame.points : 0) + points,
|
|
awards: (Number.isFinite(currentGame.awards) ? currentGame.awards : 0) + 1,
|
|
lastAwardAt: now,
|
|
lastReason: award.reason || null,
|
|
},
|
|
},
|
|
};
|
|
}, {});
|
|
}
|
|
|
|
addRecentEvent(draft, {
|
|
kind: 'pointsAwarded',
|
|
gameId,
|
|
playerKey,
|
|
nickname: award.nickname || previous.nickname || null,
|
|
points,
|
|
reason: award.reason || null,
|
|
});
|
|
});
|
|
}
|
|
|
|
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 || getRoundParticipants(draft),
|
|
};
|
|
}
|
|
|
|
function startGame(draft, gameId, now) {
|
|
const definition = getGameDefinition(gameId);
|
|
if (!definition) return false;
|
|
const currentState = ensureGameState(draft, gameId);
|
|
const nextState = definition.start
|
|
? definition.start(currentState, buildGameContext(draft, { now }))
|
|
: definition.activate
|
|
? definition.activate(currentState, buildGameContext(draft, { now }))
|
|
: currentState;
|
|
draft.games[gameId] = nextState;
|
|
draft.phase = 'running';
|
|
draft.runningGameId = gameId;
|
|
draft.selectedGameId = gameId;
|
|
draft.activeGameId = gameId;
|
|
draft.voteEndsAt = null;
|
|
draft.joinEndsAt = null;
|
|
draft.startsAt = null;
|
|
draft.resultsUntil = null;
|
|
draft.resultGameId = null;
|
|
draft.resultDisplay = null;
|
|
addRecentEvent(draft, {
|
|
kind: 'gameStarted',
|
|
gameId,
|
|
title: definition.title,
|
|
});
|
|
sendGameStartChat(definition, getRoundParticipants(draft));
|
|
return true;
|
|
}
|
|
|
|
function normalizeGameResult(result, fallbackState) {
|
|
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
return { state: fallbackState, awards: [] };
|
|
}
|
|
if (Object.prototype.hasOwnProperty.call(result, 'state')) {
|
|
return {
|
|
state: result.state,
|
|
awards: Array.isArray(result.awards) ? result.awards : [],
|
|
done: Boolean(result.done),
|
|
display: result.display && typeof result.display === 'object' ? result.display : null,
|
|
};
|
|
}
|
|
return { state: result, awards: [], done: false, display: null };
|
|
}
|
|
|
|
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 selectedCount = draft.selectedGameId ? counts[draft.selectedGameId] || 0 : 0;
|
|
|
|
// Ties keep the currently selected pending game so a single equalizing vote
|
|
// does not make the room display flicker during the voting window.
|
|
if (draft.selectedGameId && selectedCount === topCount) return draft.selectedGameId;
|
|
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);
|
|
const current = loadStore();
|
|
if (current.phase === 'joining' || current.phase === 'starting' || current.phase === 'running') {
|
|
return { error: 'a barcode game is already starting or running' };
|
|
}
|
|
|
|
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) || definition.id;
|
|
draft.selectedGameId = winner;
|
|
if (draft.phase === 'idle' || draft.phase === 'results') {
|
|
draft.phase = 'voting';
|
|
draft.voteEndsAt = now + VOTING_WINDOW_MS;
|
|
draft.joinEndsAt = null;
|
|
draft.startsAt = null;
|
|
draft.runningGameId = null;
|
|
draft.activeGameId = null;
|
|
draft.resultsUntil = null;
|
|
draft.resultDisplay = null;
|
|
draft.roundParticipants = {};
|
|
sendBarcodeGameChat(`Voting has started. Current pick: ${definition.title}. Vote in the Activities tab.`);
|
|
} else if (draft.phase === 'voting') {
|
|
draft.voteEndsAt = Math.max(draft.voteEndsAt || 0, now + VOTING_WINDOW_MS);
|
|
}
|
|
});
|
|
|
|
broadcastState();
|
|
return { success: true, state: buildStatePayload(socket) };
|
|
}
|
|
|
|
function settleActiveGameIfNeeded() {
|
|
const store = loadStore();
|
|
const now = Date.now();
|
|
const phase = store.phase || 'idle';
|
|
|
|
if (phase === 'voting' && store.voteEndsAt && now >= store.voteEndsAt) {
|
|
const winner = chooseVoteWinner(store) || store.selectedGameId;
|
|
const winnerDefinition = getGameDefinition(winner);
|
|
if (!winnerDefinition) return false;
|
|
withGameStore((draft) => {
|
|
draft.selectedGameId = winner;
|
|
draft.phase = 'joining';
|
|
draft.joinEndsAt = now + JOIN_WINDOW_MS;
|
|
draft.startsAt = null;
|
|
draft.voteEndsAt = null;
|
|
draft.roundParticipants = {};
|
|
addRecentEvent(draft, {
|
|
kind: 'gameJoining',
|
|
gameId: winner,
|
|
title: winnerDefinition.title,
|
|
});
|
|
sendBarcodeGameChat(`Voting ended. ${winnerDefinition.title} was selected with ${formatVoteSummary(draft.votes, winner)}. Scan a rover to join.`);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (phase === 'joining' && store.joinEndsAt && now >= store.joinEndsAt) {
|
|
withGameStore((draft) => {
|
|
// Joining exists so a real rover has to physically opt into a round. If
|
|
// nobody scans a rover in time, the selected game is discarded and the
|
|
// system returns to idle instead of starting an empty round that cannot
|
|
// award points to anyone.
|
|
draft.phase = 'idle';
|
|
draft.selectedGameId = null;
|
|
draft.runningGameId = null;
|
|
draft.activeGameId = null;
|
|
draft.voteEndsAt = null;
|
|
draft.joinEndsAt = null;
|
|
draft.startsAt = null;
|
|
draft.resultsUntil = null;
|
|
draft.resultGameId = null;
|
|
draft.resultDisplay = null;
|
|
draft.votes = {};
|
|
draft.roundParticipants = {};
|
|
sendBarcodeGameChat('No rover joined in time. The selected barcode game was cancelled.');
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (phase === 'starting' && store.startsAt && now >= store.startsAt) {
|
|
const gameId = store.selectedGameId;
|
|
if (!getGameDefinition(gameId)) return false;
|
|
withGameStore((draft) => {
|
|
startGame(draft, gameId, now);
|
|
});
|
|
return true;
|
|
}
|
|
|
|
if (phase === 'results' && store.resultsUntil && now >= store.resultsUntil) {
|
|
withGameStore((draft) => {
|
|
draft.phase = 'idle';
|
|
draft.selectedGameId = null;
|
|
draft.runningGameId = null;
|
|
draft.activeGameId = null;
|
|
draft.voteEndsAt = null;
|
|
draft.joinEndsAt = null;
|
|
draft.startsAt = null;
|
|
draft.resultsUntil = null;
|
|
draft.resultGameId = null;
|
|
draft.resultDisplay = null;
|
|
draft.votes = {};
|
|
draft.roundParticipants = {};
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const activeGameId = store.phase === 'running' ? store.runningGameId : null;
|
|
const definition = getGameDefinition(activeGameId);
|
|
const currentState = activeGameId ? store.games?.[activeGameId] : null;
|
|
if (!definition?.tick || !currentState) return false;
|
|
|
|
const normalizedState = ensureReadonlyGameState(store, activeGameId);
|
|
const gameResult = definition.tick(normalizedState, buildGameContext(store, { now }));
|
|
const { state: nextState, awards, done, display } = normalizeGameResult(gameResult, normalizedState);
|
|
if (JSON.stringify(nextState) === JSON.stringify(normalizedState) && !done) return false;
|
|
|
|
withGameStore((draft) => {
|
|
draft.games[activeGameId] = nextState;
|
|
applyPointAwards(draft, activeGameId, awards, now);
|
|
if (done) {
|
|
const publicState = definition.getPublicState
|
|
? definition.getPublicState(nextState, buildGameContext(draft, { now }))
|
|
: null;
|
|
const participants = getRoundParticipants(draft);
|
|
draft.phase = 'results';
|
|
draft.resultGameId = activeGameId;
|
|
draft.resultDisplay = display || publicState?.display || null;
|
|
draft.resultsUntil = now + RESULTS_WINDOW_MS;
|
|
draft.runningGameId = null;
|
|
draft.activeGameId = null;
|
|
draft.startsAt = null;
|
|
draft.voteEndsAt = null;
|
|
draft.joinEndsAt = null;
|
|
draft.votes = {};
|
|
sendGameEndChat(definition, {
|
|
display: draft.resultDisplay,
|
|
publicState,
|
|
awards,
|
|
participants,
|
|
});
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function handleScan(scan) {
|
|
const now = Number.isFinite(scan?.scannedAt) ? scan.scannedAt : Date.now();
|
|
withGameStore((draft) => {
|
|
updateGlobalCounters(draft, scan, now);
|
|
const scannedRoverParticipant = recordRoverSighting(draft, scan, now);
|
|
|
|
if (draft.phase === 'joining' && scannedRoverParticipant) {
|
|
const selectedDefinition = getGameDefinition(draft.selectedGameId);
|
|
const joinedParticipant = recordRoundParticipant(draft, scannedRoverParticipant, now);
|
|
|
|
if (selectedDefinition && joinedParticipant) {
|
|
// The first rover scan is the physical confirmation that a real player
|
|
// is at the scanner and wants this voted game to start. The short
|
|
// starting phase gives the room and chat display a predictable countdown
|
|
// before game rules begin consuming object scans.
|
|
draft.phase = 'starting';
|
|
draft.startsAt = now + STARTING_WINDOW_MS;
|
|
draft.joinEndsAt = null;
|
|
addRecentEvent(draft, {
|
|
kind: 'gameStarting',
|
|
gameId: selectedDefinition.id,
|
|
title: selectedDefinition.title,
|
|
participant: joinedParticipant.nickname,
|
|
});
|
|
}
|
|
} else if ((draft.phase === 'starting' || draft.phase === 'running') && scannedRoverParticipant) {
|
|
// Rovers scanned during the countdown or action are counted as active participants too.
|
|
// This supports the physical reality of the station: a rover may be seen
|
|
// just before or during useful object scans, and that should be enough to
|
|
// associate the driver with the current round.
|
|
recordRoundParticipant(draft, scannedRoverParticipant, now);
|
|
}
|
|
|
|
const proximityParticipants = getProximityParticipants(draft, now);
|
|
const participants = getRoundParticipants(draft);
|
|
const activeGameId = draft.phase === 'running' ? draft.runningGameId : null;
|
|
const definition = getGameDefinition(activeGameId);
|
|
|
|
if (definition) {
|
|
const currentState = ensureGameState(draft, activeGameId);
|
|
const gameResult = definition.handleScan
|
|
? definition.handleScan(currentState, scan, buildGameContext(draft, { now, participants }))
|
|
: currentState;
|
|
const { state: nextState, awards, done, display } = normalizeGameResult(gameResult, currentState);
|
|
draft.games[activeGameId] = nextState;
|
|
recordPlayerParticipation(draft, activeGameId, participants, now);
|
|
applyPointAwards(draft, activeGameId, awards, now);
|
|
if (done) {
|
|
const publicState = definition.getPublicState
|
|
? definition.getPublicState(nextState, buildGameContext(draft, { now }))
|
|
: null;
|
|
const finalParticipants = getRoundParticipants(draft);
|
|
draft.phase = 'results';
|
|
draft.resultGameId = activeGameId;
|
|
draft.resultDisplay = display || publicState?.display || null;
|
|
draft.resultsUntil = now + RESULTS_WINDOW_MS;
|
|
draft.runningGameId = null;
|
|
draft.activeGameId = null;
|
|
draft.startsAt = null;
|
|
draft.voteEndsAt = null;
|
|
draft.joinEndsAt = null;
|
|
draft.votes = {};
|
|
sendGameEndChat(definition, {
|
|
display: draft.resultDisplay,
|
|
publicState,
|
|
awards,
|
|
participants: finalParticipants,
|
|
});
|
|
}
|
|
}
|
|
|
|
addRecentEvent(draft, {
|
|
kind: 'scan',
|
|
code: scan?.code || '',
|
|
label: scan?.label || scan?.code || 'unknown',
|
|
known: Boolean(scan?.known),
|
|
type: scan?.type || null,
|
|
participants: (participants.length ? participants : proximityParticipants)
|
|
.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 getPlayerForSocket(store, socket) {
|
|
if (!socket) return null;
|
|
const identity = getIdentitySummary(socket);
|
|
const playerKey = normalizeIdentityPlayerKey(identity);
|
|
const featurePlayer = identity.userId ? getFeatureState(identity.userId, 'barcodeGames', null) : null;
|
|
const player = playerKey ? store.players?.[playerKey] || null : null;
|
|
if (!player && !featurePlayer) {
|
|
return {
|
|
playerKey,
|
|
nickname: identity.nickname || null,
|
|
totalPoints: 0,
|
|
rank: null,
|
|
games: {},
|
|
};
|
|
}
|
|
const rankedPlayers = listBarcodePlayers(store.players)
|
|
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0));
|
|
const effectivePlayer = {
|
|
...(player || {}),
|
|
...(featurePlayer || {}),
|
|
playerKey: player?.playerKey || playerKey || featurePlayer?.playerKeys?.[0],
|
|
nickname: player?.nickname || featurePlayer?.nickname || identity.nickname || null,
|
|
totalPoints: Math.max(Number(player?.totalPoints || 0), Number(featurePlayer?.totalPoints || 0)),
|
|
games: {
|
|
...(featurePlayer?.games || {}),
|
|
...(player?.games || {}),
|
|
},
|
|
};
|
|
const rank = rankedPlayers.findIndex((entry) => entry.playerKey === effectivePlayer.playerKey) + 1;
|
|
return {
|
|
playerKey: effectivePlayer.playerKey,
|
|
nickname: effectivePlayer.nickname,
|
|
totalPoints: effectivePlayer.totalPoints || 0,
|
|
rank: rank > 0 ? rank : null,
|
|
games: effectivePlayer.games || {},
|
|
};
|
|
}
|
|
|
|
function buildStatePayload(socket = null) {
|
|
settleActiveGameIfNeeded();
|
|
const store = loadStore();
|
|
const now = Date.now();
|
|
const voteCounts = countVotes(store.votes);
|
|
const selectedDefinition = getGameDefinition(store.selectedGameId);
|
|
const runningDefinition = getGameDefinition(store.runningGameId);
|
|
const participants = getRoundParticipants(store);
|
|
const context = buildGameContext(store, { now, participants });
|
|
const runningGame = runningDefinition
|
|
? runningDefinition.getPublicState(ensureReadonlyGameState(store, runningDefinition.id), context)
|
|
: null;
|
|
const activeGame = buildLifecycleGameState(store, {
|
|
now,
|
|
selectedDefinition,
|
|
runningDefinition,
|
|
runningGame,
|
|
voteCounts,
|
|
});
|
|
const leaderboard = topPlayers(store.players);
|
|
|
|
return {
|
|
phase: store.phase,
|
|
selectedGameId: store.selectedGameId,
|
|
runningGameId: store.runningGameId,
|
|
activeGameId: store.runningGameId,
|
|
participants,
|
|
games: GAME_DEFINITIONS.map((game) => ({
|
|
id: game.id,
|
|
title: game.title,
|
|
description: game.description,
|
|
themeColor: getGameThemeColor(game),
|
|
voteCount: voteCounts[game.id] || 0,
|
|
active: game.id === store.runningGameId,
|
|
selected: game.id === store.selectedGameId,
|
|
actionLabel:
|
|
store.phase === 'idle' || store.phase === 'results'
|
|
? 'Vote'
|
|
: game.id === store.selectedGameId
|
|
? 'Selected'
|
|
: 'Vote',
|
|
})),
|
|
activeGame,
|
|
leaderboard,
|
|
ownPlayer: getPlayerForSocket(store, socket),
|
|
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 buildLifecycleGameState(store, { now, selectedDefinition, runningDefinition, runningGame, voteCounts }) {
|
|
const participants = getRoundParticipants(store);
|
|
if (store.phase === 'running' && runningGame) {
|
|
return {
|
|
...runningGame,
|
|
themeColor: getGameThemeColor(runningDefinition),
|
|
participants,
|
|
};
|
|
}
|
|
if (store.phase === 'results') {
|
|
const resultDefinition = getGameDefinition(store.resultGameId);
|
|
return {
|
|
id: store.resultGameId,
|
|
title: resultDefinition?.title || 'Results',
|
|
themeColor: getGameThemeColor(resultDefinition),
|
|
status: 'results',
|
|
participants,
|
|
display: {
|
|
title: 'Results',
|
|
primary: store.resultDisplay?.primary || 'Round complete',
|
|
secondary: store.resultDisplay?.secondary || 'Vote to start another game',
|
|
timer: store.resultsUntil ? { label: 'Results clear in', endsAt: store.resultsUntil } : null,
|
|
stats: store.resultDisplay?.stats || [],
|
|
sections: store.resultDisplay?.sections || [],
|
|
results: store.resultDisplay?.results || [],
|
|
},
|
|
};
|
|
}
|
|
if (store.phase === 'starting') {
|
|
return {
|
|
id: store.selectedGameId,
|
|
title: selectedDefinition?.title || 'Starting',
|
|
themeColor: getGameThemeColor(selectedDefinition),
|
|
status: 'starting',
|
|
participants,
|
|
display: {
|
|
title: selectedDefinition?.title || 'Starting',
|
|
primary: selectedDefinition ? `${selectedDefinition.title} starts soon` : 'Game starts soon',
|
|
secondary: 'Get ready',
|
|
timer: store.startsAt ? { label: 'Starts in', endsAt: store.startsAt } : null,
|
|
stats: [],
|
|
sections: [
|
|
{
|
|
title: 'Players',
|
|
// Joining and starting are shared lifecycle states, so they expose
|
|
// their own display sections here instead of asking each game to
|
|
// explain the same startup flow.
|
|
items: participants.length
|
|
? participants.map((participant) => getParticipantName(participant)).filter(Boolean)
|
|
: ['waiting for counted rovers'],
|
|
},
|
|
],
|
|
results: [],
|
|
},
|
|
};
|
|
}
|
|
if (store.phase === 'joining') {
|
|
const selectedTitle = selectedDefinition?.title || 'the selected game';
|
|
return {
|
|
id: store.selectedGameId,
|
|
title: selectedDefinition?.title || 'Join game',
|
|
themeColor: getGameThemeColor(selectedDefinition),
|
|
status: 'joining',
|
|
participants,
|
|
display: {
|
|
title: selectedDefinition?.title || 'Join game',
|
|
primary: 'Scan your rover to start',
|
|
secondary: `${selectedTitle} needs at least one rover`,
|
|
timer: store.joinEndsAt ? { label: 'Join by', endsAt: store.joinEndsAt } : null,
|
|
stats: [],
|
|
sections: [
|
|
{
|
|
title: 'How to join',
|
|
items: ['scan your rover barcode at the station'],
|
|
},
|
|
],
|
|
results: [],
|
|
},
|
|
};
|
|
}
|
|
if (store.phase === 'voting') {
|
|
const selectedTitle = selectedDefinition?.title || 'a barcode game';
|
|
return {
|
|
id: store.selectedGameId,
|
|
title: 'Voting',
|
|
themeColor: getGameThemeColor(selectedDefinition),
|
|
status: 'voting',
|
|
participants,
|
|
display: {
|
|
title: 'Voting',
|
|
primary: `Voting for ${selectedTitle}`,
|
|
secondary: 'Most votes starts the next game',
|
|
timer: store.voteEndsAt ? { label: 'Voting ends in', endsAt: store.voteEndsAt } : null,
|
|
stats: GAME_DEFINITIONS.map((game) => ({
|
|
label: game.title,
|
|
value: voteCounts[game.id] || 0,
|
|
})),
|
|
sections: [],
|
|
results: [],
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
id: null,
|
|
title: 'Barcode games',
|
|
themeColor: null,
|
|
status: 'idle',
|
|
participants,
|
|
display: {
|
|
title: 'Barcode games',
|
|
primary: 'Choose a game',
|
|
secondary: 'Vote to start the next round',
|
|
timer: null,
|
|
stats: [],
|
|
sections: [],
|
|
results: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function listBarcodePlayers(players = {}) {
|
|
const byKey = new Map();
|
|
Object.values(players || {})
|
|
.filter((player) => String(player?.playerKey || '').startsWith('identity:') && Number.isFinite(player?.totalPoints) && player.totalPoints > 0)
|
|
.forEach((player) => byKey.set(player.playerKey, player));
|
|
|
|
listFeatureStates('barcodeGames').forEach(({ userId, state }) => {
|
|
const playerKey = `identity:${userId}`;
|
|
const previous = byKey.get(playerKey) || {};
|
|
byKey.set(playerKey, {
|
|
...previous,
|
|
...state,
|
|
playerKey,
|
|
userId,
|
|
totalPoints: Math.max(Number(previous.totalPoints || 0), Number(state?.totalPoints || 0)),
|
|
nickname: previous.nickname || state?.nickname || null,
|
|
lastRoverId: previous.lastRoverId || state?.lastRoverId || null,
|
|
});
|
|
});
|
|
|
|
return Array.from(byKey.values()).filter((player) => Number.isFinite(player?.totalPoints) && player.totalPoints > 0);
|
|
}
|
|
|
|
function topPlayers(players = {}, limit = 6) {
|
|
return listBarcodePlayers(players)
|
|
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0))
|
|
.slice(0, limit)
|
|
.map((player) => ({
|
|
playerKey: player.playerKey,
|
|
nickname: player.nickname || player.lastRoverId || 'unknown player',
|
|
totalPoints: player.totalPoints || 0,
|
|
lastRoverId: player.lastRoverId || null,
|
|
}));
|
|
}
|
|
|
|
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() {
|
|
const room = io.sockets.adapter.rooms.get(GAME_SOCKET_ROOM);
|
|
if (!room) return;
|
|
room.forEach((socketId) => {
|
|
const socket = io.sockets.sockets.get(socketId);
|
|
if (socket) {
|
|
socket.emit('barcodeGame:state', buildStatePayload(socket));
|
|
}
|
|
});
|
|
}
|
|
|
|
if (enabled) {
|
|
/*
|
|
Barcode games are an optional layer on top of the physical scanner station.
|
|
The game's own switch controls whether its sockets and subscriptions exist.
|
|
Scanner availability is runtime state and must not silently override the
|
|
operator's explicit choice to enable the game service.
|
|
*/
|
|
io.on('connection', (socket) => {
|
|
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
|
|
socket.join(GAME_SOCKET_ROOM);
|
|
const state = buildStatePayload(socket);
|
|
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 });
|
|
}
|
|
});
|
|
} else {
|
|
logger.info('Barcode games disabled by config');
|
|
}
|
|
|
|
module.exports = {
|
|
buildStatePayload,
|
|
handleScan,
|
|
setVote,
|
|
};
|
|
|
|
if (enabled) {
|
|
setInterval(() => {
|
|
if (settleActiveGameIfNeeded()) {
|
|
broadcastState();
|
|
}
|
|
}, GAME_TICK_MS).unref?.();
|
|
}
|