new game! and better points balancing too

This commit is contained in:
legop3
2026-06-18 22:46:59 -04:00
parent e3d66cc5f6
commit 80f1f82d6e
4 changed files with 506 additions and 9 deletions
@@ -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,
};
@@ -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, {
@@ -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) => {
+166 -6
View File
@@ -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,
});
}
}