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/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>
<script type="module" crossorigin src="/assets/index-BUN5_4YP.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DrszynNM.css">
<script type="module" crossorigin src="/assets/index-Bv_Ho3il.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-esPFU1Dr.css">
</head>
<body>
<div id="root"></div>
@@ -5,6 +5,7 @@
const GAME_ID = 'scanQuest';
const QUEST_LENGTH_OPTIONS = [1, 2];
const REQUEST_TIMEOUT_MS = 90 * 1000;
function createInitialState() {
return {
@@ -12,6 +13,7 @@ function createInitialState() {
progressIndex: 0,
scores: {},
completedQuests: 0,
stepStartedAt: null,
recentEvents: [],
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,
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
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) : [],
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
};
@@ -68,6 +71,7 @@ function ensureQuest(state, context = {}) {
const nextQuest = pickQuest(context.objects || []);
state.currentQuest = nextQuest;
state.progressIndex = 0;
state.stepStartedAt = nextQuest ? context.now || Date.now() : null;
state.lastMessage = nextQuest ? formatQuestPrompt(state) : 'scan quest needs objects';
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);
ensureQuest(state, context);
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);
ensureQuest(state, context);
skipExpiredQuest(state, context);
if (!state.currentQuest?.steps?.length) return state;
if (!scan?.known || scan.type !== 'object') return state;
if (!state.currentQuest?.steps?.length) return { state, awards: [] };
if (!scan?.known || scan.type !== 'object') return { state, awards: [] };
const expected = state.currentQuest.steps[state.progressIndex];
const matched = Boolean(expected && scan.code === expected.code);
@@ -123,10 +171,11 @@ function onScan(rawState, scan, context = {}) {
label: scan.label,
expected: expected.label,
});
return state;
return { state, awards: [] };
}
state.progressIndex += 1;
state.stepStartedAt = context.now || Date.now();
addRecentEvent(state, {
kind: 'hit',
label: scan.label,
@@ -134,7 +183,7 @@ function onScan(rawState, scan, context = {}) {
if (state.progressIndex < state.currentQuest.steps.length) {
state.lastMessage = formatQuestPrompt(state);
return state;
return { state, awards: [] };
}
const points = state.currentQuest.steps.length;
@@ -147,8 +196,18 @@ function onScan(rawState, scan, context = {}) {
});
state.currentQuest = pickQuest(context.objects || []);
state.progressIndex = 0;
state.stepStartedAt = state.currentQuest ? context.now || Date.now() : null;
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) {
@@ -160,6 +219,11 @@ function getTopScores(state) {
function getPublicState(rawState, context = {}) {
const state = normalizeState(rawState);
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 {
id: GAME_ID,
title: 'Scan quest',
@@ -172,6 +236,8 @@ function getPublicState(rawState, context = {}) {
current: state.progressIndex,
total: state.currentQuest?.steps?.length || 0,
},
remainingMs,
actionLabel: 'Start quest',
scores: getTopScores(state),
completedQuests: state.completedQuests,
recentEvents: state.recentEvents,
@@ -184,7 +250,12 @@ module.exports = {
description: 'Scan one or two requested objects in order.',
createInitialState,
normalizeState,
onActivated,
onScan,
activate,
reset,
handleScan,
tick,
onActivated: activate,
onScan: (state, scan, context) => handleScan(state, scan, context).state,
onTick: tick,
getPublicState,
};
@@ -5,6 +5,7 @@
const GAME_ID = 'scansPerSecond';
const ROUND_DURATION_MS = 5 * 60 * 1000;
const RESULT_IDLE_MS = 60 * 1000;
function createInitialState() {
return {
@@ -17,6 +18,7 @@ function createInitialState() {
worldRecord: null,
recentRounds: [],
participantCounts: {},
endedAt: null,
lastMessage: 'vote to start scans per second',
};
}
@@ -35,6 +37,7 @@ function normalizeState(rawState = {}) {
recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [],
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,
};
}
@@ -65,6 +68,7 @@ function finishRound(state, endedAt = Date.now()) {
const isWorldRecord = !previousRecord || result.scansPerSecond > (previousRecord.scansPerSecond || 0);
state.status = 'ended';
state.endedAt = endedAt;
state.finalResult = {
...result,
isWorldRecord,
@@ -77,6 +81,27 @@ function finishRound(state, endedAt = Date.now()) {
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()) {
const previous = normalizeState(rawState);
return {
@@ -88,19 +113,25 @@ function startRound(rawState, now = Date.now()) {
scans: [],
finalResult: null,
participantCounts: {},
endedAt: null,
lastMessage: 'scan anything',
};
}
function onActivated(rawState, context = {}) {
function activate(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.
// Activation is game-defined, but the shared service calls it generically.
// For this game, active rounds keep running while idle or ended rounds start
// cleanly so returning to the game always produces a playable challenge.
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 = []) {
participants.forEach((participant) => {
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();
let state = normalizeState(rawState);
let awards = [];
if (state.status === 'running' && state.endsAt && now >= 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
// invalid barcodes, because the challenge is about physically getting scans
@@ -138,19 +171,42 @@ function onScan(rawState, scan, context = {}) {
addParticipants(state, context.participants || []);
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);
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 state = normalizeState(rawState);
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;
}
@@ -185,6 +241,7 @@ function getPublicState(rawState, context = {}) {
worldRecord: state.worldRecord,
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
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.',
createInitialState,
normalizeState,
onActivated,
onScan,
onTick,
activate,
reset,
handleScan,
tick,
onActivated: activate,
onScan: (state, scan, context) => handleScan(state, scan, context).state,
onTick: tick,
getPublicState,
};
+145 -9
View File
@@ -16,6 +16,7 @@ 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_TICK_MS = 5 * 1000;
const GAME_DEFINITIONS = [scanQuest, scansPerSecond];
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,
nickname: participant.nickname || previous.nickname || null,
lastRoverId: participant.roverId || previous.lastRoverId || null,
totalPoints: Number.isFinite(previous.totalPoints) ? previous.totalPoints : 0,
lastSeenAt: now,
games: {
...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) {
const definition = getGameDefinition(gameId);
if (!definition) return null;
@@ -195,8 +245,8 @@ 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 }))
const nextState = definition.activate
? definition.activate(currentState, buildGameContext(draft, { now }))
: currentState;
draft.games[gameId] = nextState;
draft.activeGameId = gameId;
@@ -208,6 +258,36 @@ function activateGame(draft, gameId, now) {
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 = {}) {
const counts = {};
Object.values(votes || {}).forEach((vote) => {
@@ -278,14 +358,18 @@ function settleActiveGameIfNeeded() {
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;
}
if (!definition?.tick || !currentState) return false;
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) => {
const state = ensureGameState(draft, activeGameId);
draft.games[activeGameId] = definition.onTick(state, buildGameContext(draft, { now }));
draft.games[activeGameId] = nextState;
applyPointAwards(draft, activeGameId, awards, now);
});
return true;
}
function handleScan(scan) {
@@ -299,11 +383,13 @@ function handleScan(scan) {
if (definition) {
const currentState = ensureGameState(draft, activeGameId);
const nextState = definition.onScan
? definition.onScan(currentState, scan, buildGameContext(draft, { now, participants }))
const gameResult = definition.handleScan
? definition.handleScan(currentState, scan, buildGameContext(draft, { now, participants }))
: currentState;
const { state: nextState, awards } = normalizeGameResult(gameResult, currentState);
draft.games[activeGameId] = nextState;
recordPlayerParticipation(draft, activeGameId, participants, now);
applyPointAwards(draft, activeGameId, awards, now);
}
addRecentEvent(draft, {
@@ -318,6 +404,22 @@ function handleScan(scan) {
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) {
return Object.values(bucket || {})
.sort((a, b) => (b.count || 0) - (a.count || 0))
@@ -334,6 +436,7 @@ function buildStatePayload() {
const activeGame = activeDefinition
? activeDefinition.getPublicState(ensureReadonlyGameState(store, activeDefinition.id), context)
: null;
const leaderboard = topPlayers(store.players);
return {
activeGameId: store.activeGameId,
@@ -343,8 +446,12 @@ function buildStatePayload() {
description: game.description,
voteCount: voteCounts[game.id] || 0,
active: game.id === store.activeGameId,
actionLabel: game.id === store.activeGameId && activeGame?.actionLabel
? activeGame.actionLabel
: 'Start',
})),
activeGame,
leaderboard,
counters: {
objects: topCounters(store.globalCounters?.objects),
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) {
const definition = getGameDefinition(gameId);
if (!definition) return null;
@@ -380,6 +500,15 @@ io.on('connection', (socket) => {
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) => {
@@ -395,5 +524,12 @@ subscribe('barcode.scanned', (event) => {
module.exports = {
buildStatePayload,
handleScan,
resetActiveGame,
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 = {}) {
const base = createDefaultStore();
const votes = {};
@@ -87,7 +104,7 @@ function normalizeStoreShape(raw = {}) {
raw.recentRoverSightings && typeof raw.recentRoverSightings === 'object'
? raw.recentRoverSightings
: {},
players: raw.players && typeof raw.players === 'object' ? raw.players : {},
players: normalizePlayers(raw.players),
games: raw.games && typeof raw.games === 'object' ? raw.games : {},
recentEvents: Array.isArray(raw.recentEvents) ? raw.recentEvents.slice(-25) : [],
};
+19 -1
View File
@@ -67,11 +67,29 @@ export default function useBarcodeGameState() {
[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(
() => ({
state,
resetActiveGame,
voteForGame,
}),
[state, voteForGame],
[resetActiveGame, state, voteForGame],
);
}
+102 -23
View File
@@ -1,16 +1,22 @@
// 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.
// Purpose: Shows global barcode game state and voting controls inside the driver's Activities tab.
// Scope: Gives drivers a readable control/status surface while the scanner page remains the room-facing display.
import { useMemo, useState } from 'react';
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
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) {
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}`;
return { label, count };
}
function formatRecord(activeGame) {
@@ -27,12 +33,20 @@ function GameVoteButton({ game, disabled, onVote }) {
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' : '',
'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/80 text-emerald-50' : 'bg-neutral-950/80',
].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>
<span className="flex items-start justify-between gap-1">
<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>
);
}
@@ -41,12 +55,37 @@ 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">
<p className="mb-1 text-sm font-semibold text-slate-200">{title}</p>
<div className="space-y-1">
{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
key={`${entry.entityId || entry.code}-${entry.type || 'counter'}`}
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>
@@ -54,10 +93,12 @@ function CounterList({ title, entries }) {
}
export default function BarcodeGamesPanel() {
const { state, voteForGame } = useBarcodeGameState();
const { state, resetActiveGame, voteForGame } = useBarcodeGameState();
const [pendingGameId, setPendingGameId] = useState(null);
const [resetPending, setResetPending] = useState(false);
const activeGame = state.activeGame;
const recordText = formatRecord(activeGame);
const remainingText = formatSeconds(activeGame?.remainingMs);
const counters = state.counters || {};
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 (
<CardFrame title="Barcode games" bodyClassName="space-y-1 p-1 text-sm">
<div className="grid gap-0.5 sm:grid-cols-2">
<CardFrame
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) => (
<GameVoteButton
key={game.id}
@@ -83,25 +148,39 @@ export default function BarcodeGamesPanel() {
))}
</div>
<div className="rounded border border-neutral-700 bg-neutral-950/70 p-1">
<p className="truncate text-sm font-semibold text-white">
<div className="rounded border border-neutral-700 bg-neutral-950/80 p-3">
<p className="truncate text-xl font-bold leading-tight text-white">
{activeGame?.title || 'No barcode game'}
</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'}
</p>
{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}
<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 ? (
<p className="mt-0.5 text-[0.72rem] text-emerald-200">world record {recordText}</p>
<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 className="grid gap-1 sm:grid-cols-2">
<div className="grid gap-3 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<Leaderboard players={state.leaderboard} />
<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>
</CardFrame>
);
}