wiki links for barcodes

This commit is contained in:
legop3
2026-06-19 21:46:55 -04:00
parent 36bf5826b9
commit 2ead99fb70
7 changed files with 157 additions and 23 deletions
+8 -4
View File
@@ -3,17 +3,20 @@
"r001": { "r001": {
"type": "rover", "type": "rover",
"entityId": "Freaky", "entityId": "Freaky",
"label": "Freaky" "label": "Freaky",
"wikiUrl": "https://wiki.otter.land/Roombas/Freaky%20(Roomba)%20(Aka%20Freakier)"
}, },
"r002": { "r002": {
"type": "rover", "type": "rover",
"entityId": "WALL-E", "entityId": "WALL-E",
"label": "WALL-E" "label": "WALL-E",
"wikiUrl": "https://wiki.otter.land/Roombas/WALL-E%20(roomba)"
}, },
"r003": { "r003": {
"type": "rover", "type": "rover",
"entityId": "Bweeble", "entityId": "Bweeble",
"label": "Bweeble" "label": "Bweeble",
"wikiUrl": "https://wiki.otter.land/Roombas/Bweeble%20(Roomba)"
}, },
@@ -22,7 +25,8 @@
"o001": { "o001": {
"type": "object", "type": "object",
"entityId": "woodblock", "entityId": "woodblock",
"label": "Wooden block" "label": "Wooden block",
"wikiUrl": "https://wiki.otter.land/Room%20Objects/Wooden%20Block"
}, },
"o002": { "o002": {
"type":"object", "type":"object",
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/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> <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> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-ciE1vQRQ.js"></script> <script type="module" crossorigin src="/assets/index-C_lCnw5U.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cxghvfjc.css"> <link rel="stylesheet" crossorigin href="/assets/index-D-tAn_fb.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -6,7 +6,6 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('barcodeScannerService'); const logger = require('../../globals/logger').child('barcodeScannerService');
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths'); const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const { sendAlert } = require('../alertService');
const { publishEvent } = require('../eventBus'); const { publishEvent } = require('../eventBus');
const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache'); const { ensureAudioForText, warmAudioForTexts } = require('./ttsCache');
@@ -49,11 +48,13 @@ function createDefaultRegistry() {
type: 'rover', type: 'rover',
entityId: 'rover1', entityId: 'rover1',
label: 'rover 1', label: 'rover 1',
wikiUrl: '',
}, },
o001: { o001: {
type: 'object', type: 'object',
entityId: 'object1', entityId: 'object1',
label: 'object 1', label: 'object 1',
wikiUrl: '',
}, },
}, },
}; };
@@ -91,6 +92,7 @@ function validateRegistry(rawRegistry) {
} }
const entityId = String(rawEntry.entityId || '').trim(); const entityId = String(rawEntry.entityId || '').trim();
const label = String(rawEntry.label || '').replace(/\s+/g, ' ').trim(); const label = String(rawEntry.label || '').replace(/\s+/g, ' ').trim();
const wikiUrl = String(rawEntry.wikiUrl || rawEntry.wiki || '').trim();
if (!entityId) { if (!entityId) {
throw new Error(`registry entry ${code} needs entityId`); throw new Error(`registry entry ${code} needs entityId`);
} }
@@ -101,6 +103,11 @@ function validateRegistry(rawRegistry) {
type, type,
entityId, entityId,
label, label,
// wikiUrl is optional because existing physical labels may be scanned
// before their wiki pages are written. Keeping the field empty instead
// of rejecting the registry lets operators add links gradually while the
// rest of the barcode system keeps working.
wikiUrl,
}; };
}); });
@@ -165,16 +172,27 @@ function broadcastState() {
io.to(SCANNER_SOCKET_ROOM).emit('barcode:state', buildStatePayload()); io.to(SCANNER_SOCKET_ROOM).emit('barcode:state', buildStatePayload());
} }
function sendBarcodeScanAlert(result) { function buildPublicScanPayload(result) {
// Public clients only need resolved metadata for display. This intentionally
// omits scanner-only state and generated audio buffers so normal web UI pages
// can listen for scans without inheriting the heavy scanner-page concerns.
return {
code: result?.code || '',
known: Boolean(result?.known),
type: result?.type || null,
entityId: result?.entityId || null,
label: result?.label || result?.code || 'unknown',
wikiUrl: result?.wikiUrl || '',
scannedAt: Number.isFinite(result?.scannedAt) ? result.scannedAt : Date.now(),
};
}
function broadcastPublicScan(result) {
if (!result) return; if (!result) return;
// Barcode scan toasts use the existing alert feed so drivers/spectators get a // Use a distinct event name from the client-to-server barcode:scan command.
// lightweight system notice without adding any scanner-specific panels. The // That keeps packet direction obvious when debugging socket traffic and lets
// scanner page remains the source of the big local display and speech output. // AlertFeed subscribe without joining the scanner-only state/audio room.
sendAlert({ io.emit('barcode:scanned', buildPublicScanPayload(result));
color: result.known ? '#22c55e' : '#f59e0b',
title: 'Barcode Scanned',
message: result.label || result.code || 'unknown',
});
} }
function resolveScan(rawCode) { function resolveScan(rawCode) {
@@ -236,6 +254,7 @@ function resolveScan(rawCode) {
type: entry.type, type: entry.type,
entityId: entry.entityId, entityId: entry.entityId,
label: entry.label, label: entry.label,
wikiUrl: entry.wikiUrl || '',
speechText: entry.label, speechText: entry.label,
scannedAt, scannedAt,
registryError: loaded.error || null, registryError: loaded.error || null,
@@ -266,6 +285,7 @@ async function applyScan(rawCode) {
registryError: result.registryError || null, registryError: result.registryError || null,
}; };
broadcastState(); broadcastState();
broadcastPublicScan(result);
// Barcode games listen to the normalized scan event instead of being called // Barcode games listen to the normalized scan event instead of being called
// directly from this service. That keeps the scanner station's IO concerns // directly from this service. That keeps the scanner station's IO concerns
// separate from optional game rules, scoring, voting, and player attribution. // separate from optional game rules, scoring, voting, and player attribution.
@@ -287,7 +307,6 @@ async function applyScan(rawCode) {
error: err.message, error: err.message,
}); });
}); });
sendBarcodeScanAlert(result);
return { result }; return { result };
} }
+99 -1
View File
@@ -9,7 +9,8 @@ import ChatTypingRow from '../ChatTypingRow/index.jsx';
import ButtonBoxTile from '../ButtonBoxTile/index.jsx'; import ButtonBoxTile from '../ButtonBoxTile/index.jsx';
const LIFETIME_MS = 3000; const LIFETIME_MS = 3000;
const BUTTONBOX_LIFETIME_MS = 3500; const BUTTONBOX_LIFETIME_MS = 5000;
const BARCODE_SCAN_LIFETIME_MS = 10 * 1000;
const DEFAULT_COLOR = '#2196f3'; const DEFAULT_COLOR = '#2196f3';
function buildKey(alert) { function buildKey(alert) {
@@ -51,6 +52,34 @@ export default function AlertFeed({ scale = 1 }) {
}; };
}, [pushAlert, buttonBoxButtons, socket]); }, [pushAlert, buttonBoxButtons, socket]);
useEffect(() => {
function onBarcodeScanned(payload = {}) {
const label = typeof payload.label === 'string' && payload.label.trim()
? payload.label.trim()
: payload.code || 'unknown barcode';
/*
Barcode scans use a fixed alert id because the scanner can fire quickly
during games. Reusing the id makes the newest scan replace the previous
barcode popup in AlertFeed's keyed visible map instead of building a
stack of old scan cards that would hide the current wiki link.
*/
pushAlert({
id: 'barcode-scan-active',
kind: 'barcode-scan',
lifetimeMs: BARCODE_SCAN_LIFETIME_MS,
payload: {
...payload,
label,
},
});
}
socket.on('barcode:scanned', onBarcodeScanned);
return () => {
socket.off('barcode:scanned', onBarcodeScanned);
};
}, [pushAlert, socket]);
const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]); const latest = useMemo(() => alerts.slice(-12).map((alert) => ({ alert, key: buildKey(alert) })), [alerts]);
const { visible, nextExpiryAt } = useMemo(() => { const { visible, nextExpiryAt } = useMemo(() => {
@@ -127,6 +156,72 @@ function hexToRgb(hex) {
}; };
} }
function getSafeWikiUrl(rawUrl) {
const text = String(rawUrl || '').trim();
if (!text) return '';
try {
/*
Wiki links are operator-authored registry data, but this still normalizes
them before rendering an anchor. Allowing only http/https avoids turning a
barcode registry typo into a javascript: link while still supporting both
absolute URLs and local wiki paths such as /wiki/object-name.
*/
const parsed = new URL(text, window.location.origin);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return '';
return parsed.href;
} catch {
return '';
}
}
function BarcodeScanToast({ payload }) {
const wikiUrl = getSafeWikiUrl(payload?.wikiUrl);
const label = payload?.label || payload?.code || 'unknown barcode';
const descriptor = [payload?.type, payload?.code].filter(Boolean).join(' · ');
return (
<div className="pointer-events-auto w-[min(34rem,92vw)] rounded-md bg-neutral-950/95 px-2 py-1.5 text-slate-100 shadow-lg shadow-black/40">
<style>
{`
@keyframes barcode-wiki-hard-flash {
0%, 100% {
background: #ffffff;
color: #020617;
box-shadow: 0 0 0 2px rgba(255,255,255,0.95), 0 0 28px rgba(255,255,255,0.75);
}
50% {
background: #facc15;
color: #020617;
box-shadow: 0 0 0 2px rgba(250,204,21,0.95), 0 0 32px rgba(250,204,21,0.9);
}
}
`}
</style>
<div className="flex flex-col gap-1">
<div>
<p className="text-[0.72rem] font-semibold leading-tight text-slate-400">Barcode scan</p>
<p className="break-words text-lg font-bold leading-tight text-white">{label}</p>
{descriptor ? (
<p className="mt-0.25 text-xs leading-tight text-slate-400">{descriptor}</p>
) : null}
</div>
{wikiUrl ? (
<a
href={wikiUrl}
target="_blank"
rel="noreferrer"
className="block rounded px-1.5 py-1 text-center text-base font-black leading-tight tracking-normal"
style={{ animation: 'barcode-wiki-hard-flash 0.22s steps(1, end) infinite' }}
>
Open wiki
</a>
) : null}
</div>
</div>
);
}
function AlertToast({ alert }) { function AlertToast({ alert }) {
if (alert.kind === 'buttonbox-active' && alert.payload) { if (alert.kind === 'buttonbox-active' && alert.payload) {
const payload = alert.payload; const payload = alert.payload;
@@ -149,6 +244,9 @@ function AlertToast({ alert }) {
if (alert.kind === 'chat-typing' && alert.payload) { if (alert.kind === 'chat-typing' && alert.payload) {
return <ChatTypingRow message={alert.payload} />; return <ChatTypingRow message={alert.payload} />;
} }
if (alert.kind === 'barcode-scan' && alert.payload) {
return <BarcodeScanToast payload={alert.payload} />;
}
const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR); const rgb = hexToRgb(alert.color) || hexToRgb(DEFAULT_COLOR);
const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)'; const backgroundColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.18)` : 'rgba(33, 150, 243, 0.18)';
const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)'; const borderColor = rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.45)` : 'rgba(33, 150, 243, 0.45)';