mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
better barcode games
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user