more barcode game stuff

This commit is contained in:
legop3
2026-06-18 20:13:07 -04:00
parent 0af9d560e4
commit 7e30a856a8
10 changed files with 453 additions and 71 deletions
@@ -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,
};
+145 -9
View File
@@ -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) : [],
};