mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
barcode games!
This commit is contained in:
@@ -41,6 +41,7 @@ require('./src/services/audioLevelsService');
|
|||||||
require('./src/services/audioForwardService');
|
require('./src/services/audioForwardService');
|
||||||
require('./src/services/buttonBoxService');
|
require('./src/services/buttonBoxService');
|
||||||
require('./src/services/barcodeScannerService');
|
require('./src/services/barcodeScannerService');
|
||||||
|
require('./src/services/barcodeGameService');
|
||||||
require('./src/services/kinectService');
|
require('./src/services/kinectService');
|
||||||
require('./src/services/sessionService');
|
require('./src/services/sessionService');
|
||||||
require('./src/services/batteryManager');
|
require('./src/services/batteryManager');
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -18,8 +18,8 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-D7L_pr9D.js"></script>
|
<script type="module" crossorigin src="/assets/index-BUN5_4YP.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CjmiVAqR.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DrszynNM.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Scan Quest Game
|
||||||
|
// Purpose: Implements the ordered object-scanning quest game.
|
||||||
|
// Scope: Keeps quest selection, scoring, and quest-specific display state out of
|
||||||
|
// the shared barcode game service.
|
||||||
|
|
||||||
|
const GAME_ID = 'scanQuest';
|
||||||
|
const QUEST_LENGTH_OPTIONS = [1, 2];
|
||||||
|
|
||||||
|
function createInitialState() {
|
||||||
|
return {
|
||||||
|
currentQuest: null,
|
||||||
|
progressIndex: 0,
|
||||||
|
scores: {},
|
||||||
|
completedQuests: 0,
|
||||||
|
recentEvents: [],
|
||||||
|
lastMessage: 'vote to start scan quest',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState(rawState = {}) {
|
||||||
|
const base = createInitialState();
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
currentQuest: rawState.currentQuest && typeof rawState.currentQuest === 'object' ? rawState.currentQuest : null,
|
||||||
|
progressIndex: Number.isFinite(rawState.progressIndex) ? Math.max(0, Math.floor(rawState.progressIndex)) : 0,
|
||||||
|
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
|
||||||
|
completedQuests: Number.isFinite(rawState.completedQuests) ? Math.max(0, Math.floor(rawState.completedQuests)) : 0,
|
||||||
|
recentEvents: Array.isArray(rawState.recentEvents) ? rawState.recentEvents.slice(-12) : [],
|
||||||
|
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickQuest(objects = []) {
|
||||||
|
const candidates = objects.filter((entry) => entry?.code && entry?.label);
|
||||||
|
if (!candidates.length) return null;
|
||||||
|
const length = QUEST_LENGTH_OPTIONS[Math.floor(Math.random() * QUEST_LENGTH_OPTIONS.length)];
|
||||||
|
const steps = [];
|
||||||
|
|
||||||
|
for (let idx = 0; idx < length; idx += 1) {
|
||||||
|
// Reusing objects is allowed because physical rooms often start with only a
|
||||||
|
// small number of labeled props. The game still stays readable because the
|
||||||
|
// sequence is short and order-specific.
|
||||||
|
const entry = candidates[Math.floor(Math.random() * candidates.length)];
|
||||||
|
steps.push({
|
||||||
|
code: entry.code,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
label: entry.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
steps,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQuestPrompt(state) {
|
||||||
|
const quest = state.currentQuest;
|
||||||
|
if (!quest?.steps?.length) return 'scan quest needs objects';
|
||||||
|
const step = quest.steps[state.progressIndex] || quest.steps[0];
|
||||||
|
if (quest.steps.length === 1) return `scan ${step.label}`;
|
||||||
|
return `scan ${step.label} ${state.progressIndex + 1} of ${quest.steps.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureQuest(state, context = {}) {
|
||||||
|
if (state.currentQuest?.steps?.length) return state;
|
||||||
|
const nextQuest = pickQuest(context.objects || []);
|
||||||
|
state.currentQuest = nextQuest;
|
||||||
|
state.progressIndex = 0;
|
||||||
|
state.lastMessage = nextQuest ? formatQuestPrompt(state) : 'scan quest needs objects';
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecentEvent(state, event) {
|
||||||
|
state.recentEvents = [
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
at: Date.now(),
|
||||||
|
},
|
||||||
|
...(Array.isArray(state.recentEvents) ? state.recentEvents : []),
|
||||||
|
].slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addScore(state, participants = [], points) {
|
||||||
|
participants.forEach((participant) => {
|
||||||
|
const key = participant?.playerKey;
|
||||||
|
if (!key) return;
|
||||||
|
const previous = state.scores[key] || {};
|
||||||
|
state.scores[key] = {
|
||||||
|
playerKey: key,
|
||||||
|
nickname: participant.nickname || previous.nickname || null,
|
||||||
|
roverId: participant.roverId || previous.roverId || null,
|
||||||
|
points: (Number.isFinite(previous.points) ? previous.points : 0) + points,
|
||||||
|
lastScoredAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActivated(rawState, context = {}) {
|
||||||
|
const state = normalizeState(rawState);
|
||||||
|
ensureQuest(state, context);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScan(rawState, scan, context = {}) {
|
||||||
|
const state = normalizeState(rawState);
|
||||||
|
ensureQuest(state, context);
|
||||||
|
|
||||||
|
if (!state.currentQuest?.steps?.length) return state;
|
||||||
|
if (!scan?.known || scan.type !== 'object') return state;
|
||||||
|
|
||||||
|
const expected = state.currentQuest.steps[state.progressIndex];
|
||||||
|
const matched = Boolean(expected && scan.code === expected.code);
|
||||||
|
|
||||||
|
if (!matched) {
|
||||||
|
// Wrong scans do not erase the whole quest. That keeps the game forgiving
|
||||||
|
// for rover driving mistakes while still making the ordered target clear on
|
||||||
|
// the large scanner display.
|
||||||
|
state.lastMessage = `try ${expected.label}`;
|
||||||
|
addRecentEvent(state, {
|
||||||
|
kind: 'miss',
|
||||||
|
label: scan.label,
|
||||||
|
expected: expected.label,
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.progressIndex += 1;
|
||||||
|
addRecentEvent(state, {
|
||||||
|
kind: 'hit',
|
||||||
|
label: scan.label,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.progressIndex < state.currentQuest.steps.length) {
|
||||||
|
state.lastMessage = formatQuestPrompt(state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const points = state.currentQuest.steps.length;
|
||||||
|
state.completedQuests += 1;
|
||||||
|
addScore(state, context.participants || [], points);
|
||||||
|
addRecentEvent(state, {
|
||||||
|
kind: 'complete',
|
||||||
|
points,
|
||||||
|
participants: (context.participants || []).map((participant) => participant.nickname || participant.roverId).filter(Boolean),
|
||||||
|
});
|
||||||
|
state.currentQuest = pickQuest(context.objects || []);
|
||||||
|
state.progressIndex = 0;
|
||||||
|
state.lastMessage = state.currentQuest ? `scored ${points}. ${formatQuestPrompt(state)}` : `scored ${points}`;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopScores(state) {
|
||||||
|
return Object.values(state.scores || {})
|
||||||
|
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
||||||
|
.slice(0, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicState(rawState, context = {}) {
|
||||||
|
const state = normalizeState(rawState);
|
||||||
|
ensureQuest(state, context);
|
||||||
|
return {
|
||||||
|
id: GAME_ID,
|
||||||
|
title: 'Scan quest',
|
||||||
|
status: state.currentQuest ? 'running' : 'needs_objects',
|
||||||
|
headline: state.lastMessage || formatQuestPrompt(state),
|
||||||
|
detail: state.currentQuest?.steps?.length
|
||||||
|
? state.currentQuest.steps.map((step) => step.label).join(' then ')
|
||||||
|
: 'add object barcodes to the registry',
|
||||||
|
progress: {
|
||||||
|
current: state.progressIndex,
|
||||||
|
total: state.currentQuest?.steps?.length || 0,
|
||||||
|
},
|
||||||
|
scores: getTopScores(state),
|
||||||
|
completedQuests: state.completedQuests,
|
||||||
|
recentEvents: state.recentEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: GAME_ID,
|
||||||
|
title: 'Scan quest',
|
||||||
|
description: 'Scan one or two requested objects in order.',
|
||||||
|
createInitialState,
|
||||||
|
normalizeState,
|
||||||
|
onActivated,
|
||||||
|
onScan,
|
||||||
|
getPublicState,
|
||||||
|
};
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Scans Per Second Game
|
||||||
|
// Purpose: Implements a timed five-minute scan-rate challenge.
|
||||||
|
// Scope: Counts every scan event for this game, including unknown and invalid
|
||||||
|
// codes, while preserving persistent round results and the world record.
|
||||||
|
|
||||||
|
const GAME_ID = 'scansPerSecond';
|
||||||
|
const ROUND_DURATION_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
function createInitialState() {
|
||||||
|
return {
|
||||||
|
status: 'idle',
|
||||||
|
roundId: null,
|
||||||
|
startedAt: null,
|
||||||
|
endsAt: null,
|
||||||
|
scans: [],
|
||||||
|
finalResult: null,
|
||||||
|
worldRecord: null,
|
||||||
|
recentRounds: [],
|
||||||
|
participantCounts: {},
|
||||||
|
lastMessage: 'vote to start scans per second',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeState(rawState = {}) {
|
||||||
|
const base = createInitialState();
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: rawState.status === 'running' || rawState.status === 'ended' ? rawState.status : 'idle',
|
||||||
|
roundId: typeof rawState.roundId === 'string' ? rawState.roundId : null,
|
||||||
|
startedAt: Number.isFinite(rawState.startedAt) ? rawState.startedAt : null,
|
||||||
|
endsAt: Number.isFinite(rawState.endsAt) ? rawState.endsAt : null,
|
||||||
|
scans: Array.isArray(rawState.scans) ? rawState.scans.filter((entry) => Number.isFinite(entry?.at)) : [],
|
||||||
|
finalResult: rawState.finalResult && typeof rawState.finalResult === 'object' ? rawState.finalResult : null,
|
||||||
|
worldRecord: rawState.worldRecord && typeof rawState.worldRecord === 'object' ? rawState.worldRecord : null,
|
||||||
|
recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [],
|
||||||
|
participantCounts:
|
||||||
|
rawState.participantCounts && typeof rawState.participantCounts === 'object' ? rawState.participantCounts : {},
|
||||||
|
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateRate(scanCount, startedAt, endedAt) {
|
||||||
|
if (!scanCount || !startedAt || !endedAt || endedAt <= startedAt) return 0;
|
||||||
|
return scanCount / ((endedAt - startedAt) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResult(state, endedAt = Date.now()) {
|
||||||
|
const scanCount = state.scans.length;
|
||||||
|
const rate = calculateRate(scanCount, state.startedAt, endedAt);
|
||||||
|
return {
|
||||||
|
roundId: state.roundId,
|
||||||
|
scanCount,
|
||||||
|
durationMs: Math.max(0, endedAt - (state.startedAt || endedAt)),
|
||||||
|
scansPerSecond: Number(rate.toFixed(3)),
|
||||||
|
startedAt: state.startedAt,
|
||||||
|
endedAt,
|
||||||
|
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishRound(state, endedAt = Date.now()) {
|
||||||
|
if (state.status !== 'running') return state;
|
||||||
|
const result = buildResult(state, endedAt);
|
||||||
|
const previousRecord = state.worldRecord;
|
||||||
|
const isWorldRecord = !previousRecord || result.scansPerSecond > (previousRecord.scansPerSecond || 0);
|
||||||
|
|
||||||
|
state.status = 'ended';
|
||||||
|
state.finalResult = {
|
||||||
|
...result,
|
||||||
|
isWorldRecord,
|
||||||
|
};
|
||||||
|
state.worldRecord = isWorldRecord ? result : previousRecord;
|
||||||
|
state.recentRounds = [state.finalResult, ...(state.recentRounds || [])].slice(0, 10);
|
||||||
|
state.lastMessage = isWorldRecord
|
||||||
|
? `new record ${result.scansPerSecond} scans per second`
|
||||||
|
: `finished ${result.scansPerSecond} scans per second`;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRound(rawState, now = Date.now()) {
|
||||||
|
const previous = normalizeState(rawState);
|
||||||
|
return {
|
||||||
|
...previous,
|
||||||
|
status: 'running',
|
||||||
|
roundId: `${now}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
startedAt: now,
|
||||||
|
endsAt: now + ROUND_DURATION_MS,
|
||||||
|
scans: [],
|
||||||
|
finalResult: null,
|
||||||
|
participantCounts: {},
|
||||||
|
lastMessage: 'scan anything',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onActivated(rawState, context = {}) {
|
||||||
|
const state = normalizeState(rawState);
|
||||||
|
const now = context.now || Date.now();
|
||||||
|
// Voting for an ended or idle challenge starts a fresh five-minute round. If
|
||||||
|
// the round is already running, activation is a no-op so vote churn does not
|
||||||
|
// accidentally reset an active challenge.
|
||||||
|
return state.status === 'running' ? state : startRound(state, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addParticipants(state, participants = []) {
|
||||||
|
participants.forEach((participant) => {
|
||||||
|
const key = participant?.playerKey;
|
||||||
|
if (!key) return;
|
||||||
|
const previous = state.participantCounts[key] || {};
|
||||||
|
state.participantCounts[key] = {
|
||||||
|
playerKey: key,
|
||||||
|
nickname: participant.nickname || previous.nickname || null,
|
||||||
|
roverId: participant.roverId || previous.roverId || null,
|
||||||
|
scanCount: (Number.isFinite(previous.scanCount) ? previous.scanCount : 0) + 1,
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScan(rawState, scan, context = {}) {
|
||||||
|
const now = context.now || Date.now();
|
||||||
|
let state = normalizeState(rawState);
|
||||||
|
if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
|
||||||
|
state = finishRound(state, state.endsAt);
|
||||||
|
}
|
||||||
|
if (state.status !== 'running') return state;
|
||||||
|
|
||||||
|
// This game deliberately counts every submitted scan, including unknown and
|
||||||
|
// invalid barcodes, because the challenge is about physically getting scans
|
||||||
|
// through the station rather than finding specific registry entries.
|
||||||
|
state.scans = [
|
||||||
|
...(state.scans || []),
|
||||||
|
{
|
||||||
|
code: scan?.code || '',
|
||||||
|
known: Boolean(scan?.known),
|
||||||
|
at: now,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
addParticipants(state, context.participants || []);
|
||||||
|
|
||||||
|
if (state.endsAt && now >= state.endsAt) {
|
||||||
|
return finishRound(state, state.endsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRate = calculateRate(state.scans.length, state.startedAt, now);
|
||||||
|
state.lastMessage = `${currentRate.toFixed(2)} scans per second`;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTick(rawState, context = {}) {
|
||||||
|
const now = context.now || Date.now();
|
||||||
|
const state = normalizeState(rawState);
|
||||||
|
if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
|
||||||
|
return finishRound(state, state.endsAt);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicState(rawState, context = {}) {
|
||||||
|
let state = normalizeState(rawState);
|
||||||
|
const now = context.now || Date.now();
|
||||||
|
if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
|
||||||
|
state = finishRound(state, state.endsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedEnd = state.status === 'running' ? now : state.finalResult?.endedAt || now;
|
||||||
|
const currentRate = state.status === 'running'
|
||||||
|
? calculateRate(state.scans.length, state.startedAt, elapsedEnd)
|
||||||
|
: state.finalResult?.scansPerSecond || 0;
|
||||||
|
const remainingMs = state.status === 'running' ? Math.max(0, (state.endsAt || now) - now) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: GAME_ID,
|
||||||
|
title: 'Scans per second',
|
||||||
|
status: state.status,
|
||||||
|
headline: state.lastMessage,
|
||||||
|
detail: state.status === 'running'
|
||||||
|
? `${state.scans.length} scans, ${Math.ceil(remainingMs / 1000)} seconds left`
|
||||||
|
: state.finalResult
|
||||||
|
? `${state.finalResult.scanCount} scans in last round`
|
||||||
|
: 'five minute scan challenge',
|
||||||
|
scanCount: state.scans.length,
|
||||||
|
scansPerSecond: Number(currentRate.toFixed(3)),
|
||||||
|
remainingMs,
|
||||||
|
finalResult: state.finalResult,
|
||||||
|
worldRecord: state.worldRecord,
|
||||||
|
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
|
||||||
|
recentRounds: state.recentRounds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: GAME_ID,
|
||||||
|
title: 'Scans per second',
|
||||||
|
description: 'Count every scan for five minutes and save the world record.',
|
||||||
|
createInitialState,
|
||||||
|
normalizeState,
|
||||||
|
onActivated,
|
||||||
|
onScan,
|
||||||
|
onTick,
|
||||||
|
getPublicState,
|
||||||
|
};
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
// Barcode Game Service
|
||||||
|
// Purpose: Coordinates global barcode games, voting, player attribution, and
|
||||||
|
// persistent scan counters for the scanner station.
|
||||||
|
// Scope: Keeps game orchestration server-side while the scanner and driver pages
|
||||||
|
// remain thin IO surfaces that subscribe to state and send votes/scans.
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('barcodeGameService');
|
||||||
|
const { subscribe } = require('../eventBus');
|
||||||
|
const { getActiveDrivers } = require('../turnService');
|
||||||
|
const { getIdentitySummary } = require('../verificationService');
|
||||||
|
const { getRegistrySnapshot } = require('../barcodeScannerService');
|
||||||
|
const { loadStore, withGameStore } = require('./store');
|
||||||
|
const scanQuest = require('./games/scanQuest');
|
||||||
|
const scansPerSecond = require('./games/scansPerSecond');
|
||||||
|
|
||||||
|
const GAME_SOCKET_ROOM = 'barcode-game';
|
||||||
|
const RECENT_EVENT_LIMIT = 20;
|
||||||
|
const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000;
|
||||||
|
|
||||||
|
const GAME_DEFINITIONS = [scanQuest, scansPerSecond];
|
||||||
|
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
||||||
|
|
||||||
|
function getGameDefinition(gameId) {
|
||||||
|
return GAMES_BY_ID[String(gameId || '')] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getKnownObjects() {
|
||||||
|
try {
|
||||||
|
const snapshot = getRegistrySnapshot();
|
||||||
|
const codes = snapshot?.registry?.codes || {};
|
||||||
|
return Object.entries(codes)
|
||||||
|
.filter(([, entry]) => entry?.type === 'object')
|
||||||
|
.map(([code, entry]) => ({
|
||||||
|
code,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
label: entry.label,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to read barcode registry for game object list', { error: err.message });
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlayerKey(identity = {}, socketId = '', roverId = '') {
|
||||||
|
if (identity.cookieUserId) return `identity:${identity.cookieUserId}`;
|
||||||
|
if (socketId) return `socket:${socketId}`;
|
||||||
|
if (roverId) return `rover:${roverId}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRoverParticipant(roverId) {
|
||||||
|
const normalizedRoverId = String(roverId || '').trim();
|
||||||
|
if (!normalizedRoverId) return null;
|
||||||
|
const activeDrivers = getActiveDrivers();
|
||||||
|
const socketId = activeDrivers?.[normalizedRoverId] || null;
|
||||||
|
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
||||||
|
const identity = socket ? getIdentitySummary(socket) : {};
|
||||||
|
const playerKey = normalizePlayerKey(identity, socketId, normalizedRoverId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerKey,
|
||||||
|
roverId: normalizedRoverId,
|
||||||
|
socketId,
|
||||||
|
cookieUserId: identity.cookieUserId || null,
|
||||||
|
nickname: identity.nickname || normalizedRoverId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneRecentRoverSightings(draft, now) {
|
||||||
|
const sightings = draft.recentRoverSightings || {};
|
||||||
|
Object.entries(sightings).forEach(([roverId, sighting]) => {
|
||||||
|
if (!Number.isFinite(sighting?.scannedAt) || now - sighting.scannedAt > ROVER_ATTRIBUTION_WINDOW_MS) {
|
||||||
|
delete sightings[roverId];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
draft.recentRoverSightings = sightings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordRoverSighting(draft, scan, now) {
|
||||||
|
if (!scan?.known || scan.type !== 'rover' || !scan.entityId) return null;
|
||||||
|
const participant = resolveRoverParticipant(scan.entityId);
|
||||||
|
if (!participant?.playerKey) return null;
|
||||||
|
|
||||||
|
draft.recentRoverSightings = {
|
||||||
|
...(draft.recentRoverSightings || {}),
|
||||||
|
[participant.roverId]: {
|
||||||
|
...participant,
|
||||||
|
scannedAt: now,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return participant;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProximityParticipants(draft, now) {
|
||||||
|
pruneRecentRoverSightings(draft, now);
|
||||||
|
return Object.values(draft.recentRoverSightings || {})
|
||||||
|
.filter((sighting) => sighting?.playerKey && now - sighting.scannedAt <= ROVER_ATTRIBUTION_WINDOW_MS)
|
||||||
|
.map((sighting) => ({
|
||||||
|
playerKey: sighting.playerKey,
|
||||||
|
roverId: sighting.roverId,
|
||||||
|
socketId: sighting.socketId || null,
|
||||||
|
cookieUserId: sighting.cookieUserId || null,
|
||||||
|
nickname: sighting.nickname || sighting.roverId,
|
||||||
|
scannedAt: sighting.scannedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementCounter(bucket, key, scan, now) {
|
||||||
|
if (!key) return;
|
||||||
|
const previous = bucket[key] || {};
|
||||||
|
bucket[key] = {
|
||||||
|
code: scan.code || previous.code || null,
|
||||||
|
entityId: scan.entityId || previous.entityId || key,
|
||||||
|
label: scan.label || previous.label || key,
|
||||||
|
type: scan.type || previous.type || null,
|
||||||
|
count: (Number.isFinite(previous.count) ? previous.count : 0) + 1,
|
||||||
|
lastScannedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGlobalCounters(draft, scan, now) {
|
||||||
|
if (!scan?.known) return;
|
||||||
|
const counters = draft.globalCounters || {};
|
||||||
|
counters.codes = counters.codes || {};
|
||||||
|
counters.objects = counters.objects || {};
|
||||||
|
counters.rovers = counters.rovers || {};
|
||||||
|
|
||||||
|
// Global counters intentionally ignore unknown scans. They are meant to be a
|
||||||
|
// useful room/object popularity board, while game-specific rules can still
|
||||||
|
// choose to count unknown codes when that makes sense.
|
||||||
|
incrementCounter(counters.codes, scan.code, scan, now);
|
||||||
|
if (scan.type === 'object') {
|
||||||
|
incrementCounter(counters.objects, scan.entityId, scan, now);
|
||||||
|
} else if (scan.type === 'rover') {
|
||||||
|
incrementCounter(counters.rovers, scan.entityId, scan, now);
|
||||||
|
}
|
||||||
|
draft.globalCounters = counters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecentEvent(draft, event) {
|
||||||
|
draft.recentEvents = [
|
||||||
|
{
|
||||||
|
...event,
|
||||||
|
at: Date.now(),
|
||||||
|
},
|
||||||
|
...(Array.isArray(draft.recentEvents) ? draft.recentEvents : []),
|
||||||
|
].slice(0, RECENT_EVENT_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPlayerParticipation(draft, gameId, participants, now) {
|
||||||
|
if (!gameId || !Array.isArray(participants) || !participants.length) return;
|
||||||
|
draft.players = draft.players || {};
|
||||||
|
participants.forEach((participant) => {
|
||||||
|
if (!participant?.playerKey) return;
|
||||||
|
const previous = draft.players[participant.playerKey] || {};
|
||||||
|
const previousGames = previous.games || {};
|
||||||
|
const previousGame = previousGames[gameId] || {};
|
||||||
|
draft.players[participant.playerKey] = {
|
||||||
|
playerKey: participant.playerKey,
|
||||||
|
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
||||||
|
nickname: participant.nickname || previous.nickname || null,
|
||||||
|
lastRoverId: participant.roverId || previous.lastRoverId || null,
|
||||||
|
lastSeenAt: now,
|
||||||
|
games: {
|
||||||
|
...previousGames,
|
||||||
|
[gameId]: {
|
||||||
|
gameId,
|
||||||
|
scanCount: (Number.isFinite(previousGame.scanCount) ? previousGame.scanCount : 0) + 1,
|
||||||
|
lastPlayedAt: now,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGameState(draft, gameId) {
|
||||||
|
const definition = getGameDefinition(gameId);
|
||||||
|
if (!definition) return null;
|
||||||
|
draft.games = draft.games || {};
|
||||||
|
draft.games[gameId] = definition.normalizeState
|
||||||
|
? definition.normalizeState(draft.games[gameId])
|
||||||
|
: draft.games[gameId] || definition.createInitialState();
|
||||||
|
return draft.games[gameId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGameContext(draft, extras = {}) {
|
||||||
|
return {
|
||||||
|
now: extras.now || Date.now(),
|
||||||
|
objects: getKnownObjects(),
|
||||||
|
participants: extras.participants || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateGame(draft, gameId, now) {
|
||||||
|
const definition = getGameDefinition(gameId);
|
||||||
|
if (!definition) return false;
|
||||||
|
const currentState = ensureGameState(draft, gameId);
|
||||||
|
const nextState = definition.onActivated
|
||||||
|
? definition.onActivated(currentState, buildGameContext(draft, { now }))
|
||||||
|
: currentState;
|
||||||
|
draft.games[gameId] = nextState;
|
||||||
|
draft.activeGameId = gameId;
|
||||||
|
addRecentEvent(draft, {
|
||||||
|
kind: 'gameActivated',
|
||||||
|
gameId,
|
||||||
|
title: definition.title,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countVotes(votes = {}) {
|
||||||
|
const counts = {};
|
||||||
|
Object.values(votes || {}).forEach((vote) => {
|
||||||
|
if (!getGameDefinition(vote?.gameId)) return;
|
||||||
|
counts[vote.gameId] = (counts[vote.gameId] || 0) + 1;
|
||||||
|
});
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseVoteWinner(draft) {
|
||||||
|
const counts = countVotes(draft.votes);
|
||||||
|
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
||||||
|
if (!entries.length) return null;
|
||||||
|
const [topGameId, topCount] = entries[0];
|
||||||
|
const activeCount = draft.activeGameId ? counts[draft.activeGameId] || 0 : 0;
|
||||||
|
|
||||||
|
// Ties keep the current game so a single equalizing vote does not cause the
|
||||||
|
// room display to flicker back and forth between games.
|
||||||
|
if (draft.activeGameId && activeCount === topCount) return draft.activeGameId;
|
||||||
|
return topGameId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVoterKey(socket) {
|
||||||
|
const identity = getIdentitySummary(socket);
|
||||||
|
return normalizePlayerKey(identity, socket?.id || '', '') || `socket:${socket?.id || 'unknown'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVote(socket, gameId) {
|
||||||
|
const definition = getGameDefinition(gameId);
|
||||||
|
if (!definition) {
|
||||||
|
return { error: 'unknown barcode game' };
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const voterKey = getVoterKey(socket);
|
||||||
|
const identity = getIdentitySummary(socket);
|
||||||
|
|
||||||
|
withGameStore((draft) => {
|
||||||
|
draft.votes = draft.votes || {};
|
||||||
|
draft.votes[voterKey] = {
|
||||||
|
gameId: definition.id,
|
||||||
|
voterKey,
|
||||||
|
socketId: socket.id,
|
||||||
|
nickname: identity.nickname || null,
|
||||||
|
votedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const winner = chooseVoteWinner(draft);
|
||||||
|
const existingWinnerState = winner ? draft.games?.[winner] : null;
|
||||||
|
const shouldActivateWinner = Boolean(
|
||||||
|
winner &&
|
||||||
|
(!draft.activeGameId ||
|
||||||
|
winner !== draft.activeGameId ||
|
||||||
|
existingWinnerState?.status === 'ended' ||
|
||||||
|
existingWinnerState?.status === 'idle'),
|
||||||
|
);
|
||||||
|
if (shouldActivateWinner) {
|
||||||
|
activateGame(draft, winner, now);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
broadcastState();
|
||||||
|
return { success: true, state: buildStatePayload() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function settleActiveGameIfNeeded() {
|
||||||
|
const store = loadStore();
|
||||||
|
const activeGameId = store.activeGameId;
|
||||||
|
const definition = getGameDefinition(activeGameId);
|
||||||
|
const currentState = activeGameId ? store.games?.[activeGameId] : null;
|
||||||
|
const now = Date.now();
|
||||||
|
if (!definition?.onTick || currentState?.status !== 'running' || !currentState?.endsAt || now < currentState.endsAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
withGameStore((draft) => {
|
||||||
|
const state = ensureGameState(draft, activeGameId);
|
||||||
|
draft.games[activeGameId] = definition.onTick(state, buildGameContext(draft, { now }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScan(scan) {
|
||||||
|
const now = Number.isFinite(scan?.scannedAt) ? scan.scannedAt : Date.now();
|
||||||
|
withGameStore((draft) => {
|
||||||
|
updateGlobalCounters(draft, scan, now);
|
||||||
|
recordRoverSighting(draft, scan, now);
|
||||||
|
const participants = getProximityParticipants(draft, now);
|
||||||
|
const activeGameId = draft.activeGameId;
|
||||||
|
const definition = getGameDefinition(activeGameId);
|
||||||
|
|
||||||
|
if (definition) {
|
||||||
|
const currentState = ensureGameState(draft, activeGameId);
|
||||||
|
const nextState = definition.onScan
|
||||||
|
? definition.onScan(currentState, scan, buildGameContext(draft, { now, participants }))
|
||||||
|
: currentState;
|
||||||
|
draft.games[activeGameId] = nextState;
|
||||||
|
recordPlayerParticipation(draft, activeGameId, participants, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
addRecentEvent(draft, {
|
||||||
|
kind: 'scan',
|
||||||
|
code: scan?.code || '',
|
||||||
|
label: scan?.label || scan?.code || 'unknown',
|
||||||
|
known: Boolean(scan?.known),
|
||||||
|
type: scan?.type || null,
|
||||||
|
participants: participants.map((participant) => participant.nickname || participant.roverId).filter(Boolean),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
broadcastState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function topCounters(bucket = {}, limit = 5) {
|
||||||
|
return Object.values(bucket || {})
|
||||||
|
.sort((a, b) => (b.count || 0) - (a.count || 0))
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStatePayload() {
|
||||||
|
settleActiveGameIfNeeded();
|
||||||
|
const store = loadStore();
|
||||||
|
const now = Date.now();
|
||||||
|
const voteCounts = countVotes(store.votes);
|
||||||
|
const activeDefinition = getGameDefinition(store.activeGameId);
|
||||||
|
const context = buildGameContext(store, { now });
|
||||||
|
const activeGame = activeDefinition
|
||||||
|
? activeDefinition.getPublicState(ensureReadonlyGameState(store, activeDefinition.id), context)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeGameId: store.activeGameId,
|
||||||
|
games: GAME_DEFINITIONS.map((game) => ({
|
||||||
|
id: game.id,
|
||||||
|
title: game.title,
|
||||||
|
description: game.description,
|
||||||
|
voteCount: voteCounts[game.id] || 0,
|
||||||
|
active: game.id === store.activeGameId,
|
||||||
|
})),
|
||||||
|
activeGame,
|
||||||
|
counters: {
|
||||||
|
objects: topCounters(store.globalCounters?.objects),
|
||||||
|
rovers: topCounters(store.globalCounters?.rovers),
|
||||||
|
codes: topCounters(store.globalCounters?.codes),
|
||||||
|
},
|
||||||
|
recentEvents: Array.isArray(store.recentEvents) ? store.recentEvents.slice(0, 8) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReadonlyGameState(store, gameId) {
|
||||||
|
const definition = getGameDefinition(gameId);
|
||||||
|
if (!definition) return null;
|
||||||
|
return definition.normalizeState ? definition.normalizeState(store.games?.[gameId]) : store.games?.[gameId] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcastState() {
|
||||||
|
io.to(GAME_SOCKET_ROOM).emit('barcodeGame:state', buildStatePayload());
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
|
||||||
|
socket.join(GAME_SOCKET_ROOM);
|
||||||
|
const state = buildStatePayload();
|
||||||
|
socket.emit('barcodeGame:state', state);
|
||||||
|
cb({ success: true, state });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('barcodeGame:vote', ({ gameId } = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb(setVote(socket, gameId));
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Barcode game vote failed', { error: err.message, gameId });
|
||||||
|
cb({ error: err.message || 'barcode game vote failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
subscribe('barcode.scanned', (event) => {
|
||||||
|
try {
|
||||||
|
handleScan(event.payload);
|
||||||
|
} catch (err) {
|
||||||
|
// Scanner input should never be able to take down the server. Game failures
|
||||||
|
// are logged and skipped so the scanner page can keep resolving barcodes.
|
||||||
|
logger.warn('Barcode game scan handling failed', { error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildStatePayload,
|
||||||
|
handleScan,
|
||||||
|
setVote,
|
||||||
|
};
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Barcode Game Store
|
||||||
|
// Purpose: Owns persisted barcode game state and normalizes it on every load.
|
||||||
|
// Scope: Keeps file IO and state-shape repair separate from the game engine so
|
||||||
|
// individual games can focus on rules instead of persistence details.
|
||||||
|
const { createJsonStore } = require('../identityService');
|
||||||
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
const logger = require('../../globals/logger').child('barcodeGameStore');
|
||||||
|
|
||||||
|
const STORE_VERSION = 1;
|
||||||
|
const STORE_PATH = resolveDataPath('barcode-games.json');
|
||||||
|
|
||||||
|
function createDefaultStore() {
|
||||||
|
return {
|
||||||
|
version: STORE_VERSION,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
activeGameId: null,
|
||||||
|
votes: {},
|
||||||
|
globalCounters: {
|
||||||
|
codes: {},
|
||||||
|
objects: {},
|
||||||
|
rovers: {},
|
||||||
|
},
|
||||||
|
recentRoverSightings: {},
|
||||||
|
players: {},
|
||||||
|
games: {},
|
||||||
|
recentEvents: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneStore(store) {
|
||||||
|
// The game state is intentionally JSON-shaped because it must survive server
|
||||||
|
// restarts without custom serializers. JSON cloning is sufficient and keeps
|
||||||
|
// accidental mutable references from leaking between callers.
|
||||||
|
return JSON.parse(JSON.stringify(store || createDefaultStore()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCounterBucket(rawBucket = {}) {
|
||||||
|
const bucket = {};
|
||||||
|
Object.entries(rawBucket && typeof rawBucket === 'object' ? rawBucket : {}).forEach(([key, rawEntry]) => {
|
||||||
|
if (!key || !rawEntry || typeof rawEntry !== 'object') return;
|
||||||
|
const count = Number.isFinite(rawEntry.count) ? Math.max(0, Math.floor(rawEntry.count)) : 0;
|
||||||
|
if (!count) return;
|
||||||
|
bucket[key] = {
|
||||||
|
code: typeof rawEntry.code === 'string' ? rawEntry.code : null,
|
||||||
|
entityId: typeof rawEntry.entityId === 'string' ? rawEntry.entityId : key,
|
||||||
|
label: typeof rawEntry.label === 'string' ? rawEntry.label : key,
|
||||||
|
type: typeof rawEntry.type === 'string' ? rawEntry.type : null,
|
||||||
|
count,
|
||||||
|
lastScannedAt: Number.isFinite(rawEntry.lastScannedAt) ? rawEntry.lastScannedAt : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVote(rawVote = {}) {
|
||||||
|
const gameId = typeof rawVote.gameId === 'string' ? rawVote.gameId : null;
|
||||||
|
if (!gameId) return null;
|
||||||
|
return {
|
||||||
|
gameId,
|
||||||
|
voterKey: typeof rawVote.voterKey === 'string' ? rawVote.voterKey : null,
|
||||||
|
nickname: typeof rawVote.nickname === 'string' ? rawVote.nickname : null,
|
||||||
|
socketId: typeof rawVote.socketId === 'string' ? rawVote.socketId : null,
|
||||||
|
votedAt: Number.isFinite(rawVote.votedAt) ? rawVote.votedAt : Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStoreShape(raw = {}) {
|
||||||
|
const base = createDefaultStore();
|
||||||
|
const votes = {};
|
||||||
|
Object.entries(raw.votes && typeof raw.votes === 'object' ? raw.votes : {}).forEach(([key, rawVote]) => {
|
||||||
|
const vote = normalizeVote(rawVote);
|
||||||
|
if (vote) votes[key] = { ...vote, voterKey: vote.voterKey || key };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
version: STORE_VERSION,
|
||||||
|
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
||||||
|
activeGameId: typeof raw.activeGameId === 'string' ? raw.activeGameId : null,
|
||||||
|
votes,
|
||||||
|
globalCounters: {
|
||||||
|
codes: normalizeCounterBucket(raw.globalCounters?.codes),
|
||||||
|
objects: normalizeCounterBucket(raw.globalCounters?.objects),
|
||||||
|
rovers: normalizeCounterBucket(raw.globalCounters?.rovers),
|
||||||
|
},
|
||||||
|
recentRoverSightings:
|
||||||
|
raw.recentRoverSightings && typeof raw.recentRoverSightings === 'object'
|
||||||
|
? raw.recentRoverSightings
|
||||||
|
: {},
|
||||||
|
players: raw.players && typeof raw.players === 'object' ? raw.players : {},
|
||||||
|
games: raw.games && typeof raw.games === 'object' ? raw.games : {},
|
||||||
|
recentEvents: Array.isArray(raw.recentEvents) ? raw.recentEvents.slice(-25) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeApi = createJsonStore({
|
||||||
|
path: STORE_PATH,
|
||||||
|
normalizeStoreShape,
|
||||||
|
cloneStore,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeStore(next) {
|
||||||
|
return storeApi.writeStore({
|
||||||
|
...next,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withGameStore(mutator) {
|
||||||
|
const current = storeApi.loadStore();
|
||||||
|
const draft = cloneStore(current);
|
||||||
|
const result = mutator(draft);
|
||||||
|
writeStore(draft);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
STORE_PATH,
|
||||||
|
loadStore: storeApi.loadStore,
|
||||||
|
writeStore,
|
||||||
|
withGameStore,
|
||||||
|
cloneStore,
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('barcodeScannerService');
|
|||||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
const { sendAlert } = require('../alertService');
|
const { sendAlert } = require('../alertService');
|
||||||
|
const { publishEvent } = require('../eventBus');
|
||||||
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
|
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
|
||||||
|
|
||||||
const DATA_DIR = resolveDataDir();
|
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() {
|
function buildStatePayload() {
|
||||||
return {
|
return {
|
||||||
mode: getMode(),
|
mode: getMode(),
|
||||||
@@ -257,6 +266,14 @@ async function applyScan(rawCode) {
|
|||||||
registryError: result.registryError || null,
|
registryError: result.registryError || null,
|
||||||
};
|
};
|
||||||
broadcastState();
|
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)
|
buildScanAudio(result)
|
||||||
.then((audio) => {
|
.then((audio) => {
|
||||||
io.to(SCANNER_SOCKET_ROOM).emit('barcode:scanAudio', {
|
io.to(SCANNER_SOCKET_ROOM).emit('barcode:scanAudio', {
|
||||||
@@ -308,4 +325,5 @@ module.exports = {
|
|||||||
REGISTRY_PATH,
|
REGISTRY_PATH,
|
||||||
applyScan,
|
applyScan,
|
||||||
buildStatePayload,
|
buildStatePayload,
|
||||||
|
getRegistrySnapshot,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import VipPanel from './components/VipPanel/index.jsx';
|
|||||||
import { useSessionSelector } from './context/SessionContext.jsx';
|
import { useSessionSelector } from './context/SessionContext.jsx';
|
||||||
import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
|
import { useTelemetryVisualPolicy } from './context/TelemetryContext.jsx';
|
||||||
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
|
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
|
||||||
|
import BarcodeGamesPanel from './components/BarcodeGamesPanel/index.jsx';
|
||||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||||
@@ -159,6 +160,7 @@ function MobileFeatureTabs({
|
|||||||
{/* activities tab */}
|
{/* activities tab */}
|
||||||
<TabPanel id="activities">
|
<TabPanel id="activities">
|
||||||
<div className={`flex flex-col ${themeGapClass}`}>
|
<div className={`flex flex-col ${themeGapClass}`}>
|
||||||
|
<BarcodeGamesPanel />
|
||||||
<ButtonBoxPanel />
|
<ButtonBoxPanel />
|
||||||
<KinectPanel />
|
<KinectPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Barcode Game State Hook
|
||||||
|
// Purpose: Subscribes only interested pages/components to barcode game state.
|
||||||
|
// Scope: Keeps scanner-game traffic out of the global session tree so unrelated
|
||||||
|
// UI pages do not receive or retain barcode game data.
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSocket } from '../context/SocketContext.jsx';
|
||||||
|
|
||||||
|
const EMPTY_BARCODE_GAME_STATE = {
|
||||||
|
activeGameId: null,
|
||||||
|
games: [],
|
||||||
|
activeGame: null,
|
||||||
|
counters: {
|
||||||
|
objects: [],
|
||||||
|
rovers: [],
|
||||||
|
codes: [],
|
||||||
|
},
|
||||||
|
recentEvents: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeState(payload = {}) {
|
||||||
|
return {
|
||||||
|
...EMPTY_BARCODE_GAME_STATE,
|
||||||
|
...(payload && typeof payload === 'object' ? payload : {}),
|
||||||
|
games: Array.isArray(payload?.games) ? payload.games : [],
|
||||||
|
counters: {
|
||||||
|
...EMPTY_BARCODE_GAME_STATE.counters,
|
||||||
|
...(payload?.counters && typeof payload.counters === 'object' ? payload.counters : {}),
|
||||||
|
},
|
||||||
|
recentEvents: Array.isArray(payload?.recentEvents) ? payload.recentEvents : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useBarcodeGameState() {
|
||||||
|
const socket = useSocket();
|
||||||
|
const [state, setState] = useState(EMPTY_BARCODE_GAME_STATE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleState(payload = {}) {
|
||||||
|
setState(normalizeState(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.emit('barcodeGame:subscribe', {}, (response = {}) => {
|
||||||
|
if (response.state) {
|
||||||
|
handleState(response.state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('barcodeGame:state', handleState);
|
||||||
|
return () => {
|
||||||
|
socket.off('barcodeGame:state', handleState);
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const voteForGame = useCallback(
|
||||||
|
(gameId) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
socket.emit('barcodeGame:vote', { gameId }, (response = {}) => {
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.state) {
|
||||||
|
setState(normalizeState(response.state));
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[socket],
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
voteForGame,
|
||||||
|
}),
|
||||||
|
[state, voteForGame],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Barcode Games Panel
|
||||||
|
// Purpose: Shows global barcode game state and voting controls inside the
|
||||||
|
// driver's Activities tab.
|
||||||
|
// Scope: This panel is intentionally compact because the scanner page remains
|
||||||
|
// the main room-facing game interface.
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
|
function formatCounter(entry) {
|
||||||
|
const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : 'unknown';
|
||||||
|
const count = Number.isFinite(entry?.count) ? entry.count : 0;
|
||||||
|
return `${label} ${count}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecord(activeGame) {
|
||||||
|
const record = activeGame?.worldRecord;
|
||||||
|
if (!record) return null;
|
||||||
|
const rate = Number.isFinite(record.scansPerSecond) ? record.scansPerSecond.toFixed(2) : '0.00';
|
||||||
|
return `${rate} scans per second`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GameVoteButton({ game, disabled, onVote }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onVote(game.id)}
|
||||||
|
className={[
|
||||||
|
'button-dark min-w-0 flex-1 px-1 py-0.5 text-left text-xs disabled:opacity-50',
|
||||||
|
game.active ? 'border-emerald-300 bg-emerald-950/70 text-emerald-50' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
>
|
||||||
|
<span className="block truncate font-semibold">{game.title}</span>
|
||||||
|
<span className="block text-[0.68rem] text-slate-300">{game.voteCount || 0} votes</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CounterList({ title, entries }) {
|
||||||
|
if (!Array.isArray(entries) || !entries.length) return null;
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="mb-0.5 text-[0.68rem] font-semibold text-slate-300">{title}</p>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{entries.slice(0, 3).map((entry) => (
|
||||||
|
<p key={`${entry.entityId || entry.code}-${entry.type || 'counter'}`} className="truncate text-xs text-slate-100">
|
||||||
|
{formatCounter(entry)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarcodeGamesPanel() {
|
||||||
|
const { state, voteForGame } = useBarcodeGameState();
|
||||||
|
const [pendingGameId, setPendingGameId] = useState(null);
|
||||||
|
const activeGame = state.activeGame;
|
||||||
|
const recordText = formatRecord(activeGame);
|
||||||
|
const counters = state.counters || {};
|
||||||
|
const voteButtons = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
|
||||||
|
|
||||||
|
const handleVote = async (gameId) => {
|
||||||
|
setPendingGameId(gameId);
|
||||||
|
try {
|
||||||
|
await voteForGame(gameId);
|
||||||
|
} finally {
|
||||||
|
setPendingGameId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardFrame title="Barcode games" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
|
<div className="grid gap-0.5 sm:grid-cols-2">
|
||||||
|
{voteButtons.map((game) => (
|
||||||
|
<GameVoteButton
|
||||||
|
key={game.id}
|
||||||
|
game={game}
|
||||||
|
disabled={Boolean(pendingGameId)}
|
||||||
|
onVote={handleVote}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded border border-neutral-700 bg-neutral-950/70 p-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-white">
|
||||||
|
{activeGame?.title || 'No barcode game'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-200">
|
||||||
|
{activeGame?.headline || 'vote for a game to start'}
|
||||||
|
</p>
|
||||||
|
{activeGame?.detail ? (
|
||||||
|
<p className="mt-0.5 truncate text-[0.72rem] text-slate-400">{activeGame.detail}</p>
|
||||||
|
) : null}
|
||||||
|
{recordText ? (
|
||||||
|
<p className="mt-0.5 text-[0.72rem] text-emerald-200">world record {recordText}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-1 sm:grid-cols-2">
|
||||||
|
<CounterList title="Most scanned objects" entries={counters.objects} />
|
||||||
|
<CounterList title="Most scanned rovers" entries={counters.rovers} />
|
||||||
|
</div>
|
||||||
|
</CardFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import VipPanel from '../VipPanel/index.jsx';
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
import ButtonBoxPanel from '../ButtonBoxPanel/index.jsx';
|
||||||
|
import BarcodeGamesPanel from '../BarcodeGamesPanel/index.jsx';
|
||||||
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { useLayoutEffect, useRef, useState } from 'react';
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
@@ -317,6 +318,7 @@ export default function RightPaneTabs({ layout, onOpenHelpOverlay }) {
|
|||||||
{/* activities tab */}
|
{/* activities tab */}
|
||||||
<TabPanel id="activities">
|
<TabPanel id="activities">
|
||||||
<div className={`flex flex-col ${themeGapClass}`}>
|
<div className={`flex flex-col ${themeGapClass}`}>
|
||||||
|
<BarcodeGamesPanel />
|
||||||
<ButtonBoxPanel />
|
<ButtonBoxPanel />
|
||||||
<KinectPanel />
|
<KinectPanel />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useSpectatorMode } from '../../hooks/useSpectatorMode.js';
|
|||||||
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
import useDefaultNickname from '../../hooks/useDefaultNickname.js';
|
||||||
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
import useUserIdentitySync from '../../hooks/useUserIdentitySync.js';
|
||||||
import SocketConnectionPill from '../../components/SocketConnectionPill/index.jsx';
|
import SocketConnectionPill from '../../components/SocketConnectionPill/index.jsx';
|
||||||
|
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||||
import useScannerSpeech from './useScannerSpeech.js';
|
import useScannerSpeech from './useScannerSpeech.js';
|
||||||
|
|
||||||
const EMPTY_SCANNER_STATE = {
|
const EMPTY_SCANNER_STATE = {
|
||||||
@@ -48,6 +49,7 @@ export default function ScannerContent() {
|
|||||||
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
||||||
const [scanAudioEvent, setScanAudioEvent] = useState(null);
|
const [scanAudioEvent, setScanAudioEvent] = useState(null);
|
||||||
const [flashActive, setFlashActive] = useState(false);
|
const [flashActive, setFlashActive] = useState(false);
|
||||||
|
const { state: barcodeGameState } = useBarcodeGameState();
|
||||||
|
|
||||||
useDefaultNickname();
|
useDefaultNickname();
|
||||||
useUserIdentitySync();
|
useUserIdentitySync();
|
||||||
@@ -117,7 +119,9 @@ export default function ScannerContent() {
|
|||||||
}, [focusInput, scannerState.beepAllowed, socket]);
|
}, [focusInput, scannerState.beepAllowed, socket]);
|
||||||
|
|
||||||
const lastScan = scannerState.lastScan;
|
const lastScan = scannerState.lastScan;
|
||||||
const label = lastScan?.label || 'waiting';
|
const activeGame = barcodeGameState.activeGame;
|
||||||
|
const label = activeGame?.headline || lastScan?.label || 'waiting';
|
||||||
|
const detail = activeGame?.detail || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<main
|
||||||
@@ -141,9 +145,16 @@ export default function ScannerContent() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<section className="flex min-h-screen w-full items-center justify-center">
|
<section className="flex min-h-screen w-full items-center justify-center">
|
||||||
<h1 className="max-w-full break-words text-[18vw] font-black leading-none tracking-normal">
|
<div className="flex max-w-full flex-col items-center gap-[4vh]">
|
||||||
|
<h1 className="max-w-full break-words text-[15vw] font-black leading-none tracking-normal">
|
||||||
{label}
|
{label}
|
||||||
</h1>
|
</h1>
|
||||||
|
{detail ? (
|
||||||
|
<p className="max-w-full break-words text-[5vw] font-bold leading-tight tracking-normal">
|
||||||
|
{detail}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<SocketConnectionPill />
|
<SocketConnectionPill />
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
Reference in New Issue
Block a user