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) {