better barcode games

This commit is contained in:
legop3
2026-06-18 20:47:02 -04:00
parent 3bb31522c3
commit b7ff447fd4
9 changed files with 324 additions and 65 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-I6c2my6z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CBt8gbRK.css">
<script type="module" crossorigin src="/assets/index-DvrSfmPb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BlEp2Rqv.css">
</head>
<body>
<div id="root"></div>
@@ -6,10 +6,14 @@
const GAME_ID = 'scanQuest';
const QUEST_LENGTH_OPTIONS = [1, 2];
const REQUEST_TIMEOUT_MS = 90 * 1000;
const ROUND_DURATION_MS = 5 * 60 * 1000;
function createInitialState() {
return {
currentQuest: null,
status: 'idle',
roundStartedAt: null,
roundEndsAt: null,
progressIndex: 0,
scores: {},
completedQuests: 0,
@@ -24,6 +28,9 @@ function normalizeState(rawState = {}) {
return {
...base,
currentQuest: rawState.currentQuest && typeof rawState.currentQuest === 'object' ? rawState.currentQuest : null,
status: rawState.status === 'running' || rawState.status === 'ended' ? rawState.status : 'idle',
roundStartedAt: Number.isFinite(rawState.roundStartedAt) ? rawState.roundStartedAt : null,
roundEndsAt: Number.isFinite(rawState.roundEndsAt) ? rawState.roundEndsAt : 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,
@@ -117,16 +124,18 @@ function buildAwards(participants = [], points, reason) {
}));
}
function activate(rawState, context = {}) {
const state = normalizeState(rawState);
function start(_rawState, context = {}) {
const now = context.now || Date.now();
const state = createInitialState();
state.status = 'running';
state.roundStartedAt = now;
state.roundEndsAt = now + ROUND_DURATION_MS;
ensureQuest(state, context);
return state;
}
function reset(_rawState, context = {}) {
const state = createInitialState();
ensureQuest(state, context);
return state;
function activate(rawState, context = {}) {
return start(rawState, context);
}
function skipExpiredQuest(state, context = {}) {
@@ -152,6 +161,7 @@ function skipExpiredQuest(state, context = {}) {
function handleScan(rawState, scan, context = {}) {
const state = normalizeState(rawState);
if (state.status !== 'running') return { state, awards: [] };
ensureQuest(state, context);
skipExpiredQuest(state, context);
@@ -206,6 +216,19 @@ function handleScan(rawState, scan, context = {}) {
function tick(rawState, context = {}) {
const state = normalizeState(rawState);
const now = context.now || Date.now();
if (state.status === 'running' && state.roundEndsAt && now >= state.roundEndsAt) {
return {
state: {
...state,
status: 'ended',
lastMessage: `finished ${state.completedQuests} quests`,
},
awards: [],
done: true,
display: getPublicState({ ...state, status: 'ended' }, context).display,
};
}
ensureQuest(state, context);
return skipExpiredQuest(state, context);
}
@@ -230,7 +253,7 @@ function getPublicState(rawState, context = {}) {
return {
id: GAME_ID,
title: 'Scan quest',
status: state.currentQuest ? 'running' : 'needs_objects',
status: state.status === 'running' && state.currentQuest ? 'running' : state.status,
headline: state.lastMessage || formatQuestPrompt(state),
detail: state.currentQuest?.steps?.length
? state.currentQuest.steps.map((step) => step.label).join(' then ')
@@ -257,6 +280,7 @@ function getPublicState(rawState, context = {}) {
stats: [
{ label: 'Completed', value: state.completedQuests },
{ label: 'Quest points', value: getTopScores(state)[0]?.points || 0 },
{ label: 'Round', value: state.status === 'ended' ? 'Done' : 'Running' },
],
results: state.recentEvents.slice(0, 3).map((event) => ({
label: event.kind === 'timeout' ? 'Skipped' : event.kind,
@@ -276,7 +300,7 @@ module.exports = {
createInitialState,
normalizeState,
activate,
reset,
start,
handleScan,
tick,
onActivated: activate,
@@ -14,6 +14,8 @@ function createInitialState() {
startedAt: null,
endsAt: null,
scans: [],
bestRate: 0,
bestRateAt: null,
finalResult: null,
worldRecord: null,
recentRounds: [],
@@ -32,6 +34,8 @@ function normalizeState(rawState = {}) {
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)) : [],
bestRate: Number.isFinite(rawState.bestRate) ? Math.max(0, rawState.bestRate) : 0,
bestRateAt: Number.isFinite(rawState.bestRateAt) ? rawState.bestRateAt : null,
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) : [],
@@ -49,12 +53,14 @@ function calculateRate(scanCount, startedAt, endedAt) {
function buildResult(state, endedAt = Date.now()) {
const scanCount = state.scans.length;
const rate = calculateRate(scanCount, state.startedAt, endedAt);
const finalRate = calculateRate(scanCount, state.startedAt, endedAt);
const bestRate = Math.max(Number(state.bestRate || 0), finalRate);
return {
roundId: state.roundId,
scanCount,
durationMs: Math.max(0, endedAt - (state.startedAt || endedAt)),
scansPerSecond: Number(rate.toFixed(3)),
scansPerSecond: Number(bestRate.toFixed(3)),
finalScansPerSecond: Number(finalRate.toFixed(3)),
startedAt: state.startedAt,
endedAt,
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
@@ -111,6 +117,8 @@ function startRound(rawState, now = Date.now()) {
startedAt: now,
endsAt: now + ROUND_DURATION_MS,
scans: [],
bestRate: 0,
bestRateAt: null,
finalResult: null,
participantCounts: {},
endedAt: null,
@@ -118,13 +126,17 @@ function startRound(rawState, now = Date.now()) {
};
}
function activate(rawState, context = {}) {
function start(rawState, context = {}) {
const state = normalizeState(rawState);
const now = context.now || Date.now();
// 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);
// Starting a game should always produce a fresh playable round. The global
// service owns when a round starts, so this module does not need to preserve a
// prior ended/running state across lifecycle transitions.
return startRound(state, now);
}
function activate(rawState, context = {}) {
return start(rawState, context);
}
function reset(rawState, context = {}) {
@@ -155,7 +167,14 @@ function handleScan(rawState, scan, context = {}) {
state = finishRound(state, state.endsAt);
awards = buildAwards(state.finalResult, state);
}
if (state.status !== 'running') return { state, awards };
if (state.status !== 'running') {
return {
state,
awards,
done: Boolean(awards.length),
display: awards.length ? getPublicState(state, { ...context, now }).display : null,
};
}
// This game deliberately counts every submitted scan, including unknown and
// invalid barcodes, because the challenge is about physically getting scans
@@ -175,10 +194,16 @@ function handleScan(rawState, scan, context = {}) {
return {
state,
awards: buildAwards(state.finalResult, state),
done: true,
display: getPublicState(state, { ...context, now }).display,
};
}
const currentRate = calculateRate(state.scans.length, state.startedAt, now);
if (currentRate > (state.bestRate || 0)) {
state.bestRate = Number(currentRate.toFixed(3));
state.bestRateAt = now;
}
state.lastMessage = `${currentRate.toFixed(2)} scans per second`;
return { state, awards: [] };
}
@@ -191,6 +216,8 @@ function tick(rawState, context = {}) {
return {
state: finished,
awards: buildAwards(finished.finalResult, finished),
done: true,
display: getPublicState(finished, { ...context, now }).display,
};
}
if (state.status === 'ended' && state.endedAt && now - state.endedAt >= RESULT_IDLE_MS) {
@@ -204,6 +231,8 @@ function tick(rawState, context = {}) {
startedAt: null,
endsAt: null,
scans: [],
bestRate: 0,
bestRateAt: null,
participantCounts: {},
lastMessage: 'vote to start scans per second',
};
@@ -226,8 +255,9 @@ function getPublicState(rawState, context = {}) {
const worldRecordText = state.worldRecord
? `${Number(state.worldRecord.scansPerSecond || 0).toFixed(2)} scans per second`
: 'none yet';
const bestRate = Math.max(Number(state.bestRate || 0), state.finalResult?.scansPerSecond || 0);
const primary = state.status === 'running'
? `${Number(currentRate).toFixed(2)} scans per second`
? `${Number(bestRate).toFixed(2)} scans per second`
: state.finalResult
? `${Number(state.finalResult.scansPerSecond || 0).toFixed(2)} scans per second`
: 'Scan anything';
@@ -244,6 +274,7 @@ function getPublicState(rawState, context = {}) {
: 'five minute scan challenge',
scanCount: state.scans.length,
scansPerSecond: Number(currentRate.toFixed(3)),
bestRate: Number(bestRate.toFixed(3)),
remainingMs,
finalResult: state.finalResult,
worldRecord: state.worldRecord,
@@ -269,7 +300,7 @@ function getPublicState(rawState, context = {}) {
: null,
stats: [
{ label: 'Scans', value: state.scans.length },
{ label: 'Current rate', value: Number(currentRate).toFixed(2) },
{ label: 'Live rate', value: Number(currentRate).toFixed(2) },
{ label: 'World record', value: worldRecordText },
],
results: state.recentRounds.slice(0, 3).map((round) => ({
@@ -287,6 +318,7 @@ module.exports = {
createInitialState,
normalizeState,
activate,
start,
reset,
handleScan,
tick,
+214 -40
View File
@@ -17,6 +17,9 @@ const GAME_SOCKET_ROOM = 'barcode-game';
const RECENT_EVENT_LIMIT = 20;
const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000;
const GAME_TICK_MS = 5 * 1000;
const VOTING_WINDOW_MS = 10 * 1000;
const STARTING_WINDOW_MS = 5 * 1000;
const RESULTS_WINDOW_MS = 45 * 1000;
const GAME_DEFINITIONS = [scanQuest, scansPerSecond];
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
@@ -49,6 +52,12 @@ function normalizePlayerKey(identity = {}, socketId = '', roverId = '') {
return null;
}
function normalizeIdentityPlayerKey(identity = {}) {
// Permanent scoring is identity-only. Socket and rover IDs are useful runtime
// evidence, but they are unstable and should not create leaderboard entries.
return identity.cookieUserId ? `identity:${identity.cookieUserId}` : null;
}
function resolveRoverParticipant(roverId) {
const normalizedRoverId = String(roverId || '').trim();
if (!normalizedRoverId) return null;
@@ -56,7 +65,7 @@ function resolveRoverParticipant(roverId) {
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);
const playerKey = normalizeIdentityPlayerKey(identity);
return {
playerKey,
@@ -152,7 +161,7 @@ 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;
if (!participant?.playerKey || !String(participant.playerKey).startsWith('identity:')) return;
const previous = draft.players[participant.playerKey] || {};
const previousGames = previous.games || {};
const previousGame = previousGames[gameId] || {};
@@ -182,7 +191,7 @@ function applyPointAwards(draft, gameId, awards = [], now) {
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;
if (!playerKey || !String(playerKey).startsWith('identity:') || !points) return;
const previous = draft.players[playerKey] || {};
const previousGames = previous.games || {};
@@ -241,17 +250,27 @@ function buildGameContext(draft, extras = {}) {
};
}
function activateGame(draft, gameId, now) {
function startGame(draft, gameId, now) {
const definition = getGameDefinition(gameId);
if (!definition) return false;
const currentState = ensureGameState(draft, gameId);
const nextState = definition.activate
? definition.activate(currentState, buildGameContext(draft, { now }))
const nextState = definition.start
? definition.start(currentState, buildGameContext(draft, { now }))
: definition.activate
? definition.activate(currentState, buildGameContext(draft, { now }))
: currentState;
draft.games[gameId] = nextState;
draft.phase = 'running';
draft.runningGameId = gameId;
draft.selectedGameId = gameId;
draft.activeGameId = gameId;
draft.voteEndsAt = null;
draft.startsAt = null;
draft.resultsUntil = null;
draft.resultGameId = null;
draft.resultDisplay = null;
addRecentEvent(draft, {
kind: 'gameActivated',
kind: 'gameStarted',
gameId,
title: definition.title,
});
@@ -266,9 +285,11 @@ function normalizeGameResult(result, fallbackState) {
return {
state: result.state,
awards: Array.isArray(result.awards) ? result.awards : [],
done: Boolean(result.done),
display: result.display && typeof result.display === 'object' ? result.display : null,
};
}
return { state: result, awards: [] };
return { state: result, awards: [], done: false, display: null };
}
function countVotes(votes = {}) {
@@ -285,11 +306,11 @@ function chooseVoteWinner(draft) {
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;
const selectedCount = draft.selectedGameId ? counts[draft.selectedGameId] || 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;
// Ties keep the currently selected pending game so a single equalizing vote
// does not make the room display flicker during the voting window.
if (draft.selectedGameId && selectedCount === topCount) return draft.selectedGameId;
return topGameId;
}
@@ -306,6 +327,10 @@ function setVote(socket, gameId) {
const now = Date.now();
const voterKey = getVoterKey(socket);
const identity = getIdentitySummary(socket);
const current = loadStore();
if (current.phase === 'running' || current.phase === 'starting') {
return { error: 'a barcode game is already starting or running' };
}
withGameStore((draft) => {
draft.votes = draft.votes || {};
@@ -317,17 +342,18 @@ function setVote(socket, gameId) {
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);
const winner = chooseVoteWinner(draft) || definition.id;
draft.selectedGameId = winner;
if (draft.phase === 'idle' || draft.phase === 'results') {
draft.phase = 'voting';
draft.voteEndsAt = now + VOTING_WINDOW_MS;
draft.startsAt = null;
draft.runningGameId = null;
draft.activeGameId = null;
draft.resultsUntil = null;
draft.resultDisplay = null;
} else if (draft.phase === 'voting') {
draft.voteEndsAt = Math.max(draft.voteEndsAt || 0, now + VOTING_WINDOW_MS);
}
});
@@ -337,20 +363,78 @@ function setVote(socket, gameId) {
function settleActiveGameIfNeeded() {
const store = loadStore();
const activeGameId = store.activeGameId;
const now = Date.now();
const phase = store.phase || 'idle';
if (phase === 'voting' && store.voteEndsAt && now >= store.voteEndsAt) {
const winner = chooseVoteWinner(store) || store.selectedGameId;
if (!getGameDefinition(winner)) return false;
withGameStore((draft) => {
draft.selectedGameId = winner;
draft.phase = 'starting';
draft.startsAt = now + STARTING_WINDOW_MS;
draft.voteEndsAt = null;
addRecentEvent(draft, {
kind: 'gameStarting',
gameId: winner,
title: getGameDefinition(winner)?.title,
});
});
return true;
}
if (phase === 'starting' && store.startsAt && now >= store.startsAt) {
const gameId = store.selectedGameId;
if (!getGameDefinition(gameId)) return false;
withGameStore((draft) => {
startGame(draft, gameId, now);
});
return true;
}
if (phase === 'results' && store.resultsUntil && now >= store.resultsUntil) {
withGameStore((draft) => {
draft.phase = 'idle';
draft.selectedGameId = null;
draft.runningGameId = null;
draft.activeGameId = null;
draft.voteEndsAt = null;
draft.startsAt = null;
draft.resultsUntil = null;
draft.resultGameId = null;
draft.resultDisplay = null;
draft.votes = {};
});
return true;
}
const activeGameId = store.phase === 'running' ? store.runningGameId : null;
const definition = getGameDefinition(activeGameId);
const currentState = activeGameId ? store.games?.[activeGameId] : null;
const now = Date.now();
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;
const { state: nextState, awards, done, display } = normalizeGameResult(gameResult, normalizedState);
if (JSON.stringify(nextState) === JSON.stringify(normalizedState) && !done) return false;
withGameStore((draft) => {
draft.games[activeGameId] = nextState;
applyPointAwards(draft, activeGameId, awards, now);
if (done) {
const publicState = definition.getPublicState
? definition.getPublicState(nextState, buildGameContext(draft, { now }))
: null;
draft.phase = 'results';
draft.resultGameId = activeGameId;
draft.resultDisplay = display || publicState?.display || null;
draft.resultsUntil = now + RESULTS_WINDOW_MS;
draft.runningGameId = null;
draft.activeGameId = null;
draft.startsAt = null;
draft.voteEndsAt = null;
draft.votes = {};
}
});
return true;
}
@@ -361,7 +445,7 @@ function handleScan(scan) {
updateGlobalCounters(draft, scan, now);
recordRoverSighting(draft, scan, now);
const participants = getProximityParticipants(draft, now);
const activeGameId = draft.activeGameId;
const activeGameId = draft.phase === 'running' ? draft.runningGameId : null;
const definition = getGameDefinition(activeGameId);
if (definition) {
@@ -369,10 +453,24 @@ function handleScan(scan) {
const gameResult = definition.handleScan
? definition.handleScan(currentState, scan, buildGameContext(draft, { now, participants }))
: currentState;
const { state: nextState, awards } = normalizeGameResult(gameResult, currentState);
const { state: nextState, awards, done, display } = normalizeGameResult(gameResult, currentState);
draft.games[activeGameId] = nextState;
recordPlayerParticipation(draft, activeGameId, participants, now);
applyPointAwards(draft, activeGameId, awards, now);
if (done) {
const publicState = definition.getPublicState
? definition.getPublicState(nextState, buildGameContext(draft, { now }))
: null;
draft.phase = 'results';
draft.resultGameId = activeGameId;
draft.resultDisplay = display || publicState?.display || null;
draft.resultsUntil = now + RESULTS_WINDOW_MS;
draft.runningGameId = null;
draft.activeGameId = null;
draft.startsAt = null;
draft.voteEndsAt = null;
draft.votes = {};
}
}
addRecentEvent(draft, {
@@ -396,7 +494,7 @@ function topCounters(bucket = {}, limit = 5) {
function getPlayerForSocket(store, socket) {
if (!socket) return null;
const identity = getIdentitySummary(socket);
const playerKey = normalizePlayerKey(identity, socket.id, '');
const playerKey = normalizeIdentityPlayerKey(identity);
const player = playerKey ? store.players?.[playerKey] || null : null;
if (!player) {
return {
@@ -408,7 +506,7 @@ function getPlayerForSocket(store, socket) {
};
}
const rankedPlayers = Object.values(store.players || {})
.filter((entry) => Number.isFinite(entry?.totalPoints) && entry.totalPoints > 0)
.filter((entry) => String(entry?.playerKey || '').startsWith('identity:') && Number.isFinite(entry?.totalPoints) && entry.totalPoints > 0)
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0));
const rank = rankedPlayers.findIndex((entry) => entry.playerKey === player.playerKey) + 1;
return {
@@ -425,24 +523,34 @@ function buildStatePayload(socket = null) {
const store = loadStore();
const now = Date.now();
const voteCounts = countVotes(store.votes);
const activeDefinition = getGameDefinition(store.activeGameId);
const selectedDefinition = getGameDefinition(store.selectedGameId);
const runningDefinition = getGameDefinition(store.runningGameId);
const context = buildGameContext(store, { now });
const activeGame = activeDefinition
? activeDefinition.getPublicState(ensureReadonlyGameState(store, activeDefinition.id), context)
const runningGame = runningDefinition
? runningDefinition.getPublicState(ensureReadonlyGameState(store, runningDefinition.id), context)
: null;
const activeGame = buildLifecycleGameState(store, {
now,
selectedDefinition,
runningDefinition,
runningGame,
voteCounts,
});
const leaderboard = topPlayers(store.players);
return {
activeGameId: store.activeGameId,
phase: store.phase,
selectedGameId: store.selectedGameId,
runningGameId: store.runningGameId,
activeGameId: store.runningGameId,
games: GAME_DEFINITIONS.map((game) => ({
id: game.id,
title: game.title,
description: game.description,
voteCount: voteCounts[game.id] || 0,
active: game.id === store.activeGameId,
actionLabel: game.id === store.activeGameId && activeGame?.actionLabel
? activeGame.actionLabel
: 'Start',
active: game.id === store.runningGameId,
selected: game.id === store.selectedGameId,
actionLabel: store.phase === 'idle' || store.phase === 'results' ? 'Vote' : game.id === store.selectedGameId ? 'Selected' : 'Vote',
})),
activeGame,
leaderboard,
@@ -456,9 +564,75 @@ function buildStatePayload(socket = null) {
};
}
function buildLifecycleGameState(store, { now, selectedDefinition, runningDefinition, runningGame, voteCounts }) {
if (store.phase === 'running' && runningGame) return runningGame;
if (store.phase === 'results') {
return {
id: store.resultGameId,
title: getGameDefinition(store.resultGameId)?.title || 'Results',
status: 'results',
display: {
title: 'Results',
primary: store.resultDisplay?.primary || 'Round complete',
secondary: store.resultDisplay?.secondary || 'Vote to start another game',
timer: store.resultsUntil ? { label: 'Results clear in', endsAt: store.resultsUntil } : null,
stats: store.resultDisplay?.stats || [],
results: store.resultDisplay?.results || [],
},
};
}
if (store.phase === 'starting') {
return {
id: store.selectedGameId,
title: selectedDefinition?.title || 'Starting',
status: 'starting',
display: {
title: 'Starting',
primary: selectedDefinition ? `${selectedDefinition.title} starts soon` : 'Game starts soon',
secondary: 'Get ready',
timer: store.startsAt ? { label: 'Starts in', endsAt: store.startsAt } : null,
stats: [],
results: [],
},
};
}
if (store.phase === 'voting') {
const selectedTitle = selectedDefinition?.title || 'a barcode game';
return {
id: store.selectedGameId,
title: 'Voting',
status: 'voting',
display: {
title: 'Voting',
primary: `Voting for ${selectedTitle}`,
secondary: 'Most votes starts the next game',
timer: store.voteEndsAt ? { label: 'Voting ends in', endsAt: store.voteEndsAt } : null,
stats: GAME_DEFINITIONS.map((game) => ({
label: game.title,
value: voteCounts[game.id] || 0,
})),
results: [],
},
};
}
return {
id: null,
title: 'Barcode games',
status: 'idle',
display: {
title: 'Barcode games',
primary: 'Choose a game',
secondary: 'Vote to start the next round',
timer: null,
stats: [],
results: [],
},
};
}
function topPlayers(players = {}, limit = 6) {
return Object.values(players || {})
.filter((player) => Number.isFinite(player?.totalPoints) && player.totalPoints > 0)
.filter((player) => String(player?.playerKey || '').startsWith('identity:') && Number.isFinite(player?.totalPoints) && player.totalPoints > 0)
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0))
.slice(0, limit)
.map((player) => ({
@@ -13,6 +13,14 @@ function createDefaultStore() {
return {
version: STORE_VERSION,
updatedAt: Date.now(),
phase: 'idle',
selectedGameId: null,
runningGameId: null,
voteEndsAt: null,
startsAt: null,
resultsUntil: null,
resultGameId: null,
resultDisplay: null,
activeGameId: null,
votes: {},
globalCounters: {
@@ -89,11 +97,30 @@ function normalizeStoreShape(raw = {}) {
if (vote) votes[key] = { ...vote, voterKey: vote.voterKey || key };
});
const phase = ['idle', 'voting', 'starting', 'running', 'results'].includes(raw.phase)
? raw.phase
: raw.activeGameId
? 'running'
: 'idle';
const runningGameId = typeof raw.runningGameId === 'string'
? raw.runningGameId
: typeof raw.activeGameId === 'string'
? raw.activeGameId
: null;
return {
...base,
version: STORE_VERSION,
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
activeGameId: typeof raw.activeGameId === 'string' ? raw.activeGameId : null,
phase,
selectedGameId: typeof raw.selectedGameId === 'string' ? raw.selectedGameId : runningGameId,
runningGameId,
voteEndsAt: Number.isFinite(raw.voteEndsAt) ? raw.voteEndsAt : null,
startsAt: Number.isFinite(raw.startsAt) ? raw.startsAt : null,
resultsUntil: Number.isFinite(raw.resultsUntil) ? raw.resultsUntil : null,
resultGameId: typeof raw.resultGameId === 'string' ? raw.resultGameId : null,
resultDisplay: raw.resultDisplay && typeof raw.resultDisplay === 'object' ? raw.resultDisplay : null,
activeGameId: runningGameId,
votes,
globalCounters: {
codes: normalizeCounterBucket(raw.globalCounters?.codes),
@@ -36,6 +36,7 @@ function GameChoice({ game, disabled, onVote }) {
className={[
'button-dark flex min-h-[4.25rem] flex-col items-start justify-between px-1.5 py-1 text-left disabled:opacity-70',
game.active ? 'border-emerald-500/70' : '',
game.selected && !game.active ? 'border-cyan-500/60' : '',
].filter(Boolean).join(' ')}
>
<span className="flex w-full items-start justify-between gap-1">
@@ -44,7 +45,7 @@ function GameChoice({ game, disabled, onVote }) {
</span>
<span className="mt-0.5 line-clamp-2 text-xs leading-snug text-neutral-300">{game.description}</span>
<span className="mt-0.5 text-[0.7rem] font-semibold text-neutral-200">
{game.active ? 'Active' : game.actionLabel || 'Start'}
{game.active ? 'Active' : game.selected ? 'Selected' : game.actionLabel || 'Vote'}
</span>
</button>
);
@@ -119,6 +120,7 @@ export default function BarcodeGamesPanel() {
const [pendingGameId, setPendingGameId] = useState(null);
const activeGame = state.activeGame;
const display = activeGame?.display || {};
const votingDisabled = state.phase === 'starting' || state.phase === 'running';
const timerEndsAt = display.timer?.endsAt;
const now = useClock(Number.isFinite(timerEndsAt));
const timerText = formatTimer(timerEndsAt, now);
@@ -140,7 +142,7 @@ export default function BarcodeGamesPanel() {
<GameChoice
key={game.id}
game={game}
disabled={Boolean(pendingGameId)}
disabled={Boolean(pendingGameId) || votingDisabled}
onVote={handleVote}
/>
))}