Files
MultiRoombaRover/server/src/services/assignmentService.js
T
2026-04-19 16:16:20 -04:00

279 lines
8.6 KiB
JavaScript

const EventEmitter = require('events');
const io = require('../globals/io');
const logger = require('../globals/logger').child('assignment');
const { MODES, getMode, modeEvents } = require('./modeManager');
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('./roleService');
const roverManager = require('./roverManager');
const socketRefs = new Map(); // socketId -> socket
const assignments = new Map(); // socketId -> roverId
const waiting = new Set(); // socketIds waiting for placement
const assignmentEvents = new EventEmitter();
io.on('connection', (socket) => {
socketRefs.set(socket.id, socket);
socket.on('disconnect', () => {
socketRefs.delete(socket.id);
unassignSocket(socket);
});
});
roleEvents.on('change', ({ socket, role }) => {
if (!socket || !socket.id) return;
if (role === 'user') {
assignSocket(socket);
} else {
unassignSocket(socket);
}
});
modeEvents.on('change', (mode) => {
if (mode === MODES.ADMIN) {
for (const [socketId, roverId] of assignments.entries()) {
const socket = socketRefs.get(socketId);
if (socket && !isAdmin(socket)) {
releaseAssignment(socket, roverId);
}
}
} else if (mode === MODES.LOCKDOWN) {
for (const [socketId, roverId] of assignments.entries()) {
const socket = socketRefs.get(socketId);
if (socket && !isLockdownAdmin(socket)) {
releaseAssignment(socket, roverId);
}
}
}
reassignWaiting();
});
roverManager.managerEvents.on('lock', ({ roverId, locked }) => {
if (locked) {
reassignFromRover(roverId);
} else {
reassignWaiting();
}
});
roverManager.managerEvents.on('private', ({ roverId, open }) => {
if (open) {
reassignWaiting();
} else {
reassignFromRover(roverId);
}
});
roverManager.managerEvents.on('rover', ({ action }) => {
if (action === 'removed' || action === 'upsert') {
reassignWaiting();
}
});
roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
if (!socketId || !roverId) return;
const socket = socketRefs.get(socketId);
if (!socket) return;
assignments.set(socketId, roverId);
waiting.delete(socketId);
assignmentEvents.emit('update', socketId);
});
function assignSocket(socket, options = {}) {
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
return;
}
// avoid double assignment
if (assignments.has(socket.id)) {
return;
}
const target = pickRover(socket, {
excludeRoverId: options.excludeRoverId || null,
});
if (!target) {
waiting.add(socket.id);
logger.info('No rover available, user waiting', socket.id);
assignmentEvents.emit('update', socket.id);
return;
}
try {
roverManager.requestControl(target.id, socket, { allowUser: true });
assignments.set(socket.id, target.id);
waiting.delete(socket.id);
logger.info('Assigned user to rover', socket.id, target.id);
assignmentEvents.emit('update', socket.id);
} catch (err) {
logger.warn('Failed to assign user', err.message);
waiting.add(socket.id);
assignmentEvents.emit('update', socket.id);
}
}
function unassignSocket(socket) {
if (!socket) return;
waiting.delete(socket.id);
const roverId = assignments.get(socket.id);
if (roverId) {
roverManager.releaseControl(roverId, socket);
assignments.delete(socket.id);
logger.info('Unassigned socket from rover', socket.id, roverId);
assignmentEvents.emit('update', socket.id);
}
}
function reassignFromRover(roverId) {
for (const [socketId, rid] of assignments.entries()) {
if (rid !== roverId) continue;
const socket = socketRefs.get(socketId);
if (!socket) {
assignments.delete(socketId);
continue;
}
const access = roverManager.canRequestControl(roverId, socket, { allowUser: true });
if (access.ok) {
continue;
}
roverManager.releaseControl(rid, socket);
assignments.delete(socketId);
assignSocket(socket);
assignmentEvents.emit('update', socketId);
}
}
function reassignWaiting() {
for (const socketId of Array.from(waiting)) {
const socket = socketRefs.get(socketId);
if (socket) {
assignSocket(socket);
} else {
waiting.delete(socketId);
}
assignmentEvents.emit('update', socketId);
}
}
function releaseAssignment(socket, roverId) {
roverManager.releaseControl(roverId, socket);
assignments.delete(socket.id);
waiting.add(socket.id);
logger.info('Released assignment back to queue', socket.id, roverId);
assignmentEvents.emit('update', socket.id);
}
function forceRelease(roverId, socketId) {
const socket = socketRefs.get(socketId) || io.sockets.sockets.get(socketId);
if (assignments.get(socketId) === roverId) {
assignments.delete(socketId);
}
waiting.delete(socketId);
if (socket) {
roverManager.releaseControl(roverId, socket);
logger.info('Force released socket from rover', socketId, roverId);
} else {
logger.warn('Force release: socket not found', socketId, roverId);
}
assignmentEvents.emit('update', socketId);
}
function pickRover(socket, options = {}) {
const mode = getMode();
if (mode === MODES.ADMIN || mode === MODES.LOCKDOWN) {
return null;
}
const allCandidates = Array.from(roverManager.rovers.values()).filter((rover) => {
if (!rover || rover.locked) return false;
const access = roverManager.canRequestControl(rover.id, socket, { allowUser: true });
if (!access.ok) return false;
return true;
});
let candidates = allCandidates;
const excludeRoverId = options?.excludeRoverId || null;
if (excludeRoverId) {
const withoutExcluded = allCandidates.filter((rover) => String(rover.id) !== String(excludeRoverId));
if (withoutExcluded.length > 0) {
candidates = withoutExcluded;
}
}
if (candidates.length === 0) {
return null;
}
const dockedRank = (rover) => {
if (!rover) return 0;
if (rover.docked === true) return -1;
if (rover.docked === false) return 1;
const sensors = rover.lastSensor?.decoded || rover.lastSensor?.sensors || null;
const docked = sensors?.chargingSources?.homeBase;
if (docked === true) return -1;
if (docked === false) return 1;
return 0;
};
const idleRank = (rover) => (rover?.drivers?.size === 0 ? 1 : 0);
const compare = (a, b) => {
const aEmpty = idleRank(a);
const bEmpty = idleRank(b);
if (aEmpty !== bEmpty) return bEmpty - aEmpty;
const aDockRank = dockedRank(a);
const bDockRank = dockedRank(b);
if (aEmpty === 1 && aDockRank !== bDockRank) {
return bDockRank - aDockRank;
}
if (a.drivers.size !== b.drivers.size) {
return a.drivers.size - b.drivers.size;
}
return bDockRank - aDockRank;
};
candidates.sort(compare);
const best = candidates[0];
if (!best) return null;
const bestTier = candidates.filter((entry) => compare(entry, best) === 0);
if (!bestTier.length) return best;
return bestTier[Math.floor(Math.random() * bestTier.length)] || best;
}
function rerollAssignments() {
const users = Array.from(socketRefs.values()).filter((socket) => socket && getRole(socket) === 'user');
const previous = new Map(users.map((socket) => [socket.id, assignments.get(socket.id) || null]));
users.forEach((socket) => {
unassignSocket(socket);
});
let moved = 0;
users.forEach((socket) => {
const prevRover = previous.get(socket.id) || null;
assignSocket(socket, { excludeRoverId: prevRover });
const nextRover = assignments.get(socket.id) || null;
if (nextRover && prevRover && String(nextRover) !== String(prevRover)) {
moved += 1;
}
});
return moved;
}
function describeAssignment(socketId) {
const assignedRoverId = assignments.get(socketId) || null;
const adminRoverId = assignedRoverId ? null : roverManager.getPrimaryRoverForSocket(socketId);
const waitingIndex = waiting.has(socketId) ? Array.from(waiting).indexOf(socketId) : -1;
const roverId = assignedRoverId || adminRoverId || null;
const waitingStatus = waiting.has(socketId);
return {
roverId,
status: assignedRoverId ? 'assigned' : adminRoverId ? 'admin' : waitingStatus ? 'waiting' : null,
queuePosition: waitingIndex >= 0 ? waitingIndex + 1 : null,
};
}
module.exports = {
assignmentEvents,
describeAssignment,
forceRelease,
rerollAssignments,
getAssignedRover: (socketId) => assignments.get(socketId) || null,
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
if (!socket || !roverId) return;
const previous = assignments.get(socket.id);
if (previous && previous !== roverId && releasePrevious) {
roverManager.releaseControl(previous, socket);
}
assignments.set(socket.id, roverId);
waiting.delete(socket.id);
assignmentEvents.emit('update', socket.id);
},
};