'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 { adminLobbyResponse, adminLobbyConfirmation, clearAdminLobbyRequest, describeAdminLobbyAction, newAdminLobbyRequest, readAdminLobbyRequest, saveAdminLobbyRequest } from '@/lib/admin-lobby-client'; import type { AdminLobbyAction, AdminLobbyInput, AdminLobbyView } from '@/lib/admin-lobby-configuration'; import { FACTIONS, type FactionId } from '@/game/catalog'; import { DIFFICULTIES, type Difficulty } from '@/game/bot-profiles'; 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 : 'The lobby could not be loaded.'; const baseFactions = FACTIONS.filter(faction => faction.expansion === 'base'); export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) { const [lobby, setLobby] = useState(null); const [kind, setKind] = useState('rules'); const [enabled, setEnabled] = useState(false); const [target, setTarget] = useState(''); const [faction, setFaction] = useState('atreides'); const [difficulty, setDifficulty] = useState('Easy'); const [position, setPosition] = useState(1); const [reason, setReason] = useState(''); const [confirmed, setConfirmed] = useState(false); 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}/lobby`; useEffect(() => { alive.current = true; title.current?.focus(); let canceled = false; try { const saved = readAdminLobbyRequest(window.sessionStorage, accountId, code); // oxlint-disable-next-line react/react-compiler -- Restore the account/room's tab-scoped request after SSR. setPending(saved); if (saved) setNotice('A saved lobby 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 = adminLobbyResponse(result, code); if (!canceled) { setLobby(current); setEnabled(current.advanced); } }) .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 = adminLobbyResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code); if (alive.current) { setLobby(current); setConfirmed(false); } } 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); } } function selectTarget(id: string) { setTarget(id); setConfirmed(false); const player = lobby?.players.find(player => player.id === id); if (player?.bot) { setFaction(player.faction as FactionId); setDifficulty(player.bot); setPosition(player.position); } } function selectKind(next: AdminLobbyAction['type']) { setKind(next); setConfirmed(false); setTarget(''); setEnabled(next === 'rules' ? lobby?.advanced ?? false : next === 'techTokens' ? lobby?.techTokens ?? false : lobby?.strongholdCards ?? false); if (next === 'addBot') setFaction(baseFactions.find(item => !lobby?.players.some(player => player.faction === item.id))?.id ?? 'atreides'); if (next === 'removeBot' || next === 'configureBot') selectTarget(lobby?.players.find(player => player.bot)?.id ?? ''); if (next === 'assignHost') setTarget(lobby?.players.find(player => player.hostEligible && player.id !== lobby.host)?.id ?? ''); } const action: AdminLobbyAction = kind === 'rules' ? { type: kind, advanced: enabled } : kind === 'techTokens' || kind === 'strongholdCards' ? { type: kind, enabled } : kind === 'addBot' ? { type: kind, faction, difficulty } : kind === 'configureBot' ? { type: kind, target, faction, difficulty, position } : { type: kind, target }; const selectedPlayer = lobby?.players.find(player => player.id === target); const availableFactions = baseFactions.filter(item => !lobby?.players.some(player => player.faction === item.id && (kind !== 'configureBot' || player.id !== target))); const actionable = lobby?.editable && (kind === 'rules' ? enabled !== lobby.advanced : kind === 'techTokens' ? enabled !== lobby.techTokens : kind === 'strongholdCards' ? enabled !== lobby.strongholdCards && (!enabled || lobby.advanced) : kind === 'addBot' ? lobby.players.length < 6 && availableFactions.some(item => item.id === faction) : kind === 'assignHost' ? selectedPlayer?.hostEligible && target !== lobby.host : kind === 'removeBot' ? !!selectedPlayer?.bot : !!selectedPlayer?.bot && availableFactions.some(item => item.id === faction) && (selectedPlayer.faction !== faction || selectedPlayer.bot !== difficulty || selectedPlayer.position !== position)); async function submit(saved?: AdminLobbyInput) { if (inFlight.current || !lobby || !canManage || storageProblem || (!saved && (!confirmed || !actionable))) return; let input: AdminLobbyInput; try { input = saved ?? newAdminLobbyRequest(lobby, action, reason); saveAdminLobbyRequest(window.sessionStorage, accountId, code, input); } catch (error) { setNotice(message(error)); return; } inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving lobby change…'); try { const result = adminLobbyConfirmation(await requestJson(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), }), code, input); clearAdminLobbyRequest(window.sessionStorage, accountId, code); if (alive.current) { setPending(null); setLobby(result.lobby); setConfirmed(false); setReason(''); setNotice(result.replayed ? 'The original lobby change was already recorded. Current settings are shown below.' : 'Lobby change saved. Current settings are shown below.'); onUpdated(); } } catch (error) { let cleared = false; if (!retainAdminRoomRequest(error)) { try { clearAdminLobbyRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep the exact retry after storage failure. */ } } if (alive.current) { if (cleared) setPending(null); setConfirmed(false); setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved lobby request to confirm.` : message(error)); if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); else try { const current = adminLobbyResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code); if (alive.current) setLobby(current); } catch { /* Preserve the original outcome and retry instructions. */ } } } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } } function discardUnreadableRecord() { if (!confirmed || busy) return; try { clearAdminLobbyRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false); setNotice('Local retry record removed. This did not undo any server change. Review the current lobby before continuing.'); } catch (error) { setNotice(message(error)); } } return

Lobby configuration · {code}

{notice && {notice}} {loading ? Loading lobby configuration… : !lobby ? : <>

Current rules: {lobby.advanced ? 'Advanced preview' : 'Basic'} · Tech Tokens {lobby.techTokens ? 'on' : 'off'} · Stronghold Cards {lobby.strongholdCards ? 'on' : 'off'}.

Advanced preview is unfinished. Players must review the warning and the host must explicitly start the preview.

    {lobby.players.map(player =>
  • {player.name}{player.id === lobby.host ? ' · Host' : ''} · {FACTIONS.find(item => item.id === player.faction)?.name ?? player.faction} · {player.bot ? `AI: ${player.bot}` : 'Human'} · Circle {player.position} · {player.ready ? 'Ready' : 'Not ready'}
  • )}
{!lobby.editable &&

{lobby.blockedReason ?? 'This lobby cannot currently be changed.'}

} {!canManage ?

Your viewer role can inspect this lobby. An owner or operator can change it.

: storageProblem ?

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

: initialRequestPending ? null : pending ?

Saved request for {code}: {describeAdminLobbyAction(pending.action, lobby)}.

Reason: {pending.reason}

The exact request stays in this tab across refresh. Retrying confirms an already completed change without applying it again, including after the game has started.

: lobby.editable &&
{ event.preventDefault(); void submit(); }}> {(kind === 'rules' || kind === 'techTokens' || kind === 'strongholdCards') && } {kind === 'rules' && !enabled && lobby.strongholdCards &&

Switching to Basic also turns off Stronghold Cards.

} {kind === 'techTokens' &&

Tech Tokens require at least three players before the game can start.

} {(kind === 'configureBot' || kind === 'removeBot' || kind === 'assignHost') && } {(kind === 'addBot' || kind === 'configureBot') &&
{kind === 'configureBot' && }
} {kind === 'assignHost' &&

The selected human gains host controls. The previous host keeps their seat. Existing seat credentials remain unchanged, and all humans must ready again.

}

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

{describeAdminLobbyAction(action, lobby)} in room {code}. Settings affect everyone in this lobby and may clear readiness. This action gives the administrator no private seat access.

} }
; }