more barcode game stuff

This commit is contained in:
legop3
2026-06-18 20:13:07 -04:00
parent 0af9d560e4
commit 7e30a856a8
10 changed files with 453 additions and 71 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -18,8 +18,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script> <script defer src="https://analytics.otter.land/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-BUN5_4YP.js"></script> <script type="module" crossorigin src="/assets/index-Bv_Ho3il.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DrszynNM.css"> <link rel="stylesheet" crossorigin href="/assets/index-esPFU1Dr.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -5,6 +5,7 @@
const GAME_ID = 'scanQuest'; const GAME_ID = 'scanQuest';
const QUEST_LENGTH_OPTIONS = [1, 2]; const QUEST_LENGTH_OPTIONS = [1, 2];
const REQUEST_TIMEOUT_MS = 90 * 1000;
function createInitialState() { function createInitialState() {
return { return {
@@ -12,6 +13,7 @@ function createInitialState() {
progressIndex: 0, progressIndex: 0,
scores: {}, scores: {},
completedQuests: 0, completedQuests: 0,
stepStartedAt: null,
recentEvents: [], recentEvents: [],
lastMessage: 'vote to start scan quest', lastMessage: 'vote to start scan quest',
}; };
@@ -25,6 +27,7 @@ function normalizeState(rawState = {}) {
progressIndex: Number.isFinite(rawState.progressIndex) ? Math.max(0, Math.floor(rawState.progressIndex)) : 0, progressIndex: Number.isFinite(rawState.progressIndex) ? Math.max(0, Math.floor(rawState.progressIndex)) : 0,
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {}, scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
completedQuests: Number.isFinite(rawState.completedQuests) ? Math.max(0, Math.floor(rawState.completedQuests)) : 0, completedQuests: Number.isFinite(rawState.completedQuests) ? Math.max(0, Math.floor(rawState.completedQuests)) : 0,
stepStartedAt: Number.isFinite(rawState.stepStartedAt) ? rawState.stepStartedAt : null,
recentEvents: Array.isArray(rawState.recentEvents) ? rawState.recentEvents.slice(-12) : [], recentEvents: Array.isArray(rawState.recentEvents) ? rawState.recentEvents.slice(-12) : [],
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage, lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
}; };
@@ -68,6 +71,7 @@ function ensureQuest(state, context = {}) {
const nextQuest = pickQuest(context.objects || []); const nextQuest = pickQuest(context.objects || []);
state.currentQuest = nextQuest; state.currentQuest = nextQuest;
state.progressIndex = 0; state.progressIndex = 0;
state.stepStartedAt = nextQuest ? context.now || Date.now() : null;
state.lastMessage = nextQuest ? formatQuestPrompt(state) : 'scan quest needs objects'; state.lastMessage = nextQuest ? formatQuestPrompt(state) : 'scan quest needs objects';
return state; return state;
} }
@@ -97,18 +101,62 @@ function addScore(state, participants = [], points) {
}); });
} }
function onActivated(rawState, context = {}) { function buildAwards(participants = [], points, reason) {
// Awards are returned to the shared barcode game service instead of mutating
// the global player ledger here. That keeps this file responsible only for
// scan quest rules while all cross-game points accounting stays centralized.
return participants
.filter((participant) => participant?.playerKey)
.map((participant) => ({
playerKey: participant.playerKey,
nickname: participant.nickname || null,
roverId: participant.roverId || null,
cookieUserId: participant.cookieUserId || null,
points,
reason,
}));
}
function activate(rawState, context = {}) {
const state = normalizeState(rawState); const state = normalizeState(rawState);
ensureQuest(state, context); ensureQuest(state, context);
return state; return state;
} }
function onScan(rawState, scan, context = {}) { function reset(_rawState, context = {}) {
const state = createInitialState();
ensureQuest(state, context);
return state;
}
function skipExpiredQuest(state, context = {}) {
const now = context.now || Date.now();
if (!state.currentQuest?.steps?.length || !state.stepStartedAt) return state;
if (now - state.stepStartedAt < REQUEST_TIMEOUT_MS) return state;
// A timeout generates a fresh request instead of continuing a half-complete
// sequence. If an item is physically unreachable or the barcode is damaged,
// the room gets unstuck without punishing anyone or making the next prompt
// depend on a failed previous step.
const skippedStep = state.currentQuest.steps[state.progressIndex] || state.currentQuest.steps[0];
addRecentEvent(state, {
kind: 'timeout',
label: skippedStep?.label || null,
});
state.currentQuest = pickQuest(context.objects || []);
state.progressIndex = 0;
state.stepStartedAt = state.currentQuest ? now : null;
state.lastMessage = state.currentQuest ? `skipped. ${formatQuestPrompt(state)}` : 'scan quest needs objects';
return state;
}
function handleScan(rawState, scan, context = {}) {
const state = normalizeState(rawState); const state = normalizeState(rawState);
ensureQuest(state, context); ensureQuest(state, context);
skipExpiredQuest(state, context);
if (!state.currentQuest?.steps?.length) return state; if (!state.currentQuest?.steps?.length) return { state, awards: [] };
if (!scan?.known || scan.type !== 'object') return state; if (!scan?.known || scan.type !== 'object') return { state, awards: [] };
const expected = state.currentQuest.steps[state.progressIndex]; const expected = state.currentQuest.steps[state.progressIndex];
const matched = Boolean(expected && scan.code === expected.code); const matched = Boolean(expected && scan.code === expected.code);
@@ -123,10 +171,11 @@ function onScan(rawState, scan, context = {}) {
label: scan.label, label: scan.label,
expected: expected.label, expected: expected.label,
}); });
return state; return { state, awards: [] };
} }
state.progressIndex += 1; state.progressIndex += 1;
state.stepStartedAt = context.now || Date.now();
addRecentEvent(state, { addRecentEvent(state, {
kind: 'hit', kind: 'hit',
label: scan.label, label: scan.label,
@@ -134,7 +183,7 @@ function onScan(rawState, scan, context = {}) {
if (state.progressIndex < state.currentQuest.steps.length) { if (state.progressIndex < state.currentQuest.steps.length) {
state.lastMessage = formatQuestPrompt(state); state.lastMessage = formatQuestPrompt(state);
return state; return { state, awards: [] };
} }
const points = state.currentQuest.steps.length; const points = state.currentQuest.steps.length;
@@ -147,8 +196,18 @@ function onScan(rawState, scan, context = {}) {
}); });
state.currentQuest = pickQuest(context.objects || []); state.currentQuest = pickQuest(context.objects || []);
state.progressIndex = 0; state.progressIndex = 0;
state.stepStartedAt = state.currentQuest ? context.now || Date.now() : null;
state.lastMessage = state.currentQuest ? `scored ${points}. ${formatQuestPrompt(state)}` : `scored ${points}`; state.lastMessage = state.currentQuest ? `scored ${points}. ${formatQuestPrompt(state)}` : `scored ${points}`;
return state; return {
state,
awards: buildAwards(context.participants || [], points, 'scan quest completed'),
};
}
function tick(rawState, context = {}) {
const state = normalizeState(rawState);
ensureQuest(state, context);
return skipExpiredQuest(state, context);
} }
function getTopScores(state) { function getTopScores(state) {
@@ -160,6 +219,11 @@ function getTopScores(state) {
function getPublicState(rawState, context = {}) { function getPublicState(rawState, context = {}) {
const state = normalizeState(rawState); const state = normalizeState(rawState);
ensureQuest(state, context); ensureQuest(state, context);
skipExpiredQuest(state, context);
const now = context.now || Date.now();
const remainingMs = state.stepStartedAt
? Math.max(0, REQUEST_TIMEOUT_MS - (now - state.stepStartedAt))
: 0;
return { return {
id: GAME_ID, id: GAME_ID,
title: 'Scan quest', title: 'Scan quest',
@@ -172,6 +236,8 @@ function getPublicState(rawState, context = {}) {
current: state.progressIndex, current: state.progressIndex,
total: state.currentQuest?.steps?.length || 0, total: state.currentQuest?.steps?.length || 0,
}, },
remainingMs,
actionLabel: 'Start quest',
scores: getTopScores(state), scores: getTopScores(state),
completedQuests: state.completedQuests, completedQuests: state.completedQuests,
recentEvents: state.recentEvents, recentEvents: state.recentEvents,
@@ -184,7 +250,12 @@ module.exports = {
description: 'Scan one or two requested objects in order.', description: 'Scan one or two requested objects in order.',
createInitialState, createInitialState,
normalizeState, normalizeState,
onActivated, activate,
onScan, reset,
handleScan,
tick,
onActivated: activate,
onScan: (state, scan, context) => handleScan(state, scan, context).state,
onTick: tick,
getPublicState, getPublicState,
}; };
@@ -5,6 +5,7 @@
const GAME_ID = 'scansPerSecond'; const GAME_ID = 'scansPerSecond';
const ROUND_DURATION_MS = 5 * 60 * 1000; const ROUND_DURATION_MS = 5 * 60 * 1000;
const RESULT_IDLE_MS = 60 * 1000;
function createInitialState() { function createInitialState() {
return { return {
@@ -17,6 +18,7 @@ function createInitialState() {
worldRecord: null, worldRecord: null,
recentRounds: [], recentRounds: [],
participantCounts: {}, participantCounts: {},
endedAt: null,
lastMessage: 'vote to start scans per second', lastMessage: 'vote to start scans per second',
}; };
} }
@@ -35,6 +37,7 @@ function normalizeState(rawState = {}) {
recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [], recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [],
participantCounts: participantCounts:
rawState.participantCounts && typeof rawState.participantCounts === 'object' ? rawState.participantCounts : {}, rawState.participantCounts && typeof rawState.participantCounts === 'object' ? rawState.participantCounts : {},
endedAt: Number.isFinite(rawState.endedAt) ? rawState.endedAt : null,
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage, lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
}; };
} }
@@ -65,6 +68,7 @@ function finishRound(state, endedAt = Date.now()) {
const isWorldRecord = !previousRecord || result.scansPerSecond > (previousRecord.scansPerSecond || 0); const isWorldRecord = !previousRecord || result.scansPerSecond > (previousRecord.scansPerSecond || 0);
state.status = 'ended'; state.status = 'ended';
state.endedAt = endedAt;
state.finalResult = { state.finalResult = {
...result, ...result,
isWorldRecord, isWorldRecord,
@@ -77,6 +81,27 @@ function finishRound(state, endedAt = Date.now()) {
return state; return state;
} }
function buildAwards(result, state) {
if (!result?.participants?.length || !result.scanCount) return [];
const basePoints = Math.max(1, Math.round(result.scansPerSecond * 10));
return result.participants
.filter((participant) => participant?.playerKey)
.map((participant) => {
const share = result.scanCount > 0 ? participant.scanCount / result.scanCount : 0;
return {
playerKey: participant.playerKey,
nickname: participant.nickname || null,
roverId: participant.roverId || null,
points: Math.max(1, Math.round(basePoints * share)),
reason: 'scans per second round',
gameMeta: {
scansPerSecond: result.scansPerSecond,
roundId: state.roundId,
},
};
});
}
function startRound(rawState, now = Date.now()) { function startRound(rawState, now = Date.now()) {
const previous = normalizeState(rawState); const previous = normalizeState(rawState);
return { return {
@@ -88,19 +113,25 @@ function startRound(rawState, now = Date.now()) {
scans: [], scans: [],
finalResult: null, finalResult: null,
participantCounts: {}, participantCounts: {},
endedAt: null,
lastMessage: 'scan anything', lastMessage: 'scan anything',
}; };
} }
function onActivated(rawState, context = {}) { function activate(rawState, context = {}) {
const state = normalizeState(rawState); const state = normalizeState(rawState);
const now = context.now || Date.now(); const now = context.now || Date.now();
// Voting for an ended or idle challenge starts a fresh five-minute round. If // Activation is game-defined, but the shared service calls it generically.
// the round is already running, activation is a no-op so vote churn does not // For this game, active rounds keep running while idle or ended rounds start
// accidentally reset an active challenge. // cleanly so returning to the game always produces a playable challenge.
return state.status === 'running' ? state : startRound(state, now); return state.status === 'running' ? state : startRound(state, now);
} }
function reset(rawState, context = {}) {
const state = normalizeState(rawState);
return startRound(state, context.now || Date.now());
}
function addParticipants(state, participants = []) { function addParticipants(state, participants = []) {
participants.forEach((participant) => { participants.forEach((participant) => {
const key = participant?.playerKey; const key = participant?.playerKey;
@@ -116,13 +147,15 @@ function addParticipants(state, participants = []) {
}); });
} }
function onScan(rawState, scan, context = {}) { function handleScan(rawState, scan, context = {}) {
const now = context.now || Date.now(); const now = context.now || Date.now();
let state = normalizeState(rawState); let state = normalizeState(rawState);
let awards = [];
if (state.status === 'running' && state.endsAt && now >= state.endsAt) { if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
state = finishRound(state, state.endsAt); state = finishRound(state, state.endsAt);
awards = buildAwards(state.finalResult, state);
} }
if (state.status !== 'running') return state; if (state.status !== 'running') return { state, awards };
// This game deliberately counts every submitted scan, including unknown and // This game deliberately counts every submitted scan, including unknown and
// invalid barcodes, because the challenge is about physically getting scans // invalid barcodes, because the challenge is about physically getting scans
@@ -138,19 +171,42 @@ function onScan(rawState, scan, context = {}) {
addParticipants(state, context.participants || []); addParticipants(state, context.participants || []);
if (state.endsAt && now >= state.endsAt) { if (state.endsAt && now >= state.endsAt) {
return finishRound(state, state.endsAt); state = finishRound(state, state.endsAt);
return {
state,
awards: buildAwards(state.finalResult, state),
};
} }
const currentRate = calculateRate(state.scans.length, state.startedAt, now); const currentRate = calculateRate(state.scans.length, state.startedAt, now);
state.lastMessage = `${currentRate.toFixed(2)} scans per second`; state.lastMessage = `${currentRate.toFixed(2)} scans per second`;
return state; return { state, awards: [] };
} }
function onTick(rawState, context = {}) { function tick(rawState, context = {}) {
const now = context.now || Date.now(); const now = context.now || Date.now();
const state = normalizeState(rawState); const state = normalizeState(rawState);
if (state.status === 'running' && state.endsAt && now >= state.endsAt) { if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
return finishRound(state, state.endsAt); const finished = finishRound(state, state.endsAt);
return {
state: finished,
awards: buildAwards(finished.finalResult, finished),
};
}
if (state.status === 'ended' && state.endedAt && now - state.endedAt >= RESULT_IDLE_MS) {
// Timed result screens are useful for celebration, but the game should not
// remain permanently stuck in a completed state. After a short display
// window the module returns itself to idle, preserving records and history.
return {
...state,
status: 'idle',
roundId: null,
startedAt: null,
endsAt: null,
scans: [],
participantCounts: {},
lastMessage: 'vote to start scans per second',
};
} }
return state; return state;
} }
@@ -185,6 +241,7 @@ function getPublicState(rawState, context = {}) {
worldRecord: state.worldRecord, worldRecord: state.worldRecord,
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)), participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
recentRounds: state.recentRounds, recentRounds: state.recentRounds,
actionLabel: state.status === 'running' ? 'Running' : 'Start round',
}; };
} }
@@ -194,8 +251,12 @@ module.exports = {
description: 'Count every scan for five minutes and save the world record.', description: 'Count every scan for five minutes and save the world record.',
createInitialState, createInitialState,
normalizeState, normalizeState,
onActivated, activate,
onScan, reset,
onTick, handleScan,
tick,
onActivated: activate,
onScan: (state, scan, context) => handleScan(state, scan, context).state,
onTick: tick,
getPublicState, getPublicState,
}; };
+145 -9
View File
@@ -16,6 +16,7 @@ const scansPerSecond = require('./games/scansPerSecond');
const GAME_SOCKET_ROOM = 'barcode-game'; const GAME_SOCKET_ROOM = 'barcode-game';
const RECENT_EVENT_LIMIT = 20; const RECENT_EVENT_LIMIT = 20;
const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000; const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000;
const GAME_TICK_MS = 5 * 1000;
const GAME_DEFINITIONS = [scanQuest, scansPerSecond]; const GAME_DEFINITIONS = [scanQuest, scansPerSecond];
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game])); const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
@@ -160,6 +161,7 @@ function recordPlayerParticipation(draft, gameId, participants, now) {
cookieUserId: participant.cookieUserId || previous.cookieUserId || null, cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
nickname: participant.nickname || previous.nickname || null, nickname: participant.nickname || previous.nickname || null,
lastRoverId: participant.roverId || previous.lastRoverId || null, lastRoverId: participant.roverId || previous.lastRoverId || null,
totalPoints: Number.isFinite(previous.totalPoints) ? previous.totalPoints : 0,
lastSeenAt: now, lastSeenAt: now,
games: { games: {
...previousGames, ...previousGames,
@@ -173,6 +175,54 @@ function recordPlayerParticipation(draft, gameId, participants, now) {
}); });
} }
function applyPointAwards(draft, gameId, awards = [], now) {
if (!gameId || !Array.isArray(awards) || !awards.length) return;
draft.players = draft.players || {};
awards.forEach((award) => {
const playerKey = award?.playerKey;
const points = Number.isFinite(award?.points) ? Math.max(0, Math.floor(award.points)) : 0;
if (!playerKey || !points) return;
const previous = draft.players[playerKey] || {};
const previousGames = previous.games || {};
const previousGame = previousGames[gameId] || {};
// Global points are applied only here so individual game files cannot drift
// into different player-ledger formats. A game simply returns awards, and
// the shared service records identity, total points, and per-game totals in
// one persistent place.
draft.players[playerKey] = {
playerKey,
cookieUserId: award.cookieUserId || previous.cookieUserId || null,
nickname: award.nickname || previous.nickname || null,
lastRoverId: award.roverId || previous.lastRoverId || null,
totalPoints: (Number.isFinite(previous.totalPoints) ? previous.totalPoints : 0) + points,
lastSeenAt: now,
games: {
...previousGames,
[gameId]: {
...previousGame,
gameId,
points: (Number.isFinite(previousGame.points) ? previousGame.points : 0) + points,
awards: (Number.isFinite(previousGame.awards) ? previousGame.awards : 0) + 1,
lastAwardAt: now,
lastReason: award.reason || null,
},
},
};
addRecentEvent(draft, {
kind: 'pointsAwarded',
gameId,
playerKey,
nickname: award.nickname || previous.nickname || null,
points,
reason: award.reason || null,
});
});
}
function ensureGameState(draft, gameId) { function ensureGameState(draft, gameId) {
const definition = getGameDefinition(gameId); const definition = getGameDefinition(gameId);
if (!definition) return null; if (!definition) return null;
@@ -195,8 +245,8 @@ function activateGame(draft, gameId, now) {
const definition = getGameDefinition(gameId); const definition = getGameDefinition(gameId);
if (!definition) return false; if (!definition) return false;
const currentState = ensureGameState(draft, gameId); const currentState = ensureGameState(draft, gameId);
const nextState = definition.onActivated const nextState = definition.activate
? definition.onActivated(currentState, buildGameContext(draft, { now })) ? definition.activate(currentState, buildGameContext(draft, { now }))
: currentState; : currentState;
draft.games[gameId] = nextState; draft.games[gameId] = nextState;
draft.activeGameId = gameId; draft.activeGameId = gameId;
@@ -208,6 +258,36 @@ function activateGame(draft, gameId, now) {
return true; return true;
} }
function resetGame(draft, gameId, now) {
const definition = getGameDefinition(gameId);
if (!definition) return false;
const currentState = ensureGameState(draft, gameId);
const nextState = definition.reset
? definition.reset(currentState, buildGameContext(draft, { now }))
: definition.createInitialState();
draft.games[gameId] = nextState;
draft.activeGameId = gameId;
addRecentEvent(draft, {
kind: 'gameReset',
gameId,
title: definition.title,
});
return true;
}
function normalizeGameResult(result, fallbackState) {
if (!result || typeof result !== 'object' || Array.isArray(result)) {
return { state: fallbackState, awards: [] };
}
if (Object.prototype.hasOwnProperty.call(result, 'state')) {
return {
state: result.state,
awards: Array.isArray(result.awards) ? result.awards : [],
};
}
return { state: result, awards: [] };
}
function countVotes(votes = {}) { function countVotes(votes = {}) {
const counts = {}; const counts = {};
Object.values(votes || {}).forEach((vote) => { Object.values(votes || {}).forEach((vote) => {
@@ -278,14 +358,18 @@ function settleActiveGameIfNeeded() {
const definition = getGameDefinition(activeGameId); const definition = getGameDefinition(activeGameId);
const currentState = activeGameId ? store.games?.[activeGameId] : null; const currentState = activeGameId ? store.games?.[activeGameId] : null;
const now = Date.now(); const now = Date.now();
if (!definition?.onTick || currentState?.status !== 'running' || !currentState?.endsAt || now < currentState.endsAt) { if (!definition?.tick || !currentState) return false;
return;
} const normalizedState = ensureReadonlyGameState(store, activeGameId);
const gameResult = definition.tick(normalizedState, buildGameContext(store, { now }));
const { state: nextState, awards } = normalizeGameResult(gameResult, normalizedState);
if (JSON.stringify(nextState) === JSON.stringify(normalizedState)) return false;
withGameStore((draft) => { withGameStore((draft) => {
const state = ensureGameState(draft, activeGameId); draft.games[activeGameId] = nextState;
draft.games[activeGameId] = definition.onTick(state, buildGameContext(draft, { now })); applyPointAwards(draft, activeGameId, awards, now);
}); });
return true;
} }
function handleScan(scan) { function handleScan(scan) {
@@ -299,11 +383,13 @@ function handleScan(scan) {
if (definition) { if (definition) {
const currentState = ensureGameState(draft, activeGameId); const currentState = ensureGameState(draft, activeGameId);
const nextState = definition.onScan const gameResult = definition.handleScan
? definition.onScan(currentState, scan, buildGameContext(draft, { now, participants })) ? definition.handleScan(currentState, scan, buildGameContext(draft, { now, participants }))
: currentState; : currentState;
const { state: nextState, awards } = normalizeGameResult(gameResult, currentState);
draft.games[activeGameId] = nextState; draft.games[activeGameId] = nextState;
recordPlayerParticipation(draft, activeGameId, participants, now); recordPlayerParticipation(draft, activeGameId, participants, now);
applyPointAwards(draft, activeGameId, awards, now);
} }
addRecentEvent(draft, { addRecentEvent(draft, {
@@ -318,6 +404,22 @@ function handleScan(scan) {
broadcastState(); broadcastState();
} }
function resetActiveGame() {
const now = Date.now();
let resetGameId = null;
withGameStore((draft) => {
const gameId = draft.activeGameId;
if (!getGameDefinition(gameId)) return;
resetGameId = gameId;
resetGame(draft, gameId, now);
});
if (!resetGameId) return { error: 'no active barcode game' };
broadcastState();
return { success: true, state: buildStatePayload() };
}
function topCounters(bucket = {}, limit = 5) { function topCounters(bucket = {}, limit = 5) {
return Object.values(bucket || {}) return Object.values(bucket || {})
.sort((a, b) => (b.count || 0) - (a.count || 0)) .sort((a, b) => (b.count || 0) - (a.count || 0))
@@ -334,6 +436,7 @@ function buildStatePayload() {
const activeGame = activeDefinition const activeGame = activeDefinition
? activeDefinition.getPublicState(ensureReadonlyGameState(store, activeDefinition.id), context) ? activeDefinition.getPublicState(ensureReadonlyGameState(store, activeDefinition.id), context)
: null; : null;
const leaderboard = topPlayers(store.players);
return { return {
activeGameId: store.activeGameId, activeGameId: store.activeGameId,
@@ -343,8 +446,12 @@ function buildStatePayload() {
description: game.description, description: game.description,
voteCount: voteCounts[game.id] || 0, voteCount: voteCounts[game.id] || 0,
active: game.id === store.activeGameId, active: game.id === store.activeGameId,
actionLabel: game.id === store.activeGameId && activeGame?.actionLabel
? activeGame.actionLabel
: 'Start',
})), })),
activeGame, activeGame,
leaderboard,
counters: { counters: {
objects: topCounters(store.globalCounters?.objects), objects: topCounters(store.globalCounters?.objects),
rovers: topCounters(store.globalCounters?.rovers), rovers: topCounters(store.globalCounters?.rovers),
@@ -354,6 +461,19 @@ function buildStatePayload() {
}; };
} }
function topPlayers(players = {}, limit = 6) {
return Object.values(players || {})
.filter((player) => Number.isFinite(player?.totalPoints) && player.totalPoints > 0)
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0))
.slice(0, limit)
.map((player) => ({
playerKey: player.playerKey,
nickname: player.nickname || player.lastRoverId || 'unknown player',
totalPoints: player.totalPoints || 0,
lastRoverId: player.lastRoverId || null,
}));
}
function ensureReadonlyGameState(store, gameId) { function ensureReadonlyGameState(store, gameId) {
const definition = getGameDefinition(gameId); const definition = getGameDefinition(gameId);
if (!definition) return null; if (!definition) return null;
@@ -380,6 +500,15 @@ io.on('connection', (socket) => {
cb({ error: err.message || 'barcode game vote failed' }); cb({ error: err.message || 'barcode game vote failed' });
} }
}); });
socket.on('barcodeGame:resetActive', (_payload = {}, cb = () => {}) => {
try {
cb(resetActiveGame(socket));
} catch (err) {
logger.warn('Barcode game reset failed', { error: err.message });
cb({ error: err.message || 'barcode game reset failed' });
}
});
}); });
subscribe('barcode.scanned', (event) => { subscribe('barcode.scanned', (event) => {
@@ -395,5 +524,12 @@ subscribe('barcode.scanned', (event) => {
module.exports = { module.exports = {
buildStatePayload, buildStatePayload,
handleScan, handleScan,
resetActiveGame,
setVote, setVote,
}; };
setInterval(() => {
if (settleActiveGameIfNeeded()) {
broadcastState();
}
}, GAME_TICK_MS).unref?.();
@@ -64,6 +64,23 @@ function normalizeVote(rawVote = {}) {
}; };
} }
function normalizePlayers(rawPlayers = {}) {
const players = {};
Object.entries(rawPlayers && typeof rawPlayers === 'object' ? rawPlayers : {}).forEach(([key, rawPlayer]) => {
if (!key || !rawPlayer || typeof rawPlayer !== 'object') return;
players[key] = {
playerKey: typeof rawPlayer.playerKey === 'string' ? rawPlayer.playerKey : key,
cookieUserId: typeof rawPlayer.cookieUserId === 'string' ? rawPlayer.cookieUserId : null,
nickname: typeof rawPlayer.nickname === 'string' ? rawPlayer.nickname : null,
lastRoverId: typeof rawPlayer.lastRoverId === 'string' ? rawPlayer.lastRoverId : null,
totalPoints: Number.isFinite(rawPlayer.totalPoints) ? Math.max(0, Math.floor(rawPlayer.totalPoints)) : 0,
lastSeenAt: Number.isFinite(rawPlayer.lastSeenAt) ? rawPlayer.lastSeenAt : null,
games: rawPlayer.games && typeof rawPlayer.games === 'object' ? rawPlayer.games : {},
};
});
return players;
}
function normalizeStoreShape(raw = {}) { function normalizeStoreShape(raw = {}) {
const base = createDefaultStore(); const base = createDefaultStore();
const votes = {}; const votes = {};
@@ -87,7 +104,7 @@ function normalizeStoreShape(raw = {}) {
raw.recentRoverSightings && typeof raw.recentRoverSightings === 'object' raw.recentRoverSightings && typeof raw.recentRoverSightings === 'object'
? raw.recentRoverSightings ? raw.recentRoverSightings
: {}, : {},
players: raw.players && typeof raw.players === 'object' ? raw.players : {}, players: normalizePlayers(raw.players),
games: raw.games && typeof raw.games === 'object' ? raw.games : {}, games: raw.games && typeof raw.games === 'object' ? raw.games : {},
recentEvents: Array.isArray(raw.recentEvents) ? raw.recentEvents.slice(-25) : [], recentEvents: Array.isArray(raw.recentEvents) ? raw.recentEvents.slice(-25) : [],
}; };
+19 -1
View File
@@ -67,11 +67,29 @@ export default function useBarcodeGameState() {
[socket], [socket],
); );
const resetActiveGame = useCallback(
() =>
new Promise((resolve, reject) => {
socket.emit('barcodeGame:resetActive', {}, (response = {}) => {
if (response.error) {
reject(new Error(response.error));
return;
}
if (response.state) {
setState(normalizeState(response.state));
}
resolve(response);
});
}),
[socket],
);
return useMemo( return useMemo(
() => ({ () => ({
state, state,
resetActiveGame,
voteForGame, voteForGame,
}), }),
[state, voteForGame], [resetActiveGame, state, voteForGame],
); );
} }
+106 -27
View File
@@ -1,16 +1,22 @@
// Barcode Games Panel // Barcode Games Panel
// Purpose: Shows global barcode game state and voting controls inside the // Purpose: Shows global barcode game state and voting controls inside the driver's Activities tab.
// driver's Activities tab. // Scope: Gives drivers a readable control/status surface while the scanner page remains the room-facing display.
// Scope: This panel is intentionally compact because the scanner page remains
// the main room-facing game interface.
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js'; import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
function formatSeconds(ms) {
if (!Number.isFinite(ms) || ms <= 0) return null;
const totalSeconds = Math.ceil(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return minutes > 0 ? `${minutes}:${String(seconds).padStart(2, '0')}` : `${seconds}s`;
}
function formatCounter(entry) { function formatCounter(entry) {
const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : 'unknown'; const label = typeof entry?.label === 'string' && entry.label.trim() ? entry.label.trim() : 'unknown';
const count = Number.isFinite(entry?.count) ? entry.count : 0; const count = Number.isFinite(entry?.count) ? entry.count : 0;
return `${label} ${count}`; return { label, count };
} }
function formatRecord(activeGame) { function formatRecord(activeGame) {
@@ -27,12 +33,20 @@ function GameVoteButton({ game, disabled, onVote }) {
disabled={disabled} disabled={disabled}
onClick={() => onVote(game.id)} onClick={() => onVote(game.id)}
className={[ className={[
'button-dark min-w-0 flex-1 px-1 py-0.5 text-left text-xs disabled:opacity-50', 'button-dark min-h-[5.5rem] min-w-0 px-2 py-1.5 text-left disabled:opacity-50',
game.active ? 'border-emerald-300 bg-emerald-950/70 text-emerald-50' : '', game.active ? 'border-emerald-300 bg-emerald-950/80 text-emerald-50' : 'bg-neutral-950/80',
].filter(Boolean).join(' ')} ].filter(Boolean).join(' ')}
> >
<span className="block truncate font-semibold">{game.title}</span> <span className="flex items-start justify-between gap-1">
<span className="block text-[0.68rem] text-slate-300">{game.voteCount || 0} votes</span> <span className="text-base font-semibold leading-tight">{game.title}</span>
<span className="shrink-0 rounded border border-neutral-600 px-1 py-0.5 text-xs text-slate-200">
{game.voteCount || 0} votes
</span>
</span>
<span className="mt-1 block text-sm leading-snug text-slate-300">{game.description}</span>
<span className="mt-1 block text-xs font-semibold text-emerald-200">
{game.active ? 'Active' : game.actionLabel || 'Start'}
</span>
</button> </button>
); );
} }
@@ -41,12 +55,37 @@ function CounterList({ title, entries }) {
if (!Array.isArray(entries) || !entries.length) return null; if (!Array.isArray(entries) || !entries.length) return null;
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<p className="mb-0.5 text-[0.68rem] font-semibold text-slate-300">{title}</p> <p className="mb-1 text-sm font-semibold text-slate-200">{title}</p>
<div className="space-y-0.5"> <div className="space-y-1">
{entries.slice(0, 3).map((entry) => ( {entries.slice(0, 3).map((entry) => (
<p key={`${entry.entityId || entry.code}-${entry.type || 'counter'}`} className="truncate text-xs text-slate-100"> <div
{formatCounter(entry)} key={`${entry.entityId || entry.code}-${entry.type || 'counter'}`}
</p> className="flex items-center justify-between gap-2 rounded border border-neutral-700 bg-neutral-950/70 px-2 py-1"
>
<span className="min-w-0 truncate text-sm text-slate-100">{formatCounter(entry).label}</span>
<span className="font-mono text-sm font-semibold text-slate-200">{formatCounter(entry).count}</span>
</div>
))}
</div>
</div>
);
}
function Leaderboard({ players }) {
if (!Array.isArray(players) || !players.length) return null;
return (
<div>
<p className="mb-1 text-sm font-semibold text-slate-200">Player points</p>
<div className="space-y-1">
{players.slice(0, 5).map((player, idx) => (
<div
key={player.playerKey}
className="grid grid-cols-[2rem_minmax(0,1fr)_auto] items-center gap-2 rounded border border-neutral-700 bg-neutral-950/70 px-2 py-1"
>
<span className="font-mono text-sm text-slate-400">{idx + 1}</span>
<span className="truncate text-sm font-semibold text-white">{player.nickname}</span>
<span className="font-mono text-sm text-emerald-200">{player.totalPoints}</span>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -54,10 +93,12 @@ function CounterList({ title, entries }) {
} }
export default function BarcodeGamesPanel() { export default function BarcodeGamesPanel() {
const { state, voteForGame } = useBarcodeGameState(); const { state, resetActiveGame, voteForGame } = useBarcodeGameState();
const [pendingGameId, setPendingGameId] = useState(null); const [pendingGameId, setPendingGameId] = useState(null);
const [resetPending, setResetPending] = useState(false);
const activeGame = state.activeGame; const activeGame = state.activeGame;
const recordText = formatRecord(activeGame); const recordText = formatRecord(activeGame);
const remainingText = formatSeconds(activeGame?.remainingMs);
const counters = state.counters || {}; const counters = state.counters || {};
const voteButtons = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]); const voteButtons = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
@@ -70,9 +111,33 @@ export default function BarcodeGamesPanel() {
} }
}; };
const handleReset = async () => {
setResetPending(true);
try {
await resetActiveGame();
} finally {
setResetPending(false);
}
};
return ( return (
<CardFrame title="Barcode games" bodyClassName="space-y-1 p-1 text-sm"> <CardFrame
<div className="grid gap-0.5 sm:grid-cols-2"> title="Barcode games"
bodyClassName="space-y-3 p-2 text-sm"
actions={
activeGame ? (
<button
type="button"
className="button-dark px-2 py-1 text-xs disabled:opacity-50"
disabled={resetPending}
onClick={handleReset}
>
Reset active game
</button>
) : null
}
>
<div className="grid gap-2 md:grid-cols-2">
{voteButtons.map((game) => ( {voteButtons.map((game) => (
<GameVoteButton <GameVoteButton
key={game.id} key={game.id}
@@ -83,24 +148,38 @@ export default function BarcodeGamesPanel() {
))} ))}
</div> </div>
<div className="rounded border border-neutral-700 bg-neutral-950/70 p-1"> <div className="rounded border border-neutral-700 bg-neutral-950/80 p-3">
<p className="truncate text-sm font-semibold text-white"> <p className="truncate text-xl font-bold leading-tight text-white">
{activeGame?.title || 'No barcode game'} {activeGame?.title || 'No barcode game'}
</p> </p>
<p className="mt-0.5 text-xs text-slate-200"> <p className="mt-1 text-lg font-semibold leading-snug text-slate-100">
{activeGame?.headline || 'vote for a game to start'} {activeGame?.headline || 'vote for a game to start'}
</p> </p>
{activeGame?.detail ? ( {activeGame?.detail ? (
<p className="mt-0.5 truncate text-[0.72rem] text-slate-400">{activeGame.detail}</p> <p className="mt-1 text-sm leading-snug text-slate-300">{activeGame.detail}</p>
) : null}
{recordText ? (
<p className="mt-0.5 text-[0.72rem] text-emerald-200">world record {recordText}</p>
) : null} ) : null}
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{remainingText ? (
<div className="rounded border border-neutral-700 bg-black/30 px-2 py-1">
<p className="text-xs text-slate-400">Time left</p>
<p className="font-mono text-lg font-semibold text-white">{remainingText}</p>
</div>
) : null}
{recordText ? (
<div className="rounded border border-neutral-700 bg-black/30 px-2 py-1 sm:col-span-2">
<p className="text-xs text-slate-400">World record</p>
<p className="text-lg font-semibold text-emerald-200">{recordText}</p>
</div>
) : null}
</div>
</div> </div>
<div className="grid gap-1 sm:grid-cols-2"> <div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<CounterList title="Most scanned objects" entries={counters.objects} /> <Leaderboard players={state.leaderboard} />
<CounterList title="Most scanned rovers" entries={counters.rovers} /> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
<CounterList title="Most scanned objects" entries={counters.objects} />
<CounterList title="Most scanned rovers" entries={counters.rovers} />
</div>
</div> </div>
</CardFrame> </CardFrame>
); );