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:
1 parent
137f9223f1
commit
2cddf03932
23 files changed
+3187
-10
No files matched your search
@@ -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
@@ -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); }
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user