'use client'; import { useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request'; import { retainAdminRoomRequest } from '@/lib/admin-room-control-client'; import { adminSeatAiResponse, adminSeatAiConfirmation, clearAdminSeatAiRequest, newAdminSeatAiRequest, readAdminSeatAiRequest, saveAdminSeatAiRequest } from '@/lib/admin-seat-ai-client'; import type { AdminSeatAiInput, AdminSeatAiView } from '@/lib/admin-seat-ai'; import { DIFFICULTIES, type Difficulty } from '@/game/bot-profiles'; import { FACTIONS } from '@/game/catalog'; type Props = { accountId: string; code: string; canManage: boolean; onClose: () => void; onUpdated: () => void; onDenied: () => void; onBusyChange: (busy: boolean) => void; }; const message = (error: unknown) => error instanceof Error ? error.message : 'Participant availability could not be loaded.'; const label = (difficulty: Difficulty) => difficulty[0].toUpperCase() + difficulty.slice(1); export function AdminSeatAi({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) { const [room, setRoom] = useState(null); const [target, setTarget] = useState(''); const [difficulty, setDifficulty] = useState('Easy'); const [reason, setReason] = useState(''); const [confirmed, setConfirmed] = useState(false); const [confirmationCode, setConfirmationCode] = useState(''); const [pending, setPending] = useState(null); const [storageProblem, setStorageProblem] = useState(false); const [notice, setNotice] = useState(''); const [busy, setBusy] = useState(false); const [initialRequestPending, setInitialRequestPending] = useState(false); const [loading, setLoading] = useState(true); const alive = useRef(false), inFlight = useRef(false); const title = useRef(null); const url = `/api/admin/rooms/${code}/seat-ai`; const selected = room?.players.find(player => player.id === target && player.eligible); useEffect(() => { alive.current = true; title.current?.focus(); let canceled = false; try { const saved = readAdminSeatAiRequest(window.sessionStorage, accountId, code); // oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR. setPending(saved); if (saved) setNotice('A saved participant AI request needs confirmation. Review it below, then retry the same request.'); } catch (error) { setStorageProblem(true); setNotice(message(error)); } requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }) .then(result => { const current = adminSeatAiResponse(result, code); if (!canceled) setRoom(current); }) .catch(error => { if (!canceled) { setNotice(message(error)); if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); } }) .finally(() => { if (!canceled) setLoading(false); }); return () => { canceled = true; alive.current = false; }; }, [accountId, code, url, onDenied]); async function refresh() { if (inFlight.current) return; inFlight.current = true; setBusy(true); onBusyChange(true); try { const current = adminSeatAiResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code); if (alive.current) { setRoom(current); setTarget(''); setConfirmed(false); setConfirmationCode(''); } } catch (error) { if (alive.current) { setNotice(message(error)); if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); } } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } } async function submit(saved?: AdminSeatAiInput) { if (inFlight.current || loading || !canManage || storageProblem || (!saved && (!room?.editable || !selected || pending || !confirmed || confirmationCode !== code))) return; let input: AdminSeatAiInput; try { input = saved ?? newAdminSeatAiRequest(room!, target, difficulty, reason); saveAdminSeatAiRequest(window.sessionStorage, accountId, code, input); } catch (error) { setNotice(message(error)); return; } inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Enabling participant AI…'); try { const result = adminSeatAiConfirmation(await requestJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), }), code, input); clearAdminSeatAiRequest(window.sessionStorage, accountId, code); if (alive.current) { setPending(null); setRoom(result.room); setTarget(''); setConfirmed(false); setConfirmationCode(''); setReason(''); setNotice(result.replayed ? 'The original request was already recorded. Current participant control is shown below; later changes have been preserved.' : 'Participant AI enabled. The room remains paused. The player keeps their seat and can take back control. Resume separately through Room controls when everyone is ready.'); onUpdated(); } } catch (error) { let cleared = false; if (!retainAdminRoomRequest(error)) { try { clearAdminSeatAiRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Preserve exact retry after storage failure. */ } } if (alive.current) { if (cleared) setPending(null); setConfirmed(false); setConfirmationCode(''); setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error)); if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); else try { const current = adminSeatAiResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code); if (alive.current) setRoom(current); } catch (readError) { // Keep the exact retry proof, but never leave stale privileged controls visible. if (alive.current && readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied(); } } } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } } function discardUnreadableRecord() { if (!canManage || !room || !confirmed || busy) return; try { clearAdminSeatAiRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false); setNotice('Local retry record removed. This did not undo any server change. Review current participant control before continuing.'); } catch (error) { setNotice(message(error)); } } return

Participant AI · {code}

{notice && {notice}} {loading ? Loading participant availability… : <> {room ? <>

Room: {room.removed ? 'Removed' : room.archived ? 'Archived' : room.closed ? 'Closed' : room.paused ? 'Paused' : 'Running'}. Game: {room.status}.

Use AI to support an absent player in a paused game. They keep their cards, forces and access, and can take back control. No private seat access is given to the administrator.

{room.players.length > 0 &&
    {room.players.map(player =>
  • {player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction} · {player.control === 'human' ? 'Human' : `${player.difficulty ? label(player.difficulty) : ''} AI${player.control === 'autopilot' ? ' on human seat' : ''}`}
  • )}
} : } {!canManage ?

Your viewer role can inspect participants. An owner or operator can enable participant AI.

: storageProblem ?

No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.

: initialRequestPending ? null : pending ?

Saved request: enable {label(pending.difficulty)} AI for {room?.players.find(player => player.id === pending.target)?.name ?? pending.target} in room {code}.

Reason: {pending.reason}

The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again, including after the player takes back control or the room resumes.

After refresh or sign-in, use All rooms in both Directory and Archive filters to find the room and reopen Participant AI.

: room && !room.editable ?

{room.blockedReason}

: room && !room.players.some(player => player.eligible) ?

No human participant is eligible. A participant needs current seat access and must not already be controlled by AI.

: room &&
{ event.preventDefault(); void submit(); }}>

Use a short operational reason. Do not include credentials or private game information.

{selected ? `Enable ${label(difficulty)} AI for ${selected.name}.` : 'Choose the participant above.'} The room will remain paused. Unused permissions this player gave others to start AI will be revoked; their seat and recovery access remain.

Resume separately through Room controls when play should continue. This does not remove or restrict a participant.

} }
; }