From 8c922b446b9bfccc1d836d7f320966b26b5e2d1a Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 22 Sep 2026 10:41:09 +1000 Subject: [PATCH] Add administrator pause and joining-lock controls Provide authenticated owner/operator room controls with live account binding, durable audit and exact saved retries. Pause gates human, bot and automatic writes while preserving recovery, discussion and taking back control. Resume keeps decisions and restores AI pacing. Add directory availability filters and player notices. Verified types, lint, 5223 offline tests, 52 HTTP tests and build. Independent persistence/privacy review and authenticated HTTP acceptance passed. Desktop/mobile browser QA confirmed lost-response refresh/retry makes one audit row and resume restores controls. Existing games preserved. Authenticated deployed admin acceptance remains pending explicit QA access confirmation. --- README.md | 7 +- app/admin/admin.css | 9 + app/admin/page.tsx | 56 +- app/api/admin/rooms/[code]/control/route.ts | 35 + app/api/rooms/[code]/control/route.ts | 2 +- app/api/rooms/[code]/route.ts | 4 +- app/page.tsx | 5 + components/admin-room-controls.tsx | 162 +++ components/game-table.tsx | 28 +- components/room-control-notice.tsx | 14 + components/seat-ai-delegation.tsx | 7 +- components/seat-autopilot.tsx | 7 +- db/admin-directory.ts | 21 +- db/admin-lifecycle.ts | 177 +++ db/rooms.ts | 128 +- db/schema.ts | 40 + docs/ADMIN_PANEL.md | 15 +- docs/ADMIN_ROOM_CONTROLS.md | 83 ++ docs/CURRENT_STATUS.md | 16 +- docs/IMPLEMENTATION_STATUS.md | 11 + drizzle/0009_admin_room_controls.sql | 26 + drizzle/meta/0009_snapshot.json | 1154 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + game/engine.ts | 1 + lib/admin-directory.ts | 2 + lib/admin-room-control-client.ts | 41 + lib/room-control.ts | 57 + tests/admin-boundary-http.test.ts | 10 +- tests/admin-directory.test.ts | 15 + tests/admin-lifecycle.test.ts | 941 +++++++++++++++ tests/admin-room-controls.test.tsx | 77 ++ tests/automatic-response-recovery.test.ts | 2 +- tests/bot-continuation.test.ts | 2 +- tests/bot-pacing.test.ts | 2 +- tests/paced-games.test.ts | 2 +- tests/seat-handover-store.test.ts | 16 +- tools/admin-lifecycle-verification.mjs | 97 ++ tools/verify-admin.mjs | 19 +- tools/verify-container.mjs | 2 +- 39 files changed, 3196 insertions(+), 104 deletions(-) create mode 100644 app/api/admin/rooms/[code]/control/route.ts create mode 100644 components/admin-room-controls.tsx create mode 100644 components/room-control-notice.tsx create mode 100644 db/admin-lifecycle.ts create mode 100644 docs/ADMIN_ROOM_CONTROLS.md create mode 100644 drizzle/0009_admin_room_controls.sql create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 lib/admin-room-control-client.ts create mode 100644 lib/room-control.ts create mode 100644 tests/admin-lifecycle.test.ts create mode 100644 tests/admin-room-controls.test.tsx create mode 100644 tools/admin-lifecycle-verification.mjs diff --git a/README.md b/README.md index 568f78d8..03dbe68b 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ For private NAS hosting and manually applied checkpoint image updates, see the with new games; the requested host port is 33046. The **Administration** link opens `/admin`, with separate administrator sign-in, -session controls and a searchable room directory. See [initial owner setup and -scope](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). Room creation, -lifecycle/removal, participant support and backup tools are still being built. +session controls and a searchable room directory. **Room controls** lets owners +and operators pause/resume rooms and lock/unlock new joins. See [room controls](docs/ADMIN_ROOM_CONTROLS.md) +and [initial owner setup](docs/ADMIN_PANEL.md#initial-owner-and-access-recovery). +Room creation, archive/removal, participant support and backup tools are still being built. ## Saved seats and reconnecting diff --git a/app/admin/admin.css b/app/admin/admin.css index 3600eda5..65626c45 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -30,11 +30,20 @@ .admin-confirm { border: 1px solid var(--border); padding: 16px; } .admin-confirm button { margin-right: 10px; } .admin-development { border-top: 1px solid var(--border); margin-top: 28px; padding-top: 20px; } +.admin-room-control { margin: 24px 0; padding: 24px; border: 1px solid var(--primary); border-radius: 8px; background: #171c16; max-width: 880px; } +.admin-room-control form { display: grid; gap: 16px; margin-top: 24px; } +.admin-room-control button { min-height: 44px; } +.admin-room-control label { display: grid; gap: 6px; } +.admin-room-control .admin-check { display: flex; align-items: center; gap: 12px; min-height: 44px; } +.admin-room-control .admin-check input { width: 22px; height: 22px; min-height: 22px; flex-shrink: 0; accent-color: var(--primary); } +.admin-room-control .admin-confirm { margin-top: 16px; } +.admin-room-control h2:focus-visible { outline: 2px solid var(--primary); outline-offset: 5px; } @media (max-width: 700px) { .admin-shell { padding: 20px 16px; } .admin-header, .admin-section-heading { align-items: start; flex-direction: column; gap: 16px; } .admin-header h1 { font-size: 1.8rem; } .admin-login { padding: 20px; margin: 24px 0; } + .admin-room-control { padding: 18px; } .admin-room-table { display: block; } .admin-room-table thead { display: none; } .admin-room-table tbody { display: block; } diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 983f4698..20cd9692 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -8,11 +8,12 @@ import { Shield, RefreshCw, LogOut } from 'lucide-react'; import { ClientRequestError, requestJson } from '@/lib/client-request'; import type { AdminDirectory } from '@/lib/admin-directory'; import { FACTIONS } from '@/game/catalog'; +import { AdminRoomControls } from '@/components/admin-room-controls'; import './admin.css'; type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' }; -type Filters = { q: string; status: string; rules: string; sort: string }; -const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent' }; +type Filters = { q: string; status: string; rules: string; sort: string; availability: string }; +const initialFilters: Filters = { q: '', status: 'all', rules: 'all', sort: 'recent', availability: 'all' }; const errorMessage = (error: unknown) => error instanceof Error ? error.message : 'The request could not be completed.'; export default function Administration() { @@ -25,12 +26,19 @@ export default function Administration() { const [filters, setFilters] = useState(initialFilters); const [applied, setApplied] = useState(initialFilters); const [confirmAll, setConfirmAll] = useState(false); + const [selectedRoom, setSelectedRoom] = useState(null); + const [operationBusy, setOperationBusy] = useState(false); + const controlsBusy = busy || operationBusy; const epoch = useRef(0); const clearSession = useCallback(() => { epoch.current++; setAccount(null); setDirectory(null); setConfirmAll(false); - setBusy(false); + setBusy(false); setSelectedRoom(null); }, []); + const accessChanged = useCallback(() => { + clearSession(); + setNotice('Administrator access changed. Sign in again to confirm any saved room-settings request.'); + }, [clearSession]); const load = useCallback(async (next: Filters, page = 1) => { const current = ++epoch.current; setBusy(true); setNotice(''); @@ -55,7 +63,7 @@ export default function Administration() { }, [load]); async function sessionAction(action: 'login' | 'logout' | 'logoutAll') { - if (busy) return; + if (controlsBusy) return; setBusy(true); setNotice(''); const submittedKey = key.trim(); setKey(''); @@ -84,7 +92,7 @@ export default function Administration() {
Arrakis Table

{account &&
{account.name} {account.role} -
} + }
{notice && {notice}} {restoring ? Checking administrator access… : !account ? @@ -94,27 +102,33 @@ export default function Administration() {
{ event.preventDefault(); void sessionAction('login'); }}> setKey(event.target.value)} required disabled={busy} /> - + maxLength={130} value={key} onChange={event => setKey(event.target.value)} required disabled={controlsBusy} /> +

