From c0cc4de9e19a7df0c97db40f681c2c05f5af8c0f Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:27:20 +1000 Subject: [PATCH] Add neutral administrator lobby configuration Configure supported rules/modules and AI seats or assign an existing human as host without granting private-seat access. Use genuine unused-lobby checks, existing engine actions, live authorization/version/pause/seat fences and atomic audit receipts. Preserve exact account-bound retries across refresh and confirm completed operations after game start without resetting play. Verified types, lint, 5260 offline tests, 52 HTTP tests and build. Eleven authenticated HTTP groups and independent privacy/persistence review passed. Browser QA confirmed one AI and audit after a lost response and refresh/retry, persisted rules and AI configuration, and readable 1280/390px layouts. All existing rooms and seats preserved. Actual authenticated production administrator acceptance remains pending authorized QA operator access. --- README.md | 6 +- app/admin/page.tsx | 13 +- app/api/admin/rooms/[code]/lobby/route.ts | 31 + components/admin-lobby-controls.tsx | 188 +++ db/admin-lobby-configuration.ts | 294 +++++ db/schema.ts | 21 + docs/ADMIN_LOBBY_CONFIGURATION.md | 84 ++ docs/ADMIN_PANEL.md | 7 +- docs/ADMIN_ROOM_CREATION.md | 6 +- docs/CURRENT_STATUS.md | 18 +- docs/IMPLEMENTATION_STATUS.md | 11 + drizzle/0011_admin_lobby_configuration.sql | 15 + drizzle/meta/0011_snapshot.json | 1339 ++++++++++++++++++++ drizzle/meta/_journal.json | 7 + game/admin-lobby-configuration.ts | 301 +++++ lib/admin-lobby-client.ts | 82 ++ lib/admin-lobby-configuration.ts | 109 ++ tests/admin-boundary-http.test.ts | 10 +- tests/admin-lobby-client.test.ts | 61 + tests/admin-lobby-configuration.test.ts | 940 ++++++++++++++ tools/admin-lobby-verification.mjs | 91 ++ tools/verify-admin.mjs | 4 +- 22 files changed, 3620 insertions(+), 18 deletions(-) create mode 100644 app/api/admin/rooms/[code]/lobby/route.ts create mode 100644 components/admin-lobby-controls.tsx create mode 100644 db/admin-lobby-configuration.ts create mode 100644 docs/ADMIN_LOBBY_CONFIGURATION.md create mode 100644 drizzle/0011_admin_lobby_configuration.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100644 game/admin-lobby-configuration.ts create mode 100644 lib/admin-lobby-client.ts create mode 100644 lib/admin-lobby-configuration.ts create mode 100644 tests/admin-lobby-client.test.ts create mode 100644 tests/admin-lobby-configuration.test.ts create mode 100644 tools/admin-lobby-verification.mjs diff --git a/README.md b/README.md index 414b8641..e7ece97a 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,10 @@ 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). Owners/operators can [create a configured lobby](docs/ADMIN_ROOM_CREATION.md) with -an explicitly owned new host seat and initial AI players. Neutral lobby management, -archive/removal, participant support and backup tools are still being built. +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. Archive/removal, 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 8d8958d1..67595c82 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -10,6 +10,7 @@ import type { AdminDirectory } from '@/lib/admin-directory'; 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 './admin.css'; type Account = { id: string; name: string; role: 'owner' | 'operator' | 'viewer' }; @@ -28,6 +29,7 @@ export default function Administration() { const [applied, setApplied] = useState(initialFilters); const [confirmAll, setConfirmAll] = useState(false); const [selectedRoom, setSelectedRoom] = useState(null); + const [selectedLobby, setSelectedLobby] = useState(null); const [creatingRoom, setCreatingRoom] = useState(false); const [operationBusy, setOperationBusy] = useState(false); const controlsBusy = busy || operationBusy; @@ -35,7 +37,7 @@ export default function Administration() { const clearSession = useCallback(() => { epoch.current++; setAccount(null); setDirectory(null); setConfirmAll(false); - setBusy(false); setSelectedRoom(null); setCreatingRoom(false); + setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setCreatingRoom(false); }, []); const accessChanged = useCallback(() => { clearSession(); @@ -111,7 +113,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} @@ -135,6 +137,9 @@ export default function Administration() { {selectedRoom && setSelectedRoom(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} + {selectedLobby && setSelectedLobby(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

@@ -155,7 +160,7 @@ 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}

)}}
-

Administration preview: create a lobby with a new host seat, pause/resume rooms and manage joining locks. Archive/removal, participant support and backup tools are still being implemented.

+

Administration preview: create and configure lobbies, assign hosts, pause/resume rooms and manage joining locks. Archive/removal, participant support and backup tools are still being implemented.

} ; } diff --git a/app/api/admin/rooms/[code]/lobby/route.ts b/app/api/admin/rooms/[code]/lobby/route.ts new file mode 100644 index 00000000..6db5b659 --- /dev/null +++ b/app/api/admin/rooms/[code]/lobby/route.ts @@ -0,0 +1,31 @@ +import { env } from 'cloudflare:workers'; +import { AdminError, requireAdmin } from '@/db/admin-access'; +import { configureAdminLobby, readAdminLobby } from '@/db/admin-lobby-configuration'; +import { validAdminLobbyInput } from '@/lib/admin-lobby-configuration'; +import { adminBody, adminFailure, adminResponse, adminToken } from '@/lib/admin-http'; + +function code(request: Request) { + const code = new URL(request.url).pathname.split('/').at(-2)?.toUpperCase() ?? ''; + if (code.length !== 8 || !/^[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 readAdminLobby(env.DB, identity, code(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 (!validAdminLobbyInput(body)) throw new AdminError('Choose a lobby change, revision and operational reason.', 400); + return adminResponse(await configureAdminLobby(env.DB, identity, code(request), body)); + } catch (error) { return adminFailure(error); } +} diff --git a/components/admin-lobby-controls.tsx b/components/admin-lobby-controls.tsx new file mode 100644 index 00000000..108edfca --- /dev/null +++ b/components/admin-lobby-controls.tsx @@ -0,0 +1,188 @@ +'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 [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; 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) 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.

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

+ + +
+
} + } +
; +} diff --git a/db/admin-lobby-configuration.ts b/db/admin-lobby-configuration.ts new file mode 100644 index 00000000..625eb648 --- /dev/null +++ b/db/admin-lobby-configuration.ts @@ -0,0 +1,294 @@ +import { AdminError, type AdminIdentity } from './admin-access'; +import { + applyAdminLobbyAction, + projectAdminLobby, + AdminLobbyRuleError, +} from '../game/admin-lobby-configuration'; +import { + validAdminLobbyInput, + type AdminLobbyInput, + type AdminLobbyResult, + type AdminLobbyView, +} from '../lib/admin-lobby-configuration'; +import type { Game } from '../game/engine'; + +const authority = `SELECT a.role FROM admin_sessions s JOIN admin_accounts a ON a.id = s.admin_id + WHERE s.token_hash = ? AND a.id = ? AND a.enabled = 1 AND s.revoked_at IS NULL + AND s.expires_at > ? AND s.generation = a.session_generation + AND a.role IN ('owner','operator','viewer')`; +const mutationAuthority = authority + " AND a.role IN ('owner','operator')"; +const roomSelect = `SELECT r.code,r.state,r.version,COALESCE(c.paused,0) AS paused, + COALESCE(c.join_locked,0) AS join_locked,COALESCE(c.revision,0) AS control_revision,c.updated_at AS control_updated_at, + (SELECT json_group_array(DISTINCT s.player_id) FROM seats s WHERE s.room_code = r.code AND s.revoked = 0) AS active_humans + FROM rooms r LEFT JOIN room_controls c ON c.room_code = r.code WHERE r.code = ? AND EXISTS (${authority})`; +const receiptSelect = `SELECT operation_id,actor_admin_id,room_code,request_hash,applied_version + FROM admin_lobby_operations WHERE operation_id = ? AND EXISTS (${mutationAuthority})`; +type Row = { + code: string; + state: string; + version: number; + paused: number; + join_locked: number; + control_revision: number; + control_updated_at: number | null; + active_humans: string; +}; +type Receipt = { + operation_id: string; + actor_admin_id: string; + room_code: string; + request_hash: string; + applied_version: number; +}; +function credentials(identity: AdminIdentity, code: string, now: number) { + if ( + !identity || + typeof identity.id !== 'string' || + typeof identity.sessionHash !== 'string' || + identity.sessionHash.length !== 64 || + !/^[0-9a-f]{64}$/.test(identity.sessionHash) + ) + throw new AdminError('Administrator sign-in required.', 401); + if ( + typeof code !== 'string' || + code.length !== 8 || + !/^[A-Z0-9]{8}$/.test(code) + ) + throw new AdminError('Use an eight-character room code.', 400); + return [identity.sessionHash, identity.id, now]; +} +function authorize(result: D1Result, mutation = false) { + const role = (result.results[0] as { role: string } | undefined)?.role; + if (!role) throw new AdminError('Administrator sign-in required.', 401); + if (mutation && role === 'viewer') + throw new AdminError( + 'Your administrator role does not permit this action.', + 403, + ); +} +function decode(row: Row) { + let game: unknown = null; + try { + game = JSON.parse(row.state); + } catch { + /* Unreadable saves get an allowlisted, non-editable projection. */ + } + const activeHumans = new Set(JSON.parse(row.active_humans)); + const control = { + paused: row.paused === 1, + joinLocked: row.join_locked === 1, + revision: row.control_revision, + updatedAt: row.control_updated_at, + }; + return { + game: game as Game, + activeHumans, + view: projectAdminLobby(row.code, row.version, game, activeHumans, control), + }; +} +function missing(code: string, version: number): AdminLobbyView { + return { + code, + version, + status: 'unreadable', + editable: false, + blockedReason: 'The room is no longer available.', + host: null, + advanced: false, + techTokens: false, + strongholdCards: false, + players: [], + control: { paused: false, joinLocked: false, revision: 0, updatedAt: null }, + }; +} +function replay( + receipt: Receipt | undefined, + row: Row | undefined, + identity: AdminIdentity, + code: string, + requestHash: string, + replayed: boolean, +): AdminLobbyResult | null { + if (!receipt) return null; + if ( + receipt.actor_admin_id !== identity.id || + receipt.room_code !== code || + receipt.request_hash !== requestHash + ) + throw new AdminError( + 'This lobby operation is bound to a different administrator or request.', + 409, + ); + return { + operationId: receipt.operation_id, + appliedVersion: receipt.applied_version, + replayed, + lobby: row ? decode(row).view : missing(code, receipt.applied_version), + }; +} +const auditConfiguration = (view: AdminLobbyView) => + JSON.stringify({ + host: view.host, + advanced: view.advanced, + techTokens: view.techTokens, + strongholdCards: view.strongholdCards, + players: view.players.map( + ({ id, name, faction, bot, position, ready }) => ({ + id, + name, + faction, + bot, + position, + ready, + }), + ), + }); + +export async function readAdminLobby( + database: D1Database, + identity: AdminIdentity, + code: string, + now = Date.now(), +): Promise { + const args = credentials(identity, code, now); + const results = await database.batch([ + database.prepare(authority).bind(...args), + database.prepare(roomSelect).bind(code, ...args), + ]); + authorize(results[0]); + const row = results[1].results[0] as Row | undefined; + if (!row) throw new AdminError('Room not found.', 404); + return decode(row).view; +} + +export async function configureAdminLobby( + database: D1Database, + identity: AdminIdentity, + code: string, + input: AdminLobbyInput, + now = Date.now(), +): Promise { + const args = credentials(identity, code, now); + if (!validAdminLobbyInput(input)) + throw new AdminError( + 'Choose one supported lobby change, its current version, a fresh operation identifier and an operational reason.', + 400, + ); + const operationId = input.operationId, + expectedVersion = input.expectedVersion, + reason = input.reason.trim(); + // Canonicalize keys so equivalent JSON requests bind the same exact intent. + const action = Object.fromEntries( + Object.entries(input.action).sort(([a], [b]) => a.localeCompare(b)), + ) as AdminLobbyInput['action']; + const actionText = JSON.stringify(action); + const digest = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode( + JSON.stringify({ expectedVersion, action, reason }), + ), + ); + const requestHash = Array.from(new Uint8Array(digest), (byte) => + byte.toString(16).padStart(2, '0'), + ).join(''); + const before = await database.batch([ + database.prepare(authority).bind(...args), + database.prepare(receiptSelect).bind(operationId, ...args), + database.prepare(roomSelect).bind(code, ...args), + ]); + authorize(before[0], true); + const row = before[2].results[0] as Row | undefined; + const previous = replay( + before[1].results[0] as Receipt | undefined, + row, + identity, + code, + requestHash, + true, + ); + if (previous) return previous; + if (!row) throw new AdminError('Room not found.', 404); + const snapshot = decode(row); + if (!snapshot.view.editable) + throw new AdminError( + snapshot.view.blockedReason ?? 'This lobby cannot be configured.', + 409, + ); + if (row.version !== expectedVersion) + throw new AdminError( + 'The room changed. Refresh before choosing a new lobby operation.', + 409, + ); + snapshot.game.version = row.version; + let next: Game; + try { + next = applyAdminLobbyAction(snapshot.game, action, snapshot.activeHumans); + } catch (error) { + throw new AdminError( + error instanceof AdminLobbyRuleError + ? error.message + : 'This saved lobby cannot be configured.', + 409, + ); + } + next.version = row.version + 1; + const afterView = projectAdminLobby( + code, + next.version, + next, + snapshot.activeHumans, + snapshot.view.control, + ); + const hostTarget = action.type === 'assignHost' ? action.target : ''; + const results = await database.batch([ + database.prepare(authority).bind(...args), + database + .prepare(`UPDATE rooms SET state = ?, version = version + 1, updated_at = ? WHERE code = ? AND version = ? + AND json_extract(state, '$.status') = 'lobby' AND EXISTS (${mutationAuthority}) + AND NOT EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1) + AND NOT EXISTS (SELECT 1 FROM admin_lobby_operations WHERE operation_id = ?) + AND (? = '' OR EXISTS (SELECT 1 FROM seats WHERE room_code = rooms.code AND player_id = ? AND revoked = 0))`) + .bind( + JSON.stringify(next), + now, + code, + expectedVersion, + ...args, + operationId, + hostTarget, + hostTarget, + ), + database + .prepare(`INSERT INTO admin_lobby_operations(operation_id,actor_admin_id,room_code,request_hash,expected_version,applied_version,action,reason,before_configuration,after_configuration,created_at) + SELECT ?,?,?,?,?,?,?,?,?,?,? WHERE changes() = 1`) + .bind( + operationId, + identity.id, + code, + requestHash, + expectedVersion, + next.version, + actionText, + reason, + auditConfiguration(snapshot.view), + auditConfiguration(afterView), + now, + ), + database.prepare(receiptSelect).bind(operationId, ...args), + database.prepare(roomSelect).bind(code, ...args), + ]); + authorize(results[0], true); + const committed = replay( + results[3].results[0] as Receipt | undefined, + results[4].results[0] as Row | undefined, + identity, + code, + requestHash, + results[1].meta.changes !== 1, + ); + if (committed) return committed; + throw new AdminError( + 'The room, pause setting, host recipient or administrator access changed. Refresh before choosing a new lobby operation.', + 409, + ); +} diff --git a/db/schema.ts b/db/schema.ts index 90779b82..f6518838 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -88,6 +88,27 @@ export const adminRoomCreations = sqliteTable( ], ); +// Durable neutral lobby operation receipts and safe before/after audit metadata. +export const adminLobbyOperations = sqliteTable( + 'admin_lobby_operations', + { + operationId: text('operation_id').primaryKey(), + actorAdminId: text('actor_admin_id').notNull(), + roomCode: text('room_code').notNull(), + requestHash: text('request_hash').notNull(), + expectedVersion: integer('expected_version').notNull(), + appliedVersion: integer('applied_version').notNull(), + action: text('action').notNull(), + reason: text('reason').notNull(), + beforeConfiguration: text('before_configuration').notNull(), + afterConfiguration: text('after_configuration').notNull(), + createdAt: integer('created_at').notNull(), + }, + (table) => [ + index('admin_lobby_operations_room').on(table.roomCode, table.createdAt), + ], +); + export const seatRecoveryKeys = sqliteTable( 'seat_recovery_keys', { diff --git a/docs/ADMIN_LOBBY_CONFIGURATION.md b/docs/ADMIN_LOBBY_CONFIGURATION.md new file mode 100644 index 00000000..25778f4d --- /dev/null +++ b/docs/ADMIN_LOBBY_CONFIGURATION.md @@ -0,0 +1,84 @@ +# Configuring lobbies from Administration + +Choose **Configure lobby** in the administrator room directory. All administrator +roles can inspect the current public roster, rules, AI difficulties, circles and +readiness. Owners and operators can change Basic/Advanced preview, Tech Tokens, +Stronghold Cards, add/remove/configure AI players and assign the host to an +existing human participant with active saved-seat access. + +These controls grant no player seat. The previous host keeps their credentials +and pieces after reassignment; every human must ready again. Host assignment +changes lobby permissions, not private ownership. Reserved human seats, support +for started-game host changes and full participant administration remain pending. + +Configuration supports genuine unused lobbies of the six classic factions and +the existing optional Tech Tokens/Stronghold Cards. A paused room must be resumed +first. A joining lock does not prevent configuration. Advanced remains explicitly +unfinished and requires the ordinary preview-start acknowledgement. Tech Tokens +require three players at start; Stronghold Cards require Advanced rules. Changing +to Basic clears unused Stronghold Cards. Normal readiness, player-count and +expansion gates still apply; this is not a general saved-state editor. + +## Confirmation and recovery + +Every change shows its scope and requires an operational reason and confirmation. +Messages remain visible while the user reads them. The exact operation, version, +action and reason are saved in this tab before sending, scoped to the signed-in +administrator and room. Unknown responses keep **Retry saved lobby request**; +refresh and reopen that room's configuration to confirm the same request. +No new request overwrites an unresolved one. + +An exact retry returns the original applied version and current public settings. +It does not reapply an older setting, reset readiness or undo later play. This +also works after a completed request's room starts, pauses or becomes unavailable. +A stale first request fails and requires a refreshed explicit choice. An expired +or switched administrator session retains the original account's retry record. +Corrupt local records require deliberate discard after inspecting current settings; +discarding local data does not undo any change saved on the server. + +## Authority, preservation and privacy + +The [endpoint](../app/api/admin/rooms/[code]/lobby/route.ts) requires a live +administrator session. Writes additionally require owner/operator authority, +the configured origin, bounded strict input and the displayed administrator ID. +The [database transaction](../db/admin-lobby-configuration.ts) repeats live +authorization, the saved game version, lobby status and pause checks at commit. +Host reassignment also fences the target's current seat access. Concurrent player +actions, starts, joins or support operations cannot be overwritten. + +The [focused game adapter](../game/admin-lobby-configuration.ts) checks the entire +unused runtime and physical player pieces against genuine setup. Malformed, +unsupported or mismatched room saves are read-only with generic diagnostics. +Saved-state version metadata may legitimately lag its authoritative database +version after seat or lifecycle operations; the latter controls concurrency. +Existing engine actions calculate rules/modules/AI changes. New public history +uses generic administrator attribution; actual actor and reason stay in audit. + +The additive [migration](../drizzle/0011_admin_lobby_configuration.sql) records +the operation, actor, room, request hash, versions, reason and public before/after +configuration atomically with the game update. Exact requests are bound to their +original actor and intent. Game and seat records are never deleted or reset. +Responses use an explicit public projection, independent of GameView, with no +private cards, choices, credentials or cookies. Read/replay remains available +independently of permission to make a new lobby change. + +## Verification + +Focused regressions cover all supported actions, genuine starts, readiness and +piece preservation, malformed saves, private projection, exact concurrent retries, +transaction rollback, live authority and races with starts, pause, joins and seat +revocation. Client regressions cover strict matching confirmation, account/room +isolation, corrupt storage and replay after play has begun. + +`tools/verify-admin.mjs` uses a dedicated QA operator and creates one additional +two-human lobby for neutral configuration. It exercises all seven action families, +both unchanged seat cookies, new-host readiness/start and exact saved retry after +start. Every QA room remains saved. All eleven authenticated HTTP groups passed. +Local browser acceptance confirmed one AI seat and one audit record after losing +a successful response, refreshing and retrying. Rules and AI faction/difficulty/ +circle changes survived another refresh. Desktop (1280px) and mobile (390px) +controls had 16px form text, 44px action buttons and no horizontal overflow. +No browser console errors were recorded. Source-bound checks and independent +review accompany the checkpoint. Container and actual deployed checks +are separate; positive production administrator acceptance requires explicitly +authorized QA operator access and remains open until recorded. diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 0b733008..0f2ec2eb 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -11,7 +11,7 @@ recovery and persistence systems where suitable. | --- | --- | --- | | Administrator access | Personal access keys, server-enforced owner/operator/viewer roles, persistent eight-hour sessions, individual/all-session sign-out and operator provisioning/revocation. A room host is not a site administrator. Account-management UI and further operational permissions remain. | Prototyped | | Room directory | Search room codes/public player names, filter status/rules/availability, sort and paginate; show host, roster, modules, game change time, pause/join flags and bounded decision ownership. Detailed setup/shared-window ownership remains. | Prototyped | -| Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. Neutral administration of lobby configuration, reserved human seats and reassignment to another existing player remain. | Prototyped, partial | +| Create and configure | [Create a lobby](ADMIN_ROOM_CREATION.md) with an explicitly owned new host seat, Basic/Advanced preview and initial AI configuration; invite humans and use ordinary lobby controls/voluntary host-seat handover. [Neutral lobby configuration](ADMIN_LOBBY_CONFIGURATION.md) adds rules/modules, AI seats and host assignment to an existing human without private access. Reserved human seats remain. | Prototyped, partial | | Lifecycle and removal | [Pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md) have controls, durable audit, exact retries and player/AI enforcement. Close/end, archive, restore and recoverable/permanent removal remain missing. | Prototyped, partial | | Participant support | Remove disruptive participants or revoke access, manage appropriate restrictions, assist saved-seat recovery and host reassignment, and use supported AI takeover/replacement without losing forces, cards or decisions. | Missing | | Saved-game operations | Create/list/download backups, validate imports, restore a selected checkpoint safely, and diagnose or resume interrupted automatic work through authoritative game actions. | Missing | @@ -38,6 +38,8 @@ Owners/operators may pause/resume rooms and lock/unlock new joins through account, never accepted from a client. Owners/operators can also [create a configured lobby](ADMIN_ROOM_CREATION.md), with explicit new-host ownership and saved exact retries. +[Configure lobby](ADMIN_LOBBY_CONFIGURATION.md) changes supported rules, AI seats +and the host without granting private seat access. Disabling an account invalidates its sessions; later re-enabling it cannot restore them. The database refuses to disable, demote or delete the final enabled owner. No default key, public account creation or host-to-administrator promotion exists. @@ -106,7 +108,8 @@ sessions of that QA account. Never supply a human operator's normal key. It chec authentication, cookie properties, ordinary-host denial, directory privacy, session revocation and unchanged game/seat continuation. Operator/owner QA keys also create dedicated rooms for joining locks, pause/takeback/resume, -administrator creation, invitations and voluntary host handover. Private reports retain +administrator creation, invitations, voluntary host handover and neutral lobby +configuration with existing-human host assignment and a real game start. Private reports retain room IDs and safe outcomes, never keys/cookies. The container verifier provisions its own disposable QA account and checks admin-session continuity across restart and replacement, followed by the HTTP flow and HTTPS-proxy cookie behavior. diff --git a/docs/ADMIN_ROOM_CREATION.md b/docs/ADMIN_ROOM_CREATION.md index d6bf7a08..a92308d7 100644 --- a/docs/ADMIN_ROOM_CREATION.md +++ b/docs/ADMIN_ROOM_CREATION.md @@ -14,9 +14,9 @@ set up for another person, use the existing **Pass your seat to another player** flow in that lobby; the host player identity and room progress survive transfer. This workflow grants no access to any existing participant's seat. -This is a connected room-creation prototype, not complete neutral administrator -lobby management. Creating reserved human seats, assigning a different existing -participant as host and managing lobbies without a playing seat remain part of +Use [Configure lobby](ADMIN_LOBBY_CONFIGURATION.md) to manage rules and AI seats +or assign another existing human as host without a playing seat. Reserved human +seats and remaining administration still belong to [the full panel requirements](ADMIN_PANEL.md). ## Saved confirmation diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 76623d41..238d1be8 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,11 +6,19 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work -Current work adds [administrator room creation](ADMIN_ROOM_CREATION.md): a new -explicitly owned human host seat, supported rules/modules, initial AI seats, -invitation joining and exact saved retries. Creation uses ordinary engine setup -and preserves normal readiness/start gates. Neutral administrator configuration, -reserved human seats and reassignment to existing participants remain unfinished. +Current work adds [neutral administrator lobby configuration](ADMIN_LOBBY_CONFIGURATION.md): +supported rules/modules, AI seat management and host assignment to an existing +human without private seat access. Exact saved requests, live authority and +version fences preserve concurrent play and saved continuation. Reserved human +seats, full participant support, archive/removal and backups remain unfinished. + +Checkpoint `e7d4efc` added [administrator room creation](ADMIN_ROOM_CREATION.md) +and passed types/lint, 5,240 offline tests, 52 HTTP tests and build, with independent +review and eight dedicated administrator HTTP groups. It is deployed on the NAS: +container verification passed, migration 0010 applied, all eight prior rooms and +ten seats were preserved, and a saved seat restored through public HTTPS and the +browser. Anonymous administrator creation remains denied. Positive production +administrator acceptance still requires explicitly authorized QA operator access. Checkpoint `8c922b4` added [pause/resume and joining locks](ADMIN_ROOM_CONTROLS.md) and passed types/lint, 5,223 offline tests, 52 HTTP tests and build, with independent diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 05048840..588219d6 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,16 @@ # Dune implementation status +## 22 September 2026 — Neutral administrator lobby configuration + +Added [rules/modules, AI configuration and existing-human host assignment](ADMIN_LOBBY_CONFIGURATION.md) +through public administrator controls. Genuine unused-lobby admission, existing +engine calculations, atomic live authorization/version fences and durable audit +preserve normal start gates, physical pieces and private seats. Exact tab-scoped +requests confirm without reapplying after a lost response, refresh or later game +start. Independent malformed-save review, focused regressions, actual HTTP/browser +checks and required source-bound checks accompany the checkpoint. Full admin +scope and authenticated production acceptance remain unfinished. + ## 22 September 2026 — Administrator room creation Added [configured lobby creation](ADMIN_ROOM_CREATION.md) with explicit new-host diff --git a/drizzle/0011_admin_lobby_configuration.sql b/drizzle/0011_admin_lobby_configuration.sql new file mode 100644 index 00000000..9096522f --- /dev/null +++ b/drizzle/0011_admin_lobby_configuration.sql @@ -0,0 +1,15 @@ +CREATE TABLE `admin_lobby_operations` ( + `operation_id` text PRIMARY KEY NOT NULL, + `actor_admin_id` text NOT NULL, + `room_code` text NOT NULL, + `request_hash` text NOT NULL, + `expected_version` integer NOT NULL, + `applied_version` integer NOT NULL, + `action` text NOT NULL, + `reason` text NOT NULL, + `before_configuration` text NOT NULL, + `after_configuration` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `admin_lobby_operations_room` ON `admin_lobby_operations` (`room_code`,`created_at`); \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json new file mode 100644 index 00000000..2725ec02 --- /dev/null +++ b/drizzle/meta/0011_snapshot.json @@ -0,0 +1,1339 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "2b84bd41-2fd8-4407-99b3-a6a8d88b3a92", + "prevId": "05bc871d-07db-45ed-9501-8d65bbce33c6", + "tables": { + "admin_accounts": { + "name": "admin_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_accounts_key_hash_unique": { + "name": "admin_accounts_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "admin_accounts_role": { + "name": "admin_accounts_role", + "value": "\"admin_accounts\".\"role\" IN ('owner', 'operator', 'viewer')" + }, + "admin_accounts_enabled": { + "name": "admin_accounts_enabled", + "value": "\"admin_accounts\".\"enabled\" IN (0, 1)" + }, + "admin_accounts_generation": { + "name": "admin_accounts_generation", + "value": "\"admin_accounts\".\"session_generation\" >= 0" + } + } + }, + "admin_audit": { + "name": "admin_audit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_admin_id": { + "name": "target_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + } + }, + "indexes": { + "admin_audit_created_at": { + "name": "admin_audit_created_at", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_lobby_operations": { + "name": "admin_lobby_operations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_version": { + "name": "expected_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_configuration": { + "name": "before_configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_configuration": { + "name": "after_configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_lobby_operations_room": { + "name": "admin_lobby_operations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_audit": { + "name": "admin_room_audit", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'room_control'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_paused": { + "name": "before_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_join_locked": { + "name": "before_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_paused": { + "name": "after_paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_join_locked": { + "name": "after_join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_audit_room": { + "name": "admin_room_audit_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_room_creations": { + "name": "admin_room_creations", + "columns": { + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "actor_admin_id": { + "name": "actor_admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_creations_session_hash_unique": { + "name": "admin_room_creations_session_hash_unique", + "columns": [ + "session_hash" + ], + "isUnique": true + }, + "admin_room_creations_room": { + "name": "admin_room_creations_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_sessions": { + "name": "admin_sessions", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "admin_id": { + "name": "admin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "admin_sessions_id_unique": { + "name": "admin_sessions_id_unique", + "columns": [ + "id" + ], + "isUnique": true + }, + "admin_sessions_account": { + "name": "admin_sessions_account", + "columns": [ + "admin_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "admin_sessions_admin_id_admin_accounts_id_fk": { + "name": "admin_sessions_admin_id_admin_accounts_id_fk", + "tableFrom": "admin_sessions", + "tableTo": "admin_accounts", + "columnsFrom": [ + "admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_controls": { + "name": "room_controls", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "paused": { + "name": "paused", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "join_locked": { + "name": "join_locked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_controls_room_code_rooms_code_fk": { + "name": "room_controls_room_code_rooms_code_fk", + "tableFrom": "room_controls", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_controls_paused": { + "name": "room_controls_paused", + "value": "\"room_controls\".\"paused\" IN (0, 1)" + }, + "room_controls_join_locked": { + "name": "room_controls_join_locked", + "value": "\"room_controls\".\"join_locked\" IN (0, 1)" + }, + "room_controls_revision": { + "name": "room_controls_revision", + "value": "\"room_controls\".\"revision\" >= 0" + } + } + }, + "room_entry_receipts": { + "name": "room_entry_receipts", + "columns": { + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_entry_receipts_room_code_rooms_code_fk": { + "name": "room_entry_receipts_room_code_rooms_code_fk", + "tableFrom": "room_entry_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "room_messages": { + "name": "room_messages", + "columns": { + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_session_hash": { + "name": "sender_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_name": { + "name": "sender_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sender_faction": { + "name": "sender_faction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_id": { + "name": "recipient_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recipient_name": { + "name": "recipient_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "room_messages_room_id": { + "name": "room_messages_room_id", + "columns": [ + "room_code", + "id" + ], + "isUnique": true + }, + "room_messages_channel": { + "name": "room_messages_channel", + "columns": [ + "room_code", + "recipient_id", + "sequence" + ], + "isUnique": false + }, + "room_messages_sender": { + "name": "room_messages_sender", + "columns": [ + "room_code", + "sender_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "room_messages_room_code_rooms_code_fk": { + "name": "room_messages_room_code_rooms_code_fk", + "tableFrom": "room_messages", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "code": { + "name": "code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "rooms_updated_at": { + "name": "rooms_updated_at", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_ai_delegations": { + "name": "seat_ai_delegations", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant_id": { + "name": "grant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delegate_id": { + "name": "delegate_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_session_hash": { + "name": "owner_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delegate_session_hash": { + "name": "delegate_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "seat_ai_delegations_grant_id_unique": { + "name": "seat_ai_delegations_grant_id_unique", + "columns": [ + "grant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_ai_delegations_room_code_rooms_code_fk": { + "name": "seat_ai_delegations_room_code_rooms_code_fk", + "tableFrom": "seat_ai_delegations", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_ai_delegations_room_code_owner_id_grant_id_pk": { + "columns": [ + "room_code", + "owner_id", + "grant_id" + ], + "name": "seat_ai_delegations_room_code_owner_id_grant_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_handover_claim_receipts": { + "name": "seat_handover_claim_receipts", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "offer_hash": { + "name": "offer_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_fence": { + "name": "claim_fence", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "seat_handover_claim_receipts_claim_fence_unique": { + "name": "seat_handover_claim_receipts_claim_fence_unique", + "columns": [ + "claim_fence" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_handover_claim_receipts_room_code_rooms_code_fk": { + "name": "seat_handover_claim_receipts_room_code_rooms_code_fk", + "tableFrom": "seat_handover_claim_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk": { + "columns": [ + "room_code", + "player_id", + "operation_hash" + ], + "name": "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_handover_offers": { + "name": "seat_handover_offers", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "offer_hash": { + "name": "offer_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "issuer_session_hash": { + "name": "issuer_session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "claim_operation_hash": { + "name": "claim_operation_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "seat_handover_offers_offer_hash_unique": { + "name": "seat_handover_offers_offer_hash_unique", + "columns": [ + "offer_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "seat_handover_offers_room_code_rooms_code_fk": { + "name": "seat_handover_offers_room_code_rooms_code_fk", + "tableFrom": "seat_handover_offers", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_handover_offers_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_handover_offers_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_recovery_keys": { + "name": "seat_recovery_keys", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recovery_hash": { + "name": "recovery_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "current_operation_hash": { + "name": "current_operation_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_recovery_keys_room_code_rooms_code_fk": { + "name": "seat_recovery_keys_room_code_rooms_code_fk", + "tableFrom": "seat_recovery_keys", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_recovery_keys_room_code_player_id_pk": { + "columns": [ + "room_code", + "player_id" + ], + "name": "seat_recovery_keys_room_code_player_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seat_recovery_receipts": { + "name": "seat_recovery_receipts", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_hash": { + "name": "operation_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recovery_hash": { + "name": "recovery_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_hash": { + "name": "session_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seat_recovery_receipts_room_code_rooms_code_fk": { + "name": "seat_recovery_receipts_room_code_rooms_code_fk", + "tableFrom": "seat_recovery_receipts", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "seat_recovery_receipts_room_code_player_id_operation_hash_pk": { + "columns": [ + "room_code", + "player_id", + "operation_hash" + ], + "name": "seat_recovery_receipts_room_code_player_id_operation_hash_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "seats": { + "name": "seats", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "player_id": { + "name": "player_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "seats_room_code_rooms_code_fk": { + "name": "seats_room_code_rooms_code_fk", + "tableFrom": "seats", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e20b8da9..01faf730 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1790038013633, "tag": "0010_admin_room_creation", "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1790039439940, + "tag": "0011_admin_lobby_configuration", + "breakpoints": true } ] } \ No newline at end of file diff --git a/game/admin-lobby-configuration.ts b/game/admin-lobby-configuration.ts new file mode 100644 index 00000000..e4cb8232 --- /dev/null +++ b/game/admin-lobby-configuration.ts @@ -0,0 +1,301 @@ +import { applyAction, createGame, newPlayer, type Game } from './engine'; +import { FACTIONS } from './catalog'; +import { DIFFICULTIES } from './bot-profiles'; +import { createTechTokens } from './tech-tokens'; +import { createStrongholdCards } from './stronghold-cards'; +import type { + AdminLobbyAction, + AdminLobbyView, +} from '../lib/admin-lobby-configuration'; +import type { RoomControl } from '../lib/room-control'; + +const unavailable = 'This saved lobby needs inspection before configuration.'; +export class AdminLobbyRuleError extends Error {} +const reject = () => { + throw new AdminLobbyRuleError(unavailable); +}; +const record = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +function same(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((value, index) => same(value, b[index])) + ); + if (!record(a) || !record(b)) return false; + const aKeys = Object.keys(a).filter((key) => a[key] !== undefined), + bKeys = Object.keys(b).filter((key) => b[key] !== undefined); + return ( + aKeys.length === bKeys.length && + aKeys.every((key) => Object.hasOwn(b, key) && same(a[key], b[key])) + ); +} + +/** The same legacy/default player-circle allocation used by ordinary lobby actions. */ +function positions(game: Game) { + const result: Record = Object.create(null); + const occupied = new Set(); + if ( + game.playerPositions !== undefined && + (!record(game.playerPositions) || + Object.keys(game.playerPositions).some( + (id) => !game.players.some((player) => player.id === id), + )) + ) + reject(); + for (const player of game.players) { + const position = game.playerPositions?.[player.id]; + if (position === undefined) continue; + if ( + !Number.isInteger(position) || + position < 1 || + position > 6 || + occupied.has(position) + ) + reject(); + result[player.id] = position; + occupied.add(position); + } + for (const player of game.players) { + if (result[player.id] !== undefined) continue; + const position = [1, 2, 3, 4, 5, 6].find( + (candidate) => !occupied.has(candidate), + ); + if (position === undefined) reject(); + result[player.id] = position!; + occupied.add(position!); + } + return result; +} + +/** Compare native unused pieces and the complete runtime against a fresh game. + * Unknown runtime/private fields fail closed; diagnostics never name those fields. */ +export function freshAdminLobby(game: Game): boolean { + try { + if ( + game.status !== 'lobby' || + typeof game.advanced !== 'boolean' || + !Array.isArray(game.players) || + game.players.length < 1 || + game.players.length > 6 || + !Array.isArray(game.expansions) || + game.expansions.length || + !Array.isArray(game.log) || + !game.log.every( + (entry) => + entry && + Number.isSafeInteger(entry.seq) && + entry.seq > 0 && + typeof entry.text === 'string', + ) || + new Set(game.players.map((player) => player.id)).size !== + game.players.length || + new Set(game.players.map((player) => player.faction)).size !== + game.players.length || + (game.botsPending !== undefined && game.botsPending !== false) || + game.botNextActionAt !== undefined + ) + return false; + const host = game.players.find((player) => player.id === game.host); + if (!host || host.bot) return false; + for (const player of game.players) { + if ( + !player || + typeof player.id !== 'string' || + !player.id || + player.id.length > 80 || + typeof player.name !== 'string' || + typeof player.ready !== 'boolean' || + !FACTIONS.some( + (faction) => + faction.id === player.faction && faction.expansion === 'base', + ) || + (player.bot !== undefined && !DIFFICULTIES.includes(player.bot)) + ) + return false; + const expected = newPlayer(player.id, player.name, player.faction); + expected.ready = player.ready; + if (player.bot) expected.bot = player.bot; + // Old saved lobbies may omit this zero-valued counter, as ordinary start permits. + if (player.freeForcesRevived === undefined) + delete (expected as Partial).freeForcesRevived; + if (!same(player, expected)) return false; + } + positions(game); + if (game.techTokens != null && !same(game.techTokens, createTechTokens())) + return false; + if ( + game.strongholdCards != null && + (!game.advanced || !same(game.strongholdCards, createStrongholdCards())) + ) + return false; + if ( + game.homeworlds != null || + game.nexusCards != null || + game.leaderSkills != null || + game.ecazTreachery != null || + (game.discoveryEnabled !== undefined && game.discoveryEnabled !== false) + ) + return false; + const expected = createGame(game.code, host, game.advanced); + const configuration = new Set([ + 'code', + 'version', + 'host', + 'players', + 'playerPositions', + 'advanced', + 'expansions', + 'log', + 'botsPending', + 'botNextActionAt', + 'techTokens', + 'strongholdCards', + 'homeworlds', + 'nexusCards', + 'leaderSkills', + 'ecazTreachery', + 'discoveryEnabled', + ]); + for (const key of new Set([...Object.keys(game), ...Object.keys(expected)])) + if ( + !configuration.has(key) && + !same( + Object.hasOwn(game, key) + ? (game as unknown as Record)[key] + : undefined, + Object.hasOwn(expected, key) + ? (expected as unknown as Record)[key] + : undefined, + ) + ) + return false; + return true; + } catch { + return false; + } +} + +export function projectAdminLobby( + code: string, + version: number, + state: unknown, + activeHumans: ReadonlySet, + control: RoomControl, +): AdminLobbyView { + const base: AdminLobbyView = { + code, + version, + status: 'unreadable', + editable: false, + blockedReason: unavailable, + host: null, + advanced: false, + techTokens: false, + strongholdCards: false, + players: [], + control, + }; + try { + const game = state as Game; + if ( + !record(state) || + !['lobby', 'setup', 'playing', 'finished'].includes(game.status) || + !Array.isArray(game.players) || + game.players.length < 1 || + game.players.length > 6 || + game.players.some( + (player) => + !player || + typeof player.id !== 'string' || + !player.id || + player.id.length > 80 || + typeof player.name !== 'string' || + player.name.length > 32 || + !FACTIONS.some((faction) => faction.id === player.faction) || + (player.bot !== undefined && !DIFFICULTIES.includes(player.bot)), + ) + ) + return base; + const circles = positions(game); + const supported = game.code === code && freshAdminLobby(game); + return { + ...base, + status: game.status, + advanced: game.advanced === true, + techTokens: !!game.techTokens, + strongholdCards: !!game.strongholdCards, + host: game.players.some((player) => player.id === game.host) + ? game.host + : null, + editable: supported && !control.paused, + blockedReason: + game.status !== 'lobby' + ? 'Lobby configuration is available only before the game starts.' + : !supported + ? unavailable + : control.paused + ? 'Resume the room before configuring its lobby.' + : null, + players: game.players.map((player) => ({ + id: player.id, + name: player.name, + faction: player.faction, + bot: player.bot ?? null, + position: circles[player.id], + ready: player.ready === true, + hostEligible: supported && !player.bot && activeHumans.has(player.id), + })), + }; + } catch { + return base; + } +} + +export function applyAdminLobbyAction( + game: Game, + action: AdminLobbyAction, + activeHumans: ReadonlySet, +): Game { + if (!freshAdminLobby(game)) reject(); + let next: Game; + if (action.type === 'assignHost') { + const target = game.players.find((player) => player.id === action.target); + if (!target || target.bot || !activeHumans.has(target.id)) + throw new AdminLobbyRuleError( + 'Choose an existing human player with active saved-seat access.', + ); + if (target.id === game.host) + throw new AdminLobbyRuleError('That player already hosts this room.'); + next = structuredClone(game); + next.host = target.id; + next.players.forEach((player) => { + player.ready = !!player.bot; + }); + } else { + try { + next = applyAction(game, game.host, action); + } catch { + throw new AdminLobbyRuleError( + 'That lobby change is unavailable. Check the selected rules, factions, AI seat and player circle.', + ); + } + } + if (!freshAdminLobby(next)) reject(); + // Ordinary engine actions retain their calculation, but must not impersonate + // the human host in the public chronicle. Administrator identity/reason stay in audit. + next.log = [ + ...structuredClone(game.log), + { + seq: (game.log.at(-1)?.seq ?? 0) + 1, + text: + action.type === 'assignHost' + ? 'An administrator reassigned the room host. Human readiness was cleared.' + : 'An administrator updated the lobby configuration.', + }, + ]; + return next; +} diff --git a/lib/admin-lobby-client.ts b/lib/admin-lobby-client.ts new file mode 100644 index 00000000..22ccf8ad --- /dev/null +++ b/lib/admin-lobby-client.ts @@ -0,0 +1,82 @@ +import { randomId } from './random-id'; +import { validAdminLobbyInput, type AdminLobbyAction, type AdminLobbyInput, type AdminLobbyView, type AdminLobbyResult } from './admin-lobby-configuration'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { FACTIONS } from '../game/catalog'; + +type Storage = Pick; +const key = (account: string, code: string) => `dune.admin-lobby-request.v1:${account}:${code}`; +const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); +const keys = (value: Record, names: string[]) => Object.keys(value).length === names.length && names.every(name => Object.hasOwn(value, name)); +const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0; + +export function validAdminLobbyView(value: unknown): value is AdminLobbyView { + if (!object(value) || !keys(value, ['code', 'version', 'status', 'editable', 'blockedReason', 'host', 'advanced', 'techTokens', 'strongholdCards', 'players', 'control']) || + typeof value.code !== 'string' || value.code.length !== 8 || !/^[A-Z2-9]{8}$/.test(value.code) || !integer(value.version) || + !['lobby', 'setup', 'playing', 'finished', 'unreadable'].includes(String(value.status)) || typeof value.editable !== 'boolean' || + !(value.blockedReason === null || typeof value.blockedReason === 'string') || !(value.host === null || typeof value.host === 'string') || + typeof value.advanced !== 'boolean' || typeof value.techTokens !== 'boolean' || typeof value.strongholdCards !== 'boolean' || + !Array.isArray(value.players) || value.players.length > 6 || !object(value.control)) return false; + const control = value.control; + return keys(control, ['paused', 'joinLocked', 'revision', 'updatedAt']) && typeof control.paused === 'boolean' && typeof control.joinLocked === 'boolean' && + integer(control.revision) && (control.updatedAt === null || integer(control.updatedAt)) && value.players.every(player => + object(player) && keys(player, ['id', 'name', 'faction', 'bot', 'position', 'ready', 'hostEligible']) && + typeof player.id === 'string' && typeof player.name === 'string' && FACTIONS.some(faction => faction.id === player.faction) && + (player.bot === null || DIFFICULTIES.some(difficulty => difficulty === player.bot)) && integer(player.position) && player.position >= 1 && player.position <= 6 && + typeof player.ready === 'boolean' && typeof player.hostEligible === 'boolean'); +} + +export function adminLobbyResponse(value: unknown, code: string): AdminLobbyView { + if (!validAdminLobbyView(value) || value.code !== code) throw new Error('The server returned unreadable lobby settings.'); + return value; +} + +export function adminLobbyConfirmation(value: unknown, code: string, input: AdminLobbyInput): AdminLobbyResult { + if (!object(value) || !keys(value, ['operationId', 'replayed', 'appliedVersion', 'lobby']) || value.operationId !== input.operationId || + typeof value.replayed !== 'boolean' || !integer(value.appliedVersion) || value.appliedVersion !== input.expectedVersion + 1) + throw new Error('The server did not confirm this lobby request.'); + const lobby = adminLobbyResponse(value.lobby, code); + if (lobby.version < value.appliedVersion) throw new Error('The server returned a lobby older than the confirmed change.'); + return value as AdminLobbyResult; +} + +export function newAdminLobbyRequest(view: AdminLobbyView, action: AdminLobbyAction, reason: string): AdminLobbyInput { + const input = { operationId: randomId(), expectedVersion: view.version, action, reason: reason.trim() }; + if (!validAdminLobbyInput(input)) throw new Error('Choose a valid lobby change and a reason of 1–300 characters.'); + return input; +} + +export function readAdminLobbyRequest(storage: Storage, account: string, code: string): AdminLobbyInput | null { + const raw = storage.getItem(key(account, code)); + if (raw === null) return null; + let value: unknown; + try { value = raw.length <= 2048 ? JSON.parse(raw) : null; } catch { value = null; } + if (!validAdminLobbyInput(value)) throw new Error('This tab’s saved lobby request is unreadable. Check the current lobby before discarding its local retry record.'); + return value; +} + +export function saveAdminLobbyRequest(storage: Storage, account: string, code: string, input: AdminLobbyInput) { + if (!validAdminLobbyInput(input)) throw new Error('Invalid lobby request.'); + const previous = readAdminLobbyRequest(storage, account, code), text = JSON.stringify(input); + if (previous && JSON.stringify(previous) !== text) throw new Error('Resolve the saved lobby request before sending a different change.'); + storage.setItem(key(account, code), text); + if (storage.getItem(key(account, code)) !== text) throw new Error('The lobby request could not be preserved in this tab. Nothing was sent.'); +} + +export function clearAdminLobbyRequest(storage: Storage, account: string, code: string) { + storage.removeItem(key(account, code)); + if (storage.getItem(key(account, code)) !== null) throw new Error('The saved lobby request could not be cleared.'); +} + +export function describeAdminLobbyAction(action: AdminLobbyAction, view: AdminLobbyView): string { + const player = 'target' in action ? view.players.find(player => player.id === action.target)?.name ?? 'the selected player' : ''; + const faction = 'faction' in action ? FACTIONS.find(item => item.id === action.faction)?.name ?? action.faction : ''; + switch (action.type) { + case 'rules': return `Select ${action.advanced ? 'Advanced preview' : 'Basic'} rules`; + case 'techTokens': return `${action.enabled ? 'Enable' : 'Disable'} Tech Tokens`; + case 'strongholdCards': return `${action.enabled ? 'Enable' : 'Disable'} Stronghold Cards`; + case 'addBot': return `Add ${faction} AI at ${action.difficulty}`; + case 'removeBot': return `Remove AI player ${player}`; + case 'configureBot': return `Configure ${player}: ${faction}, ${action.difficulty}, circle ${action.position}`; + case 'assignHost': return `Assign ${player} as host`; + } +} diff --git a/lib/admin-lobby-configuration.ts b/lib/admin-lobby-configuration.ts new file mode 100644 index 00000000..6896714d --- /dev/null +++ b/lib/admin-lobby-configuration.ts @@ -0,0 +1,109 @@ +import { FACTIONS, type FactionId } from '../game/catalog'; +import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; +import type { RoomControl } from './room-control'; + +export type AdminLobbyAction = + | { type: 'rules'; advanced: boolean } + | { type: 'techTokens' | 'strongholdCards'; enabled: boolean } + | { type: 'addBot'; faction: FactionId; difficulty: Difficulty } + | { type: 'removeBot' | 'assignHost'; target: string } + | { + type: 'configureBot'; + target: string; + faction: FactionId; + difficulty: Difficulty; + position: number; + }; +export type AdminLobbyInput = { + operationId: string; + expectedVersion: number; + action: AdminLobbyAction; + reason: string; +}; +export type AdminLobbyView = { + code: string; + version: number; + status: 'lobby' | 'setup' | 'playing' | 'finished' | 'unreadable'; + editable: boolean; + blockedReason: string | null; + host: string | null; + advanced: boolean; + techTokens: boolean; + strongholdCards: boolean; + players: { + id: string; + name: string; + faction: string; + bot: Difficulty | null; + position: number; + ready: boolean; + hostEligible: boolean; + }[]; + control: RoomControl; +}; +export type AdminLobbyResult = { + operationId: string; + replayed: boolean; + appliedVersion: number; + lobby: AdminLobbyView; +}; + +const object = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); +const keys = (value: Record, expected: string[]) => + Object.keys(value).length === expected.length && + expected.every((key) => Object.hasOwn(value, key)); +const target = (value: unknown) => + typeof value === 'string' && + value.length > 0 && + value.length <= 80 && + value.trim() === value; +export function validAdminLobbyInput(value: unknown): value is AdminLobbyInput { + if ( + !object(value) || + !keys(value, ['operationId', 'expectedVersion', 'action', 'reason']) || + typeof value.operationId !== 'string' || + value.operationId.length !== 36 || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( + value.operationId, + ) || + !Number.isSafeInteger(value.expectedVersion) || + Number(value.expectedVersion) < 0 || + Number(value.expectedVersion) >= Number.MAX_SAFE_INTEGER || + typeof value.reason !== 'string' || + !value.reason.trim() || + value.reason.length > 300 || + value.reason + .split('') + .some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) || + !object(value.action) + ) + return false; + const action = value.action; + if (action.type === 'rules') + return ( + keys(action, ['type', 'advanced']) && typeof action.advanced === 'boolean' + ); + if (action.type === 'techTokens' || action.type === 'strongholdCards') + return ( + keys(action, ['type', 'enabled']) && typeof action.enabled === 'boolean' + ); + if (action.type === 'removeBot' || action.type === 'assignHost') + return keys(action, ['type', 'target']) && target(action.target); + if (action.type !== 'addBot' && action.type !== 'configureBot') return false; + if ( + !FACTIONS.some( + (faction) => + faction.id === action.faction && faction.expansion === 'base', + ) || + !DIFFICULTIES.some((difficulty) => difficulty === action.difficulty) + ) + return false; + return action.type === 'addBot' + ? keys(action, ['type', 'faction', 'difficulty']) + : keys(action, ['type', 'target', 'faction', 'difficulty', 'position']) && + target(action.target) && + Number.isInteger(action.position) && + Number(action.position) >= 1 && + Number(action.position) <= 6; +} diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index 89d5d5dd..144e23fe 100644 --- a/tests/admin-boundary-http.test.ts +++ b/tests/admin-boundary-http.test.ts @@ -13,7 +13,7 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.ok(cookie); const room = (await created.json() as { code: string }).code; for (const headers of [{}, { cookie }, { cookie: 'dune_admin_session=' + 'a'.repeat(64) }, { 'oai-authenticated-user-id': 'owner', 'oai-authenticated-user-email': 'owner@example.test' }] as Record[]) { - for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`]) { + for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`]) { const result = await fetch(base + path, { headers, signal: AbortSignal.timeout(15000) }); assert.equal(result.status, 401, path); assert.equal(result.headers.get('cache-control'), 'no-store'); @@ -26,6 +26,14 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden }); assert.equal(write.status, 401); assert.deepEqual(Object.keys(await write.json()), ['error']); + const lobby = await fetch(`${base}/api/admin/rooms/${room}/lobby`, { + method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' }, + body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, action: { type: 'rules', advanced: true }, reason: 'Denied lobby QA' }), + signal: AbortSignal.timeout(15000), + }); + assert.equal(lobby.status, 401); + assert.equal(lobby.headers.get('set-cookie'), null); + assert.deepEqual(Object.keys(await lobby.json()), ['error']); const create = await fetch(base + '/api/admin/rooms', { method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' }, body: JSON.stringify({ operationId: crypto.randomUUID(), sessionToken: 'b'.repeat(64), name: 'Denied creation QA', faction: 'atreides', advanced: false, techTokens: false, strongholdCards: false, bots: [], reason: 'Must not create a room' }), diff --git a/tests/admin-lobby-client.test.ts b/tests/admin-lobby-client.test.ts new file mode 100644 index 00000000..3b4a3b57 --- /dev/null +++ b/tests/admin-lobby-client.test.ts @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { adminLobbyConfirmation, adminLobbyResponse, clearAdminLobbyRequest, newAdminLobbyRequest, readAdminLobbyRequest, saveAdminLobbyRequest } from '../lib/admin-lobby-client'; +import type { AdminLobbyView } from '../lib/admin-lobby-configuration'; +import { DEFAULT_ROOM_CONTROL } from '../lib/room-control'; + +const view = (): AdminLobbyView => ({ code: 'ABCD2345', version: 4, status: 'lobby', editable: true, blockedReason: null, host: 'host', + advanced: false, techTokens: false, strongholdCards: false, control: { ...DEFAULT_ROOM_CONTROL }, + players: [{ id: 'host', name: 'Host', faction: 'atreides', bot: null, position: 1, ready: false, hostEligible: true }] }); +function storage() { + const values = new Map(); + return { values, getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => { values.set(key, value); }, removeItem: (key: string) => { values.delete(key); } }; +} + +void test('lobby retry preserves exact action/version/reason across refresh and isolates administrator and room', () => { + const tab = storage(), input = newAdminLobbyRequest(view(), { type: 'rules', advanced: true }, ' QA preview '); + saveAdminLobbyRequest(tab, 'a', view().code, input); + assert.deepEqual(readAdminLobbyRequest(tab, 'a', view().code), input); + assert.equal(input.expectedVersion, 4); assert.equal(input.reason, 'QA preview'); + assert.equal(readAdminLobbyRequest(tab, 'b', view().code), null); + assert.equal(readAdminLobbyRequest(tab, 'a', 'DIFF2345'), null); + for (const change of [{ expectedVersion: 5 }, { reason: 'Different' }, { action: { type: 'rules' as const, advanced: false } }]) + assert.throws(() => saveAdminLobbyRequest(tab, 'a', view().code, { ...input, ...change }), /Resolve the saved/); + saveAdminLobbyRequest(tab, 'a', view().code, input); + clearAdminLobbyRequest(tab, 'a', view().code); + assert.equal(readAdminLobbyRequest(tab, 'a', view().code), null); +}); + +void test('corrupt/unwritable retry storage fails closed without replacing its original record', () => { + const tab = storage(), input = newAdminLobbyRequest(view(), { type: 'assignHost', target: 'human' }, 'QA'); + assert.throws(() => saveAdminLobbyRequest({ ...tab, setItem() {} }, 'a', view().code, input), /Nothing was sent/); + saveAdminLobbyRequest(tab, 'a', view().code, input); + const key = [...tab.values.keys()][0]; + tab.values.set(key, '{broken'); + assert.throws(() => readAdminLobbyRequest(tab, 'a', view().code), /unreadable/); + assert.throws(() => saveAdminLobbyRequest(tab, 'a', view().code, input), /unreadable/); + assert.equal(tab.values.get(key), '{broken'); + assert.throws(() => clearAdminLobbyRequest({ ...tab, removeItem() {} }, 'a', view().code), /could not be cleared/); + clearAdminLobbyRequest(tab, 'a', view().code); + assert.throws(() => newAdminLobbyRequest(view(), { type: 'rules', advanced: true }, ' ')); +}); + +void test('confirmation binds exact operation and applied version while accepting current started/paused public state', () => { + const lobby = view(), input = newAdminLobbyRequest(lobby, { type: 'rules', advanced: true }, 'QA'); + const current = { ...lobby, version: 9, status: 'playing' as const, editable: false, blockedReason: 'Already started', control: { ...lobby.control, paused: true } }; + const receipt = { operationId: input.operationId, appliedVersion: 5, replayed: true, lobby: current }; + assert.deepEqual(adminLobbyConfirmation(receipt, lobby.code, input), receipt); + for (const invalid of [{ ...receipt, operationId: crypto.randomUUID() }, { ...receipt, appliedVersion: 6 }, + { ...receipt, lobby: { ...current, version: 4 } }, { ...receipt, lobby: { ...current, code: 'DIFF2345' } }, + { ...receipt, sessionToken: 'secret' }, { ...receipt, replayed: undefined }]) + assert.throws(() => adminLobbyConfirmation(invalid, lobby.code, input)); +}); + +void test('lobby response accepts unreadable room diagnostics but rejects private fields at every projection level', () => { + const lobby = view(); + assert.deepEqual(adminLobbyResponse(lobby, lobby.code), lobby); + assert.doesNotThrow(() => adminLobbyResponse({ ...lobby, status: 'unreadable', editable: false, blockedReason: 'Needs inspection', host: null, players: [] }, lobby.code)); + for (const invalid of [{ ...lobby, hand: [] }, { ...lobby, players: [{ ...lobby.players[0], traitors: [] }] }, + { ...lobby, control: { ...lobby.control, reason: 'Private audit' } }, { ...lobby, players: [{ ...lobby.players[0], position: 7 }] }]) + assert.throws(() => adminLobbyResponse(invalid, lobby.code)); +}); diff --git a/tests/admin-lobby-configuration.test.ts b/tests/admin-lobby-configuration.test.ts new file mode 100644 index 00000000..9c9c0c12 --- /dev/null +++ b/tests/admin-lobby-configuration.test.ts @@ -0,0 +1,940 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomBytes, randomUUID, webcrypto } from 'node:crypto'; +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; +import { adminStore } from './admin-access-fixture'; +import { + adminLogin, + requireAdmin, + adminLogoutAll, + AdminError, + type AdminRole, +} from '../db/admin-access'; +import { + readAdminLobby, + configureAdminLobby, +} from '../db/admin-lobby-configuration'; +import { applyAdminRoomControl } from '../db/admin-lifecycle'; +import { + validAdminLobbyInput, + type AdminLobbyInput, + type AdminLobbyAction, +} from '../lib/admin-lobby-configuration'; +import { + freshAdminLobby, + applyAdminLobbyAction, +} from '../game/admin-lobby-configuration'; +import * as engine from '../game/engine'; +import * as bots from '../game/bots'; +import * as seatAiDelegation from '../lib/seat-ai-delegation'; +import type * as Rooms from '../db/rooms'; + +const secret = () => randomBytes(32).toString('hex'); +const status = (wanted: number) => (error: unknown) => + error instanceof AdminError && error.status === wanted; +const operation = ( + expectedVersion: number, + action: AdminLobbyAction = { type: 'rules', advanced: true }, +): AdminLobbyInput => ({ + operationId: randomUUID(), + expectedVersion, + action, + reason: 'Private operational reason', +}); +function loadRooms(database: D1Database) { + const exports = {}; + runInNewContext( + ts.transpileModule( + readFileSync(new URL('../db/rooms.ts', import.meta.url), 'utf8'), + { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }, + ).outputText, + { + exports, + crypto: webcrypto, + TextEncoder, + structuredClone, + JSON, + require: (name: string) => { + if (name === 'cloudflare:workers') return { env: { DB: database } }; + if (name === '@/game/engine') return engine; + if (name === '@/game/bots') return bots; + if (name === '@/lib/seat-ai-delegation') return seatAiDelegation; + throw new Error('Unexpected dependency ' + name); + }, + }, + ); + return exports as typeof Rooms; +} +async function fixture(role: AdminRole = 'operator') { + const store = adminStore(); + assert.ok(store.sqlite instanceof DatabaseSync); + const account = store.provision(role, 'Private administrator name'); + const login = await adminLogin(store.database, account.key); + const identity = await requireAdmin(store.database, login.token); + const rooms = loadRooms(store.database); + const owner = await rooms.createRoom('Lobby owner', 'atreides', false, []); + const code = owner.view.code; + const auth = await rooms.authenticate(code, owner.token); + const guest = await rooms.joinRoom(code, 'Lobby guest', 'emperor'); + const guestAuth = await rooms.authenticate(code, guest.token!); + const row = () => + store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(code)!; + const version = () => Number(row().version); + const read = () => readAdminLobby(store.database, identity, code); + const configure = (input = operation(version())) => + configureAdminLobby(store.database, identity, code, input); + const secureRows = () => + [ + 'seats', + 'seat_recovery_keys', + 'seat_recovery_receipts', + 'seat_handover_offers', + 'seat_handover_claim_receipts', + 'seat_ai_delegations', + 'room_entry_receipts', + 'room_messages', + ].map((table) => store.sqlite.prepare(`SELECT * FROM ${table}`).all()); + return { + ...store, + account, + login, + identity, + rooms, + owner, + guest, + code, + auth, + guestAuth, + row, + version, + read, + configure, + secureRows, + }; +} +function pauseBatch(f: Awaited>, nth = 2) { + let enter!: () => void, + release!: () => void, + count = 0; + const waiting = new Promise((resolve) => { + enter = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + f.hooks.beforeBatch = async () => { + if (++count !== nth) return; + delete f.hooks.beforeBatch; + enter(); + await gate; + }; + return { waiting, release }; +} +const audits = (f: Awaited>) => + f.sqlite.prepare('SELECT * FROM admin_lobby_operations').all(); + +void test('lobby migration is additive and preserves existing game/seat/admin/creation records', () => { + const store = adminStore(':memory:', false); + try { + for (const file of readdirSync(new URL('../drizzle/', import.meta.url)) + .filter((file) => file.endsWith('.sql') && file < '0011') + .sort()) + store.sqlite.exec( + readFileSync(new URL('../drizzle/' + file, import.meta.url), 'utf8'), + ); + store.sqlite + .prepare( + 'INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)', + ) + .run('PRESERVE', 'private saved bytes', 5, 42); + store.sqlite + .prepare( + 'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)', + ) + .run('private hash', 'PRESERVE', 'owner'); + store.provision('owner'); + const rows = () => + [ + 'rooms', + 'seats', + 'admin_accounts', + 'admin_audit', + 'admin_room_creations', + ].map((table) => store.sqlite.prepare(`SELECT * FROM ${table}`).all()); + const before = rows(); + store.sqlite.exec( + readFileSync( + new URL( + '../drizzle/0011_admin_lobby_configuration.sql', + import.meta.url, + ), + 'utf8', + ), + ); + assert.deepEqual(rows(), before); + assert.deepEqual( + store.sqlite.prepare('SELECT * FROM admin_lobby_operations').all(), + [], + ); + } finally { + store.sqlite.close(); + } +}); + +void test('admin lobby input permits only seven exact bounded commands with valid base factions and public parameters', () => { + const valid: AdminLobbyAction[] = [ + { type: 'rules', advanced: true }, + { type: 'techTokens', enabled: true }, + { type: 'strongholdCards', enabled: false }, + { type: 'addBot', faction: 'guild', difficulty: 'Hard' }, + { type: 'removeBot', target: 'bot' }, + { + type: 'configureBot', + target: 'bot', + faction: 'guild', + difficulty: 'Easy', + position: 6, + }, + { type: 'assignHost', target: 'guest' }, + ]; + for (const action of valid) + assert.equal(validAdminLobbyInput(operation(3, action)), true); + const malformed = [ + { type: 'start' }, + { type: 'ready' }, + { type: 'homeworlds', enabled: true }, + { type: 'setAutopilot', difficulty: 'Hard' }, + { type: 'addBot', faction: 'ixians', difficulty: 'Hard' }, + { type: 'addBot', faction: 'guild', difficulty: 'Impossible' }, + { type: 'rules', advanced: 'true' }, + { type: 'assignHost', target: '' }, + { type: 'assignHost', target: 'guest', token: secret() }, + { + type: 'configureBot', + target: 'bot', + faction: 'guild', + difficulty: 'Easy', + position: 7, + }, + ]; + for (const action of malformed) + assert.equal(validAdminLobbyInput({ ...operation(3), action }), false); + for (const patch of [ + { operationId: randomUUID() + '\n' }, + { expectedVersion: -1 }, + { expectedVersion: Number.MAX_SAFE_INTEGER }, + { reason: ' ' }, + { reason: 'x'.repeat(301) }, + { reason: 'line\nbreak' }, + { sessionToken: secret() }, + ]) + assert.equal(validAdminLobbyInput({ ...operation(3), ...patch }), false); +}); + +void test('viewer receives only public lobby configuration and active-human host eligibility without mutation', async () => { + const f = await fixture('viewer'); + try { + const before = f.row(), + secure = f.secureRows(); + const view = await f.read(); + assert.equal(view.editable, true); + assert.equal(view.blockedReason, null); + assert.equal(view.status, 'lobby'); + assert.equal(view.host, f.auth.playerId); + assert.ok(view.players.every((player) => player.hostEligible)); + assert.deepEqual( + view.players.map((player) => player.position), + [1, 2], + ); + assert.deepEqual(Object.keys(view).sort(), [ + 'advanced', + 'blockedReason', + 'code', + 'control', + 'editable', + 'host', + 'players', + 'status', + 'strongholdCards', + 'techTokens', + 'version', + ]); + assert.deepEqual(Object.keys(view.players[0]).sort(), [ + 'bot', + 'faction', + 'hostEligible', + 'id', + 'name', + 'position', + 'ready', + ]); + for (const value of [ + f.owner.token, + f.guest.token!, + f.identity.id, + f.identity.sessionHash, + 'traitor', + 'spice', + 'prediction', + 'deck', + ]) + assert.equal(JSON.stringify(view).includes(value), false); + assert.deepEqual(f.row(), before); + assert.deepEqual(f.secureRows(), secure); + await assert.rejects(f.configure(), status(403)); + assert.deepEqual(audits(f), []); + } finally { + f.sqlite.close(); + } +}); + +void test('neutral admin changes reuse ordinary rule/AI actions, preserve host sessions and clear human readiness', async () => { + const f = await fixture(); + try { + for (const auth of [f.auth, f.guestAuth]) + await f.rooms.act(f.code, auth, f.version(), { type: 'ready' }); + const secure = f.secureRows(); + await f.configure( + operation(f.version(), { type: 'rules', advanced: true }), + ); + await f.configure( + operation(f.version(), { type: 'techTokens', enabled: true }), + ); + await f.configure( + operation(f.version(), { type: 'strongholdCards', enabled: true }), + ); + const added = await f.configure( + operation(f.version(), { + type: 'addBot', + faction: 'guild', + difficulty: 'Easy', + }), + ); + const bot = added.lobby.players.find((player) => player.bot)!; + assert.equal(bot.hostEligible, false); + assert.equal(bot.ready, true); + const configured = await f.configure( + operation(f.version(), { + type: 'configureBot', + target: bot.id, + faction: 'harkonnen', + difficulty: 'Brutal', + position: 6, + }), + ); + assert.deepEqual( + configured.lobby.players.find((player) => player.id === bot.id), + { + id: bot.id, + name: 'Harkonnen AI', + faction: 'harkonnen', + bot: 'Brutal', + position: 6, + ready: true, + hostEligible: false, + }, + ); + assert.ok( + configured.lobby.players + .filter((player) => !player.bot) + .every((player) => !player.ready), + ); + const changed = await f.configure( + operation(f.version(), { type: 'rules', advanced: false }), + ); + assert.equal(changed.lobby.strongholdCards, false); + assert.equal(changed.lobby.techTokens, true); + await f.configure( + operation(f.version(), { type: 'removeBot', target: bot.id }), + ); + const g = await f.rooms.readRoom(f.code); + assert.equal(g.players.length, 2); + assert.equal(g.host, f.auth.playerId); + assert.ok( + g.players.every( + (player) => + player.reserves === 20 && + player.spice === 0 && + player.hand.length === 0, + ), + ); + assert.deepEqual(f.secureRows(), secure); + assert.ok( + g.log + .slice(2) + .every((entry) => entry.text.startsWith('An administrator')), + ); + assert.equal(JSON.stringify(g.log).includes(f.identity.name), false); + assert.equal( + JSON.stringify(g.log).includes('Private operational reason'), + false, + ); + for (const auth of [f.auth, f.guestAuth]) + assert.equal( + (await f.rooms.readSeatView(f.code, auth)).me, + auth.playerId, + ); + } finally { + f.sqlite.close(); + } +}); + +void test('fresh supported guard accepts legacy player circles and disabled modules but rejects used pieces, unknown runtime and unsupported profiles generically', async () => { + const f = await fixture(); + try { + const original = await f.rooms.readRoom(f.code); + const legacy = structuredClone(original); + delete legacy.playerPositions; + legacy.homeworlds = null; + legacy.nexusCards = null; + delete legacy.leaderSkills; + legacy.discoveryEnabled = false; + assert.equal(freshAdminLobby(legacy), true); + const corruptions: ((game: engine.Game) => void)[] = [ + (g) => { + g.players[0].spice = 1; + }, + (g) => { + g.players[0].reserves = 19; + }, + (g) => { + g.players[0].leaders[0].dead = true; + }, + (g) => { + g.expansions = ['ix']; + }, + (g) => { + g.homeworlds = { custody: null }; + }, + (g) => { + g.techTokens = { axlotl: { owner: g.host, spice: 0 } } as NonNullable< + engine.Game['techTokens'] + >; + }, + (g) => { + (g as unknown as Record).futurePrivateField = + 'PRIVATE_SENTINEL'; + }, + (g) => { + g.playerPositions![g.players[1].id] = 1; + }, + (g) => { + g.deck = [{ id: 'PRIVATE_CARD' }] as engine.Game['deck']; + }, + ]; + for (const corrupt of corruptions) { + const game = structuredClone(original); + corrupt(game); + f.sqlite + .prepare('UPDATE rooms SET state=? WHERE code=?') + .run(JSON.stringify(game), f.code); + const before = f.row(); + const view = await f.read(); + assert.equal(view.editable, false); + assert.equal(JSON.stringify(view).includes('PRIVATE'), false); + await assert.rejects( + f.configure(), + (error) => status(409)(error) && !String(error).includes('PRIVATE'), + ); + assert.deepEqual(f.row(), before); + assert.deepEqual(audits(f), []); + } + f.sqlite + .prepare('UPDATE rooms SET state=? WHERE code=?') + .run('{bad private save', f.code); + assert.equal((await f.read()).status, 'unreadable'); + await assert.rejects(f.configure(), status(409)); + } finally { + f.sqlite.close(); + } +}); + +void test('host reassignment changes authority only, clears human readiness and preserves both original saved seats and pieces', async () => { + const f = await fixture(); + try { + for (const auth of [f.auth, f.guestAuth]) + await f.rooms.act(f.code, auth, f.version(), { type: 'ready' }); + const before = await f.rooms.readRoom(f.code), + secure = f.secureRows(); + const result = await f.configure( + operation(f.version(), { + type: 'assignHost', + target: f.guestAuth.playerId, + }), + ); + assert.equal(result.lobby.host, f.guestAuth.playerId); + const next = await f.rooms.readRoom(f.code); + assert.deepEqual( + next.players.map((player) => ({ ...player, ready: false })), + before.players.map((player) => ({ ...player, ready: false })), + ); + assert.ok(next.players.every((player) => !player.ready)); + assert.deepEqual(next.playerPositions, before.playerPositions); + assert.deepEqual(f.secureRows(), secure); + await assert.rejects( + f.rooms.act(f.code, f.auth, f.version(), { + type: 'addBot', + faction: 'guild', + difficulty: 'Hard', + }), + /host/i, + ); + await f.rooms.act(f.code, f.guestAuth, f.version(), { + type: 'addBot', + faction: 'guild', + difficulty: 'Hard', + }); + const bot = (await f.read()).players.find((player) => player.bot)!; + await assert.rejects( + f.configure( + operation(f.version(), { type: 'assignHost', target: bot.id }), + ), + status(409), + ); + await assert.rejects( + f.configure( + operation(f.version(), { type: 'removeBot', target: f.auth.playerId }), + ), + status(409), + ); + for (const auth of [f.auth, f.guestAuth]) + assert.equal( + (await f.rooms.readSeatView(f.code, auth)).me, + auth.playerId, + ); + } finally { + f.sqlite.close(); + } +}); + +void test('mismatched saved room codes reject edits without repair while completed receipts remain confirmable', async () => { + const f = await fixture(); + try { + const input = operation(f.version()); + const applied = await f.configure(input); + f.sqlite + .prepare( + "UPDATE rooms SET state=json_set(state,'$.code','MISMATCH') WHERE code=?", + ) + .run(f.code); + const before = f.row(); + const view = await f.read(); + assert.equal(view.code, f.code); + assert.equal(view.editable, false); + assert.equal( + view.blockedReason, + 'This saved lobby needs inspection before configuration.', + ); + await assert.rejects( + f.configure( + operation(f.version(), { type: 'techTokens', enabled: true }), + ), + status(409), + ); + const replay = await f.configure(input); + assert.equal(replay.replayed, true); + assert.equal(replay.appliedVersion, applied.appliedVersion); + assert.deepEqual(replay.lobby, view); + assert.deepEqual(f.row(), before); + assert.equal(audits(f).length, 1); + } finally { + f.sqlite.close(); + } +}); + +void test('freshness checks require own fields and reject prototype-named corrupt player or root data', async () => { + for (const target of ['player', 'root'] as const) { + const f = await fixture(); + try { + const game = await f.rooms.readRoom(f.code); + const malformed = (target === 'player' + ? game.players[0] + : game) as unknown as Record; + if (target === 'player') delete malformed.spice; + Object.defineProperty(malformed, '__proto__', { + value: {}, + enumerable: true, + }); + f.sqlite + .prepare('UPDATE rooms SET state=? WHERE code=?') + .run(JSON.stringify(game), f.code); + assert.equal(freshAdminLobby(JSON.parse(String(f.row().state))), false); + const before = f.row(), + secure = f.secureRows(); + const view = await f.read(); + assert.equal(view.editable, false); + assert.equal( + view.blockedReason, + 'This saved lobby needs inspection before configuration.', + ); + await assert.rejects( + f.configure( + operation(f.version(), { + type: 'assignHost', + target: f.guestAuth.playerId, + }), + ), + status(409), + ); + assert.deepEqual(f.row(), before); + assert.deepEqual(f.secureRows(), secure); + assert.deepEqual(audits(f), []); + } finally { + f.sqlite.close(); + } + } +}); + +void test('neutral configuration retains genuine Basic/Advanced setup, module prerequisites and human readiness', async () => { + for (const advanced of [false, true]) { + const f = await fixture(); + try { + if (advanced) + await f.configure( + operation(f.version(), { type: 'rules', advanced: true }), + ); + await f.configure( + operation(f.version(), { type: 'techTokens', enabled: true }), + ); + if (advanced) + await f.configure( + operation(f.version(), { type: 'strongholdCards', enabled: true }), + ); + else + await assert.rejects( + f.configure( + operation(f.version(), { type: 'strongholdCards', enabled: true }), + ), + status(409), + ); + for (const auth of [f.auth, f.guestAuth]) + await f.rooms.act(f.code, auth, f.version(), { type: 'ready' }); + const start = { + type: 'start', + ...(advanced ? { advancedPreview: true } : {}), + } as engine.Action; + await assert.rejects( + f.rooms.act(f.code, f.auth, f.version(), start), + /three players/i, + ); + await f.configure( + operation(f.version(), { + type: 'addBot', + faction: 'guild', + difficulty: 'Hard', + }), + ); + await assert.rejects( + f.rooms.act(f.code, f.auth, f.version(), start), + /ready/i, + ); + for (const auth of [f.auth, f.guestAuth]) + await f.rooms.act(f.code, auth, f.version(), { type: 'ready' }); + if (advanced) + await assert.rejects( + f.rooms.act(f.code, f.auth, f.version(), { type: 'start' }), + /preview|unfinished/i, + ); + const view = await f.rooms.act(f.code, f.auth, f.version(), start); + assert.equal(view.status, 'setup'); + assert.equal(view.advancedPreview, advanced); + assert.ok( + view.players.find((player) => player.id === f.auth.playerId)! + .traitorChoices?.length, + ); + const adminView = await f.read(); + assert.equal(adminView.editable, false); + assert.equal(adminView.status, 'setup'); + assert.equal(JSON.stringify(adminView).includes('traitor'), false); + } finally { + f.sqlite.close(); + } + } +}); + +void test('exact lobby receipt replays after start with current public metadata and never repeats its change', async () => { + const f = await fixture(); + try { + const input = operation(f.version(), { + type: 'addBot', + faction: 'guild', + difficulty: 'Hard', + }); + const applied = await f.configure(input); + const same = { + ...input, + action: { difficulty: 'Hard', faction: 'guild', type: 'addBot' } as const, + reason: ' ' + input.reason + ' ', + }; + assert.deepEqual(await f.configure(same), { ...applied, replayed: true }); + for (const auth of [f.auth, f.guestAuth]) + await f.rooms.act(f.code, auth, f.version(), { type: 'ready' }); + await f.rooms.act(f.code, f.auth, f.version(), { type: 'start' }); + const before = f.row(); + const replay = await f.configure(input); + assert.equal(replay.replayed, true); + assert.equal(replay.appliedVersion, applied.appliedVersion); + assert.equal(replay.lobby.status, 'setup'); + assert.equal(replay.lobby.version, f.version()); + assert.deepEqual(f.row(), before); + assert.equal(audits(f).length, 1); + for (const patch of [ + { reason: 'Changed reason' }, + { expectedVersion: f.version() }, + { action: { ...input.action, difficulty: 'Easy' } }, + ]) + await assert.rejects( + f.configure({ ...input, ...patch } as AdminLobbyInput), + status(409), + ); + const other = f.provision('operator'); + const login = await adminLogin(f.database, other.key); + const identity = await requireAdmin(f.database, login.token); + await assert.rejects( + configureAdminLobby(f.database, identity, f.code, input), + status(409), + ); + const text = JSON.stringify(audits(f)); + for (const privateValue of [ + f.identity.sessionHash, + f.owner.token, + f.guest.token!, + 'traitorChoices', + 'deck', + ]) + assert.equal(text.includes(privateValue), false); + assert.equal(audits(f)[0].actor_admin_id, f.identity.id); + assert.equal(audits(f)[0].reason, input.reason); + } finally { + f.sqlite.close(); + } +}); + +void test('paused lobbies reject configuration while joining locks permit it, without changing operational flags', async () => { + const f = await fixture(); + try { + const pause = (revision: number, paused: boolean, joinLocked: boolean) => + applyAdminRoomControl(f.database, f.identity, f.code, { + operationId: randomUUID(), + expectedRevision: revision, + paused, + joinLocked, + reason: 'QA control', + }); + await pause(0, true, false); + const before = f.row(); + assert.equal((await f.read()).editable, false); + await assert.rejects(f.configure(), status(409)); + assert.deepEqual(f.row(), before); + await pause(1, false, true); + const result = await f.configure(); + assert.equal(result.lobby.advanced, true); + assert.equal(result.lobby.control.joinLocked, true); + assert.equal(result.lobby.control.revision, 2); + } finally { + f.sqlite.close(); + } +}); + +void test('administrator session/role changes are fenced at snapshot reads, writes and exact receipt replay', async () => { + for (const phase of ['read', 'write', 'replay'] as const) + for (const mode of ['logout', 'disable', 'expire', 'demote'] as const) { + const f = await fixture(); + try { + const input = operation(f.version()); + if (phase === 'replay') await f.configure(input); + const before = f.row(), + beforeAudit = audits(f); + const gate = pauseBatch(f, phase === 'write' ? 2 : 1); + const pending = phase === 'read' ? f.read() : f.configure(input); + await gate.waiting; + if (mode === 'logout') await adminLogoutAll(f.database, f.identity); + else if (mode === 'disable') + f.sqlite + .prepare('UPDATE admin_accounts SET enabled=0 WHERE id=?') + .run(f.identity.id); + else if (mode === 'expire') + f.sqlite + .prepare( + 'UPDATE admin_sessions SET expires_at=1 WHERE token_hash=?', + ) + .run(f.identity.sessionHash); + else + f.sqlite + .prepare("UPDATE admin_accounts SET role='viewer' WHERE id=?") + .run(f.identity.id); + gate.release(); + if (phase === 'read' && mode === 'demote') { + const result = await pending; + assert.ok('code' in result); + assert.equal(result.code, f.code); + } else + await assert.rejects(pending, status(mode === 'demote' ? 403 : 401)); + assert.deepEqual(f.row(), before); + assert.deepEqual(audits(f), beforeAudit); + } finally { + f.sqlite.close(); + } + } +}); + +void test('prepared lobby writes lose to start, join, human action, pause and target-seat revocation without overwriting the winner', async () => { + for (const winner of [ + 'start', + 'join', + 'action', + 'pause', + 'target', + ] as const) { + const f = await fixture(); + try { + if (winner === 'start') + for (const auth of [f.auth, f.guestAuth]) + await f.rooms.act(f.code, auth, f.version(), { type: 'ready' }); + const input = operation( + f.version(), + winner === 'target' + ? { type: 'assignHost', target: f.guestAuth.playerId } + : { type: 'rules', advanced: true }, + ); + const gate = pauseBatch(f); + const pending = f.configure(input); + await gate.waiting; + if (winner === 'start') + await f.rooms.act(f.code, f.auth, f.version(), { type: 'start' }); + else if (winner === 'join') + await f.rooms.joinRoom(f.code, 'Joining human', 'guild'); + else if (winner === 'action') + await f.rooms.act(f.code, f.auth, f.version(), { type: 'ready' }); + else if (winner === 'pause') + await applyAdminRoomControl(f.database, f.identity, f.code, { + operationId: randomUUID(), + expectedRevision: 0, + paused: true, + joinLocked: false, + reason: 'QA race', + }); + else + f.sqlite + .prepare('UPDATE seats SET revoked=1 WHERE token_hash=?') + .run(f.guestAuth.tokenHash); + const before = f.row(), + secure = f.secureRows(); + gate.release(); + await assert.rejects(pending, status(409)); + assert.deepEqual(f.row(), before); + assert.deepEqual(f.secureRows(), secure); + assert.equal(audits(f).length, 0); + } finally { + f.sqlite.close(); + } + } +}); + +void test('concurrent same operation commits one receipt and conflicting operations commit only one version', async () => { + for (const same of [true, false]) { + const f = await fixture(); + try { + const input = operation(f.version()); + const gate = pauseBatch(f); + const pending = f.configure(input); + await gate.waiting; + const won = await f.configure( + same + ? input + : operation(f.version(), { + type: 'addBot', + faction: 'guild', + difficulty: 'Hard', + }), + ); + const before = f.row(); + gate.release(); + if (same) assert.deepEqual(await pending, { ...won, replayed: true }); + else await assert.rejects(pending, status(409)); + assert.deepEqual(f.row(), before); + assert.equal(audits(f).length, 1); + } finally { + f.sqlite.close(); + } + } +}); + +void test('audit failure rolls back room configuration/version and rejected pure actions leave input untouched', async () => { + const f = await fixture(); + try { + const game = await f.rooms.readRoom(f.code), + copy = structuredClone(game); + assert.throws(() => + applyAdminLobbyAction( + game, + { type: 'addBot', faction: 'atreides', difficulty: 'Hard' }, + new Set([f.auth.playerId, f.guestAuth.playerId]), + ), + ); + assert.deepEqual(game, copy); + const before = f.row(), + secure = f.secureRows(); + f.sqlite.exec( + "CREATE TRIGGER fail_lobby_audit BEFORE INSERT ON admin_lobby_operations BEGIN SELECT RAISE(ABORT,'QA audit failure'); END;", + ); + await assert.rejects(f.configure(), /QA audit failure/); + assert.deepEqual(f.row(), before); + assert.deepEqual(f.secureRows(), secure); + assert.equal(audits(f).length, 0); + } finally { + f.sqlite.close(); + } +}); + +void test('neutral lobby operation and host assignment survive database reopen without changing any saved-seat authority', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dune-admin-lobby-')); + let store = adminStore(join(dir, 'rooms.sqlite')); + try { + const account = store.provision('owner'); + const login = await adminLogin(store.database, account.key); + const identity = await requireAdmin(store.database, login.token); + const rooms = loadRooms(store.database); + const owner = await rooms.createRoom('Owner', 'atreides', false, []); + const code = owner.view.code; + const guest = await rooms.joinRoom(code, 'Guest', 'guild'); + const auth = await rooms.authenticate(code, guest.token!); + const input = operation(guest.view.version, { + type: 'assignHost', + target: auth.playerId, + }); + const applied = await configureAdminLobby( + store.database, + identity, + code, + input, + ); + const seatRows = store.sqlite.prepare('SELECT * FROM seats').all(); + store.sqlite.close(); + store = adminStore(join(dir, 'rooms.sqlite'), false); + assert.deepEqual( + await configureAdminLobby(store.database, identity, code, input), + { ...applied, replayed: true }, + ); + assert.deepEqual( + store.sqlite.prepare('SELECT * FROM seats').all(), + seatRows, + ); + const restored = await loadRooms(store.database).readSeatView(code, auth); + assert.equal(restored.host, auth.playerId); + assert.equal(restored.me, auth.playerId); + } finally { + store.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tools/admin-lobby-verification.mjs b/tools/admin-lobby-verification.mjs new file mode 100644 index 00000000..bbb35c12 --- /dev/null +++ b/tools/admin-lobby-verification.mjs @@ -0,0 +1,91 @@ +// Dedicated QA lobby only. Administrator configuration never returns a seat cookie. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; + +export async function verifyLobbyConfiguration(request, adminCookie, report) { + try { + const created = await request('/api/rooms', { name: 'Admin lobby QA ' + Date.now(), faction: 'atreides', advanced: false, expansions: [] }); + assert.equal(created.status, 201); + const code = created.data.code, path = '/api/rooms/' + code, url = '/api/admin/rooms/' + code + '/lobby'; + report.lobbyRoom = code; + const hostCookie = created.cookie; + const joined = await request(path, { type: 'join', name: 'Lobby host assignment QA', faction: 'fremen' }); + assert.equal(joined.status, 200); + const secondCookie = joined.cookie, secondId = joined.data.me; + assert.equal((await request(url)).status, 401); + assert.equal((await request(url, undefined, hostCookie)).status, 401); + let current = await request(url, undefined, adminCookie); + assert.equal(current.status, 200); assert.equal(current.rawCookie, null); + assert.equal(current.data.editable, true); + assert.deepEqual(Object.keys(current.data).sort(), ['advanced', 'blockedReason', 'code', 'control', 'editable', 'host', 'players', 'status', 'strongholdCards', 'techTokens', 'version']); + for (const player of current.data.players) assert.deepEqual(Object.keys(player).sort(), ['bot', 'faction', 'hostEligible', 'id', 'name', 'position', 'ready']); + const makeInput = action => ({ operationId: randomUUID(), expectedVersion: current.data.version, action, reason: 'Dedicated lobby configuration acceptance' }); + const firstInput = makeInput({ type: 'rules', advanced: true }); + for (const denied of [await request(url, firstInput, adminCookie, 'https://foreign.invalid'), await request(url, firstInput, adminCookie, undefined, randomUUID())]) { + assert.ok([401, 403].includes(denied.status)); assert.equal(denied.rawCookie, null); + } + async function apply(input) { + const result = await request(url, input, adminCookie); + assert.equal(result.status, 200); assert.equal(result.rawCookie, null); + assert.deepEqual(Object.keys(result.data).sort(), ['appliedVersion', 'lobby', 'operationId', 'replayed']); + assert.equal(result.data.operationId, input.operationId); assert.equal(result.data.appliedVersion, input.expectedVersion + 1); + current = { data: result.data.lobby }; + return result; + } + await apply(firstInput); assert.equal(current.data.advanced, true); + await apply(makeInput({ type: 'techTokens', enabled: true })); + await apply(makeInput({ type: 'strongholdCards', enabled: true })); + await apply(makeInput({ type: 'addBot', faction: 'guild', difficulty: 'Easy' })); + const botId = current.data.players.find(player => player.bot).id; + await apply(makeInput({ type: 'configureBot', target: botId, faction: 'emperor', difficulty: 'Hard', position: 5 })); + assert.equal(current.data.players.find(player => player.id === botId).faction, 'emperor'); + assert.equal(current.data.players.find(player => player.id === botId).position, 5); + await apply(makeInput({ type: 'removeBot', target: botId })); + assert.equal(current.data.players.length, 2); + await apply(makeInput({ type: 'rules', advanced: false })); assert.equal(current.data.strongholdCards, false); + await apply(makeInput({ type: 'techTokens', enabled: false })); + const stale = await request(url, { ...firstInput, operationId: randomUUID() }, adminCookie); + assert.equal(stale.status, 409); assert.equal(stale.rawCookie, null); + report.checks.push('Neutral admin rules/modules and AI add/configure/remove persist; public-only projection, origin/account/host denial and stale version rejection'); + + for (const cookie of [hostCookie, secondCookie]) { + const player = await request(path, undefined, cookie); + const ready = await request(path, { version: player.data.version, action: { type: 'ready' } }, cookie); + assert.equal(ready.status, 200); + } + current = await request(url, undefined, adminCookie); + const assignment = makeInput({ type: 'assignHost', target: secondId }); + await apply(assignment); assert.equal(current.data.host, secondId); + assert.ok(current.data.players.every(player => !player.ready)); + const afterAssignment = (await request(path, undefined, hostCookie)).data; + assert.notEqual(afterAssignment.me, secondId); assert.equal(afterAssignment.host, secondId); + assert.equal((await request(path, undefined, secondCookie)).data.me, secondId); + const noSeat = await request(path, undefined, adminCookie); + assert.ok([401, 409].includes(noSeat.status)); + assert.equal(noSeat.rawCookie, null); + for (const field of ['players', 'me', 'hand', 'state']) assert.ok(!Object.hasOwn(noSeat.data, field)); + const oldHostChange = await request(path, { version: afterAssignment.version, action: { type: 'rules', advanced: true } }, hostCookie); + assert.notEqual(oldHostChange.status, 200); + report.checks.push('Existing human host assignment clears readiness, preserves both seat cookies and grants no administrator private-seat access'); + + for (const cookie of [hostCookie, secondCookie]) { + const player = await request(path, undefined, cookie); + assert.equal((await request(path, { version: player.data.version, action: { type: 'ready' } }, cookie)).status, 200); + } + const beforeStart = (await request(path, undefined, secondCookie)).data; + const started = await request(path, { version: beforeStart.version, action: { type: 'start' } }, secondCookie); + assert.equal(started.status, 200); assert.notEqual(started.data.status, 'lobby'); + const replay = await apply(assignment); assert.equal(replay.data.replayed, true); assert.equal(current.data.editable, false); + assert.equal(current.data.host, secondId); + assert.deepEqual((await request(path, undefined, secondCookie)).data, started.data); + const denied = await request(url, makeInput({ type: 'rules', advanced: true }), adminCookie); + assert.equal(denied.status, 409); assert.equal(denied.rawCookie, null); + assert.deepEqual((await request(path, undefined, secondCookie)).data, started.data); + report.checks.push('New host starts a real saved game; exact pre-start assignment retry confirms without resetting its started continuation'); + } catch (error) { + report.failureRequest ??= report.lastRequest; + report.lobbyFailure = 'Administrator lobby configuration acceptance failed; dedicated QA room remains saved.'; + report.lobbyFailureLocation = error instanceof Error ? error.stack?.split('\n').find(line => line.trim().startsWith('at ') && line.includes('admin-lobby-verification.mjs'))?.trim() : undefined; + throw error; + } +} diff --git a/tools/verify-admin.mjs b/tools/verify-admin.mjs index b5b4e729..da3b3a39 100644 --- a/tools/verify-admin.mjs +++ b/tools/verify-admin.mjs @@ -7,6 +7,7 @@ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { verifyRoomLifecycle } from './admin-lifecycle-verification.mjs'; import { verifyRoomCreation } from './admin-creation-verification.mjs'; +import { verifyLobbyConfiguration } from './admin-lobby-verification.mjs'; const options = {}; for (let n = 2; n < process.argv.length; n += 2) { @@ -31,7 +32,7 @@ async function request(path, body, cookie, origin = base, expectedAdminId = admi const response = await fetch(base + path, { method: body === undefined ? 'GET' : 'POST', headers: { ...(body === undefined ? {} : { 'content-type': 'application/json', origin }), ...(cookie ? { cookie } : {}), - ...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/control$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, + ...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000), }); report.lastRequest = { path, status: response.status }; @@ -85,6 +86,7 @@ try { session1 = lifecycleSession.cookie; await verifyRoomLifecycle(request, session1, report); await verifyRoomCreation(request, session1, report); + await verifyLobbyConfiguration(request, session1, report); } report.passed = true; } catch (error) {