diff --git a/server/src/services/discordBotService/commands/replay.js b/server/src/services/discordBotService/commands/replay.js index 32241f6a..298e7a0f 100644 --- a/server/src/services/discordBotService/commands/replay.js +++ b/server/src/services/discordBotService/commands/replay.js @@ -1,6 +1,6 @@ // Discord Replay Command // Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow. -// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs. +// Scope: Resolves sources, enforces replay availability, reports job progress, uploads video, and broadcasts media URLs. const { AttachmentBuilder } = require('discord.js'); const io = require('../../../globals/io'); const { hostReplay } = require('../../replayMediaService'); @@ -60,12 +60,6 @@ function createReplayCommand({ return; } - const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' }); - if (!attempt.ok) { - await replyDenied(message, `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`); - return; - } - const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord'; const job = createReplayJob({ id: buildReplayJobId('discord'), @@ -74,17 +68,23 @@ function createReplayCommand({ sources: resolved.sources || [], includeSidebar: true, }); - jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) }); - - const progressMessage = await message.reply({ - content: sanitizeMentions(buildAcceptedMessage(job)), - allowedMentions: DEFAULT_ALLOWED_MENTIONS, - }); - const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command'); + const attempt = tryTriggerReplay({ jobId: job.id, by: { source: 'discord', requester } }); + if (!attempt.ok) { + await replyDenied(message, `Replay denied: ${attempt.error}`); + return; + } + let progressMessage = null; + let stopTyping = () => {}; let builtReplay = null; let deliveredMedia = null; try { + jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) }); + progressMessage = await message.reply({ + content: sanitizeMentions(buildAcceptedMessage(job)), + allowedMentions: DEFAULT_ALLOWED_MENTIONS, + }); + stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command'); jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') }); if (progressMessage?.edit) { await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS }); diff --git a/server/src/services/replayDeliveryService/index.js b/server/src/services/replayDeliveryService/index.js index 564437df..b8de2660 100644 --- a/server/src/services/replayDeliveryService/index.js +++ b/server/src/services/replayDeliveryService/index.js @@ -35,9 +35,9 @@ async function deliverReplay(payload = {}) { requestedBy: payload.requestedBy, }); - jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) }); let providerContext = null; try { + jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) }); let providerError = null; if (preferredDeliveryProvider?.begin) { try { diff --git a/server/src/services/replayDeliveryService/workflow.js b/server/src/services/replayDeliveryService/workflow.js index e737a34e..289681c2 100644 --- a/server/src/services/replayDeliveryService/workflow.js +++ b/server/src/services/replayDeliveryService/workflow.js @@ -2,6 +2,7 @@ // Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text. // Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline. const Fuse = require('fuse.js'); +const { updateReplayStatus } = require('../replayEngineV2/cooldown'); const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false }; const FUZZY_THRESHOLD = 0.42; @@ -180,6 +181,22 @@ function createJobStatusEmitter({ io, logger, sanitizeMentions }) { function emit(job, status, extra = {}) { const payload = buildPayload(job, status, extra); + updateReplayStatus(payload); + if (status === 'accepted') { + // Sound once per participating rover; a disconnected rover must not abort video creation. + const { issueCommand } = require('../commandService'); + const roverIds = new Set(job.sources.filter((entry) => entry.type === 'rover').map((entry) => entry.id)); + for (const roverId of roverIds) { + try { + issueCommand(roverId, { + type: 'song', + song: { notes: [{ note: 72, duration: 8 }, { note: 76, duration: 8 }, { note: 79, duration: 16 }] }, + }); + } catch (err) { + logger?.warn?.('Unable to play replay tune', { roverId, error: err.message }); + } + } + } io.emit('replay:status', payload); if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media); if (status === 'failed') io.emit('replay:failed', payload); diff --git a/server/src/services/replayEngineV2/cooldown.js b/server/src/services/replayEngineV2/cooldown.js index 8de688cc..87b23903 100644 --- a/server/src/services/replayEngineV2/cooldown.js +++ b/server/src/services/replayEngineV2/cooldown.js @@ -4,40 +4,61 @@ const EventEmitter = require('events'); const COOLDOWN_MS = 10 * 1000; +const MAX_CONCURRENT_JOBS = 3; const replayEvents = new EventEmitter(); +const activeJobs = new Map(); +let status = null; let lastTriggeredAt = null; let lastTriggeredBy = null; -function getRemainingMs(now = Date.now()) { - if (!lastTriggeredAt) return 0; - return Math.max(0, COOLDOWN_MS - (now - lastTriggeredAt)); -} - function getReplayState() { - const remainingMs = getRemainingMs(); + const remainingMs = lastTriggeredAt === null ? 0 : Math.max(0, COOLDOWN_MS - (Date.now() - lastTriggeredAt)); return { cooldownMs: COOLDOWN_MS, lastTriggeredAt, lastTriggeredBy, remainingMs, - available: remainingMs === 0, + atJobLimit: activeJobs.size >= MAX_CONCURRENT_JOBS, + activeSourceKeys: [...new Set(Array.from(activeJobs.values()).flatMap((sources) => + sources.map((source) => `${source.type}:${source.id}`), + ))], + status, + available: remainingMs === 0 && activeJobs.size < MAX_CONCURRENT_JOBS, }; } -function tryTriggerReplay(by = null) { - const remainingMs = getRemainingMs(); - if (remainingMs > 0) { - return { ok: false, remainingMs, state: getReplayState() }; - } - lastTriggeredAt = Date.now(); - lastTriggeredBy = by || null; - const state = getReplayState(); - replayEvents.emit('update', { state, by: lastTriggeredBy }); - return { ok: true, state }; +function emitUpdate() { + replayEvents.emit('update', { state: getReplayState(), by: lastTriggeredBy }); } -module.exports = { - tryTriggerReplay, - getReplayState, - replayEvents, -}; +function tryTriggerReplay({ jobId, by = null } = {}) { + const state = getReplayState(); + if (state.atJobLimit) { + return { ok: false, error: 'Replay limit reached: 3 jobs are already being made', state }; + } + if (state.remainingMs > 0) { + return { ok: false, error: `Replay cooldown active. Try again in ${Math.ceil(state.remainingMs / 1000)}s.`, remainingMs: state.remainingMs, state }; + } + if (!jobId) throw new Error('Replay job id required'); + activeJobs.set(jobId, []); + status = { jobId, status: 'accepted' }; + lastTriggeredAt = Date.now(); + lastTriggeredBy = by; + emitUpdate(); + return { ok: true, state: getReplayState() }; +} + +function updateReplayStatus(payload) { + if (!activeJobs.has(payload.jobId)) return; + if (payload.jobId === status?.jobId) { + status = { jobId: payload.jobId, status: payload.status, title: payload.title, message: payload.message }; + } + if (payload.status === 'ready' || payload.status === 'failed') { + activeJobs.delete(payload.jobId); + } else { + activeJobs.set(payload.jobId, payload.sources || []); + } + emitUpdate(); +} + +module.exports = { tryTriggerReplay, getReplayState, replayEvents, updateReplayStatus }; diff --git a/server/src/services/replayEngineV2/socketHooks.js b/server/src/services/replayEngineV2/socketHooks.js index 0e2e1408..9c878605 100644 --- a/server/src/services/replayEngineV2/socketHooks.js +++ b/server/src/services/replayEngineV2/socketHooks.js @@ -1,6 +1,6 @@ // Replay Socket Hooks // Purpose: Registers web socket replay-trigger handler that publishes replay requests. -// Scope: Applies replay mode/cooldown/source validation for socket-triggered replay requests. +// Scope: Applies replay mode/availability/source validation for socket-triggered replay requests. const io = require('../../globals/io'); const logger = require('../../globals/logger').child('replaySocket'); const { getMode, MODES } = require('../modeManager'); @@ -45,9 +45,9 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau const title = normalizeReplayTitle(payload?.title); const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar); const jobId = buildReplayJobId('web'); - const attempt = tryTriggerReplay({ by: { source: 'web', requester } }); + const attempt = tryTriggerReplay({ jobId, by: { source: 'web', requester } }); if (!attempt.ok) { - cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state }); + cb({ error: attempt.error, remainingMs: attempt.remainingMs, state: attempt.state }); return; } diff --git a/server/src/services/sessionService/index.js b/server/src/services/sessionService/index.js index f87953a8..2e5d2b3b 100644 --- a/server/src/services/sessionService/index.js +++ b/server/src/services/sessionService/index.js @@ -459,7 +459,7 @@ balanceBoardEvents.on('change', () => { }); replayEvents.on('update', () => { - logger.info('Replay cooldown updated; syncing all clients'); + logger.info('Replay state updated; syncing all clients'); syncAll(); }); diff --git a/webui/src/components/ReplaySourcesPanel/index.jsx b/webui/src/components/ReplaySourcesPanel/index.jsx index f82d14aa..e633775a 100644 --- a/webui/src/components/ReplaySourcesPanel/index.jsx +++ b/webui/src/components/ReplaySourcesPanel/index.jsx @@ -46,7 +46,7 @@ export default function ReplaySourcesPanel({ const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null); const roster = useSessionSelector((state) => state.session?.roster ?? []); const replayState = useSessionSelector((state) => state.session?.replay || null); - const replayStatus = useSessionSelector((state) => state.replayStatus); + const replayStatus = replayState?.status; const socket = useSocket(); const { triggerReplay } = useSessionActions(); const sources = useMemo(() => normalizeSources(replaySources || []), [replaySources]); @@ -161,48 +161,25 @@ export default function ReplaySourcesPanel({ }; }, [sources]); - const hasReplayCooldown = Boolean(replayState?.lastTriggeredAt && replayState?.cooldownMs); - const replayCooldownEndsAt = hasReplayCooldown - ? replayState.lastTriggeredAt + replayState.cooldownMs + const cooldownNow = useSharedClock(1000, Boolean(replayState?.lastTriggeredAt)); + const remainingMs = replayState?.lastTriggeredAt + ? Math.max(0, replayState.lastTriggeredAt + replayState.cooldownMs - cooldownNow) : 0; - const cooldownNow = useSharedClock(1000, hasReplayCooldown); - const remainingMs = useMemo(() => { - if (!hasReplayCooldown) return 0; - /* - The button only shows whole seconds, so a shared one-second clock gives the - same useful information without each mounted replay panel owning a 250ms - interval. The exact server cooldown still decides whether the action is - accepted; this value is only the local disabled-state/display estimate. - */ - const next = replayCooldownEndsAt - cooldownNow; - return Math.max(0, next); - }, [cooldownNow, hasReplayCooldown, replayCooldownEndsAt]); - - const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || !selected.length; + const atJobLimit = replayState?.atJobLimit === true; + const replaySourceKeys = replayState?.activeSourceKeys; + const activeSourceKeys = useMemo(() => new Set(replaySourceKeys || []), [replaySourceKeys]); + const replayDisabled = busy || mode === 'lockdown' || remainingMs > 0 || atJobLimit || !selected.length; const selectedSet = useMemo(() => { // Checkbox rendering asks the same membership question for every source. // A Set avoids repeated linear scans and, more importantly, gives memoized // child lists a stable value while the selected keys have not changed. return new Set(selected); }, [selected]); - const replayStatusText = useMemo(() => { - if (!replayStatus?.status) return null; - const titleText = replayStatus.title ? `: ${replayStatus.title}` : ''; - switch (replayStatus.status) { - case 'accepted': - return `Replay accepted${titleText}`; - case 'building': - return `Replay building${titleText}`; - case 'uploading': - return `Replay uploading${titleText}`; - case 'ready': - return `Replay ready${titleText}`; - case 'failed': - return replayStatus.message || `Replay failed${titleText}`; - default: - return replayStatus.message || `Replay ${replayStatus.status}${titleText}`; - } - }, [replayStatus]); + const replayStatusText = replayStatus?.status + ? replayStatus.status === 'failed' && replayStatus.message + ? replayStatus.message + : `Replay ${replayStatus.status}${replayStatus.title ? `: ${replayStatus.title}` : ''}` + : null; const toggleKey = useCallback((key) => { setSelected((prev) => { return prev.includes(key) ? prev.filter((value) => value !== key) : [...prev, key]; @@ -250,13 +227,13 @@ export default function ReplaySourcesPanel({ current desktop sidebar. They only collapse when the card becomes narrower than the supported new-drive desktop column. */}