mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
scanquest updates
This commit is contained in:
@@ -4,9 +4,8 @@
|
|||||||
// the shared barcode game service.
|
// the shared barcode game service.
|
||||||
|
|
||||||
const GAME_ID = 'scanQuest';
|
const GAME_ID = 'scanQuest';
|
||||||
const QUEST_LENGTH_OPTIONS = [1, 2];
|
const QUEST_LENGTH = 5;
|
||||||
const REQUEST_TIMEOUT_MS = 90 * 1000;
|
const REQUEST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
const ROUND_DURATION_MS = 5 * 60 * 1000;
|
|
||||||
const POINTS_PER_STEP = 5;
|
const POINTS_PER_STEP = 5;
|
||||||
|
|
||||||
function createInitialState() {
|
function createInitialState() {
|
||||||
@@ -17,7 +16,8 @@ function createInitialState() {
|
|||||||
roundEndsAt: null,
|
roundEndsAt: null,
|
||||||
progressIndex: 0,
|
progressIndex: 0,
|
||||||
scores: {},
|
scores: {},
|
||||||
completedQuests: 0,
|
completedItems: 0,
|
||||||
|
skippedItems: 0,
|
||||||
stepStartedAt: null,
|
stepStartedAt: null,
|
||||||
recentEvents: [],
|
recentEvents: [],
|
||||||
lastMessage: 'vote to start scan quest',
|
lastMessage: 'vote to start scan quest',
|
||||||
@@ -34,7 +34,12 @@ function normalizeState(rawState = {}) {
|
|||||||
roundEndsAt: Number.isFinite(rawState.roundEndsAt) ? rawState.roundEndsAt : null,
|
roundEndsAt: Number.isFinite(rawState.roundEndsAt) ? rawState.roundEndsAt : null,
|
||||||
progressIndex: Number.isFinite(rawState.progressIndex) ? Math.max(0, Math.floor(rawState.progressIndex)) : 0,
|
progressIndex: Number.isFinite(rawState.progressIndex) ? Math.max(0, Math.floor(rawState.progressIndex)) : 0,
|
||||||
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
|
scores: rawState.scores && typeof rawState.scores === 'object' ? rawState.scores : {},
|
||||||
completedQuests: Number.isFinite(rawState.completedQuests) ? Math.max(0, Math.floor(rawState.completedQuests)) : 0,
|
completedItems: Number.isFinite(rawState.completedItems)
|
||||||
|
? Math.max(0, Math.floor(rawState.completedItems))
|
||||||
|
: Number.isFinite(rawState.completedQuests)
|
||||||
|
? Math.max(0, Math.floor(rawState.completedQuests))
|
||||||
|
: 0,
|
||||||
|
skippedItems: Number.isFinite(rawState.skippedItems) ? Math.max(0, Math.floor(rawState.skippedItems)) : 0,
|
||||||
stepStartedAt: Number.isFinite(rawState.stepStartedAt) ? rawState.stepStartedAt : null,
|
stepStartedAt: Number.isFinite(rawState.stepStartedAt) ? rawState.stepStartedAt : null,
|
||||||
recentEvents: Array.isArray(rawState.recentEvents) ? rawState.recentEvents.slice(-12) : [],
|
recentEvents: Array.isArray(rawState.recentEvents) ? rawState.recentEvents.slice(-12) : [],
|
||||||
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
|
lastMessage: typeof rawState.lastMessage === 'string' ? rawState.lastMessage : base.lastMessage,
|
||||||
@@ -44,24 +49,22 @@ function normalizeState(rawState = {}) {
|
|||||||
function pickQuest(objects = []) {
|
function pickQuest(objects = []) {
|
||||||
const candidates = objects.filter((entry) => entry?.code && entry?.label);
|
const candidates = objects.filter((entry) => entry?.code && entry?.label);
|
||||||
if (!candidates.length) return null;
|
if (!candidates.length) return null;
|
||||||
const length = QUEST_LENGTH_OPTIONS[Math.floor(Math.random() * QUEST_LENGTH_OPTIONS.length)];
|
const shuffled = candidates
|
||||||
const steps = [];
|
.map((entry) => ({ entry, sort: Math.random() }))
|
||||||
|
.sort((a, b) => a.sort - b.sort)
|
||||||
for (let idx = 0; idx < length; idx += 1) {
|
.map(({ entry }) => entry);
|
||||||
// Reusing objects is allowed because physical rooms often start with only a
|
const selected = shuffled.slice(0, Math.min(QUEST_LENGTH, shuffled.length));
|
||||||
// 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 {
|
return {
|
||||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
steps,
|
// The entire route is chosen up front so players can see the ordered list
|
||||||
|
// and plan rover movement. Items are unique when possible because repeated
|
||||||
|
// entries make the list harder to reason about from the scanner display.
|
||||||
|
steps: selected.map((entry) => ({
|
||||||
|
code: entry.code,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
label: entry.label,
|
||||||
|
})),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -70,7 +73,6 @@ function formatQuestPrompt(state) {
|
|||||||
const quest = state.currentQuest;
|
const quest = state.currentQuest;
|
||||||
if (!quest?.steps?.length) return 'scan quest needs objects';
|
if (!quest?.steps?.length) return 'scan quest needs objects';
|
||||||
const step = quest.steps[state.progressIndex] || quest.steps[0];
|
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}`;
|
return `scan ${step.label} ${state.progressIndex + 1} of ${quest.steps.length}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +132,7 @@ function start(_rawState, context = {}) {
|
|||||||
const state = createInitialState();
|
const state = createInitialState();
|
||||||
state.status = 'running';
|
state.status = 'running';
|
||||||
state.roundStartedAt = now;
|
state.roundStartedAt = now;
|
||||||
state.roundEndsAt = now + ROUND_DURATION_MS;
|
state.roundEndsAt = null;
|
||||||
ensureQuest(state, context);
|
ensureQuest(state, context);
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -144,19 +146,26 @@ function skipExpiredQuest(state, context = {}) {
|
|||||||
if (!state.currentQuest?.steps?.length || !state.stepStartedAt) return state;
|
if (!state.currentQuest?.steps?.length || !state.stepStartedAt) return state;
|
||||||
if (now - state.stepStartedAt < REQUEST_TIMEOUT_MS) return state;
|
if (now - state.stepStartedAt < REQUEST_TIMEOUT_MS) return state;
|
||||||
|
|
||||||
// A timeout generates a fresh request instead of continuing a half-complete
|
// A timeout advances to the next item instead of generating a new quest. The
|
||||||
// sequence. If an item is physically unreachable or the barcode is damaged,
|
// whole ordered list stays stable, but a bad or unreachable barcode cannot
|
||||||
// the room gets unstuck without punishing anyone or making the next prompt
|
// trap the game forever on one physical object.
|
||||||
// depend on a failed previous step.
|
|
||||||
const skippedStep = state.currentQuest.steps[state.progressIndex] || state.currentQuest.steps[0];
|
const skippedStep = state.currentQuest.steps[state.progressIndex] || state.currentQuest.steps[0];
|
||||||
addRecentEvent(state, {
|
addRecentEvent(state, {
|
||||||
kind: 'timeout',
|
kind: 'timeout',
|
||||||
label: skippedStep?.label || null,
|
label: skippedStep?.label || null,
|
||||||
});
|
});
|
||||||
state.currentQuest = pickQuest(context.objects || []);
|
state.skippedItems += 1;
|
||||||
state.progressIndex = 0;
|
state.progressIndex += 1;
|
||||||
state.stepStartedAt = state.currentQuest ? now : null;
|
|
||||||
state.lastMessage = state.currentQuest ? `skipped. ${formatQuestPrompt(state)}` : 'scan quest needs objects';
|
if (state.progressIndex >= state.currentQuest.steps.length) {
|
||||||
|
state.status = 'ended';
|
||||||
|
state.stepStartedAt = null;
|
||||||
|
state.lastMessage = `finished ${state.completedItems} items`;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.stepStartedAt = now;
|
||||||
|
state.lastMessage = `skipped. ${formatQuestPrompt(state)}`;
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +174,14 @@ function handleScan(rawState, scan, context = {}) {
|
|||||||
if (state.status !== 'running') return { state, awards: [] };
|
if (state.status !== 'running') return { state, awards: [] };
|
||||||
ensureQuest(state, context);
|
ensureQuest(state, context);
|
||||||
skipExpiredQuest(state, context);
|
skipExpiredQuest(state, context);
|
||||||
|
if (state.status === 'ended') {
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
awards: [],
|
||||||
|
done: true,
|
||||||
|
display: getPublicState(state, context).display,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!state.currentQuest?.steps?.length) return { state, awards: [] };
|
if (!state.currentQuest?.steps?.length) return { state, awards: [] };
|
||||||
if (!scan?.known || scan.type !== 'object') return { state, awards: [] };
|
if (!scan?.known || scan.type !== 'object') return { state, awards: [] };
|
||||||
@@ -187,54 +204,56 @@ function handleScan(rawState, scan, context = {}) {
|
|||||||
|
|
||||||
state.progressIndex += 1;
|
state.progressIndex += 1;
|
||||||
state.stepStartedAt = context.now || Date.now();
|
state.stepStartedAt = context.now || Date.now();
|
||||||
|
state.completedItems += 1;
|
||||||
addRecentEvent(state, {
|
addRecentEvent(state, {
|
||||||
kind: 'hit',
|
kind: 'hit',
|
||||||
label: scan.label,
|
label: scan.label,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (state.progressIndex < state.currentQuest.steps.length) {
|
// A completed item is worth more than a raw scan because it asks the driver
|
||||||
state.lastMessage = formatQuestPrompt(state);
|
|
||||||
return { state, awards: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
// 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.
|
// window. Keeping this in a constant makes future game-balance passes obvious.
|
||||||
const points = state.currentQuest.steps.length * POINTS_PER_STEP;
|
const points = POINTS_PER_STEP;
|
||||||
state.completedQuests += 1;
|
|
||||||
addScore(state, context.participants || [], points);
|
addScore(state, context.participants || [], points);
|
||||||
addRecentEvent(state, {
|
addRecentEvent(state, {
|
||||||
kind: 'complete',
|
kind: 'complete',
|
||||||
points,
|
points,
|
||||||
participants: (context.participants || []).map((participant) => participant.nickname || participant.roverId).filter(Boolean),
|
participants: (context.participants || []).map((participant) => participant.nickname || participant.roverId).filter(Boolean),
|
||||||
});
|
});
|
||||||
state.currentQuest = pickQuest(context.objects || []);
|
|
||||||
state.progressIndex = 0;
|
if (state.progressIndex >= state.currentQuest.steps.length) {
|
||||||
state.stepStartedAt = state.currentQuest ? context.now || Date.now() : null;
|
state.status = 'ended';
|
||||||
state.lastMessage = state.currentQuest ? `scored ${points}. ${formatQuestPrompt(state)}` : `scored ${points}`;
|
state.stepStartedAt = null;
|
||||||
|
state.lastMessage = `finished ${state.completedItems} items`;
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
awards: buildAwards(context.participants || [], points, 'scan quest item completed'),
|
||||||
|
done: true,
|
||||||
|
display: getPublicState(state, context).display,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
state.lastMessage = `scored ${points}. ${formatQuestPrompt(state)}`;
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
awards: buildAwards(context.participants || [], points, 'scan quest completed'),
|
awards: buildAwards(context.participants || [], points, 'scan quest item completed'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tick(rawState, context = {}) {
|
function tick(rawState, context = {}) {
|
||||||
const state = normalizeState(rawState);
|
const state = normalizeState(rawState);
|
||||||
const now = context.now || Date.now();
|
if (state.status !== 'running') return state;
|
||||||
if (state.status === 'running' && state.roundEndsAt && now >= state.roundEndsAt) {
|
ensureQuest(state, context);
|
||||||
|
const nextState = skipExpiredQuest(state, context);
|
||||||
|
if (nextState.status === 'ended') {
|
||||||
return {
|
return {
|
||||||
state: {
|
state: nextState,
|
||||||
...state,
|
|
||||||
status: 'ended',
|
|
||||||
lastMessage: `finished ${state.completedQuests} quests`,
|
|
||||||
},
|
|
||||||
awards: [],
|
awards: [],
|
||||||
done: true,
|
done: true,
|
||||||
display: getPublicState({ ...state, status: 'ended' }, context).display,
|
display: getPublicState(nextState, context).display,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
ensureQuest(state, context);
|
return nextState;
|
||||||
return skipExpiredQuest(state, context);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTopScores(state) {
|
function getTopScores(state) {
|
||||||
@@ -253,15 +272,16 @@ function getPublicState(rawState, context = {}) {
|
|||||||
: 0;
|
: 0;
|
||||||
const currentStep = state.currentQuest?.steps?.[state.progressIndex] || null;
|
const currentStep = state.currentQuest?.steps?.[state.progressIndex] || null;
|
||||||
const totalSteps = state.currentQuest?.steps?.length || 0;
|
const totalSteps = state.currentQuest?.steps?.length || 0;
|
||||||
const stepLabel = totalSteps > 1 ? `Step ${state.progressIndex + 1} of ${totalSteps}` : 'Find the object';
|
const stepLabel = totalSteps ? `Item ${Math.min(state.progressIndex + 1, totalSteps)} of ${totalSteps}` : 'Find the object';
|
||||||
|
const orderedList = state.currentQuest?.steps?.length
|
||||||
|
? state.currentQuest.steps.map((step, idx) => `${idx + 1}. ${step.label}`).join(' -> ')
|
||||||
|
: 'add object barcodes to the registry';
|
||||||
return {
|
return {
|
||||||
id: GAME_ID,
|
id: GAME_ID,
|
||||||
title: 'Scan quest',
|
title: 'Scan quest',
|
||||||
status: state.status === 'running' && state.currentQuest ? 'running' : state.status,
|
status: state.status === 'running' && state.currentQuest ? 'running' : state.status,
|
||||||
headline: state.lastMessage || formatQuestPrompt(state),
|
headline: state.lastMessage || formatQuestPrompt(state),
|
||||||
detail: state.currentQuest?.steps?.length
|
detail: orderedList,
|
||||||
? state.currentQuest.steps.map((step) => step.label).join(' then ')
|
|
||||||
: 'add object barcodes to the registry',
|
|
||||||
progress: {
|
progress: {
|
||||||
current: state.progressIndex,
|
current: state.progressIndex,
|
||||||
total: state.currentQuest?.steps?.length || 0,
|
total: state.currentQuest?.steps?.length || 0,
|
||||||
@@ -282,9 +302,9 @@ function getPublicState(rawState, context = {}) {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
stats: [
|
stats: [
|
||||||
{ label: 'Completed', value: state.completedQuests },
|
{ label: 'Completed', value: state.completedItems },
|
||||||
|
{ label: 'Skipped', value: state.skippedItems },
|
||||||
{ label: 'Quest points', value: getTopScores(state)[0]?.points || 0 },
|
{ label: 'Quest points', value: getTopScores(state)[0]?.points || 0 },
|
||||||
{ label: 'Round', value: state.status === 'ended' ? 'Done' : 'Running' },
|
|
||||||
],
|
],
|
||||||
results: state.recentEvents.slice(0, 3).map((event) => ({
|
results: state.recentEvents.slice(0, 3).map((event) => ({
|
||||||
label: event.kind === 'timeout' ? 'Skipped' : event.kind,
|
label: event.kind === 'timeout' ? 'Skipped' : event.kind,
|
||||||
@@ -292,7 +312,8 @@ function getPublicState(rawState, context = {}) {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
scores: getTopScores(state),
|
scores: getTopScores(state),
|
||||||
completedQuests: state.completedQuests,
|
completedItems: state.completedItems,
|
||||||
|
skippedItems: state.skippedItems,
|
||||||
recentEvents: state.recentEvents,
|
recentEvents: state.recentEvents,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user