even more more barcode game stuff

This commit is contained in:
legop3
2026-06-18 20:27:41 -04:00
parent 7e30a856a8
commit 3bb31522c3
10 changed files with 253 additions and 194 deletions
@@ -224,6 +224,9 @@ function getPublicState(rawState, context = {}) {
const remainingMs = state.stepStartedAt
? Math.max(0, REQUEST_TIMEOUT_MS - (now - state.stepStartedAt))
: 0;
const currentStep = state.currentQuest?.steps?.[state.progressIndex] || null;
const totalSteps = state.currentQuest?.steps?.length || 0;
const stepLabel = totalSteps > 1 ? `Step ${state.progressIndex + 1} of ${totalSteps}` : 'Find the object';
return {
id: GAME_ID,
title: 'Scan quest',
@@ -238,6 +241,28 @@ function getPublicState(rawState, context = {}) {
},
remainingMs,
actionLabel: 'Start quest',
display: {
// The display payload is intentionally structured instead of HTML or
// React component names. Games describe what should be shown, while each
// client surface decides how large or compact that information should be.
title: 'Scan quest',
primary: currentStep ? `Scan ${currentStep.label}` : 'Add object barcodes',
secondary: currentStep ? stepLabel : 'The registry needs at least one object',
timer: state.stepStartedAt
? {
label: 'Time left',
endsAt: state.stepStartedAt + REQUEST_TIMEOUT_MS,
}
: null,
stats: [
{ label: 'Completed', value: state.completedQuests },
{ label: 'Quest points', value: getTopScores(state)[0]?.points || 0 },
],
results: state.recentEvents.slice(0, 3).map((event) => ({
label: event.kind === 'timeout' ? 'Skipped' : event.kind,
value: event.label || event.points || '',
})),
},
scores: getTopScores(state),
completedQuests: state.completedQuests,
recentEvents: state.recentEvents,
@@ -223,6 +223,14 @@ function getPublicState(rawState, context = {}) {
? calculateRate(state.scans.length, state.startedAt, elapsedEnd)
: state.finalResult?.scansPerSecond || 0;
const remainingMs = state.status === 'running' ? Math.max(0, (state.endsAt || now) - now) : 0;
const worldRecordText = state.worldRecord
? `${Number(state.worldRecord.scansPerSecond || 0).toFixed(2)} scans per second`
: 'none yet';
const primary = state.status === 'running'
? `${Number(currentRate).toFixed(2)} scans per second`
: state.finalResult
? `${Number(state.finalResult.scansPerSecond || 0).toFixed(2)} scans per second`
: 'Scan anything';
return {
id: GAME_ID,
@@ -242,6 +250,33 @@ function getPublicState(rawState, context = {}) {
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
recentRounds: state.recentRounds,
actionLabel: state.status === 'running' ? 'Running' : 'Start round',
display: {
// This display description lets the scanner page and the driver panel
// render the same game state at different scales without hardcoding
// scans-per-second-specific UI branches in shared React components.
title: 'Scans per second',
primary,
secondary: state.status === 'running'
? `${state.scans.length} scans counted`
: state.finalResult
? `${state.finalResult.scanCount} scans in the last round`
: 'Five minute scan challenge',
timer: state.status === 'running' && state.endsAt
? {
label: 'Time left',
endsAt: state.endsAt,
}
: null,
stats: [
{ label: 'Scans', value: state.scans.length },
{ label: 'Current rate', value: Number(currentRate).toFixed(2) },
{ label: 'World record', value: worldRecordText },
],
results: state.recentRounds.slice(0, 3).map((round) => ({
label: 'Round',
value: `${Number(round.scansPerSecond || 0).toFixed(2)} scans per second`,
})),
},
};
}
+39 -46
View File
@@ -258,23 +258,6 @@ function activateGame(draft, gameId, now) {
return true;
}
function resetGame(draft, gameId, now) {
const definition = getGameDefinition(gameId);
if (!definition) return false;
const currentState = ensureGameState(draft, gameId);
const nextState = definition.reset
? definition.reset(currentState, buildGameContext(draft, { now }))
: definition.createInitialState();
draft.games[gameId] = nextState;
draft.activeGameId = gameId;
addRecentEvent(draft, {
kind: 'gameReset',
gameId,
title: definition.title,
});
return true;
}
function normalizeGameResult(result, fallbackState) {
if (!result || typeof result !== 'object' || Array.isArray(result)) {
return { state: fallbackState, awards: [] };
@@ -349,7 +332,7 @@ function setVote(socket, gameId) {
});
broadcastState();
return { success: true, state: buildStatePayload() };
return { success: true, state: buildStatePayload(socket) };
}
function settleActiveGameIfNeeded() {
@@ -404,29 +387,40 @@ function handleScan(scan) {
broadcastState();
}
function resetActiveGame() {
const now = Date.now();
let resetGameId = null;
withGameStore((draft) => {
const gameId = draft.activeGameId;
if (!getGameDefinition(gameId)) return;
resetGameId = gameId;
resetGame(draft, gameId, now);
});
if (!resetGameId) return { error: 'no active barcode game' };
broadcastState();
return { success: true, state: buildStatePayload() };
}
function topCounters(bucket = {}, limit = 5) {
return Object.values(bucket || {})
.sort((a, b) => (b.count || 0) - (a.count || 0))
.slice(0, limit);
}
function buildStatePayload() {
function getPlayerForSocket(store, socket) {
if (!socket) return null;
const identity = getIdentitySummary(socket);
const playerKey = normalizePlayerKey(identity, socket.id, '');
const player = playerKey ? store.players?.[playerKey] || null : null;
if (!player) {
return {
playerKey,
nickname: identity.nickname || null,
totalPoints: 0,
rank: null,
games: {},
};
}
const rankedPlayers = Object.values(store.players || {})
.filter((entry) => Number.isFinite(entry?.totalPoints) && entry.totalPoints > 0)
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0));
const rank = rankedPlayers.findIndex((entry) => entry.playerKey === player.playerKey) + 1;
return {
playerKey: player.playerKey,
nickname: player.nickname || identity.nickname || null,
totalPoints: player.totalPoints || 0,
rank: rank > 0 ? rank : null,
games: player.games || {},
};
}
function buildStatePayload(socket = null) {
settleActiveGameIfNeeded();
const store = loadStore();
const now = Date.now();
@@ -452,6 +446,7 @@ function buildStatePayload() {
})),
activeGame,
leaderboard,
ownPlayer: getPlayerForSocket(store, socket),
counters: {
objects: topCounters(store.globalCounters?.objects),
rovers: topCounters(store.globalCounters?.rovers),
@@ -481,13 +476,20 @@ function ensureReadonlyGameState(store, gameId) {
}
function broadcastState() {
io.to(GAME_SOCKET_ROOM).emit('barcodeGame:state', buildStatePayload());
const room = io.sockets.adapter.rooms.get(GAME_SOCKET_ROOM);
if (!room) return;
room.forEach((socketId) => {
const socket = io.sockets.sockets.get(socketId);
if (socket) {
socket.emit('barcodeGame:state', buildStatePayload(socket));
}
});
}
io.on('connection', (socket) => {
socket.on('barcodeGame:subscribe', (_payload = {}, cb = () => {}) => {
socket.join(GAME_SOCKET_ROOM);
const state = buildStatePayload();
const state = buildStatePayload(socket);
socket.emit('barcodeGame:state', state);
cb({ success: true, state });
});
@@ -501,14 +503,6 @@ io.on('connection', (socket) => {
}
});
socket.on('barcodeGame:resetActive', (_payload = {}, cb = () => {}) => {
try {
cb(resetActiveGame(socket));
} catch (err) {
logger.warn('Barcode game reset failed', { error: err.message });
cb({ error: err.message || 'barcode game reset failed' });
}
});
});
subscribe('barcode.scanned', (event) => {
@@ -524,7 +518,6 @@ subscribe('barcode.scanned', (event) => {
module.exports = {
buildStatePayload,
handleScan,
resetActiveGame,
setVote,
};