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
@@ -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),