mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
barcode game colors!
This commit is contained in:
@@ -16,7 +16,7 @@ llmCommentary:
|
||||
overseerControl:
|
||||
enabled: false
|
||||
# autonomous runs the existing vote-gated loop forever; directAddress only
|
||||
# runs one cycle when a chat message starts with the configured name.
|
||||
# runs one cycle when a chat message mentions the configured name.
|
||||
mode: "autonomous"
|
||||
observeOnly: true
|
||||
postToolsOnlyMessages: false
|
||||
|
||||
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
@@ -18,8 +18,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-4TYOvsyF.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CmnedUzQ.css">
|
||||
<script type="module" crossorigin src="/assets/index-C4SKjZW7.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C6VExJdW.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -316,6 +316,7 @@ module.exports = {
|
||||
id: GAME_ID,
|
||||
title: 'Most items',
|
||||
description: 'Scan as many different known objects as possible.',
|
||||
themeColor: { r: 16, g: 185, b: 129 },
|
||||
createInitialState,
|
||||
normalizeState,
|
||||
activate,
|
||||
|
||||
@@ -301,6 +301,7 @@ module.exports = {
|
||||
id: GAME_ID,
|
||||
title: 'Scan quest',
|
||||
description: 'Scan one or two requested objects in order.',
|
||||
themeColor: { r: 34, g: 211, b: 238 },
|
||||
createInitialState,
|
||||
normalizeState,
|
||||
activate,
|
||||
|
||||
@@ -319,6 +319,7 @@ module.exports = {
|
||||
id: GAME_ID,
|
||||
title: 'Scans per second',
|
||||
description: 'Count every scan for five minutes and save the world record.',
|
||||
themeColor: { r: 245, g: 158, b: 11 },
|
||||
createInitialState,
|
||||
normalizeState,
|
||||
activate,
|
||||
|
||||
@@ -195,6 +195,24 @@ function getGameDefinition(gameId) {
|
||||
return GAMES_BY_ID[String(gameId || '')] || null;
|
||||
}
|
||||
|
||||
function normalizeRgbChannel(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function getGameThemeColor(definition) {
|
||||
const raw = definition?.themeColor;
|
||||
const r = normalizeRgbChannel(raw?.r);
|
||||
const g = normalizeRgbChannel(raw?.g);
|
||||
const b = normalizeRgbChannel(raw?.b);
|
||||
|
||||
// Games own their visual identity, but the socket payload should always be a
|
||||
// small safe RGB object. Invalid or missing values fall back to neutral text
|
||||
// colors in the browser instead of leaking arbitrary styling data into React.
|
||||
if (r === null || g === null || b === null) return null;
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function getKnownObjects() {
|
||||
try {
|
||||
const snapshot = getRegistrySnapshot();
|
||||
@@ -842,6 +860,7 @@ function buildStatePayload(socket = null) {
|
||||
id: game.id,
|
||||
title: game.title,
|
||||
description: game.description,
|
||||
themeColor: getGameThemeColor(game),
|
||||
voteCount: voteCounts[game.id] || 0,
|
||||
active: game.id === store.runningGameId,
|
||||
selected: game.id === store.selectedGameId,
|
||||
@@ -869,13 +888,16 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
||||
if (store.phase === 'running' && runningGame) {
|
||||
return {
|
||||
...runningGame,
|
||||
themeColor: getGameThemeColor(runningDefinition),
|
||||
participants,
|
||||
};
|
||||
}
|
||||
if (store.phase === 'results') {
|
||||
const resultDefinition = getGameDefinition(store.resultGameId);
|
||||
return {
|
||||
id: store.resultGameId,
|
||||
title: getGameDefinition(store.resultGameId)?.title || 'Results',
|
||||
title: resultDefinition?.title || 'Results',
|
||||
themeColor: getGameThemeColor(resultDefinition),
|
||||
status: 'results',
|
||||
participants,
|
||||
display: {
|
||||
@@ -892,6 +914,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
||||
return {
|
||||
id: store.selectedGameId,
|
||||
title: selectedDefinition?.title || 'Starting',
|
||||
themeColor: getGameThemeColor(selectedDefinition),
|
||||
status: 'starting',
|
||||
participants,
|
||||
display: {
|
||||
@@ -909,6 +932,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
||||
return {
|
||||
id: store.selectedGameId,
|
||||
title: selectedDefinition?.title || 'Join game',
|
||||
themeColor: getGameThemeColor(selectedDefinition),
|
||||
status: 'joining',
|
||||
participants,
|
||||
display: {
|
||||
@@ -926,6 +950,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
||||
return {
|
||||
id: store.selectedGameId,
|
||||
title: 'Voting',
|
||||
themeColor: getGameThemeColor(selectedDefinition),
|
||||
status: 'voting',
|
||||
participants,
|
||||
display: {
|
||||
@@ -944,6 +969,7 @@ function buildLifecycleGameState(store, { now, selectedDefinition, runningDefini
|
||||
return {
|
||||
id: null,
|
||||
title: 'Barcode games',
|
||||
themeColor: null,
|
||||
status: 'idle',
|
||||
participants,
|
||||
display: {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const fsp = require('fs/promises');
|
||||
const Fuse = require('fuse.js');
|
||||
const { Ollama } = require('ollama');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('overseerControl');
|
||||
@@ -36,7 +35,6 @@ const overseerConfig = config.overseerControl || {};
|
||||
const RUN_MODE_AUTONOMOUS = 'autonomous';
|
||||
const RUN_MODE_DIRECT_ADDRESS = 'directAddress';
|
||||
const RUN_MODES = new Set([RUN_MODE_AUTONOMOUS, RUN_MODE_DIRECT_ADDRESS]);
|
||||
const DIRECT_ADDRESS_FUZZY_THRESHOLD = 0.3;
|
||||
const enabled = Boolean(overseerConfig.enabled);
|
||||
const observeOnly = overseerConfig.observeOnly !== false;
|
||||
const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
|
||||
@@ -53,42 +51,18 @@ const runWhileNoPeopleOnline = Boolean(overseerConfig.runWhileNoPeopleOnline);
|
||||
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
|
||||
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||
|
||||
function normalizeDirectAddressText(value) {
|
||||
function normalizeNameMentionText(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
// Direct-address matching is intentionally word-oriented. Replacing
|
||||
// punctuation with spaces lets "overseer," and "overseer:" behave like the
|
||||
// same invocation phrase without letting punctuation affect Fuse scores.
|
||||
// Mention detection is intentionally literal to the configured name. The
|
||||
// only normalization here is presentation-level cleanup so punctuation and
|
||||
// repeated whitespace do not decide whether the configured name appears.
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
const directAddressName = normalizeDirectAddressText(name);
|
||||
const directAddressNameWords = directAddressName.split(' ').filter(Boolean);
|
||||
const directAddressAliases = (() => {
|
||||
const aliases = new Set();
|
||||
if (directAddressName) aliases.add(directAddressName);
|
||||
|
||||
// The default name includes a leading article, but users naturally shorten
|
||||
// "The Overseer" to "Overseer". Keeping this as an alias makes the direct
|
||||
// invocation humane while still requiring the match to be at the very start
|
||||
// of the chat message.
|
||||
if (directAddressNameWords[0] === 'the' && directAddressNameWords.length > 1) {
|
||||
aliases.add(directAddressNameWords.slice(1).join(' '));
|
||||
}
|
||||
|
||||
return Array.from(aliases).map((label) => ({
|
||||
label,
|
||||
wordCount: label.split(' ').filter(Boolean).length,
|
||||
}));
|
||||
})();
|
||||
const directAddressFuse = new Fuse(directAddressAliases, {
|
||||
includeScore: true,
|
||||
ignoreLocation: true,
|
||||
threshold: DIRECT_ADDRESS_FUZZY_THRESHOLD,
|
||||
keys: [{ name: 'label', weight: 1 }],
|
||||
});
|
||||
const normalizedConfiguredName = normalizeNameMentionText(name);
|
||||
|
||||
const runtime = {
|
||||
timer: null,
|
||||
@@ -324,66 +298,15 @@ function normalizeChatDraft(text) {
|
||||
return next;
|
||||
}
|
||||
|
||||
function isConfiguredNameAddress(text) {
|
||||
const clean = String(text || '').trimStart();
|
||||
if (!clean) return false;
|
||||
function mentionsConfiguredName(text) {
|
||||
if (!normalizedConfiguredName) return false;
|
||||
const normalizedMessage = normalizeNameMentionText(text);
|
||||
if (!normalizedMessage) return false;
|
||||
|
||||
const lowerText = clean.toLowerCase();
|
||||
const lowerName = name.toLowerCase();
|
||||
if (!lowerText.startsWith(lowerName)) return false;
|
||||
|
||||
const nextChar = clean.charAt(name.length);
|
||||
if (!nextChar) return true;
|
||||
|
||||
// The character after the configured name must separate the invocation from
|
||||
// the rest of the message. This keeps "The Overseer, help" and "the overseer
|
||||
// help" valid while preventing accidental triggers such as "The Overseerish".
|
||||
return /[\s,.:;!?-]/.test(nextChar);
|
||||
}
|
||||
|
||||
function buildLeadingDirectAddressCandidates(text) {
|
||||
const words = normalizeDirectAddressText(text).split(' ').filter(Boolean);
|
||||
if (!words.length) return [];
|
||||
|
||||
const maxAliasWords = directAddressAliases.reduce((max, alias) => Math.max(max, alias.wordCount), 1);
|
||||
const maxWords = Math.min(words.length, Math.max(2, maxAliasWords + 1));
|
||||
const candidates = [];
|
||||
|
||||
for (let count = 1; count <= maxWords; count += 1) {
|
||||
// Each candidate is made only from the start of the message. This preserves
|
||||
// the "starts with the bot name" contract even though the actual comparison
|
||||
// is fuzzy.
|
||||
candidates.push(words.slice(0, count).join(' '));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function isFuzzyConfiguredNameAddress(text) {
|
||||
if (!directAddressAliases.length) return false;
|
||||
|
||||
const candidates = buildLeadingDirectAddressCandidates(text);
|
||||
for (const candidate of candidates) {
|
||||
const exactAlias = directAddressAliases.find((alias) => alias.label === candidate);
|
||||
if (exactAlias) return true;
|
||||
|
||||
const best = directAddressFuse.search(candidate)[0];
|
||||
if (!best || Number(best.score) > DIRECT_ADDRESS_FUZZY_THRESHOLD) continue;
|
||||
|
||||
const alias = best.item;
|
||||
const candidateWords = candidate.split(' ').filter(Boolean);
|
||||
if (candidateWords.length !== alias.wordCount) continue;
|
||||
|
||||
// Fuse can rate very short partial phrases surprisingly well. Requiring the
|
||||
// candidate to be close to the alias length keeps first words like "the" or
|
||||
// "over" from waking a multi-word bot while still allowing typos such as
|
||||
// "overseer" -> "oversear" or "the overseer" -> "teh overseer".
|
||||
if (Math.abs(candidate.length - alias.label.length) > 2) continue;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Pad both sides with spaces so a configured name only matches as a whole
|
||||
// normalized phrase. For example, "The Overseer" matches "hey, The Overseer"
|
||||
// but not "The Overseerish".
|
||||
return ` ${normalizedMessage} `.includes(` ${normalizedConfiguredName} `);
|
||||
}
|
||||
|
||||
function summarizeResult(result) {
|
||||
@@ -850,7 +773,7 @@ subscribe('chat:message', ({ payload } = {}) => {
|
||||
return;
|
||||
}
|
||||
if (!directAddressMode) return;
|
||||
if (!isConfiguredNameAddress(text) && !isFuzzyConfiguredNameAddress(text)) return;
|
||||
if (!mentionsConfiguredName(text)) return;
|
||||
void runDirectAddressCycle('direct_address_chat');
|
||||
});
|
||||
verificationEvents.on('change', () => evaluateSchedulerGate('online vote update'));
|
||||
|
||||
@@ -5,6 +5,58 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import useBarcodeGameState from '../../barcodeGames/useBarcodeGameState.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
function clampRgbChannel(value) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
||||
function normalizeRgb(themeColor) {
|
||||
const r = clampRgbChannel(themeColor?.r);
|
||||
const g = clampRgbChannel(themeColor?.g);
|
||||
const b = clampRgbChannel(themeColor?.b);
|
||||
if (r === null || g === null || b === null) return null;
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function rgba(rgb, alpha) {
|
||||
return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function getGameTheme(themeColor) {
|
||||
const rgb = normalizeRgb(themeColor);
|
||||
if (!rgb) {
|
||||
return {
|
||||
textStyle: undefined,
|
||||
buttonStyle: undefined,
|
||||
titleBoxStyle: undefined,
|
||||
boxStyle: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// The game supplies only identity color. This component chooses opacity and
|
||||
// placement so every game stays visually consistent with the dark CardFrame
|
||||
// UI while still being recognizable at a glance.
|
||||
return {
|
||||
textStyle: { color: rgba(rgb, 0.96) },
|
||||
buttonStyle: {
|
||||
borderColor: rgba(rgb, 0.62),
|
||||
borderLeftColor: rgba(rgb, 0.9),
|
||||
backgroundColor: rgba(rgb, 0.1),
|
||||
},
|
||||
selectedButtonStyle: {
|
||||
borderColor: rgba(rgb, 0.86),
|
||||
borderLeftColor: rgba(rgb, 1),
|
||||
backgroundColor: rgba(rgb, 0.16),
|
||||
},
|
||||
titleBoxStyle: {
|
||||
borderLeftColor: rgba(rgb, 0.88),
|
||||
},
|
||||
boxStyle: {
|
||||
borderColor: rgba(rgb, 0.62),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function useClock(enabled) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
|
||||
@@ -28,19 +80,18 @@ function formatTimer(endsAt, now) {
|
||||
}
|
||||
|
||||
function GameChoice({ game, disabled, onVote }) {
|
||||
const theme = getGameTheme(game.themeColor);
|
||||
const style = game.active || game.selected ? theme.selectedButtonStyle || theme.buttonStyle : theme.buttonStyle;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || game.active}
|
||||
onClick={() => onVote(game.id)}
|
||||
className={[
|
||||
'button-dark flex min-h-[4.25rem] flex-col items-start justify-between px-1.5 py-1 text-left disabled:opacity-70',
|
||||
game.active ? 'border-emerald-500/70' : '',
|
||||
game.selected && !game.active ? 'border-cyan-500/60' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
className="button-dark flex min-h-[4.25rem] flex-col items-start justify-between border-l-4 px-1.5 py-1 text-left disabled:opacity-70"
|
||||
style={style}
|
||||
>
|
||||
<span className="flex w-full items-start justify-between gap-1">
|
||||
<span className="text-sm font-semibold leading-tight text-neutral-50">{game.title}</span>
|
||||
<span className="text-sm font-semibold leading-tight text-neutral-50" style={theme.textStyle}>{game.title}</span>
|
||||
<span className="shrink-0 font-mono text-[0.72rem] text-neutral-300">{game.voteCount || 0}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 line-clamp-2 text-xs leading-snug text-neutral-300">{game.description}</span>
|
||||
@@ -51,12 +102,12 @@ function GameChoice({ game, disabled, onVote }) {
|
||||
);
|
||||
}
|
||||
|
||||
function StatGrid({ stats }) {
|
||||
function StatGrid({ stats, theme }) {
|
||||
if (!Array.isArray(stats) || !stats.length) return null;
|
||||
return (
|
||||
<div className="grid gap-1 sm:grid-cols-3">
|
||||
{stats.slice(0, 6).map((stat) => (
|
||||
<div key={stat.label} className="border border-neutral-700 px-1.5 py-1">
|
||||
<div key={stat.label} className="border border-neutral-700 px-1.5 py-1" style={theme.boxStyle}>
|
||||
<p className="truncate text-[0.68rem] text-neutral-400">{stat.label}</p>
|
||||
<p className="truncate text-sm font-semibold text-neutral-100">{stat.value}</p>
|
||||
</div>
|
||||
@@ -65,7 +116,7 @@ function StatGrid({ stats }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantsBlock({ participants }) {
|
||||
function ParticipantsBlock({ participants, theme }) {
|
||||
const knownParticipants = Array.isArray(participants) ? participants : [];
|
||||
// Participants are server-owned because rover scans, identity lookup, and
|
||||
// score eligibility all happen outside this panel. The UI only formats the
|
||||
@@ -75,7 +126,7 @@ function ParticipantsBlock({ participants }) {
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="min-w-0 border border-neutral-700 px-2 py-1.5">
|
||||
<div className="min-w-0 border border-neutral-700 px-2 py-1.5" style={theme.boxStyle}>
|
||||
<p className="text-sm font-semibold text-neutral-400">Participants</p>
|
||||
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
||||
{knownParticipants.length}
|
||||
@@ -151,6 +202,7 @@ export default function BarcodeGamesPanel() {
|
||||
const timerText = formatTimer(timerEndsAt, now);
|
||||
const games = useMemo(() => (Array.isArray(state.games) ? state.games : []), [state.games]);
|
||||
const participants = Array.isArray(state.participants) ? state.participants : [];
|
||||
const activeTheme = getGameTheme(activeGame?.themeColor);
|
||||
|
||||
const handleVote = async (gameId) => {
|
||||
setPendingGameId(gameId);
|
||||
@@ -176,8 +228,8 @@ export default function BarcodeGamesPanel() {
|
||||
|
||||
<section className="space-y-2 border-t border-neutral-700 pt-2">
|
||||
<div className="grid items-start gap-2 lg:grid-cols-[minmax(0,1fr)_15rem_15rem]">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words text-2xl font-bold leading-tight text-neutral-50">
|
||||
<div className="min-w-0 border-l-4 border-neutral-700 pl-2" style={activeTheme.titleBoxStyle}>
|
||||
<p className="break-words text-2xl font-bold leading-tight text-neutral-50" style={activeTheme.textStyle}>
|
||||
{display.title || activeGame?.title || 'No game active'}
|
||||
</p>
|
||||
<p className="mt-0.5 break-words text-lg font-semibold leading-tight text-neutral-200">
|
||||
@@ -187,7 +239,7 @@ export default function BarcodeGamesPanel() {
|
||||
<p className="mt-1 break-words text-base leading-snug text-neutral-300">{display.secondary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border border-neutral-700 px-2 py-1.5 text-left lg:text-right">
|
||||
<div className="border border-neutral-700 px-2 py-1.5 text-left lg:text-right" style={activeTheme.boxStyle}>
|
||||
<p className="text-sm font-semibold text-neutral-400">{display.timer?.label || 'Time'}</p>
|
||||
<p className="font-mono text-2xl font-semibold leading-tight text-neutral-50">
|
||||
{timerText || '--'}
|
||||
@@ -196,9 +248,9 @@ export default function BarcodeGamesPanel() {
|
||||
{timerText ? 'active timer' : 'no timer'}
|
||||
</p>
|
||||
</div>
|
||||
<ParticipantsBlock participants={participants} />
|
||||
<ParticipantsBlock participants={participants} theme={activeTheme} />
|
||||
</div>
|
||||
<StatGrid stats={display.stats} />
|
||||
<StatGrid stats={display.stats} theme={activeTheme} />
|
||||
</section>
|
||||
|
||||
<Leaderboard players={state.leaderboard} ownPlayer={state.ownPlayer} />
|
||||
|
||||
Reference in New Issue
Block a user