Compare commits

...
2 Commits
Author SHA1 Message Date
legop3 c8836f7d65 rover server restart desync fix hopefully 2026-08-20 15:27:23 -04:00
legop3 f77a969098 loading screen, better HUD, etc. 2026-08-20 14:38:07 -04:00
24 changed files with 381 additions and 104 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
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
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

+2 -2
View File
@@ -12,8 +12,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<!-- site-metadata:inject --> <!-- site-metadata:inject -->
<!-- analytics:inject --> <!-- analytics:inject -->
<script type="module" crossorigin src="/assets/index-C0lpCbci.js"></script> <script type="module" crossorigin src="/assets/index-BZ2ymoHR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BITzjVQo.css"> <link rel="stylesheet" crossorigin href="/assets/index-BFNKIMjg.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+14 -2
View File
@@ -97,8 +97,20 @@ roverManager.managerEvents.on('private', ({ roverId, open }) => {
} }
}); });
roverManager.managerEvents.on('rover', ({ action }) => { roverManager.managerEvents.on('rover', ({ roverId, action }) => {
if (action === 'removed' || action === 'upsert') { if (action === 'removed') {
/*
The physical rover record is the authority for current driver ownership.
Once it disappears, every assignment that names it must be released and
run through ordinary placement again. Leaving those map entries intact
lets the same id become visible after reconnect without recreating its
driver membership, which is the exact stale-UI/video-auth split this
lifecycle boundary must prevent.
*/
reassignFromRover(roverId);
return;
}
if (action === 'upsert') {
reassignWaiting(); reassignWaiting();
} }
}); });
@@ -72,6 +72,7 @@ const {
} = privateAccess; } = privateAccess;
const roverLifecycle = createRoverLifecycle({ const roverLifecycle = createRoverLifecycle({
io,
rovers, rovers,
socketToRovers, socketToRovers,
managerEvents, managerEvents,
@@ -86,6 +87,7 @@ const roverLifecycle = createRoverLifecycle({
const { const {
requestControl, requestControl,
releaseControl, releaseControl,
removeRoverDrivers,
isDriver, isDriver,
canDrive, canDrive,
getRoversForSocket, getRoversForSocket,
@@ -119,6 +121,7 @@ const rosterLifecycle = createRosterLifecycle({
normalizePrivateSafety, normalizePrivateSafety,
stopDockGuard: (...args) => stopDockGuard(...args), stopDockGuard: (...args) => stopDockGuard(...args),
getControlDenialReason, getControlDenialReason,
removeRoverDrivers,
}); });
const { const {
@@ -24,6 +24,7 @@ function createRosterLifecycle(deps) {
isRoverVisibleToSocket, isRoverVisibleToSocket,
normalizePrivateSafety, normalizePrivateSafety,
stopDockGuard, stopDockGuard,
removeRoverDrivers,
} = deps; } = deps;
function ensureRecord(id) { function ensureRecord(id) {
@@ -106,7 +107,14 @@ function createRosterLifecycle(deps) {
function removeRover(id) { function removeRover(id) {
const record = rovers.get(id); const record = rovers.get(id);
if (!record) return; if (!record) return;
/*
Remove the public record before emitting driver-removal events. Any
session sync caused by those events must already see this rover as
offline, while removeRoverDrivers still receives the captured record so
it can clean the reverse membership index and Socket.IO room membership.
*/
rovers.delete(id); rovers.delete(id);
removeRoverDrivers(id, record);
stopDockGuard(id); stopDockGuard(id);
privateButtonStates.delete(id); privateButtonStates.delete(id);
privateNoUsersSince.delete(id); privateNoUsersSince.delete(id);
@@ -3,6 +3,7 @@
// Scope: Keeps runtime behavior unchanged by reusing rover-manager state maps and injected policy helpers. // Scope: Keeps runtime behavior unchanged by reusing rover-manager state maps and injected policy helpers.
function createRoverLifecycle(deps) { function createRoverLifecycle(deps) {
const { const {
io,
rovers, rovers,
socketToRovers, socketToRovers,
managerEvents, managerEvents,
@@ -85,6 +86,40 @@ function createRoverLifecycle(deps) {
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' }); managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'remove' });
} }
function removeRoverDrivers(roverId, removedRecord = null) {
/*
A rover connection owns the record that contains its driver set, but the
reverse socket-to-rover index outlives that record. Disconnect cleanup
must therefore remove both halves before a reconnect creates a fresh
record with the same id. Otherwise session assignment can name the rover
while video/control authorization correctly sees no driver membership.
removedRecord is accepted because rosterLifecycle deliberately deletes
the public rover record first. Session syncs triggered by the driver
events below will consequently hide the unavailable rover immediately,
even before assignmentService finishes normal reassignment.
*/
const record = removedRecord || rovers.get(roverId);
if (!record) return [];
const driverIds = Array.from(record.drivers || []);
driverIds.forEach((socketId) => {
const joined = socketToRovers.get(socketId);
if (joined) {
joined.delete(roverId);
if (joined.size === 0) socketToRovers.delete(socketId);
}
const socket = io.sockets.sockets.get(socketId);
socket?.leave(record.room);
record.drivers.delete(socketId);
turnService.driverRemoved(roverId, socketId);
managerEvents.emit('driver', { socketId, roverId, action: 'remove' });
});
return driverIds;
}
function isDriver(roverId, socket) { function isDriver(roverId, socket) {
const record = rovers.get(roverId); const record = rovers.get(roverId);
if (!record) return false; if (!record) return false;
@@ -175,6 +210,7 @@ function createRoverLifecycle(deps) {
removeSocket, removeSocket,
requestControl, requestControl,
releaseControl, releaseControl,
removeRoverDrivers,
isDriver, isDriver,
canDrive, canDrive,
getRoversForSocket, getRoversForSocket,
@@ -0,0 +1,59 @@
// Rover Manager Lifecycle Tests
// Purpose: Verifies that physical rover removal clears every ownership index before a same-id reconnect.
// Scope: Covers driver membership cleanup only; assignment placement policy remains in assignmentService.
const test = require('node:test');
const assert = require('node:assert/strict');
const EventEmitter = require('node:events');
const { createRoverLifecycle } = require('./roverLifecycle');
test('removing a rover clears driver sets, reverse membership, rooms, and turns', () => {
const roverId = 'rover-one';
const socketId = 'driver-one';
const leftRooms = [];
const removedTurns = [];
const driverEvents = [];
const socket = {
id: socketId,
leave: (room) => leftRooms.push(room),
};
const record = {
id: roverId,
room: `rover:${roverId}`,
drivers: new Set([socketId]),
};
const rovers = new Map([[roverId, record]]);
const socketToRovers = new Map([[socketId, new Set([roverId])]]);
const managerEvents = new EventEmitter();
managerEvents.on('driver', (event) => driverEvents.push(event));
const lifecycle = createRoverLifecycle({
io: { sockets: { sockets: new Map([[socketId, socket]]) } },
rovers,
socketToRovers,
managerEvents,
turnService: {
driverRemoved: (removedRoverId, removedSocketId) => {
removedTurns.push([removedRoverId, removedSocketId]);
},
},
isAdmin: () => false,
sendAlert: () => {},
ALERT_COLOR: '#000000',
getMode: () => 'public',
getControlDenialReason: () => null,
});
/* Mirror rosterLifecycle's ordering: the public record is gone before the
captured record is supplied for complete membership cleanup. */
rovers.delete(roverId);
const removedDriverIds = lifecycle.removeRoverDrivers(roverId, record);
assert.deepEqual(removedDriverIds, [socketId]);
assert.equal(record.drivers.size, 0);
assert.equal(socketToRovers.has(socketId), false);
assert.deepEqual(leftRooms, [`rover:${roverId}`]);
assert.deepEqual(removedTurns, [[roverId, socketId]]);
assert.deepEqual(driverEvents, [
{ socketId, roverId, action: 'remove' },
]);
});
+26 -2
View File
@@ -135,6 +135,20 @@ function buildUserEntry(socket) {
const role = getRole(socket); const role = getRole(socket);
const assignment = assignmentService.describeAssignment(socket.id); const assignment = assignmentService.describeAssignment(socket.id);
const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id); const primaryRover = roverManager.getPrimaryRoverForSocket(socket.id);
/*
assignmentService owns automatic placement policy, while roverManager owns
actual control membership. Validate both candidate indexes before exposing
presence because neither cached direction is authoritative without the
physical rover record agreeing that this socket is one of its drivers.
*/
const verifiedPrimaryRover = primaryRover
&& roverManager.isDriver(primaryRover, socket)
? primaryRover
: null;
const verifiedAssignmentRover = assignment?.roverId
&& roverManager.isDriver(assignment.roverId, socket)
? assignment.roverId
: null;
const ptzChatTarget = getPtzChatTargetForSocket(socket.id); const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
return { return {
socketId: socket.id, socketId: socket.id,
@@ -147,7 +161,7 @@ function buildUserEntry(socket) {
the PTZ chat target while the socket is queued or operating so presence, the PTZ chat target while the socket is queued or operating so presence,
queue lookup, and chat identity all agree. queue lookup, and chat identity all agree.
*/ */
roverId: ptzChatTarget?.roverId || primaryRover || assignment?.roverId || null, roverId: ptzChatTarget?.roverId || verifiedPrimaryRover || verifiedAssignmentRover || null,
}; };
} }
@@ -172,7 +186,17 @@ function buildSession(socket) {
})); }));
const roster = roverManager.getRosterForSocket(socket); const roster = roverManager.getRosterForSocket(socket);
const assignment = assignmentService.describeAssignment(socket?.id || ''); const assignment = assignmentService.describeAssignment(socket?.id || '');
const assignmentRoverId = filterVisibleRoverId(socket, assignment?.roverId); /*
Visibility alone is insufficient here: a reconnected rover can be visible
before a stale assignment map has recreated actual driver membership. The
session contract consumed by every UI surface must require both visibility
and roverManager's authoritative membership check.
*/
const verifiedAssignmentRover = assignment?.roverId
&& roverManager.isDriver(assignment.roverId, socket)
? assignment.roverId
: null;
const assignmentRoverId = filterVisibleRoverId(socket, verifiedAssignmentRover);
const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket); const activeDrivers = filterActiveDriversForSocket(getActiveDrivers(), socket);
const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket); const turnQueues = filterTurnQueuesForSocket(getTurnQueues(), socket);
const socials = features.socials && configuredSocials?.length ? configuredSocials : []; const socials = features.socials && configuredSocials?.length ? configuredSocials : [];
Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

@@ -18,42 +18,69 @@ import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
import usePodVisibility from '../CornerPods/usePodVisibility.js'; import usePodVisibility from '../CornerPods/usePodVisibility.js';
function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndock }) { function DockedAction({ driveKeyLabel, pending, controlsDisabled, error, onUndock }) {
const [hidden, setHidden] = useState(false);
const waitingForTurn = controlsDisabled && !pending; const waitingForTurn = controlsDisabled && !pending;
/* The dismissal belongs to this mounted docked episode. DockingHud unmounts
this component when the rover leaves the base, and its roverId key remounts
it for a different assignment, so no persistence or reset effect is needed. */
if (hidden && !pending) return null;
const mainToneClass = waitingForTurn
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75';
const hideToneClass = waitingForTurn
? 'bg-slate-950/95 ring-slate-400/70 hover:bg-slate-900'
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200';
return ( return (
<div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6"> <>
<button {/* The docked shield is owned by the dismissible action so hiding the
type="button" prompt also reveals the video and ordinary HUD instead of leaving an
disabled={pending || controlsDisabled} unexplained dark, input-blocking layer behind. */}
onClick={onUndock} <div className="pointer-events-auto absolute inset-0 z-[25] bg-black/75" aria-hidden="true" />
className={`pointer-events-auto flex w-[min(32rem,80%)] flex-col items-center gap-2 px-8 py-7 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${ <div className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center p-6">
waitingForTurn <div className="relative w-[min(32rem,80%)]">
? 'cursor-not-allowed bg-slate-950/95 ring-slate-400/70' <button
: 'bg-emerald-950/90 ring-emerald-300/80 hover:bg-emerald-900/95 focus-visible:ring-emerald-200 disabled:cursor-wait disabled:opacity-75' type="button"
}`} disabled={pending || controlsDisabled}
> onClick={onUndock}
<strong className="text-3xl leading-tight">{pending ? 'Undocking…' : 'Your rover is docked'}</strong> className={`pointer-events-auto flex w-full flex-col items-center gap-2 px-8 py-7 text-center text-white shadow-2xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${mainToneClass}`}
{pending ? ( >
null <strong className="text-3xl leading-tight">{pending ? 'Undocking…' : 'Your rover is docked'}</strong>
) : waitingForTurn ? ( {pending ? (
/* A disabled action must explain the ownership constraint instead of null
continuing to advertise a click and keybind that cannot succeed. */ ) : waitingForTurn ? (
<span className="text-lg font-semibold leading-snug text-slate-300"> /* A disabled action must explain the ownership constraint instead of
Wait for your turn to undock. continuing to advertise a click and keybind that cannot succeed. */
</span> <span className="text-lg font-semibold leading-snug text-slate-300">
) : ( Wait for your turn to undock.
<span className="text-lg font-semibold leading-snug text-emerald-50"> </span>
Click here ) : (
{driveKeyLabel ? ( <span className="text-lg font-semibold leading-snug text-emerald-50">
<> Click here
{' '}or press <KeyPill label={driveKeyLabel} /> {driveKeyLabel ? (
</> <>
) : null} {' '}or press <KeyPill label={driveKeyLabel} />
{' '}to undock and drive the rover </>
</span> ) : null}
)} {' '}to undock and drive the rover
{error ? <span className="text-sm font-semibold text-red-200">{error}</span> : null} </span>
</button> )}
</div> {error ? <span className="text-sm font-semibold text-red-200">{error}</span> : null}
</button>
{!pending ? (
<button
type="button"
onClick={() => setHidden(true)}
className={`pointer-events-auto absolute left-1/2 top-full -translate-x-1/2 rounded-b-lg px-6 py-1.5 text-sm font-bold text-white shadow-xl ring-2 transition focus-visible:outline-none focus-visible:ring-4 ${hideToneClass}`}
>
Hide
</button>
) : null}
</div>
</div>
</>
); );
} }
@@ -318,15 +345,12 @@ export default function DockingHud({ roverId }) {
return ( return (
<> <>
{/* This single layer both dims and blocks the ordinary rover HUD. A passive {/* Automatic docking keeps its own lighter blocking shield. The ordinary
undocked rover is still moving autonomously, so it gets a lighter but equally docked shield lives inside DockedAction because the new Hide control
blocking shield. Explicit pending state keeps the correct shield mounted until must dismiss the prompt and its dimming as one coherent surface. */}
the complete drive sequence finishes even as telemetry changes underneath it. */}
<div <div
className={`absolute inset-0 z-[25] transition-all duration-300 ${ className={`absolute inset-0 z-[25] transition-all duration-300 ${
docked || pendingAction === 'undocking' autoDocking || pendingAction === 'resuming'
? 'pointer-events-auto bg-black/75 opacity-100'
: autoDocking || pendingAction === 'resuming'
? 'pointer-events-auto bg-black/55 opacity-100' ? 'pointer-events-auto bg-black/55 opacity-100'
: 'pointer-events-none opacity-0' : 'pointer-events-none opacity-0'
}`} }`}
@@ -335,6 +359,7 @@ export default function DockingHud({ roverId }) {
{docked || pendingAction === 'undocking' ? ( {docked || pendingAction === 'undocking' ? (
<DockedAction <DockedAction
key={roverId}
driveKeyLabel={driveKeyLabel} driveKeyLabel={driveKeyLabel}
pending={pendingAction === 'undocking'} pending={pendingAction === 'undocking'}
controlsDisabled={!canControl} controlsDisabled={!canControl}
@@ -0,0 +1,40 @@
// Initial Session Overlay
// Purpose: Hides incomplete driver-page placeholders until the first authoritative session snapshot arrives.
// Scope: Provides startup presentation only; it deliberately does not delay or alter page initialization underneath.
import { useSessionSelector } from '../../context/SessionContext.jsx';
import spinnerImage from '../../assets/spinner.png';
import './styles.css';
export default function InitialSessionOverlay() {
const connected = useSessionSelector((state) => Boolean(state.connected));
const sessionReady = useSessionSelector((state) => state.session !== null);
if (sessionReady) return null;
return (
<div
className="fixed inset-0 z-[1000] flex items-center justify-center bg-black text-slate-200"
role="status"
aria-live="polite"
aria-label={connected ? 'Loading session' : 'Connecting'}
>
<div className="flex flex-col items-center gap-3">
{/* The source image is intentionally constrained to a small fixed box;
its intrinsic pixel dimensions must never determine overlay layout.
Reduced-motion users still see the identifying image without either
the rotation or continuously changing color. */}
<div className="initial-session-spinner-rotation h-20 w-20" aria-hidden="true">
<img
src={spinnerImage}
alt=""
draggable="false"
className="initial-session-spinner-image h-full w-full select-none object-contain"
/>
</div>
<div className="text-sm font-medium text-slate-300">
{connected ? 'Loading session…' : 'Connecting…'}
</div>
</div>
</div>
);
}
@@ -0,0 +1,35 @@
/*
These animations belong only to InitialSessionOverlay. Keeping them beside
the component avoids adding feature-specific behavior to the global styles.
Rotation and hue use separate elements so their independent infinite cycles
cannot overwrite one another's transform or filter properties.
*/
@keyframes initial-session-spinner-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes initial-session-spinner-hue {
from {
filter: hue-rotate(0deg);
}
to {
filter: hue-rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
.initial-session-spinner-rotation {
animation: initial-session-spinner-rotate 1.8s linear infinite;
}
.initial-session-spinner-image {
animation: initial-session-spinner-hue 2.8s linear infinite;
}
}
@@ -19,9 +19,9 @@ function ControlRow({ label, keyLabel }) {
function DesktopQuickstart({ keymap }) { function DesktopQuickstart({ keymap }) {
return ( return (
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-sm text-slate-200">1. Press "Start Driving" put your rover into driving mode.</p> <p className="text-sm text-slate-200">1. Click "Your rover is docked" to undock.</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
<p className="text-sm text-slate-200">2. Use the drive controls to move your rover:</p> <p className="text-sm text-slate-200">2. Drive with these keybindings:</p>
<div className="space-y-0.5"> <div className="space-y-0.5">
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} /> <ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} /> <ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
@@ -31,7 +31,8 @@ function DesktopQuickstart({ keymap }) {
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} /> <ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
</div> </div>
</div> </div>
<p className="text-sm text-slate-200">3. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p> <p className="text-sm text-slate-200">3. Use the video HUD for rover controls and information.</p>
<p className="text-sm text-slate-200">4. Click "Dock rover" when finished.</p>
</div> </div>
); );
} }
@@ -39,10 +40,11 @@ function DesktopQuickstart({ keymap }) {
function MobileQuickstart() { function MobileQuickstart() {
return ( return (
<div className="space-y-0.5 text-sm text-slate-200"> <div className="space-y-0.5 text-sm text-slate-200">
<p>1. Press "Start Driving" put your rover into driving mode.</p> <p>1. Tap "Your rover is docked" to undock.</p>
<p>2. Touch and hold in Joystick area to move.</p> <p>2. Hold and drag on the drive pad.</p>
<p>3. Use the other column for motor, horn, and camera controls.</p> <p>3. Choose Precision, Normal, or Turbo above the drive pad.</p>
<p>4. When done, enter "Docking Assist" and line up the front sensor with the dock sensor.</p> <p>4. Use the other column for rover controls.</p>
<p>5. Tap "Dock and charge" when finished.</p>
</div> </div>
); );
} }
@@ -71,7 +73,6 @@ export default function QuickstartOverlay({
layout, layout,
showOnLoad, showOnLoad,
onToggleShowOnLoad, onToggleShowOnLoad,
onOpenHelp,
onClose, onClose,
}) { }) {
const rawKeymap = useControlSelector((control) => control.state.keymap); const rawKeymap = useControlSelector((control) => control.state.keymap);
-5
View File
@@ -87,10 +87,6 @@ function DriverPageContent({ layout, oldDesktop }) {
}, },
[saveQuickstartSettings], [saveQuickstartSettings],
); );
const openHelpFromQuickstart = useCallback(() => {
setQuickstartVisible(false);
setHelpVisible(true);
}, []);
return ( return (
<ControlSystemProvider> <ControlSystemProvider>
{/* {/*
@@ -127,7 +123,6 @@ function DriverPageContent({ layout, oldDesktop }) {
layout={layout} layout={layout}
showOnLoad={quickstartSettings?.showOnLoad !== false} showOnLoad={quickstartSettings?.showOnLoad !== false}
onToggleShowOnLoad={setQuickstartShowOnLoad} onToggleShowOnLoad={setQuickstartShowOnLoad}
onOpenHelp={openHelpFromQuickstart}
onClose={closeQuickstart} onClose={closeQuickstart}
/> />
</ControlSystemProvider> </ControlSystemProvider>
+60 -27
View File
@@ -11,26 +11,44 @@ export const HELP_CONTENT = {
type: 'list', type: 'list',
title: 'Chat and nicknames', title: 'Chat and nicknames',
items: [ items: [
'Set a nickname in the user list panel, on the bottom left of the page below the rover video.', 'Chat and nickname controls are in the Chat/Rovers tab.',
{ segments: ['Toggle chat focus with ', { action: 'chatFocus' }, '. Press ', { action: 'chatFocus'}, ' again to send.'] }, { segments: ['Open the HUD chat composer with ', { action: 'chatFocus' }, '. Press ', { action: 'chatFocus'}, ' again to send.'] },
] ]
}, },
{ {
type: 'list', type: 'list',
title: 'Driving the rover', title: 'Driving the rover',
items: [ items: [
{ segments: ['Press the "Start Driving" button onscreen, or press ' , { action: 'driveMacro' }, ' on your keyboard to put the rover into driving mode.'] }, { segments: ['Click "Your rover is docked", or press ', { action: 'driveMacro' }, ', to undock.'] },
'Refer to the controls for the controls for the rover.' 'Drive with the movement keybindings.',
'Rover controls dim while another person has the turn.',
],
},
{
type: 'list',
title: 'Video HUD',
items: [
'Top left shows the rover name and turns.',
'Top right shows battery and rover status.',
'Bottom left contains horn, headlight, and laser controls.',
'Bottom right contains camera tilt and chat.',
'Use the arrows to open and close pods and their expansions.',
],
},
{
type: 'list',
title: 'Page layout',
items: [
'Room, Activities, and VIP are in the left sidebar.',
'Chat/Rovers, Help, and Settings are in the right sidebar.',
], ],
}, },
{ {
type: 'list', type: 'list',
title: 'Docking the rover', title: 'Docking the rover',
items: [ items: [
'If the rover shows "Docking in Progress", it is already auto-seeking the dock.', { segments: ['Click "Dock rover", or press ', { action: 'dockMacro' }, '.'] },
'To dock manually, enter Docking Assist from the drive panel.', 'Click "Rover is docking itself" to resume driving.',
{ segments: ['Press "Enter Docking Assist", or press ', { action: 'dockMacro' }, '.'] },
'In assist mode, camera tilts down and driving speed is limited for precise alignment.',
], ],
}, },
], ],
@@ -56,8 +74,8 @@ export const HELP_CONTENT = {
id: 'macros', id: 'macros',
title: 'Rover modes & chat', title: 'Rover modes & chat',
items: [ items: [
{ action: 'driveMacro', label: 'Drive macro' }, { action: 'driveMacro', label: 'Undock / resume driving' },
{ action: 'dockMacro', label: 'Docking assist toggle' }, { action: 'dockMacro', label: 'Dock rover' },
{ action: 'chatFocus', label: 'Chat focus' }, { action: 'chatFocus', label: 'Chat focus' },
], ],
}, },
@@ -119,27 +137,35 @@ export const HELP_CONTENT = {
type: 'list', type: 'list',
title: 'Chat and nicknames', title: 'Chat and nicknames',
items: [ items: [
'Set a nickname in the user list panel below.', 'Chat and nickname controls are in the Chat tab.',
'Tap in the chat box to send messages in the chat.' 'Tap the chat box to send a message.',
] ]
}, },
{ {
type: 'list', type: 'list',
title: 'Driving the rover', title: 'Driving the rover',
items: [ items: [
{ segments: ['Press the "Start Driving" button onscreen to put the rover into driving mode.'] }, 'Tap "Your rover is docked" to undock.',
'Look below the rover video. Use the joystick column to move the rover, and hold the aux buttons in the other control column.' 'Hold and drag on the drive pad.',
'Choose Precision, Normal, or Turbo above the drive pad.',
'Use the other column for rover controls.',
'Rover controls dim while another person has the turn.',
], ],
}, },
{ {
type: 'list', type: 'list',
title: 'Docking the rover', title: 'Docking the rover',
items: [ items: [
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.', 'Tap "Dock and charge" when finished.',
{ segments: ['For manual docking, press "Enter Docking Assist".'] },
'Assist mode tilts camera down and limits speed for precise alignment.',
], ],
} },
{
type: 'list',
title: 'More controls',
items: [
'Chat, Activities, VIP, Room Controls, Help, and Settings are below the rover controls.',
],
},
], ],
}, },
'mobile-landscape': { 'mobile-landscape': {
@@ -148,28 +174,35 @@ export const HELP_CONTENT = {
type: 'list', type: 'list',
title: 'Chat and nicknames', title: 'Chat and nicknames',
items: [ items: [
'Scroll down to see more of the page.', 'Chat and nickname controls are in the Chat tab.',
'Set a nickname in the user list panel below.', 'Tap the chat box to send a message.',
'Tap in the chat box to send messages in the chat.'
] ]
}, },
{ {
type: 'list', type: 'list',
title: 'Driving the rover', title: 'Driving the rover',
items: [ items: [
{ segments: ['Press the "Start Driving" button, or the "Drive" button to put the rover into driving mode.'] }, 'Tap "Your rover is docked" to undock.',
'Use the joystick column beside the video feed to move the rover, and hold the aux buttons in the other control column.' 'Hold and drag on the drive pad.',
'Choose Precision, Normal, or Turbo above the drive pad.',
'Use the other column for rover controls.',
'Rover controls dim while another person has the turn.',
], ],
}, },
{ {
type: 'list', type: 'list',
title: 'Docking the rover', title: 'Docking the rover',
items: [ items: [
'If you see "Docking in Progress", the rover is currently auto-seeking the dock.', 'Tap "Dock and charge" when finished.',
{ segments: ['For manual docking, press "Enter Docking Assist" (or "Dock" button).'] },
'Assist mode tilts camera down and limits speed for precise alignment.',
], ],
} },
{
type: 'list',
title: 'More controls',
items: [
'Chat, Activities, VIP, Room Controls, Help, and Settings are below the rover controls.',
],
},
], ],
// aside: [ // aside: [
// { // {
+6
View File
@@ -21,6 +21,7 @@ import { SettingsProvider } from './settings/index.js'
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx' import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx' import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
import PtzAppRoot from './ptz/PtzAppRoot.jsx' import PtzAppRoot from './ptz/PtzAppRoot.jsx'
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
// The reporting route includes the charting and CSV libraries. Loading that // The reporting route includes the charting and CSV libraries. Loading that
// bundle only when `/reports` is visited keeps ordinary rover-control sessions // bundle only when `/reports` is visited keeps ordinary rover-control sessions
@@ -31,6 +32,11 @@ createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<SocketProvider> <SocketProvider>
<SessionProvider> <SessionProvider>
{/* Every route depends on the first authoritative session snapshot.
Mounting this opaque layer at the shared provider boundary prevents
incomplete route-specific placeholders from flashing while still
allowing every application tree to initialize underneath it. */}
<InitialSessionOverlay />
<TelemetryProvider> <TelemetryProvider>
<SettingsProvider> <SettingsProvider>
<ChatProvider> <ChatProvider>