Prototype paused admin participant AI support

Enable an existing AI difficulty for a human seat through admin controls without granting private access or advancing paused play. Preserve custody, credentials, takeback and separate paced resume. Bind atomic writes and outgoing-grant revocation to live authority, room/pause versions and active seat access with durable exact receipts.

Validation: types/lint, 5408 offline tests, build and 52 HTTP tests passed on source fingerprint 2157de0f4bb0b74d9760d52102601c52344cf674dee8ccd1d559f0a246a1d4ff; 22 authenticated admin HTTP groups, independent SQL/privacy/UI review, desktop/mobile and refresh checks. All 2098 baseline rooms and 4455 seats preserved. Migration 0015 is additive. Positive production admin QA awaits authorized access; full admin/rules scope remains unfinished.
This commit is contained in:
Quintine committed 2026-09-26 14:04:17 +10:00
1 parent 137f9223f1
commit 2cddf03932
23 files changed
+3187 -10

No files matched your search

+3 -1
View File
@@ -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
+9 -4
View File
@@ -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<string | null>(null);
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
const [selectedLobby, setSelectedLobby] = useState<string | null>(null);
const [selectedArchive, setSelectedArchive] = useState<string | null>(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() {
</section> : <>
<section aria-labelledby="admin-rooms-title">
<div className="admin-section-heading"><div><h2 id="admin-rooms-title">Rooms</h2><p className="admin-secondary">Public room details. Cards, sealed choices and private discussion stay hidden.</p></div>
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
<div className="admin-room-actions">{account.role !== 'viewer' && <Button disabled={controlsBusy} onClick={() => { setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(true); }}>Create a room</Button>}
<Button variant="outline" disabled={controlsBusy} onClick={() => void load(applied, directory?.page ?? 1)}><RefreshCw aria-hidden="true" /> Refresh</Button></div></div>
{creatingRoom && account.role !== 'viewer' && <AdminRoomCreation key={account.id} accountId={account.id}
onClose={() => setCreatingRoom(false)} onDenied={accessChanged} onBusyChange={setOperationBusy}
@@ -146,6 +148,9 @@ export default function Administration() {
</select></label>
<Button type="submit" disabled={controlsBusy}>Apply filters</Button>
</form>
{selectedSeatAi && <AdminSeatAi key={`${account.id}:${selectedSeatAi}`} accountId={account.id} code={selectedSeatAi}
canManage={account.role !== 'viewer'} onClose={() => setSelectedSeatAi(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
{selectedRoom && <AdminRoomControls key={`${account.id}:${selectedRoom}`} accountId={account.id} code={selectedRoom}
canManage={account.role !== 'viewer'} onClose={() => setSelectedRoom(null)} onDenied={accessChanged}
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
@@ -183,7 +188,7 @@ export default function Administration() {
<TableCell>{room.turn !== null && room.status !== 'lobby' ? `Turn ${room.turn} · ` : ''}{room.phase}
<p>{room.pending.label}{room.pending.owners.length > 0 ? ': ' + room.pending.owners.map(id => room.players.find(p => p.id === id)?.name).filter(Boolean).join(', ') : ''}</p>
</TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(room.code); }}>Archive or unarchive · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedArchive(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(room.code); }}>Close or reopen · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); setSelectedClosure(null); setSelectedArchive(null); }}>Remove or restore · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedArchive(null); setSelectedClosure(null); setSelectedRemoval(null); setSelectedSeatAi(room.code); }}>Participant AI · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(room.code); }}>Archive or unarchive · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedArchive(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(room.code); }}>Close or reopen · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedSeatAi(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setSelectedLobby(room.code); }}>Configure lobby · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(room.code); setSelectedClosure(null); setSelectedArchive(null); }}>Remove or restore · {room.code}</Button><p><time dateTime={new Date(room.updatedAt).toISOString()}>{new Date(room.updatedAt).toLocaleString()}</time></p><p className="admin-secondary">Version {room.version}</p></TableCell>
</TableRow>)}</TableBody></Table>}
<nav className="admin-pagination" aria-label="Room pages">
<Button variant="outline" disabled={controlsBusy || directory.page <= 1} onClick={() => void load(applied, 1)}>First</Button>
@@ -199,7 +204,7 @@ export default function Administration() {
{!confirmAll ? <Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(true)}>Sign out everywhere…</Button> :
<div className="admin-confirm"><p>End all your administrator sessions now?</p><Button disabled={controlsBusy} onClick={() => void sessionAction('logoutAll')}>Confirm sign out everywhere</Button><Button variant="outline" disabled={controlsBusy} onClick={() => setConfirmAll(false)}>Cancel</Button></div>}
</section>
<p className="admin-secondary admin-development">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.</p>
<p className="admin-secondary admin-development">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.</p>
</>}
</main>;
}
@@ -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); }
}
+158
View File
@@ -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<AdminSeatAiView | null>(null);
const [target, setTarget] = useState('');
const [difficulty, setDifficulty] = useState<Difficulty>('Easy');
const [reason, setReason] = useState('');
const [confirmed, setConfirmed] = useState(false);
const [confirmationCode, setConfirmationCode] = useState('');
const [pending, setPending] = useState<AdminSeatAiInput | null>(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<HTMLHeadingElement>(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<unknown>(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<unknown>(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<unknown>(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<unknown>(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 <section className="admin-room-control" aria-labelledby="admin-seat-ai-title" aria-busy={loading || busy}>
<div className="admin-section-heading"><h2 id="admin-seat-ai-title" ref={title} tabIndex={-1}>Participant AI · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close participant controls</Button></div>
{notice && <output className="admin-notice">{notice}</output>}
{loading ? <output>Loading participant availability…</output> : <>
{room ? <>
<p>Room: <strong>{room.removed ? 'Removed' : room.archived ? 'Archived' : room.closed ? 'Closed' : room.paused ? 'Paused' : 'Running'}</strong>. Game: <strong>{room.status}</strong>.</p>
<p>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.</p>
{room.players.length > 0 && <ul>{room.players.map(player => <li key={player.id}>{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' : ''}`}</li>)}</ul>}
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh participants</Button>
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading participants</Button>}
{!canManage ? <p>Your viewer role can inspect participants. An owner or operator can enable participant AI.</p> : storageProblem ? <div className="admin-confirm">
<p>No new changes can be sent until this tab’s unreadable retry record is resolved. Discarding the record does not cancel a server change.</p>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !room} onChange={e => setConfirmed(e.target.checked)} />I have inspected room {code} and want to remove its local retry record.</label>
<Button disabled={busy || !room || !confirmed} onClick={discardUnreadableRecord}>Discard unreadable local record</Button>
</div> : initialRequestPending ? null : pending ? <div className="admin-confirm">
<p>Saved request: enable <strong>{label(pending.difficulty)} AI</strong> for <strong>{room?.players.find(player => player.id === pending.target)?.name ?? pending.target}</strong> in room <strong>{code}</strong>.</p><p>Reason: {pending.reason}</p>
<p>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.</p>
<p>After refresh or sign-in, use All rooms in both Directory and Archive filters to find the room and reopen Participant AI.</p>
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved request</Button>
</div> : room && !room.editable ? <p>{room.blockedReason}</p> : room && !room.players.some(player => player.eligible) ? <p>No human participant is eligible. A participant needs current seat access and must not already be controlled by AI.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
<label htmlFor="admin-seat-ai-target">Participant<select id="admin-seat-ai-target" required value={target} disabled={busy} onChange={e => { setTarget(e.target.value); setConfirmed(false); }}>
<option value="">Choose a human participant</option>{room.players.filter(player => player.eligible).map(player => <option key={player.id} value={player.id}>{player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction}</option>)}
</select></label>
<label htmlFor="admin-seat-ai-difficulty">AI difficulty<select id="admin-seat-ai-difficulty" value={difficulty} disabled={busy} onChange={e => { setDifficulty(e.target.value as Difficulty); setConfirmed(false); }}>
{DIFFICULTIES.map(value => <option key={value} value={value}>{label(value)}</option>)}
</select></label>
<label htmlFor="admin-seat-ai-reason">Reason for the audit history<Input id="admin-seat-ai-reason" value={reason} maxLength={300} required disabled={busy} onChange={e => { setReason(e.target.value); setConfirmed(false); }} /></label>
<p className="admin-secondary">Use a short operational reason. Do not include credentials or private game information.</p>
<div className="admin-confirm">
<p>{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.</p>
<p>Resume separately through Room controls when play should continue. This does not remove or restrict a participant.</p>
<label htmlFor="admin-seat-ai-code">Type {code} to confirm this room<Input id="admin-seat-ai-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={e => { setConfirmationCode(e.target.value); setConfirmed(false); }} /></label>
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !selected} onChange={e => setConfirmed(e.target.checked)} />I have reviewed this participant and understand that they retain their seat access and can take back control.</label>
<Button type="submit" disabled={busy || !selected || !confirmed || !reason.trim() || confirmationCode !== code}>Enable participant AI</Button>
</div>
</form>}
</>}
</section>;
}
+92
View File
@@ -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<AdminSeatAiView> {
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<AdminSeatAiResult> {
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);
}
+19
View File
@@ -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(
+7 -1
View File
@@ -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
+82
View File
@@ -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.
+14 -2
View File
@@ -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
+16
View File
@@ -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
+15
View File
@@ -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`);
File diff suppressed because it is too large. Load diff
+7
View File
@@ -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
}
]
}
+208
View File
@@ -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<string, unknown> => !!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<Card['kind'], true>;
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<Decision['kind'], true>;
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<ResponseWindow['kind'], true>;
/** 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;
}
+45
View File
@@ -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<string, unknown> => !!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<AdminSeatAiView, 'status'|'players'|'editable'|'blockedReason'>, value: unknown, activeHumans: Set<string>): 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;
}
+69
View File
@@ -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<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
const key = (account: string, code: string) => `dune.admin-seat-ai-request.v1:${account}:${code}`;
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const keys = (value: Record<string, unknown>, 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.');
}
+27
View File
@@ -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<string, unknown>, 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);
}
+8 -1
View File
@@ -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<string, string>[]) {
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' }),
+54
View File
@@ -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<string, string>();
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));
});
+107
View File
@@ -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<string, unknown>) => void][] = [
['deck', game => { delete game.deck; }], ['private hand', game => { delete (game.players as Record<string, unknown>[])[0].hand; }],
['negative reserves', game => { (game.players as Record<string, unknown>[])[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<string, unknown>) => { game[key] = {}; }] as [string, (game: Record<string, unknown>) => void]),
['aid', game => { game.aid = { [genuine.host]: null }; }],
];
for (const [name, corrupt] of cases) {
const game = restore(genuine); corrupt(game as unknown as Record<string, unknown>);
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 });
});
+143
View File
@@ -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<ReturnType<typeof fixture>>;
function pauseCommit(f: Fixture) {
let enter!: () => void, release!: () => void, calls = 0;
const waiting = new Promise<void>(resolve => { enter = resolve; }), gate = new Promise<void>(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(); }
});
+81
View File
@@ -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;
}
+3 -1
View File
@@ -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) {