From 80f1f82d6ee79ebffcb87ef17a460bf7e2ea8a96 Mon Sep 17 00:00:00 2001 From: legop3 Date: Thu, 18 Jun 2026 22:46:59 -0400 Subject: [PATCH] new game! and better points balancing too --- .../barcodeGameService/games/mostItems.js | 329 ++++++++++++++++++ .../barcodeGameService/games/scanQuest.js | 8 +- .../games/scansPerSecond.js | 6 +- .../src/services/barcodeGameService/index.js | 172 ++++++++- 4 files changed, 506 insertions(+), 9 deletions(-) create mode 100644 server/src/services/barcodeGameService/games/mostItems.js diff --git a/server/src/services/barcodeGameService/games/mostItems.js b/server/src/services/barcodeGameService/games/mostItems.js new file mode 100644 index 00000000..f77a9e06 --- /dev/null +++ b/server/src/services/barcodeGameService/games/mostItems.js @@ -0,0 +1,329 @@ +// Most Items Game +// Purpose: Runs a timed object-collection challenge where the room tries to +// scan as many different known objects as possible. +// Scope: Tracks only game-local round state and returns point awards; the +// shared barcode game service owns voting, player identity, persistence, and +// global leaderboard updates. + +const GAME_ID = 'mostItems'; +const ROUND_DURATION_MS = 5 * 60 * 1000; +const POINTS_PER_UNIQUE_ITEM = 3; +const MAX_POINTS_PER_PLAYER = 30; + +function createInitialState() { + return { + status: 'idle', + roundId: null, + startedAt: null, + endsAt: null, + seenObjects: {}, + totalObjectScans: 0, + duplicateObjectScans: 0, + ignoredScans: 0, + participantCounts: {}, + finalResult: null, + worldRecord: null, + recentRounds: [], + lastObjectLabel: null, + lastMessage: 'vote to start most items', + }; +} + +function normalizeState(rawState = {}) { + const base = createInitialState(); + return { + ...base, + status: rawState.status === 'running' || rawState.status === 'ended' ? rawState.status : 'idle', + roundId: typeof rawState.roundId === 'string' ? rawState.roundId : null, + startedAt: Number.isFinite(rawState.startedAt) ? rawState.startedAt : null, + endsAt: Number.isFinite(rawState.endsAt) ? rawState.endsAt : null, + seenObjects: rawState.seenObjects && typeof rawState.seenObjects === 'object' ? rawState.seenObjects : {}, + totalObjectScans: Number.isFinite(rawState.totalObjectScans) ? Math.max(0, Math.floor(rawState.totalObjectScans)) : 0, + duplicateObjectScans: Number.isFinite(rawState.duplicateObjectScans) + ? Math.max(0, Math.floor(rawState.duplicateObjectScans)) + : 0, + ignoredScans: Number.isFinite(rawState.ignoredScans) ? Math.max(0, Math.floor(rawState.ignoredScans)) : 0, + participantCounts: + rawState.participantCounts && typeof rawState.participantCounts === 'object' ? rawState.participantCounts : {}, + finalResult: rawState.finalResult && typeof rawState.finalResult === 'object' ? rawState.finalResult : null, + worldRecord: rawState.worldRecord && typeof rawState.worldRecord === 'object' ? rawState.worldRecord : null, + recentRounds: Array.isArray(rawState.recentRounds) ? rawState.recentRounds.slice(-10) : [], + lastObjectLabel: typeof rawState.lastObjectLabel === 'string' ? rawState.lastObjectLabel : null, + lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage, + }; +} + +function getObjectKey(scan = {}) { + // entityId is preferred because it is the actual object identity. The code is + // kept as a fallback so a registry entry with a missing/odd entity value still + // behaves deterministically instead of collapsing into one anonymous object. + return String(scan.entityId || scan.code || '').trim(); +} + +function getUniqueCount(state) { + return Object.keys(state.seenObjects || {}).length; +} + +function getParticipantList(state) { + return Object.values(state.participantCounts || {}) + .sort((a, b) => (b.uniqueItems || 0) - (a.uniqueItems || 0)) + .map((participant) => ({ + playerKey: participant.playerKey || null, + nickname: participant.nickname || participant.roverId || 'unknown player', + roverId: participant.roverId || null, + uniqueItems: Number.isFinite(participant.uniqueItems) ? participant.uniqueItems : 0, + lastSeenAt: Number.isFinite(participant.lastSeenAt) ? participant.lastSeenAt : null, + })); +} + +function addParticipantCredit(state, participants = [], now, uniqueItemScanned) { + participants.forEach((participant) => { + const key = participant?.playerKey; + if (!key) return; + const previous = state.participantCounts[key] || {}; + + // This is a cooperative game, but late joiners should not get retroactive + // credit for objects scanned before they joined. Each unique item credits + // the identity-backed participants who were active at scan time. + state.participantCounts[key] = { + playerKey: key, + nickname: participant.nickname || previous.nickname || null, + roverId: participant.roverId || previous.roverId || null, + uniqueItems: (Number.isFinite(previous.uniqueItems) ? previous.uniqueItems : 0) + (uniqueItemScanned ? 1 : 0), + lastSeenAt: now, + }; + }); +} + +function buildResult(state, endedAt = Date.now()) { + const participants = getParticipantList(state); + const uniqueItems = getUniqueCount(state); + return { + roundId: state.roundId, + uniqueItems, + totalObjectScans: state.totalObjectScans, + duplicateObjectScans: state.duplicateObjectScans, + ignoredScans: state.ignoredScans, + durationMs: Math.max(0, endedAt - (state.startedAt || endedAt)), + startedAt: state.startedAt, + endedAt, + participants, + objects: Object.values(state.seenObjects || {}).sort((a, b) => (a.firstScannedAt || 0) - (b.firstScannedAt || 0)), + }; +} + +function finishRound(state, endedAt = Date.now()) { + if (state.status !== 'running') return state; + const result = buildResult(state, endedAt); + const previousRecord = state.worldRecord; + const isWorldRecord = !previousRecord || result.uniqueItems > (previousRecord.uniqueItems || 0); + + state.status = 'ended'; + state.finalResult = { + ...result, + isWorldRecord, + }; + state.worldRecord = isWorldRecord ? result : previousRecord; + state.recentRounds = [state.finalResult, ...(state.recentRounds || [])].slice(0, 10); + state.lastMessage = isWorldRecord + ? `new record ${result.uniqueItems} items` + : `finished ${result.uniqueItems} items`; + return state; +} + +function buildAwards(result) { + if (!result?.participants?.length || !result.uniqueItems) return []; + + return result.participants + .filter((participant) => participant?.playerKey && participant.uniqueItems > 0) + .map((participant) => { + const points = Math.min(MAX_POINTS_PER_PLAYER, participant.uniqueItems * POINTS_PER_UNIQUE_ITEM); + return { + playerKey: participant.playerKey, + nickname: participant.nickname || null, + roverId: participant.roverId || null, + points, + reason: 'most items round', + gameMeta: { + uniqueItems: participant.uniqueItems, + roundId: result.roundId, + }, + }; + }); +} + +function start(rawState, context = {}) { + const previous = normalizeState(rawState); + const now = context.now || Date.now(); + return { + ...previous, + status: 'running', + roundId: `${now}-${Math.random().toString(36).slice(2, 8)}`, + startedAt: now, + endsAt: now + ROUND_DURATION_MS, + seenObjects: {}, + totalObjectScans: 0, + duplicateObjectScans: 0, + ignoredScans: 0, + participantCounts: {}, + finalResult: null, + lastObjectLabel: null, + lastMessage: 'scan different objects', + }; +} + +function activate(rawState, context = {}) { + return start(rawState, context); +} + +function handleScan(rawState, scan, context = {}) { + const now = context.now || Date.now(); + let state = normalizeState(rawState); + if (state.status === 'running' && state.endsAt && now >= state.endsAt) { + state = finishRound(state, state.endsAt); + return { + state, + awards: buildAwards(state.finalResult), + done: true, + display: getPublicState(state, { ...context, now }).display, + }; + } + if (state.status !== 'running') return { state, awards: [] }; + + if (!scan?.known || scan.type !== 'object') { + // Rover and unknown scans still matter to the shared coordinator for + // participant tracking, but this game only scores known physical objects. + state.ignoredScans += 1; + state.lastMessage = 'find object barcodes'; + return { state, awards: [] }; + } + + const objectKey = getObjectKey(scan); + if (!objectKey) { + state.ignoredScans += 1; + state.lastMessage = 'object needs an id'; + return { state, awards: [] }; + } + + state.totalObjectScans += 1; + const previousObject = state.seenObjects[objectKey] || null; + const uniqueItemScanned = !previousObject; + + if (uniqueItemScanned) { + state.seenObjects[objectKey] = { + code: scan.code || null, + entityId: scan.entityId || objectKey, + label: scan.label || objectKey, + firstScannedAt: now, + }; + state.lastObjectLabel = scan.label || objectKey; + state.lastMessage = `${getUniqueCount(state)} items`; + } else { + state.duplicateObjectScans += 1; + state.lastObjectLabel = previousObject.label || scan.label || objectKey; + state.lastMessage = `${state.lastObjectLabel} already counted`; + } + + addParticipantCredit(state, context.participants || [], now, uniqueItemScanned); + return { state, awards: [] }; +} + +function tick(rawState, context = {}) { + const now = context.now || Date.now(); + const state = normalizeState(rawState); + if (state.status === 'running' && state.endsAt && now >= state.endsAt) { + const finished = finishRound(state, state.endsAt); + return { + state: finished, + awards: buildAwards(finished.finalResult), + done: true, + display: getPublicState(finished, { ...context, now }).display, + }; + } + return state; +} + +function getPublicState(rawState, context = {}) { + let state = normalizeState(rawState); + const now = context.now || Date.now(); + if (state.status === 'running' && state.endsAt && now >= state.endsAt) { + state = finishRound(state, state.endsAt); + } + + const uniqueItems = state.status === 'ended' && state.finalResult + ? state.finalResult.uniqueItems + : getUniqueCount(state); + const remainingMs = state.status === 'running' ? Math.max(0, (state.endsAt || now) - now) : 0; + const worldRecordText = state.worldRecord ? `${state.worldRecord.uniqueItems || 0} items` : 'none yet'; + const participantResults = state.status === 'ended' && state.finalResult + ? state.finalResult.participants + : getParticipantList(state); + + return { + id: GAME_ID, + title: 'Most items', + status: state.status, + headline: state.lastMessage, + detail: state.status === 'running' + ? `${uniqueItems} unique items, ${Math.ceil(remainingMs / 1000)} seconds left` + : state.finalResult + ? `${state.finalResult.uniqueItems} unique items in last round` + : 'scan different known objects', + uniqueItems, + totalObjectScans: state.totalObjectScans, + duplicateObjectScans: state.duplicateObjectScans, + remainingMs, + finalResult: state.finalResult, + worldRecord: state.worldRecord, + scores: participantResults + .map((participant) => ({ + playerKey: participant.playerKey, + nickname: participant.nickname, + roverId: participant.roverId, + points: Math.min(MAX_POINTS_PER_PLAYER, (participant.uniqueItems || 0) * POINTS_PER_UNIQUE_ITEM), + })) + .filter((entry) => entry.points > 0), + participants: participantResults, + recentRounds: state.recentRounds, + actionLabel: state.status === 'running' ? 'Running' : 'Start round', + display: { + // The display contract is deliberately generic. The web UI renders these + // fields for every game, so the game describes object-count progress + // without requiring custom React components. + title: 'Most items', + primary: `${uniqueItems} ${uniqueItems === 1 ? 'item' : 'items'}`, + secondary: state.lastObjectLabel ? `Last: ${state.lastObjectLabel}` : 'Scan different known objects', + timer: state.status === 'running' && state.endsAt + ? { + label: 'Time left', + endsAt: state.endsAt, + } + : null, + stats: [ + { label: 'Unique items', value: uniqueItems }, + { label: 'Object scans', value: state.totalObjectScans }, + { label: 'World record', value: worldRecordText }, + ], + results: Object.values(state.seenObjects || {}).slice(-3).map((object) => ({ + label: 'Item', + value: object.label || object.entityId || object.code, + })), + }, + }; +} + +module.exports = { + id: GAME_ID, + title: 'Most items', + description: 'Scan as many different known objects as possible.', + createInitialState, + normalizeState, + activate, + start, + handleScan, + tick, + onActivated: activate, + onScan: (state, scan, context) => handleScan(state, scan, context).state, + onTick: tick, + getPublicState, +}; diff --git a/server/src/services/barcodeGameService/games/scanQuest.js b/server/src/services/barcodeGameService/games/scanQuest.js index 5dc57601..50b18d46 100644 --- a/server/src/services/barcodeGameService/games/scanQuest.js +++ b/server/src/services/barcodeGameService/games/scanQuest.js @@ -5,8 +5,9 @@ const GAME_ID = 'scanQuest'; const QUEST_LENGTH_OPTIONS = [1, 2]; -const REQUEST_TIMEOUT_MS = 3.5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 90 * 1000; const ROUND_DURATION_MS = 5 * 60 * 1000; +const POINTS_PER_STEP = 5; function createInitialState() { return { @@ -196,7 +197,10 @@ function handleScan(rawState, scan, context = {}) { return { state, awards: [] }; } - const points = state.currentQuest.steps.length; + // A completed quest is worth more than a raw scan because it asks the driver + // to find a specific object, stay in order, and finish within the per-request + // window. Keeping this in a constant makes future game-balance passes obvious. + const points = state.currentQuest.steps.length * POINTS_PER_STEP; state.completedQuests += 1; addScore(state, context.participants || [], points); addRecentEvent(state, { diff --git a/server/src/services/barcodeGameService/games/scansPerSecond.js b/server/src/services/barcodeGameService/games/scansPerSecond.js index fd16583a..fe3c037b 100644 --- a/server/src/services/barcodeGameService/games/scansPerSecond.js +++ b/server/src/services/barcodeGameService/games/scansPerSecond.js @@ -6,6 +6,7 @@ const GAME_ID = 'scansPerSecond'; const ROUND_DURATION_MS = 5 * 60 * 1000; const RESULT_IDLE_MS = 60 * 1000; +const MAX_ROUND_POINTS = 30; function createInitialState() { return { @@ -89,7 +90,10 @@ function finishRound(state, endedAt = Date.now()) { function buildAwards(result, state) { if (!result?.participants?.length || !result.scanCount) return []; - const basePoints = Math.max(1, Math.round(result.scansPerSecond * 10)); + // The rate can get silly if people discover a very fast scanning technique. + // Capping the shared point pool keeps this speed game comparable to the + // objective games while preserving the uncapped scan-rate record separately. + const basePoints = Math.min(MAX_ROUND_POINTS, Math.max(1, Math.round(result.scansPerSecond * 10))); return result.participants .filter((participant) => participant?.playerKey) .map((participant) => { diff --git a/server/src/services/barcodeGameService/index.js b/server/src/services/barcodeGameService/index.js index 243c3f71..e098e868 100644 --- a/server/src/services/barcodeGameService/index.js +++ b/server/src/services/barcodeGameService/index.js @@ -14,6 +14,7 @@ const { getRegistrySnapshot } = require('../barcodeScannerService'); const { loadStore, withGameStore } = require('./store'); const scanQuest = require('./games/scanQuest'); const scansPerSecond = require('./games/scansPerSecond'); +const mostItems = require('./games/mostItems'); const GAME_SOCKET_ROOM = 'barcode-game'; const RECENT_EVENT_LIMIT = 20; @@ -24,7 +25,7 @@ const JOIN_WINDOW_MS = 30 * 1000; const STARTING_WINDOW_MS = 5 * 1000; const RESULTS_WINDOW_MS = 45 * 1000; -const GAME_DEFINITIONS = [scanQuest, scansPerSecond]; +const GAME_DEFINITIONS = [scanQuest, scansPerSecond, mostItems]; const GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game])); const config = loadConfig(); const barcodeGamesConfig = config.barcodeGames || {}; @@ -44,6 +45,152 @@ function sendBarcodeGameChat(text) { }); } +function formatList(items = [], limit = 5) { + // Chat messages should stay readable in the normal feed. This helper keeps + // every lifecycle message to a short list while still making it obvious when + // more players or stats existed than could fit comfortably in one message. + const values = items + .map((item) => String(item || '').trim()) + .filter(Boolean); + if (!values.length) return ''; + + const visible = values.slice(0, limit); + const extraCount = values.length - visible.length; + const suffix = extraCount > 0 ? `, and ${extraCount} more` : ''; + return `${visible.join(', ')}${suffix}`; +} + +function getParticipantName(participant = {}) { + return participant.nickname || participant.roverId || participant.participantKey || ''; +} + +function formatParticipantSummary(participants = []) { + const names = formatList(participants.map(getParticipantName)); + return names || 'no counted rovers yet'; +} + +function formatVoteSummary(votes = {}, selectedGameId = null) { + const counts = countVotes(votes); + const selectedVotes = selectedGameId ? counts[selectedGameId] || 0 : 0; + const totalVotes = Object.values(counts).reduce((sum, count) => sum + count, 0); + + // The selected vote count matters more than a full per-game breakdown in + // chat. The detailed vote buttons still show every game, while the bot gives + // enough context to explain why this specific game moved to joining. + if (!totalVotes) return 'No votes were counted'; + if (selectedVotes === totalVotes) { + return `${selectedVotes} ${selectedVotes === 1 ? 'vote' : 'votes'}`; + } + return `${selectedVotes} of ${totalVotes} ${totalVotes === 1 ? 'vote' : 'votes'}`; +} + +function normalizeAwardSummaries(awards = []) { + const totalsByPlayer = {}; + + awards.forEach((award) => { + const playerKey = award?.playerKey; + const points = Number.isFinite(award?.points) ? Math.max(0, Math.floor(award.points)) : 0; + // applyPointAwards uses this same identity-only rule. Repeating it here + // prevents the bot from claiming that a rover-only participant earned + // leaderboard points when the persistent ledger intentionally ignored it. + if (!playerKey || !String(playerKey).startsWith('identity:') || !points) return; + const previous = totalsByPlayer[playerKey] || {}; + totalsByPlayer[playerKey] = { + playerKey, + nickname: award.nickname || previous.nickname || award.roverId || 'unknown player', + roverId: award.roverId || previous.roverId || null, + points: (Number.isFinite(previous.points) ? previous.points : 0) + points, + }; + }); + + return Object.values(totalsByPlayer).sort((a, b) => (b.points || 0) - (a.points || 0)); +} + +function formatAwardSummary(awards = []) { + const summaries = normalizeAwardSummaries(awards); + if (!summaries.length) return ''; + + // Award packets are the most authoritative source for point changes because + // they are what the shared ledger actually applies. The formatter groups + // multiple awards per player so chat does not spam one line per scan. + return formatList( + summaries.map((award) => `${award.nickname || award.roverId} scored ${award.points} ${award.points === 1 ? 'point' : 'points'}`), + 5, + ); +} + +function formatPublicScoreSummary(publicState = {}) { + const scoreEntries = Array.isArray(publicState?.scores) + ? publicState.scores + : Array.isArray(publicState?.finalResult?.participants) + ? publicState.finalResult.participants + : []; + + const summaries = scoreEntries + .map((entry) => { + // Public game state is less authoritative than award packets, but it is + // still valuable for games that award throughout the round. The formatter + // accepts both point-shaped and scan-count-shaped entries so game modules + // can expose natural result data without chat-specific contracts. + const points = Number.isFinite(entry?.points) ? Math.max(0, Math.floor(entry.points)) : null; + const scanCount = Number.isFinite(entry?.scanCount) ? Math.max(0, Math.floor(entry.scanCount)) : null; + const name = entry?.nickname || entry?.roverId || entry?.playerKey || ''; + + if (!name) return null; + if (points !== null && points > 0) { + return `${name} scored ${points} ${points === 1 ? 'point' : 'points'}`; + } + if (scanCount !== null && scanCount > 0) { + return `${name} made ${scanCount} ${scanCount === 1 ? 'scan' : 'scans'}`; + } + return null; + }) + .filter(Boolean); + + // Some games, like scan quest, award during the round instead of returning a + // final award packet. Their public state still carries round scores, so this + // gives the bot a useful end summary without making each game hand-write chat. + return formatList(summaries, 5); +} + +function formatResultSummary(display = {}) { + const primary = String(display?.primary || '').trim(); + const usefulStats = Array.isArray(display?.stats) + ? display.stats + .map((stat) => { + const label = String(stat?.label || '').trim(); + const value = String(stat?.value ?? '').trim(); + if (!label || !value) return ''; + return `${label}: ${value}`; + }) + .filter(Boolean) + : []; + + return formatList([primary, ...usefulStats], 3); +} + +function sendGameStartChat(definition, participants = []) { + const title = definition?.title || 'Barcode game'; + const participantSummary = formatParticipantSummary(participants); + sendBarcodeGameChat(`${title} has started with ${participantSummary}.`); +} + +function sendGameEndChat(definition, { display = null, publicState = null, awards = [], participants = [] } = {}) { + const title = definition?.title || 'Barcode game'; + const awardSummary = formatAwardSummary(awards) || formatPublicScoreSummary(publicState); + const resultSummary = formatResultSummary(display || publicState?.display || {}); + const participantSummary = formatParticipantSummary(participants); + const details = [ + resultSummary, + awardSummary || `Played by ${participantSummary}`, + ].filter(Boolean); + + // End messages intentionally combine game-provided results with shared ledger + // awards. That keeps the bot useful for both end-scored games and games that + // score during play while still avoiding game-specific chat code. + sendBarcodeGameChat(`${title} ended. ${details.join(' ') || 'Results are now showing.'}`); +} + function getGameDefinition(gameId) { return GAMES_BY_ID[String(gameId || '')] || null; } @@ -340,7 +487,7 @@ function startGame(draft, gameId, now) { gameId, title: definition.title, }); - sendBarcodeGameChat(`${definition.title} has started.`); + sendGameStartChat(definition, getRoundParticipants(draft)); return true; } @@ -421,7 +568,7 @@ function setVote(socket, gameId) { draft.resultsUntil = null; draft.resultDisplay = null; draft.roundParticipants = {}; - sendBarcodeGameChat(`Voting has started for ${definition.title}. Vote in the Activities tab.`); + sendBarcodeGameChat(`Voting has started. Current pick: ${definition.title}. Vote in the Activities tab.`); } else if (draft.phase === 'voting') { draft.voteEndsAt = Math.max(draft.voteEndsAt || 0, now + VOTING_WINDOW_MS); } @@ -452,7 +599,7 @@ function settleActiveGameIfNeeded() { gameId: winner, title: winnerDefinition.title, }); - sendBarcodeGameChat(`Voting ended. ${winnerDefinition.title} was selected.`); + sendBarcodeGameChat(`Voting ended. ${winnerDefinition.title} was selected with ${formatVoteSummary(draft.votes, winner)}. Scan a rover to join.`); }); return true; } @@ -475,6 +622,7 @@ function settleActiveGameIfNeeded() { draft.resultDisplay = null; draft.votes = {}; draft.roundParticipants = {}; + sendBarcodeGameChat('No rover joined in time. The selected barcode game was cancelled.'); }); return true; } @@ -523,6 +671,7 @@ function settleActiveGameIfNeeded() { const publicState = definition.getPublicState ? definition.getPublicState(nextState, buildGameContext(draft, { now })) : null; + const participants = getRoundParticipants(draft); draft.phase = 'results'; draft.resultGameId = activeGameId; draft.resultDisplay = display || publicState?.display || null; @@ -533,7 +682,12 @@ function settleActiveGameIfNeeded() { draft.voteEndsAt = null; draft.joinEndsAt = null; draft.votes = {}; - sendBarcodeGameChat(`${definition.title} ended. Results are now showing.`); + sendGameEndChat(definition, { + display: draft.resultDisplay, + publicState, + awards, + participants, + }); } }); return true; @@ -590,6 +744,7 @@ function handleScan(scan) { const publicState = definition.getPublicState ? definition.getPublicState(nextState, buildGameContext(draft, { now })) : null; + const finalParticipants = getRoundParticipants(draft); draft.phase = 'results'; draft.resultGameId = activeGameId; draft.resultDisplay = display || publicState?.display || null; @@ -600,7 +755,12 @@ function handleScan(scan) { draft.voteEndsAt = null; draft.joinEndsAt = null; draft.votes = {}; - sendBarcodeGameChat(`${definition.title} ended. Results are now showing.`); + sendGameEndChat(definition, { + display: draft.resultDisplay, + publicState, + awards, + participants: finalParticipants, + }); } }