First setup: the server owner creates a personal key using the installation instructions. Access is closed until an administrator is provisioned.

: <>

Rooms

Public room details. Cards, sealed choices and private discussion stay hidden.

-
+
{ event.preventDefault(); void load(filters); }}> - - + - - + - +
+ {selectedRoom && setSelectedRoom(null)} onDenied={accessChanged} + onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{!directory ? {busy ? 'Loading rooms…' : 'Use Refresh to load rooms.'} : <>

{directory.total.toLocaleString()} {directory.total === 1 ? 'room' : 'rooms'} match

@@ -123,6 +137,8 @@ export default function Administration() { Room & playersRulesProgressLast game change {directory.rooms.map(room => {room.code} {room.status} + {room.control.paused && Paused} + {room.control.joinLocked && Joins locked}

Host: {room.players.find(p => p.id === room.host)?.name ?? 'Unavailable'}

{room.players.length} {room.players.length === 1 ? 'player' : 'players'}
    {room.players.map(player =>
  • {player.name} · {FACTIONS.find(f => f.id === player.faction)?.name ?? player.faction} · {player.control === 'ai' ? 'AI' : player.control === 'autopilot' ? 'Human seat on AI' : 'Human'}
  • )}
@@ -133,23 +149,23 @@ export default function Administration() { {room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}

{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}

-

Version {room.version}

+

Version {room.version}

)}
} }

Your access

Sessions expire after eight hours. Sign out everywhere to end all your current administrator sessions, including this browser. Your personal access key remains valid.

- {!confirmAll ? : -

End all your administrator sessions now?

} + {!confirmAll ? : +

End all your administrator sessions now?

}
-

Administration preview: room creation, lifecycle controls, participant support and backup tools are still being implemented.

+

Administration preview: pause/resume and joining locks are available. Room creation, archive/removal, participant support and backup tools are still being implemented.

} ; } diff --git a/app/api/admin/rooms/[code]/control/route.ts b/app/api/admin/rooms/[code]/control/route.ts new file mode 100644 index 00000000..4092113f --- /dev/null +++ b/app/api/admin/rooms/[code]/control/route.ts @@ -0,0 +1,35 @@ +import { env, waitUntil } from 'cloudflare:workers'; +import { AdminError, requireAdmin } from '@/db/admin-access'; +import { applyAdminRoomControl, readAdminRoomControl } from '@/db/admin-lifecycle'; +import { resumeRoom } from '@/db/room-continuation'; +import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http'; +import { validAdminRoomControlInput } from '@/lib/room-control'; + +function roomCode(request: Request) { + const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? ''; + if (!/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the eight-character room code.', 400); + return code; +} + +export async function GET(request: Request) { + try { + const identity = await requireAdmin(env.DB, adminToken(request)); + if (request.headers.has('X-Dune-Admin-Id') && request.headers.get('X-Dune-Admin-Id') !== identity.id) + throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401); + return adminResponse(await readAdminRoomControl(env.DB, identity, roomCode(request))); + } catch (error) { return adminFailure(error); } +} + +export async function POST(request: Request) { + try { + const body = await adminBody(request, env.DUNE_PUBLIC_ORIGIN); + const identity = await requireAdmin(env.DB, adminToken(request), ['owner', 'operator']); + if (request.headers.get('X-Dune-Admin-Id') !== identity.id) + throw new AdminError('The signed-in administrator changed. Reload administration before continuing.', 401); + if (!validAdminRoomControlInput(body)) throw new AdminError('Provide room settings and a reason of 1–300 characters.', 400); + const code = roomCode(request); + const result = await applyAdminRoomControl(env.DB, identity, code, body); + if (!result.paused) waitUntil(resumeRoom(code)); + return adminResponse(result); + } catch (error) { return adminFailure(error); } +} diff --git a/app/api/rooms/[code]/control/route.ts b/app/api/rooms/[code]/control/route.ts index 5a3657d6..0cbf8689 100644 --- a/app/api/rooms/[code]/control/route.ts +++ b/app/api/rooms/[code]/control/route.ts @@ -85,7 +85,7 @@ export async function POST(req: Request) { ownerId: input.ownerId, grantId: input.grantId, }); - if (result.view.botsPending || needsAutomaticRoomRecovery(result.view)) + if (!result.view.roomControl?.paused && (result.view.botsPending || needsAutomaticRoomRecovery(result.view))) waitUntil(resumeRoom(code)); return Response.json(result, { headers: noStore }); } diff --git a/app/api/rooms/[code]/route.ts b/app/api/rooms/[code]/route.ts index 100e1dbb..2d457002 100644 --- a/app/api/rooms/[code]/route.ts +++ b/app/api/rooms/[code]/route.ts @@ -56,7 +56,7 @@ export async function GET(req: Request) { const view = await readSeatView(code, id); // Reconnect resumes persisted automatic effects/acknowledgements and AI work // after an interruption. Actual human choices remain engine-owned decisions. - if (view.botsPending || needsAutomaticRoomRecovery(view)) + if (!view.roomControl?.paused && (view.botsPending || needsAutomaticRoomRecovery(view))) waitUntil(resumeRoom(code)); return Response.json(view, { headers: { 'Cache-Control': 'no-store' }, @@ -132,7 +132,7 @@ export async function POST(req: Request) { ) throw new RuleError('The action is invalid.'); const view = await act(code, id, body.version, body.action); - if (view.botsPending || needsAutomaticRoomRecovery(view)) + if (!view.roomControl?.paused && (view.botsPending || needsAutomaticRoomRecovery(view))) waitUntil(resumeRoom(code)); return Response.json(view, { headers: { 'Cache-Control': 'no-store' }, diff --git a/app/page.tsx b/app/page.tsx index 13318c95..b6a2d443 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -170,6 +170,7 @@ export default function Home() { const room = data.code; if ( !data.botsPending || + data.roomControl?.paused || activeRoom.current !== room || data.me !== activeSeat.current || data.version < (knownVersions.current.get(room) ?? -1) || @@ -501,6 +502,10 @@ export default function Home() { async function send(action: Action) { if (!game || mutationPending.current || recovery.current) return; + if (game.roomControl?.paused && !(action.type === 'setAutopilot' && action.difficulty === null)) { + setNotice('An administrator paused this room. Game decisions will be available when the room resumes.'); + return; + } const room = game.code, seat = game.me, requestEpoch = epoch.current; diff --git a/components/admin-room-controls.tsx b/components/admin-room-controls.tsx new file mode 100644 index 00000000..ed5e1f55 --- /dev/null +++ b/components/admin-room-controls.tsx @@ -0,0 +1,162 @@ +'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 { clearAdminRoomRequest, newAdminRoomRequest, readAdminRoomRequest, retainAdminRoomRequest, saveAdminRoomRequest } from '@/lib/admin-room-control-client'; +import type { AdminRoomControlInput, RoomControl } from '@/lib/room-control'; + +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 room settings could not be loaded.'; +function controlResponse(value: RoomControl): RoomControl { + if (!value || typeof value.paused !== 'boolean' || typeof value.joinLocked !== 'boolean' || + !Number.isSafeInteger(value.revision) || value.revision < 0 || + !(value.updatedAt === null || Number.isSafeInteger(value.updatedAt))) throw new Error('The server returned unreadable room settings.'); + return value; +} + +export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) { + const [control, setControl] = useState(null); + const [paused, setPaused] = useState(false); + const [joinLocked, setJoinLocked] = useState(false); + 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 [loading, setLoading] = useState(true); + const alive = useRef(false); + const title = useRef(null); + const inFlight = useRef(false); + const url = `/api/admin/rooms/${code}/control`; + + useEffect(() => { + alive.current = true; + title.current?.focus(); + let canceled = false; + try { + const saved = readAdminRoomRequest(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 request needs confirmation. Review the current settings, 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 = controlResponse(result); + if (!canceled) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); } + }) + .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 = controlResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })); + if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); 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); } + } + + async function submit(saved?: AdminRoomControlInput) { + if (inFlight.current || !control || !canManage || storageProblem || (!saved && !confirmed)) return; + let input: AdminRoomControlInput; + try { + input = saved ?? newAdminRoomRequest(control, paused, joinLocked, reason); + saveAdminRoomRequest(window.sessionStorage, accountId, code, input); + } catch (error) { setNotice(message(error)); return; } + inFlight.current = true; setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving room settings…'); + try { + const result = await requestJson(url, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), + }); + const current = controlResponse(result); + if (typeof result.replayed !== 'boolean') throw new Error('The server did not confirm this operation.'); + clearAdminRoomRequest(window.sessionStorage, accountId, code); + if (alive.current) { + setPending(null); setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); + setConfirmed(false); setReason(''); + setNotice(result.replayed ? 'The original request was already recorded. Current settings are shown below.' : 'Room settings saved.'); + onUpdated(); + } + } catch (error) { + const uncertain = requestMayHaveCompleted(error); + let cleared = false; + if (!retainAdminRoomRequest(error)) { + try { clearAdminRoomRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Keep exact retry after storage failure. */ } + } + if (alive.current) { + if (cleared) setPending(null); + setConfirmed(false); + setNotice(uncertain ? `${message(error)} The change may have been saved. Use Retry saved request to confirm; no new request has been sent.` : message(error)); + if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); + else { + try { + const current = controlResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } })); + if (alive.current) { setControl(current); setPaused(current.paused); setJoinLocked(current.joinLocked); } + } catch { /* Preserve the original outcome and retry instructions. */ } + } + } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } + } + + function discardUnreadableRecord() { + if (!confirmed || busy) return; + try { + clearAdminRoomRequest(window.sessionStorage, accountId, code); + setStorageProblem(false); setConfirmed(false); + setNotice('Local retry record removed. This did not undo any server change. Review the current settings before continuing.'); + } catch (error) { setNotice(message(error)); } + } + + const changed = control && (control.paused !== paused || control.joinLocked !== joinLocked); + return
+

Room {code}

+ {notice && {notice}} + {loading ? Loading current room settings… : !control ? : <> +

Current status: {control.paused ? 'Paused' : 'Running'} · New joins {control.joinLocked ? 'locked' : 'open'}.

+

Pause stops player decisions, automatic effects and AI play. The pending game stays saved. Discussion, seat recovery and taking back control remain available.

+

A joining lock blocks new players. Existing players can reconnect or recover their seats.

+ + {!canManage ?

Your viewer role can inspect settings. An owner or operator can change them.

: 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.

+ + +
: pending ?
+

Saved request: {pending.paused ? 'pause' : 'resume'} room {code}; {pending.joinLocked ? 'lock' : 'open'} new joins.

+

Reason: {pending.reason}

+

The exact request is kept in this tab across refresh. Retrying confirms an already completed operation without applying it again.

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

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

+
+

This affects every player in room {code}. Warn active players before interrupting their game. Resuming keeps the pending choices and restarts AI pacing.

+ + +
+
} + } +
; +} diff --git a/components/game-table.tsx b/components/game-table.tsx index 7775549f..aef008cf 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -86,6 +86,7 @@ import { } from './richese-allied-no-field'; import { presenceByLocation } from '@/game/force-presence'; import { AutomaticActionNotice } from './automatic-action-notice'; +import { RoomControlNotice } from './room-control-notice'; import { TableSounds } from './table-sounds'; import { EcazSpice } from './ecaz-spice'; import { VictoryProgress } from './victory-progress'; @@ -285,7 +286,7 @@ export function GameTable({ : [], ); const busy = - transportBusy || !!me.autopilot || g.automaticContinuationPending; + transportBusy || !!g.roomControl?.paused || !!me.autopilot || g.automaticContinuationPending; const traitorBattle = g.battle; const traitorBeneficiary = traitorBattle && @@ -954,6 +955,7 @@ export function GameTable({ +
{g.status === 'lobby' @@ -1573,14 +1575,14 @@ export function GameTable({ - - - + + + - - - + + + {!g.nexusCards?.waiting.length && } @@ -3035,9 +3037,9 @@ export function GameTable({ ))} ) : g.decision.kind === 'sukRescue' ? ( - + ) : g.decision.kind === 'rihani' ? ( - + ) : g.decision.kind === 'battleLosses' ? ( <>

@@ -3142,13 +3144,13 @@ export function GameTable({ })} ) : g.decision.kind === 'discoveryEntry' ? ( - + ) : g.decision.kind === 'ecologicalStorm' ? ( - + ) : g.decision.kind === 'discoveryDiscard' ? ( - + ) : g.decision.kind === 'greatMakerVote' || g.decision.kind === 'greatMakerRide' ? ( - + ) : g.decision.kind === 'wormProtection' ? ( <>

diff --git a/components/room-control-notice.tsx b/components/room-control-notice.tsx new file mode 100644 index 00000000..3c729723 --- /dev/null +++ b/components/room-control-notice.tsx @@ -0,0 +1,14 @@ +import type { RoomControl } from '@/lib/room-control'; + +/** Public operational status only; administrator reasons and identity stay private. */ +export function RoomControlNotice({ control }: { control?: RoomControl }) { + if (!control?.paused && !control?.joinLocked) return null; + return

+ {control.paused ? <> +

Room paused by an administrator

+

Player decisions and automatic play are paused. Your game and pending choices are saved. An administrator can resume this room.

+

You can still read the table, use discussion, protect or recover your seat, and take back control from AI.

+ :

New joins are locked

} + {control.joinLocked &&

New players cannot join. Existing players can reconnect and recover their saved seats.

} +
; +} diff --git a/components/seat-ai-delegation.tsx b/components/seat-ai-delegation.tsx index e2301394..43688f22 100644 --- a/components/seat-ai-delegation.tsx +++ b/components/seat-ai-delegation.tsx @@ -73,6 +73,7 @@ export function SeatAiDelegation({ const received = grants.filter((grant) => grant.delegateId === game.me); const started = ['setup', 'playing'].includes(game.status); const locked = disabled || busy || !loaded; + const paused = !!game.roomControl?.paused; if (!own || own.bot) return null; function store(next: SeatAiDelegateRequest | null) { @@ -254,7 +255,8 @@ export function SeatAiDelegation({ )} )} - {started && !own.autopilot && peers.length > 0 && ( + {paused &&

The room is paused. New AI permissions and activation will be available after it resumes. Revoking permission and confirming saved requests remain available.

} + {started && !paused && !own.autopilot && peers.length > 0 && (