Prototype administrative discussion mute controls
Let owners/operators mute or unmute new public/private sends from a human seat while retaining history, incoming messages, gameplay and credentials. Fence metadata and durable audit writes with live authority, membership and versions. Preserve exact admin/message retries and inherit restrictions through seat recovery. Validation: types/lint, 5431 offline tests, build, 52 HTTP integration tests, 24 administrator HTTP groups, independent SQL/privacy/UI review and desktop/mobile refresh acceptance. All 2230 prior games and 4746 seats preserved. Additive migration 0016. Verified runtime fingerprint 588967fbb9c2e3b6abe2990c4fce96ee69e2256fb7e3ee416314b87593cebba9; documentation-only grammar/button-label review gives b5e57bdd26812debede91c588e38f247f1fa895ad23e7ae30bc8550eedcd90ec. New-revision deployed acceptance remains open; full admin/rules scope is unfinished.
This commit is contained in:
1 parent
2cddf03932
commit
160fdc1f30
26 files changed
+3008
-33
No files matched your search
@@ -54,8 +54,10 @@ 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.
|
||||
[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.
|
||||
game while preserving their seat and takeback control.
|
||||
[Discussion controls](docs/ADMIN_DISCUSSION.md) mute or unmute new messages from
|
||||
a human seat while preserving history and gameplay. 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 { AdminDiscussion } from '@/components/admin-discussion';
|
||||
import { AdminSeatAi } from '@/components/admin-seat-ai';
|
||||
import { AdminRoomArchive } from '@/components/admin-room-archive';
|
||||
import { AdminRoomClosure } from '@/components/admin-room-closure';
|
||||
@@ -32,6 +33,7 @@ export default function Administration() {
|
||||
const [filters, setFilters] = useState(initialFilters);
|
||||
const [applied, setApplied] = useState(initialFilters);
|
||||
const [confirmAll, setConfirmAll] = useState(false);
|
||||
const [selectedDiscussion, setSelectedDiscussion] = useState<string | null>(null);
|
||||
const [selectedSeatAi, setSelectedSeatAi] = useState<string | null>(null);
|
||||
const [selectedRoom, setSelectedRoom] = useState<string | null>(null);
|
||||
const [selectedLobby, setSelectedLobby] = useState<string | null>(null);
|
||||
@@ -45,7 +47,7 @@ export default function Administration() {
|
||||
const clearSession = useCallback(() => {
|
||||
epoch.current++;
|
||||
setAccount(null); setDirectory(null); setConfirmAll(false);
|
||||
setBusy(false); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false);
|
||||
setBusy(false); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(null); setSelectedLobby(null); setSelectedRemoval(null); setSelectedClosure(null); setSelectedArchive(null); setCreatingRoom(false);
|
||||
}, []);
|
||||
const accessChanged = useCallback(() => {
|
||||
clearSession();
|
||||
@@ -121,7 +123,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={() => { setSelectedSeatAi(null); 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={() => { setSelectedDiscussion(null); 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}
|
||||
@@ -148,6 +150,9 @@ export default function Administration() {
|
||||
</select></label>
|
||||
<Button type="submit" disabled={controlsBusy}>Apply filters</Button>
|
||||
</form>
|
||||
{selectedDiscussion && <AdminDiscussion key={`${account.id}:${selectedDiscussion}`} accountId={account.id} code={selectedDiscussion}
|
||||
canManage={account.role !== 'viewer'} onClose={() => setSelectedDiscussion(null)} onDenied={accessChanged}
|
||||
onUpdated={() => void load(applied, directory?.page ?? 1)} onBusyChange={setOperationBusy} />}
|
||||
{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} />}
|
||||
@@ -188,7 +193,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); 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>
|
||||
<TableCell><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedArchive(null); setSelectedClosure(null); setSelectedRemoval(null); setSelectedSeatAi(null); setSelectedDiscussion(room.code); }}>Discussion controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedRoom(null); setSelectedLobby(null); setSelectedArchive(null); setSelectedClosure(null); setSelectedRemoval(null); setSelectedDiscussion(null); setSelectedSeatAi(room.code); }}>Participant AI · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedDiscussion(null); 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); setSelectedDiscussion(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); setSelectedDiscussion(null); setSelectedSeatAi(null); setSelectedRoom(room.code); }}>Room controls · {room.code}</Button><Button variant="outline" disabled={controlsBusy} onClick={() => { setCreatingRoom(false); setSelectedDiscussion(null); 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); setSelectedDiscussion(null); 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>
|
||||
@@ -204,7 +209,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. Participant AI can support human seats in paused games. Permanent removal, further 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; discussion controls mute or unmute new messages from a seat. Permanent removal, further participant support and backup tools are still being implemented.</p>
|
||||
</>}
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { env } from 'cloudflare:workers';
|
||||
import { AdminError, requireAdmin } from '@/db/admin-access';
|
||||
import { applyAdminDiscussion, readAdminDiscussion } from '@/db/admin-discussion';
|
||||
import { validAdminDiscussionInput } from '@/lib/admin-discussion';
|
||||
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 readAdminDiscussion(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 (!validAdminDiscussionInput(input)) throw new AdminError('Choose a participant, discussion setting, current versions and a short reason.', 400);
|
||||
return adminResponse(await applyAdminDiscussion(env.DB, identity, code(request), input));
|
||||
} catch (error) { return adminFailure(error); }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'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 { adminDiscussionResponse, adminDiscussionConfirmation, clearAdminDiscussionRequest, newAdminDiscussionRequest, readAdminDiscussionRequest, saveAdminDiscussionRequest } from '@/lib/admin-discussion-client';
|
||||
import type { AdminDiscussionInput, AdminDiscussionView } from '@/lib/admin-discussion';
|
||||
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.';
|
||||
|
||||
export function AdminDiscussion({ accountId, code, canManage, onClose, onUpdated, onDenied, onBusyChange }: Props) {
|
||||
const [room, setRoom] = useState<AdminDiscussionView | null>(null);
|
||||
const [target, setTarget] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [confirmationCode, setConfirmationCode] = useState('');
|
||||
const [pending, setPending] = useState<AdminDiscussionInput | 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}/discussion`;
|
||||
const selected = room?.players.find(player => player.id === target && player.control !== 'ai');
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true; title.current?.focus();
|
||||
let canceled = false;
|
||||
try {
|
||||
const saved = readAdminDiscussionRequest(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 discussion 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 = adminDiscussionResponse(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 = adminDiscussionResponse(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?: AdminDiscussionInput) {
|
||||
if (inFlight.current || loading || !canManage || storageProblem ||
|
||||
(!saved && (!room?.editable || !selected || pending || !confirmed || confirmationCode !== code))) return;
|
||||
let input: AdminDiscussionInput;
|
||||
try {
|
||||
input = saved ?? newAdminDiscussionRequest(room!, target, !selected!.muted, reason);
|
||||
saveAdminDiscussionRequest(window.sessionStorage, accountId, code, input);
|
||||
} catch (error) { setNotice(message(error)); return; }
|
||||
inFlight.current = true; setInitialRequestPending(!saved); setBusy(true); onBusyChange(true); setPending(input); setNotice('Saving discussion setting…');
|
||||
try {
|
||||
const result = adminDiscussionConfirmation(await requestJson<unknown>(url, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Dune-Admin-Id': accountId }, body: JSON.stringify(input),
|
||||
}), code, input);
|
||||
clearAdminDiscussionRequest(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 discussion settings are shown below; later changes have been preserved.' :
|
||||
input.muted ? 'Discussion muted for this seat. The player can still read messages and play. Saved-message retries can confirm earlier sends.' : 'Discussion unmuted for this seat. New messages are available again when the room is open.');
|
||||
onUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
let cleared = false;
|
||||
if (!retainAdminRoomRequest(error)) {
|
||||
try { clearAdminDiscussionRequest(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 = adminDiscussionResponse(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 {
|
||||
clearAdminDiscussionRequest(window.sessionStorage, accountId, code); setStorageProblem(false); setConfirmed(false);
|
||||
setNotice('Local retry record removed. This did not undo any server change. Review current discussion settings before continuing.');
|
||||
} catch (error) { setNotice(message(error)); }
|
||||
}
|
||||
|
||||
return <section className="admin-room-control" aria-labelledby="admin-discussion-title" aria-busy={loading || busy}>
|
||||
<div className="admin-section-heading"><h2 id="admin-discussion-title" ref={title} tabIndex={-1}>Discussion controls · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close discussion controls</Button></div>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{loading ? <output>Loading participant discussion…</output> : <>
|
||||
{room ? <>
|
||||
<p>Room: <strong>{room.removed ? 'Removed' : room.archived ? 'Archived' : room.closed ? 'Closed' : 'Open'}</strong>. Game: <strong>{room.status}</strong>.</p>
|
||||
<p>Mute blocks new public and private messages from a seat. Its owner can still read discussion, receive messages and play. Existing messages stay saved; this panel cannot read them.</p>
|
||||
{room.closed && <p>The room is closed to all new messages. These settings also apply when it reopens.</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 === 'ai' ? 'AI seat — no discussion' : player.muted ? 'Discussion muted' : 'Discussion allowed'}</li>)}</ul>}
|
||||
<Button variant="outline" disabled={busy} onClick={() => void refresh()}>Refresh discussion settings</Button>
|
||||
</> : <Button disabled={busy} onClick={() => void refresh()}>Retry loading discussion settings</Button>}
|
||||
{!canManage ? <p>Your viewer role can inspect these settings. An owner or operator can mute or unmute a seat.</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={event => setConfirmed(event.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: <strong>{pending.muted ? 'Mute' : 'Unmute'} discussion</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, even if another administrator changed the setting afterward.</p>
|
||||
<p>After refresh or sign-in, use All rooms in both directory filters to find later removed or archived rooms and reopen Discussion controls.</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.control !== 'ai') ? <p>No human seats are available for discussion moderation.</p> : room && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
|
||||
<label htmlFor="admin-discussion-target">Participant<select id="admin-discussion-target" required value={target} disabled={busy} onChange={event => { setTarget(event.target.value); setConfirmed(false); }}>
|
||||
<option value="">Choose a human seat</option>{room.players.filter(player => player.control !== 'ai').map(player => <option key={player.id} value={player.id}>{player.name} · {FACTIONS.find(faction => faction.id === player.faction)?.name ?? player.faction} · {player.muted ? 'Muted' : 'Allowed'}</option>)}
|
||||
</select></label>
|
||||
<label htmlFor="admin-discussion-reason">Reason for the audit history<Input id="admin-discussion-reason" value={reason} maxLength={300} required disabled={busy} onChange={event => { setReason(event.target.value); setConfirmed(false); }} /></label>
|
||||
<p className="admin-secondary">Use a short operational reason. Do not copy private messages, credentials or private game information.</p>
|
||||
<div className="admin-confirm">
|
||||
<p>{selected ? `${selected.muted ? 'Unmute' : 'Mute'} discussion for ${selected.name}.` : 'Choose a participant above.'} The setting follows this game seat through recovery and handover. A later owner inherits it until an administrator changes it.</p>
|
||||
<p>Game decisions, AI control, seat access, recovery and saved messages remain unchanged. Previously saved sends can still be confirmed.</p>
|
||||
<label htmlFor="admin-discussion-code">Type {code} to confirm this room<Input id="admin-discussion-code" value={confirmationCode} maxLength={8} autoComplete="off" autoCapitalize="characters" spellCheck={false} disabled={busy} onChange={event => { setConfirmationCode(event.target.value); setConfirmed(false); }} /></label>
|
||||
<label className="admin-check"><input type="checkbox" checked={confirmed} disabled={busy || !selected} onChange={event => setConfirmed(event.target.checked)} />I have reviewed this seat and understand how its discussion setting affects current and future owners.</label>
|
||||
<Button type="submit" disabled={busy || !selected || !confirmed || !reason.trim() || confirmationCode !== code}>{selected?.muted ? 'Unmute discussion' : 'Mute discussion'}</Button>
|
||||
</div>
|
||||
</form>}
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useId, useRef, useState } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { Button } from './ui/button';
|
||||
import { requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import { ClientRequestError, requestJson, requestMayHaveCompleted } from '@/lib/client-request';
|
||||
import {
|
||||
TALK_LIMIT,
|
||||
clearTalkDraft,
|
||||
@@ -24,7 +24,8 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
const [text, setText] = useState('');
|
||||
const [record, setRecord] = useState<TalkDraft | null>(null);
|
||||
const [pageChannel, setPageChannel] = useState<string | null>(null);
|
||||
const [page, setPage] = useState<TalkPage>({ messages: [], before: null });
|
||||
const [page, setPage] = useState<TalkPage>({ messages: [], before: null, muted: false });
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -104,6 +105,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
!readingHistory.current
|
||||
) {
|
||||
setPage(result);
|
||||
setMuted(result.muted === true);
|
||||
setPageChannel(url);
|
||||
setReadError('');
|
||||
}
|
||||
@@ -120,7 +122,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
readingHistory.current = false;
|
||||
// oxlint-disable-next-line react/react-compiler -- Reset saved-history browsing when the external conversation subscription changes.
|
||||
setHistory(false);
|
||||
setPage({ messages: [], before: null });
|
||||
setPage({ messages: [], before: null, muted: false });
|
||||
void read();
|
||||
return () => {
|
||||
controller.abort();
|
||||
@@ -138,11 +140,14 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
const generation = channelGeneration.current;
|
||||
try {
|
||||
const result = await requestJson<TalkPage>(pageUrl(shownPage.before));
|
||||
if (generation === channelGeneration.current)
|
||||
if (generation === channelGeneration.current) {
|
||||
setMuted(result.muted === true);
|
||||
setPage((current) => ({
|
||||
before: result.before,
|
||||
messages: mergeTalkPages(result.messages, current.messages),
|
||||
muted: result.muted,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
if (generation === channelGeneration.current)
|
||||
setReadError('Older messages could not be loaded. Try again.');
|
||||
@@ -152,7 +157,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
}
|
||||
}
|
||||
async function submit() {
|
||||
if (sending.current || !ready || (closed && !record)) return;
|
||||
if (sending.current || !ready || ((closed || muted) && !record)) return;
|
||||
const draft = record ?? {
|
||||
id: randomId(),
|
||||
recipientId: recipient || null,
|
||||
@@ -188,6 +193,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
setRefresh((value) => value + 1);
|
||||
} catch (error) {
|
||||
if (!mounted.current) return;
|
||||
if (error instanceof ClientRequestError && error.code === 'SEAT_DISCUSSION_MUTED') setMuted(true);
|
||||
if (dispatched && (wasRetry || requestMayHaveCompleted(error))) {
|
||||
setNotice(
|
||||
'Message not confirmed. Retry the exact message, or check the conversation before discarding its retry details.',
|
||||
@@ -316,6 +322,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
<p className="muted">No messages loaded in this conversation.</p>
|
||||
)}
|
||||
{closed && <p className="notice">This room is closed. Discussion history remains available; an exact saved message can still be confirmed.</p>}
|
||||
{muted && <p className="notice">An administrator muted discussion for this seat. You can still read messages and play. An exact saved message can still be confirmed; new messages are available after unmuting. <Button variant="outline" onClick={() => setRefresh(value => value + 1)}>Refresh discussion availability</Button></p>}
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -332,7 +339,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
rows={3}
|
||||
maxLength={TALK_LIMIT}
|
||||
value={text}
|
||||
disabled={busy || !!record || closed}
|
||||
disabled={busy || !!record || closed || muted}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
aria-describedby={`${field}-count`}
|
||||
/>
|
||||
@@ -343,7 +350,7 @@ export function TableTalk({ game }: { game: GameView }) {
|
||||
type="submit"
|
||||
disabled={
|
||||
!ready ||
|
||||
(closed && !record) ||
|
||||
((closed || muted) && !record) ||
|
||||
busy ||
|
||||
!text.trim() ||
|
||||
(!record && !!recipient && !target)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { AdminError, type AdminIdentity } from './admin-access';
|
||||
import { FACTIONS } from '../game/catalog';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { validAdminDiscussionInput, type AdminDiscussionInput, type AdminDiscussionView, type AdminDiscussionResult } from '../lib/admin-discussion';
|
||||
|
||||
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.version,r.state,
|
||||
EXISTS (SELECT 1 FROM room_removals WHERE room_code=r.code AND removed=1) AS removed,
|
||||
EXISTS (SELECT 1 FROM room_closures WHERE room_code=r.code AND closed=1) AS closed,
|
||||
EXISTS (SELECT 1 FROM room_archives WHERE room_code=r.code AND archived=1) AS archived,
|
||||
(SELECT json_group_array(json_object('id',player_id,'muted',muted,'revision',revision)) FROM seat_discussion_controls WHERE room_code=r.code) AS controls
|
||||
FROM rooms r WHERE r.code=? AND EXISTS (${authority})`;
|
||||
const receiptSelect = `SELECT operation_id,actor_admin_id,room_code,request_hash,expected_version,applied_revision
|
||||
FROM admin_discussion_operations WHERE operation_id=? AND EXISTS (${mutationAuthority})`;
|
||||
type Row = { code: string; version: number; state: string; removed: number; closed: number; archived: number; controls: string };
|
||||
type Receipt = { operation_id: string; actor_admin_id: string; room_code: string; request_hash: string; expected_version: number; applied_revision: number };
|
||||
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
const counter = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) < Number.MAX_SAFE_INTEGER;
|
||||
function argsFor(identity: AdminIdentity, code: string, now: number) {
|
||||
if (!identity || typeof identity.id !== 'string' || typeof identity.sessionHash !== 'string' || !/^[0-9a-f]{64}$/.test(identity.sessionHash)) throw new AdminError('Administrator sign-in required.', 401);
|
||||
if (typeof code !== 'string' || code.length !== 8 || !/^[A-Z2-9]{8}$/.test(code)) throw new AdminError('Enter the 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);
|
||||
}
|
||||
export function projectAdminDiscussion(row: Row): AdminDiscussionView {
|
||||
const base: AdminDiscussionView = { code: row.code, version: row.version, removed: row.removed === 1, closed: row.closed === 1, archived: row.archived === 1,
|
||||
status: 'unreadable', editable: false, blockedReason: 'This room needs inspection before changing participant discussion.', players: [] };
|
||||
try {
|
||||
const game: unknown = JSON.parse(row.state), controls: unknown = JSON.parse(row.controls);
|
||||
if (!object(game) || !['lobby','setup','playing','finished'].includes(String(game.status)) || !Array.isArray(game.players) || game.players.length < 1 || game.players.length > 6 ||
|
||||
!game.players.every(player => object(player) && typeof player.id === 'string' && !!player.id.trim() && player.id.length <= 80 && typeof player.name === 'string' && player.name.length <= 80 &&
|
||||
FACTIONS.some(faction => faction.id === player.faction) && [player.bot, player.autopilot].every(value => value === undefined || DIFFICULTIES.some(difficulty => difficulty === value))) ||
|
||||
new Set(game.players.map(player => player.id)).size !== game.players.length || !Array.isArray(controls) || !controls.every(control => object(control) && typeof control.id === 'string' && [0,1].includes(Number(control.muted)) && counter(control.revision))) return base;
|
||||
const players = game.players.map(player => {
|
||||
const control = controls.find(control => control.id === player.id);
|
||||
return { id: player.id as string, name: player.name as string, faction: player.faction as string,
|
||||
control: player.bot ? 'ai' as const : player.autopilot ? 'autopilot' as const : 'human' as const, muted: control?.muted === 1, revision: control?.revision ?? 0 };
|
||||
});
|
||||
return { ...base, status: game.status as AdminDiscussionView['status'], editable: !base.removed, blockedReason: base.removed ? 'Restore this removed room before changing participant discussion.' : null, players };
|
||||
} catch { return base; }
|
||||
}
|
||||
function replay(receipt: Receipt | undefined, row: Row | undefined, identity: AdminIdentity, code: string, hash: string, replayed: boolean): AdminDiscussionResult | null {
|
||||
if (!receipt) return null;
|
||||
if (receipt.actor_admin_id !== identity.id || receipt.room_code !== code || receipt.request_hash !== hash) throw new AdminError('This discussion operation belongs to a different administrator or request.', 409);
|
||||
const room = row ? projectAdminDiscussion(row) : { code, version: receipt.expected_version, removed: true, closed: false, archived: false, status: 'unreadable' as const,
|
||||
editable: false, blockedReason: 'The room is no longer available.', players: [] };
|
||||
return { operationId: receipt.operation_id, appliedRevision: receipt.applied_revision, replayed, room };
|
||||
}
|
||||
export async function readAdminDiscussion(database: D1Database, identity: AdminIdentity, code: string, now = Date.now()): Promise<AdminDiscussionView> {
|
||||
const args = argsFor(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 projectAdminDiscussion(row);
|
||||
}
|
||||
export async function applyAdminDiscussion(database: D1Database, identity: AdminIdentity, code: string, input: AdminDiscussionInput, now = Date.now()): Promise<AdminDiscussionResult> {
|
||||
const args = argsFor(identity, code, now);
|
||||
if (!validAdminDiscussionInput(input)) throw new AdminError('Choose a human participant, discussion setting, current versions and a short operational reason.', 400);
|
||||
const { operationId, expectedVersion, expectedRevision, target, muted } = input, reason = input.reason.trim();
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify({ expectedVersion, expectedRevision, target, muted, reason })));
|
||||
const hash = 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, hash, true);
|
||||
if (previous) return previous;
|
||||
if (!row) throw new AdminError('Room not found.', 404);
|
||||
const view = projectAdminDiscussion(row), player = view.players.find(player => player.id === target && player.control !== 'ai');
|
||||
if (!view.editable) throw new AdminError(view.blockedReason ?? 'Refresh participant availability.', 409);
|
||||
if (view.version !== expectedVersion || !player || player.revision !== expectedRevision || player.muted === muted) throw new AdminError('The room or participant discussion setting changed. Refresh before choosing a new operation.', 409);
|
||||
const result = await database.batch([
|
||||
database.prepare(authority).bind(...args),
|
||||
database.prepare(`INSERT INTO seat_discussion_controls(room_code,player_id,muted,revision,updated_at)
|
||||
SELECT r.code,?,?,?,? FROM rooms r WHERE r.code=? AND r.version=? AND EXISTS (${mutationAuthority})
|
||||
AND NOT EXISTS (SELECT 1 FROM room_removals WHERE room_code=r.code AND removed=1)
|
||||
AND EXISTS (SELECT 1 FROM json_each(r.state,'$.players') p WHERE json_extract(p.value,'$.id')=? AND json_extract(p.value,'$.bot') IS NULL)
|
||||
AND COALESCE((SELECT revision FROM seat_discussion_controls WHERE room_code=r.code AND player_id=?),0)=?
|
||||
AND COALESCE((SELECT muted FROM seat_discussion_controls WHERE room_code=r.code AND player_id=?),0)<>?
|
||||
AND NOT EXISTS (SELECT 1 FROM admin_discussion_operations WHERE operation_id=?)
|
||||
ON CONFLICT(room_code,player_id) DO UPDATE SET muted=excluded.muted,revision=excluded.revision,updated_at=excluded.updated_at
|
||||
WHERE seat_discussion_controls.revision=?`)
|
||||
.bind(target, Number(muted), expectedRevision + 1, now, code, expectedVersion, ...args, target, target, expectedRevision, target, Number(muted), operationId, expectedRevision),
|
||||
database.prepare(`INSERT INTO admin_discussion_operations(operation_id,actor_admin_id,room_code,request_hash,expected_version,expected_revision,applied_revision,target,muted,reason,created_at)
|
||||
SELECT ?,?,?,?,?,?,?,?,?,?,? WHERE changes()=1`).bind(operationId, identity.id, code, hash, expectedVersion, expectedRevision, expectedRevision + 1, target, Number(muted), reason, now),
|
||||
database.prepare(receiptSelect).bind(operationId, ...args), database.prepare(roomSelect).bind(code, ...args),
|
||||
]);
|
||||
authorize(result[0], true);
|
||||
const committed = replay(result[3].results[0] as Receipt | undefined, result[4].results[0] as Row | undefined, identity, code, hash, result[1].meta.changes !== 1);
|
||||
if (committed) return committed;
|
||||
throw new AdminError('The room, participant setting or administrator access changed. Refresh before choosing a new operation.', 409);
|
||||
}
|
||||
@@ -363,6 +363,32 @@ export const roomEntryReceipts = sqliteTable('room_entry_receipts', {
|
||||
playerId: text('player_id').notNull(),
|
||||
});
|
||||
|
||||
// Discussion moderation follows the logical seat across recovery and handover.
|
||||
export const seatDiscussionControls = sqliteTable('seat_discussion_controls', {
|
||||
roomCode: text('room_code').notNull().references(() => rooms.code, { onDelete: 'cascade' }),
|
||||
playerId: text('player_id').notNull(),
|
||||
muted: integer('muted').notNull().default(0),
|
||||
revision: integer('revision').notNull().default(0),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
}, table => [primaryKey({ columns: [table.roomCode, table.playerId] }),
|
||||
check('seat_discussion_muted', sql`${table.muted} IN (0, 1)`),
|
||||
check('seat_discussion_revision', sql`${table.revision} >= 0`)]);
|
||||
|
||||
// Keep audit receipts independent of room/account deletion, like other admin operations.
|
||||
export const adminDiscussionOperations = sqliteTable('admin_discussion_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(),
|
||||
expectedRevision: integer('expected_revision').notNull(),
|
||||
appliedRevision: integer('applied_revision').notNull(),
|
||||
target: text('target').notNull(),
|
||||
muted: integer('muted').notNull(),
|
||||
reason: text('reason').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, table => [index('admin_discussion_room').on(table.roomCode, table.createdAt)]);
|
||||
|
||||
export const roomMessages = sqliteTable(
|
||||
'room_messages',
|
||||
{
|
||||
|
||||
+10
-2
@@ -70,7 +70,9 @@ export async function readTableTalk(
|
||||
WHERE m.room_code = r.code AND m.id = ? AND (${channel})))
|
||||
ORDER BY m.sequence DESC LIMIT ${TALK_PAGE_SIZE + 1}
|
||||
)
|
||||
) AS messages ${viewer}`)
|
||||
) AS messages,
|
||||
EXISTS (SELECT 1 FROM seat_discussion_controls WHERE room_code=r.code AND player_id=s.player_id AND muted=1) AS muted
|
||||
${viewer}`)
|
||||
.bind(
|
||||
recipientId,
|
||||
recipientId,
|
||||
@@ -86,7 +88,7 @@ export async function readTableTalk(
|
||||
auth.playerId,
|
||||
auth.tokenHash,
|
||||
)
|
||||
.first<{ messages: string }>();
|
||||
.first<{ messages: string; muted: number }>();
|
||||
if (!row) {
|
||||
await requireRoomNotRemoved(code);
|
||||
throw new TableTalkError('Your seat could not be verified.');
|
||||
@@ -96,6 +98,7 @@ export async function readTableTalk(
|
||||
return {
|
||||
messages,
|
||||
before: newestFirst.length > TALK_PAGE_SIZE ? messages[0].id : null,
|
||||
muted: row.muted === 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -117,6 +120,7 @@ export async function sendTableTalk(
|
||||
(SELECT json_extract(target.value, '$.name') FROM json_each(r.state, '$.players') target WHERE json_extract(target.value, '$.id') = ?), ?, ?
|
||||
${viewer}
|
||||
AND NOT EXISTS (SELECT 1 FROM room_closures WHERE room_code = r.code AND closed = 1)
|
||||
AND NOT EXISTS (SELECT 1 FROM seat_discussion_controls WHERE room_code=r.code AND player_id=s.player_id AND muted=1)
|
||||
AND (? IS NULL OR EXISTS (SELECT 1 FROM json_each(r.state, '$.players') target
|
||||
WHERE json_extract(target.value, '$.id') = ? AND json_extract(target.value, '$.bot') IS NULL))
|
||||
AND NOT EXISTS (SELECT 1 FROM room_messages recent WHERE recent.room_code = r.code
|
||||
@@ -158,6 +162,10 @@ export async function sendTableTalk(
|
||||
await requireRoomNotRemoved(code);
|
||||
if (await database().prepare('SELECT 1 FROM room_closures WHERE room_code = ? AND closed = 1').bind(code).first())
|
||||
throw new TableTalkError('An administrator closed this room. Discussion history remains readable; new messages are available after reopening.', 409, 'ROOM_CLOSED');
|
||||
// Only a still-authenticated seat may learn its own restriction. Never gate prior receipts on mute.
|
||||
if (await database().prepare(`SELECT 1 ${viewer} AND EXISTS (SELECT 1 FROM seat_discussion_controls WHERE room_code=r.code AND player_id=s.player_id AND muted=1)`)
|
||||
.bind(code, auth.playerId, auth.tokenHash).first())
|
||||
throw new TableTalkError('An administrator muted discussion for this seat. You can still read messages and play; new messages are available after unmuting.', 409, 'SEAT_DISCUSSION_MUTED');
|
||||
throw new TableTalkError(
|
||||
'Message not confirmed. Check your seat and recipient, wait a second, then try again.',
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Administrative discussion controls
|
||||
|
||||
**Discussion controls** in the administration directory lets an owner or operator
|
||||
mute or unmute new public and private messages from one human seat. Viewers can
|
||||
inspect the public roster and discussion settings. A muted player can still read
|
||||
history, receive messages and play the game. This prototype does not revoke seat
|
||||
access, remove a participant or permit administrators to read private messages.
|
||||
|
||||
## Use and recovery
|
||||
|
||||
Select the human participant, enter a short operational reason, type the room
|
||||
code and acknowledge that the setting follows the seat. **Mute discussion** or
|
||||
**Unmute discussion** applies the change. Human seats using AI remain eligible;
|
||||
permanent AI seats do not send messages. Closed and archived rooms permit settings
|
||||
for reopening; a removed room must first be restored. Closing a room still blocks
|
||||
all new sends independently of its seat settings.
|
||||
|
||||
The player sees a persistent explanation when muted, with **Refresh discussion
|
||||
availability** for checking an unmute while browsing older history. Only the
|
||||
caller's mute status is returned with their message page. Other players receive
|
||||
no mute indicator or private discussion activity. Incoming history remains under
|
||||
the existing sender/recipient privacy rules. Recovery and voluntary handover
|
||||
inherit the logical seat's setting; revoked credentials remain invalid.
|
||||
|
||||
The exact admin operation, room version, seat-setting revision, target, setting
|
||||
and reason are saved in account/room-scoped tab storage before sending. The first
|
||||
request shows concise progress. Uncertain requests retain explicit retry controls
|
||||
across refresh; success text stays until a deliberate action. Retrying an old
|
||||
mute after a later unmute confirms the old receipt without muting again. Existing
|
||||
committed message requests can likewise be confirmed while muted; altered or
|
||||
new requests cannot bypass the restriction. A new owner cannot replay the former
|
||||
credential's message receipt.
|
||||
|
||||
## Persistence and authority
|
||||
|
||||
Additive migration [0016](../drizzle/0016_seat_discussion_controls.sql) stores
|
||||
seat settings separately from game JSON. The action leaves room versions, game
|
||||
change timestamps, private custody, credentials, readiness and AI pacing intact.
|
||||
It requires the current room version and a separate seat-setting revision. Live
|
||||
admin authority, human membership, room availability and both versions are fenced
|
||||
at commit. The setting and audit receipt commit together or roll back together.
|
||||
Message insertion independently checks the live mute setting in SQL, preventing
|
||||
a stale browser from sending. A message committed before a later mute still
|
||||
returns its successful receipt.
|
||||
|
||||
Controls: [admin panel](../components/admin-discussion.tsx),
|
||||
[player discussion](../components/table-talk.tsx),
|
||||
[client retry](../lib/admin-discussion-client.ts).
|
||||
Server: [route](../app/api/admin/rooms/[code]/discussion/route.ts),
|
||||
[admin transaction](../db/admin-discussion.ts), [message store](../db/table-talk.ts).
|
||||
|
||||
```sh
|
||||
npm test -- admin-discussion table-talk
|
||||
npm run test:integration -- admin-boundary table-talk
|
||||
```
|
||||
|
||||
Focused regressions cover rollback, concurrent/conflicting operations, live
|
||||
role/session/membership/version changes, stale revisions after unmute, receipt
|
||||
replay, own-only status, incoming messages, mute/send ordering and seat rotation.
|
||||
The dedicated admin HTTP verifier creates a new QA room for deny/mute/read/retry/
|
||||
unmute flows and preserves both seats. Independent SQL/privacy and UI source
|
||||
reviews found no blocking defects. Browser, checkpoint and preservation evidence
|
||||
are recorded separately; deployed acceptance must match the running revision.
|
||||
|
||||
Access revocation, disruptive participant removal, administrator-assisted recovery,
|
||||
backup/restore and operational audit browsing remain unfinished. Discussion
|
||||
moderation is a partial participant-support prototype, not full admin acceptance.
|
||||
+5
-2
@@ -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 | [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 |
|
||||
| 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. [Discussion controls](ADMIN_DISCUSSION.md) mute/unmute new sends while retaining history and gameplay. Participant 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 |
|
||||
|
||||
@@ -56,6 +56,8 @@ No default key, public account creation or host-to-administrator promotion exist
|
||||
[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.
|
||||
[Discussion controls](ADMIN_DISCUSSION.md) restrict new messages from a human seat,
|
||||
including after recovery/handover, without exposing private history.
|
||||
|
||||
The directory uses an explicit field allowlist, independent of any player view.
|
||||
It never returns hands, Traitors, predictions, plans, private messages, recovery
|
||||
@@ -129,7 +131,8 @@ room IDs and safe outcomes, never keys/cookies. The container verifier provision
|
||||
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.
|
||||
continuity, takeback and durable retries after resuming. Discussion checks use a
|
||||
new QA room for mute/unmute, own-only status, incoming messages and saved retries.
|
||||
|
||||
Local browser checks cover sign-in, directory filters, refresh restoration and
|
||||
mobile layout. Final source-bound checks and separately recorded deployed
|
||||
|
||||
+13
-7
@@ -6,13 +6,19 @@ 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.
|
||||
Current work adds [discussion moderation](ADMIN_DISCUSSION.md): owners/operators
|
||||
can mute or unmute new public/private messages from a human seat. History,
|
||||
incoming messages, game state and credentials remain saved; the setting follows
|
||||
recovery/handover. Atomic authority/version fences, durable audit and exact
|
||||
admin/message retries are connected. This is partial participant support;
|
||||
access revocation, participant removal, assisted recovery, permanent deletion,
|
||||
bulk actions and backup/operations tools remain unfinished.
|
||||
|
||||
[Participant AI](ADMIN_PARTICIPANT_AI.md) checkpoint `2cddf03` is pushed. Types/lint,
|
||||
5,408 offline tests, 52 HTTP tests, build, twenty-two administrator HTTP groups
|
||||
and desktop/mobile browser acceptance passed. Deployment request delivery remains
|
||||
unconfirmed; the latest confirmed production revision is `e918e2e`. No strategic
|
||||
AI refinement was added.
|
||||
|
||||
Readable-notice checkpoint `137f922` is pushed. Types/lint, 5,381 offline tests,
|
||||
52 HTTP tests, build and delayed-response desktop/mobile browser QA passed.
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 September 2026 — Administrative discussion moderation prototype
|
||||
|
||||
[Discussion controls](ADMIN_DISCUSSION.md) connect human-seat mute/unmute to the
|
||||
admin directory and player message panel. New public/private sends are blocked
|
||||
at commit; history, incoming messages and exact committed retries remain usable.
|
||||
Settings survive seat recovery/handover without reviving old credentials. Game
|
||||
JSON, versions, timestamps, private custody and AI pacing remain unchanged.
|
||||
|
||||
Atomic live authority/membership/version fences, per-seat revisions and audit
|
||||
receipts protect concurrent and uncertain requests. Independent SQL/privacy and
|
||||
UI source review found no blocking defects. Focused regressions cover mute/send
|
||||
ordering, rollback, stale unmute races and exact retry privacy. Browser, HTTP,
|
||||
source-bound checkpoint checks and preservation evidence accompany this slice.
|
||||
New-revision deployed acceptance remains open; full administration and game
|
||||
rules/expansion completion remain unfinished.
|
||||
|
||||
## 26 September 2026 — Administrative participant AI prototype
|
||||
|
||||
[Participant AI](ADMIN_PARTICIPANT_AI.md) connects paused human-seat support to
|
||||
|
||||
@@ -28,6 +28,16 @@ resumes updates. Channel changes discard obsolete read results. There are no
|
||||
public direct-message counts, typing indicators or global sequence numbers.
|
||||
Messages are stored durably; pagination does not delete old history.
|
||||
|
||||
## Discussion moderation
|
||||
|
||||
An administrator can [mute new messages from a seat](ADMIN_DISCUSSION.md). The
|
||||
player sees a persistent explanation; reading history, receiving messages and
|
||||
playing remain available. The setting follows recovery or handover and is never
|
||||
shown as another player's private activity. New sends are checked at database
|
||||
commit. An exact previously committed send can still be confirmed while muted;
|
||||
no new message is created. Refresh availability after an unmute, or allow the
|
||||
normal latest-message poll to update the controls.
|
||||
|
||||
## Saved retries and state isolation
|
||||
|
||||
The browser saves an exact message UUID, recipient and body in tab storage before
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE `admin_discussion_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_revision` integer NOT NULL,
|
||||
`applied_revision` integer NOT NULL,
|
||||
`target` text NOT NULL,
|
||||
`muted` integer NOT NULL,
|
||||
`reason` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `admin_discussion_room` ON `admin_discussion_operations` (`room_code`,`created_at`);--> statement-breakpoint
|
||||
CREATE TABLE `seat_discussion_controls` (
|
||||
`room_code` text NOT NULL,
|
||||
`player_id` text NOT NULL,
|
||||
`muted` integer DEFAULT 0 NOT NULL,
|
||||
`revision` integer DEFAULT 0 NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
PRIMARY KEY(`room_code`, `player_id`),
|
||||
FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade,
|
||||
CONSTRAINT "seat_discussion_muted" CHECK("seat_discussion_controls"."muted" IN (0, 1)),
|
||||
CONSTRAINT "seat_discussion_revision" CHECK("seat_discussion_controls"."revision" >= 0)
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -113,6 +113,13 @@
|
||||
"when": 1790389395436,
|
||||
"tag": "0015_admin_seat_ai",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1790395696065,
|
||||
"tag": "0016_seat_discussion_controls",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { randomId } from './random-id';
|
||||
import { validAdminDiscussionInput, validAdminDiscussionView, type AdminDiscussionInput, type AdminDiscussionView, type AdminDiscussionResult } from './admin-discussion';
|
||||
type Storage = Pick<globalThis.Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
const key = (account: string, code: string) => `dune.admin-discussion-request.v1:${account}:${code}`;
|
||||
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
export function adminDiscussionResponse(value: unknown, code: string): AdminDiscussionView {
|
||||
if (!validAdminDiscussionView(value) || value.code !== code) throw new Error('The server returned unreadable participant discussion settings.');
|
||||
return value;
|
||||
}
|
||||
export function adminDiscussionConfirmation(value: unknown, code: string, input: AdminDiscussionInput): AdminDiscussionResult {
|
||||
if (!object(value) || Object.keys(value).length !== 4 || !['operationId','replayed','appliedRevision','room'].every(key => Object.hasOwn(value, key)) ||
|
||||
value.operationId !== input.operationId || typeof value.replayed !== 'boolean' || value.appliedRevision !== input.expectedRevision + 1)
|
||||
throw new Error('The server did not confirm this participant discussion request.');
|
||||
const room = adminDiscussionResponse(value.room, code), target = room.players.find(player => player.id === input.target);
|
||||
if (room.version < input.expectedVersion || (target && target.revision < Number(value.appliedRevision)))
|
||||
throw new Error('The server returned participant settings older than the confirmed change.');
|
||||
return value as AdminDiscussionResult;
|
||||
}
|
||||
export function newAdminDiscussionRequest(view: AdminDiscussionView, target: string, muted: boolean, reason: string): AdminDiscussionInput {
|
||||
if (!view.editable) throw new Error(view.blockedReason ?? 'Refresh participant discussion settings.');
|
||||
const player = view.players.find(player => player.id === target && player.control !== 'ai');
|
||||
if (!player || player.muted === muted) throw new Error('Choose a human participant with a different discussion setting.');
|
||||
const input = { operationId: randomId(), expectedVersion: view.version, expectedRevision: player.revision, target, muted, reason: reason.trim() };
|
||||
if (!validAdminDiscussionInput(input)) throw new Error('Choose a participant and a reason of 1–300 characters on one line.');
|
||||
return input;
|
||||
}
|
||||
export function readAdminDiscussionRequest(storage: Storage, account: string, code: string): AdminDiscussionInput | 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 (!validAdminDiscussionInput(value)) throw new Error('This tab’s saved discussion request is unreadable. Inspect the room before discarding its local retry record.');
|
||||
return value;
|
||||
}
|
||||
export function saveAdminDiscussionRequest(storage: Storage, account: string, code: string, input: AdminDiscussionInput) {
|
||||
if (!validAdminDiscussionInput(input)) throw new Error('Invalid participant discussion request.');
|
||||
const previous = readAdminDiscussionRequest(storage, account, code), encoded = JSON.stringify(input);
|
||||
if (previous && JSON.stringify(previous) !== encoded) throw new Error('Resolve the saved discussion request before sending a different change.');
|
||||
storage.setItem(key(account, code), encoded);
|
||||
if (storage.getItem(key(account, code)) !== encoded) throw new Error('The discussion request could not be preserved in this tab. Nothing was sent.');
|
||||
}
|
||||
export function clearAdminDiscussionRequest(storage: Storage, account: string, code: string) {
|
||||
storage.removeItem(key(account, code));
|
||||
if (storage.getItem(key(account, code)) !== null) throw new Error('The saved discussion request could not be cleared.');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FACTIONS } from '../game/catalog';
|
||||
|
||||
export type AdminDiscussionInput = {
|
||||
operationId: string; expectedVersion: number; expectedRevision: number;
|
||||
target: string; muted: boolean; reason: string;
|
||||
};
|
||||
export type AdminDiscussionView = {
|
||||
code: string; version: number; removed: boolean; closed: boolean; archived: boolean;
|
||||
status: 'lobby' | 'setup' | 'playing' | 'finished' | 'unreadable';
|
||||
editable: boolean; blockedReason: string | null;
|
||||
players: { id: string; name: string; faction: string; control: 'human' | 'autopilot' | 'ai'; muted: boolean; revision: number }[];
|
||||
};
|
||||
export type AdminDiscussionResult = { operationId: string; replayed: boolean; appliedRevision: number; room: AdminDiscussionView };
|
||||
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 counter = (value: unknown) => Number.isSafeInteger(value) && Number(value) >= 0 && Number(value) < Number.MAX_SAFE_INTEGER;
|
||||
const oneLine = (value: unknown, max: number): value is string => typeof value === 'string' && value.length <= max && !!value.trim() &&
|
||||
!value.split('').some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
||||
|
||||
export function validAdminDiscussionInput(value: unknown): value is AdminDiscussionInput {
|
||||
return object(value) && keys(value, ['operationId','expectedVersion','expectedRevision','target','muted','reason']) &&
|
||||
typeof value.operationId === 'string' && value.operationId.length === 36 && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value.operationId) &&
|
||||
counter(value.expectedVersion) && counter(value.expectedRevision) && oneLine(value.target, 80) && value.target.trim() === value.target &&
|
||||
typeof value.muted === 'boolean' && oneLine(value.reason, 300);
|
||||
}
|
||||
export function validAdminDiscussionView(value: unknown): value is AdminDiscussionView {
|
||||
return object(value) && keys(value, ['code','version','removed','closed','archived','status','editable','blockedReason','players']) &&
|
||||
typeof value.code === 'string' && value.code.length === 8 && /^[A-Z2-9]{8}$/.test(value.code) && counter(value.version) &&
|
||||
['removed','closed','archived','editable'].every(key => typeof value[key] === 'boolean') &&
|
||||
['lobby','setup','playing','finished','unreadable'].includes(String(value.status)) && (value.blockedReason === null || typeof value.blockedReason === 'string') &&
|
||||
Array.isArray(value.players) && value.players.length <= 6 && value.players.every(player => object(player) &&
|
||||
keys(player, ['id','name','faction','control','muted','revision']) && oneLine(player.id, 80) && typeof player.name === 'string' && player.name.length <= 80 &&
|
||||
FACTIONS.some(faction => faction.id === player.faction) && ['human','autopilot','ai'].includes(String(player.control)) &&
|
||||
typeof player.muted === 'boolean' && counter(player.revision));
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ export type TalkMessage = {
|
||||
text: string;
|
||||
createdAt: number;
|
||||
};
|
||||
export type TalkPage = { messages: TalkMessage[]; before: string | null };
|
||||
export type TalkPage = { messages: TalkMessage[]; before: string | null; muted: boolean };
|
||||
export const talkId = (value: unknown): value is string =>
|
||||
typeof value === 'string' &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
|
||||
|
||||
@@ -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`, `/api/admin/rooms/${room}/seat-ai`]) {
|
||||
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`, `/api/admin/rooms/${room}/discussion`]) {
|
||||
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');
|
||||
@@ -64,6 +64,13 @@ void test('admin HTTP denies anonymous, forged admin, room-host and spoofed iden
|
||||
assert.equal(participant.status, 401);
|
||||
assert.equal(participant.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await participant.json()), ['error']);
|
||||
const discussion = await fetch(`${base}/api/admin/rooms/${room}/discussion`, {
|
||||
method: 'POST', headers: { ...headers, origin: base, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ operationId: crypto.randomUUID(), expectedVersion: 0, expectedRevision: 0, target: 'owner', muted: true, reason: 'Denied moderation QA' }),
|
||||
});
|
||||
assert.equal(discussion.status, 401);
|
||||
assert.equal(discussion.headers.get('set-cookie'), null);
|
||||
assert.deepEqual(Object.keys(await discussion.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 { adminDiscussionConfirmation, adminDiscussionResponse, clearAdminDiscussionRequest, newAdminDiscussionRequest, readAdminDiscussionRequest, saveAdminDiscussionRequest } from '../lib/admin-discussion-client';
|
||||
import type { AdminDiscussionView } from '../lib/admin-discussion';
|
||||
|
||||
const view = (): AdminDiscussionView => ({ code: 'SEATQAAB', version: 7, status: 'playing', closed: false, removed: false, archived: false,
|
||||
editable: true, blockedReason: null, players: [{ id: 'owner', name: 'QA Owner', faction: 'atreides', control: 'human', muted: false, revision: 2 }] });
|
||||
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 = newAdminDiscussionRequest(room, 'owner', true, ' QA support ');
|
||||
assert.equal(input.reason, 'QA support');
|
||||
saveAdminDiscussionRequest(tab, 'admin', room.code, input);
|
||||
assert.deepEqual(readAdminDiscussionRequest(tab, 'admin', room.code), input);
|
||||
assert.equal(readAdminDiscussionRequest(tab, 'other', room.code), null);
|
||||
assert.equal(readAdminDiscussionRequest(tab, 'admin', 'OTHERQAA'), null);
|
||||
for (const change of [{ operationId: crypto.randomUUID() }, { expectedVersion: 8 }, { expectedRevision: 3 }, { target: 'other' }, { muted: false }, { reason: 'Different' }])
|
||||
assert.throws(() => saveAdminDiscussionRequest(tab, 'admin', room.code, { ...input, ...change }), /Resolve the saved/);
|
||||
saveAdminDiscussionRequest(tab, 'admin', room.code, input);
|
||||
clearAdminDiscussionRequest(tab, 'admin', room.code); assert.equal(readAdminDiscussionRequest(tab, 'admin', room.code), null);
|
||||
});
|
||||
void test('bad participant retry storage cannot lose or overwrite unresolved intent', () => {
|
||||
const tab = storage(), room = view(), input = newAdminDiscussionRequest(room, 'owner', true, 'QA');
|
||||
assert.throws(() => saveAdminDiscussionRequest({ ...tab, setItem() {} }, 'admin', room.code, input), /Nothing was sent/);
|
||||
saveAdminDiscussionRequest(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, expectedRevision: -1 })]) {
|
||||
tab.values.set(key, raw);
|
||||
assert.throws(() => readAdminDiscussionRequest(tab, 'admin', room.code), /unreadable/);
|
||||
assert.throws(() => saveAdminDiscussionRequest(tab, 'admin', room.code, input), /unreadable/);
|
||||
assert.equal(tab.values.get(key), raw);
|
||||
}
|
||||
assert.throws(() => clearAdminDiscussionRequest({ ...tab, removeItem() {} }, 'admin', room.code), /could not be cleared/);
|
||||
clearAdminDiscussionRequest(tab, 'admin', room.code);
|
||||
for (const reason of [' ', 'a'.repeat(301), 'two\nlines', 'control\u007f']) assert.throws(() => newAdminDiscussionRequest(room, 'owner', true, reason));
|
||||
assert.throws(() => newAdminDiscussionRequest({ ...room, editable: false, blockedReason: 'Pause first' }, 'owner', true, 'QA'), /Pause first/);
|
||||
assert.throws(() => newAdminDiscussionRequest(room, 'absent', true, 'QA'), /human participant/);
|
||||
});
|
||||
void test('participant confirmation binds original operation/version but accepts later human unmute', () => {
|
||||
const room = view(), input = newAdminDiscussionRequest(room, 'owner', true, 'QA');
|
||||
const result = { operationId: input.operationId, appliedRevision: 3, replayed: true, room: { ...room, version: 10, players: [{ ...room.players[0], revision: 4 }] } };
|
||||
assert.deepEqual(adminDiscussionConfirmation(result, room.code, input), result);
|
||||
for (const bad of [{ ...result, operationId: crypto.randomUUID() }, { ...result, appliedRevision: 4 }, { ...result, replayed: 'yes' },
|
||||
{ ...result, room: { ...room, version: 7 } }, { ...result, token: 'unexpected' }]) assert.throws(() => adminDiscussionConfirmation(bad, room.code, input));
|
||||
});
|
||||
void test('participant responses reject unexpected private fields and malformed operational values', () => {
|
||||
const room = view(); assert.deepEqual(adminDiscussionResponse(room, room.code), room);
|
||||
for (const bad of [null, [], { ...room, state: {} }, { ...room, auditReason: 'private' }, { ...room, code: 'bad' },
|
||||
{ ...room, version: -1 }, { ...room, removed: 'yes' }, { ...room, players: [{ ...room.players[0], revision: 1.2 }] },
|
||||
{ ...room, players: [{ ...room.players[0], hand: [] }] }, { ...room, players: [{ ...room.players[0], muted: 'yes' }] }])
|
||||
assert.throws(() => adminDiscussionResponse(bad, room.code));
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { adminStore } from './admin-access-fixture';
|
||||
import { adminLogin, requireAdmin, AdminError, type AdminRole } from '../db/admin-access';
|
||||
import { readAdminDiscussion, applyAdminDiscussion } from '../db/admin-discussion';
|
||||
import { createGame, newPlayer } from '../game/engine';
|
||||
import { adminDiscussionConfirmation } from '../lib/admin-discussion-client';
|
||||
import type { AdminDiscussionInput } from '../lib/admin-discussion';
|
||||
|
||||
const now = 10_000, code = 'MUTEQAAA';
|
||||
const denied = (error: unknown) => error instanceof AdminError && [401,403,409].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 player = newPlayer(randomUUID(), 'Public participant', 'atreides');
|
||||
const game = createGame(code, player);
|
||||
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)').run(code, JSON.stringify(game), 7, 42);
|
||||
const input: AdminDiscussionInput = { operationId: randomUUID(), expectedVersion: 7, expectedRevision: 0, target: player.id, muted: true, reason: 'Private operational reason' };
|
||||
const apply = (request = input) => applyAdminDiscussion(store.database, identity, code, request, now);
|
||||
const rooms = () => store.sqlite.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
const controls = () => store.sqlite.prepare('SELECT * FROM seat_discussion_controls').all();
|
||||
const audits = () => store.sqlite.prepare('SELECT * FROM admin_discussion_operations').all();
|
||||
return { ...store, identity, player, input, apply, rooms, controls, audits };
|
||||
}
|
||||
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('discussion moderation changes only metadata; old receipt after unmute/removal never reapplies', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) SELECT ?,state,version,updated_at FROM rooms WHERE code=?').run('OTHERQAA', code);
|
||||
f.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run('private seat hash', code, f.player.id);
|
||||
const before = f.rooms(), seats = f.sqlite.prepare('SELECT * FROM seats').all();
|
||||
const first = await f.apply(); adminDiscussionConfirmation(first, code, f.input);
|
||||
assert.equal(first.replayed, false); assert.equal(first.room.players[0].muted, true);
|
||||
assert.deepEqual(Object.keys(first.room.players[0]).sort(), ['control','faction','id','muted','name','revision']);
|
||||
assert.doesNotMatch(JSON.stringify(first), /Private|token_hash|traitor|hand|recovery/i);
|
||||
await f.apply({ ...f.input, operationId: randomUUID(), expectedRevision: 1, muted: false });
|
||||
const retry = await f.apply(); adminDiscussionConfirmation(retry, code, f.input);
|
||||
assert.equal(retry.replayed, true); assert.equal(retry.room.players[0].muted, false);
|
||||
f.sqlite.prepare('INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES(?,1,1,1,1)').run(code);
|
||||
assert.equal((await f.apply()).room.removed, true);
|
||||
await assert.rejects(() => f.apply({ ...f.input, operationId: randomUUID(), expectedRevision: 2 }), denied);
|
||||
await assert.rejects(() => f.apply({ ...f.input, reason: 'Changed payload' }), denied);
|
||||
assert.equal(f.audits().length, 2); assert.deepEqual(f.rooms(), before); assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seats);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('closed archived rooms permit settings for reopening without exposing discussion or requiring a seat credential', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
f.sqlite.prepare('INSERT INTO room_closures(room_code,closed,revision,closed_at,updated_at) VALUES(?,1,1,1,1)').run(code);
|
||||
f.sqlite.prepare('INSERT INTO room_archives(room_code,archived,revision,archived_at,updated_at) VALUES(?,1,1,1,1)').run(code);
|
||||
const before = f.rooms(), result = await f.apply();
|
||||
assert.equal(result.room.archived, true); assert.equal(result.room.closed, true); assert.equal(result.room.players[0].muted, true); assert.deepEqual(f.rooms(), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
for (const [name, sql] of [
|
||||
['operator demotion', "UPDATE admin_accounts SET role='viewer'"], ['operator disable', 'UPDATE admin_accounts SET enabled=0'],
|
||||
['session revocation', 'UPDATE admin_sessions SET revoked_at=1'], ['session generation', 'UPDATE admin_accounts SET session_generation=session_generation+1'],
|
||||
['session expiration', 'UPDATE admin_sessions SET expires_at=1'], ['room version', 'UPDATE rooms SET version=version+1'],
|
||||
['room removal', "INSERT INTO room_removals(room_code,removed,revision,removed_at,updated_at) VALUES('MUTEQAAA',1,1,1,1)"],
|
||||
['human conversion', "UPDATE rooms SET state=json_set(state,'$.players[0].bot','Easy')"],
|
||||
['membership removal', "UPDATE rooms SET state=json_set(state,'$.players',json('[]'))"],
|
||||
]) void test(`discussion write fences ${name} at commit`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
let batches = 0;
|
||||
f.hooks.beforeBatch = async () => { if (++batches === 2) { delete f.hooks.beforeBatch; f.sqlite.exec(sql); } };
|
||||
await assert.rejects(f.apply, denied); assert.equal(f.controls().length, 0); assert.equal(f.audits().length, 0);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('failed moderation audit rolls back metadata', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = f.rooms();
|
||||
f.sqlite.exec("CREATE TRIGGER reject_receipt BEFORE INSERT ON admin_discussion_operations BEGIN SELECT RAISE(ABORT,'audit blocked'); END;");
|
||||
await assert.rejects(f.apply, /audit blocked/); assert.equal(f.controls().length, 0); assert.equal(f.audits().length, 0); assert.deepEqual(f.rooms(), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
for (const different of [false, true]) void test(`racing ${different ? 'conflicting' : 'identical'} discussion operations retain one audit`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const barrier = pauseCommit(f), pending = f.apply(); await barrier.waiting;
|
||||
assert.equal((await f.apply(different ? { ...f.input, reason: 'Different reason' } : f.input)).replayed, false);
|
||||
barrier.release();
|
||||
if (different) await assert.rejects(() => pending, denied); else assert.equal((await pending).replayed, true);
|
||||
assert.equal(f.audits().length, 1); assert.equal(f.controls()[0].revision, 1);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('mute/unmute race rejects stale metadata revision even though game version remains unchanged', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = f.rooms(), barrier = pauseCommit(f), pending = f.apply(); await barrier.waiting;
|
||||
await f.apply({ ...f.input, operationId: randomUUID() });
|
||||
await f.apply({ ...f.input, operationId: randomUUID(), expectedRevision: 1, muted: false });
|
||||
barrier.release(); await assert.rejects(() => pending, denied);
|
||||
assert.equal(f.audits().length, 2); assert.equal(f.controls()[0].muted, 0); assert.deepEqual(f.rooms(), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
void test('viewer is read-only and malformed roster is rejected without reflecting private contents', async () => {
|
||||
const f = await fixture('viewer');
|
||||
try {
|
||||
assert.equal((await readAdminDiscussion(f.database, f.identity, code, now)).players.length, 1);
|
||||
await assert.rejects(f.apply, denied);
|
||||
f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run('{"status":"playing","players":"PRIVATE CREDENTIAL"}', code);
|
||||
const view = await readAdminDiscussion(f.database, f.identity, code, now);
|
||||
assert.equal(view.editable, false); assert.equal(view.status, 'unreadable'); assert.deepEqual(view.players, []);
|
||||
assert.doesNotMatch(JSON.stringify(view), /PRIVATE CREDENTIAL/); assert.equal(f.audits().length, 0);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -127,11 +127,11 @@ void test('discussion HTTP preserves sealed setup and room version; public and d
|
||||
undefined,
|
||||
f.c.cookie,
|
||||
);
|
||||
assert.deepEqual(outsider.data, { messages: [], before: null });
|
||||
assert.deepEqual(outsider.data, { messages: [], before: null, muted: false });
|
||||
assert.deepEqual(
|
||||
(await request(endpoint + `?before=${direct.id}`, undefined, f.c.cookie))
|
||||
.data,
|
||||
{ messages: [], before: null },
|
||||
{ messages: [], before: null, muted: false },
|
||||
);
|
||||
const publicText = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -227,6 +227,6 @@ void test('discussion HTTP retries save once and seat recovery inherits conversa
|
||||
f.c.cookie,
|
||||
)
|
||||
).data,
|
||||
{ messages: [], before: null },
|
||||
{ messages: [], before: null, muted: false },
|
||||
);
|
||||
});
|
||||
@@ -87,7 +87,7 @@ void test('public and private discussion preserves game rows and hides direct ac
|
||||
assert.equal(JSON.stringify(publicPage).includes('PRIVATE'), false);
|
||||
assert.deepEqual(
|
||||
await f.talk.readTableTalk(f.code, f.auth[2], f.auth[0].playerId),
|
||||
{ messages: [], before: null },
|
||||
{ messages: [], before: null, muted: false },
|
||||
);
|
||||
for (const [viewer, peer] of [
|
||||
[0, 1],
|
||||
@@ -251,11 +251,11 @@ void test('pagination filters before limiting and never accepts a hidden message
|
||||
assert.equal(older.before, null);
|
||||
assert.deepEqual(
|
||||
await f.talk.readTableTalk(f.code, f.auth[2], null, privateId),
|
||||
{ messages: [], before: null },
|
||||
{ messages: [], before: null, muted: false },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await f.talk.readTableTalk(f.code, f.auth[2], null, crypto.randomUUID()),
|
||||
{ messages: [], before: null },
|
||||
{ messages: [], before: null, muted: false },
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
@@ -347,3 +347,50 @@ void test('a credential rotation after a committed insert still returns that exa
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('mute prevents new public/private sends, preserves incoming history and exact committed retries, and follows seat rotation', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = gameRows(f), prior = draft('Private history before mute', f.auth[1].playerId);
|
||||
await f.talk.sendTableTalk(f.code, f.auth[0], prior, 1000);
|
||||
f.sqlite.prepare('INSERT INTO seat_discussion_controls(room_code,player_id,muted,revision,updated_at) VALUES(?,?,1,1,1)').run(f.code, f.auth[0].playerId);
|
||||
for (const recipient of [null, f.auth[1].playerId])
|
||||
await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], draft('Blocked', recipient), 2000), (error: unknown) => !!error && typeof error === 'object' && 'code' in error && error.code === 'SEAT_DISCUSSION_MUTED');
|
||||
assert.equal((await f.talk.sendTableTalk(f.code, f.auth[0], prior, 3000)).replayed, true);
|
||||
await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], { ...prior, text: 'Altered' }, 3000));
|
||||
await f.talk.sendTableTalk(f.code, f.auth[1], draft('Incoming remains available', f.auth[0].playerId), 4000);
|
||||
const own = await f.talk.readTableTalk(f.code, f.auth[0], f.auth[1].playerId);
|
||||
assert.equal(own.muted, true); assert.equal(own.messages.length, 2);
|
||||
const observer = await f.talk.readTableTalk(f.code, f.auth[2], null);
|
||||
assert.deepEqual(observer, { messages: [], before: null, muted: false });
|
||||
f.sqlite.prepare('UPDATE seats SET revoked=1 WHERE token_hash=?').run(f.auth[0].tokenHash);
|
||||
const fresh = { ...f.auth[0], tokenHash: 'new-muted-controller' };
|
||||
f.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)').run(fresh.tokenHash, f.code, fresh.playerId);
|
||||
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[0], null));
|
||||
assert.deepEqual(await f.load().readTableTalk(f.code, fresh, f.auth[1].playerId), own);
|
||||
await assert.rejects(f.talk.sendTableTalk(f.code, fresh, prior, 5000));
|
||||
await assert.rejects(f.talk.sendTableTalk(f.code, fresh, draft('Still muted'), 5000));
|
||||
f.sqlite.prepare('UPDATE seat_discussion_controls SET muted=0,revision=2').run();
|
||||
await f.talk.sendTableTalk(f.code, fresh, draft('Unmuted'), 6000);
|
||||
assert.equal((await f.talk.readTableTalk(f.code, fresh, null)).muted, false);
|
||||
assert.deepEqual(gameRows(f), before);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
for (const when of ['before', 'after'] as const) void test(`mute ${when} message insert respects commit order`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = draft('Racing mute');
|
||||
const hook = when === 'before' ? 'beforeStatement' : 'afterStatement';
|
||||
f.hooks[hook] = async sql => {
|
||||
if (!sql.startsWith('INSERT INTO room_messages')) return;
|
||||
delete f.hooks[hook];
|
||||
f.sqlite.prepare('INSERT INTO seat_discussion_controls(room_code,player_id,muted,revision,updated_at) VALUES(?,?,1,1,1)').run(f.code, f.auth[0].playerId);
|
||||
};
|
||||
if (when === 'before') await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], input, 1000));
|
||||
else {
|
||||
assert.deepEqual(await f.talk.sendTableTalk(f.code, f.auth[0], input, 1000), { id: input.id, replayed: false });
|
||||
assert.equal((await f.talk.sendTableTalk(f.code, f.auth[0], input, 2000)).replayed, true);
|
||||
}
|
||||
assert.equal(f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n, when === 'before' ? 0 : 1);
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Creates its own QA room; never accepts an existing game for moderation.
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
export async function verifyDiscussionModeration(request, adminCookie, report) {
|
||||
const made = await request('/api/rooms', { name: 'Discussion moderation 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 + '/discussion';
|
||||
report.discussionRoom = code;
|
||||
const joined = await request(path, { type: 'join', name: 'Discussion guest QA', faction: 'emperor' });
|
||||
assert.equal(joined.status, 200);
|
||||
const guest = joined.cookie, messages = path + '/messages';
|
||||
const prior = { id: randomUUID(), recipientId: joined.data.me, text: 'QA message before mute' };
|
||||
assert.equal((await request(messages, prior, host)).status, 200);
|
||||
const before = (await request(path, undefined, host)).data;
|
||||
const initial = await request(adminPath, undefined, adminCookie); assert.equal(initial.status, 200);
|
||||
const input = { operationId: randomUUID(), expectedVersion: initial.data.version, expectedRevision: 0, target: made.data.me, muted: true, reason: 'Discussion moderation QA' };
|
||||
for (const denied of [
|
||||
await request(adminPath, input, host), await request(adminPath, input, adminCookie, 'https://foreign.invalid'),
|
||||
await request(adminPath, input, adminCookie, undefined, randomUUID()),
|
||||
]) { assert.ok([401,403].includes(denied.status)); assert.equal(denied.rawCookie, null); }
|
||||
const muted = await request(adminPath, input, adminCookie);
|
||||
assert.equal(muted.status, 200); assert.equal(muted.rawCookie, null); assert.equal(muted.data.replayed, false);
|
||||
assert.equal(muted.data.appliedRevision, 1); assert.equal(muted.data.room.version, initial.data.version);
|
||||
assert.deepEqual(Object.keys(muted.data.room.players[0]).sort(), ['control','faction','id','muted','name','revision']);
|
||||
assert.deepEqual((await request(path, undefined, host)).data, before);
|
||||
for (const recipientId of [null, joined.data.me]) {
|
||||
const denied = await request(messages, { id: randomUUID(), recipientId, text: 'Must be blocked' }, host);
|
||||
assert.equal(denied.status, 409); assert.equal(denied.data.code, 'SEAT_DISCUSSION_MUTED');
|
||||
}
|
||||
const own = await request(messages + '?recipient=' + joined.data.me, undefined, host);
|
||||
assert.equal(own.status, 200); assert.equal(own.data.muted, true); assert.equal(own.data.messages[0].id, prior.id);
|
||||
assert.equal((await request(messages, undefined, guest)).data.muted, false);
|
||||
const retry = await request(messages, prior, host); assert.equal(retry.status, 200); assert.equal(retry.data.replayed, true);
|
||||
assert.equal((await request(messages, { id: randomUUID(), recipientId: made.data.me, text: 'QA incoming message while muted' }, guest)).status, 200);
|
||||
report.checks.push('Discussion moderation enforces live admin/account/origin authority, own-only mute status, new-send barrier and exact committed message retry without game changes');
|
||||
const undo = { ...input, operationId: randomUUID(), expectedRevision: 1, muted: false };
|
||||
assert.equal((await request(adminPath, undo, adminCookie)).status, 200);
|
||||
const old = await request(adminPath, input, adminCookie);
|
||||
assert.equal(old.status, 200); assert.equal(old.data.replayed, true);
|
||||
assert.equal(old.data.room.players.find(player => player.id === input.target).muted, false);
|
||||
assert.equal((await request(messages, undefined, host)).data.muted, false);
|
||||
assert.deepEqual((await request(path, undefined, host)).data, before);
|
||||
report.checks.push('Unmute restores sending availability; replaying an old admin request cannot mute again and preserves both human seats');
|
||||
}
|
||||
@@ -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 { verifyDiscussionModeration } from './admin-discussion-verification.mjs';
|
||||
import { verifyParticipantAi } from './admin-seat-ai-verification.mjs';
|
||||
import { verifyRoomArchive } from './admin-archive-verification.mjs';
|
||||
import { verifyRoomClosure } from './admin-closure-verification.mjs';
|
||||
@@ -36,7 +37,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|seat-ai)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
|
||||
...(expectedAdminId && (path === '/api/admin/rooms' && body !== undefined || /^\/api\/admin\/rooms\/[^/]+\/(control|lobby|removal|closure|archive|seat-ai|discussion)$/.test(path)) ? { 'X-Dune-Admin-Id': expectedAdminId } : {}) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
report.lastRequest = { path, status: response.status };
|
||||
@@ -95,6 +96,7 @@ try {
|
||||
await verifyRoomClosure(request, session1, report);
|
||||
await verifyRoomArchive(request, session1, report);
|
||||
await verifyParticipantAi(request, session1, report);
|
||||
await verifyDiscussionModeration(request, session1, report);
|
||||
}
|
||||
report.passed = true;
|
||||
} catch (error) {
|
||||
|
||||
Reference in new issue
Block a user