mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 09:31:20 -04:00
barcode games!
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user