From 98b1b3261bc8fc434c8aea1b6b4bb12b3f7aa2be Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:31:02 +1000 Subject: [PATCH] Add administrative room close and reopen controls Preserve readable private tables, discussion and pending games while closure blocks new play, joins, messages, AI and seat mutations. Add live-authorized atomic closure audits, room-version/revision fences, dedicated controls and exact saved retries. Reopen with the saved pause/join settings and current credentials; restoring a removed but closed room keeps its AI stopped. Verified types, lint, 5356 offline tests, 52 HTTP tests and build. Seventeen dedicated authenticated HTTP groups and independent privacy/persistence review passed. Desktop/mobile browser QA confirmed one close after a lost response and refresh, persistent readable notices, retained private draft, mobile reopening and saved-seat refresh. All 2,001 baseline rooms, 4,243 seats and related records preserved. Positive production admin acceptance awaits authorized QA access; full admin and rules gates remain open. --- README.md | 4 +- app/admin/page.tsx | 16 +- app/api/admin/rooms/[code]/closure/route.ts | 35 + app/api/admin/rooms/[code]/control/route.ts | 2 +- app/api/admin/rooms/[code]/removal/route.ts | 2 +- app/api/rooms/[code]/control/route.ts | 2 +- app/api/rooms/[code]/route.ts | 4 +- app/page.tsx | 3 +- components/admin-room-closure.tsx | 147 ++ components/admin-room-controls.tsx | 4 +- components/admin-room-removal.tsx | 2 +- components/game-table.tsx | 27 +- components/room-control-notice.tsx | 5 + components/seat-ai-delegation.tsx | 9 +- components/seat-autopilot.tsx | 2 + components/seat-handover.tsx | 10 +- components/seat-recovery.tsx | 16 +- components/table-talk.tsx | 7 +- db/admin-closure.ts | 225 +++ db/admin-directory.ts | 13 +- db/admin-lifecycle.ts | 5 +- db/admin-lobby-configuration.ts | 6 +- db/admin-removal.ts | 6 +- db/rooms.ts | 64 +- db/schema.ts | 45 + db/table-talk.ts | 3 + docs/ADMIN_PANEL.md | 5 +- docs/ADMIN_ROOM_CLOSURE.md | 78 + docs/CURRENT_STATUS.md | 21 +- docs/IMPLEMENTATION_STATUS.md | 18 + drizzle/0013_admin_room_closure.sql | 28 + drizzle/meta/0013_snapshot.json | 1707 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + lib/admin-closure-client.ts | 64 + lib/admin-closure.ts | 63 + lib/admin-lobby-client.ts | 2 +- lib/admin-removal-client.ts | 2 +- lib/admin-removal.ts | 1 + lib/client-request.ts | 5 +- lib/room-control.ts | 1 + tests/admin-boundary-http.test.ts | 10 +- tests/admin-closure-client.test.ts | 84 + tests/admin-closure.test.ts | 851 +++++++++ tests/admin-directory.test.ts | 15 + tests/client-request.test.ts | 7 + tests/room-closure-runtime.test.ts | 598 +++++++ tests/seat-recovery-render.test.tsx | 11 + tools/admin-closure-verification.mjs | 95 ++ tools/verify-admin.mjs | 4 +- 49 files changed, 4255 insertions(+), 86 deletions(-) create mode 100644 app/api/admin/rooms/[code]/closure/route.ts create mode 100644 components/admin-room-closure.tsx create mode 100644 db/admin-closure.ts create mode 100644 docs/ADMIN_ROOM_CLOSURE.md create mode 100644 drizzle/0013_admin_room_closure.sql create mode 100644 drizzle/meta/0013_snapshot.json create mode 100644 lib/admin-closure-client.ts create mode 100644 lib/admin-closure.ts create mode 100644 tests/admin-closure-client.test.ts create mode 100644 tests/admin-closure.test.ts create mode 100644 tests/room-closure-runtime.test.ts create mode 100644 tests/seat-recovery-render.test.tsx create mode 100644 tools/admin-closure-verification.mjs diff --git a/README.md b/README.md index 3b6b48d9..5a349d40 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ an explicitly owned new host seat and initial AI players. [Configure lobby](docs/ADMIN_LOBBY_CONFIGURATION.md) manages supported rules, AI seats and host assignment without private seat access. [Remove or restore](docs/ADMIN_ROOM_REMOVAL.md) recoverably removes selected rooms; -use the Directory filter to find them for restoration. Close/end, archive, +use the Directory filter to find them for restoration. +[Close or reopen](docs/ADMIN_ROOM_CLOSURE.md) stops further play while preserving +readable tables and history, then resumes the same saved position. Archive, permanent deletion, participant support and backup tools are still being built. ## Saved seats and reconnecting diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 2194f1c2..555d8b69 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -11,6 +11,7 @@ import { FACTIONS } from '@/game/catalog'; import { AdminRoomControls } from '@/components/admin-room-controls'; import { AdminRoomCreation } from '@/components/admin-room-creation'; import { AdminLobbyControls } from '@/components/admin-lobby-controls'; +import { AdminRoomClosure } from '@/components/admin-room-closure'; import { AdminRoomRemoval } from '@/components/admin-room-removal'; import './admin.css'; @@ -31,6 +32,7 @@ export default function Administration() { const [confirmAll, setConfirmAll] = useState(false); const [selectedRoom, setSelectedRoom] = useState(null); const [selectedLobby, setSelectedLobby] = useState(null); + const [selectedClosure, setSelectedClosure] = useState(null); const [selectedRemoval, setSelectedRemoval] = useState(null); const [creatingRoom, setCreatingRoom] = useState(false); const [operationBusy, setOperationBusy] = useState(false); @@ -39,7 +41,7 @@ export default function Administration() { const clearSession = useCallback(() => { epoch.current++; setAccount(null); setDirectory(null); setConfirmAll(false); - setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setCreatingRoom(false); + setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setCreatingRoom(false); }, []); const accessChanged = useCallback(() => { clearSession(); @@ -115,7 +117,7 @@ export default function Administration() { : <>

