mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 17:40:46 -04:00
barcode gamings
This commit is contained in:
@@ -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) : [],
|
||||
|
||||
Reference in New Issue
Block a user