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:
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
@@ -18,8 +18,8 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-I6c2my6z.js"></script>
|
<script type="module" crossorigin src="/assets/index-DvrSfmPb.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CBt8gbRK.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BlEp2Rqv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
const GAME_ID = 'scanQuest';
|
const GAME_ID = 'scanQuest';
|
||||||
const QUEST_LENGTH_OPTIONS = [1, 2];
|
const QUEST_LENGTH_OPTIONS = [1, 2];
|
||||||
const REQUEST_TIMEOUT_MS = 90 * 1000;
|
const REQUEST_TIMEOUT_MS = 90 * 1000;
|
||||||
|
const ROUND_DURATION_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
function createInitialState() {
|
function createInitialState() {
|
||||||
return {
|
return {
|
||||||
currentQuest: null,
|
currentQuest: null,
|
||||||
|
status: 'idle',
|
||||||
|
roundStartedAt: null,
|
||||||
|
roundEndsAt: null,
|
||||||
progressIndex: 0,
|
progressIndex: 0,
|
||||||
scores: {},
|
scores: {},
|
||||||
completedQuests: 0,
|
completedQuests: 0,
|
||||||
@@ -24,6 +28,9 @@ function normalizeState(rawState = {}) {
|
|||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
currentQuest: rawState.currentQuest && typeof rawState.currentQuest === 'object' ? rawState.currentQuest : null,
|
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,
|
progressIndex: Number.isFinite(rawState.progressIndex) ? Math.max(0, Math.floor(rawState.progressIndex)) : 0,
|
||||||
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
|
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
|
||||||
completedQuests: Number.isFinite(rawState.completedQuests) ? Math.max(0, Math.floor(rawState.completedQuests)) : 0,
|
completedQuests: Number.isFinite(rawState.completedQuests) ? Math.max(0, Math.floor(rawState.completedQuests)) : 0,
|
||||||
@@ -117,16 +124,18 @@ function buildAwards(participants = [], points, reason) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate(rawState, context = {}) {
|
function start(_rawState, context = {}) {
|
||||||
const state = normalizeState(rawState);
|
const now = context.now || Date.now();
|
||||||
|
const state = createInitialState();
|
||||||
|
state.status = 'running';
|
||||||
|
state.roundStartedAt = now;
|
||||||
|
state.roundEndsAt = now + ROUND_DURATION_MS;
|
||||||
ensureQuest(state, context);
|
ensureQuest(state, context);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset(_rawState, context = {}) {
|
function activate(rawState, context = {}) {
|
||||||
const state = createInitialState();
|
return start(rawState, context);
|
||||||
ensureQuest(state, context);
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipExpiredQuest(state, context = {}) {
|
function skipExpiredQuest(state, context = {}) {
|
||||||
@@ -152,6 +161,7 @@ function skipExpiredQuest(state, context = {}) {
|
|||||||
|
|
||||||
function handleScan(rawState, scan, context = {}) {
|
function handleScan(rawState, scan, context = {}) {
|
||||||
const state = normalizeState(rawState);
|
const state = normalizeState(rawState);
|
||||||
|
if (state.status !== 'running') return { state, awards: [] };
|
||||||
ensureQuest(state, context);
|
ensureQuest(state, context);
|
||||||
skipExpiredQuest(state, context);
|
skipExpiredQuest(state, context);
|
||||||
|
|
||||||
@@ -206,6 +216,19 @@ function handleScan(rawState, scan, context = {}) {
|
|||||||
|
|
||||||
function tick(rawState, context = {}) {
|
function tick(rawState, context = {}) {
|
||||||
const state = normalizeState(rawState);
|
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);
|
ensureQuest(state, context);
|
||||||
return skipExpiredQuest(state, context);
|
return skipExpiredQuest(state, context);
|
||||||
}
|
}
|
||||||
@@ -230,7 +253,7 @@ function getPublicState(rawState, context = {}) {
|
|||||||
return {
|
return {
|
||||||
id: GAME_ID,
|
id: GAME_ID,
|
||||||
title: 'Scan quest',
|
title: 'Scan quest',
|
||||||
status: state.currentQuest ? 'running' : 'needs_objects',
|
status: state.status === 'running' && state.currentQuest ? 'running' : state.status,
|
||||||
headline: state.lastMessage || formatQuestPrompt(state),
|
headline: state.lastMessage || formatQuestPrompt(state),
|
||||||
detail: state.currentQuest?.steps?.length
|
detail: state.currentQuest?.steps?.length
|
||||||
? state.currentQuest.steps.map((step) => step.label).join(' then ')
|
? state.currentQuest.steps.map((step) => step.label).join(' then ')
|
||||||
@@ -257,6 +280,7 @@ function getPublicState(rawState, context = {}) {
|
|||||||
stats: [
|
stats: [
|
||||||
{ label: 'Completed', value: state.completedQuests },
|
{ label: 'Completed', value: state.completedQuests },
|
||||||
{ label: 'Quest points', value: getTopScores(state)[0]?.points || 0 },
|
{ 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) => ({
|
results: state.recentEvents.slice(0, 3).map((event) => ({
|
||||||
label: event.kind === 'timeout' ? 'Skipped' : event.kind,
|
label: event.kind === 'timeout' ? 'Skipped' : event.kind,
|
||||||
@@ -276,7 +300,7 @@ module.exports = {
|
|||||||
createInitialState,
|
createInitialState,
|
||||||
normalizeState,
|
normalizeState,
|
||||||
activate,
|
activate,
|
||||||
reset,
|
start,
|
||||||
handleScan,
|
handleScan,
|
||||||
tick,
|
tick,
|
||||||
onActivated: activate,
|
onActivated: activate,
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ function createInitialState() {
|
|||||||
startedAt: null,
|
startedAt: null,
|
||||||
endsAt: null,
|
endsAt: null,
|
||||||
scans: [],
|
scans: [],
|
||||||
|
bestRate: 0,
|
||||||
|
bestRateAt: null,
|
||||||
finalResult: null,
|
finalResult: null,
|
||||||
worldRecord: null,
|
worldRecord: null,
|
||||||
recentRounds: [],
|
recentRounds: [],
|
||||||
@@ -32,6 +34,8 @@ function normalizeState(rawState = {}) {
|
|||||||
startedAt: Number.isFinite(rawState.startedAt) ? rawState.startedAt : null,
|
startedAt: Number.isFinite(rawState.startedAt) ? rawState.startedAt : null,
|
||||||
endsAt: Number.isFinite(rawState.endsAt) ? rawState.endsAt : null,
|
endsAt: Number.isFinite(rawState.endsAt) ? rawState.endsAt : null,
|
||||||
scans: Array.isArray(rawState.scans) ? rawState.scans.filter((entry) => Number.isFinite(entry?.at)) : [],
|
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,
|
finalResult: rawState.finalResult && typeof rawState.finalResult === 'object' ? rawState.finalResult : null,
|
||||||
worldRecord: rawState.worldRecord && typeof rawState.worldRecord === 'object' ? rawState.worldRecord : null,
|
worldRecord: rawState.worldRecord && typeof rawState.worldRecord === 'object' ? rawState.worldRecord : null,
|
||||||
recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [],
|
recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [],
|
||||||
@@ -49,12 +53,14 @@ function calculateRate(scanCount, startedAt, endedAt) {
|
|||||||
|
|
||||||
function buildResult(state, endedAt = Date.now()) {
|
function buildResult(state, endedAt = Date.now()) {
|
||||||
const scanCount = state.scans.length;
|
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 {
|
return {
|
||||||
roundId: state.roundId,
|
roundId: state.roundId,
|
||||||
scanCount,
|
scanCount,
|
||||||
durationMs: Math.max(0, endedAt - (state.startedAt || endedAt)),
|
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,
|
startedAt: state.startedAt,
|
||||||
endedAt,
|
endedAt,
|
||||||
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
|
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
|
||||||
@@ -111,6 +117,8 @@ function startRound(rawState, now = Date.now()) {
|
|||||||
startedAt: now,
|
startedAt: now,
|
||||||
endsAt: now + ROUND_DURATION_MS,
|
endsAt: now + ROUND_DURATION_MS,
|
||||||
scans: [],
|
scans: [],
|
||||||
|
bestRate: 0,
|
||||||
|
bestRateAt: null,
|
||||||
finalResult: null,
|
finalResult: null,
|
||||||
participantCounts: {},
|
participantCounts: {},
|
||||||
endedAt: null,
|
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 state = normalizeState(rawState);
|
||||||
const now = context.now || Date.now();
|
const now = context.now || Date.now();
|
||||||
// Activation is game-defined, but the shared service calls it generically.
|
// Starting a game should always produce a fresh playable round. The global
|
||||||
// For this game, active rounds keep running while idle or ended rounds start
|
// service owns when a round starts, so this module does not need to preserve a
|
||||||
// cleanly so returning to the game always produces a playable challenge.
|
// prior ended/running state across lifecycle transitions.
|
||||||
return state.status === 'running' ? state : startRound(state, now);
|
return startRound(state, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(rawState, context = {}) {
|
||||||
|
return start(rawState, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
function reset(rawState, context = {}) {
|
function reset(rawState, context = {}) {
|
||||||
@@ -155,7 +167,14 @@ function handleScan(rawState, scan, context = {}) {
|
|||||||
state = finishRound(state, state.endsAt);
|
state = finishRound(state, state.endsAt);
|
||||||
awards = buildAwards(state.finalResult, state);
|
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
|
// This game deliberately counts every submitted scan, including unknown and
|
||||||
// invalid barcodes, because the challenge is about physically getting scans
|
// invalid barcodes, because the challenge is about physically getting scans
|
||||||
@@ -175,10 +194,16 @@ function handleScan(rawState, scan, context = {}) {
|
|||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
awards: buildAwards(state.finalResult, state),
|
awards: buildAwards(state.finalResult, state),
|
||||||
|
done: true,
|
||||||
|
display: getPublicState(state, { ...context, now }).display,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentRate = calculateRate(state.scans.length, state.startedAt, now);
|
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`;
|
state.lastMessage = `${currentRate.toFixed(2)} scans per second`;
|
||||||
return { state, awards: [] };
|
return { state, awards: [] };
|
||||||
}
|
}
|
||||||
@@ -191,6 +216,8 @@ function tick(rawState, context = {}) {
|
|||||||
return {
|
return {
|
||||||
state: finished,
|
state: finished,
|
||||||
awards: buildAwards(finished.finalResult, 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) {
|
if (state.status === 'ended' && state.endedAt && now - state.endedAt >= RESULT_IDLE_MS) {
|
||||||
@@ -204,6 +231,8 @@ function tick(rawState, context = {}) {
|
|||||||
startedAt: null,
|
startedAt: null,
|
||||||
endsAt: null,
|
endsAt: null,
|
||||||
scans: [],
|
scans: [],
|
||||||
|
bestRate: 0,
|
||||||
|
bestRateAt: null,
|
||||||
participantCounts: {},
|
participantCounts: {},
|
||||||
lastMessage: 'vote to start scans per second',
|
lastMessage: 'vote to start scans per second',
|
||||||
};
|
};
|
||||||
@@ -226,8 +255,9 @@ function getPublicState(rawState, context = {}) {
|
|||||||
const worldRecordText = state.worldRecord
|
const worldRecordText = state.worldRecord
|
||||||
? `${Number(state.worldRecord.scansPerSecond || 0).toFixed(2)} scans per second`
|
? `${Number(state.worldRecord.scansPerSecond || 0).toFixed(2)} scans per second`
|
||||||
: 'none yet';
|
: 'none yet';
|
||||||
|
const bestRate = Math.max(Number(state.bestRate || 0), state.finalResult?.scansPerSecond || 0);
|
||||||
const primary = state.status === 'running'
|
const primary = state.status === 'running'
|
||||||
? `${Number(currentRate).toFixed(2)} scans per second`
|
? `${Number(bestRate).toFixed(2)} scans per second`
|
||||||
: state.finalResult
|
: state.finalResult
|
||||||
? `${Number(state.finalResult.scansPerSecond || 0).toFixed(2)} scans per second`
|
? `${Number(state.finalResult.scansPerSecond || 0).toFixed(2)} scans per second`
|
||||||
: 'Scan anything';
|
: 'Scan anything';
|
||||||
@@ -244,6 +274,7 @@ function getPublicState(rawState, context = {}) {
|
|||||||
: 'five minute scan challenge',
|
: 'five minute scan challenge',
|
||||||
scanCount: state.scans.length,
|
scanCount: state.scans.length,
|
||||||
scansPerSecond: Number(currentRate.toFixed(3)),
|
scansPerSecond: Number(currentRate.toFixed(3)),
|
||||||
|
bestRate: Number(bestRate.toFixed(3)),
|
||||||
remainingMs,
|
remainingMs,
|
||||||
finalResult: state.finalResult,
|
finalResult: state.finalResult,
|
||||||
worldRecord: state.worldRecord,
|
worldRecord: state.worldRecord,
|
||||||
@@ -269,7 +300,7 @@ function getPublicState(rawState, context = {}) {
|
|||||||
: null,
|
: null,
|
||||||
stats: [
|
stats: [
|
||||||
{ label: 'Scans', value: state.scans.length },
|
{ 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 },
|
{ label: 'World record', value: worldRecordText },
|
||||||
],
|
],
|
||||||
results: state.recentRounds.slice(0, 3).map((round) => ({
|
results: state.recentRounds.slice(0, 3).map((round) => ({
|
||||||
@@ -287,6 +318,7 @@ module.exports = {
|
|||||||
createInitialState,
|
createInitialState,
|
||||||
normalizeState,
|
normalizeState,
|
||||||
activate,
|
activate,
|
||||||
|
start,
|
||||||
reset,
|
reset,
|
||||||
handleScan,
|
handleScan,
|
||||||
tick,
|
tick,
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ const GAME_SOCKET_ROOM = 'barcode-game';
|
|||||||
const RECENT_EVENT_LIMIT = 20;
|
const RECENT_EVENT_LIMIT = 20;
|
||||||
const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000;
|
const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000;
|
||||||
const GAME_TICK_MS = 5 * 1000;
|
const GAME_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 GAME_DEFINITIONS = [scanQuest, scansPerSecond];
|
||||||
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
||||||
@@ -49,6 +52,12 @@ function normalizePlayerKey(identity = {}, socketId = '', roverId = '') {
|
|||||||
return null;
|
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) {
|
function resolveRoverParticipant(roverId) {
|
||||||
const normalizedRoverId = String(roverId || '').trim();
|
const normalizedRoverId = String(roverId || '').trim();
|
||||||
if (!normalizedRoverId) return null;
|
if (!normalizedRoverId) return null;
|
||||||
@@ -56,7 +65,7 @@ function resolveRoverParticipant(roverId) {
|
|||||||
const socketId = activeDrivers?.[normalizedRoverId] || null;
|
const socketId = activeDrivers?.[normalizedRoverId] || null;
|
||||||
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
const socket = socketId ? io.sockets.sockets.get(socketId) : null;
|
||||||
const identity = socket ? getIdentitySummary(socket) : {};
|
const identity = socket ? getIdentitySummary(socket) : {};
|
||||||
const playerKey = normalizePlayerKey(identity, socketId, normalizedRoverId);
|
const playerKey = normalizeIdentityPlayerKey(identity);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
playerKey,
|
playerKey,
|
||||||
@@ -152,7 +161,7 @@ function recordPlayerParticipation(draft, gameId, participants, now) {
|
|||||||
if (!gameId || !Array.isArray(participants) || !participants.length) return;
|
if (!gameId || !Array.isArray(participants) || !participants.length) return;
|
||||||
draft.players = draft.players || {};
|
draft.players = draft.players || {};
|
||||||
participants.forEach((participant) => {
|
participants.forEach((participant) => {
|
||||||
if (!participant?.playerKey) return;
|
if (!participant?.playerKey || !String(participant.playerKey).startsWith('identity:')) return;
|
||||||
const previous = draft.players[participant.playerKey] || {};
|
const previous = draft.players[participant.playerKey] || {};
|
||||||
const previousGames = previous.games || {};
|
const previousGames = previous.games || {};
|
||||||
const previousGame = previousGames[gameId] || {};
|
const previousGame = previousGames[gameId] || {};
|
||||||
@@ -182,7 +191,7 @@ function applyPointAwards(draft, gameId, awards = [], now) {
|
|||||||
awards.forEach((award) => {
|
awards.forEach((award) => {
|
||||||
const playerKey = award?.playerKey;
|
const playerKey = award?.playerKey;
|
||||||
const points = Number.isFinite(award?.points) ? Math.max(0, Math.floor(award.points)) : 0;
|
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 previous = draft.players[playerKey] || {};
|
||||||
const previousGames = previous.games || {};
|
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);
|
const definition = getGameDefinition(gameId);
|
||||||
if (!definition) return false;
|
if (!definition) return false;
|
||||||
const currentState = ensureGameState(draft, gameId);
|
const currentState = ensureGameState(draft, gameId);
|
||||||
const nextState = definition.activate
|
const nextState = definition.start
|
||||||
? definition.activate(currentState, buildGameContext(draft, { now }))
|
? definition.start(currentState, buildGameContext(draft, { now }))
|
||||||
|
: definition.activate
|
||||||
|
? definition.activate(currentState, buildGameContext(draft, { now }))
|
||||||
: currentState;
|
: currentState;
|
||||||
draft.games[gameId] = nextState;
|
draft.games[gameId] = nextState;
|
||||||
|
draft.phase = 'running';
|
||||||
|
draft.runningGameId = gameId;
|
||||||
|
draft.selectedGameId = gameId;
|
||||||
draft.activeGameId = gameId;
|
draft.activeGameId = gameId;
|
||||||
|
draft.voteEndsAt = null;
|
||||||
|
draft.startsAt = null;
|
||||||
|
draft.resultsUntil = null;
|
||||||
|
draft.resultGameId = null;
|
||||||
|
draft.resultDisplay = null;
|
||||||
addRecentEvent(draft, {
|
addRecentEvent(draft, {
|
||||||
kind: 'gameActivated',
|
kind: 'gameStarted',
|
||||||
gameId,
|
gameId,
|
||||||
title: definition.title,
|
title: definition.title,
|
||||||
});
|
});
|
||||||
@@ -266,9 +285,11 @@ function normalizeGameResult(result, fallbackState) {
|
|||||||
return {
|
return {
|
||||||
state: result.state,
|
state: result.state,
|
||||||
awards: Array.isArray(result.awards) ? result.awards : [],
|
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 = {}) {
|
function countVotes(votes = {}) {
|
||||||
@@ -285,11 +306,11 @@ function chooseVoteWinner(draft) {
|
|||||||
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]);
|
||||||
if (!entries.length) return null;
|
if (!entries.length) return null;
|
||||||
const [topGameId, topCount] = entries[0];
|
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
|
// Ties keep the currently selected pending game so a single equalizing vote
|
||||||
// room display to flicker back and forth between games.
|
// does not make the room display flicker during the voting window.
|
||||||
if (draft.activeGameId && activeCount === topCount) return draft.activeGameId;
|
if (draft.selectedGameId && selectedCount === topCount) return draft.selectedGameId;
|
||||||
return topGameId;
|
return topGameId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +327,10 @@ function setVote(socket, gameId) {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const voterKey = getVoterKey(socket);
|
const voterKey = getVoterKey(socket);
|
||||||
const identity = getIdentitySummary(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) => {
|
withGameStore((draft) => {
|
||||||
draft.votes = draft.votes || {};
|
draft.votes = draft.votes || {};
|
||||||
@@ -317,17 +342,18 @@ function setVote(socket, gameId) {
|
|||||||
votedAt: now,
|
votedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
const winner = chooseVoteWinner(draft);
|
const winner = chooseVoteWinner(draft) || definition.id;
|
||||||
const existingWinnerState = winner ? draft.games?.[winner] : null;
|
draft.selectedGameId = winner;
|
||||||
const shouldActivateWinner = Boolean(
|
if (draft.phase === 'idle' || draft.phase === 'results') {
|
||||||
winner &&
|
draft.phase = 'voting';
|
||||||
(!draft.activeGameId ||
|
draft.voteEndsAt = now + VOTING_WINDOW_MS;
|
||||||
winner !== draft.activeGameId ||
|
draft.startsAt = null;
|
||||||
existingWinnerState?.status === 'ended' ||
|
draft.runningGameId = null;
|
||||||
existingWinnerState?.status === 'idle'),
|
draft.activeGameId = null;
|
||||||
);
|
draft.resultsUntil = null;
|
||||||
if (shouldActivateWinner) {
|
draft.resultDisplay = null;
|
||||||
activateGame(draft, winner, now);
|
} 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() {
|
function settleActiveGameIfNeeded() {
|
||||||
const store = loadStore();
|
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 definition = getGameDefinition(activeGameId);
|
||||||
const currentState = activeGameId ? store.games?.[activeGameId] : null;
|
const currentState = activeGameId ? store.games?.[activeGameId] : null;
|
||||||
const now = Date.now();
|
|
||||||
if (!definition?.tick || !currentState) return false;
|
if (!definition?.tick || !currentState) return false;
|
||||||
|
|
||||||
const normalizedState = ensureReadonlyGameState(store, activeGameId);
|
const normalizedState = ensureReadonlyGameState(store, activeGameId);
|
||||||
const gameResult = definition.tick(normalizedState, buildGameContext(store, { now }));
|
const gameResult = definition.tick(normalizedState, buildGameContext(store, { now }));
|
||||||
const { state: nextState, awards } = normalizeGameResult(gameResult, normalizedState);
|
const { state: nextState, awards, done, display } = normalizeGameResult(gameResult, normalizedState);
|
||||||
if (JSON.stringify(nextState) === JSON.stringify(normalizedState)) return false;
|
if (JSON.stringify(nextState) === JSON.stringify(normalizedState) && !done) return false;
|
||||||
|
|
||||||
withGameStore((draft) => {
|
withGameStore((draft) => {
|
||||||
draft.games[activeGameId] = nextState;
|
draft.games[activeGameId] = nextState;
|
||||||
applyPointAwards(draft, activeGameId, awards, 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 = {};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -361,7 +445,7 @@ function handleScan(scan) {
|
|||||||
updateGlobalCounters(draft, scan, now);
|
updateGlobalCounters(draft, scan, now);
|
||||||
recordRoverSighting(draft, scan, now);
|
recordRoverSighting(draft, scan, now);
|
||||||
const participants = getProximityParticipants(draft, now);
|
const participants = getProximityParticipants(draft, now);
|
||||||
const activeGameId = draft.activeGameId;
|
const activeGameId = draft.phase === 'running' ? draft.runningGameId : null;
|
||||||
const definition = getGameDefinition(activeGameId);
|
const definition = getGameDefinition(activeGameId);
|
||||||
|
|
||||||
if (definition) {
|
if (definition) {
|
||||||
@@ -369,10 +453,24 @@ function handleScan(scan) {
|
|||||||
const gameResult = definition.handleScan
|
const gameResult = definition.handleScan
|
||||||
? definition.handleScan(currentState, scan, buildGameContext(draft, { now, participants }))
|
? definition.handleScan(currentState, scan, buildGameContext(draft, { now, participants }))
|
||||||
: currentState;
|
: currentState;
|
||||||
const { state: nextState, awards } = normalizeGameResult(gameResult, currentState);
|
const { state: nextState, awards, done, display } = normalizeGameResult(gameResult, currentState);
|
||||||
draft.games[activeGameId] = nextState;
|
draft.games[activeGameId] = nextState;
|
||||||
recordPlayerParticipation(draft, activeGameId, participants, now);
|
recordPlayerParticipation(draft, activeGameId, participants, now);
|
||||||
applyPointAwards(draft, activeGameId, awards, now);
|
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, {
|
addRecentEvent(draft, {
|
||||||
@@ -396,7 +494,7 @@ function topCounters(bucket = {}, limit = 5) {
|
|||||||
function getPlayerForSocket(store, socket) {
|
function getPlayerForSocket(store, socket) {
|
||||||
if (!socket) return null;
|
if (!socket) return null;
|
||||||
const identity = getIdentitySummary(socket);
|
const identity = getIdentitySummary(socket);
|
||||||
const playerKey = normalizePlayerKey(identity, socket.id, '');
|
const playerKey = normalizeIdentityPlayerKey(identity);
|
||||||
const player = playerKey ? store.players?.[playerKey] || null : null;
|
const player = playerKey ? store.players?.[playerKey] || null : null;
|
||||||
if (!player) {
|
if (!player) {
|
||||||
return {
|
return {
|
||||||
@@ -408,7 +506,7 @@ function getPlayerForSocket(store, socket) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const rankedPlayers = Object.values(store.players || {})
|
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));
|
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0));
|
||||||
const rank = rankedPlayers.findIndex((entry) => entry.playerKey === player.playerKey) + 1;
|
const rank = rankedPlayers.findIndex((entry) => entry.playerKey === player.playerKey) + 1;
|
||||||
return {
|
return {
|
||||||
@@ -425,24 +523,34 @@ function buildStatePayload(socket = null) {
|
|||||||
const store = loadStore();
|
const store = loadStore();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const voteCounts = countVotes(store.votes);
|
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 context = buildGameContext(store, { now });
|
||||||
const activeGame = activeDefinition
|
const runningGame = runningDefinition
|
||||||
? activeDefinition.getPublicState(ensureReadonlyGameState(store, activeDefinition.id), context)
|
? runningDefinition.getPublicState(ensureReadonlyGameState(store, runningDefinition.id), context)
|
||||||
: null;
|
: null;
|
||||||
|
const activeGame = buildLifecycleGameState(store, {
|
||||||
|
now,
|
||||||
|
selectedDefinition,
|
||||||
|
runningDefinition,
|
||||||
|
runningGame,
|
||||||
|
voteCounts,
|
||||||
|
});
|
||||||
const leaderboard = topPlayers(store.players);
|
const leaderboard = topPlayers(store.players);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeGameId: store.activeGameId,
|
phase: store.phase,
|
||||||
|
selectedGameId: store.selectedGameId,
|
||||||
|
runningGameId: store.runningGameId,
|
||||||
|
activeGameId: store.runningGameId,
|
||||||
games: GAME_DEFINITIONS.map((game) => ({
|
games: GAME_DEFINITIONS.map((game) => ({
|
||||||
id: game.id,
|
id: game.id,
|
||||||
title: game.title,
|
title: game.title,
|
||||||
description: game.description,
|
description: game.description,
|
||||||
voteCount: voteCounts[game.id] || 0,
|
voteCount: voteCounts[game.id] || 0,
|
||||||
active: game.id === store.activeGameId,
|
active: game.id === store.runningGameId,
|
||||||
actionLabel: game.id === store.activeGameId && activeGame?.actionLabel
|
selected: game.id === store.selectedGameId,
|
||||||
? activeGame.actionLabel
|
actionLabel: store.phase === 'idle' || store.phase === 'results' ? 'Vote' : game.id === store.selectedGameId ? 'Selected' : 'Vote',
|
||||||
: 'Start',
|
|
||||||
})),
|
})),
|
||||||
activeGame,
|
activeGame,
|
||||||
leaderboard,
|
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) {
|
function topPlayers(players = {}, limit = 6) {
|
||||||
return Object.values(players || {})
|
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))
|
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0))
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
.map((player) => ({
|
.map((player) => ({
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ function createDefaultStore() {
|
|||||||
return {
|
return {
|
||||||
version: STORE_VERSION,
|
version: STORE_VERSION,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
phase: 'idle',
|
||||||
|
selectedGameId: null,
|
||||||
|
runningGameId: null,
|
||||||
|
voteEndsAt: null,
|
||||||
|
startsAt: null,
|
||||||
|
resultsUntil: null,
|
||||||
|
resultGameId: null,
|
||||||
|
resultDisplay: null,
|
||||||
activeGameId: null,
|
activeGameId: null,
|
||||||
votes: {},
|
votes: {},
|
||||||
globalCounters: {
|
globalCounters: {
|
||||||
@@ -89,11 +97,30 @@ function normalizeStoreShape(raw = {}) {
|
|||||||
if (vote) votes[key] = { ...vote, voterKey: vote.voterKey || key };
|
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 {
|
return {
|
||||||
...base,
|
...base,
|
||||||
version: STORE_VERSION,
|
version: STORE_VERSION,
|
||||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : Date.now(),
|
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,
|
votes,
|
||||||
globalCounters: {
|
globalCounters: {
|
||||||
codes: normalizeCounterBucket(raw.globalCounters?.codes),
|
codes: normalizeCounterBucket(raw.globalCounters?.codes),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function GameChoice({ game, disabled, onVote }) {
|
|||||||
className={[
|
className={[
|
||||||
'button-dark flex min-h-[4.25rem] flex-col items-start justify-between px-1.5 py-1 text-left disabled:opacity-70',
|
'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.active ? 'border-emerald-500/70' : '',
|
||||||
|
game.selected && !game.active ? 'border-cyan-500/60' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
>
|
>
|
||||||
<span className="flex w-full items-start justify-between gap-1">
|
<span className="flex w-full items-start justify-between gap-1">
|
||||||
@@ -44,7 +45,7 @@ function GameChoice({ game, disabled, onVote }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 line-clamp-2 text-xs leading-snug text-neutral-300">{game.description}</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">
|
<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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -119,6 +120,7 @@ export default function BarcodeGamesPanel() {
|
|||||||
const [pendingGameId, setPendingGameId] = useState(null);
|
const [pendingGameId, setPendingGameId] = useState(null);
|
||||||
const activeGame = state.activeGame;
|
const activeGame = state.activeGame;
|
||||||
const display = activeGame?.display || {};
|
const display = activeGame?.display || {};
|
||||||
|
const votingDisabled = state.phase === 'starting' || state.phase === 'running';
|
||||||
const timerEndsAt = display.timer?.endsAt;
|
const timerEndsAt = display.timer?.endsAt;
|
||||||
const now = useClock(Number.isFinite(timerEndsAt));
|
const now = useClock(Number.isFinite(timerEndsAt));
|
||||||
const timerText = formatTimer(timerEndsAt, now);
|
const timerText = formatTimer(timerEndsAt, now);
|
||||||
@@ -140,7 +142,7 @@ export default function BarcodeGamesPanel() {
|
|||||||
<GameChoice
|
<GameChoice
|
||||||
key={game.id}
|
key={game.id}
|
||||||
game={game}
|
game={game}
|
||||||
disabled={Boolean(pendingGameId)}
|
disabled={Boolean(pendingGameId) || votingDisabled}
|
||||||
onVote={handleVote}
|
onVote={handleVote}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user