Rooms

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

-
{account.role !== 'viewer' && } +
{account.role !== 'viewer' && }
{creatingRoom && account.role !== 'viewer' && setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy} @@ -132,7 +134,7 @@ export default function Administration() { + +
: pending ?
+

Saved request: {pending.closed ? 'Close' : 'Reopen'} room {code}.

Reason: {pending.reason}

+

The exact request stays in this tab across refresh and reopening these controls. Retrying confirms a completed change without applying it again. Current availability can differ if another administrator changed the room afterward.

+

After refreshing the page or signing in again, choose All rooms in the Directory filter to find this room and reopen these controls.

+ +
: room?.removed ?

Restore this removed room before changing its closure. Any saved close/reopen request can still be confirmed above.

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

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

+
+ {room.closed ? <> +

Reopen play in room {code}. Saved progress and seats are retained, and current credentials and access revocations remain in effect.

+

Reopening preserves the previous pause and joining settings. {room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.

+ : <> +

Close room {code} to further play. This stops joining, game decisions, AI, new messages and seat security changes. Existing players can still read their own table and discussion history.

+

Warn active players before interrupting their game. Closing preserves the exact pending game and seats. It does not award a winner. An administrator can reopen it later.

+ + } + + +
+
} + } +
; +} diff --git a/components/admin-room-controls.tsx b/components/admin-room-controls.tsx index ed5e1f55..a1fea3f1 100644 --- a/components/admin-room-controls.tsx +++ b/components/admin-room-controls.tsx @@ -133,7 +133,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat

Room {code}

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

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

+

Current status: {control.closed ? 'Closed' : 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.

@@ -146,7 +146,7 @@ export function AdminRoomControls({ accountId, code, canManage, onClose, onUpdat

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(); }}> + : control.closed ?

Reopen this room before changing pause or joining settings. Existing settings are preserved while closed.

: { event.preventDefault(); void submit(); }}> diff --git a/components/admin-room-removal.tsx b/components/admin-room-removal.tsx index b087e03b..96629f96 100644 --- a/components/admin-room-removal.tsx +++ b/components/admin-room-removal.tsx @@ -132,7 +132,7 @@ export function AdminRoomRemoval({ accountId, code, canManage, onClose, onUpdate
{room.removed ? <>

Restore ordinary access to room {code}. Saved progress and seats are retained, and current credentials and access revocations remain in effect.

-

Restoration preserves the previous pause and joining settings. {room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.

+

Restoration preserves the previous pause and joining settings. {room.closed && 'This room is also closed: restoration keeps it closed to further play until reopened.'} {room.closed ? 'AI remains stopped while closed.' : room.paused ? 'This room will remain paused.' : 'This room will resume with the existing AI pace.'} New joins will remain {room.joinLocked ? 'locked' : 'open'}.

: <>

Remove room {code} from ordinary access. This interrupts every player: private room access, joining, game actions, AI play, seat security changes and messages stop until restoration.

Warn active players before interrupting their game. This recoverable removal keeps the saved game and seats for restoration.

diff --git a/components/game-table.tsx b/components/game-table.tsx index aef008cf..18f0f31f 100644 --- a/components/game-table.tsx +++ b/components/game-table.tsx @@ -286,7 +286,7 @@ export function GameTable({ : [], ); const busy = - transportBusy || !!g.roomControl?.paused || !!me.autopilot || g.automaticContinuationPending; + transportBusy || (!!g.roomControl?.paused || !!g.roomControl?.closed) || !!me.autopilot || g.automaticContinuationPending; const traitorBattle = g.battle; const traitorBeneficiary = traitorBattle && @@ -1575,14 +1575,14 @@ export function GameTable({ - - - + + + - - - + + + {!g.nexusCards?.waiting.length && } @@ -1633,6 +1633,7 @@ export function GameTable({ Your faction