mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
more barcode game stuff
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
const GAME_ID = 'scanQuest';
|
||||
const QUEST_LENGTH_OPTIONS = [1, 2];
|
||||
const REQUEST_TIMEOUT_MS = 90 * 1000;
|
||||
|
||||
function createInitialState() {
|
||||
return {
|
||||
@@ -12,6 +13,7 @@ function createInitialState() {
|
||||
progressIndex: 0,
|
||||
scores: {},
|
||||
completedQuests: 0,
|
||||
stepStartedAt: null,
|
||||
recentEvents: [],
|
||||
lastMessage: 'vote to start scan quest',
|
||||
};
|
||||
@@ -25,6 +27,7 @@ function normalizeState(rawState = {}) {
|
||||
progressIndex: Number.isFinite(rawState.progressIndex) ? Math.max(0, Math.floor(rawState.progressIndex)) : 0,
|
||||
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
|
||||
completedQuests: Number.isFinite(rawState.completedQuests) ? Math.max(0, Math.floor(rawState.completedQuests)) : 0,
|
||||
stepStartedAt: Number.isFinite(rawState.stepStartedAt) ? rawState.stepStartedAt : null,
|
||||
recentEvents: Array.isArray(rawState.recentEvents) ? rawState.recentEvents.slice(-12) : [],
|
||||
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
|
||||
};
|
||||
@@ -68,6 +71,7 @@ function ensureQuest(state, context = {}) {
|
||||
const nextQuest = pickQuest(context.objects || []);
|
||||
state.currentQuest = nextQuest;
|
||||
state.progressIndex = 0;
|
||||
state.stepStartedAt = nextQuest ? context.now || Date.now() : null;
|
||||
state.lastMessage = nextQuest ? formatQuestPrompt(state) : 'scan quest needs objects';
|
||||
return state;
|
||||
}
|
||||
@@ -97,18 +101,62 @@ function addScore(state, participants = [], points) {
|
||||
});
|
||||
}
|
||||
|
||||
function onActivated(rawState, context = {}) {
|
||||
function buildAwards(participants = [], points, reason) {
|
||||
// Awards are returned to the shared barcode game service instead of mutating
|
||||
// the global player ledger here. That keeps this file responsible only for
|
||||
// scan quest rules while all cross-game points accounting stays centralized.
|
||||
return participants
|
||||
.filter((participant) => participant?.playerKey)
|
||||
.map((participant) => ({
|
||||
playerKey: participant.playerKey,
|
||||
nickname: participant.nickname || null,
|
||||
roverId: participant.roverId || null,
|
||||
cookieUserId: participant.cookieUserId || null,
|
||||
points,
|
||||
reason,
|
||||
}));
|
||||
}
|
||||
|
||||
function activate(rawState, context = {}) {
|
||||
const state = normalizeState(rawState);
|
||||
ensureQuest(state, context);
|
||||
return state;
|
||||
}
|
||||
|
||||
function onScan(rawState, scan, context = {}) {
|
||||
function reset(_rawState, context = {}) {
|
||||
const state = createInitialState();
|
||||
ensureQuest(state, context);
|
||||
return state;
|
||||
}
|
||||
|
||||
function skipExpiredQuest(state, context = {}) {
|
||||
const now = context.now || Date.now();
|
||||
if (!state.currentQuest?.steps?.length || !state.stepStartedAt) return state;
|
||||
if (now - state.stepStartedAt < REQUEST_TIMEOUT_MS) return state;
|
||||
|
||||
// A timeout generates a fresh request instead of continuing a half-complete
|
||||
// sequence. If an item is physically unreachable or the barcode is damaged,
|
||||
// the room gets unstuck without punishing anyone or making the next prompt
|
||||
// depend on a failed previous step.
|
||||
const skippedStep = state.currentQuest.steps[state.progressIndex] || state.currentQuest.steps[0];
|
||||
addRecentEvent(state, {
|
||||
kind: 'timeout',
|
||||
label: skippedStep?.label || null,
|
||||
});
|
||||
state.currentQuest = pickQuest(context.objects || []);
|
||||
state.progressIndex = 0;
|
||||
state.stepStartedAt = state.currentQuest ? now : null;
|
||||
state.lastMessage = state.currentQuest ? `skipped. ${formatQuestPrompt(state)}` : 'scan quest needs objects';
|
||||
return state;
|
||||
}
|
||||
|
||||
function handleScan(rawState, scan, context = {}) {
|
||||
const state = normalizeState(rawState);
|
||||
ensureQuest(state, context);
|
||||
skipExpiredQuest(state, context);
|
||||
|
||||
if (!state.currentQuest?.steps?.length) return state;
|
||||
if (!scan?.known || scan.type !== 'object') return state;
|
||||
if (!state.currentQuest?.steps?.length) return { state, awards: [] };
|
||||
if (!scan?.known || scan.type !== 'object') return { state, awards: [] };
|
||||
|
||||
const expected = state.currentQuest.steps[state.progressIndex];
|
||||
const matched = Boolean(expected && scan.code === expected.code);
|
||||
@@ -123,10 +171,11 @@ function onScan(rawState, scan, context = {}) {
|
||||
label: scan.label,
|
||||
expected: expected.label,
|
||||
});
|
||||
return state;
|
||||
return { state, awards: [] };
|
||||
}
|
||||
|
||||
state.progressIndex += 1;
|
||||
state.stepStartedAt = context.now || Date.now();
|
||||
addRecentEvent(state, {
|
||||
kind: 'hit',
|
||||
label: scan.label,
|
||||
@@ -134,7 +183,7 @@ function onScan(rawState, scan, context = {}) {
|
||||
|
||||
if (state.progressIndex < state.currentQuest.steps.length) {
|
||||
state.lastMessage = formatQuestPrompt(state);
|
||||
return state;
|
||||
return { state, awards: [] };
|
||||
}
|
||||
|
||||
const points = state.currentQuest.steps.length;
|
||||
@@ -147,8 +196,18 @@ function onScan(rawState, scan, context = {}) {
|
||||
});
|
||||
state.currentQuest = pickQuest(context.objects || []);
|
||||
state.progressIndex = 0;
|
||||
state.stepStartedAt = state.currentQuest ? context.now || Date.now() : null;
|
||||
state.lastMessage = state.currentQuest ? `scored ${points}. ${formatQuestPrompt(state)}` : `scored ${points}`;
|
||||
return state;
|
||||
return {
|
||||
state,
|
||||
awards: buildAwards(context.participants || [], points, 'scan quest completed'),
|
||||
};
|
||||
}
|
||||
|
||||
function tick(rawState, context = {}) {
|
||||
const state = normalizeState(rawState);
|
||||
ensureQuest(state, context);
|
||||
return skipExpiredQuest(state, context);
|
||||
}
|
||||
|
||||
function getTopScores(state) {
|
||||
@@ -160,6 +219,11 @@ function getTopScores(state) {
|
||||
function getPublicState(rawState, context = {}) {
|
||||
const state = normalizeState(rawState);
|
||||
ensureQuest(state, context);
|
||||
skipExpiredQuest(state, context);
|
||||
const now = context.now || Date.now();
|
||||
const remainingMs = state.stepStartedAt
|
||||
? Math.max(0, REQUEST_TIMEOUT_MS - (now - state.stepStartedAt))
|
||||
: 0;
|
||||
return {
|
||||
id: GAME_ID,
|
||||
title: 'Scan quest',
|
||||
@@ -172,6 +236,8 @@ function getPublicState(rawState, context = {}) {
|
||||
current: state.progressIndex,
|
||||
total: state.currentQuest?.steps?.length || 0,
|
||||
},
|
||||
remainingMs,
|
||||
actionLabel: 'Start quest',
|
||||
scores: getTopScores(state),
|
||||
completedQuests: state.completedQuests,
|
||||
recentEvents: state.recentEvents,
|
||||
@@ -184,7 +250,12 @@ module.exports = {
|
||||
description: 'Scan one or two requested objects in order.',
|
||||
createInitialState,
|
||||
normalizeState,
|
||||
onActivated,
|
||||
onScan,
|
||||
activate,
|
||||
reset,
|
||||
handleScan,
|
||||
tick,
|
||||
onActivated: activate,
|
||||
onScan: (state, scan, context) => handleScan(state, scan, context).state,
|
||||
onTick: tick,
|
||||
getPublicState,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const GAME_ID = 'scansPerSecond';
|
||||
const ROUND_DURATION_MS = 5 * 60 * 1000;
|
||||
const RESULT_IDLE_MS = 60 * 1000;
|
||||
|
||||
function createInitialState() {
|
||||
return {
|
||||
@@ -17,6 +18,7 @@ function createInitialState() {
|
||||
worldRecord: null,
|
||||
recentRounds: [],
|
||||
participantCounts: {},
|
||||
endedAt: null,
|
||||
lastMessage: 'vote to start scans per second',
|
||||
};
|
||||
}
|
||||
@@ -35,6 +37,7 @@ function normalizeState(rawState = {}) {
|
||||
recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [],
|
||||
participantCounts:
|
||||
rawState.participantCounts && typeof rawState.participantCounts === 'object' ? rawState.participantCounts : {},
|
||||
endedAt: Number.isFinite(rawState.endedAt) ? rawState.endedAt : null,
|
||||
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
|
||||
};
|
||||
}
|
||||
@@ -65,6 +68,7 @@ function finishRound(state, endedAt = Date.now()) {
|
||||
const isWorldRecord = !previousRecord || result.scansPerSecond > (previousRecord.scansPerSecond || 0);
|
||||
|
||||
state.status = 'ended';
|
||||
state.endedAt = endedAt;
|
||||
state.finalResult = {
|
||||
...result,
|
||||
isWorldRecord,
|
||||
@@ -77,6 +81,27 @@ function finishRound(state, endedAt = Date.now()) {
|
||||
return state;
|
||||
}
|
||||
|
||||
function buildAwards(result, state) {
|
||||
if (!result?.participants?.length || !result.scanCount) return [];
|
||||
const basePoints = Math.max(1, Math.round(result.scansPerSecond * 10));
|
||||
return result.participants
|
||||
.filter((participant) => participant?.playerKey)
|
||||
.map((participant) => {
|
||||
const share = result.scanCount > 0 ? participant.scanCount / result.scanCount : 0;
|
||||
return {
|
||||
playerKey: participant.playerKey,
|
||||
nickname: participant.nickname || null,
|
||||
roverId: participant.roverId || null,
|
||||
points: Math.max(1, Math.round(basePoints * share)),
|
||||
reason: 'scans per second round',
|
||||
gameMeta: {
|
||||
scansPerSecond: result.scansPerSecond,
|
||||
roundId: state.roundId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function startRound(rawState, now = Date.now()) {
|
||||
const previous = normalizeState(rawState);
|
||||
return {
|
||||
@@ -88,19 +113,25 @@ function startRound(rawState, now = Date.now()) {
|
||||
scans: [],
|
||||
finalResult: null,
|
||||
participantCounts: {},
|
||||
endedAt: null,
|
||||
lastMessage: 'scan anything',
|
||||
};
|
||||
}
|
||||
|
||||
function onActivated(rawState, context = {}) {
|
||||
function activate(rawState, context = {}) {
|
||||
const state = normalizeState(rawState);
|
||||
const now = context.now || Date.now();
|
||||
// Voting for an ended or idle challenge starts a fresh five-minute round. If
|
||||
// the round is already running, activation is a no-op so vote churn does not
|
||||
// accidentally reset an active challenge.
|
||||
// Activation is game-defined, but the shared service calls it generically.
|
||||
// For this game, active rounds keep running while idle or ended rounds start
|
||||
// cleanly so returning to the game always produces a playable challenge.
|
||||
return state.status === 'running' ? state : startRound(state, now);
|
||||
}
|
||||
|
||||
function reset(rawState, context = {}) {
|
||||
const state = normalizeState(rawState);
|
||||
return startRound(state, context.now || Date.now());
|
||||
}
|
||||
|
||||
function addParticipants(state, participants = []) {
|
||||
participants.forEach((participant) => {
|
||||
const key = participant?.playerKey;
|
||||
@@ -116,13 +147,15 @@ function addParticipants(state, participants = []) {
|
||||
});
|
||||
}
|
||||
|
||||
function onScan(rawState, scan, context = {}) {
|
||||
function handleScan(rawState, scan, context = {}) {
|
||||
const now = context.now || Date.now();
|
||||
let state = normalizeState(rawState);
|
||||
let awards = [];
|
||||
if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
|
||||
state = finishRound(state, state.endsAt);
|
||||
awards = buildAwards(state.finalResult, state);
|
||||
}
|
||||
if (state.status !== 'running') return state;
|
||||
if (state.status !== 'running') return { state, awards };
|
||||
|
||||
// This game deliberately counts every submitted scan, including unknown and
|
||||
// invalid barcodes, because the challenge is about physically getting scans
|
||||
@@ -138,19 +171,42 @@ function onScan(rawState, scan, context = {}) {
|
||||
addParticipants(state, context.participants || []);
|
||||
|
||||
if (state.endsAt && now >= state.endsAt) {
|
||||
return finishRound(state, state.endsAt);
|
||||
state = finishRound(state, state.endsAt);
|
||||
return {
|
||||
state,
|
||||
awards: buildAwards(state.finalResult, state),
|
||||
};
|
||||
}
|
||||
|
||||
const currentRate = calculateRate(state.scans.length, state.startedAt, now);
|
||||
state.lastMessage = `${currentRate.toFixed(2)} scans per second`;
|
||||
return state;
|
||||
return { state, awards: [] };
|
||||
}
|
||||
|
||||
function onTick(rawState, context = {}) {
|
||||
function tick(rawState, context = {}) {
|
||||
const now = context.now || Date.now();
|
||||
const state = normalizeState(rawState);
|
||||
if (state.status === 'running' && state.endsAt && now >= state.endsAt) {
|
||||
return finishRound(state, state.endsAt);
|
||||
const finished = finishRound(state, state.endsAt);
|
||||
return {
|
||||
state: finished,
|
||||
awards: buildAwards(finished.finalResult, finished),
|
||||
};
|
||||
}
|
||||
if (state.status === 'ended' && state.endedAt && now - state.endedAt >= RESULT_IDLE_MS) {
|
||||
// Timed result screens are useful for celebration, but the game should not
|
||||
// remain permanently stuck in a completed state. After a short display
|
||||
// window the module returns itself to idle, preserving records and history.
|
||||
return {
|
||||
...state,
|
||||
status: 'idle',
|
||||
roundId: null,
|
||||
startedAt: null,
|
||||
endsAt: null,
|
||||
scans: [],
|
||||
participantCounts: {},
|
||||
lastMessage: 'vote to start scans per second',
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -185,6 +241,7 @@ function getPublicState(rawState, context = {}) {
|
||||
worldRecord: state.worldRecord,
|
||||
participants: Object.values(state.participantCounts || {}).sort((a, b) => (b.scanCount || 0) - (a.scanCount || 0)),
|
||||
recentRounds: state.recentRounds,
|
||||
actionLabel: state.status === 'running' ? 'Running' : 'Start round',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -194,8 +251,12 @@ module.exports = {
|
||||
description: 'Count every scan for five minutes and save the world record.',
|
||||
createInitialState,
|
||||
normalizeState,
|
||||
onActivated,
|
||||
onScan,
|
||||
onTick,
|
||||
activate,
|
||||
reset,
|
||||
handleScan,
|
||||
tick,
|
||||
onActivated: activate,
|
||||
onScan: (state, scan, context) => handleScan(state, scan, context).state,
|
||||
onTick: tick,
|
||||
getPublicState,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ const scansPerSecond = require('./games/scansPerSecond');
|
||||
const GAME_SOCKET_ROOM = 'barcode-game';
|
||||
const RECENT_EVENT_LIMIT = 20;
|
||||
const ROVER_ATTRIBUTION_WINDOW_MS = 60 * 1000;
|
||||
const GAME_TICK_MS = 5 * 1000;
|
||||
|
||||
const GAME_DEFINITIONS = [scanQuest, scansPerSecond];
|
||||
const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
||||
@@ -160,6 +161,7 @@ function recordPlayerParticipation(draft, gameId, participants, now) {
|
||||
cookieUserId: participant.cookieUserId || previous.cookieUserId || null,
|
||||
nickname: participant.nickname || previous.nickname || null,
|
||||
lastRoverId: participant.roverId || previous.lastRoverId || null,
|
||||
totalPoints: Number.isFinite(previous.totalPoints) ? previous.totalPoints : 0,
|
||||
lastSeenAt: now,
|
||||
games: {
|
||||
...previousGames,
|
||||
@@ -173,6 +175,54 @@ function recordPlayerParticipation(draft, gameId, participants, now) {
|
||||
});
|
||||
}
|
||||
|
||||
function applyPointAwards(draft, gameId, awards = [], now) {
|
||||
if (!gameId || !Array.isArray(awards) || !awards.length) return;
|
||||
draft.players = draft.players || {};
|
||||
|
||||
awards.forEach((award) => {
|
||||
const playerKey = award?.playerKey;
|
||||
const points = Number.isFinite(award?.points) ? Math.max(0, Math.floor(award.points)) : 0;
|
||||
if (!playerKey || !points) return;
|
||||
|
||||
const previous = draft.players[playerKey] || {};
|
||||
const previousGames = previous.games || {};
|
||||
const previousGame = previousGames[gameId] || {};
|
||||
|
||||
// Global points are applied only here so individual game files cannot drift
|
||||
// into different player-ledger formats. A game simply returns awards, and
|
||||
// the shared service records identity, total points, and per-game totals in
|
||||
// one persistent place.
|
||||
draft.players[playerKey] = {
|
||||
playerKey,
|
||||
cookieUserId: award.cookieUserId || previous.cookieUserId || null,
|
||||
nickname: award.nickname || previous.nickname || null,
|
||||
lastRoverId: award.roverId || previous.lastRoverId || null,
|
||||
totalPoints: (Number.isFinite(previous.totalPoints) ? previous.totalPoints : 0) + points,
|
||||
lastSeenAt: now,
|
||||
games: {
|
||||
...previousGames,
|
||||
[gameId]: {
|
||||
...previousGame,
|
||||
gameId,
|
||||
points: (Number.isFinite(previousGame.points) ? previousGame.points : 0) + points,
|
||||
awards: (Number.isFinite(previousGame.awards) ? previousGame.awards : 0) + 1,
|
||||
lastAwardAt: now,
|
||||
lastReason: award.reason || null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
addRecentEvent(draft, {
|
||||
kind: 'pointsAwarded',
|
||||
gameId,
|
||||
playerKey,
|
||||
nickname: award.nickname || previous.nickname || null,
|
||||
points,
|
||||
reason: award.reason || null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureGameState(draft, gameId) {
|
||||
const definition = getGameDefinition(gameId);
|
||||
if (!definition) return null;
|
||||
@@ -195,8 +245,8 @@ function activateGame(draft, gameId, now) {
|
||||
const definition = getGameDefinition(gameId);
|
||||
if (!definition) return false;
|
||||
const currentState = ensureGameState(draft, gameId);
|
||||
const nextState = definition.onActivated
|
||||
? definition.onActivated(currentState, buildGameContext(draft, { now }))
|
||||
const nextState = definition.activate
|
||||
? definition.activate(currentState, buildGameContext(draft, { now }))
|
||||
: currentState;
|
||||
draft.games[gameId] = nextState;
|
||||
draft.activeGameId = gameId;
|
||||
@@ -208,6 +258,36 @@ 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: [] };
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(result, 'state')) {
|
||||
return {
|
||||
state: result.state,
|
||||
awards: Array.isArray(result.awards) ? result.awards : [],
|
||||
};
|
||||
}
|
||||
return { state: result, awards: [] };
|
||||
}
|
||||
|
||||
function countVotes(votes = {}) {
|
||||
const counts = {};
|
||||
Object.values(votes || {}).forEach((vote) => {
|
||||
@@ -278,14 +358,18 @@ function settleActiveGameIfNeeded() {
|
||||
const definition = getGameDefinition(activeGameId);
|
||||
const currentState = activeGameId ? store.games?.[activeGameId] : null;
|
||||
const now = Date.now();
|
||||
if (!definition?.onTick || currentState?.status !== 'running' || !currentState?.endsAt || now < currentState.endsAt) {
|
||||
return;
|
||||
}
|
||||
if (!definition?.tick || !currentState) return false;
|
||||
|
||||
const normalizedState = ensureReadonlyGameState(store, activeGameId);
|
||||
const gameResult = definition.tick(normalizedState, buildGameContext(store, { now }));
|
||||
const { state: nextState, awards } = normalizeGameResult(gameResult, normalizedState);
|
||||
if (JSON.stringify(nextState) === JSON.stringify(normalizedState)) return false;
|
||||
|
||||
withGameStore((draft) => {
|
||||
const state = ensureGameState(draft, activeGameId);
|
||||
draft.games[activeGameId] = definition.onTick(state, buildGameContext(draft, { now }));
|
||||
draft.games[activeGameId] = nextState;
|
||||
applyPointAwards(draft, activeGameId, awards, now);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleScan(scan) {
|
||||
@@ -299,11 +383,13 @@ function handleScan(scan) {
|
||||
|
||||
if (definition) {
|
||||
const currentState = ensureGameState(draft, activeGameId);
|
||||
const nextState = definition.onScan
|
||||
? definition.onScan(currentState, scan, buildGameContext(draft, { now, participants }))
|
||||
const gameResult = definition.handleScan
|
||||
? definition.handleScan(currentState, scan, buildGameContext(draft, { now, participants }))
|
||||
: currentState;
|
||||
const { state: nextState, awards } = normalizeGameResult(gameResult, currentState);
|
||||
draft.games[activeGameId] = nextState;
|
||||
recordPlayerParticipation(draft, activeGameId, participants, now);
|
||||
applyPointAwards(draft, activeGameId, awards, now);
|
||||
}
|
||||
|
||||
addRecentEvent(draft, {
|
||||
@@ -318,6 +404,22 @@ 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))
|
||||
@@ -334,6 +436,7 @@ function buildStatePayload() {
|
||||
const activeGame = activeDefinition
|
||||
? activeDefinition.getPublicState(ensureReadonlyGameState(store, activeDefinition.id), context)
|
||||
: null;
|
||||
const leaderboard = topPlayers(store.players);
|
||||
|
||||
return {
|
||||
activeGameId: store.activeGameId,
|
||||
@@ -343,8 +446,12 @@ function buildStatePayload() {
|
||||
description: game.description,
|
||||
voteCount: voteCounts[game.id] || 0,
|
||||
active: game.id === store.activeGameId,
|
||||
actionLabel: game.id === store.activeGameId && activeGame?.actionLabel
|
||||
? activeGame.actionLabel
|
||||
: 'Start',
|
||||
})),
|
||||
activeGame,
|
||||
leaderboard,
|
||||
counters: {
|
||||
objects: topCounters(store.globalCounters?.objects),
|
||||
rovers: topCounters(store.globalCounters?.rovers),
|
||||
@@ -354,6 +461,19 @@ function buildStatePayload() {
|
||||
};
|
||||
}
|
||||
|
||||
function topPlayers(players = {}, limit = 6) {
|
||||
return Object.values(players || {})
|
||||
.filter((player) => Number.isFinite(player?.totalPoints) && player.totalPoints > 0)
|
||||
.sort((a, b) => (b.totalPoints || 0) - (a.totalPoints || 0))
|
||||
.slice(0, limit)
|
||||
.map((player) => ({
|
||||
playerKey: player.playerKey,
|
||||
nickname: player.nickname || player.lastRoverId || 'unknown player',
|
||||
totalPoints: player.totalPoints || 0,
|
||||
lastRoverId: player.lastRoverId || null,
|
||||
}));
|
||||
}
|
||||
|
||||
function ensureReadonlyGameState(store, gameId) {
|
||||
const definition = getGameDefinition(gameId);
|
||||
if (!definition) return null;
|
||||
@@ -380,6 +500,15 @@ io.on('connection', (socket) => {
|
||||
cb({ error: err.message || 'barcode game vote failed' });
|
||||
}
|
||||
});
|
||||
|
||||
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) => {
|
||||
@@ -395,5 +524,12 @@ subscribe('barcode.scanned', (event) => {
|
||||
module.exports = {
|
||||
buildStatePayload,
|
||||
handleScan,
|
||||
resetActiveGame,
|
||||
setVote,
|
||||
};
|
||||
|
||||
setInterval(() => {
|
||||
if (settleActiveGameIfNeeded()) {
|
||||
broadcastState();
|
||||
}
|
||||
}, GAME_TICK_MS).unref?.();
|
||||
|
||||
@@ -64,6 +64,23 @@ function normalizeVote(rawVote = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayers(rawPlayers = {}) {
|
||||
const players = {};
|
||||
Object.entries(rawPlayers && typeof rawPlayers === 'object' ? rawPlayers : {}).forEach(([key, rawPlayer]) => {
|
||||
if (!key || !rawPlayer || typeof rawPlayer !== 'object') return;
|
||||
players[key] = {
|
||||
playerKey: typeof rawPlayer.playerKey === 'string' ? rawPlayer.playerKey : key,
|
||||
cookieUserId: typeof rawPlayer.cookieUserId === 'string' ? rawPlayer.cookieUserId : null,
|
||||
nickname: typeof rawPlayer.nickname === 'string' ? rawPlayer.nickname : null,
|
||||
lastRoverId: typeof rawPlayer.lastRoverId === 'string' ? rawPlayer.lastRoverId : null,
|
||||
totalPoints: Number.isFinite(rawPlayer.totalPoints) ? Math.max(0, Math.floor(rawPlayer.totalPoints)) : 0,
|
||||
lastSeenAt: Number.isFinite(rawPlayer.lastSeenAt) ? rawPlayer.lastSeenAt : null,
|
||||
games: rawPlayer.games && typeof rawPlayer.games === 'object' ? rawPlayer.games : {},
|
||||
};
|
||||
});
|
||||
return players;
|
||||
}
|
||||
|
||||
function normalizeStoreShape(raw = {}) {
|
||||
const base = createDefaultStore();
|
||||
const votes = {};
|
||||
@@ -87,7 +104,7 @@ function normalizeStoreShape(raw = {}) {
|
||||
raw.recentRoverSightings && typeof raw.recentRoverSightings === 'object'
|
||||
? raw.recentRoverSightings
|
||||
: {},
|
||||
players: raw.players && typeof raw.players === 'object' ? raw.players : {},
|
||||
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