better umm.yeah

This commit is contained in:
legop3
2026-06-19 20:18:36 -04:00
parent eec009c485
commit 36bf5826b9
11 changed files with 241 additions and 51 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -78,8 +78,8 @@
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-D_nIHtoI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CxLsehkZ.css">
<script type="module" crossorigin src="/assets/index-ciE1vQRQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cxghvfjc.css">
</head>
<body>
<div id="root"></div>
@@ -258,17 +258,16 @@ function getPublicState(rawState, context = {}) {
const participantResults = state.status === 'ended' && state.finalResult
? state.finalResult.participants
: getParticipantList(state);
const scannedObjects = Object.values(state.seenObjects || {})
.sort((a, b) => (a.firstScannedAt || 0) - (b.firstScannedAt || 0))
.slice(-5)
.map((object) => object.label || object.entityId || object.code)
.filter(Boolean);
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,
@@ -304,6 +303,25 @@ function getPublicState(rawState, context = {}) {
{ label: 'Object scans', value: state.totalObjectScans },
{ label: 'World record', value: worldRecordText },
],
sections: [
{
title: 'Progress',
// This replaces the older top-level detail text with a display-owned
// section so every client renders game explanations from the same
// structured field instead of guessing between headline/detail.
items: [
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',
],
},
{
title: 'Recent items',
items: scannedObjects.length ? scannedObjects : ['no objects scanned yet'],
},
],
results: Object.values(state.seenObjects || {}).slice(-3).map((object) => ({
label: 'Item',
value: object.label || object.entityId || object.code,
@@ -273,15 +273,24 @@ function getPublicState(rawState, context = {}) {
const currentStep = state.currentQuest?.steps?.[state.progressIndex] || null;
const totalSteps = state.currentQuest?.steps?.length || 0;
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';
const routeItems = state.currentQuest?.steps?.length
? state.currentQuest.steps.map((step, idx) => ({
// The item label is already numbered here because the same payload is
// shown on both the far-away scanner screen and the compact Activities
// panel. Keeping the ordering text server-side prevents every client
// from inventing its own route numbering rules.
label: `${idx + 1}. ${step.label}`,
status: idx < state.progressIndex
? 'complete'
: idx === state.progressIndex
? 'active'
: 'pending',
}))
: [];
return {
id: GAME_ID,
title: 'Scan quest',
status: state.status === 'running' && state.currentQuest ? 'running' : state.status,
headline: state.lastMessage || formatQuestPrompt(state),
detail: orderedList,
progress: {
current: state.progressIndex,
total: state.currentQuest?.steps?.length || 0,
@@ -306,6 +315,16 @@ function getPublicState(rawState, context = {}) {
{ label: 'Skipped', value: state.skippedItems },
{ label: 'Quest points', value: getTopScores(state)[0]?.points || 0 },
],
sections: [
{
title: 'Route',
// This section is the important part of scan quest: players need to
// know the entire ordered route before they drive across the room.
// It is intentionally structured instead of folded into secondary
// text so clients can show progress and the route at the same time.
items: routeItems.length ? routeItems : [{ label: 'add object barcodes to the registry', status: 'pending' }],
},
],
results: state.recentEvents.slice(0, 3).map((event) => ({
label: event.kind === 'timeout' ? 'Skipped' : event.kind,
value: event.label || event.points || '',
@@ -270,12 +270,6 @@ function getPublicState(rawState, context = {}) {
id: GAME_ID,
title: 'Scans per second',
status: state.status,
headline: state.lastMessage,
detail: state.status === 'running'
? `${state.scans.length} scans, ${Math.ceil(remainingMs / 1000)} seconds left`
: state.finalResult
? `${state.finalResult.scanCount} scans in last round`
: 'five minute scan challenge',
scanCount: state.scans.length,
scansPerSecond: Number(currentRate.toFixed(3)),
bestRate: Number(bestRate.toFixed(3)),
@@ -307,6 +301,21 @@ function getPublicState(rawState, context = {}) {
{ label: 'Live scan rate', value: Number(currentRate).toFixed(2) },
{ label: 'World record', value: worldRecordText },
],
sections: [
{
title: 'Round',
// The section carries the richer explanatory text that used to live
// in top-level detail. Keeping it under display means clients have a
// single place to look for all visible game-status information.
items: [
state.status === 'running'
? `${state.scans.length} scans, ${Math.ceil(remainingMs / 1000)} seconds left`
: state.finalResult
? `${state.finalResult.scanCount} scans in last round`
: 'five minute scan challenge',
],
},
],
results: state.recentRounds.slice(0, 3).map((round) => ({
label: 'Round',
value: `${Number(round.scansPerSecond || 0).toFixed(2)} scans per second`,
@@ -56,7 +56,13 @@ function formatList(items = [], limit = 5) {
const visible = values.slice(0, limit);
const extraCount = values.length - visible.length;
const suffix = extraCount > 0 ? `, and ${extraCount} more` : '';
if (extraCount === 1) {
// A single hidden item reads badly as "and 1 more" in chat, especially for
// result summaries where the omitted item is usually just one normal stat.
// Including it is still short and avoids making the bot sound confused.
return values.slice(0, limit + 1).join(', ');
}
const suffix = extraCount > 1 ? `, plus ${extraCount} more` : '';
return `${visible.join(', ')}${suffix}`;
}
@@ -166,7 +172,11 @@ function formatResultSummary(display = {}) {
.filter(Boolean)
: [];
return formatList([primary, ...usefulStats], 3);
// Result summaries are already compact because games normally publish one
// primary line and a few stats. Joining them all avoids vague bot messages
// like "and 1 more" while still leaving the heavier participant lists to the
// bounded formatList helper above.
return [primary, ...usefulStats].filter(Boolean).join('. ');
}
function sendGameStartChat(definition, participants = []) {
@@ -906,6 +916,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
secondary: store.resultDisplay?.secondary || 'Vote to start another game',
timer: store.resultsUntil ? { label: 'Results clear in', endsAt: store.resultsUntil } : null,
stats: store.resultDisplay?.stats || [],
sections: store.resultDisplay?.sections || [],
results: store.resultDisplay?.results || [],
},
};
@@ -923,6 +934,17 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
secondary: 'Get ready',
timer: store.startsAt ? { label: 'Starts in', endsAt: store.startsAt } : null,
stats: [],
sections: [
{
title: 'Players',
// Joining and starting are shared lifecycle states, so they expose
// their own display sections here instead of asking each game to
// explain the same startup flow.
items: participants.length
? participants.map((participant) => getParticipantName(participant)).filter(Boolean)
: ['waiting for counted rovers'],
},
],
results: [],
},
};
@@ -941,6 +963,12 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
secondary: `${selectedTitle} needs at least one rover`,
timer: store.joinEndsAt ? { label: 'Join by', endsAt: store.joinEndsAt } : null,
stats: [],
sections: [
{
title: 'How to join',
items: ['scan your rover barcode at the station'],
},
],
results: [],
},
};
@@ -962,6 +990,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
label: game.title,
value: voteCounts[game.id] || 0,
})),
sections: [],
results: [],
},
};
@@ -978,6 +1007,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
secondary: 'Vote to start the next round',
timer: null,
stats: [],
sections: [],
results: [],
},
};