mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
new game! and better points balancing too
This commit is contained in:
@@ -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 GAME_ID = 'scanQuest';
|
||||||
const QUEST_LENGTH_OPTIONS = [1, 2];
|
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 ROUND_DURATION_MS = 5 * 60 * 1000;
|
||||||
|
const POINTS_PER_STEP = 5;
|
||||||
|
|
||||||
function createInitialState() {
|
function createInitialState() {
|
||||||
return {
|
return {
|
||||||
@@ -196,7 +197,10 @@ function handleScan(rawState, scan, context = {}) {
|
|||||||
return { state, awards: [] };
|
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;
|
state.completedQuests += 1;
|
||||||
addScore(state, context.participants || [], points);
|
addScore(state, context.participants || [], points);
|
||||||
addRecentEvent(state, {
|
addRecentEvent(state, {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
const GAME_ID = 'scansPerSecond';
|
const GAME_ID = 'scansPerSecond';
|
||||||
const ROUND_DURATION_MS = 5 * 60 * 1000;
|
const ROUND_DURATION_MS = 5 * 60 * 1000;
|
||||||
const RESULT_IDLE_MS = 60 * 1000;
|
const RESULT_IDLE_MS = 60 * 1000;
|
||||||
|
const MAX_ROUND_POINTS = 30;
|
||||||
|
|
||||||
function createInitialState() {
|
function createInitialState() {
|
||||||
return {
|
return {
|
||||||
@@ -89,7 +90,10 @@ function finishRound(state, endedAt = Date.now()) {
|
|||||||
|
|
||||||
function buildAwards(result, state) {
|
function buildAwards(result, state) {
|
||||||
if (!result?.participants?.length || !result.scanCount) return [];
|
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
|
return result.participants
|
||||||
.filter((participant) => participant?.playerKey)
|
.filter((participant) => participant?.playerKey)
|
||||||
.map((participant) => {
|
.map((participant) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const { getRegistrySnapshot } = require('../barcodeScannerService');
|
|||||||
const { loadStore, withGameStore } = require('./store');
|
const { loadStore, withGameStore } = require('./store');
|
||||||
const scanQuest = require('./games/scanQuest');
|
const scanQuest = require('./games/scanQuest');
|
||||||
const scansPerSecond = require('./games/scansPerSecond');
|
const scansPerSecond = require('./games/scansPerSecond');
|
||||||
|
const mostItems = require('./games/mostItems');
|
||||||
|
|
||||||
const GAME_SOCKET_ROOM = 'barcode-game';
|
const GAME_SOCKET_ROOM = 'barcode-game';
|
||||||
const RECENT_EVENT_LIMIT = 20;
|
const RECENT_EVENT_LIMIT = 20;
|
||||||
@@ -24,7 +25,7 @@ const JOIN_WINDOW_MS = 30 * 1000;
|
|||||||
const STARTING_WINDOW_MS = 5 * 1000;
|
const STARTING_WINDOW_MS = 5 * 1000;
|
||||||
const RESULTS_WINDOW_MS = 45 * 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 GAMES_BY_ID = Object.fromEntries(GAME_DEFINITIONS.map((game) => [game.id, game]));
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const barcodeGamesConfig = config.barcodeGames || {};
|
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) {
|
function getGameDefinition(gameId) {
|
||||||
return GAMES_BY_ID[String(gameId || '')] || null;
|
return GAMES_BY_ID[String(gameId || '')] || null;
|
||||||
}
|
}
|
||||||
@@ -340,7 +487,7 @@ function startGame(draft, gameId, now) {
|
|||||||
gameId,
|
gameId,
|
||||||
title: definition.title,
|
title: definition.title,
|
||||||
});
|
});
|
||||||
sendBarcodeGameChat(`${definition.title} has started.`);
|
sendGameStartChat(definition, getRoundParticipants(draft));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,7 +568,7 @@ function setVote(socket, gameId) {
|
|||||||
draft.resultsUntil = null;
|
draft.resultsUntil = null;
|
||||||
draft.resultDisplay = null;
|
draft.resultDisplay = null;
|
||||||
draft.roundParticipants = {};
|
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') {
|
} else if (draft.phase === 'voting') {
|
||||||
draft.voteEndsAt = Math.max(draft.voteEndsAt || 0, now + VOTING_WINDOW_MS);
|
draft.voteEndsAt = Math.max(draft.voteEndsAt || 0, now + VOTING_WINDOW_MS);
|
||||||
}
|
}
|
||||||
@@ -452,7 +599,7 @@ function settleActiveGameIfNeeded() {
|
|||||||
gameId: winner,
|
gameId: winner,
|
||||||
title: winnerDefinition.title,
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -475,6 +622,7 @@ function settleActiveGameIfNeeded() {
|
|||||||
draft.resultDisplay = null;
|
draft.resultDisplay = null;
|
||||||
draft.votes = {};
|
draft.votes = {};
|
||||||
draft.roundParticipants = {};
|
draft.roundParticipants = {};
|
||||||
|
sendBarcodeGameChat('No rover joined in time. The selected barcode game was cancelled.');
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -523,6 +671,7 @@ function settleActiveGameIfNeeded() {
|
|||||||
const publicState = definition.getPublicState
|
const publicState = definition.getPublicState
|
||||||
? definition.getPublicState(nextState, buildGameContext(draft, { now }))
|
? definition.getPublicState(nextState, buildGameContext(draft, { now }))
|
||||||
: null;
|
: null;
|
||||||
|
const participants = getRoundParticipants(draft);
|
||||||
draft.phase = 'results';
|
draft.phase = 'results';
|
||||||
draft.resultGameId = activeGameId;
|
draft.resultGameId = activeGameId;
|
||||||
draft.resultDisplay = display || publicState?.display || null;
|
draft.resultDisplay = display || publicState?.display || null;
|
||||||
@@ -533,7 +682,12 @@ function settleActiveGameIfNeeded() {
|
|||||||
draft.voteEndsAt = null;
|
draft.voteEndsAt = null;
|
||||||
draft.joinEndsAt = null;
|
draft.joinEndsAt = null;
|
||||||
draft.votes = {};
|
draft.votes = {};
|
||||||
sendBarcodeGameChat(`${definition.title} ended. Results are now showing.`);
|
sendGameEndChat(definition, {
|
||||||
|
display: draft.resultDisplay,
|
||||||
|
publicState,
|
||||||
|
awards,
|
||||||
|
participants,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -590,6 +744,7 @@ function handleScan(scan) {
|
|||||||
const publicState = definition.getPublicState
|
const publicState = definition.getPublicState
|
||||||
? definition.getPublicState(nextState, buildGameContext(draft, { now }))
|
? definition.getPublicState(nextState, buildGameContext(draft, { now }))
|
||||||
: null;
|
: null;
|
||||||
|
const finalParticipants = getRoundParticipants(draft);
|
||||||
draft.phase = 'results';
|
draft.phase = 'results';
|
||||||
draft.resultGameId = activeGameId;
|
draft.resultGameId = activeGameId;
|
||||||
draft.resultDisplay = display || publicState?.display || null;
|
draft.resultDisplay = display || publicState?.display || null;
|
||||||
@@ -600,7 +755,12 @@ function handleScan(scan) {
|
|||||||
draft.voteEndsAt = null;
|
draft.voteEndsAt = null;
|
||||||
draft.joinEndsAt = null;
|
draft.joinEndsAt = null;
|
||||||
draft.votes = {};
|
draft.votes = {};
|
||||||
sendBarcodeGameChat(`${definition.title} ended. Results are now showing.`);
|
sendGameEndChat(definition, {
|
||||||
|
display: draft.resultDisplay,
|
||||||
|
publicState,
|
||||||
|
awards,
|
||||||
|
participants: finalParticipants,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user