'use client'; import { useState, useEffect, useCallback, useRef } from 'react'; import { TableTalk } from '@/components/table-talk'; import { GameTable } from '@/components/game-table'; import { RulesetControls } from '@/components/ruleset-controls'; import { SeatHandoverSetup, SeatHandoverClaim, } from '@/components/seat-handover'; import { HANDOVER_CLAIM_STORAGE_KEY } from '@/lib/seat-handover'; import { SeatAiDelegation } from '@/components/seat-ai-delegation'; import { SeatRecoverySetup, SeatRecoveryClaim, } from '@/components/seat-recovery'; import type { GameView, Action } from '@/game/engine'; import { ArrowRight, Users, BookOpen, Globe, ChevronRight, Shield, Orbit, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { FACTIONS, EXPANSIONS, faction } from '@/game/catalog'; import { ClientRequestError, requestJson, requestMayHaveCompleted, } from '@/lib/client-request'; import { ROOM_ENTRY_STORAGE_KEY, clearRoomEntry, createRoomEntry, parseRoomEntry, roomEntryConfirmed, roomEntryRejectedBeforeCommit, roomEntryUrl, saveRoomEntry, type RoomEntryAttempt, } from '@/lib/room-entry'; // A rejected invitation needs the ordinary join form, not network recovery. const inviteRejected = (error: unknown) => error instanceof ClientRequestError && error.kind === 'http' && [401, 403, 404, 409].includes(error.status ?? 0); export default function Home() { const activeRoom = useRef(null); const activeSeat = useRef(null); const mutationPending = useRef(false); const reconnectPending = useRef(false); const [checkingConnection, setCheckingConnection] = useState(false); const reads = useRef(new Map>()); const knownVersions = useRef(new Map()); const recovery = useRef<{ room: string; epoch: number } | null>(null); const epoch = useRef(0); const botAttempt = useRef<{ room: string; version: number } | null>(null); const [restoring, setRestoring] = useState(true); const [restoreFailed, setRestoreFailed] = useState(false); const [needsReconcile, setNeedsReconcile] = useState(false); const [botRecovery, setBotRecovery] = useState(false); const [game, setGame] = useState(null); const [busy, setBusy] = useState(false); const [connection, setConnection] = useState(''); const [selected, setSelected] = useState('atreides'); const [advanced, setAdvanced] = useState(false); const [expansions, setExpansions] = useState([]); const [name, setName] = useState(''); const [code, setCode] = useState(''); const [notice, setNotice] = useState(''); const seatClaimUncertain = useRef(false); const [claimUncertain, setClaimUncertain] = useState(false); const pendingEntry = useRef(null); const [entryAttempt, setEntryAttempt] = useState( null, ); const [entryProblem, setEntryProblem] = useState(''); const [showEntryRecovery, setShowEntryRecovery] = useState(false); const [showHandover, setShowHandover] = useState(false); const [handoverPending, setHandoverPending] = useState(true); const [aiPermissionPending, setAiPermissionPending] = useState(true); const [abandonAcknowledged, setAbandonAcknowledged] = useState(false); const f = faction(selected); const roomCode = game?.code; // This function is deliberately GET-only, including WebMCP reads and recovery. const refresh = useCallback( async (room: string, fresh = false): Promise => { let pending = reads.current.get(room); if (pending && !fresh) return pending; // Reconciliation must start after previously issued reads have settled. while (pending) { await pending.catch(() => {}); pending = reads.current.get(room); } const readEpoch = epoch.current; const request = requestJson(`/api/rooms/${room}`, { cache: 'no-store', }) .then((data) => { if ( activeRoom.current !== room || readEpoch !== epoch.current || data.version < (knownVersions.current.get(room) ?? -1) ) throw new ClientRequestError( 'This read belongs to an older table or seat. Read the current table again.', 'aborted', ); activeSeat.current = data.me; knownVersions.current.set(room, data.version); setGame((previous) => !previous || previous.code !== data.code || data.version >= previous.version ? data : previous, ); setConnection(''); if ( recovery.current?.room === room && recovery.current.epoch === readEpoch ) { recovery.current = null; setNeedsReconcile(false); } if ( botAttempt.current?.room === room && ((!data.botsPending && data.version >= botAttempt.current.version) || data.version > botAttempt.current.version) ) { botAttempt.current = null; setBotRecovery(false); } return data; }) .finally(() => { reads.current.delete(room); }); reads.current.set(room, request); return request; }, [], ); const reconcile = useCallback( async (room: string) => { recovery.current = { room, epoch: ++epoch.current }; setNeedsReconcile(true); try { await refresh(room, true); } catch { if (activeRoom.current === room) setConnection( 'Reconnect to confirm the latest table before taking another action.', ); } }, [refresh], ); const advanceBots = useCallback( async (data: GameView, explicit = false) => { const room = data.code; if ( !data.botsPending || activeRoom.current !== room || data.me !== activeSeat.current || data.version < (knownVersions.current.get(room) ?? -1) || mutationPending.current || recovery.current ) return; if ( !explicit && botAttempt.current?.room === room && botAttempt.current.version === data.version ) return; const seat = activeSeat.current, requestEpoch = epoch.current; mutationPending.current = true; setBusy(true); botAttempt.current = { room, version: data.version }; try { const next = await requestJson(`/api/rooms/${room}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: data.version, action: { type: 'advanceBots' }, }), }); if ( activeRoom.current === room && activeSeat.current === seat && epoch.current === requestEpoch ) { knownVersions.current.set( room, Math.max(next.version, knownVersions.current.get(room) ?? -1), ); setGame((previous) => !previous || previous.code !== room || next.version >= previous.version ? next : previous, ); botAttempt.current = null; setBotRecovery(false); } } catch (error) { if ( activeRoom.current === room && activeSeat.current === seat && epoch.current === requestEpoch ) { // A confirmed rejection may be retried from a new read. An uncertain POST is never replayed. const rejected = error instanceof ClientRequestError && error.kind === 'http' && error.status === 409; if (rejected) botAttempt.current = null; else setBotRecovery(true); await reconcile(room); } } finally { mutationPending.current = false; setBusy(false); } }, [reconcile], ); useEffect(() => { // A one-use handover needs its exact receipt before any other seat flow. try { if (window.sessionStorage.getItem(HANDOVER_CLAIM_STORAGE_KEY) !== null) { // oxlint-disable-next-line react/react-compiler -- Restore private tab-scoped continuation after SSR. setShowHandover(true); setRestoring(false); return; } } catch { setShowHandover(true); setRestoring(false); return; } // A saved entry always gets an explicit retry screen. A GET with another // seat's cookie cannot prove which create/join operation completed. try { const saved = window.sessionStorage.getItem(ROOM_ENTRY_STORAGE_KEY); if (saved !== null) { const attempt = parseRoomEntry(saved); pendingEntry.current = attempt; // oxlint-disable-next-line react/react-compiler -- Hydrate an external tab-scoped recovery record after SSR. setEntryAttempt(attempt); setRestoring(false); return; } } catch { setEntryProblem( 'The saved room request could not be read safely. It has not been sent or deleted. Restore access to this tab’s storage and reload, or deliberately abandon it below.', ); setRestoring(false); return; } const room = new URLSearchParams(window.location.search).get('room'); let live = true; if (room && /^[A-Z2-9]{8}$/.test(room)) { activeRoom.current = room; // oxlint-disable-next-line react/react-compiler -- Restore server-owned state from the external invite URL; refresh awaits the network. void refresh(room) .catch((error: Error) => { if (live && activeRoom.current === room) { setCode(room); setNotice(error.message); if (inviteRejected(error)) activeRoom.current = null; else setRestoreFailed(true); } }) .finally(() => { if (live) setRestoring(false); }); } else { // oxlint-disable-next-line react/react-compiler -- The SSR-safe restoration placeholder resolves after checking the browser's invite URL. setRestoring(false); } return () => { live = false; }; }, [refresh]); useEffect(() => { if (!roomCode) return; let live = true; let timer: ReturnType; const tick = async () => { let nextPollMs = 2000; try { if ( live && document.visibilityState === 'visible' && !mutationPending.current ) { const data = await refresh(roomCode); // Authenticated reads resume the persisted queue. Poll often enough to // display each paced action without posting redundant wake-up mutations. nextPollMs = data.botsPending ? 500 : 2000; } } catch { if (live && activeRoom.current === roomCode) setConnection('Connection interrupted. Retrying…'); } finally { // Schedule after completion, so slow requests cannot stack polling cycles. if (live) timer = setTimeout(tick, nextPollMs); } }; timer = setTimeout(tick, 2000); return () => { live = false; clearTimeout(timer); }; }, [roomCode, refresh]); async function dispatchEntry( attempt: RoomEntryAttempt, firstDispatch = false, ) { if ( mutationPending.current || recovery.current || pendingEntry.current !== attempt || seatClaimUncertain.current ) return; const requestEpoch = ++epoch.current; mutationPending.current = true; setNotice(''); setBusy(true); try { const data = await requestJson(roomEntryUrl(attempt), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: attempt.bodyText, }); if (requestEpoch !== epoch.current || pendingEntry.current !== attempt) return; if ( !roomEntryConfirmed(attempt, data, firstDispatch) || data.version < (knownVersions.current.get(data.code) ?? -1) ) throw new ClientRequestError( 'The response did not confirm this saved room request. Its proof is retained; retry the same request or deliberately abandon it.', 'invalid-response', ); try { clearRoomEntry(window.sessionStorage); pendingEntry.current = null; setEntryAttempt(null); setEntryProblem(''); } catch { setNotice( 'Your seat is confirmed, but this tab could not confirm removal of its private retry proof. Review the saved request when storage is available.', ); } activeRoom.current = data.code; activeSeat.current = data.me; setHandoverPending(true); knownVersions.current.set(data.code, data.version); recovery.current = null; botAttempt.current = null; setBotRecovery(false); setNeedsReconcile(false); setShowEntryRecovery(false); setAbandonAcknowledged(false); setGame(data); window.history.replaceState(null, '', `/?room=${data.code}`); } catch (error) { if (requestEpoch === epoch.current && pendingEntry.current === attempt) { if (roomEntryRejectedBeforeCommit(firstDispatch, error)) { try { clearRoomEntry(window.sessionStorage); pendingEntry.current = null; setEntryAttempt(null); setEntryProblem(''); setNotice((error as Error).message); return; } catch { setNotice( `${(error as Error).message} Nothing was committed by this request, but removal of its saved proof could not be confirmed. Restore browser storage and reload, or explicitly abandon the saved request.`, ); return; } } const uncertain = requestMayHaveCompleted(error); setNotice( uncertain ? `${(error as Error).message} The request may have completed. Use the explicit retry to send its exact saved proof; it has not been sent again automatically.` : `${(error as Error).message} The saved request is retained. Retry it unchanged, or deliberately abandon it before changing your details.`, ); } } finally { mutationPending.current = false; setBusy(false); } } async function enter(join = false) { if ( mutationPending.current || recovery.current || pendingEntry.current || seatClaimUncertain.current || entryProblem ) return; let attempt: RoomEntryAttempt; try { attempt = createRoomEntry( { name, faction: selected, expansions, advanced }, join ? code : undefined, ); } catch { setNotice( 'Check your name, room code and faction before trying again. Nothing was sent.', ); return; } try { // Storage must succeed before dispatch. Never overwrite an unresolved proof. saveRoomEntry(window.sessionStorage, attempt); } catch { setEntryProblem( 'The request could not be safely saved in this tab. Nothing was sent. A saved record may still be present; restore access to browser storage and reload, or explicitly abandon the record below.', ); return; } pendingEntry.current = attempt; setEntryAttempt(attempt); setAbandonAcknowledged(false); await dispatchEntry(attempt, true); } function exitTable() { if ( handoverPending || aiPermissionPending || mutationPending.current || reconnectPending.current || seatClaimUncertain.current ) return; activeRoom.current = null; setHandoverPending(true); setAiPermissionPending(true); activeSeat.current = null; ++epoch.current; recovery.current = null; botAttempt.current = null; setNeedsReconcile(false); setBotRecovery(false); setConnection(''); setGame(null); setNotice(''); window.history.replaceState(null, '', '/'); } function abandonEntry() { if ( mutationPending.current || seatClaimUncertain.current || !abandonAcknowledged ) return; try { clearRoomEntry(window.sessionStorage); pendingEntry.current = null; setEntryAttempt(null); setEntryProblem(''); setShowEntryRecovery(false); setAbandonAcknowledged(false); setNotice( 'The saved retry proof was removed. This did not cancel a room or seat already created on the server.', ); } catch { setNotice( 'Removal of the saved proof could not be confirmed. Restore access to this tab’s storage before abandoning this request.', ); } } async function send(action: Action) { if (!game || mutationPending.current || recovery.current) return; const room = game.code, seat = game.me, requestEpoch = epoch.current; mutationPending.current = true; setBusy(true); setNotice(''); try { const data = await requestJson(`/api/rooms/${room}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version: game.version, action }), }); if ( activeRoom.current === room && activeSeat.current === seat && epoch.current === requestEpoch ) { knownVersions.current.set( room, Math.max(data.version, knownVersions.current.get(room) ?? -1), ); setGame((previous) => !previous || previous.code !== room || data.version >= previous.version ? data : previous, ); } } catch (error) { if ( activeRoom.current === room && activeSeat.current === seat && epoch.current === requestEpoch ) { const uncertain = requestMayHaveCompleted(error); setNotice( uncertain ? `${(error as Error).message} Your action may have completed. It has not been sent again; check the refreshed table.` : (error as Error).message, ); await reconcile(room); } } finally { mutationPending.current = false; setBusy(false); } } async function reconnect(resumeBots = false) { const room = activeRoom.current; if (!room || mutationPending.current || reconnectPending.current) return; reconnectPending.current = true; setCheckingConnection(true); setConnection('Checking the latest table…'); try { const data = await refresh(room, true); if (restoreFailed && activeRoom.current === room) { setRestoreFailed(false); setNotice(''); } if (resumeBots) await advanceBots(data, true); } catch (error) { if (activeRoom.current === room) { if (restoreFailed && inviteRejected(error)) { activeRoom.current = null; setRestoreFailed(false); setCode(room); setNotice((error as Error).message); setConnection(''); } else setConnection( 'Could not reconnect. Try again when the server is available.', ); } } finally { reconnectPending.current = false; setCheckingConnection(false); } } function controlUncertain(uncertain: boolean) { seatClaimUncertain.current = uncertain; setClaimUncertain(uncertain); } function controlPending(pending: boolean) { mutationPending.current = pending; setBusy(pending); } function restoredSeat(view: GameView, claim = false) { if ( !claim && (activeRoom.current !== view.code || activeSeat.current !== view.me) ) return; if (claim) { setHandoverPending(true); setAiPermissionPending(true); activeRoom.current = view.code; activeSeat.current = view.me; ++epoch.current; botAttempt.current = null; setBotRecovery(false); window.history.replaceState(null, '', `/?room=${view.code}`); } knownVersions.current.set( view.code, Math.max(view.version, knownVersions.current.get(view.code) ?? -1), ); setGame((previous) => claim || !previous || previous.code !== view.code || view.version >= previous.version ? view : previous, ); recovery.current = null; setNeedsReconcile(false); setRestoreFailed(false); setConnection(''); setNotice(''); } useEffect(() => { const ctx = ( document as Document & { modelContext?: { registerTool: (tool: unknown, options: unknown) => Promise; }; } ).modelContext; if (!ctx) return; const lifecycle = new AbortController(); try { Promise.resolve( ctx.registerTool( { name: 'read_dune_table', description: 'Read the current Dune table, including only information visible to this player.', inputSchema: { type: 'object', properties: {}, additionalProperties: false, }, annotations: { readOnlyHint: true, untrustedContentHint: true }, execute: () => roomCode ? refresh(roomCode) : { status: 'not_seated' }, }, { signal: lifecycle.signal }, ), ).catch(() => {}); } catch {} return () => lifecycle.abort(); }, [roomCode, refresh]); if (showHandover) return (
window.location.reload()} onRestored={(view) => { restoredSeat(view, true); setShowHandover(false); }} />
); if (game) return ( <>
{(entryAttempt || entryProblem) && (

A previous room request still has private retry proof saved in this tab. Opening this seat did not remove it.

)} restoredSeat(view)} /> restoredSeat(view)} /> restoredSeat(view)} />
{(notice || connection || needsReconcile || botRecovery) && ( {notice && {notice}} {connection && {connection}} {needsReconcile && ( Actions paused until the current table is confirmed. )} {(connection || needsReconcile) && ( )} {botRecovery && ( <> AI progress was not confirmed. Check the table before resuming. )} )} ); if (restoring || restoreFailed) return (
DUNEARRAKIS TABLE

{restoring ? 'Restoring your table' : 'Your table could not be restored'}

{restoring ? 'Checking your saved seat and the latest game state…' : connection || notice} {restoreFailed && !restoring && ( <>

Retry to check your saved seat and current game. This does not join a new seat or take a game action.

)}
); if (entryAttempt || entryProblem) return (
DUNEARRAKIS TABLE

{entryAttempt ? `Saved ${entryAttempt.kind === 'create' ? 'room creation' : 'room join'} request` : 'Saved request needs attention'}

{entryAttempt && (

{entryAttempt.kind === 'join' ? `Room ${entryAttempt.roomCode}. ` : ''} Prepared {new Date(entryAttempt.createdAt).toLocaleString()}.

)}

This tab keeps your retry details through refresh. Keep it open until the request is resolved; closing it or clearing browser data can remove those details.

A response may have been lost after the server created a room or seat. Retry sends the exact same request and private proof. It does not start a new attempt. No request is retried automatically.

{entryProblem &&

{entryProblem}

} {notice && {notice}} {entryAttempt && ( )}

Keep this proof until the request is resolved. Abandoning removes only this tab’s retry proof; it cannot undo a room or seat already created. You may lose access without a saved recovery kit, and a new attempt can create a duplicate room or seat.

A previously saved seat recovery kit can still be used. This keeps the room-entry proof; the recovered seat may be different.

{claimUncertain && ( Seat recovery may have completed. Retry the current recovery request below first. Room-entry retry, abandonment and navigation are paused to preserve its exact recovery details. )} {(showEntryRecovery || claimUncertain) && ( restoredSeat(view, true)} /> )}
); return (
DUNEARRAKIS TABLE
Multiplayer table{' '} Learn to play Rulebook
ARRAKIS 89.04° N / IMPERIAL SURVEY
POLAR SINK
{[ 'Arrakeen', 'Carthag', 'Tuek’s Sietch', 'Habbanya Sietch', 'Sietch Tabr', ].map((t, i) => (
{t}
))}
N
YOUR HOUSE

{f.name}

{f.description}

{f.spice}Starting spice 20Forces {f.revival}Free revival
THE SPICE MUST FLOW CLASSIC / 2019
); }