mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
262 lines
8.8 KiB
JavaScript
262 lines
8.8 KiB
JavaScript
// Scan Quest Game
|
|
// Purpose: Implements the ordered object-scanning quest game.
|
|
// Scope: Keeps quest selection, scoring, and quest-specific display state out of
|
|
// the shared barcode game service.
|
|
|
|
const GAME_ID = 'scanQuest';
|
|
const QUEST_LENGTH_OPTIONS = [1, 2];
|
|
const REQUEST_TIMEOUT_MS = 90 * 1000;
|
|
|
|
function createInitialState() {
|
|
return {
|
|
currentQuest: null,
|
|
progressIndex: 0,
|
|
scores: {},
|
|
completedQuests: 0,
|
|
stepStartedAt: null,
|
|
recentEvents: [],
|
|
lastMessage: 'vote to start scan quest',
|
|
};
|
|
}
|
|
|
|
function normalizeState(rawState = {}) {
|
|
const base = createInitialState();
|
|
return {
|
|
...base,
|
|
currentQuest: rawState.currentQuest && typeof rawState.currentQuest === 'object' ? rawState.currentQuest : null,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function pickQuest(objects = []) {
|
|
const candidates = objects.filter((entry) => entry?.code && entry?.label);
|
|
if (!candidates.length) return null;
|
|
const length = QUEST_LENGTH_OPTIONS[Math.floor(Math.random() * QUEST_LENGTH_OPTIONS.length)];
|
|
const steps = [];
|
|
|
|
for (let idx = 0; idx < length; idx += 1) {
|
|
// Reusing objects is allowed because physical rooms often start with only a
|
|
// small number of labeled props. The game still stays readable because the
|
|
// sequence is short and order-specific.
|
|
const entry = candidates[Math.floor(Math.random() * candidates.length)];
|
|
steps.push({
|
|
code: entry.code,
|
|
entityId: entry.entityId,
|
|
label: entry.label,
|
|
});
|
|
}
|
|
|
|
return {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
steps,
|
|
createdAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
function formatQuestPrompt(state) {
|
|
const quest = state.currentQuest;
|
|
if (!quest?.steps?.length) return 'scan quest needs objects';
|
|
const step = quest.steps[state.progressIndex] || quest.steps[0];
|
|
if (quest.steps.length === 1) return `scan ${step.label}`;
|
|
return `scan ${step.label} ${state.progressIndex + 1} of ${quest.steps.length}`;
|
|
}
|
|
|
|
function ensureQuest(state, context = {}) {
|
|
if (state.currentQuest?.steps?.length) return state;
|
|
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;
|
|
}
|
|
|
|
function addRecentEvent(state, event) {
|
|
state.recentEvents = [
|
|
{
|
|
...event,
|
|
at: Date.now(),
|
|
},
|
|
...(Array.isArray(state.recentEvents) ? state.recentEvents : []),
|
|
].slice(0, 12);
|
|
}
|
|
|
|
function addScore(state, participants = [], points) {
|
|
participants.forEach((participant) => {
|
|
const key = participant?.playerKey;
|
|
if (!key) return;
|
|
const previous = state.scores[key] || {};
|
|
state.scores[key] = {
|
|
playerKey: key,
|
|
nickname: participant.nickname || previous.nickname || null,
|
|
roverId: participant.roverId || previous.roverId || null,
|
|
points: (Number.isFinite(previous.points) ? previous.points : 0) + points,
|
|
lastScoredAt: Date.now(),
|
|
};
|
|
});
|
|
}
|
|
|
|
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 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, 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);
|
|
|
|
if (!matched) {
|
|
// Wrong scans do not erase the whole quest. That keeps the game forgiving
|
|
// for rover driving mistakes while still making the ordered target clear on
|
|
// the large scanner display.
|
|
state.lastMessage = `try ${expected.label}`;
|
|
addRecentEvent(state, {
|
|
kind: 'miss',
|
|
label: scan.label,
|
|
expected: expected.label,
|
|
});
|
|
return { state, awards: [] };
|
|
}
|
|
|
|
state.progressIndex += 1;
|
|
state.stepStartedAt = context.now || Date.now();
|
|
addRecentEvent(state, {
|
|
kind: 'hit',
|
|
label: scan.label,
|
|
});
|
|
|
|
if (state.progressIndex < state.currentQuest.steps.length) {
|
|
state.lastMessage = formatQuestPrompt(state);
|
|
return { state, awards: [] };
|
|
}
|
|
|
|
const points = state.currentQuest.steps.length;
|
|
state.completedQuests += 1;
|
|
addScore(state, context.participants || [], points);
|
|
addRecentEvent(state, {
|
|
kind: 'complete',
|
|
points,
|
|
participants: (context.participants || []).map((participant) => participant.nickname || participant.roverId).filter(Boolean),
|
|
});
|
|
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,
|
|
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) {
|
|
return Object.values(state.scores || {})
|
|
.sort((a, b) => (b.points || 0) - (a.points || 0))
|
|
.slice(0, 5);
|
|
}
|
|
|
|
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',
|
|
status: state.currentQuest ? 'running' : 'needs_objects',
|
|
headline: state.lastMessage || formatQuestPrompt(state),
|
|
detail: state.currentQuest?.steps?.length
|
|
? state.currentQuest.steps.map((step) => step.label).join(' then ')
|
|
: 'add object barcodes to the registry',
|
|
progress: {
|
|
current: state.progressIndex,
|
|
total: state.currentQuest?.steps?.length || 0,
|
|
},
|
|
remainingMs,
|
|
actionLabel: 'Start quest',
|
|
scores: getTopScores(state),
|
|
completedQuests: state.completedQuests,
|
|
recentEvents: state.recentEvents,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
id: GAME_ID,
|
|
title: 'Scan quest',
|
|
description: 'Scan one or two requested objects in order.',
|
|
createInitialState,
|
|
normalizeState,
|
|
activate,
|
|
reset,
|
|
handleScan,
|
|
tick,
|
|
onActivated: activate,
|
|
onScan: (state, scan, context) => handleScan(state, scan, context).state,
|
|
onTick: tick,
|
|
getPublicState,
|
|
};
|