diff --git a/README.md b/README.md index 51816a39..13a73974 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,9 @@ use the Directory filter to find them for restoration. readable tables and history, then resumes the same saved position. [Archive or unarchive](docs/ADMIN_ROOM_ARCHIVE.md) organizes closed rooms while retaining their tables and history; unarchiving leaves play closed. -Permanent deletion, participant support and backup tools are still being built. +[Participant AI](docs/ADMIN_PARTICIPANT_AI.md) supports an absent human in a paused +game while preserving their seat and takeback control. Permanent deletion, +further 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 9f6ac86f..7c751b55 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -11,6 +11,7 @@ import { FACTIONS } from '@/game/catalog'; import { AdminRoomControls } from '@/components/admin-room-controls'; import { AdminRoomCreation } from '@/components/admin-room-creation'; import { AdminLobbyControls } from '@/components/admin-lobby-controls'; +import { AdminSeatAi } from '@/components/admin-seat-ai'; import { AdminRoomArchive } from '@/components/admin-room-archive'; import { AdminRoomClosure } from '@/components/admin-room-closure'; import { AdminRoomRemoval } from '@/components/admin-room-removal'; @@ -31,6 +32,7 @@ export default function Administration() { const [filters, setFilters] = useState(initialFilters); const [applied, setApplied] = useState(initialFilters); const [confirmAll, setConfirmAll] = useState(false); + const [selectedSeatAi, setSelectedSeatAi] = useState(null); const [selectedRoom, setSelectedRoom] = useState(null); const [selectedLobby, setSelectedLobby] = useState(null); const [selectedArchive, setSelectedArchive] = useState(null); @@ -43,7 +45,7 @@ export default function Administration() { const clearSession = useCallback(() => { epoch.current++; setAccount(null); setDirectory(null); setConfirmAll(false); - setBusy(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false); + setBusy(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false); }, []); const accessChanged = useCallback(() => { clearSession(); @@ -119,7 +121,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} @@ -146,6 +148,9 @@ export default function Administration() { + {selectedSeatAi && setSelectedSeatAi(null)} onDenied={accessChanged} + onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} {selectedRoom && setSelectedRoom(null)} onDenied={accessChanged} onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />} @@ -183,7 +188,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 and configure lobbies, assign hosts, pause/resume rooms, manage joining locks, close/reopen play, archive/unarchive closed rooms, and recoverably remove or restore rooms. Permanent removal, participant support and backup tools are still being implemented.

+

Administration preview: create and configure lobbies, assign hosts, pause/resume rooms, manage joining locks, close/reopen play, archive/unarchive closed rooms, and recoverably remove or restore rooms. Participant AI can support human seats in paused games. Permanent removal, further participant support and backup tools are still being implemented.

} ; } diff --git a/app/api/admin/rooms/[code]/seat-ai/route.ts b/app/api/admin/rooms/[code]/seat-ai/route.ts new file mode 100644 index 00000000..de0073f5 --- /dev/null +++ b/app/api/admin/rooms/[code]/seat-ai/route.ts @@ -0,0 +1,33 @@ +import { env } from 'cloudflare:workers'; +import { AdminError, requireAdmin } from '@/db/admin-access'; +import { applyAdminSeatAi, readAdminSeatAi } from '@/db/admin-seat-ai'; +import { validAdminSeatAiInput } from '@/lib/admin-seat-ai'; +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 readAdminSeatAi(env.DB, identity, code(request))); + } catch (error) { return adminFailure(error); } +} + +export async function POST(request: Request) { + try { + const input = 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 (!validAdminSeatAiInput(input)) throw new AdminError('Choose a participant, difficulty, current versions and an operational reason.', 400); + const room = code(request); + const result = await applyAdminSeatAi(env.DB, identity, room, input); + return adminResponse(result); + } catch (error) { return adminFailure(error); } +} diff --git a/components/admin-seat-ai.tsx b/components/admin-seat-ai.tsx new file mode 100644 index 00000000..22a79402 --- /dev/null +++ b/components/admin-seat-ai.tsx @@ -0,0 +1,158 @@ +'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 { adminSeatAiResponse, adminSeatAiConfirmation, clearAdminSeatAiRequest, newAdminSeatAiRequest, readAdminSeatAiRequest, saveAdminSeatAiRequest } from '@/lib/admin-seat-ai-client'; +import type { AdminSeatAiInput, AdminSeatAiView } from '@/lib/admin-seat-ai'; +import { DIFFICULTIES, type Difficulty } from '@/game/bot-profiles'; +import { FACTIONS } from '@/game/catalog'; + +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 : 'Participant availability could not be loaded.'; +const label = (difficulty: Difficulty) => difficulty[0].toUpperCase() + difficulty.slice(1); + +export function AdminSeatAi({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) { + const [room, setRoom] = useState(null); + const [target, setTarget] = useState(''); + const [difficulty, setDifficulty] = useState('Easy'); + const [reason, setReason] = useState(''); + const [confirmed, setConfirmed] = useState(false); + const [confirmationCode, setConfirmationCode] = useState(''); + const [pending, setPending] = useState(null); + const [storageProblem, setStorageProblem] = useState(false); + const [notice, setNotice] = useState(''); + const [busy, setBusy] = useState(false); + const [initialRequestPending, setInitialRequestPending] = useState(false); + const [loading, setLoading] = useState(true); + const alive = useRef(false), inFlight = useRef(false); + const title = useRef(null); + const url = `/api/admin/rooms/${code}/seat-ai`; + const selected = room?.players.find(player => player.id === target && player.eligible); + + useEffect(() => { + alive.current = true; title.current?.focus(); + let canceled = false; + try { + const saved = readAdminSeatAiRequest(window.sessionStorage, accountId, code); + // oxlint-disable-next-line react/react-compiler -- Restore this account/room's tab-scoped request after SSR. + setPending(saved); + if (saved) setNotice('A saved participant AI 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 = adminSeatAiResponse(result, code); if (!canceled) setRoom(current); }) + .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 = adminSeatAiResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code); + if (alive.current) { setRoom(current); setTarget(''); setConfirmed(false); setConfirmationCode(''); } + } catch (error) { + if (alive.current) { + setNotice(message(error)); + if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); + } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) setBusy(false); } + } + + async function submit(saved?: AdminSeatAiInput) { + if (inFlight.current || loading || !canManage || storageProblem || + (!saved && (!room?.editable || !selected || pending || !confirmed || confirmationCode !== code))) return; + let input: AdminSeatAiInput; + try { + input = saved ?? newAdminSeatAiRequest(room!, target, difficulty, reason); + saveAdminSeatAiRequest(window.sessionStorage, accountId, code, input); + } catch (error) { setNotice(message(error)); return; } + inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Enabling participant AI…'); + try { + const result = adminSeatAiConfirmation(await requestJson(url, { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input), + }), code, input); + clearAdminSeatAiRequest(window.sessionStorage, accountId, code); + if (alive.current) { + setPending(null); setRoom(result.room); setTarget(''); setConfirmed(false); setConfirmationCode(''); setReason(''); + setNotice(result.replayed ? 'The original request was already recorded. Current participant control is shown below; later changes have been preserved.' : + 'Participant AI enabled. The room remains paused. The player keeps their seat and can take back control. Resume separately through Room controls when everyone is ready.'); + onUpdated(); + } + } catch (error) { + let cleared = false; + if (!retainAdminRoomRequest(error)) { + try { clearAdminSeatAiRequest(window.sessionStorage, accountId, code); cleared = true; } catch { /* Preserve exact retry after storage failure. */ } + } + if (alive.current) { + if (cleared) setPending(null); + setConfirmed(false); setConfirmationCode(''); + setNotice(requestMayHaveCompleted(error) ? `${message(error)} The change may have been saved. Use Retry saved request to confirm.` : message(error)); + if (error instanceof ClientRequestError && [401, 403].includes(error.status ?? 0)) onDenied(); + else try { + const current = adminSeatAiResponse(await requestJson(url, { cache: 'no-store', headers: { 'X-Dune-Admin-Id': accountId } }), code); + if (alive.current) setRoom(current); + } catch (readError) { + // Keep the exact retry proof, but never leave stale privileged controls visible. + if (alive.current && readError instanceof ClientRequestError && [401, 403].includes(readError.status ?? 0)) onDenied(); + } + } + } finally { inFlight.current = false; onBusyChange(false); if (alive.current) { setInitialRequestPending(false); setBusy(false); } } + } + + function discardUnreadableRecord() { + if (!canManage || !room || !confirmed || busy) return; + try { + clearAdminSeatAiRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false); + setNotice('Local retry record removed. This did not undo any server change. Review current participant control before continuing.'); + } catch (error) { setNotice(message(error)); } + } + + return
+

Participant AI · {code}

+ {notice && {notice}} + {loading ? Loading participant availability… : <> + {room ? <> +

Room: {room.removed ? 'Removed' : room.archived ? 'Archived' : room.closed ? 'Closed' : room.paused ? 'Paused' : 'Running'}. Game: {room.status}.

+

Use AI to support an absent player in a paused game. They keep their cards, forces and access, and can take back control. No private seat access is given to the administrator.

+ {room.players.length > 0 &&
    {room.players.map(player =>
  • {player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction} · {player.control === 'human' ? 'Human' : `${player.difficulty ? label(player.difficulty) : ''} AI${player.control === 'autopilot' ? ' on human seat' : ''}`}
  • )}
} + + : } + {!canManage ?

Your viewer role can inspect participants. An owner or operator can enable participant AI.

: storageProblem ?
+

No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.

+ + +
: initialRequestPending ? null : pending ?
+

Saved request: enable {label(pending.difficulty)} AI for {room?.players.find(player => player.id === pending.target)?.name ?? pending.target} in room {code}.

Reason: {pending.reason}

+

The exact request stays in this tab across refresh. Retrying confirms the original change without applying it again, including after the player takes back control or the room resumes.

+

After refresh or sign-in, use All rooms in both Directory and Archive filters to find the room and reopen Participant AI.

+ +
: room && !room.editable ?

{room.blockedReason}

: room && !room.players.some(player => player.eligible) ?

No human participant is eligible. A participant needs current seat access and must not already be controlled by AI.

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

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

+
+

{selected ? `Enable ${label(difficulty)} AI for ${selected.name}.` : 'Choose the participant above.'} The room will remain paused. Unused permissions this player gave others to start AI will be revoked; their seat and recovery access remain.

+

Resume separately through Room controls when play should continue. This does not remove or restrict a participant.

+ + + +
+
} + } +
; +} diff --git a/db/admin-seat-ai.ts b/db/admin-seat-ai.ts new file mode 100644 index 00000000..2262bf91 --- /dev/null +++ b/db/admin-seat-ai.ts @@ -0,0 +1,92 @@ +import { AdminError, type AdminIdentity } from './admin-access'; +import { validAdminSeatAiInput, type AdminSeatAiInput, type AdminSeatAiView, type AdminSeatAiResult } from '../lib/admin-seat-ai'; +import { enableAdminSeatAi, projectAdminSeatAi, seatAiUnavailable } from '../game/admin-seat-ai'; +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.revision,0) AS control_revision, + EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1) AS closed, + EXISTS (SELECT 1 FROM room_removals WHERE room_code = r.code AND removed = 1) AS removed, + EXISTS (SELECT 1 FROM room_archives WHERE room_code = r.code AND archived = 1) AS archived, + (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_seat_ai_operations WHERE operation_id = ? AND EXISTS (${mutationAuthority})`; +type Row = { code: string; state: string; version: number; paused: number; control_revision: number; closed: number; removed: number; archived: number; 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 { /* Keep malformed state private. */ } + // Historical administrative actions can increment the row fence without rewriting JSON. + if (game && typeof game === 'object' && !Array.isArray(game)) (game as Game).version = row.version; + const view = projectAdminSeatAi({ code: row.code, version: row.version, controlRevision: row.control_revision, + paused: row.paused === 1, closed: row.closed === 1, removed: row.removed === 1, archived: row.archived === 1 }, game, new Set(JSON.parse(row.active_humans))); + return { game: game as Game, view }; +} +function replay(receipt: Receipt | undefined, row: Row | undefined, identity: AdminIdentity, code: string, requestHash: string, replayed: boolean): AdminSeatAiResult | null { + if (!receipt) return null; + if (receipt.actor_admin_id !== identity.id || receipt.room_code !== code || receipt.request_hash !== requestHash) throw new AdminError('This participant operation is bound to a different administrator or request.', 409); + const room: AdminSeatAiView = row ? decode(row).view : { code, version: receipt.applied_version, controlRevision: 0, status: 'unreadable', + paused: false, closed: false, removed: false, archived: false, editable: false, blockedReason: 'The room is no longer available.', players: [] }; + return { operationId: receipt.operation_id, appliedVersion: receipt.applied_version, replayed, room }; +} +export async function readAdminSeatAi(database: D1Database, identity: AdminIdentity, code: string, now = Date.now()): Promise { + const args = credentials(identity, code, now); + const result = await database.batch([database.prepare(authority).bind(...args), database.prepare(roomSelect).bind(code, ...args)]); + authorize(result[0]); const row = result[1].results[0] as Row | undefined; + if (!row) throw new AdminError('Room not found.', 404); + return decode(row).view; +} +export async function applyAdminSeatAi(database: D1Database, identity: AdminIdentity, code: string, input: AdminSeatAiInput, now = Date.now()): Promise { + const args = credentials(identity, code, now); + if (!validAdminSeatAiInput(input)) throw new AdminError('Choose a human participant, difficulty, current room and pause versions, a fresh operation identifier and an operational reason.', 400); + const { operationId, expectedVersion, expectedControlRevision, target, difficulty } = input, reason = input.reason.trim(); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify({ expectedVersion, expectedControlRevision, target, difficulty, 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, 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 ?? seatAiUnavailable, 409); + if (row.version !== expectedVersion || row.control_revision !== expectedControlRevision) throw new AdminError('The room or pause settings changed. Refresh before choosing a new operation.', 409); + if (!snapshot.view.players.some(player => player.id === target && player.eligible)) throw new AdminError('Choose a human participant with current seat access and no active AI controller.', 409); + let next: Game; + try { next = enableAdminSeatAi(snapshot.game, target, difficulty); } catch { throw new AdminError(seatAiUnavailable, 409); } + next.version = row.version + 1; + const result = await database.batch([ + database.prepare(authority).bind(...args), + database.prepare(`UPDATE rooms SET state = ?,version = version + 1,updated_at = ? WHERE code = ? AND version = ? + AND EXISTS (${mutationAuthority}) + AND EXISTS (SELECT 1 FROM room_controls WHERE room_code = rooms.code AND paused = 1 AND revision = ?) + AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code = rooms.code AND removed = 1) + AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = rooms.code AND closed = 1) + AND NOT EXISTS (SELECT 1 FROM room_archives WHERE room_code = rooms.code AND archived = 1) + AND EXISTS (SELECT 1 FROM seats WHERE room_code = rooms.code AND player_id = ? AND revoked = 0) + AND NOT EXISTS (SELECT 1 FROM admin_seat_ai_operations WHERE operation_id = ?)`) + .bind(JSON.stringify(next), now, code, expectedVersion, ...args, expectedControlRevision, target, operationId), + database.prepare(`INSERT INTO admin_seat_ai_operations(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_control_revision,applied_version,target,difficulty,reason,created_at) + SELECT ?,?,?,?,?,?,?,?,?,?,? WHERE changes() = 1`).bind(operationId, identity.id, code, requestHash, expectedVersion, expectedControlRevision, next.version, target, difficulty, reason, now), + // Zero grants is valid; write the audit before this variable-size revocation. + database.prepare(`UPDATE seat_ai_delegations SET revoked_at = ? WHERE room_code = ? AND owner_id = ? AND used_at IS NULL AND revoked_at IS NULL AND changes() = 1`).bind(now, code, target), + database.prepare(receiptSelect).bind(operationId, ...args), database.prepare(roomSelect).bind(code, ...args), + ]); + authorize(result[0], true); + const committed = replay(result[4].results[0] as Receipt | undefined, result[5].results[0] as Row | undefined, identity, code, requestHash, result[1].meta.changes !== 1); + if (committed) return committed; + throw new AdminError('The room, participant access, pause setting or administrator access changed. Refresh before choosing a new operation.', 409); +} diff --git a/db/schema.ts b/db/schema.ts index 833f0176..0e5b2cd9 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -181,6 +181,25 @@ export const adminRoomArchives = sqliteTable( ], ); +// Participant AI never gives administrators a private seat. Receipts survive room removal. +export const adminSeatAiOperations = sqliteTable( + 'admin_seat_ai_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(), + expectedControlRevision: integer('expected_control_revision').notNull(), + appliedVersion: integer('applied_version').notNull(), + target: text('target').notNull(), + difficulty: text('difficulty').notNull(), + reason: text('reason').notNull(), + createdAt: integer('created_at').notNull(), + }, + table => [index('admin_seat_ai_room').on(table.roomCode, table.createdAt)], +); + // Durable receipts are also the room operations audit. They intentionally survive // account/room removal, and contain no credentials, game state or private cards. export const adminRoomAudit = sqliteTable( diff --git a/docs/ADMIN_PANEL.md b/docs/ADMIN_PANEL.md index 4734fecc..0be82e27 100644 --- a/docs/ADMIN_PANEL.md +++ b/docs/ADMIN_PANEL.md @@ -13,7 +13,7 @@ recovery and persistence systems where suitable. | 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 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), [administrative close/reopen](ADMIN_ROOM_CLOSURE.md) and [recoverable removal/restoration](ADMIN_ROOM_REMOVAL.md) have controls, durable audit, exact retries and player/AI enforcement. [Archive/unarchive](ADMIN_ROOM_ARCHIVE.md) has separate directory filters and exact retries. Permanent deletion and bulk actions 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 | +| Participant support | [Participant AI](ADMIN_PARTICIPANT_AI.md) enables an existing difficulty on an eligible human seat in a paused game, retaining access/takeback and private custody. Participant restrictions/removal, access revocation, assisted recovery and further replacement remain. Existing lobby controls can reassign the host. | Prototyped, partial | | 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 | | Operations and audit | Show server/build/storage health, room/player counts and actionable errors; provide maintenance controls and a searchable record of administrator actions. | Missing | @@ -53,6 +53,10 @@ Disabling an account invalidates its sessions; later re-enabling it cannot resto 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. +[Participant AI](ADMIN_PARTICIPANT_AI.md) lets owners/operators support an absent +human in a paused, started game. The player retains access and takeback controls; +the administrator receives no private seat. Resume remains a separate action. + The directory uses an explicit field allowlist, independent of any player view. It never returns hands, Traitors, predictions, plans, private messages, recovery records or raw saved state. Searches inspect only codes and public player names. @@ -124,6 +128,8 @@ saved seats and revoked credentials. 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. +Participant checks use another new QA room for paused AI enable, private-seat +continuity, takeback and durable retries after resuming. Local browser checks cover sign-in, directory filters, refresh restoration and mobile layout. Final source-bound checks and separately recorded deployed diff --git a/docs/ADMIN_PARTICIPANT_AI.md b/docs/ADMIN_PARTICIPANT_AI.md new file mode 100644 index 00000000..4ebfff1d --- /dev/null +++ b/docs/ADMIN_PARTICIPANT_AI.md @@ -0,0 +1,82 @@ +# Administrative participant AI + +The **Participant AI** action in the administration directory enables an existing +AI difficulty for an absent human in a paused, started game. This is a bounded +participant-support prototype. It does not revoke access, remove a faction, +replace a permanent AI player, or give an administrator a private seat. + +## Use + +1. Find the room and use **Room controls** to pause it at an appropriate point. +2. Open **Participant AI**. Select an eligible human, choose Easy, Medium, Hard or + Brutal, and enter a short operational reason without private information. +3. Type the room code, acknowledge that the player keeps their seat and can take + back control, then choose **Enable participant AI**. +4. The room stays paused. Resume it separately through **Room controls** when + everyone is ready. The existing 1.5-second AI continuation delay starts then. + +Owners and operators may apply the action; viewers may inspect public participant +availability. The room must be in setup or play, paused, open, unarchived and not +removed. The target needs a current human seat credential and no existing AI +controller. Use existing lobby configuration for permanent AI seats before play. + +The human can use **Take back control** even while paused. Their forces, cards, +resources, sealed choices, host assignment, credentials, recovery kits, handovers +and discussion remain saved. Unused permissions that this participant gave +others to start AI are revoked atomically; permissions merely delegated to them +are unchanged. The public chronicle truthfully identifies an administrator's +controller change without exposing the administrator's identity or audit reason. + +## Saved requests and authority + +The exact operation identifier, room and pause versions, participant, difficulty +and reason are saved in account/room-scoped session storage before sending. +First submission shows concise progress. Uncertain responses retain explicit +retry controls, including after refresh. A different unresolved request cannot +overwrite the saved proof. Success text persists until another deliberate action. + +**Retry saved request** confirms the original result even after the player takes +back control, the room resumes, or an administrator removes it. It never enables +AI again. After signing in again, use All rooms in both directory filters to +find later removed/archived rooms and reopen these controls. An unreadable local +record requires explicit acknowledgment before discarding it; that does not undo +any server operation. + +The server repeats live session/role, room version, pause revision, lifecycle and +target-seat checks inside the transaction. Game write, audit receipt and unused +outgoing permission revocation commit together. A rejected/racing request cannot +partially change the game. Replayed operations remain bound to their original +administrator, room and payload. Administrative reads return an explicit public +roster; they do not use a private player view or return credentials. + +Saved games pass a bounded core/continuation shape check before this controller +change. The ordinary authoritative engine still applies module integrity checks; +the operation also verifies that only the chosen controller and one chronicle +event changed. This is not a general import validator or complete rules +certification. Unreadable saves are preserved for inspection rather than repaired +by the administration action. No worker or bot action is scheduled while paused. + +## Implementation and verification + +Controls: [panel](../components/admin-seat-ai.tsx), [client retry](../lib/admin-seat-ai-client.ts). +Authority and persistence: [route](../app/api/admin/rooms/[code]/seat-ai/route.ts), +[transaction](../db/admin-seat-ai.ts), [engine adapter](../game/admin-seat-ai.ts), +[additive migration](../drizzle/0015_admin_seat_ai.sql). + +```sh +npm test -- admin-seat-ai +npm run test:integration -- admin-boundary +``` + +The dedicated administrator verifier also exercises enable, frozen polling, +private-seat continuity, takeback and replay after resume in a new QA room. +Unit/recovery checks cover JSON-restored Basic/Advanced setup, malformed core, +precise mutation scope, concurrent duplicate/conflicting requests, commit-time +authority and seat revocation, rollback and the existing resume deadline. +Independent review, browser acceptance, preservation and full checkpoint checks +are recorded separately. Positive deployed administrator acceptance still needs +explicitly authorized production QA access; local passes do not establish it. + +Participant restrictions/removal, administrator-assisted recovery, further +replacement workflows and the rest of the complete admin panel remain unfinished. +This work does not refine AI strategy or open expansion/publication gates. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 0a0ad537..1abc46b3 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -6,6 +6,18 @@ Expansion starts and full rules/publication acceptance remain gated.** ## Current checkpoint and work +Current work adds [participant AI support](ADMIN_PARTICIPANT_AI.md): a live +owner/operator can enable an existing AI difficulty for an eligible human in a +paused, started game. Saved custody and seat access remain; the human can take +back control, and resume is separate. Atomic audit, exact saved retries and +commit-time room/seat/authority checks are connected. Participant restrictions, +access revocation, assisted recovery, permanent deletion, bulk actions and +backup/operations tools remain unfinished. This adds no strategic AI refinement. + +Readable-notice checkpoint `137f922` is pushed. Types/lint, 5,381 offline tests, +52 HTTP tests, build and delayed-response desktop/mobile browser QA passed. +Its new-revision deployment handoff is pending confirmation. + The [readable-notice follow-up](READABLE_NOTICES.md) extends first-request progress to admin operations, AI permissions and seat handovers, preventing recovery instructions from flashing before a successful response. Seat recovery/handover @@ -13,8 +25,8 @@ confirmations remain above the table until Continue. Existing retry proofs and rules/AI pacing are unchanged. New-revision deployed acceptance remains separate from local checks; positive production admin testing still needs authorized access. -Current work adds [administrative archive/unarchive](ADMIN_ROOM_ARCHIVE.md): -closed rooms can leave the default directory without changing saved games, +The connected [administrative archive/unarchive](ADMIN_ROOM_ARCHIVE.md) prototype lets +closed rooms leave the default directory without changing saved games, credentials, discussion or AI deadlines. Unarchiving leaves play closed; reopening requires unarchiving first. Exact saved retries and combined archive / removal filters preserve recovery. Permanent deletion, bulk actions and full diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 7a3ccbb1..eec4138e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,5 +1,21 @@ # Dune implementation status +## 26 September 2026 — Administrative participant AI prototype + +[Participant AI](ADMIN_PARTICIPANT_AI.md) connects paused human-seat support to +the existing authoritative controller action. It retains player access, private +custody and AI deadlines; explicit resume starts normal paced continuation. +Atomic authority/room/pause/seat fences, durable receipts and target-owned unused +permission revocation preserve concurrent and uncertain requests. Local retry +controls survive refresh and confirm old operations without reversing takeback. + +Independent review corrected sector-18 and worm-ride compatibility, core +continuation shape guards and changed-access handling after reconciliation. +Focused regressions and twenty-two administrator HTTP groups passed; source-bound +checks, browser acceptance and preservation accompany the checkpoint separately. +Positive production admin testing remains pending authorized access. The complete +administration panel, rules/expansion coverage and AI refinement remain unfinished. + ## 26 September 2026 — Readable admin and saved-seat notices First administrator and seat-control submissions show progress while the request diff --git a/drizzle/0015_admin_seat_ai.sql b/drizzle/0015_admin_seat_ai.sql new file mode 100644 index 00000000..6881dc29 --- /dev/null +++ b/drizzle/0015_admin_seat_ai.sql @@ -0,0 +1,15 @@ +CREATE TABLE `admin_seat_ai_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, + `expected_control_revision` integer NOT NULL, + `applied_version` integer NOT NULL, + `target` text NOT NULL, + `difficulty` text NOT NULL, + `reason` text NOT NULL, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `admin_seat_ai_room` ON `admin_seat_ai_operations` (`room_code`,`created_at`); \ No newline at end of file diff --git a/drizzle/meta/0015_snapshot.json b/drizzle/meta/0015_snapshot.json new file mode 100644 index 00000000..403e5465 --- /dev/null +++ b/drizzle/meta/0015_snapshot.json @@ -0,0 +1,1987 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "51138d82-4316-4680-b370-1ddc93864468", + "prevId": "92b4c430-f821-42c6-a220-e471e9cbc6e5", + "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_archives": { + "name": "admin_room_archives", + "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 + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_archives_room": { + "name": "admin_room_archives_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_closures": { + "name": "admin_room_closures", + "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 + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_closures_room": { + "name": "admin_room_closures_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_room_removals": { + "name": "admin_room_removals", + "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 + }, + "expected_revision": { + "name": "expected_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_revision": { + "name": "applied_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "removed": { + "name": "removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before_metadata": { + "name": "before_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "after_metadata": { + "name": "after_metadata", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "admin_room_removals_room": { + "name": "admin_room_removals_room", + "columns": [ + "room_code", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_seat_ai_operations": { + "name": "admin_seat_ai_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 + }, + "expected_control_revision": { + "name": "expected_control_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_version": { + "name": "applied_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "difficulty": { + "name": "difficulty", + "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_seat_ai_room": { + "name": "admin_seat_ai_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_archives": { + "name": "room_archives", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_archives_room_code_rooms_code_fk": { + "name": "room_archives_room_code_rooms_code_fk", + "tableFrom": "room_archives", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_archives_archived": { + "name": "room_archives_archived", + "value": "\"room_archives\".\"archived\" IN (0, 1)" + }, + "room_archives_revision": { + "name": "room_archives_revision", + "value": "\"room_archives\".\"revision\" >= 0" + }, + "room_archives_time": { + "name": "room_archives_time", + "value": "(\"room_archives\".\"archived\" = 1 AND \"room_archives\".\"archived_at\" IS NOT NULL) OR (\"room_archives\".\"archived\" = 0 AND \"room_archives\".\"archived_at\" IS NULL)" + } + } + }, + "room_closures": { + "name": "room_closures", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "closed": { + "name": "closed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "closed_at": { + "name": "closed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_closures_room_code_rooms_code_fk": { + "name": "room_closures_room_code_rooms_code_fk", + "tableFrom": "room_closures", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_closures_closed": { + "name": "room_closures_closed", + "value": "\"room_closures\".\"closed\" IN (0, 1)" + }, + "room_closures_revision": { + "name": "room_closures_revision", + "value": "\"room_closures\".\"revision\" >= 0" + }, + "room_closures_time": { + "name": "room_closures_time", + "value": "(\"room_closures\".\"closed\" = 1 AND \"room_closures\".\"closed_at\" IS NOT NULL) OR (\"room_closures\".\"closed\" = 0 AND \"room_closures\".\"closed_at\" IS NULL)" + } + } + }, + "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": {} + }, + "room_removals": { + "name": "room_removals", + "columns": { + "room_code": { + "name": "room_code", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "removed": { + "name": "removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "removed_at": { + "name": "removed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "room_removals_room_code_rooms_code_fk": { + "name": "room_removals_room_code_rooms_code_fk", + "tableFrom": "room_removals", + "tableTo": "rooms", + "columnsFrom": [ + "room_code" + ], + "columnsTo": [ + "code" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "room_removals_removed": { + "name": "room_removals_removed", + "value": "\"room_removals\".\"removed\" IN (0, 1)" + }, + "room_removals_revision": { + "name": "room_removals_revision", + "value": "\"room_removals\".\"revision\" >= 0" + }, + "room_removals_time": { + "name": "room_removals_time", + "value": "(\"room_removals\".\"removed\" = 1 AND \"room_removals\".\"removed_at\" IS NOT NULL) OR (\"room_removals\".\"removed\" = 0 AND \"room_removals\".\"removed_at\" IS NULL)" + } + } + }, + "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 fa692499..6f561675 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1790044461950, "tag": "0014_admin_room_archive", "breakpoints": true + }, + { + "idx": 15, + "version": "6", + "when": 1790389395436, + "tag": "0015_admin_seat_ai", + "breakpoints": true } ] } \ No newline at end of file diff --git a/game/admin-seat-ai-shape.ts b/game/admin-seat-ai-shape.ts new file mode 100644 index 00000000..ee379df3 --- /dev/null +++ b/game/admin-seat-ai-shape.ts @@ -0,0 +1,208 @@ +import type { Decision, ResponseWindow } from './engine'; +import type { Card } from './cards'; +import { FACTIONS, PHASES } from './catalog'; + +const record = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); +const count = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) < Number.MAX_SAFE_INTEGER; +const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0; +const strings = (value: unknown): value is string[] => Array.isArray(value) && value.every(text); +const counts = (value: unknown) => record(value) && Object.values(value).every(count); +const sector = (value: unknown): value is number => count(value) && value >= 1 && value <= 18; +const elites = (value: unknown) => record(value) && + ['reserves','tanks','revived'].every(key => count(value[key])) && counts(value.forces); +const cardKinds = { projectile:true, poison:true, lasgun:true, shield:true, snooper:true, poisonBlade:true, shieldSnooper:true, + weirdingWay:true, chemistry:true, poisonTooth:true, artillery:true, worthless:true, hero:true, special:true } satisfies Record; +const card = (value: unknown) => record(value) && text(value.id) && text(value.name) && typeof value.kind === 'string' && Object.hasOwn(cardKinds, value.kind) && (value.effect === undefined || typeof value.effect === 'string'); +const cards = (value: unknown) => Array.isArray(value) && value.every(card); +const leader = (value: unknown) => record(value) && text(value.id) && text(value.name) && count(value.strength) && + FACTIONS.some(faction => faction.id === value.faction) && typeof value.dead === 'boolean' && count(value.deaths); +const spiceCard = (value: unknown) => record(value) && (value.worm === true || value.sandtrout === true || + (text(value.territory) && count(value.amount) && sector(value.sector))); +const spiceCards = (value: unknown) => Array.isArray(value) && value.every(spiceCard); +const optionalCount = (value: unknown) => value === undefined || count(value); +const nullableCount = (value: unknown) => value === null || count(value); + +const decisions = { + harassWithdraw: true, + diplomatDefense: true, + leaderSkillVisibility: true, + leaderSkillRevival: true, + homeworldRevivalDeployment: true, + grummanCollection: true, + caladanReinforcement: true, + choamAudit: true, + choamAuditPayment: true, + strongholdCopy: true, + richeseBlackMarket: true, + richeseDeclaration: true, + richeseCache: true, + richeseUnbid: true, + richeseAllyShipment: true, + richeseAllyOpportunity: true, + ecazAmbassador: true, + moritaniTerror: true, + moritaniSetup: true, + moritaniPlacement: true, + ecazPlacement: true, + ecazSpice: true, + choamStorm: true, + choamMovement: true, + choamMentat: true, + choamFreeRevival: true, + choamBattleFunding: true, + choamMarket: true, + choamTradeReply: true, + choamTradeConfirm: true, + ixSetup: true, + ixAuction: true, + ixTechnology: true, + ixRicheseTechnology: true, + ixAllyCard: true, + mobileStronghold: true, + ixSubstitution: true, + revivalStop: true, + faceDance: true, + techToken: true, + poisonTooth: true, + stoneBurner: true, + sukRescue: true, + rihani: true, + fullPlanOffer: true, + bureaucratPayment: true, + moritaniAssassinate: true, + mentatQuestion: true, + fullPlanRead: true, + homeworldShipmentGuild: true, + guildShipment: true, + nullentropy: true, + handExchange: true, + battleLosses: true, + homeworldDefense: true, + homeworldExplosion: true, + captureOffer: true, + capturedLeader: true, + guildTiming: true, + auctionPayment: true, + advisor: true, + intrusion: true, + advisorBattle: true, + stormLosses: true, + wormPlacement: true, + wormProtection: true, + discoveryDiscard: true, + discoveryEntry: true, + ecologicalStorm: true, + greatMakerVote: true, + greatMakerRide: true, + wormRide: true, + moritaniRetention: true, + battleCards: true, +} satisfies Record; +const responses = { + moritaniPlacement: true, + ecazPlacement: true, + ecazCollection: true, + moritaniAlliance: true, + moritaniDuke: true, + moritaniRetention: true, + voice: true, + prescience: true, + nexusPrescience: true, + advisor: true, + emperorIncome: true, + richesePurchaseIncome: true, + richeseGift: true, + richeseNoField: true, + harkonnenBonus: true, + harkonnenTraitor: true, + guildIncome: true, + emperorGift: true, + emperorRevival: true, + stormPeek: true, + stormProtection: true, + wormPlacement: true, + wormSurvival: true, + wormAllyProtection: true, + atreidesAuction: true, + atreidesSpice: true, + guildTiming: true, + kwisatz: true, + capture: true, + eliteStrength: true, + fremenSupport: true, + advisorFlip: true, + nexusAdvisorFlip: true, + nexusSardaukar: true, + nexusGuildCunning: true, + bgCharity: true, + choamCharity: true, + choamInflation: true, + choamSale: true, + choamWorthless: true, + choamBattleIncome: true, + choamAudit: true, + choamBattleAid: true, + worthlessKarama: true, + faceDancerReplacement: true, + choamRevival: true, + revivalLimit: true, + revivalDiscount: true, + earlyRevival: true, + revivalIncome: true, + foreignGhola: true, + ixSubstitution: true, + ixMovement: true, + fremenMovement: true, + mobileStronghold: true, + richeseAuction: true, + richeseBlackMarket: true, + ixAuction: true, + ixTechnology: true, + ixAllyCard: true, +} satisfies Record; + +/** Bounded core/continuation shape check, not a complete save or rules certification. + * Optional modules retain their own engine validators; do not assume fixed inventories. */ +export function adminSeatAiCoreShape(value: unknown): boolean { + if (!record(value) || value.schema !== 1 || !count(value.turn) || value.turn < 1 || value.turn > 10 || + !count(value.phase) || value.phase >= PHASES.length || !sector(value.storm) || + !Array.isArray(value.players) || value.players.length < 1 || value.players.length > 6 || + (['setup', 'playing'].includes(String(value.status)) && value.players.length < 2)) return false; + const players = value.players; + if (!players.every(p => record(p) && text(p.id))) return false; + const ids = new Set(players.map(p => p.id)); + const seated = (id: unknown) => typeof id === 'string' && ids.has(id); + const seats = (list: unknown) => strings(list) && list.every(seated) && new Set(list).size === list.length; + if (!seated(value.host) || !(value.active === null || seated(value.active)) || !seats(value.order) || !seats(value.ready) || + !(value.movementRemaining === null || seats(value.movementRemaining)) || !seats(value.stormDialers) || + !seats(value.hajr) || !strings(value.wormRides) || !seats(value.freeRevival) || !seats(value.winner) || + !strings(value.expansions) || !value.expansions.every(expansion => ['ix','choam','ecaz'].includes(expansion)) || + !cards(value.deck) || !cards(value.discard) || !counts(value.spice) || !counts(value.stormDials) || + !Array.isArray(value.spiceDeck) || !value.spiceDeck.every(spiceCard) || + !Array.isArray(value.spiceDiscard) || value.spiceDiscard.length !== 2 || !value.spiceDiscard.every(pile => Array.isArray(pile) && pile.every(spiceCard)) || + !['guildTimingGranted','guildTimingLocked','spicePeekKnown','stormCardKnown','nexus','shieldWallDestroyed'].every(key => typeof value[key] === 'boolean') || + !record(value.allianceOffers) || !Object.entries(value.allianceOffers).every(([owner, recipient]) => seated(owner) && seated(recipient)) || + !counts(value.emperorExtra) || !record(value.aid) || + !Object.entries(value.aid).every(([owner, offer]) => seated(owner) && record(offer) && seated(offer.recipient) && count(offer.amount))) return false; + const auction = value.auction, storm = value.stormResolution, blow = value.spiceWindow; + if (auction !== null && (!record(auction) || !cards(auction.cards) || !count(auction.index) || !count(auction.bid) || + !(auction.bidder === null || seated(auction.bidder)) || !seated(auction.active) || !seats(auction.passed) || !count(auction.opener) || !optionalCount(auction.allyPayment))) return false; + if (!nullableCount(value.stormPending) || !nullableCount(value.stormCard) || + (storm !== null && (!record(storm) || !sector(storm.from) || !count(storm.distance) || !count(storm.traversed) || !strings(storm.pending)))) return false; + if (blow !== null && (!record(blow) || !text(blow.territory) || !sector(blow.sector) || !count(blow.amount) || + typeof blow.harvested !== 'boolean' || !optionalCount(blow.harvesters))) return false; + if (value.spiceResolution !== null && (!record(value.spiceResolution) || !spiceCards(value.spiceResolution.skipped))) return false; + if (value.spiceSequence !== null && (!record(value.spiceSequence) || ![0, 1].includes(Number(value.spiceSequence.pile)) || + typeof value.spiceSequence.pile !== 'number' || !spiceCards(value.spiceSequence.skipped))) return false; + if (value.karamaShipping !== null && (!record(value.karamaShipping) || !seated(value.karamaShipping.player) || !seated(value.karamaShipping.owner))) return false; + if (value.pendingCapture !== null && (!record(value.pendingCapture) || !seated(value.pendingCapture.player) || !seated(value.pendingCapture.loser) || !text(value.pendingCapture.territory))) return false; + if (!players.every(p => record(p) && ['spice','reserves','tanks','bribes','revived','revivalCycle','moved','battleLosses'].every(key => count(p[key])) && + ['ready','leaderRevived','shipped'].every(key => typeof p[key] === 'boolean') && counts(p.forces) && cards(p.hand) && + Array.isArray(p.leaders) && p.leaders.every(leader) && strings(p.traitors) && strings(p.traitorChoices) && + (p.ally === null || seated(p.ally)) && (p.elites === undefined || elites(p.elites)))) return false; + if (value.decision !== null && (!record(value.decision) || typeof value.decision.kind !== 'string' || + !Object.hasOwn(decisions, value.decision.kind) || !seated(value.decision.player))) return false; + if (value.response !== null && (!record(value.response) || typeof value.response.kind !== 'string' || + !Object.hasOwn(responses, value.response.kind) || !seated(value.response.owner) || !seats(value.response.passed))) return false; + return true; +} diff --git a/game/admin-seat-ai.ts b/game/admin-seat-ai.ts new file mode 100644 index 00000000..15309fec --- /dev/null +++ b/game/admin-seat-ai.ts @@ -0,0 +1,45 @@ +import { adminSeatAiCoreShape } from './admin-seat-ai-shape'; +import { applyAction, type Game } from './engine'; +import { FACTIONS } from './catalog'; +import { DIFFICULTIES, type Difficulty } from './bot-profiles'; +import type { AdminSeatAiView } from '../lib/admin-seat-ai'; + +export const seatAiUnavailable = 'This saved game needs inspection before changing participant control.'; +const record = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); +const counter = (v: unknown) => Number.isSafeInteger(v) && Number(v) >= 0 && Number(v) < Number.MAX_SAFE_INTEGER; +const difficulty = (v: unknown) => v === undefined || DIFFICULTIES.some(d => d === v); +/** Guard the public envelope before projecting it; engine integrity guards run again on mutation. */ +export function seatAiGame(value: unknown, code: string): value is Game { + if (!adminSeatAiCoreShape(value) || !record(value) || value.code !== code || !['lobby','setup','playing','finished'].includes(String(value.status)) || + typeof value.advanced !== 'boolean' || !counter(value.version) || !Array.isArray(value.players) || value.players.length < 1 || value.players.length > 6 || + !Array.isArray(value.log) || value.log.length > 250 || !value.log.every((entry, index, log) => record(entry) && counter(entry.seq) && Number(entry.seq) > 0 && typeof entry.text === 'string' && (index === 0 || Number(entry.seq) > Number(log[index - 1].seq))) || + (value.botsPending !== undefined && typeof value.botsPending !== 'boolean') || (value.botNextActionAt !== undefined && !counter(value.botNextActionAt))) return false; + return value.players.every(p => record(p) && typeof p.id === 'string' && !!p.id.trim() && p.id.length <= 80 && typeof p.name === 'string' && p.name.length <= 80 && + FACTIONS.some(f => f.id === p.faction) && difficulty(p.bot) && difficulty(p.autopilot) && !(p.bot && p.autopilot)) && + new Set(value.players.map(p => p.id)).size === value.players.length && new Set(value.players.map(p => p.faction)).size === value.players.length; +} +export function projectAdminSeatAi(base: Omit, value: unknown, activeHumans: Set): AdminSeatAiView { + if (!seatAiGame(value, base.code)) return { ...base, status: 'unreadable', players: [], editable: false, blockedReason: seatAiUnavailable }; + const blockedReason = base.removed ? 'Restore this removed room first.' : base.archived ? 'Unarchive and reopen this room first.' : base.closed ? 'Reopen this room first.' : + !['setup','playing'].includes(value.status) ? 'Participant AI is available only after the game starts and before it ends.' : !base.paused ? 'Pause this room before changing participant control.' : null; + return { ...base, status: value.status, blockedReason, editable: blockedReason === null, players: value.players.map(p => ({ + id: p.id, name: p.name, faction: p.faction, control: p.bot ? 'ai' : p.autopilot ? 'autopilot' : 'human', difficulty: p.bot ?? p.autopilot ?? null, + eligible: !p.bot && !p.autopilot && activeHumans.has(p.id), + })) }; +} +/** Uses the ordinary legal controller change, without room-worker scheduling or private projection. */ +export function enableAdminSeatAi(game: Game, target: string, difficulty: Difficulty): Game { + if (!seatAiGame(game, game.code)) throw new Error(seatAiUnavailable); + const owner = game.players.find(p => p.id === target); + if (!owner || owner.bot || owner.autopilot) throw new Error(seatAiUnavailable); + const next = applyAction(game, target, { type: 'setAutopilot', difficulty }); + // setAutopilot appends one event, dropping the oldest at the 250-entry cap. + const event = next.log.at(-1), seq = (game.log.at(-1)?.seq ?? 0) + 1; + if (!event || event.seq !== seq || event.text !== `${owner.name} enabled ${difficulty} AI control for their seat.`) throw new Error(seatAiUnavailable); + const expected = structuredClone(game); + expected.players.find(p => p.id === target)!.autopilot = difficulty; + expected.log.push({ seq, text: event.text }); if (expected.log.length > 250) expected.log.shift(); + if (JSON.stringify(next) !== JSON.stringify(expected)) throw new Error(seatAiUnavailable); + event.text = `An administrator enabled ${difficulty} AI for ${owner.name}. The room remains paused; this player retains their seat and can take back control.`; + return next; +} diff --git a/lib/admin-seat-ai-client.ts b/lib/admin-seat-ai-client.ts new file mode 100644 index 00000000..940c4eee --- /dev/null +++ b/lib/admin-seat-ai-client.ts @@ -0,0 +1,69 @@ +import { FACTIONS } from '../game/catalog'; +import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; +import { randomId } from './random-id'; +import { validAdminSeatAiInput, type AdminSeatAiInput, type AdminSeatAiView, type AdminSeatAiResult } from './admin-seat-ai'; + +type Storage = Pick; +const key = (account: string, code: string) => `dune.admin-seat-ai-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 validAdminSeatAiView(value: unknown): value is AdminSeatAiView { + if (!object(value) || !keys(value, ['code','version','controlRevision','status','paused','closed','removed','archived','editable','blockedReason','players']) || + typeof value.code !== 'string' || !/^[A-Z2-9]{8}$/.test(value.code) || value.code.length !== 8 || !integer(value.version) || !integer(value.controlRevision) || + !['lobby','setup','playing','finished','unreadable'].includes(String(value.status)) || + !['paused','closed','removed','archived','editable'].every(key => typeof value[key] === 'boolean') || + !(value.blockedReason === null || typeof value.blockedReason === 'string') || !Array.isArray(value.players) || value.players.length > 6) return false; + return value.players.every(player => object(player) && keys(player, ['id','name','faction','control','difficulty','eligible']) && + typeof player.id === 'string' && !!player.id && player.id.length <= 80 && typeof player.name === 'string' && player.name.length <= 80 && + FACTIONS.some(faction => faction.id === player.faction) && ['human','ai','autopilot'].includes(String(player.control)) && + (player.difficulty === null || DIFFICULTIES.some(difficulty => difficulty === player.difficulty)) && typeof player.eligible === 'boolean'); +} + +export function adminSeatAiResponse(value: unknown, code: string): AdminSeatAiView { + if (!validAdminSeatAiView(value) || value.code !== code) throw new Error('The server returned unreadable room availability.'); + return value; +} + +export function adminSeatAiConfirmation(value: unknown, code: string, input: AdminSeatAiInput): AdminSeatAiResult { + if (!object(value) || !keys(value, ['operationId', 'replayed', 'appliedVersion', 'room']) || + 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 participant AI request.'); + const room = adminSeatAiResponse(value.room, code); + if (room.version < value.appliedVersion) + throw new Error('The server returned room availability older than the confirmed change.'); + return value as AdminSeatAiResult; +} + +export function newAdminSeatAiRequest(view: AdminSeatAiView, target: string, difficulty: Difficulty, reason: string): AdminSeatAiInput { + const input = { operationId: randomId(), expectedVersion: view.version, expectedControlRevision: view.controlRevision, target, difficulty, reason: reason.trim() }; + if (!validAdminSeatAiInput(input)) throw new Error('Choose a participant, difficulty and a reason of 1–300 characters on one line.'); + if (!view.editable) throw new Error(view.blockedReason ?? 'Refresh the room before changing participant control.'); + if (!view.players.some(player => player.id === target && player.eligible)) throw new Error('Choose an eligible human participant.'); + return input; +} + +export function readAdminSeatAiRequest(storage: Storage, account: string, code: string): AdminSeatAiInput | 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 (!validAdminSeatAiInput(value)) throw new Error('This tab’s saved participant AI request is unreadable. Inspect the current room before discarding its local retry record.'); + return value; +} + +/** Save before sending; a different unresolved request must never be replaced. */ +export function saveAdminSeatAiRequest(storage: Storage, account: string, code: string, input: AdminSeatAiInput) { + if (!validAdminSeatAiInput(input)) throw new Error('Invalid participant AI request.'); + const previous = readAdminSeatAiRequest(storage, account, code), encoded = JSON.stringify(input); + if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved participant AI request before sending a different change.'); + storage.setItem(key(account, code), encoded); + if (storage.getItem(key(account, code)) !== encoded) throw new Error('The participant AI request could not be preserved in this tab. Nothing was sent.'); +} + +export function clearAdminSeatAiRequest(storage: Storage, account: string, code: string) { + storage.removeItem(key(account, code)); + if (storage.getItem(key(account, code)) !== null) throw new Error('The saved participant AI request could not be cleared.'); +} diff --git a/lib/admin-seat-ai.ts b/lib/admin-seat-ai.ts new file mode 100644 index 00000000..95c9dc40 --- /dev/null +++ b/lib/admin-seat-ai.ts @@ -0,0 +1,27 @@ +import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; + +export type AdminSeatAiInput = { + operationId: string; expectedVersion: number; expectedControlRevision: number; + target: string; difficulty: Difficulty; reason: string; +}; +export type AdminSeatAiView = { + code: string; version: number; controlRevision: number; + status: 'lobby' | 'setup' | 'playing' | 'finished' | 'unreadable'; + paused: boolean; closed: boolean; removed: boolean; archived: boolean; + editable: boolean; blockedReason: string | null; + players: { id: string; name: string; faction: string; control: 'human' | 'ai' | 'autopilot'; difficulty: Difficulty | null; eligible: boolean }[]; +}; +export type AdminSeatAiResult = { operationId: string; replayed: boolean; appliedVersion: number; room: AdminSeatAiView }; + +const hasControlCharacter = (text: string) => text.split('').some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127); + +export function validAdminSeatAiInput(value: unknown): value is AdminSeatAiInput { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const input = value as Record, fields = ['operationId', 'expectedVersion', 'expectedControlRevision', 'target', 'difficulty', 'reason']; + return Object.keys(input).length === fields.length && fields.every(key => Object.hasOwn(input, key)) && + typeof input.operationId === 'string' && input.operationId.length === 36 && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(input.operationId) && + [input.expectedVersion, input.expectedControlRevision].every(value => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) < Number.MAX_SAFE_INTEGER) && + typeof input.target === 'string' && input.target.length > 0 && input.target.length <= 80 && input.target.trim() === input.target && !hasControlCharacter(input.target) && + DIFFICULTIES.some(difficulty => difficulty === input.difficulty) && + typeof input.reason === 'string' && !!input.reason.trim() && input.reason.length <= 300 && !hasControlCharacter(input.reason); +} diff --git a/tests/admin-boundary-http.test.ts b/tests/admin-boundary-http.test.ts index a1445832..7106adc7 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`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`]) { + for (const path of ['/api/admin/session', '/api/admin/rooms', `/api/admin/rooms/${room}/control`, `/api/admin/rooms/${room}/lobby`, `/api/admin/rooms/${room}/removal`, `/api/admin/rooms/${room}/closure`, `/api/admin/rooms/${room}/archive`, `/api/admin/rooms/${room}/seat-ai`]) { 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'); @@ -57,6 +57,13 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden assert.equal(archive.status, 401); assert.equal(archive.headers.get('set-cookie'), null); assert.deepEqual(Object.keys(await archive.json()), ['error']); + const participant = await fetch(`${base}/api/admin/rooms/${room}/seat-ai`, { + method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' }, + body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedControlRevision: 0, target: 'owner', difficulty: 'Easy', reason: 'Denied participant QA' }), + }); + assert.equal(participant.status, 401); + assert.equal(participant.headers.get('set-cookie'), null); + assert.deepEqual(Object.keys(await participant.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-seat-ai-client.test.ts b/tests/admin-seat-ai-client.test.ts new file mode 100644 index 00000000..32dee59c --- /dev/null +++ b/tests/admin-seat-ai-client.test.ts @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { adminSeatAiConfirmation, adminSeatAiResponse, clearAdminSeatAiRequest, newAdminSeatAiRequest, readAdminSeatAiRequest, saveAdminSeatAiRequest } from '../lib/admin-seat-ai-client'; +import type { AdminSeatAiView } from '../lib/admin-seat-ai'; + +const view = (): AdminSeatAiView => ({ code: 'SEATQAAB', version: 7, controlRevision: 2, status: 'playing', paused: true, closed: false, removed: false, archived: false, + editable: true, blockedReason: null, players: [{ id: 'owner', name: 'QA Owner', faction: 'atreides', control: 'human', difficulty: null, eligible: 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('participant retry stores exact intent before dispatch, survives refresh, and isolates room/account', () => { + const tab = storage(), room = view(), input = newAdminSeatAiRequest(room, 'owner', 'Hard', ' QA support '); + assert.equal(input.reason, 'QA support'); + saveAdminSeatAiRequest(tab, 'admin', room.code, input); + assert.deepEqual(readAdminSeatAiRequest(tab, 'admin', room.code), input); + assert.equal(readAdminSeatAiRequest(tab, 'other', room.code), null); + assert.equal(readAdminSeatAiRequest(tab, 'admin', 'OTHERQAA'), null); + for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedControlRevision: 3 }, { target: 'other' }, { difficulty: 'Easy' as const }, { reason: 'Different' }]) + assert.throws(() => saveAdminSeatAiRequest(tab, 'admin', room.code, { ...input, ...change }), /Resolve the saved/); + saveAdminSeatAiRequest(tab, 'admin', room.code, input); + clearAdminSeatAiRequest(tab, 'admin', room.code); assert.equal(readAdminSeatAiRequest(tab, 'admin', room.code), null); +}); +void test('bad participant retry storage cannot lose or overwrite unresolved intent', () => { + const tab = storage(), room = view(), input = newAdminSeatAiRequest(room, 'owner', 'Easy', 'QA'); + assert.throws(() => saveAdminSeatAiRequest({ ...tab, setItem() {} }, 'admin', room.code, input), /Nothing was sent/); + saveAdminSeatAiRequest(tab, 'admin', room.code, input); + const key = [...tab.values.keys()][0]; + for (const raw of ['{broken', JSON.stringify({ ...input, token: 'not accepted' }), ' '.repeat(2049), JSON.stringify({ ...input, expectedControlRevision: -1 })]) { + tab.values.set(key, raw); + assert.throws(() => readAdminSeatAiRequest(tab, 'admin', room.code), /unreadable/); + assert.throws(() => saveAdminSeatAiRequest(tab, 'admin', room.code, input), /unreadable/); + assert.equal(tab.values.get(key), raw); + } + assert.throws(() => clearAdminSeatAiRequest({ ...tab, removeItem() {} }, 'admin', room.code), /could not be cleared/); + clearAdminSeatAiRequest(tab, 'admin', room.code); + for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'control\u007f']) assert.throws(() => newAdminSeatAiRequest(room, 'owner', 'Easy', reason)); + assert.throws(() => newAdminSeatAiRequest({ ...room, editable: false, blockedReason: 'Pause first' }, 'owner', 'Easy', 'QA'), /Pause first/); + assert.throws(() => newAdminSeatAiRequest(room, 'absent', 'Easy', 'QA'), /eligible/); +}); +void test('participant confirmation binds original operation/version but accepts later human takeback', () => { + const room = view(), input = newAdminSeatAiRequest(room, 'owner', 'Hard', 'QA'); + const result = { operationId: input.operationId, appliedVersion: 8, replayed: true, room: { ...room, version: 10, paused: false, editable: false, blockedReason: 'Pause first' } }; + assert.deepEqual(adminSeatAiConfirmation(result, room.code, input), result); + for (const bad of [{ ...result, operationId: crypto.randomUUID() }, { ...result, appliedVersion: 9 }, { ...result, replayed: 'yes' }, + { ...result, room: { ...room, version: 7 } }, { ...result, token: 'unexpected' }]) assert.throws(() => adminSeatAiConfirmation(bad, room.code, input)); +}); +void test('participant responses reject unexpected private fields and malformed operational values', () => { + const room = view(); assert.deepEqual(adminSeatAiResponse(room, room.code), room); + for (const bad of [null, [], { ...room, state: {} }, { ...room, auditReason: 'private' }, { ...room, code: 'bad' }, + { ...room, version: -1 }, { ...room, paused: 'yes' }, { ...room, controlRevision: 1.2 }, + { ...room, players: [{ ...room.players[0], hand: [] }] }, { ...room, players: [{ ...room.players[0], difficulty: 'Impossible' }] }]) + assert.throws(() => adminSeatAiResponse(bad, room.code)); +}); diff --git a/tests/admin-seat-ai-game.test.ts b/tests/admin-seat-ai-game.test.ts new file mode 100644 index 00000000..5f2fa19c --- /dev/null +++ b/tests/admin-seat-ai-game.test.ts @@ -0,0 +1,107 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { botActions } from '../game/bots'; +import { DIFFICULTIES } from '../game/bot-profiles'; +import { enableAdminSeatAi, projectAdminSeatAi, seatAiGame, seatAiUnavailable } from '../game/admin-seat-ai'; +import { adminSeatAiCoreShape } from '../game/admin-seat-ai-shape'; +import type { FactionId } from '../game/catalog'; + +const restore = (game: Game): Game => JSON.parse(JSON.stringify(game)); +const base = { code: 'SEATQAAB', version: 2, controlRevision: 1, paused: true, closed: false, removed: false, archived: false }; +const factions: FactionId[] = ['atreides','harkonnen','emperor','fremen','guild','beneGesserit']; +function start(roster: FactionId[] = ['atreides','emperor'], advanced = false) { + let game = createGame(base.code, newPlayer(roster[0], roster[0], roster[0]), advanced); + for (const faction of roster.slice(1)) joinGame(game, newPlayer(faction, faction, faction)); + for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' }); + return applyAction(game, game.host, { type: 'start', ...(advanced ? { advancedPreview: true } : {}) }); +} + +void test('participant AI preserves genuine Basic and Advanced preview setup and saved continuation', () => { + for (const advanced of [false, true]) for (const roster of [['atreides','emperor'], factions] as FactionId[][]) { + let game = start(roster, advanced), steps = 0; + do { + assert.ok(seatAiGame(restore(game), base.code), `${advanced}: ${game.setupStage}`); + for (const difficulty of DIFFICULTIES) { + const snapshot = restore(game), before = restore(snapshot); + snapshot.botsPending = true; snapshot.botNextActionAt = 50_000; + const next = enableAdminSeatAi(snapshot, game.host, difficulty); + assert.equal(next.players[0].autopilot, difficulty); + assert.equal(next.botsPending, true); assert.equal(next.botNextActionAt, 50_000); + const expected = restore(snapshot); + expected.players[0].autopilot = difficulty; + expected.log.push(next.log.at(-1)!); + assert.deepEqual(next, expected, 'Only the chosen controller and truthful event change'); + assert.deepEqual(snapshot, { ...before, botsPending: true, botNextActionAt: 50_000 }, 'Source stays immutable'); + assert.match(next.log.at(-1)!.text, /administrator enabled.*room remains paused/); + assert.doesNotThrow(() => viewGame(restore(next), game.host)); + } + if (game.status !== 'setup') break; + const owner = viewGame(game, game.host).setupPending[0]; + assert.ok(owner); + const view = viewGame(game, owner); + view.players.find(player => player.id === owner)!.bot = 'Easy'; + let next: Game | undefined; + for (const action of botActions(view)) { try { next = applyAction(game, owner, action); break; } catch { /* Choose a legal setup action. */ } } + assert.ok(next, 'Every setup obligation has a legal continuation'); + game = restore(next); + } while (++steps < 30); + assert.equal(game.status, 'playing'); + } +}); + +void test('malformed saved core and continuation fail closed without exposing or repairing private state', () => { + const genuine = start(); + const cases: [string, (game: Record) => void][] = [ + ['deck', game => { delete game.deck; }], ['private hand', game => { delete (game.players as Record[])[0].hand; }], + ['negative reserves', game => { (game.players as Record[])[0].reserves = -5; }], + ['phase', game => { game.phase = 999; }], ['storm 0', game => { game.storm = 0; }], ['storm 19', game => { game.storm = 19; }], + ['unknown decision', game => { game.decision = { kind: 'private-invalid', player: genuine.host }; }], + ...['auction','stormResolution','spiceWindow','spiceSequence','spiceResolution','pendingCapture','karamaShipping'].map(key => [key, (game: Record) => { game[key] = {}; }] as [string, (game: Record) => void]), + ['aid', game => { game.aid = { [genuine.host]: null }; }], + ]; + for (const [name, corrupt] of cases) { + const game = restore(genuine); corrupt(game as unknown as Record); + const before = restore(game); + assert.equal(seatAiGame(game, base.code), false, name); + assert.throws(() => enableAdminSeatAi(game, game.host, 'Easy'), { message: seatAiUnavailable }, name); + assert.deepEqual(game, before, name); + assert.deepEqual(projectAdminSeatAi(base, game, new Set([game.host])), { ...base, status: 'unreadable', players: [], editable: false, blockedReason: seatAiUnavailable }); + } +}); + +void test('core domain permits sector 18 and repeated territory ride destinations and storm force locations', () => { + const game = start(); game.storm = 18; + assert.ok(game.spiceDeck.some(card => 'sector' in card && card.sector === 18)); + game.wormRides = ['red_chasm','red_chasm']; + game.stormResolution = { from: 18, distance: 3, traversed: 1, pending: ['red_chasm:1'] }; + assert.equal(adminSeatAiCoreShape(restore(game)), true); +}); + +void test('admin projection includes only public participant fields and eligibility requires current access', () => { + const game = start(); game.players[1].autopilot = 'Medium'; + const view = projectAdminSeatAi(base, game, new Set([game.host])); + assert.equal(view.editable, true); + assert.deepEqual(view.players[0], { id: game.host, name: game.players[0].name, faction: game.players[0].faction, control: 'human', difficulty: null, eligible: true }); + assert.equal(view.players[1].eligible, false); + assert.deepEqual(Object.keys(view.players[1]).sort(), ['control','difficulty','eligible','faction','id','name']); + assert.equal(projectAdminSeatAi(base, game, new Set()).players[0].eligible, false); + for (const flags of [{ paused: false }, { removed: true }, { closed: true }, { archived: true }]) { + const blocked = projectAdminSeatAi({ ...base, ...flags }, game, new Set([game.host])); + assert.equal(blocked.editable, false); assert.ok(blocked.blockedReason); + } + for (const status of ['lobby','finished'] as const) { + const blocked = projectAdminSeatAi(base, { ...game, status }, new Set([game.host])); + assert.equal(blocked.editable, false); + } +}); + +void test('participant controller change preserves capped chronicle sequence and cannot replace an existing controller', () => { + const game = start(); game.log = Array.from({ length: 250 }, (_, index) => ({ seq: index + 20, text: `Saved event ${index}` })); + const next = enableAdminSeatAi(game, game.host, 'Brutal'); + assert.equal(next.log.length, 250); assert.deepEqual(next.log.slice(0, -1), game.log.slice(1)); + assert.equal(next.log.at(-1)!.seq, 270); + assert.throws(() => enableAdminSeatAi(next, game.host, 'Easy'), { message: seatAiUnavailable }); + const bot = restore(game); bot.players[0].bot = 'Easy'; + assert.throws(() => enableAdminSeatAi(bot, game.host, 'Easy'), { message: seatAiUnavailable }); +}); diff --git a/tests/admin-seat-ai.test.ts b/tests/admin-seat-ai.test.ts new file mode 100644 index 00000000..7d7445f1 --- /dev/null +++ b/tests/admin-seat-ai.test.ts @@ -0,0 +1,143 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; +import { adminStore, sha256 } from './admin-access-fixture'; +import { adminLogin, requireAdmin, AdminError, type AdminRole } from '../db/admin-access'; +import { applyAdminSeatAi, readAdminSeatAi } from '../db/admin-seat-ai'; +import { applyAdminRoomControl } from '../db/admin-lifecycle'; +import { createGame, joinGame, newPlayer, applyAction, type Game } from '../game/engine'; +import { adminSeatAiConfirmation } from '../lib/admin-seat-ai-client'; +import type { AdminSeatAiInput } from '../lib/admin-seat-ai'; + +const now = 10_000, code = 'SEATSQLS'; +const status = (...wanted: number[]) => (error: unknown) => error instanceof AdminError && wanted.includes(error.status); +async function fixture(role: AdminRole = 'operator') { + const store = adminStore(); assert.ok(store.sqlite instanceof DatabaseSync); + const account = store.provision(role, 'Private administrator identity'); + const login = await adminLogin(store.database, account.key, now); + const identity = await requireAdmin(store.database, login.token, undefined, now); + const owner = newPlayer(randomUUID(), 'QA Owner', 'atreides'), other = newPlayer(randomUUID(), 'QA Other', 'emperor'); + let game = createGame(code, owner); joinGame(game, other); + game = applyAction(game, owner.id, { type: 'ready' }); + game = applyAction(game, other.id, { type: 'ready' }); + game = applyAction(game, owner.id, { type: 'start' }); + game.botsPending = true; game.botNextActionAt = 50_000; + store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run(code, JSON.stringify(game), 0, 1); + store.sqlite.prepare('INSERT INTO room_controls(room_code,paused,revision,updated_at) VALUES(?,1,1,1)').run(code); + const tokenHash = sha256('QA-owner-seat'); + store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)').run(tokenHash, code, owner.id); + store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id,revoked) VALUES(?,?,?,0)').run(sha256('QA-other-seat'), code, other.id); + const input: AdminSeatAiInput = { operationId: randomUUID(), expectedVersion: 0, expectedControlRevision: 1, target: owner.id, difficulty: 'Hard', reason: 'Private operational reason' }; + const apply = (request = input) => applyAdminSeatAi(store.database, identity, code, request, now); + const row = () => store.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get(code)! as { code: string; state: string; version: number; updated_at: number }; + const saved = (): Game => JSON.parse(row().state); + const auditCount = () => Number(store.sqlite.prepare('SELECT COUNT(*) AS n FROM admin_seat_ai_operations').get()!.n); + return { ...store, identity, input, owner, other, tokenHash, apply, row, saved, auditCount }; +} +type Fixture = Awaited>; +function pauseCommit(f: Fixture) { + let enter!: () => void, release!: () => void, calls = 0; + const waiting = new Promise(resolve => { enter = resolve; }), gate = new Promise(resolve => { release = resolve; }); + f.hooks.beforeBatch = async () => { if (++calls === 2) { delete f.hooks.beforeBatch; enter(); await gate; } }; + return { waiting, release }; +} + +void test('participant AI transaction preserves private custody, credentials, pause pacing and unrelated rooms', async () => { + const f = await fixture(); + try { + const insert = f.sqlite.prepare('INSERT INTO seat_ai_delegations(room_code,owner_id,grant_id,delegate_id,difficulty,owner_session_hash,delegate_session_hash,expires_at,used_at,revoked_at) VALUES(?,?,?,?,?,?,?,?,?,?)'); + const grants: [string,string,string,number|null,number|null][] = [ + ['unused', f.owner.id, f.other.id, null, null], ['used', f.owner.id, f.other.id, 99, null], + ['revoked', f.owner.id, f.other.id, null, 99], ['incoming', f.other.id, f.owner.id, null, null], + ]; + for (const [grant, owner, delegate, used, revoked] of grants) insert.run(code, owner, grant, delegate, 'Easy', f.tokenHash, sha256('QA-other-seat'), now + 100, used, revoked); + f.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run('OTHERQAA', f.row().state, 20, 50); + const otherRoom = f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get('OTHERQAA'); + const seats = f.sqlite.prepare('SELECT * FROM seats').all(), original = f.saved(); + const result = await f.apply(); adminSeatAiConfirmation(result, code, f.input); + assert.equal(result.replayed, false); assert.equal(result.appliedVersion, 1); + const next = f.saved(), expected = structuredClone(original); + expected.players[0].autopilot = 'Hard'; expected.version = 1; expected.log.push(next.log.at(-1)!); + assert.deepEqual(next, expected); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats); + assert.deepEqual(f.sqlite.prepare('SELECT * FROM rooms WHERE code=?').get('OTHERQAA'), otherRoom); + assert.deepEqual(f.sqlite.prepare('SELECT grant_id,used_at,revoked_at FROM seat_ai_delegations ORDER BY grant_id').all().map(row => ({ ...row })), [ + { grant_id: 'incoming', used_at: null, revoked_at: null }, { grant_id: 'revoked', used_at: null, revoked_at: 99 }, + { grant_id: 'unused', used_at: null, revoked_at: now }, { grant_id: 'used', used_at: 99, revoked_at: null }, + ]); + assert.deepEqual(Object.keys(result.room.players[0]).sort(), ['control','difficulty','eligible','faction','id','name']); + assert.doesNotMatch(JSON.stringify(result), /Private administrator identity|Private operational reason|token_hash|traitor|hand|recovery/i); + assert.equal(f.auditCount(), 1); + } finally { f.sqlite.close(); } +}); + +void test('exact participant retry after takeback, resume and removal never reapplies controller change', async () => { + const f = await fixture(); + try { + await f.apply(); + const later = applyAction(f.saved(), f.owner.id, { type: 'setAutopilot', difficulty: null }); later.version = 2; + f.sqlite.prepare('UPDATE rooms SET state=?,version=2 WHERE code=?').run(JSON.stringify(later), code); + f.sqlite.prepare('UPDATE room_controls SET paused=0,revision=2 WHERE room_code=?').run(code); + f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,1,1)').run(code); + const stable = f.row(), replay = await f.apply(); adminSeatAiConfirmation(replay, code, f.input); + assert.equal(replay.replayed, true); assert.equal(replay.room.removed, true); assert.equal(replay.room.players[0].control, 'human'); + assert.deepEqual(f.row(), stable); assert.equal(f.auditCount(), 1); + await assert.rejects(() => f.apply({ ...f.input, difficulty: 'Easy' }), status(409)); + } finally { f.sqlite.close(); } +}); + +for (const [name, sql] of [ + ['seat revocation', 'UPDATE seats SET revoked=1'], ['version change', 'UPDATE rooms SET version=version+1'], + ['resume', 'UPDATE room_controls SET paused=0,revision=revision+1'], ['control revision', 'UPDATE room_controls SET revision=revision+1'], + ['removal', "INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES('SEATSQLS',1,1,1,1)"], + ['closure', "INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES('SEATSQLS',1,1,1,1)"], + ['archive', "INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES('SEATSQLS',1,1,1,1)"], + ['role demotion', "UPDATE admin_accounts SET role='viewer'"], ['session revocation', 'UPDATE admin_sessions SET revoked_at=1'], + ['session generation', 'UPDATE admin_accounts SET session_generation=session_generation+1'], ['session expiry', 'UPDATE admin_sessions SET expires_at=1'], +]) void test(`participant AI commit fences ${name}`, async () => { + const f = await fixture(); + try { + const original = f.row().state; let calls = 0; + f.hooks.beforeBatch = async () => { if (++calls === 2) { delete f.hooks.beforeBatch; f.sqlite.exec(sql); } }; + await assert.rejects(f.apply, status(401, 403, 409)); + assert.equal(f.row().state, original); assert.equal(f.auditCount(), 0); + } finally { f.sqlite.close(); } +}); + +void test('participant AI receipt failure rolls back game write and pending permissions', async () => { + const f = await fixture(); + try { + const before = f.row(); + f.sqlite.exec("CREATE TRIGGER reject_receipt BEFORE INSERT ON admin_seat_ai_operations BEGIN SELECT RAISE(ABORT,'fixture receipt failure'); END;"); + await assert.rejects(f.apply, /fixture receipt failure/); assert.deepEqual(f.row(), before); assert.equal(f.auditCount(), 0); + } finally { f.sqlite.close(); } +}); + +for (const different of [false, true]) void test(`racing ${different ? 'conflicting' : 'identical'} participant request retains one operation`, async () => { + const f = await fixture(); + try { + const barrier = pauseCommit(f), pending = f.apply(); await barrier.waiting; + const winner = await f.apply(different ? { ...f.input, difficulty: 'Easy' } : f.input); + assert.equal(winner.replayed, false); const state = f.row(); barrier.release(); + if (different) await assert.rejects(() => pending, status(409)); else assert.equal((await pending).replayed, true); + assert.deepEqual(f.row(), state); assert.equal(f.auditCount(), 1); + } finally { f.sqlite.close(); } +}); + +void test('viewer reads public participant availability but cannot change it', async () => { + const f = await fixture('viewer'); + try { + assert.equal((await readAdminSeatAi(f.database, f.identity, code, now)).players.length, 2); + const before = f.row(); await assert.rejects(f.apply, status(403)); assert.deepEqual(f.row(), before); + } finally { f.sqlite.close(); } +}); + +void test('resume starts a fresh paced continuation only after the administrator enables AI', async () => { + const f = await fixture(); + try { + await f.apply(); assert.equal(f.saved().botNextActionAt, 50_000); + await applyAdminRoomControl(f.database, f.identity, code, { operationId: randomUUID(), expectedRevision: 1, paused: false, joinLocked: false, reason: 'QA resume' }, now); + assert.equal(f.saved().botsPending, true); assert.equal(f.saved().botNextActionAt, now + 1500); + } finally { f.sqlite.close(); } +}); diff --git a/tools/admin-seat-ai-verification.mjs b/tools/admin-seat-ai-verification.mjs new file mode 100644 index 00000000..e83bd2ba --- /dev/null +++ b/tools/admin-seat-ai-verification.mjs @@ -0,0 +1,81 @@ +// All mutations use a newly created QA room. Never accepts an existing human room. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { setTimeout as delay } from 'node:timers/promises'; + +export async function verifyParticipantAi(request, adminCookie, report) { + const made = await request('/api/rooms', { name: 'Participant AI acceptance QA', faction: 'atreides', advanced: false, expansions: [] }); + assert.equal(made.status, 201); + const code = made.data.code, host = made.cookie, path = '/api/rooms/' + code, adminPath = '/api/admin/rooms/' + code; + report.participantAiRoom = code; + const joined = await request(path, { type: 'join', name: 'Participant AI guest QA', faction: 'emperor' }); + assert.equal(joined.status, 200); + const guest = joined.cookie; + async function action(cookie, action) { + const view = await request(path, undefined, cookie); assert.equal(view.status, 200); + return request(path, { version: view.data.version, action }, cookie); + } + async function pause(paused) { + const current = await request(adminPath + '/control', undefined, adminCookie); assert.equal(current.status, 200); + const result = await request(adminPath + '/control', { operationId: randomUUID(), expectedRevision: current.data.revision, paused, joinLocked: false, reason: 'Participant AI acceptance QA' }, adminCookie); + assert.equal(result.status, 200); + } + let failure; + try { + for (const cookie of [host, guest]) assert.equal((await action(cookie, { type: 'ready' })).status, 200); + assert.equal((await action(host, { type: 'start' })).status, 200); + const running = await request(adminPath + '/seat-ai', undefined, adminCookie); assert.equal(running.status, 200); + assert.equal(running.data.editable, false); + const early = { operationId: randomUUID(), expectedVersion: running.data.version, expectedControlRevision: running.data.controlRevision, target: made.data.me, difficulty: 'Easy', reason: 'Participant AI acceptance QA' }; + assert.equal((await request(adminPath + '/seat-ai', early, adminCookie)).status, 409); + await pause(true); + const ready = await request(adminPath + '/seat-ai', undefined, adminCookie); assert.equal(ready.status, 200); assert.equal(ready.data.editable, true); + const input = { ...early, operationId: randomUUID(), expectedVersion: ready.data.version, expectedControlRevision: ready.data.controlRevision }; + for (const denied of [ + await request(adminPath + '/seat-ai', input, host), + await request(adminPath + '/seat-ai', input, adminCookie, 'https://foreign.invalid'), + await request(adminPath + '/seat-ai', input, adminCookie, undefined, randomUUID()), + ]) { assert.ok([401, 403].includes(denied.status)); assert.equal(denied.rawCookie, null); } + const before = (await request(path, undefined, host)).data; + const changed = await request(adminPath + '/seat-ai', input, adminCookie); + assert.equal(changed.status, 200); assert.equal(changed.rawCookie, null); assert.equal(changed.data.replayed, false); + assert.equal(changed.data.appliedVersion, input.expectedVersion + 1); assert.equal(changed.data.room.paused, true); + assert.deepEqual(Object.keys(changed.data.room.players[0]).sort(), ['control','difficulty','eligible','faction','id','name']); + const frozen = (await request(path, undefined, host)).data; + assert.equal(frozen.me, before.me); assert.equal(frozen.roomControl.paused, true); + assert.deepEqual(frozen.players, before.players.map(player => player.id === frozen.me ? { ...player, autopilot: 'Easy' } : player)); + assert.equal((await request(path, undefined, guest)).data.me, joined.data.me); + await delay(2200); + assert.deepEqual((await request(path, undefined, host)).data, frozen, 'Paused AI must not advance'); + const replay = await request(adminPath + '/seat-ai', input, adminCookie); assert.equal(replay.status, 200); assert.equal(replay.data.replayed, true); + assert.deepEqual((await request(path, undefined, host)).data, frozen); + report.checks.push('Participant AI requires paused room and live authority; enable preserves private seats and cannot advance paused play or duplicate its operation'); + const takeback = await action(host, { type: 'setAutopilot', difficulty: null }); assert.equal(takeback.status, 200); + assert.equal(takeback.data.players.find(player => player.id === takeback.data.me).autopilot, undefined); + assert.equal(takeback.data.roomControl.paused, true); + const afterTakeback = await request(adminPath + '/seat-ai', input, adminCookie); assert.equal(afterTakeback.status, 200); assert.equal(afterTakeback.data.replayed, true); + assert.deepEqual((await request(path, undefined, host)).data, takeback.data); + await pause(false); + const resumed = (await request(path, undefined, host)).data; + assert.equal(resumed.roomControl.paused, false); + assert.equal((await request(adminPath + '/seat-ai', input, adminCookie)).data.replayed, true); + assert.deepEqual((await request(path, undefined, host)).data, resumed); + report.checks.push('Human takeback works while paused; exact participant retry after takeback and resume cannot re-enable AI'); + } catch (error) { + report.failureRequest ??= report.lastRequest; + report.participantAiFailure = 'Participant AI acceptance failed before cleanup.'; + failure = error; + } finally { + try { + const view = await request(path, undefined, host); assert.equal(view.status, 200); + if (view.data.players.find(player => player.id === view.data.me)?.autopilot) + assert.equal((await action(host, { type: 'setAutopilot', difficulty: null })).status, 200); + const current = await request(adminPath + '/control', undefined, adminCookie); assert.equal(current.status, 200); + if (current.data.paused) await pause(false); + } catch (error) { + report.participantAiCleanupFailure = { message: 'The QA room cleanup could not be confirmed.', request: report.lastRequest }; + failure ??= error; + } + } + if (failure) throw failure; +} diff --git a/tools/verify-admin.mjs b/tools/verify-admin.mjs index 016055e3..8a6c74fc 100644 --- a/tools/verify-admin.mjs +++ b/tools/verify-admin.mjs @@ -8,6 +8,7 @@ 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'; +import { verifyParticipantAi } from './admin-seat-ai-verification.mjs'; import { verifyRoomArchive } from './admin-archive-verification.mjs'; import { verifyRoomClosure } from './admin-closure-verification.mjs'; import { verifyRoomRemoval } from './admin-removal-verification.mjs'; @@ -35,7 +36,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|lobby|removal|closure|archive)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, + ...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive|seat-ai)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000), }); report.lastRequest = { path, status: response.status }; @@ -93,6 +94,7 @@ try { await verifyRoomRemoval(request, session1, report); await verifyRoomClosure(request, session1, report); await verifyRoomArchive(request, session1, report); + await verifyParticipantAi(request, session1, report); } report.passed = true; } catch (error) {