mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
barcode gamings
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/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-DvrSfmPb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BlEp2Rqv.css">
|
||||
<script type="module" crossorigin src="/assets/index-BDg4B9Q_.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Ch_aZGnW.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -20,6 +20,7 @@ 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 JOIN_WINDOW_MS = 30 * 1000;
|
||||
const STARTING_WINDOW_MS = 5 * 1000;
|
||||
const RESULTS_WINDOW_MS = 45 * 1000;
|
||||
|
||||
@@ -87,6 +88,11 @@ function resolveRoverParticipant(roverId) {
|
||||
const playerKey = normalizeIdentityPlayerKey(identity);
|
||||
|
||||
return {
|
||||
// participantKey is the runtime round key. It can fall back to the rover ID
|
||||
// so the UI can show that a physical rover joined even if the driver has no
|
||||
// verified identity yet. playerKey stays identity-only because persistent
|
||||
// scoring should not create separate leaderboard rows for sockets or rovers.
|
||||
participantKey: playerKey || `rover:${normalizedRoverId}`,
|
||||
playerKey,
|
||||
roverId: normalizedRoverId,
|
||||
socketId,
|
||||
@@ -108,7 +114,7 @@ function pruneRecentRoverSightings(draft, now) {
|
||||
function recordRoverSighting(draft, scan, now) {
|
||||
if (!scan?.known || scan.type !== 'rover' || !scan.entityId) return null;
|
||||
const participant = resolveRoverParticipant(scan.entityId);
|
||||
if (!participant?.playerKey) return null;
|
||||
if (!participant?.participantKey) return null;
|
||||
|
||||
draft.recentRoverSightings = {
|
||||
...(draft.recentRoverSightings || {}),
|
||||
@@ -120,11 +126,51 @@ function recordRoverSighting(draft, scan, now) {
|
||||
return participant;
|
||||
}
|
||||
|
||||
function recordRoundParticipant(draft, participant, now) {
|
||||
if (!participant?.participantKey) return null;
|
||||
const previous = draft.roundParticipants?.[participant.participantKey] || {};
|
||||
|
||||
draft.roundParticipants = {
|
||||
...(draft.roundParticipants || {}),
|
||||
[participant.participantKey]: {
|
||||
participantKey: participant.participantKey,
|
||||
playerKey: participant.playerKey || previous.playerKey || null,
|
||||
roverId: participant.roverId || previous.roverId || null,
|
||||
socketId: participant.socketId || previous.socketId || null,
|
||||
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
||||
nickname: participant.nickname || previous.nickname || participant.roverId || 'unknown player',
|
||||
joinedAt: Number.isFinite(previous.joinedAt) ? previous.joinedAt : now,
|
||||
lastSeenAt: now,
|
||||
scanCount: (Number.isFinite(previous.scanCount) ? previous.scanCount : 0) + 1,
|
||||
},
|
||||
};
|
||||
|
||||
return draft.roundParticipants[participant.participantKey];
|
||||
}
|
||||
|
||||
function getRoundParticipants(draft) {
|
||||
return Object.values(draft.roundParticipants || {})
|
||||
.filter((participant) => participant?.participantKey)
|
||||
.sort((a, b) => (a.joinedAt || 0) - (b.joinedAt || 0))
|
||||
.map((participant) => ({
|
||||
participantKey: participant.participantKey,
|
||||
playerKey: participant.playerKey || null,
|
||||
roverId: participant.roverId || null,
|
||||
socketId: participant.socketId || null,
|
||||
cookieUserId: participant.cookieUserId || null,
|
||||
nickname: participant.nickname || participant.roverId || 'unknown player',
|
||||
joinedAt: Number.isFinite(participant.joinedAt) ? participant.joinedAt : null,
|
||||
lastSeenAt: Number.isFinite(participant.lastSeenAt) ? participant.lastSeenAt : null,
|
||||
scanCount: Number.isFinite(participant.scanCount) ? participant.scanCount : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function getProximityParticipants(draft, now) {
|
||||
pruneRecentRoverSightings(draft, now);
|
||||
return Object.values(draft.recentRoverSightings || {})
|
||||
.filter((sighting) => sighting?.playerKey && now - sighting.scannedAt <= ROVER_ATTRIBUTION_WINDOW_MS)
|
||||
.filter((sighting) => sighting?.participantKey && now - sighting.scannedAt <= ROVER_ATTRIBUTION_WINDOW_MS)
|
||||
.map((sighting) => ({
|
||||
participantKey: sighting.participantKey,
|
||||
playerKey: sighting.playerKey,
|
||||
roverId: sighting.roverId,
|
||||
socketId: sighting.socketId || null,
|
||||
@@ -265,7 +311,7 @@ function buildGameContext(draft, extras = {}) {
|
||||
return {
|
||||
now: extras.now || Date.now(),
|
||||
objects: getKnownObjects(),
|
||||
participants: extras.participants || [],
|
||||
participants: extras.participants || getRoundParticipants(draft),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,6 +330,7 @@ function startGame(draft, gameId, now) {
|
||||
draft.selectedGameId = gameId;
|
||||
draft.activeGameId = gameId;
|
||||
draft.voteEndsAt = null;
|
||||
draft.joinEndsAt = null;
|
||||
draft.startsAt = null;
|
||||
draft.resultsUntil = null;
|
||||
draft.resultGameId = null;
|
||||
@@ -348,7 +395,7 @@ function setVote(socket, gameId) {
|
||||
const voterKey = getVoterKey(socket);
|
||||
const identity = getIdentitySummary(socket);
|
||||
const current = loadStore();
|
||||
if (current.phase === 'running' || current.phase === 'starting') {
|
||||
if (current.phase === 'joining' || current.phase === 'starting' || current.phase === 'running') {
|
||||
return { error: 'a barcode game is already starting or running' };
|
||||
}
|
||||
|
||||
@@ -367,11 +414,13 @@ function setVote(socket, gameId) {
|
||||
if (draft.phase === 'idle' || draft.phase === 'results') {
|
||||
draft.phase = 'voting';
|
||||
draft.voteEndsAt = now + VOTING_WINDOW_MS;
|
||||
draft.joinEndsAt = null;
|
||||
draft.startsAt = null;
|
||||
draft.runningGameId = null;
|
||||
draft.activeGameId = null;
|
||||
draft.resultsUntil = null;
|
||||
draft.resultDisplay = null;
|
||||
draft.roundParticipants = {};
|
||||
sendBarcodeGameChat(`Voting has started for ${definition.title}. Vote in the Activities tab.`);
|
||||
} else if (draft.phase === 'voting') {
|
||||
draft.voteEndsAt = Math.max(draft.voteEndsAt || 0, now + VOTING_WINDOW_MS);
|
||||
@@ -393,11 +442,13 @@ function settleActiveGameIfNeeded() {
|
||||
if (!winnerDefinition) return false;
|
||||
withGameStore((draft) => {
|
||||
draft.selectedGameId = winner;
|
||||
draft.phase = 'starting';
|
||||
draft.startsAt = now + STARTING_WINDOW_MS;
|
||||
draft.phase = 'joining';
|
||||
draft.joinEndsAt = now + JOIN_WINDOW_MS;
|
||||
draft.startsAt = null;
|
||||
draft.voteEndsAt = null;
|
||||
draft.roundParticipants = {};
|
||||
addRecentEvent(draft, {
|
||||
kind: 'gameStarting',
|
||||
kind: 'gameJoining',
|
||||
gameId: winner,
|
||||
title: winnerDefinition.title,
|
||||
});
|
||||
@@ -406,6 +457,28 @@ function settleActiveGameIfNeeded() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (phase === 'joining' && store.joinEndsAt && now >= store.joinEndsAt) {
|
||||
withGameStore((draft) => {
|
||||
// Joining exists so a real rover has to physically opt into a round. If
|
||||
// nobody scans a rover in time, the selected game is discarded and the
|
||||
// system returns to idle instead of starting an empty round that cannot
|
||||
// award points to anyone.
|
||||
draft.phase = 'idle';
|
||||
draft.selectedGameId = null;
|
||||
draft.runningGameId = null;
|
||||
draft.activeGameId = null;
|
||||
draft.voteEndsAt = null;
|
||||
draft.joinEndsAt = null;
|
||||
draft.startsAt = null;
|
||||
draft.resultsUntil = null;
|
||||
draft.resultGameId = null;
|
||||
draft.resultDisplay = null;
|
||||
draft.votes = {};
|
||||
draft.roundParticipants = {};
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (phase === 'starting' && store.startsAt && now >= store.startsAt) {
|
||||
const gameId = store.selectedGameId;
|
||||
if (!getGameDefinition(gameId)) return false;
|
||||
@@ -422,11 +495,13 @@ function settleActiveGameIfNeeded() {
|
||||
draft.runningGameId = null;
|
||||
draft.activeGameId = null;
|
||||
draft.voteEndsAt = null;
|
||||
draft.joinEndsAt = null;
|
||||
draft.startsAt = null;
|
||||
draft.resultsUntil = null;
|
||||
draft.resultGameId = null;
|
||||
draft.resultDisplay = null;
|
||||
draft.votes = {};
|
||||
draft.roundParticipants = {};
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -456,6 +531,7 @@ function settleActiveGameIfNeeded() {
|
||||
draft.activeGameId = null;
|
||||
draft.startsAt = null;
|
||||
draft.voteEndsAt = null;
|
||||
draft.joinEndsAt = null;
|
||||
draft.votes = {};
|
||||
sendBarcodeGameChat(`${definition.title} ended. Results are now showing.`);
|
||||
}
|
||||
@@ -467,8 +543,37 @@ function handleScan(scan) {
|
||||
const now = Number.isFinite(scan?.scannedAt) ? scan.scannedAt : Date.now();
|
||||
withGameStore((draft) => {
|
||||
updateGlobalCounters(draft, scan, now);
|
||||
recordRoverSighting(draft, scan, now);
|
||||
const participants = getProximityParticipants(draft, now);
|
||||
const scannedRoverParticipant = recordRoverSighting(draft, scan, now);
|
||||
|
||||
if (draft.phase === 'joining' && scannedRoverParticipant) {
|
||||
const selectedDefinition = getGameDefinition(draft.selectedGameId);
|
||||
const joinedParticipant = recordRoundParticipant(draft, scannedRoverParticipant, now);
|
||||
|
||||
if (selectedDefinition && joinedParticipant) {
|
||||
// The first rover scan is the physical confirmation that a real player
|
||||
// is at the scanner and wants this voted game to start. The short
|
||||
// starting phase gives the room and chat display a predictable countdown
|
||||
// before game rules begin consuming object scans.
|
||||
draft.phase = 'starting';
|
||||
draft.startsAt = now + STARTING_WINDOW_MS;
|
||||
draft.joinEndsAt = null;
|
||||
addRecentEvent(draft, {
|
||||
kind: 'gameStarting',
|
||||
gameId: selectedDefinition.id,
|
||||
title: selectedDefinition.title,
|
||||
participant: joinedParticipant.nickname,
|
||||
});
|
||||
}
|
||||
} else if ((draft.phase === 'starting' || draft.phase === 'running') && scannedRoverParticipant) {
|
||||
// Rovers scanned during the countdown or action are counted as active participants too.
|
||||
// This supports the physical reality of the station: a rover may be seen
|
||||
// just before or during useful object scans, and that should be enough to
|
||||
// associate the driver with the current round.
|
||||
recordRoundParticipant(draft, scannedRoverParticipant, now);
|
||||
}
|
||||
|
||||
const proximityParticipants = getProximityParticipants(draft, now);
|
||||
const participants = getRoundParticipants(draft);
|
||||
const activeGameId = draft.phase === 'running' ? draft.runningGameId : null;
|
||||
const definition = getGameDefinition(activeGameId);
|
||||
|
||||
@@ -493,6 +598,7 @@ function handleScan(scan) {
|
||||
draft.activeGameId = null;
|
||||
draft.startsAt = null;
|
||||
draft.voteEndsAt = null;
|
||||
draft.joinEndsAt = null;
|
||||
draft.votes = {};
|
||||
sendBarcodeGameChat(`${definition.title} ended. Results are now showing.`);
|
||||
}
|
||||
@@ -504,7 +610,9 @@ function handleScan(scan) {
|
||||
label: scan?.label || scan?.code || 'unknown',
|
||||
known: Boolean(scan?.known),
|
||||
type: scan?.type || null,
|
||||
participants: participants.map((participant) => participant.nickname || participant.roverId).filter(Boolean),
|
||||
participants: (participants.length ? participants : proximityParticipants)
|
||||
.map((participant) => participant.nickname || participant.roverId)
|
||||
.filter(Boolean),
|
||||
});
|
||||
});
|
||||
broadcastState();
|
||||
@@ -550,7 +658,8 @@ function buildStatePayload(socket = null) {
|
||||
const voteCounts = countVotes(store.votes);
|
||||
const selectedDefinition = getGameDefinition(store.selectedGameId);
|
||||
const runningDefinition = getGameDefinition(store.runningGameId);
|
||||
const context = buildGameContext(store, { now });
|
||||
const participants = getRoundParticipants(store);
|
||||
const context = buildGameContext(store, { now, participants });
|
||||
const runningGame = runningDefinition
|
||||
? runningDefinition.getPublicState(ensureReadonlyGameState(store, runningDefinition.id), context)
|
||||
: null;
|
||||
@@ -568,6 +677,7 @@ function buildStatePayload(socket = null) {
|
||||
selectedGameId: store.selectedGameId,
|
||||
runningGameId: store.runningGameId,
|
||||
activeGameId: store.runningGameId,
|
||||
participants,
|
||||
games: GAME_DEFINITIONS.map((game) => ({
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
@@ -575,7 +685,12 @@ function buildStatePayload(socket = null) {
|
||||
voteCount: voteCounts[game.id] || 0,
|
||||
active: game.id === store.runningGameId,
|
||||
selected: game.id === store.selectedGameId,
|
||||
actionLabel: store.phase === 'idle' || store.phase === 'results' ? 'Vote' : game.id === store.selectedGameId ? 'Selected' : 'Vote',
|
||||
actionLabel:
|
||||
store.phase === 'idle' || store.phase === 'results'
|
||||
? 'Vote'
|
||||
: game.id === store.selectedGameId
|
||||
? 'Selected'
|
||||
: 'Vote',
|
||||
})),
|
||||
activeGame,
|
||||
leaderboard,
|
||||
@@ -590,12 +705,19 @@ function buildStatePayload(socket = null) {
|
||||
}
|
||||
|
||||
function buildLifecycleGameState(store, { now, selectedDefinition, runningDefinition, runningGame, voteCounts }) {
|
||||
if (store.phase === 'running' && runningGame) return runningGame;
|
||||
const participants = getRoundParticipants(store);
|
||||
if (store.phase === 'running' && runningGame) {
|
||||
return {
|
||||
...runningGame,
|
||||
participants,
|
||||
};
|
||||
}
|
||||
if (store.phase === 'results') {
|
||||
return {
|
||||
id: store.resultGameId,
|
||||
title: getGameDefinition(store.resultGameId)?.title || 'Results',
|
||||
status: 'results',
|
||||
participants,
|
||||
display: {
|
||||
title: 'Results',
|
||||
primary: store.resultDisplay?.primary || 'Round complete',
|
||||
@@ -611,8 +733,9 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
||||
id: store.selectedGameId,
|
||||
title: selectedDefinition?.title || 'Starting',
|
||||
status: 'starting',
|
||||
participants,
|
||||
display: {
|
||||
title: 'Starting',
|
||||
title: selectedDefinition?.title || 'Starting',
|
||||
primary: selectedDefinition ? `${selectedDefinition.title} starts soon` : 'Game starts soon',
|
||||
secondary: 'Get ready',
|
||||
timer: store.startsAt ? { label: 'Starts in', endsAt: store.startsAt } : null,
|
||||
@@ -621,12 +744,30 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
||||
},
|
||||
};
|
||||
}
|
||||
if (store.phase === 'joining') {
|
||||
const selectedTitle = selectedDefinition?.title || 'the selected game';
|
||||
return {
|
||||
id: store.selectedGameId,
|
||||
title: selectedDefinition?.title || 'Join game',
|
||||
status: 'joining',
|
||||
participants,
|
||||
display: {
|
||||
title: selectedDefinition?.title || 'Join game',
|
||||
primary: 'Scan your rover to start',
|
||||
secondary: `${selectedTitle} needs at least one rover`,
|
||||
timer: store.joinEndsAt ? { label: 'Join by', endsAt: store.joinEndsAt } : null,
|
||||
stats: [],
|
||||
results: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (store.phase === 'voting') {
|
||||
const selectedTitle = selectedDefinition?.title || 'a barcode game';
|
||||
return {
|
||||
id: store.selectedGameId,
|
||||
title: 'Voting',
|
||||
status: 'voting',
|
||||
participants,
|
||||
display: {
|
||||
title: 'Voting',
|
||||
primary: `Voting for ${selectedTitle}`,
|
||||
@@ -644,6 +785,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
||||
id: null,
|
||||
title: 'Barcode games',
|
||||
status: 'idle',
|
||||
participants,
|
||||
display: {
|
||||
title: 'Barcode games',
|
||||
primary: 'Choose a game',
|
||||
|
||||
@@ -17,6 +17,7 @@ function createDefaultStore() {
|
||||
selectedGameId: null,
|
||||
runningGameId: null,
|
||||
voteEndsAt: null,
|
||||
joinEndsAt: null,
|
||||
startsAt: null,
|
||||
resultsUntil: null,
|
||||
resultGameId: null,
|
||||
@@ -29,6 +30,11 @@ function createDefaultStore() {
|
||||
rovers: {},
|
||||
},
|
||||
recentRoverSightings: {},
|
||||
// roundParticipants persists only the current round's joined rovers/users.
|
||||
// It is separate from recentRoverSightings because the UI needs stable
|
||||
// participants for the whole round, while sightings expire quickly for
|
||||
// proximity attribution.
|
||||
roundParticipants: {},
|
||||
players: {},
|
||||
games: {},
|
||||
recentEvents: [],
|
||||
@@ -97,7 +103,7 @@ function normalizeStoreShape(raw = {}) {
|
||||
if (vote) votes[key] = { ...vote, voterKey: vote.voterKey || key };
|
||||
});
|
||||
|
||||
const phase = ['idle', 'voting', 'starting', 'running', 'results'].includes(raw.phase)
|
||||
const phase = ['idle', 'voting', 'joining', 'starting', 'running', 'results'].includes(raw.phase)
|
||||
? raw.phase
|
||||
: raw.activeGameId
|
||||
? 'running'
|
||||
@@ -116,6 +122,7 @@ function normalizeStoreShape(raw = {}) {
|
||||
selectedGameId: typeof raw.selectedGameId === 'string' ? raw.selectedGameId : runningGameId,
|
||||
runningGameId,
|
||||
voteEndsAt: Number.isFinite(raw.voteEndsAt) ? raw.voteEndsAt : null,
|
||||
joinEndsAt: Number.isFinite(raw.joinEndsAt) ? raw.joinEndsAt : 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,
|
||||
@@ -131,6 +138,7 @@ function normalizeStoreShape(raw = {}) {
|
||||
raw.recentRoverSightings && typeof raw.recentRoverSightings === 'object'
|
||||
? raw.recentRoverSightings
|
||||
: {},
|
||||
roundParticipants: raw.roundParticipants && typeof raw.roundParticipants === 'object' ? raw.roundParticipants : {},
|
||||
players: normalizePlayers(raw.players),
|
||||
games: raw.games && typeof raw.games === 'object' ? raw.games : {},
|
||||
recentEvents: Array.isArray(raw.recentEvents) ? raw.recentEvents.slice(-25) : [],
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
|
||||
const STATE_STALE_MS = 10 * 1000;
|
||||
const RESUBSCRIBE_MS = 5 * 1000;
|
||||
|
||||
const EMPTY_BARCODE_GAME_STATE = {
|
||||
activeGameId: null,
|
||||
games: [],
|
||||
activeGame: null,
|
||||
participants: [],
|
||||
counters: {
|
||||
objects: [],
|
||||
rovers: [],
|
||||
@@ -23,6 +27,7 @@ function normalizeState(payload = {}) {
|
||||
...EMPTY_BARCODE_GAME_STATE,
|
||||
...(payload && typeof payload === 'object' ? payload : {}),
|
||||
games: Array.isArray(payload?.games) ? payload.games : [],
|
||||
participants: Array.isArray(payload?.participants) ? payload.participants : [],
|
||||
counters: {
|
||||
...EMPTY_BARCODE_GAME_STATE.counters,
|
||||
...(payload?.counters && typeof payload.counters === 'object' ? payload.counters : {}),
|
||||
@@ -34,20 +39,92 @@ function normalizeState(payload = {}) {
|
||||
export default function useBarcodeGameState() {
|
||||
const socket = useSocket();
|
||||
const [state, setState] = useState(EMPTY_BARCODE_GAME_STATE);
|
||||
const [connectionState, setConnectionState] = useState({
|
||||
connected: Boolean(socket.connected),
|
||||
stale: true,
|
||||
lastReceivedAt: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let staleTimer = null;
|
||||
let retryTimer = null;
|
||||
let lastReceivedAt = 0;
|
||||
|
||||
function handleState(payload = {}) {
|
||||
if (disposed) return;
|
||||
lastReceivedAt = Date.now();
|
||||
setState(normalizeState(payload));
|
||||
setConnectionState({
|
||||
connected: Boolean(socket.connected),
|
||||
stale: false,
|
||||
lastReceivedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function subscribeToGameState() {
|
||||
// Socket.io rooms are server-side state, so a reconnect needs a fresh
|
||||
// subscribe packet. Retrying the same idempotent subscribe is cheap and
|
||||
// prevents the scanner page from getting stuck with old game text after a
|
||||
// transient Wi-Fi or server restart.
|
||||
socket.emit('barcodeGame:subscribe', {}, (response = {}) => {
|
||||
if (response.state) {
|
||||
handleState(response.state);
|
||||
return;
|
||||
}
|
||||
setConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
stale: !lastReceivedAt,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
setConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: true,
|
||||
stale: !lastReceivedAt,
|
||||
}));
|
||||
subscribeToGameState();
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
setConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: false,
|
||||
stale: true,
|
||||
}));
|
||||
}
|
||||
|
||||
socket.emit('barcodeGame:subscribe', {}, (response = {}) => {
|
||||
if (response.state) {
|
||||
handleState(response.state);
|
||||
}
|
||||
});
|
||||
socket.on('barcodeGame:state', handleState);
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
subscribeToGameState();
|
||||
|
||||
staleTimer = window.setInterval(() => {
|
||||
const isStale = !lastReceivedAt || Date.now() - lastReceivedAt > STATE_STALE_MS;
|
||||
setConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
stale: isStale,
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
retryTimer = window.setInterval(() => {
|
||||
if (!socket.connected) return;
|
||||
if (!lastReceivedAt || Date.now() - lastReceivedAt > STATE_STALE_MS) {
|
||||
subscribeToGameState();
|
||||
}
|
||||
}, RESUBSCRIBE_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(staleTimer);
|
||||
window.clearInterval(retryTimer);
|
||||
socket.off('barcodeGame:state', handleState);
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
@@ -71,8 +148,9 @@ export default function useBarcodeGameState() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
state,
|
||||
connectionState,
|
||||
voteForGame,
|
||||
}),
|
||||
[state, voteForGame],
|
||||
[connectionState, state, voteForGame],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,6 +65,28 @@ function StatGrid({ stats }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantsBlock({ participants }) {
|
||||
const knownParticipants = Array.isArray(participants) ? participants : [];
|
||||
// Participants are server-owned because rover scans, identity lookup, and
|
||||
// score eligibility all happen outside this panel. The UI only formats the
|
||||
// current round list so users can tell whether their rover was counted.
|
||||
const displayNames = knownParticipants
|
||||
.map((participant) => participant?.nickname || participant?.roverId)
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 border border-neutral-700 px-2 py-1.5">
|
||||
<p className="text-sm font-semibold text-neutral-400">Participants</p>
|
||||
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
||||
{knownParticipants.length}
|
||||
</p>
|
||||
<p className="truncate text-sm leading-tight text-neutral-300">
|
||||
{displayNames.length ? displayNames.join(', ') : 'Scan rover to join'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CounterList({ title, entries }) {
|
||||
if (!Array.isArray(entries) || !entries.length) return null;
|
||||
return (
|
||||
@@ -120,11 +142,15 @@ export default function BarcodeGamesPanel() {
|
||||
const [pendingGameId, setPendingGameId] = useState(null);
|
||||
const activeGame = state.activeGame;
|
||||
const display = activeGame?.display || {};
|
||||
const votingDisabled = state.phase === 'starting' || state.phase === 'running';
|
||||
// Voting should stop once the shared game system has moved past selection.
|
||||
// The joining phase is included because it is still part of starting the
|
||||
// selected game, even though game rules are not running until the countdown.
|
||||
const votingDisabled = state.phase === 'joining' || state.phase === 'starting' || state.phase === 'running';
|
||||
const timerEndsAt = display.timer?.endsAt;
|
||||
const now = useClock(Number.isFinite(timerEndsAt));
|
||||
const timerText = formatTimer(timerEndsAt, now);
|
||||
const games = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
|
||||
const participants = Array.isArray(state.participants) ? state.participants : [];
|
||||
|
||||
const handleVote = async (gameId) => {
|
||||
setPendingGameId(gameId);
|
||||
@@ -148,23 +174,29 @@ export default function BarcodeGamesPanel() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="space-y-1 border-t border-neutral-700 pt-1.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<section className="space-y-2 border-t border-neutral-700 pt-2">
|
||||
<div className="grid items-start gap-2 lg:grid-cols-[minmax(0,1fr)_15rem_15rem]">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-neutral-400">{display.title || activeGame?.title || 'No game active'}</p>
|
||||
<p className="break-words text-lg font-semibold leading-tight text-neutral-50">
|
||||
<p className="break-words text-2xl font-bold leading-tight text-neutral-50">
|
||||
{display.title || activeGame?.title || 'No game active'}
|
||||
</p>
|
||||
<p className="mt-0.5 break-words text-lg font-semibold leading-tight text-neutral-200">
|
||||
{display.primary || 'Vote for a game to start'}
|
||||
</p>
|
||||
{display.secondary ? (
|
||||
<p className="mt-0.5 break-words text-sm leading-snug text-neutral-300">{display.secondary}</p>
|
||||
<p className="mt-1 break-words text-base leading-snug text-neutral-300">{display.secondary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{timerText ? (
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-[0.68rem] text-neutral-400">{display.timer?.label || 'Time'}</p>
|
||||
<p className="font-mono text-base font-semibold text-neutral-50">{timerText}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="border border-neutral-700 px-2 py-1.5 text-left lg:text-right">
|
||||
<p className="text-sm font-semibold text-neutral-400">{display.timer?.label || 'Time'}</p>
|
||||
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
||||
{timerText || '--'}
|
||||
</p>
|
||||
<p className="text-sm leading-tight text-neutral-500">
|
||||
{timerText ? 'active timer' : 'no timer'}
|
||||
</p>
|
||||
</div>
|
||||
<ParticipantsBlock participants={participants} />
|
||||
</div>
|
||||
<StatGrid stats={display.stats} />
|
||||
</section>
|
||||
|
||||
@@ -15,6 +15,8 @@ const EMPTY_SCANNER_STATE = {
|
||||
lastScan: null,
|
||||
registryError: null,
|
||||
};
|
||||
const SCANNER_STATE_STALE_MS = 10 * 1000;
|
||||
const SCANNER_RESUBSCRIBE_MS = 5 * 1000;
|
||||
|
||||
function useClock(enabled) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -69,9 +71,14 @@ export default function ScannerContent() {
|
||||
const inputRef = useRef(null);
|
||||
const flashTimerRef = useRef(null);
|
||||
const [scannerState, setScannerState] = useState(EMPTY_SCANNER_STATE);
|
||||
const [scannerConnectionState, setScannerConnectionState] = useState({
|
||||
connected: Boolean(socket.connected),
|
||||
stale: true,
|
||||
lastReceivedAt: null,
|
||||
});
|
||||
const [scanAudioEvent, setScanAudioEvent] = useState(null);
|
||||
const [flashActive, setFlashActive] = useState(false);
|
||||
const { state: barcodeGameState } = useBarcodeGameState();
|
||||
const { state: barcodeGameState, connectionState: barcodeGameConnectionState } = useBarcodeGameState();
|
||||
|
||||
useDefaultNickname();
|
||||
useUserIdentitySync();
|
||||
@@ -93,22 +100,94 @@ export default function ScannerContent() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let staleTimer = null;
|
||||
let retryTimer = null;
|
||||
let lastReceivedAt = 0;
|
||||
|
||||
function handleScannerState(nextState = {}) {
|
||||
if (disposed) return;
|
||||
lastReceivedAt = Date.now();
|
||||
setScannerState({
|
||||
...EMPTY_SCANNER_STATE,
|
||||
...(nextState && typeof nextState === 'object' ? nextState : {}),
|
||||
});
|
||||
setScannerConnectionState({
|
||||
connected: Boolean(socket.connected),
|
||||
stale: false,
|
||||
lastReceivedAt,
|
||||
});
|
||||
}
|
||||
|
||||
function handleScanAudio(payload = null) {
|
||||
setScanAudioEvent(payload && typeof payload === 'object' ? payload : null);
|
||||
}
|
||||
|
||||
socket.emit('barcode:subscribe', {}, () => {});
|
||||
function subscribeToScannerState() {
|
||||
// The scan input path is intentionally independent from display state, so
|
||||
// the page can beep/flash even if it missed a previous status broadcast.
|
||||
// This subscribe is idempotent and gives the rover-facing page a way to
|
||||
// repair its display after reconnects or quiet periods.
|
||||
socket.emit('barcode:subscribe', {}, (response = {}) => {
|
||||
if (response.state) {
|
||||
handleScannerState(response.state);
|
||||
return;
|
||||
}
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
stale: !lastReceivedAt,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: true,
|
||||
stale: !lastReceivedAt,
|
||||
}));
|
||||
subscribeToScannerState();
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: false,
|
||||
stale: true,
|
||||
}));
|
||||
}
|
||||
|
||||
socket.on('barcode:state', handleScannerState);
|
||||
socket.on('barcode:scanAudio', handleScanAudio);
|
||||
socket.on('connect', handleConnect);
|
||||
socket.on('disconnect', handleDisconnect);
|
||||
subscribeToScannerState();
|
||||
|
||||
staleTimer = window.setInterval(() => {
|
||||
const isStale = !lastReceivedAt || Date.now() - lastReceivedAt > SCANNER_STATE_STALE_MS;
|
||||
setScannerConnectionState((previous) => ({
|
||||
...previous,
|
||||
connected: Boolean(socket.connected),
|
||||
stale: isStale,
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
retryTimer = window.setInterval(() => {
|
||||
if (!socket.connected) return;
|
||||
if (!lastReceivedAt || Date.now() - lastReceivedAt > SCANNER_STATE_STALE_MS) {
|
||||
subscribeToScannerState();
|
||||
}
|
||||
}, SCANNER_RESUBSCRIBE_MS);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
window.clearInterval(staleTimer);
|
||||
window.clearInterval(retryTimer);
|
||||
socket.off('barcode:state', handleScannerState);
|
||||
socket.off('barcode:scanAudio', handleScanAudio);
|
||||
socket.off('connect', handleConnect);
|
||||
socket.off('disconnect', handleDisconnect);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
@@ -146,8 +225,19 @@ export default function ScannerContent() {
|
||||
const timerEndsAt = display.timer?.endsAt;
|
||||
const now = useClock(Number.isFinite(timerEndsAt));
|
||||
const timerText = formatTimer(timerEndsAt, now);
|
||||
const label = display.primary || activeGame?.headline || lastScan?.label || 'waiting';
|
||||
const detail = display.secondary || activeGame?.detail || '';
|
||||
const showGameDisplay = activeGame?.status && activeGame.status !== 'idle';
|
||||
// Idle game state is useful for the Activities panel, but the scanner page's
|
||||
// normal job is still showing the resolved barcode text. Only active lifecycle
|
||||
// states take over the large rover-facing display.
|
||||
const title = showGameDisplay ? display.title || activeGame?.title || 'Barcode games' : lastScan?.label || 'Waiting';
|
||||
const label = showGameDisplay ? display.primary || activeGame?.headline || '' : '';
|
||||
const detail = showGameDisplay ? display.secondary || activeGame?.detail || '' : '';
|
||||
const participants = Array.isArray(barcodeGameState.participants) ? barcodeGameState.participants : [];
|
||||
const syncMessage = !scannerConnectionState.connected
|
||||
? 'scanner offline'
|
||||
: scannerConnectionState.stale || barcodeGameConnectionState.stale
|
||||
? 'syncing'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -171,22 +261,35 @@ export default function ScannerContent() {
|
||||
}}
|
||||
/>
|
||||
<section className="flex min-h-screen w-full items-center justify-center">
|
||||
<div className="flex max-w-full flex-col items-center gap-[4vh]">
|
||||
<h1 className="max-w-full break-words text-[15vw] font-black leading-none tracking-normal">
|
||||
{label}
|
||||
<div className="flex max-w-full flex-col items-center gap-[3vh]">
|
||||
<h1 className="max-w-full break-words text-[17vw] font-black leading-none tracking-normal">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="max-w-full break-words text-[6vw] font-bold leading-tight tracking-normal">
|
||||
{label}
|
||||
</p>
|
||||
{detail ? (
|
||||
<p className="max-w-full break-words text-[5vw] font-bold leading-tight tracking-normal">
|
||||
<p className="max-w-full break-words text-[4vw] font-bold leading-tight tracking-normal">
|
||||
{detail}
|
||||
</p>
|
||||
) : null}
|
||||
{timerText ? (
|
||||
<p className="font-mono text-[6vw] font-black leading-none tracking-normal">
|
||||
<p className="font-mono text-[7vw] font-black leading-none tracking-normal">
|
||||
{timerText}
|
||||
</p>
|
||||
) : null}
|
||||
{participants.length ? (
|
||||
<p className="max-w-full truncate text-[3vw] font-semibold leading-tight tracking-normal">
|
||||
{participants.map((participant) => participant.nickname || participant.roverId).filter(Boolean).join(' / ')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
{syncMessage ? (
|
||||
<div className="absolute bottom-4 left-4 text-left text-[2.5vw] font-bold leading-none tracking-normal opacity-80 md:text-xl">
|
||||
{syncMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<SocketConnectionPill />
|
||||
</main>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user