driver removal information finaly!

This commit is contained in:
legop3
2026-07-05 22:45:38 -04:00
parent df22ac6d81
commit b526beb712
18 changed files with 502 additions and 168 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
+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-BnsUBjx0.js"></script> <script type="module" crossorigin src="/assets/index-Buubck0y.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DFtJPk7c.css"> <link rel="stylesheet" crossorigin href="/assets/index-DaeBPi7X.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -13,6 +13,37 @@ const assignments = new Map(); // socketId -> roverId
const waiting = new Set(); // socketIds waiting for placement const waiting = new Set(); // socketIds waiting for placement
const assignmentEvents = new EventEmitter(); const assignmentEvents = new EventEmitter();
function normalizeRemovalMessage(message, fallback) {
/*
Removal notices are shown directly in the driving UI, so the server trims
caller-provided text before emitting it. Keeping this normalization close to
the release helper makes every forced-removal path use the same readable
fallback instead of forcing each caller to duplicate defensive string checks.
*/
const clean = String(message || '').trim();
return clean || fallback;
}
function emitRemovalNotice(socket, notice = {}) {
/*
The browser may lose its rover assignment in the same server tick that the
reason is generated. Sending a dedicated event before releasing control lets
the client preserve the explanation even after normal session sync says the
user no longer has an assigned rover.
*/
if (!socket) return;
const roverId = String(notice.roverId || '').trim() || null;
const message = normalizeRemovalMessage(notice.message, 'You were removed from the rover.');
socket.emit('session:roverRemovalNotice', {
roverId,
title: normalizeRemovalMessage(notice.title, 'Removed from rover'),
message,
reasonCode: String(notice.reasonCode || 'removed').trim() || 'removed',
actor: notice.actor || null,
ts: Date.now(),
});
}
io.on('connection', (socket) => { io.on('connection', (socket) => {
socketRefs.set(socket.id, socket); socketRefs.set(socket.id, socket);
socket.on('disconnect', () => { socket.on('disconnect', () => {
@@ -176,6 +207,18 @@ function forceRelease(roverId, socketId) {
assignmentEvents.emit('update', socketId); assignmentEvents.emit('update', socketId);
} }
function forceReleaseWithNotice(roverId, socketId, notice = {}) {
/*
This is the one public path for moderation-style removals. It deliberately
emits the explanation before forceRelease mutates assignment state, because
session sync listeners can update the UI immediately after the release and
the UI needs the reason to already be in local state.
*/
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
emitRemovalNotice(socket, { ...notice, roverId });
forceRelease(roverId, socketId);
}
function pickRover(socket, options = {}) { function pickRover(socket, options = {}) {
const mode = getMode(); const mode = getMode();
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) { if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
@@ -266,6 +309,7 @@ module.exports = {
assignmentEvents, assignmentEvents,
describeAssignment, describeAssignment,
forceRelease, forceRelease,
forceReleaseWithNotice,
rerollAssignments, rerollAssignments,
getAssignedRover: (socketId) => assignments.get(socketId) || null, getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => { moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
@@ -12,6 +12,7 @@ function formatHelp() {
'`rs bridge mode <global|private>` — change chat bridge mode', '`rs bridge mode <global|private>` — change chat bridge mode',
'`rs bridge off` — disable chat bridge for this server', '`rs bridge off` — disable chat bridge for this server',
'`rs lights <status|lock|unlock>` — show or change room light lock state', '`rs lights <status|lock|unlock>` — show or change room light lock state',
'`rs kick <user> [reason]` — remove a user from their current rover; use `user | reason` for multi-word names',
'`rs lock <rover>` — lock a rover; rover names can be fuzzy', '`rs lock <rover>` — lock a rover; rover names can be fuzzy',
'`rs unlock <rover>` — unlock a rover; rover names can be fuzzy', '`rs unlock <rover>` — unlock a rover; rover names can be fuzzy',
'`rs mode <open|turns|admin|lockdown>` — change server mode', '`rs mode <open|turns|admin|lockdown>` — change server mode',
@@ -13,6 +13,7 @@ const { createDeterCommand } = require('./deter');
const { createBridgeCommand } = require('./bridge'); const { createBridgeCommand } = require('./bridge');
const { createTimeStatusCommand } = require('./timeStatus'); const { createTimeStatusCommand } = require('./timeStatus');
const { createLightsCommand } = require('./lights'); const { createLightsCommand } = require('./lights');
const { createKickCommand } = require('./kick');
function createCommandHandlers(deps) { function createCommandHandlers(deps) {
const { const {
@@ -35,6 +36,7 @@ function createCommandHandlers(deps) {
const handleBridgeCommand = createBridgeCommand(deps); const handleBridgeCommand = createBridgeCommand(deps);
const handleTimeStatusCommand = createTimeStatusCommand(deps); const handleTimeStatusCommand = createTimeStatusCommand(deps);
const handleLightsCommand = createLightsCommand(deps); const handleLightsCommand = createLightsCommand(deps);
const handleKickCommand = createKickCommand(deps);
async function handleCommand(message) { async function handleCommand(message) {
if (message.author.bot) return; if (message.author.bot) return;
@@ -58,7 +60,7 @@ function createCommandHandlers(deps) {
// lockdown mode narrows them from normal admins to lockdown admins. Room // lockdown mode narrows them from normal admins to lockdown admins. Room
// light locking belongs here because it can force the physical room lights // light locking belongs here because it can force the physical room lights
// on and disables ordinary Home Assistant room controls for everyone else. // on and disables ordinary Home Assistant room controls for everyone else.
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights']); const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick']);
if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') { if (!isAdmin && action !== '' && action !== 'status' && action !== 'help' && action !== 'replay' && action !== 'bridge' && action !== 'goal' && action !== 'reason' && action !== 'verify' && action !== 'deter') {
await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } }); await message.reply({ content: 'Only admins can run that command.', allowedMentions: { parse: [], repliedUser: false } });
@@ -82,6 +84,8 @@ function createCommandHandlers(deps) {
return handleBridgeCommand(message, tokens); return handleBridgeCommand(message, tokens);
case 'lights': case 'lights':
return handleLightsCommand(message, tokens); return handleLightsCommand(message, tokens);
case 'kick':
return handleKickCommand(message, rest);
case 'lock': case 'lock':
return handleLockCommand(message, rest, true); return handleLockCommand(message, rest, true);
case 'unlock': case 'unlock':
@@ -0,0 +1,136 @@
// Discord Kick Command
// Purpose: Removes a connected user from their current rover without applying any persistent moderation state.
// Scope: Resolves an online driver, sends them a UI-visible reason, and releases their current rover assignment.
const Fuse = require('fuse.js');
const DEFAULT_KICK_REASON = 'Removed from rover by admin.';
function normalizeText(value) {
return String(value || '').trim();
}
function normalizeSearchText(value) {
return normalizeText(value).toLowerCase().replace(/\s+/g, ' ');
}
function splitSelectorAndReason(rawText) {
const text = normalizeText(rawText);
if (!text) return { selector: '', reason: '' };
const pipeIndex = text.indexOf('|');
if (pipeIndex >= 0) {
/*
A pipe delimiter is the escape hatch for multi-word nicknames. Without a
delimiter the command intentionally treats the first token as the selector
so quick admin commands stay short: `rs kick bob being reckless`.
*/
return {
selector: normalizeText(text.slice(0, pipeIndex)),
reason: normalizeText(text.slice(pipeIndex + 1)),
};
}
const parts = text.split(/\s+/);
return {
selector: normalizeText(parts.shift()),
reason: normalizeText(parts.join(' ')),
};
}
function buildKickCandidates({ io, roverManager, assignmentService, getNickname }) {
return Array.from(io.sockets.sockets.values())
.map((socket) => {
const socketId = normalizeText(socket?.id);
const assignedRoverId = assignmentService?.getAssignedRover?.(socketId) || null;
const primaryRoverId = roverManager.getPrimaryRoverForSocket(socketId);
const roverId = assignedRoverId || primaryRoverId || null;
if (!socketId || !roverId) return null;
const nickname = normalizeText(getNickname(socket));
const username = normalizeText(socket?.data?.user?.username);
return {
socket,
socketId,
roverId,
nickname,
username,
label: nickname || username || socketId.slice(0, 6),
searchSocketId: normalizeSearchText(socketId),
searchShortSocketId: normalizeSearchText(socketId.slice(0, 6)),
searchNickname: normalizeSearchText(nickname),
searchUsername: normalizeSearchText(username),
};
})
.filter(Boolean);
}
function resolveKickTarget(selector, candidates) {
const query = normalizeSearchText(selector);
if (!query) return { error: 'Specify a user to kick. Example: `rs kick nickname reason`' };
const exact = candidates.filter((entry) => (
entry.searchSocketId === query ||
entry.searchShortSocketId === query ||
entry.searchNickname === query ||
entry.searchUsername === query
));
if (exact.length === 1) return { target: exact[0] };
if (exact.length > 1) {
return { error: `User matched multiple drivers: ${exact.map((entry) => entry.label).join(', ')}.` };
}
const fuse = new Fuse(candidates, {
includeScore: true,
threshold: 0.38,
ignoreLocation: true,
keys: [
{ name: 'nickname', weight: 0.7 },
{ name: 'username', weight: 0.2 },
{ name: 'socketId', weight: 0.1 },
],
});
const results = fuse.search(selector);
if (!results.length) return { error: 'User not found among current rover drivers.' };
const first = results[0];
const second = results[1];
if (second && Math.abs(Number(second.score || 0) - Number(first.score || 0)) < 0.08) {
return {
error: `User matched multiple drivers: ${results.slice(0, 5).map((entry) => entry.item.label).join(', ')}.`,
};
}
return { target: first.item };
}
function createKickCommand({ io, roverManager, getNickname, sanitizeMentions }) {
return async function handleKickCommand(message, rawText) {
const { selector, reason } = splitSelectorAndReason(rawText);
const assignmentService = require('../../assignmentService');
const candidates = buildKickCandidates({
io,
roverManager,
assignmentService,
getNickname,
});
const resolved = resolveKickTarget(selector, candidates);
if (resolved.error) {
await message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: { parse: [], repliedUser: false } });
return;
}
const target = resolved.target;
const removalReason = reason || DEFAULT_KICK_REASON;
/*
The command deliberately calls the notice-aware release helper instead of
roverManager.releaseControl. That keeps admin kicks aligned with automated
removals and gives the driver a stable explanation in the video panel.
*/
assignmentService.forceReleaseWithNotice(target.roverId, target.socketId, {
title: 'Removed by admin',
message: removalReason,
reasonCode: 'admin-kick',
actor: message.author?.id || null,
});
await message.reply({
content: sanitizeMentions(`Removed ${target.label} from ${target.roverId}: ${removalReason}`),
allowedMentions: { parse: [], repliedUser: false },
});
};
}
module.exports = {
createKickCommand,
};
@@ -34,6 +34,7 @@ const {
managerEvents, managerEvents,
backoffTimers, backoffTimers,
dockGuardStates, dockGuardStates,
dockProtectionStrikeStates,
privateButtonStates, privateButtonStates,
privateNoUsersSince, privateNoUsersSince,
privateSafetyTimers, privateSafetyTimers,
@@ -154,6 +155,7 @@ const sensorPipeline = createSensorPipeline({
rovers, rovers,
managerEvents, managerEvents,
dockGuardStates, dockGuardStates,
dockProtectionStrikeStates,
backoffTimers, backoffTimers,
privateButtonStates, privateButtonStates,
privateSafetyTimers, privateSafetyTimers,
@@ -8,6 +8,7 @@ function createSensorPipeline(deps) {
rovers, rovers,
managerEvents, managerEvents,
dockGuardStates, dockGuardStates,
dockProtectionStrikeStates,
backoffTimers, backoffTimers,
privateButtonStates, privateButtonStates,
privateSafetyTimers, privateSafetyTimers,
@@ -38,6 +39,9 @@ function createSensorPipeline(deps) {
shouldApplyPrivateSensorSafety, shouldApplyPrivateSensorSafety,
} = deps; } = deps;
const DOCK_PROTECTION_MAX_STRIKES = 3;
const DOCK_PROTECTION_STRIKE_RESET_MS = 5 * 60 * 1000;
function getPrivateSafetyState(roverId) { function getPrivateSafetyState(roverId) {
if (!privateSafetyStates.has(roverId)) { if (!privateSafetyStates.has(roverId)) {
privateSafetyStates.set(roverId, { privateSafetyStates.set(roverId, {
@@ -416,6 +420,68 @@ function createSensorPipeline(deps) {
); );
} }
function getDockProtectionStrikeState(socketId) {
/*
The strike record is keyed by driver socket because the moderation action
removes a person from control, not a rover from service. The last rover is
still tracked so "three in a row" means repeated bump-off-dock incidents
by the same browser session without a different rover resetting context.
*/
const key = String(socketId || '').trim();
if (!key) return null;
if (!dockProtectionStrikeStates.has(key)) {
dockProtectionStrikeStates.set(key, {
count: 0,
lastRoverId: null,
updatedAt: 0,
});
}
return dockProtectionStrikeStates.get(key);
}
function recordDockProtectionStrike(suspect) {
const socketId = String(suspect?.socketId || '').trim();
const roverId = String(suspect?.roverId || '').trim();
const state = getDockProtectionStrikeState(socketId);
if (!state || !roverId) return 0;
const now = Date.now();
const previousIsFresh = state.updatedAt && now - state.updatedAt <= DOCK_PROTECTION_STRIKE_RESET_MS;
/*
Consecutive protection hits should punish repeated behavior, not stale
memory from some unrelated rover interaction. Switching suspect rover
resets the count because the dock-protection heuristic has a different
physical context and should earn its own three-strike sequence.
*/
state.count = previousIsFresh && state.lastRoverId === roverId ? state.count + 1 : 1;
state.lastRoverId = roverId;
state.updatedAt = now;
return state.count;
}
function clearDockProtectionStrikes(socketId) {
const key = String(socketId || '').trim();
if (key) dockProtectionStrikeStates.delete(key);
}
function removeDriverForDockProtection(suspect, strikes) {
const socketId = String(suspect?.socketId || '').trim();
const roverId = String(suspect?.roverId || '').trim();
if (!socketId || !roverId) return false;
const assignmentService = require('../assignmentService');
/*
Release through assignmentService so rover membership, turn queues, and
the browser-facing removal notice all move together. Directly editing
roverManager sets here would skip queue cleanup and produce stale UI.
*/
assignmentService.forceReleaseWithNotice(roverId, socketId, {
title: 'Removed for dock protection',
message: `You were removed from ${roverId} after triggering bump-off-dock protection ${strikes} times in a row.`,
reasonCode: 'dock-protection',
});
clearDockProtectionStrikes(socketId);
return true;
}
function handleIdleUndock(undockedRecord) { function handleIdleUndock(undockedRecord) {
if (!undockedRecord || undockedRecord.drivers.size > 0) return; if (!undockedRecord || undockedRecord.drivers.size > 0) return;
const now = Date.now(); const now = Date.now();
@@ -435,10 +501,11 @@ function createSensorPipeline(deps) {
const suspectRecord = rovers.get(suspect.roverId); const suspectRecord = rovers.get(suspect.roverId);
if (!suspectRecord) return; if (!suspectRecord) return;
const bumpRecent = suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS; const bumpRecent = suspectRecord.lastBumpAt && now - suspectRecord.lastBumpAt <= DOCK_GUARD_WINDOW_MS;
const strikes = recordDockProtectionStrike(suspect);
sendAlert({ sendAlert({
color: ALERT_COLOR, color: ALERT_COLOR,
title: 'Dock protection', title: 'Dock protection',
message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}.`, message: `${undockedRecord.id} undocked while idle; stopping ${suspect.roverId}${strikes ? ` (${strikes}/${DOCK_PROTECTION_MAX_STRIKES})` : ''}.`,
}); });
try { try {
issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } }); issueCommand(suspect.roverId, { type: 'drive', driveDirect: { left: 0, right: 0 } });
@@ -449,6 +516,13 @@ function createSensorPipeline(deps) {
setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS); setDriveCooldown(suspect.roverId, DOCK_GUARD_WINDOW_MS);
if (bumpRecent) nudgeRover(suspect.roverId, 'backward'); if (bumpRecent) nudgeRover(suspect.roverId, 'backward');
else nudgeRover(suspect.roverId, 'forward'); else nudgeRover(suspect.roverId, 'forward');
if (strikes >= DOCK_PROTECTION_MAX_STRIKES && removeDriverForDockProtection(suspect, strikes)) {
sendAlert({
color: ALERT_COLOR,
title: 'Driver removed',
message: `${suspect.socketId} removed from ${suspect.roverId} after ${strikes} dock-protection triggers.`,
});
}
} }
function handleSensorFrame(roverId, frame) { function handleSensorFrame(roverId, frame) {
@@ -9,6 +9,7 @@ const spectatorSockets = new Set();
const managerEvents = new EventEmitter(); const managerEvents = new EventEmitter();
const backoffTimers = new Map(); const backoffTimers = new Map();
const dockGuardStates = new Map(); const dockGuardStates = new Map();
const dockProtectionStrikeStates = new Map();
const privateButtonStates = new Map(); const privateButtonStates = new Map();
const privateNoUsersSince = new Map(); const privateNoUsersSince = new Map();
const privateSafetyTimers = new Map(); const privateSafetyTimers = new Map();
@@ -21,6 +22,7 @@ module.exports = {
managerEvents, managerEvents,
backoffTimers, backoffTimers,
dockGuardStates, dockGuardStates,
dockProtectionStrikeStates,
privateButtonStates, privateButtonStates,
privateNoUsersSince, privateNoUsersSince,
privateSafetyTimers, privateSafetyTimers,
+10 -1
View File
@@ -11,9 +11,18 @@ function stopRover(roverId) {
} }
} }
function removeDriverCompletely(roverId, socketId) { function removeDriverCompletely(roverId, socketId, notice = null) {
try { try {
const assignmentService = require('../assignmentService'); const assignmentService = require('../assignmentService');
/*
Turn-service removals should explain themselves to the affected browser
when a caller provides notice metadata. Plain forceRelease remains the
fallback for old internal cleanup paths that only need to mutate state.
*/
if (notice && typeof assignmentService.forceReleaseWithNotice === 'function') {
assignmentService.forceReleaseWithNotice(roverId, socketId, notice);
return;
}
assignmentService.forceRelease(roverId, socketId); assignmentService.forceRelease(roverId, socketId);
} catch (err) { } catch (err) {
// best effort; log elsewhere if needed // best effort; log elsewhere if needed
+6 -1
View File
@@ -290,12 +290,17 @@ function handleIdleTimeout(roverId, expectedDriver) {
const skips = incrementSkip(roverId, expectedDriver); const skips = incrementSkip(roverId, expectedDriver);
stopRover(roverId); stopRover(roverId);
if (skips >= MAX_IDLE_SKIPS) { if (skips >= MAX_IDLE_SKIPS) {
const removalMessage = `You were removed from ${roverId} after ${skips} idle skips because no driving input was detected during your turns.`;
sendAlert({ sendAlert({
color: ALERT_COLOR, color: ALERT_COLOR,
title: 'Driver removed', title: 'Driver removed',
message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`, message: `${expectedDriver} removed from ${roverId} after ${skips} idle skips`,
}); });
removeDriverCompletely(roverId, expectedDriver); removeDriverCompletely(roverId, expectedDriver, {
title: 'Removed for inactivity',
message: removalMessage,
reasonCode: 'idle-removal',
});
return; return;
} }
sendAlert({ sendAlert({
+41 -12
View File
@@ -11,6 +11,46 @@ import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx'; import DriverBottomStrip from '../HudOverlays/DriverBottomStrip/index.jsx';
import HudChatInput from '../HudOverlays/HudChatInput/index.jsx'; import HudChatInput from '../HudOverlays/HudChatInput/index.jsx';
import CardFrame from '../CardFrame/index.jsx'; import CardFrame from '../CardFrame/index.jsx';
import { useSharedClock } from '../../hooks/useSharedClock.js';
const REMOVAL_NOTICE_VISIBLE_MS = 2 * 60 * 1000;
function EmptyDriverVideoNotice() {
const removalNotice = useSessionSelector((state) => state.roverRemovalNotice || null);
const now = useSharedClock(1000, Boolean(removalNotice?.receivedAt));
const noticeAgeMs = removalNotice?.receivedAt ? now - removalNotice.receivedAt : Infinity;
/*
Removal explanations should feel immediate and contextual. After a short
window, falling back to the neutral no-rover state avoids showing an old
moderation/safety message during unrelated later waiting periods.
*/
const showRemovalNotice = Boolean(removalNotice?.message && noticeAgeMs <= REMOVAL_NOTICE_VISIBLE_MS);
const title = showRemovalNotice ? removalNotice.title || 'Removed from rover' : 'No rover assigned';
const message = showRemovalNotice
? removalNotice.message
: 'You are not currently assigned to a rover.';
return (
<CardFrame hideHeader className="shrink-0">
<div className="panel-muted flex aspect-[4/3] items-center justify-center p-4 text-center">
<div
className={`mx-auto flex max-w-md flex-col gap-1 rounded border px-4 py-3 ${
showRemovalNotice
? 'border-amber-300/60 bg-amber-950/35 text-amber-50'
: 'border-slate-700/70 bg-slate-950/35 text-slate-300'
}`}
>
<div className={showRemovalNotice ? 'text-sm font-semibold text-amber-100' : 'text-sm font-semibold text-slate-200'}>
{title}
</div>
<div className={showRemovalNotice ? 'text-sm text-amber-50/90' : 'text-sm text-slate-400'}>
{message}
</div>
</div>
</div>
</CardFrame>
);
}
export default function DriverVideo({ layoutFormat = 'desktop' }) { export default function DriverVideo({ layoutFormat = 'desktop' }) {
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
@@ -18,18 +58,7 @@ export default function DriverVideo({ layoutFormat = 'desktop' }) {
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt); const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
if (!roverId) { if (!roverId) {
return ( return <EmptyDriverVideoNotice />;
<CardFrame hideHeader className="shrink-0">
<div className="panel-muted content-center text-center text-sm text-slate-400 aspect-[4/3]">
<p>You are not assigned to a rover.</p>
<p className="mt-0">
<a href="/spectate" className="text-blue-400 underline hover:text-blue-500">
Click here to visit the spectator page.
</a>
</p>
</div>
</CardFrame>
);
} }
const mobileHud = layoutFormat !== 'desktop'; const mobileHud = layoutFormat !== 'desktop';
+28
View File
@@ -19,6 +19,7 @@ const INITIAL_STATE = {
latestReplay: null, latestReplay: null,
latestRequestedReplay: null, latestRequestedReplay: null,
duplicateIdentityBlock: null, duplicateIdentityBlock: null,
roverRemovalNotice: null,
}; };
const SessionContext = createContext(null); const SessionContext = createContext(null);
@@ -304,6 +305,31 @@ export function SessionProvider({ children }) {
}, },
})); }));
} }
function handleRoverRemovalNotice(payload = {}) {
/*
Removal reasons arrive as socket events because the next normal session
sync only says "not assigned". Keeping the explanation outside the
session tree lets the no-rover video panel tell the user why control was
removed after admin, safety, or idle-removal actions.
*/
const message =
typeof payload?.message === 'string' && payload.message.trim()
? payload.message.trim()
: 'You were removed from the rover.';
const title =
typeof payload?.title === 'string' && payload.title.trim()
? payload.title.trim()
: 'Removed from rover';
setState((prev) => ({
...prev,
roverRemovalNotice: {
...payload,
title,
message,
receivedAt: Date.now(),
},
}));
}
socket.on('session:sync', handleSession); socket.on('session:sync', handleSession);
socket.on('log:init', handleLogInit); socket.on('log:init', handleLogInit);
socket.on('log:entry', handleLogEntry); socket.on('log:entry', handleLogEntry);
@@ -317,6 +343,7 @@ export function SessionProvider({ children }) {
socket.on('replay:ready', handleReplayReady); socket.on('replay:ready', handleReplayReady);
socket.on('replay:failed', handleReplayFailed); socket.on('replay:failed', handleReplayFailed);
socket.on('session:duplicateIdentity', handleDuplicateIdentity); socket.on('session:duplicateIdentity', handleDuplicateIdentity);
socket.on('session:roverRemovalNotice', handleRoverRemovalNotice);
return () => { return () => {
socket.off('session:sync', handleSession); socket.off('session:sync', handleSession);
socket.off('log:init', handleLogInit); socket.off('log:init', handleLogInit);
@@ -331,6 +358,7 @@ export function SessionProvider({ children }) {
socket.off('replay:ready', handleReplayReady); socket.off('replay:ready', handleReplayReady);
socket.off('replay:failed', handleReplayFailed); socket.off('replay:failed', handleReplayFailed);
socket.off('session:duplicateIdentity', handleDuplicateIdentity); socket.off('session:duplicateIdentity', handleDuplicateIdentity);
socket.off('session:roverRemovalNotice', handleRoverRemovalNotice);
}; };
}, [setState, socket]); }, [setState, socket]);