Add durable public and private table discussion

Humans can discuss plans publicly or message another human seat privately while
pending game decisions stay intact. The conversation panel provides plain text,
explicit recipient names and factions, saved history, private pagination and
exact retries through refresh. Human AI-control owners can still talk; bots do
not interpret messages or negotiate. Existing game actions execute agreements.

An additive indexed D1 message table stores discussion separately from game JSON,
versioning, readiness and bot continuation. Every read/insert checks active seat
credentials and membership; direct audiences and cursor lookups are filtered
before limiting. Public history has no private activity counts or sequential IDs.
Recovery/handover inherits seat history while fencing old credentials.

Independent review found two retry races: a rotation between a committed insert
and receipt read, and stale unmounted completion deleting a newer retry record.
Both are fixed with regressions. A prior uncertain retry stays pending on later
rejection. The nine focused offline cases and two HTTP cases cover production SQL,
concurrent/altered receipts, audience/cursor privacy, rotation/membership races,
actual saved-kit recovery and unchanged sealed setup state.

Types, lint, all 4963 offline tests, production build and 48 HTTP tests
pass on stable source. An initial check caught an invalid checklist status label;
that was corrected before the successful final2 verification.

Desktop browser checks send public/private messages, receive a peer reply, retain
private history after refresh and remove it on channel switch. Literal markup and
URLs remain text. Recipient and bystander API reads confirm the browser messages'
audiences. Screenshot capture was unavailable and the viewport override did not
take effect; mobile, full keyboard/visual and injected browser network-failure
acceptance remain open. No final rules, mode, AI or publication gate changes.

All 1373 pre-existing rooms, 2883 seats and auxiliary rows
are unchanged. SQLite integrity/foreign keys pass; migration 0007 adds messages
without replacing data. The healthy server was reused without restart.

Private evidence: 20260921-table-talk.
Verified source tree: 236dd55b6c15d55dbced735be67427e5fbd7fa348d583f119e67a03894ec21b0
This commit is contained in:
Quintine committed 2026-09-21 09:52:31 +10:00
1 parent b2fcef8d51
commit 7814f740ef
19 files changed
+2303 -17

No files matched your search

+6
View File
@@ -51,6 +51,12 @@ An uncertain saved-kit recovery keeps its exact retry details in memory and prev
Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits. Use **Pass your seat to another player** for a voluntary one-time transfer. The private offer lasts 24 hours; acceptance preserves progress and revokes the old owner’s sessions and recovery kits. The recipient uses **Accept a seat handover** on the home page, then creates their own recovery kit. Keep the tab open until acceptance is confirmed; exact private retry details survive refresh in that tab. See [seat handover](docs/SEAT_HANDOVER.md) for cancellation, retry behavior and current limits.
Use **Table discussion** below the board for public conversation or private
messages to another human seat. Messages survive refresh; exact retries avoid
duplicates. Private history follows the seat through recovery or handover.
Messages do not execute game actions, and AI opponents do not answer them yet.
See [table discussion](docs/TABLE_DISCUSSION.md).
The table header offers optional **Sound effects**, with mute, volume and a test The table header offers optional **Sound effects**, with mute, volume and a test
sample. Short cues mark phase changes and automatic action notices. Sound starts sample. Short cues mark phase changes and automatic action notices. Sound starts
off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md) off; preferences stay in your browser. See [table sounds](docs/TABLE_SOUNDS.md)
+73
View File
@@ -0,0 +1,73 @@
import { authenticate } from '@/db/rooms';
import { readTableTalk, sendTableTalk, TableTalkError } from '@/db/table-talk';
import { RuleError } from '@/game/engine';
const noStore = { 'Cache-Control': 'no-store' };
async function handle(req: Request, write: boolean) {
try {
const url = new URL(req.url);
if (
write &&
req.headers.get('origin') &&
req.headers.get('origin') !== url.origin
)
throw new TableTalkError('Invalid request origin.', 403);
const code = url.pathname.split('/').at(-2)!.toUpperCase();
if (!/^[A-Z2-9]{8}$/.test(code))
throw new TableTalkError('Invalid room code.', 400);
const token =
req.headers
.get('cookie')
?.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`dune_${code}=`))
?.split('=')[1] ?? '';
const auth = await authenticate(code, token);
if (write) {
const raw = await req.text();
if (raw.length > 6500)
throw new TableTalkError('Request too large.', 413);
return Response.json(await sendTableTalk(code, auth, JSON.parse(raw)), {
headers: noStore,
});
}
if (
[...url.searchParams.keys()].some(
(key) => !['recipient', 'before'].includes(key),
)
)
throw new TableTalkError('Invalid conversation request.', 400);
return Response.json(
await readTableTalk(
code,
auth,
url.searchParams.get('recipient'),
url.searchParams.get('before'),
),
{ headers: noStore },
);
} catch (error) {
return Response.json(
{
error:
error instanceof TableTalkError || error instanceof RuleError
? error.message
: error instanceof SyntaxError
? 'Invalid JSON.'
: 'Table discussion is temporarily unavailable.',
},
{
status:
error instanceof TableTalkError
? error.status
: error instanceof RuleError
? 409
: error instanceof SyntaxError
? 400
: 500,
headers: noStore,
},
);
}
}
export const GET = (req: Request) => handle(req, false);
export const POST = (req: Request) => handle(req, true);
+15
View File
@@ -1927,3 +1927,18 @@ footer {
.spice-card-front > button { .spice-card-front > button {
margin-top: auto; margin-top: auto;
} }
/* Table discussion uses the same dark surfaces and readable controls as the table. */
.table-talk { margin-block: 1.5rem; border: 1px solid var(--border); border-radius: 0.75rem; overflow: hidden; }
.table-talk h2 { margin: 0; font-size: 1.125rem; }
.table-talk h2 button { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 48px; padding: 1rem; text-align: left; }
.table-talk-content { padding: 0 1rem 1rem; }
.table-talk-content label { display: block; margin-block: 0.75rem 0.375rem; font-size: 1rem; }
.table-talk-content select, .table-talk-content textarea { width: 100%; min-height: 44px; font-size: 1rem; }
.table-talk-content textarea { resize: vertical; }
.table-talk-content button { min-height: 44px; }
.table-talk-messages { list-style: none; margin: 1rem 0; padding: 0; max-height: 24rem; overflow-y: auto; overflow-wrap: anywhere; }
.table-talk-messages li { padding: 0.75rem 0; border-bottom: 1px solid var(--border); }
.table-talk-messages p { white-space: pre-wrap; unicode-bidi: plaintext; margin: 0.5rem 0 0; font-size: 1rem; }
.table-talk-author { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.25rem 1rem; }
.table-talk-author time { font-size: 0.875rem; }
+2
View File
@@ -1,6 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { TableTalk } from '@/components/table-talk';
import { GameTable } from '@/components/game-table'; import { GameTable } from '@/components/game-table';
import { import {
SeatHandoverSetup, SeatHandoverSetup,
@@ -675,6 +676,7 @@ export default function Home() {
exitDisabled={handoverPending || aiPermissionPending} exitDisabled={handoverPending || aiPermissionPending}
/> />
<div className="table-shell"> <div className="table-shell">
<TableTalk key={`talk:${game.code}:${game.me}`} game={game} />
{(entryAttempt || entryProblem) && ( {(entryAttempt || entryProblem) && (
<section className="notice"> <section className="notice">
<p> <p>
+388
View File
@@ -0,0 +1,388 @@
'use client';
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 {
TALK_LIMIT,
clearTalkDraft,
mergeTalkPages,
restoreTalkDraft,
talkDraftKey,
type TalkDraft,
type TalkPage,
} from '@/lib/table-talk';
/** Remount on room/seat changes. Messages belong to seats, never browser identities. */
export function TableTalk({ game }: { game: GameView }) {
const field = useId();
const [open, setOpen] = useState(false);
const [recipient, setRecipient] = useState('');
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 [ready, setReady] = useState(false);
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState('');
const [readError, setReadError] = useState('');
const [refresh, setRefresh] = useState(0);
const sending = useRef(false);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const olderPending = useRef(false);
const readingHistory = useRef(false);
const [history, setHistory] = useState(false);
const channelGeneration = useRef(0);
const key = talkDraftKey(game.code, game.me);
const peers = game.players.filter(
(player) => player.id !== game.me && !player.bot,
);
const target = peers.find((player) => player.id === recipient);
const own = game.players.find((player) => player.id === game.me);
useEffect(() => {
try {
const raw = sessionStorage.getItem(key);
const saved = restoreTalkDraft(raw);
if (saved) {
// oxlint-disable-next-line react/react-compiler -- Restore the exact pending message after SSR.
setRecord(saved);
setRecipient(saved.recipientId ?? '');
setText(saved.text);
setOpen(true);
setNotice(
'A message may have been sent. Retry this exact message to confirm it without a duplicate.',
);
} else if (raw) {
setOpen(true);
setNotice(
'The saved message could not be read. No message was sent from that record.',
);
}
setReady(true);
} catch {
setNotice(
'Allow tab storage and reload to send messages with safe retry.',
);
}
}, [key]);
const endpoint = `/api/rooms/${game.code}/messages`;
function pageUrl(before?: string) {
const query = new URLSearchParams();
if (recipient) query.set('recipient', recipient);
if (before) query.set('before', before);
return `${endpoint}?${query}`;
}
const url = pageUrl();
const shownPage = pageChannel === url ? page : { messages: [], before: null };
useEffect(() => {
if (!open) return;
const controller = new AbortController();
const generation = ++channelGeneration.current;
let timer: ReturnType<typeof setTimeout>;
async function read() {
if (readingHistory.current) {
timer = setTimeout(read, 5000);
return;
}
try {
const result = await requestJson<TalkPage>(url, {
signal: controller.signal,
});
if (
!controller.signal.aborted &&
generation === channelGeneration.current &&
!readingHistory.current
) {
setPage(result);
setPageChannel(url);
setReadError('');
}
} catch {
if (!controller.signal.aborted)
setReadError(
'Messages could not be refreshed. The table is still available.',
);
} finally {
if (!controller.signal.aborted) timer = setTimeout(read, 5000);
}
}
// A conversation change clears the old recipient's text before any new read.
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 });
void read();
return () => {
controller.abort();
clearTimeout(timer);
channelGeneration.current = generation + 1;
};
}, [url, open, refresh]);
async function older() {
if (!shownPage.before || olderPending.current) return;
olderPending.current = true;
readingHistory.current = true;
setHistory(true);
setLoading(true);
const generation = channelGeneration.current;
try {
const result = await requestJson<TalkPage>(pageUrl(shownPage.before));
if (generation === channelGeneration.current)
setPage((current) => ({
before: result.before,
messages: mergeTalkPages(result.messages, current.messages),
}));
} catch {
if (generation === channelGeneration.current)
setReadError('Older messages could not be loaded. Try again.');
} finally {
olderPending.current = false;
setLoading(false);
}
}
async function submit() {
if (sending.current || !ready) return;
const draft = record ?? {
id: crypto.randomUUID(),
recipientId: recipient || null,
text,
};
const wasRetry = !!record;
sending.current = true;
setBusy(true);
let dispatched = false;
try {
sessionStorage.setItem(key, JSON.stringify(draft));
setRecord(draft);
dispatched = true;
const receipt = await requestJson<{ id: string; replayed: boolean }>(
endpoint,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(draft),
},
);
if (!mounted.current) return;
if (receipt.id !== draft.id)
throw new Error('Message receipt did not match.');
clearTalkDraft(sessionStorage, key, draft);
setRecord(null);
setText('');
setNotice(
receipt.replayed
? 'The original message is saved. No duplicate was sent.'
: 'Message sent.',
);
setRefresh((value) => value + 1);
} catch (error) {
if (!mounted.current) return;
if (dispatched && (wasRetry || requestMayHaveCompleted(error))) {
setNotice(
'Message not confirmed. Retry the exact message, or check the conversation before discarding its retry details.',
);
} else {
try {
clearTalkDraft(sessionStorage, key, draft);
setRecord(null);
} catch {}
setNotice(
error instanceof Error ? error.message : 'Message could not be sent.',
);
}
} finally {
sending.current = false;
if (mounted.current) setBusy(false);
}
}
if (!own || own.bot) return null;
return (
<section
className="table-talk"
id="table-discussion"
aria-labelledby={`${field}-title`}
>
<h2 id={`${field}-title`}>
<button
type="button"
aria-expanded={open}
aria-controls={`${field}-content`}
onClick={() => setOpen((value) => !value)}
>
Table discussion <span aria-hidden="true">{open ? '−' : '+'}</span>
</button>
</h2>
{open && (
<div id={`${field}-content`} className="table-talk-content">
<p className="muted">
Talk through plans and offers. Use game controls to transfer spice,
form alliances or make a Truthtrance commitment. Messages do not
perform game actions.
</p>
<label htmlFor={`${field}-recipient`}>Conversation</label>
<select
id={`${field}-recipient`}
value={recipient}
disabled={busy || !!record}
onChange={(event) => {
setRecipient(event.target.value);
setText('');
setNotice('');
}}
>
<option value="">Everyone at the table</option>
{peers.map((player) => (
<option key={player.id} value={player.id}>
Private · {player.name} · {faction(player.faction).name}
</option>
))}
{recipient && !target && (
<option value={recipient}>Previous seat</option>
)}
</select>
<p className="fine">
{recipient
? `Only your seat and ${target?.name ?? 'the addressed seat'} can read this conversation. A seat's next owner inherits its messages.`
: 'Public messages are visible to everyone who joins this room.'}{' '}
AI opponents do not read or answer messages yet.
</p>
{readError && (
<output>
{readError}{' '}
<Button
variant="outline"
onClick={() => setRefresh((value) => value + 1)}
>
Refresh messages
</Button>
</output>
)}
{history && (
<p className="fine">
Browsing saved history.{' '}
<Button
variant="outline"
onClick={() => setRefresh((value) => value + 1)}
>
Return to latest messages
</Button>
</p>
)}
{shownPage.before && (
<Button
variant="outline"
disabled={loading}
onClick={() => void older()}
>
{loading ? 'Loading…' : 'Load older messages'}
</Button>
)}
<ol
className="table-talk-messages"
aria-label={
recipient
? 'Private seat conversation'
: 'Public table conversation'
}
aria-live="polite"
aria-relevant="additions"
>
{shownPage.messages.map((message) => (
<li key={message.id}>
<div className="table-talk-author">
<strong>
<bdi>{message.senderName}</bdi>
</strong>
<time dateTime={new Date(message.createdAt).toISOString()}>
{new Date(message.createdAt).toLocaleString()}
</time>
</div>
<p dir="auto">{message.text}</p>
</li>
))}
</ol>
{shownPage.messages.length === 0 && (
<p className="muted">No messages loaded in this conversation.</p>
)}
<form
onSubmit={(event) => {
event.preventDefault();
void submit();
}}
>
<label htmlFor={`${field}-message`}>
{recipient
? `Message to ${target?.name ?? 'the addressed seat'}`
: 'Message to everyone'}
</label>
<textarea
id={`${field}-message`}
rows={3}
maxLength={TALK_LIMIT}
value={text}
disabled={busy || !!record}
onChange={(event) => setText(event.target.value)}
aria-describedby={`${field}-count`}
/>
<p className="fine" id={`${field}-count`}>
{text.length} / {TALK_LIMIT} characters · plain text
</p>
<Button
type="submit"
disabled={
!ready ||
busy ||
!text.trim() ||
(!record && !!recipient && !target)
}
>
{busy
? 'Sending…'
: record
? 'Retry exact message'
: recipient
? 'Send private message'
: 'Send to everyone'}
</Button>
</form>
{notice && <output>{notice}</output>}
{record && !busy && (
<p className="fine">
Discarding retry details cannot undo a message already sent.
</p>
)}
{record && !busy && (
<Button
variant="outline"
onClick={() => {
try {
sessionStorage.removeItem(key);
setRecord(null);
setText('');
setNotice(
'Retry details discarded. A message already sent remains in the conversation.',
);
} catch {
setNotice(
'Retry details could not be removed from tab storage.',
);
}
}}
>
Discard retry details
</Button>
)}
</div>
)}
</section>
);
}
+34
View File
@@ -3,6 +3,8 @@ import {
text, text,
integer, integer,
primaryKey, primaryKey,
index,
uniqueIndex,
} from 'drizzle-orm/sqlite-core'; } from 'drizzle-orm/sqlite-core';
export const rooms = sqliteTable('rooms', { export const rooms = sqliteTable('rooms', {
code: text('code').primaryKey(), code: text('code').primaryKey(),
@@ -118,3 +120,35 @@ export const roomEntryReceipts = sqliteTable('room_entry_receipts', {
.references(() => rooms.code, { onDelete: 'cascade' }), .references(() => rooms.code, { onDelete: 'cascade' }),
playerId: text('player_id').notNull(), playerId: text('player_id').notNull(),
}); });
export const roomMessages = sqliteTable(
'room_messages',
{
sequence: integer('sequence').primaryKey({ autoIncrement: true }),
roomCode: text('room_code')
.notNull()
.references(() => rooms.code, { onDelete: 'cascade' }),
id: text('id').notNull(),
senderId: text('sender_id').notNull(),
senderSessionHash: text('sender_session_hash').notNull(),
senderName: text('sender_name').notNull(),
senderFaction: text('sender_faction').notNull(),
recipientId: text('recipient_id'),
recipientName: text('recipient_name'),
body: text('body').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
uniqueIndex('room_messages_room_id').on(table.roomCode, table.id),
index('room_messages_channel').on(
table.roomCode,
table.recipientId,
table.sequence,
),
index('room_messages_sender').on(
table.roomCode,
table.senderId,
table.createdAt,
),
],
);
+142
View File
@@ -0,0 +1,142 @@
import { env } from 'cloudflare:workers';
import type { SeatAuth } from './rooms';
import {
TALK_PAGE_SIZE,
talkId,
validTalkDraft,
type TalkDraft,
type TalkMessage,
type TalkPage,
} from '../lib/table-talk';
export class TableTalkError extends Error {
constructor(
message: string,
readonly status = 409,
) {
super(message);
}
}
function database() {
if (!env.DB) throw new Error('Game storage is unavailable.');
return env.DB;
}
// Every read and write checks the exact active session AND room membership.
// A separate message table keeps private activity out of game versions/logs.
const viewer = `FROM rooms r JOIN seats s ON s.room_code = r.code
JOIN json_each(r.state, '$.players') p ON json_extract(p.value, '$.id') = s.player_id
WHERE r.code = ? AND s.player_id = ? AND s.token_hash = ? AND s.revoked = 0
AND json_extract(p.value, '$.bot') IS NULL`;
const channel = `(m.recipient_id IS NULL AND ? IS NULL) OR
(? IS NOT NULL AND ((m.sender_id = s.player_id AND m.recipient_id = ?) OR
(m.sender_id = ? AND m.recipient_id = s.player_id)))`;
export async function readTableTalk(
code: string,
auth: SeatAuth,
recipientId: string | null,
before: string | null = null,
): Promise<TalkPage> {
if (
(recipientId !== null &&
(!talkId(recipientId) || recipientId === auth.playerId)) ||
(before !== null && !talkId(before))
)
throw new TableTalkError('Choose a valid conversation.', 400);
const row = await database()
.prepare(`SELECT (
SELECT json_group_array(json(message)) FROM (
SELECT json_object('id', m.id, 'senderId', m.sender_id, 'senderName', m.sender_name,
'senderFaction', m.sender_faction, 'recipientId', m.recipient_id,
'recipientName', m.recipient_name, 'text', m.body, 'createdAt', m.created_at) AS message
FROM room_messages m WHERE m.room_code = r.code AND (${channel})
AND (? IS NULL OR m.sequence < (SELECT m.sequence FROM room_messages m
WHERE m.room_code = r.code AND m.id = ? AND (${channel})))
ORDER BY m.sequence DESC LIMIT ${TALK_PAGE_SIZE + 1}
)
) AS messages ${viewer}`)
.bind(
recipientId,
recipientId,
recipientId,
recipientId,
before,
before,
recipientId,
recipientId,
recipientId,
recipientId,
code,
auth.playerId,
auth.tokenHash,
)
.first<{ messages: string }>();
if (!row) throw new TableTalkError('Your seat could not be verified.');
const newestFirst: TalkMessage[] = JSON.parse(row.messages);
const messages = newestFirst.slice(0, TALK_PAGE_SIZE).reverse();
return {
messages,
before: newestFirst.length > TALK_PAGE_SIZE ? messages[0].id : null,
};
}
export async function sendTableTalk(
code: string,
auth: SeatAuth,
input: unknown,
now = Date.now(),
) {
if (!validTalkDraft(input))
throw new TableTalkError('Enter a message of 1–1000 characters.', 400);
const draft: TalkDraft = input;
if (draft.recipientId === auth.playerId)
throw new TableTalkError('Choose another human seat.', 400);
const result = await database()
.prepare(`INSERT INTO room_messages
(room_code, id, sender_id, sender_session_hash, sender_name, sender_faction, recipient_id, recipient_name, body, created_at)
SELECT r.code, ?, s.player_id, s.token_hash, json_extract(p.value, '$.name'), json_extract(p.value, '$.faction'), ?,
(SELECT json_extract(target.value, '$.name') FROM json_each(r.state, '$.players') target WHERE json_extract(target.value, '$.id') = ?), ?, ?
${viewer}
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
AND recent.sender_id = s.player_id AND recent.created_at > ?)
ON CONFLICT(room_code, id) DO NOTHING`)
.bind(
draft.id,
draft.recipientId,
draft.recipientId,
draft.text,
now,
code,
auth.playerId,
auth.tokenHash,
draft.recipientId,
draft.recipientId,
now - 1000,
)
.run();
// The INSERT itself authenticated the writer. A later rotation must not turn
// a committed send into a definite rejection; this receipt contains only the caller's ID.
if (result.meta.changes === 1) return { id: draft.id, replayed: false };
// No foreign receipt/body is ever returned, even for a deliberately reused UUID.
const receipt = await database()
.prepare(`SELECT 1 AS accepted ${viewer}
AND EXISTS (SELECT 1 FROM room_messages m WHERE m.room_code = r.code AND m.id = ?
AND m.sender_id = s.player_id AND m.sender_session_hash = s.token_hash
AND m.recipient_id IS ? AND m.body = ?)`)
.bind(
code,
auth.playerId,
auth.tokenHash,
draft.id,
draft.recipientId,
draft.text,
)
.first<{ accepted: number }>();
if (!receipt)
throw new TableTalkError(
'Message not confirmed. Check your seat and recipient, wait a second, then try again.',
);
return { id: draft.id, replayed: result.meta.changes === 0 };
}
+14 -16
View File
@@ -6,26 +6,24 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work ## Current checkpoint and work
Latest delivered checkpoint: `66c41b2`, [Kwisatz Haderach inspection](KWISATZ_INSPECTION.md). Latest delivered checkpoint: `b2fcef8`, [Storm and Spice Blow practice](INTERACTIVE_INTRODUCTION.md).
Types, lint, **4,950 offline tests** and production build passed. All 1,373 rooms, Types, lint, **4,954 offline tests** and production build passed. All 1,373 rooms,
2,883 seats and existing auxiliary rows stayed unchanged. The running server was 2,883 seats and existing auxiliary rows stayed unchanged. The running server was
reused. Earlier checkpoints include `ed53eb7` for Storm Cards, `67e429e` for reused. Earlier checkpoints include `66c41b2` for Kwisatz Haderach inspection,
Charity/Revival practice, `c4ff75a` for alliances, `b7ec940` for bidding, `ed53eb7` for Storm Cards and `67e429e` for Charity/Revival practice.
`b9718ac` for lobby AI configuration and `b21b2bc` for one-use AI permission.
The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement
until feature completion, with three adjacent 75% targets. See the until feature completion, with three adjacent 75% targets. See the
[implementation history](IMPLEMENTATION_STATUS.md). [implementation history](IMPLEMENTATION_STATUS.md).
Current work: [Storm and Spice Blow practice](INTERACTIVE_INTRODUCTION.md) expands Current work: [table discussion](TABLE_DISCUSSION.md) adds public conversation and
the introduction to thirteen lessons and eleven independent Basic examples. private human-seat messages, separate from game state and decision continuation.
Sealed dials, reveal, sector traversal, shelter, spice accumulation/storm loss, Durable history, audience-filtered pagination, exact retries and active-session
first-turn worm return and later-worm Nexus timing reuse shared live calculations. fences support communication through refresh and seat recovery. AI conversation
Version 6 preserves old lesson identities and outcomes. Full faction, Fremen-ride, and prose-deal enforcement remain unfinished. Source-bound checks, independent
Advanced and expansion teaching remain unfinished. Source-bound checks, review, review, browser and preservation/Git evidence belong to the private checkpoint.
browser and preservation/Git evidence belong to the private checkpoint. A fresh bounded Terror/Kull audit confirmed existing pending rulings; independent
A bounded Duke review confirmed revival/loan paths still cross recorded custody, multiplayer communication proceeds without inventing their outcomes. The full
return and cycle questions; no duplicate ruling or invented outcome was added. rules, multiplayer and component acceptance gates remain open.
Broader multiplayer and component acceptance remain unfinished.
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
still cross recorded pending rulings. Continue independent features rather than still cross recorded pending rulings. Continue independent features rather than
@@ -95,7 +93,7 @@ These links define bounded working behavior, not complete module certification.
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. | | Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. | | Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. | | Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play; [Harass & Withdraw](HARASS_WITHDRAW_RUNTIME.md) connects the bounded battle return in the same independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
| Multiplayer | Saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), [named-player AI permission](SEAT_AI_PERMISSION.md) and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. | | Multiplayer | [Public and private discussion](TABLE_DISCUSSION.md), saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), [named-player AI permission](SEAT_AI_PERMISSION.md) and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
## Remaining readiness ## Remaining readiness
+14
View File
@@ -1,5 +1,19 @@
# Dune implementation status # Dune implementation status
## 21 September 2026 — durable table discussion prototype
Public table conversation and direct human-seat messages now have usable controls,
plain-text rendering, durable history and exact retries. Message SQL independently
fences current credentials and membership, filters private channels before paging,
and leaves game JSON/version, sealed choices and bot continuation unchanged.
Recovery/handover transfers access to the seat's history; old credentials stop
working. Independent review found and fixed committed-write/rotation and late
unmounted-response retry races, with regressions. See [table discussion](TABLE_DISCUSSION.md)
for the complete boundary. Final source-bound checks, browser observations,
preservation and delivery records belong to the private checkpoint. AI messaging,
prose-contract enforcement and full acceptance remain open; no mode gate changes.
## 21 September 2026 — Storm and Spice Blow practice ## 21 September 2026 — Storm and Spice Blow practice
The [interactive introduction](INTERACTIVE_INTRODUCTION.md) now has thirteen The [interactive introduction](INTERACTIVE_INTRODUCTION.md) now has thirteen
+71
View File
@@ -0,0 +1,71 @@
# Table discussion and private seat messages
The table now has an expandable **Table discussion** panel beneath the game.
Humans can speak publicly or choose a named human seat for a private conversation,
in the lobby, during any pending decision and after the game. A human using AI
control can still talk. Permanent AI opponents do not read or answer messages;
AI negotiation remains deferred until non-AI feature completion.
## Message delivery and audience
Each message is plain text, limited to 1,000 characters. HTML and URLs remain
literal text, with no executable markup or external links. The server supplies
the sender's identity. A public message is visible to all present and future
seats in that room; a direct message is visible only to its sender and recipient.
Private messages belong to those game seats: recovery or voluntary handover gives
the new controller access to their history, and revokes the old credential's
access. The panel explains that boundary before sending.
Discussion does not spend spice, change alliances, seal a plan, evaluate prose
promises or execute game actions. Use the existing authoritative game controls
to carry out an agreement. This is communication infrastructure, not a new ruling
about binding deals or a completed negotiation/AI strategy system.
The latest 50 messages in the selected conversation load on opening and refresh
serially every five seconds, with bounded requests. **Load older messages** pauses
live replacement while browsing saved history; **Return to latest messages**
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.
## Saved retries and state isolation
The browser saves an exact message UUID, recipient and body in tab storage before
sending. An uncertain response keeps the input locked for **Retry exact message**;
refresh restores that same request. Exact repeats save once, and conflicting
UUID reuse never returns another message's body. An already uncertain retry is
not discarded just because a later request fails. Explicitly discarding retry
details cannot undo an already committed send. Unsent drafts are local inputs;
only pending sends have refresh recovery. No credentials enter this storage.
A late response from an unmounted panel cannot erase a newer pending send.
Successful writes return their own receipt even if the seat subsequently rotates.
Replaying under a different seat session cannot reactivate the earlier operation;
the current owner can inspect the conversation and discard obsolete retry details.
Additive migration `0007_clammy_sprite.sql` creates `room_messages` and its indexes.
Every SQL read and insert checks the exact active seat token, room membership and
human status; inserts check recipient membership at commit. The internal sequence
is used only for ordering. Cursor lookups apply the same audience filter as the
message page, so an unknown or hidden UUID does not expose message metadata.
One new message per sender per second bounds accidental rapid submission; exact
retries do not consume another slot. There is no game-version conflict to refresh:
message operations never write game JSON, reset readiness, advance bots or enter
a gameplay continuation. Existing saved games and schema history are preserved.
## Evidence and remaining work
`table-talk-recovery.test.ts` exercises production SQL against the real migrations:
private/public projection, hidden cursors, pagination, exact concurrent retries,
changed payloads, active-token and membership fences, rotation during a write,
store recreation, human AI control and unchanged game rows. The HTTP tests use
real authenticated rooms and sealed setup choices, verify origin/error handling,
exact retries and actual saved-kit recovery. Client tests cover input/receipt
validation, identity-scoped storage and late-completion cleanup.
Independent review found two retry races during implementation; both have been
fixed and regression tested. Browser observations, the final check/build/HTTP
report and saved-row comparison are source-bound private checkpoint artifacts.
This prototype does not certify complete network-failure browser behavior,
notification/unread design, full accessibility/visual acceptance, complete
negotiation rules or any AI strength target. Existing release gates remain closed.
+18
View File
@@ -0,0 +1,18 @@
CREATE TABLE `room_messages` (
`sequence` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`room_code` text NOT NULL,
`id` text NOT NULL,
`sender_id` text NOT NULL,
`sender_session_hash` text NOT NULL,
`sender_name` text NOT NULL,
`sender_faction` text NOT NULL,
`recipient_id` text,
`recipient_name` text,
`body` text NOT NULL,
`created_at` integer NOT NULL,
FOREIGN KEY (`room_code`) REFERENCES `rooms`(`code`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `room_messages_room_id` ON `room_messages` (`room_code`,`id`);--> statement-breakpoint
CREATE INDEX `room_messages_channel` ON `room_messages` (`room_code`,`recipient_id`,`sequence`);--> statement-breakpoint
CREATE INDEX `room_messages_sender` ON `room_messages` (`room_code`,`sender_id`,`created_at`);
+736
View File
@@ -0,0 +1,736 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9d40c7aa-918d-4da7-adc8-d6d8dc49d1c5",
"prevId": "f1dbb26a-284d-40c6-b25c-41f5dfa64930",
"tables": {
"room_entry_receipts": {
"name": "room_entry_receipts",
"columns": {
"operation_hash": {
"name": "operation_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"request_hash": {
"name": "request_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_hash": {
"name": "session_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"room_entry_receipts_room_code_rooms_code_fk": {
"name": "room_entry_receipts_room_code_rooms_code_fk",
"tableFrom": "room_entry_receipts",
"tableTo": "rooms",
"columnsFrom": [
"room_code"
],
"columnsTo": [
"code"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"room_messages": {
"name": "room_messages",
"columns": {
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sender_id": {
"name": "sender_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sender_session_hash": {
"name": "sender_session_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sender_name": {
"name": "sender_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sender_faction": {
"name": "sender_faction",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"recipient_id": {
"name": "recipient_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"recipient_name": {
"name": "recipient_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"room_messages_room_id": {
"name": "room_messages_room_id",
"columns": [
"room_code",
"id"
],
"isUnique": true
},
"room_messages_channel": {
"name": "room_messages_channel",
"columns": [
"room_code",
"recipient_id",
"sequence"
],
"isUnique": false
},
"room_messages_sender": {
"name": "room_messages_sender",
"columns": [
"room_code",
"sender_id",
"created_at"
],
"isUnique": false
}
},
"foreignKeys": {
"room_messages_room_code_rooms_code_fk": {
"name": "room_messages_room_code_rooms_code_fk",
"tableFrom": "room_messages",
"tableTo": "rooms",
"columnsFrom": [
"room_code"
],
"columnsTo": [
"code"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"rooms": {
"name": "rooms",
"columns": {
"code": {
"name": "code",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"state": {
"name": "state",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"seat_ai_delegations": {
"name": "seat_ai_delegations",
"columns": {
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"grant_id": {
"name": "grant_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"delegate_id": {
"name": "delegate_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"difficulty": {
"name": "difficulty",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner_session_hash": {
"name": "owner_session_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"delegate_session_hash": {
"name": "delegate_session_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"used_at": {
"name": "used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked_at": {
"name": "revoked_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"seat_ai_delegations_grant_id_unique": {
"name": "seat_ai_delegations_grant_id_unique",
"columns": [
"grant_id"
],
"isUnique": true
}
},
"foreignKeys": {
"seat_ai_delegations_room_code_rooms_code_fk": {
"name": "seat_ai_delegations_room_code_rooms_code_fk",
"tableFrom": "seat_ai_delegations",
"tableTo": "rooms",
"columnsFrom": [
"room_code"
],
"columnsTo": [
"code"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"seat_ai_delegations_room_code_owner_id_grant_id_pk": {
"columns": [
"room_code",
"owner_id",
"grant_id"
],
"name": "seat_ai_delegations_room_code_owner_id_grant_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"seat_handover_claim_receipts": {
"name": "seat_handover_claim_receipts",
"columns": {
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation_hash": {
"name": "operation_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"offer_hash": {
"name": "offer_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"secret_hash": {
"name": "secret_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_hash": {
"name": "session_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_fence": {
"name": "claim_fence",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claimed_at": {
"name": "claimed_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"seat_handover_claim_receipts_claim_fence_unique": {
"name": "seat_handover_claim_receipts_claim_fence_unique",
"columns": [
"claim_fence"
],
"isUnique": true
}
},
"foreignKeys": {
"seat_handover_claim_receipts_room_code_rooms_code_fk": {
"name": "seat_handover_claim_receipts_room_code_rooms_code_fk",
"tableFrom": "seat_handover_claim_receipts",
"tableTo": "rooms",
"columnsFrom": [
"room_code"
],
"columnsTo": [
"code"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"seat_handover_claim_receipts_room_code_player_id_operation_hash_pk": {
"columns": [
"room_code",
"player_id",
"operation_hash"
],
"name": "seat_handover_claim_receipts_room_code_player_id_operation_hash_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"seat_handover_offers": {
"name": "seat_handover_offers",
"columns": {
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"offer_hash": {
"name": "offer_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"secret_hash": {
"name": "secret_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"issuer_session_hash": {
"name": "issuer_session_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"claim_operation_hash": {
"name": "claim_operation_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_hash": {
"name": "session_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"claimed_at": {
"name": "claimed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"seat_handover_offers_offer_hash_unique": {
"name": "seat_handover_offers_offer_hash_unique",
"columns": [
"offer_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"seat_handover_offers_room_code_rooms_code_fk": {
"name": "seat_handover_offers_room_code_rooms_code_fk",
"tableFrom": "seat_handover_offers",
"tableTo": "rooms",
"columnsFrom": [
"room_code"
],
"columnsTo": [
"code"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"seat_handover_offers_room_code_player_id_pk": {
"columns": [
"room_code",
"player_id"
],
"name": "seat_handover_offers_room_code_player_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"seat_recovery_keys": {
"name": "seat_recovery_keys",
"columns": {
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"recovery_hash": {
"name": "recovery_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"current_operation_hash": {
"name": "current_operation_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"seat_recovery_keys_room_code_rooms_code_fk": {
"name": "seat_recovery_keys_room_code_rooms_code_fk",
"tableFrom": "seat_recovery_keys",
"tableTo": "rooms",
"columnsFrom": [
"room_code"
],
"columnsTo": [
"code"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"seat_recovery_keys_room_code_player_id_pk": {
"columns": [
"room_code",
"player_id"
],
"name": "seat_recovery_keys_room_code_player_id_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"seat_recovery_receipts": {
"name": "seat_recovery_receipts",
"columns": {
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation_hash": {
"name": "operation_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"recovery_hash": {
"name": "recovery_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"session_hash": {
"name": "session_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"seat_recovery_receipts_room_code_rooms_code_fk": {
"name": "seat_recovery_receipts_room_code_rooms_code_fk",
"tableFrom": "seat_recovery_receipts",
"tableTo": "rooms",
"columnsFrom": [
"room_code"
],
"columnsTo": [
"code"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"seat_recovery_receipts_room_code_player_id_operation_hash_pk": {
"columns": [
"room_code",
"player_id",
"operation_hash"
],
"name": "seat_recovery_receipts_room_code_player_id_operation_hash_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
},
"seats": {
"name": "seats",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"room_code": {
"name": "room_code",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"player_id": {
"name": "player_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"seats_room_code_rooms_code_fk": {
"name": "seats_room_code_rooms_code_fk",
"tableFrom": "seats",
"tableTo": "rooms",
"columnsFrom": [
"room_code"
],
"columnsTo": [
"code"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -50,6 +50,13 @@
"when": 1789883506588, "when": 1789883506588,
"tag": "0006_thick_imperial_guard", "tag": "0006_thick_imperial_guard",
"breakpoints": true "breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1789947127493,
"tag": "0007_clammy_sprite",
"breakpoints": true
} }
] ]
} }
+24
View File
@@ -254,6 +254,30 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Verification', status: 'Partial', detail: 'Focused checks compare all 24 offered storm dials and four spice scenarios with real engine actions, preserve v5 lesson identities, and compare resource payments, allowances and leader custody with engine actions, validate paid-save continuation and prior lesson migration, and cover alliance consent/break and later victory across 24 engine comparisons, bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts', 'tests/introduction-alliance.test.ts', 'tests/nexus-alliance.test.ts', 'tests/introduction-resources.test.ts', 'tests/introduction-resources-migration.test.ts', 'tests/introduction-opening.test.ts'] }, { area: 'Verification', status: 'Partial', detail: 'Focused checks compare all 24 offered storm dials and four spice scenarios with real engine actions, preserve v5 lesson identities, and compare resource payments, allowances and leader custody with engine actions, validate paid-save continuation and prior lesson migration, and cover alliance consent/break and later victory across 24 engine comparisons, bidding/re-entry against the live engine, full hands, payment/card custody, pricing, all offered plans, weapon survival, ties, losses, collection limits, 96 movement comparisons against the engine, single/mutual/declined Traitor calls and saved-choice migration/validation. This does not certify all rules or the complete learning journey.', evidence: ['tests/introduction.test.ts', 'tests/introduction-practice.test.ts', 'tests/introduction-bidding.test.ts', 'tests/introduction-alliance.test.ts', 'tests/nexus-alliance.test.ts', 'tests/introduction-resources.test.ts', 'tests/introduction-resources-migration.test.ts', 'tests/introduction-opening.test.ts'] },
], ],
}, },
{
id: 'table-discussion',
title: 'Table discussion and private messages',
category: 'Getting started',
coverage: 'Partial',
developmentStage: 'Prototyped',
summary: 'Discuss plans publicly or privately with another human seat, with saved history and exact message retries.',
steps: [
'Open Table discussion beneath the board. Choose Everyone at the table or a named human seat before sending. The server supplies your sender identity; messages are plain text, up to 1,000 characters.',
'Public messages can be read by everyone who joins this room. Private messages can be read only by the two addressed seats. Recovery or handover gives a seat’s next controller its message history and revokes the old credential.',
'Messages are available while game decisions are pending and do not change their state. Use the game controls to carry out agreed actions; typing a message does not transfer spice, form an alliance or make a Truthtrance commitment.',
'If sending is uncertain, Retry exact message confirms the original request without sending a duplicate. Refresh restores pending sends. Discarding retry details cannot undo a message already sent.',
'Load older messages to browse saved history. Return to latest messages resumes live updates. The panel shows no private-activity counts to other seats.',
'Human owners using AI control can still talk. Permanent AI opponents do not read or answer messages yet; AI negotiation waits until the game features are complete.',
],
related: ['privacy', 'alliance-funding', 'card-truthtrance', 'implementation-checklist'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Separate durable message storage supports public/direct conversation, private pagination, session-fenced reads/writes and exact retries without changing game state.', evidence: ['db/table-talk.ts', 'lib/table-talk.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Named conversation selection, plain-text messages, saved retries and older history are connected. Full notification, accessibility and visual acceptance remain open.', evidence: ['components/table-talk.tsx'] },
{ area: 'AI', status: 'Planned', detail: 'Human controllers can communicate while AI operates their seat. Automated message understanding and negotiation are deferred until non-AI feature completion.' },
{ area: 'Documentation', status: 'Implemented', detail: 'Audience, seat inheritance, saved retries and the distinction between discussion and game actions are explained.', evidence: ['docs/TABLE_DISCUSSION.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Production SQL and HTTP cases cover audience, cursor privacy, exact retries, token rotation, saved-kit recovery and unchanged sealed setup. Full browser/network and multiplayer acceptance remain open.', evidence: ['tests/table-talk-recovery.test.ts', 'tests/table-talk-http.test.ts', 'tests/table-talk-client.test.ts'] },
],
},
{ {
id: 'table-sounds', id: 'table-sounds',
title: 'Sound effects', title: 'Sound effects',
+74
View File
@@ -0,0 +1,74 @@
export const TALK_LIMIT = 1000;
export const TALK_PAGE_SIZE = 50;
export type TalkDraft = {
id: string;
recipientId: string | null;
text: string;
};
export type TalkMessage = {
id: string;
senderId: string;
senderName: string;
senderFaction: string;
recipientId: string | null;
recipientName: string | null;
text: string;
createdAt: number;
};
export type TalkPage = { messages: TalkMessage[]; before: string | null };
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);
export function validTalkDraft(value: unknown): value is TalkDraft {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const draft = value as Record<string, unknown>;
return (
Object.keys(draft).length === 3 &&
talkId(draft.id) &&
(draft.recipientId === null || talkId(draft.recipientId)) &&
typeof draft.text === 'string' &&
draft.text.length <= TALK_LIMIT &&
draft.text.trim().length > 0 &&
Array.from(draft.text).every((character) => {
const code = character.codePointAt(0)!;
return (
code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127)
);
})
);
}
export function talkDraftKey(room: string, seat: string) {
return `dune-table-talk-v1:${room}:${seat}`;
}
export function restoreTalkDraft(raw: string | null): TalkDraft | null {
if (!raw) return null;
try {
const value: unknown = JSON.parse(raw);
return validTalkDraft(value) ? value : null;
} catch {
return null;
}
}
/** A bounded page merge: UUIDs identify only already-entitled messages. */
export function mergeTalkPages(older: TalkMessage[], newer: TalkMessage[]) {
const seen = new Set(newer.map((message) => message.id));
return [...older.filter((message) => !seen.has(message.id)), ...newer];
}
/** A late response may clear only its own tab-scoped retry proof. */
export function clearTalkDraft(
storage: Pick<Storage, 'getItem' | 'removeItem'>,
key: string,
draft: TalkDraft,
): boolean {
const saved = restoreTalkDraft(storage.getItem(key));
if (
!saved ||
saved.id !== draft.id ||
saved.recipientId !== draft.recipientId ||
saved.text !== draft.text
)
return false;
storage.removeItem(key);
return true;
}
+12 -1
View File
@@ -13,6 +13,8 @@ export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
const sqlite = new DatabaseSync(':memory:'); const sqlite = new DatabaseSync(':memory:');
const hooks: { const hooks: {
beforeWrite?: () => Promise<void>; beforeWrite?: () => Promise<void>;
beforeStatement?: (sql: string) => Promise<void>;
afterStatement?: (sql: string) => Promise<void>;
beforeBatch?: () => Promise<void>; beforeBatch?: () => Promise<void>;
} = {}; } = {};
const writes: { expected: number; changes: number }[] = []; const writes: { expected: number; changes: number }[] = [];
@@ -34,6 +36,7 @@ export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
return sqlite.prepare(this.sql).get(...this.values) ?? null; return sqlite.prepare(this.sql).get(...this.values) ?? null;
} }
async run() { async run() {
await hooks.beforeStatement?.(this.sql);
const continuation = this.sql.startsWith('UPDATE rooms SET state'); const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.(); if (continuation) await hooks.beforeWrite?.();
const changes = Number( const changes = Number(
@@ -41,6 +44,7 @@ export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
); );
if (continuation) if (continuation)
writes.push({ expected: Number(this.values[3]), changes }); writes.push({ expected: Number(this.values[3]), changes });
await hooks.afterStatement?.(this.sql);
return { meta: { changes } }; return { meta: { changes } };
} }
} }
@@ -90,5 +94,12 @@ export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
); );
return exports as typeof Rooms; return exports as typeof Rooms;
} }
return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes }; return {
rooms: loadRooms(),
restart: loadRooms,
sqlite,
hooks,
writes,
database,
};
} }
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { TableTalk } from '../components/table-talk';
import { createGame, newPlayer, viewGame } from '../game/engine';
import {
validTalkDraft,
clearTalkDraft,
restoreTalkDraft,
talkDraftKey,
mergeTalkPages,
} from '../lib/table-talk';
void test('message retry validation preserves exact text and recipient, rejects injected identities and invalid payloads', () => {
const draft = {
id: crypto.randomUUID(),
recipientId: crypto.randomUUID(),
text: ' line one\n<script>literal text</script> ',
};
assert.deepEqual(restoreTalkDraft(JSON.stringify(draft)), draft);
for (const value of [
null,
[],
{},
{ ...draft, senderId: 'forged' },
{ ...draft, id: 1 },
{ ...draft, text: '\u0000' },
{ ...draft, text: ' '.repeat(20) },
{ ...draft, text: 'a'.repeat(1001) },
{ ...draft, recipientId: 'table' },
])
assert.equal(validTalkDraft(value), false);
assert.equal(restoreTalkDraft('not JSON'), null);
assert.notEqual(
talkDraftKey('AAAAAAAA', 'one'),
talkDraftKey('AAAAAAAA', 'two'),
);
assert.notEqual(
talkDraftKey('AAAAAAAA', 'one'),
talkDraftKey('BBBBBBBB', 'one'),
);
});
void test('discussion entry has a named disclosure and no server-rendered private history', () => {
const game = createGame(
'ABCDEFGH',
newPlayer('one', 'Human', 'atreides'),
false,
[],
);
const html = renderToStaticMarkup(
createElement(TableTalk, { game: viewGame(game, 'one') }),
);
assert.match(html, /Table discussion/);
assert.match(html, /aria-expanded="false"/);
assert.doesNotMatch(html, /textarea|<script|href="https?:/);
const message = {
id: 'one',
senderId: 'a',
senderName: 'A',
senderFaction: 'atreides',
recipientId: null,
recipientName: null,
text: 'one',
createdAt: 1,
};
assert.deepEqual(
mergeTalkPages([message], [message, { ...message, id: 'two' }]).map(
(m) => m.id,
),
['one', 'two'],
);
});
void test('late completion cannot erase a newer message retry record', () => {
const older = {
id: crypto.randomUUID(),
recipientId: null,
text: 'Old send',
};
const newer = { ...older, id: crypto.randomUUID(), text: 'New send' };
let saved: string | null = JSON.stringify(newer);
const storage = {
getItem: () => saved,
removeItem: () => {
saved = null;
},
};
assert.equal(clearTalkDraft(storage, 'key', older), false);
assert.deepEqual(restoreTalkDraft(saved), newer);
assert.equal(clearTalkDraft(storage, 'key', newer), true);
assert.equal(saved, null);
});
+232
View File
@@ -0,0 +1,232 @@
// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
import type { GameView } from '../game/engine';
import type { TalkPage } from '../lib/table-talk';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
async function request(
path: string,
body?: unknown,
cookie?: string,
origin?: string,
) {
const response = await fetch(base + path, {
method: body === undefined ? 'GET' : 'POST',
signal: AbortSignal.timeout(15_000),
headers: {
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(cookie ? { cookie } : {}),
...(origin ? { origin } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const raw = await response.text();
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
parsed = { error: 'Non-JSON response' };
}
return {
status: response.status,
cache: response.headers.get('cache-control'),
cookie: response.headers.get('set-cookie')?.split(';')[0],
data: parsed as GameView &
TalkPage & {
view: GameView;
id: string;
replayed: boolean;
error?: string;
},
};
}
async function fixture() {
const a = await request('/api/rooms', {
name: 'Discussion QA sender',
faction: 'atreides',
advanced: false,
expansions: [],
});
assert.equal(a.status, 201);
assert.ok(a.cookie);
const path = `/api/rooms/${a.data.code}`;
const b = await request(path, {
type: 'join',
name: 'Discussion QA recipient',
faction: 'emperor',
});
const c = await request(path, {
type: 'join',
name: 'Discussion QA observer',
faction: 'guild',
});
assert.equal(b.status, 200);
assert.ok(b.cookie);
assert.equal(c.status, 200);
assert.ok(c.cookie);
let view = c.data;
for (const seat of [a, b, c]) {
const result = await request(
path,
{ version: view.version, action: { type: 'ready' } },
seat.cookie,
);
assert.equal(result.status, 200);
view = result.data;
}
const started = await request(
path,
{ version: view.version, action: { type: 'start' } },
a.cookie,
);
assert.equal(started.status, 200);
const me = started.data.players.find((p) => p.id === a.data.me)!;
const sealed = await request(
path,
{
version: started.data.version,
action: { type: 'traitor', leader: me.traitorChoices![0] },
},
a.cookie,
);
assert.equal(sealed.status, 200);
return { a, b, c, path, version: sealed.data.version };
}
void test('discussion HTTP preserves sealed setup and room version; public and direct channels enforce audience and origin', async () => {
const f = await fixture(),
endpoint = f.path + '/messages';
const before = (await request(f.path, undefined, f.a.cookie)).data;
const direct = {
id: crypto.randomUUID(),
recipientId: f.b.data.me,
text: 'Private QA offer only for the recipient.',
};
assert.equal((await request(endpoint, direct)).status, 409);
assert.equal(
(await request(endpoint, direct, f.a.cookie, 'https://foreign.invalid'))
.status,
403,
);
const sent = await request(endpoint, direct, f.a.cookie);
assert.equal(sent.status, 200);
assert.equal(sent.cache, 'no-store');
assert.equal(sent.data.id, direct.id);
const recipient = await request(
endpoint + `?recipient=${f.a.data.me}`,
undefined,
f.b.cookie,
);
assert.equal(recipient.status, 200);
assert.equal(recipient.cache, 'no-store');
assert.deepEqual(
recipient.data.messages.map((m) => m.text),
[direct.text],
);
const outsider = await request(
endpoint + `?recipient=${f.a.data.me}`,
undefined,
f.c.cookie,
);
assert.deepEqual(outsider.data, { messages: [], before: null });
assert.deepEqual(
(await request(endpoint + `?before=${direct.id}`, undefined, f.c.cookie))
.data,
{ messages: [], before: null },
);
const publicText = {
id: crypto.randomUUID(),
recipientId: null,
text: 'Public QA discussion <script>literal only</script>.',
};
assert.equal((await request(endpoint, publicText, f.b.cookie)).status, 200);
for (const seat of [f.a, f.b, f.c]) {
const read = await request(endpoint, undefined, seat.cookie);
assert.deepEqual(
read.data.messages.map((m) => m.text),
[publicText.text],
);
assert.equal(JSON.stringify(read.data).includes(direct.text), false);
assert.equal(JSON.stringify(read.data).includes('session_hash'), false);
assert.equal(JSON.stringify(read.data).includes('sequence'), false);
}
assert.deepEqual((await request(f.path, undefined, f.a.cookie)).data, before);
for (const body of [
{ ...direct, senderId: f.c.data.me },
{ ...direct, recipientId: f.a.data.me },
{ ...direct, text: 'x'.repeat(1001) },
])
assert.ok((await request(endpoint, body, f.a.cookie)).status >= 400);
assert.equal(
(await request(endpoint + '?recipient=invalid', undefined, f.a.cookie))
.status,
400,
);
assert.equal(
(await request(endpoint + '?unknown=1', undefined, f.a.cookie)).status,
400,
);
});
void test('discussion HTTP retries save once and seat recovery inherits conversation while fencing old cookies', async () => {
const f = await fixture(),
endpoint = f.path + '/messages';
const input = {
id: crypto.randomUUID(),
recipientId: f.b.data.me,
text: 'Saved before recovery; exact retries do not duplicate.',
};
const result = await Promise.all([
request(endpoint, input, f.a.cookie),
request(endpoint, input, f.a.cookie),
]);
assert.ok(result.every((r) => r.status === 200));
assert.deepEqual(
result.map((r) => r.data.replayed).sort((a, b) => Number(a) - Number(b)),
[false, true],
);
assert.equal(
(await request(endpoint, { ...input, text: 'Changed receipt' }, f.a.cookie))
.status,
409,
);
const recoverySecret = 'a7'.repeat(32);
const key = await request(
f.path + '/control',
{ type: 'setRecoveryKey', version: f.version, recoverySecret },
f.a.cookie,
);
assert.equal(key.status, 200);
const recovered = await request(f.path + '/control', {
type: 'recoverSeat',
playerId: f.a.data.me,
recoverySecret,
operationId: crypto.randomUUID(),
newSessionToken:
crypto.randomUUID().replaceAll('-', '') +
crypto.randomUUID().replaceAll('-', ''),
});
assert.equal(recovered.status, 200);
assert.ok(recovered.cookie);
assert.equal((await request(endpoint, undefined, f.a.cookie)).status, 409);
assert.equal((await request(endpoint, input, f.a.cookie)).status, 409);
const restored = await request(
endpoint + `?recipient=${f.b.data.me}`,
undefined,
recovered.cookie,
);
assert.equal(restored.status, 200);
assert.deepEqual(
restored.data.messages.map((m) => m.id),
[input.id],
);
assert.equal((await request(endpoint, input, recovered.cookie)).status, 409);
assert.deepEqual(
(
await request(
endpoint + `?recipient=${f.a.data.me}`,
undefined,
f.c.cookie,
)
).data,
{ messages: [], before: null },
);
});
+349
View File
@@ -0,0 +1,349 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DatabaseSync } from 'node:sqlite';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
import ts from 'typescript';
import * as contract from '../lib/table-talk';
import type * as Talk from '../db/table-talk';
import { unitStore } from './fixture-nexus-room-store';
async function fixture() {
const store = unitStore();
assert.ok(store.sqlite instanceof DatabaseSync);
const a = await store.rooms.createRoom(
'Atreides speaker',
'atreides',
false,
[],
);
const b = await store.rooms.joinRoom(
a.view.code,
'Emperor listener',
'emperor',
);
const c = await store.rooms.joinRoom(a.view.code, 'Guild observer', 'guild');
const auth = await Promise.all(
[a, b, c].map((seat) => store.rooms.authenticate(a.view.code, seat.token!)),
);
function load() {
const exports = {};
runInNewContext(
ts.transpileModule(
readFileSync(new URL('../db/table-talk.ts', import.meta.url), 'utf8'),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
},
).outputText,
{
exports,
JSON,
require: (name: string) => {
if (name === 'cloudflare:workers')
return { env: { DB: store.database } };
if (name === '../lib/table-talk') return contract;
throw new Error(`Unexpected module: ${name}`);
},
},
);
const loaded = exports as typeof Talk;
return {
sendTableTalk: async (...args: Parameters<typeof loaded.sendTableTalk>) =>
structuredClone(await loaded.sendTableTalk(...args)),
readTableTalk: async (...args: Parameters<typeof loaded.readTableTalk>) =>
structuredClone(await loaded.readTableTalk(...args)),
};
}
return { ...store, code: a.view.code, auth, talk: load(), load };
}
const draft = (text: string, recipientId: string | null = null) => ({
id: crypto.randomUUID(),
text,
recipientId,
});
const gameRows = (f: Awaited<ReturnType<typeof fixture>>) =>
f.sqlite.prepare('SELECT * FROM rooms').all();
void test('public and private discussion preserves game rows and hides direct activity from third seats', async () => {
const f = await fixture();
try {
const before = gameRows(f);
const publicText = draft('Can we discuss an alliance?');
const privateText = draft(
'PRIVATE OFFER: Arrakeen for Sietch Tabr.',
f.auth[1].playerId,
);
await f.talk.sendTableTalk(f.code, f.auth[0], publicText, 1000);
await f.talk.sendTableTalk(f.code, f.auth[0], privateText, 2000);
const publicPage = await f.talk.readTableTalk(f.code, f.auth[2], null);
assert.deepEqual(
publicPage.messages.map((m) => m.text),
[publicText.text],
);
assert.equal(publicPage.before, null);
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 },
);
for (const [viewer, peer] of [
[0, 1],
[1, 0],
]) {
const page = await f.talk.readTableTalk(
f.code,
f.auth[viewer],
f.auth[peer].playerId,
);
assert.equal(page.messages[0].text, privateText.text);
for (const key of [
'sequence',
'senderSessionHash',
'sender_session_hash',
'tokenHash',
])
assert.equal(JSON.stringify(page).includes(key), false);
}
assert.deepEqual(
await f.load().readTableTalk(f.code, f.auth[1], f.auth[0].playerId),
await f.talk.readTableTalk(f.code, f.auth[1], f.auth[0].playerId),
);
assert.deepEqual(gameRows(f), before);
} finally {
f.sqlite.close();
}
});
void test('exact message retries are idempotent under races, and altered or foreign receipts cannot disclose or mutate', async () => {
const f = await fixture();
try {
const input = draft('One sealed offer.', f.auth[1].playerId);
const results = await Promise.all([
f.talk.sendTableTalk(f.code, f.auth[0], input, 5000),
f.talk.sendTableTalk(f.code, f.auth[0], input, 5000),
]);
assert.deepEqual(
results.map((r) => r.replayed).sort((a, b) => Number(a) - Number(b)),
[false, true],
);
for (const [auth, changed] of [
[f.auth[0], { ...input, text: 'Altered' }],
[f.auth[0], { ...input, recipientId: null }],
[f.auth[2], input],
[f.auth[0], { ...input, senderId: f.auth[1].playerId }],
] as const)
await assert.rejects(f.talk.sendTableTalk(f.code, auth, changed, 6000));
assert.equal(
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
1,
);
assert.equal(
(await f.talk.sendTableTalk(f.code, f.auth[0], input, 5000)).replayed,
true,
);
await assert.rejects(
f.talk.sendTableTalk(f.code, f.auth[0], draft('Too fast'), 5500),
);
await f.talk.sendTableTalk(
f.code,
f.auth[0],
draft('After interval'),
6000,
);
} finally {
f.sqlite.close();
}
});
void test('SQL fences stale sender credentials and recipients at commit; new seat owner inherits history without reviving old sends', async () => {
const f = await fixture();
try {
const old = draft('Before recovery', f.auth[1].playerId);
await f.talk.sendTableTalk(f.code, f.auth[0], old, 1000);
f.hooks.beforeStatement = async (sql) => {
if (!sql.startsWith('INSERT INTO room_messages')) return;
delete f.hooks.beforeStatement;
f.sqlite
.prepare('UPDATE seats SET revoked = 1 WHERE token_hash = ?')
.run(f.auth[0].tokenHash);
};
await assert.rejects(
f.talk.sendTableTalk(f.code, f.auth[0], draft('Stale controller'), 2000),
);
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[0], null));
const fresh = { ...f.auth[0], tokenHash: 'new-test-session' };
f.sqlite
.prepare(
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
)
.run(fresh.tokenHash, f.code, fresh.playerId);
assert.equal(
(await f.talk.readTableTalk(f.code, fresh, f.auth[1].playerId))
.messages[0].text,
old.text,
);
await assert.rejects(f.talk.sendTableTalk(f.code, fresh, old, 3000));
f.hooks.beforeStatement = async (sql) => {
if (!sql.startsWith('INSERT INTO room_messages')) return;
delete f.hooks.beforeStatement;
const game = await f.rooms.readRoom(f.code);
game.players = game.players.filter((p) => p.id !== f.auth[1].playerId);
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(game), f.code);
};
await assert.rejects(
f.talk.sendTableTalk(
f.code,
fresh,
draft('Recipient removed', f.auth[1].playerId),
4000,
),
);
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[1], null));
assert.equal(
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
1,
);
} finally {
f.sqlite.close();
}
});
void test('pagination filters before limiting and never accepts a hidden message as a cursor', async () => {
const f = await fixture();
try {
const ids: string[] = [];
let privateId = '';
for (let index = 0; index < 54; index++) {
const message = draft(`Public ${index}`);
ids.push(message.id);
await f.talk.sendTableTalk(f.code, f.auth[0], message, index * 2000);
const secret = draft(`Private ${index}`, f.auth[1].playerId);
privateId = secret.id;
await f.talk.sendTableTalk(
f.code,
f.auth[0],
secret,
index * 2000 + 1000,
);
}
const first = await f.talk.readTableTalk(f.code, f.auth[2], null);
assert.equal(first.messages.length, 50);
assert.deepEqual(
first.messages.map((m) => m.id),
ids.slice(4),
);
assert.equal(first.before, ids[4]);
const older = await f.talk.readTableTalk(
f.code,
f.auth[2],
null,
first.before,
);
assert.deepEqual(
older.messages.map((m) => m.id),
ids.slice(0, 4),
);
assert.equal(older.before, null);
assert.deepEqual(
await f.talk.readTableTalk(f.code, f.auth[2], null, privateId),
{ messages: [], before: null },
);
assert.deepEqual(
await f.talk.readTableTalk(f.code, f.auth[2], null, crypto.randomUUID()),
{ messages: [], before: null },
);
} finally {
f.sqlite.close();
}
});
void test('discussion remains independent of sealed decisions, AI ownership and game completion', async () => {
const f = await fixture();
try {
for (const auth of f.auth)
await f.rooms.act(
f.code,
auth,
(await f.rooms.readRoom(f.code)).version,
{ type: 'ready' },
);
await f.rooms.act(
f.code,
f.auth[0],
(await f.rooms.readRoom(f.code)).version,
{ type: 'start' },
);
const game = await f.rooms.readRoom(f.code);
game.players[0].autopilot = 'Hard';
game.players[2].bot = 'Easy';
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(game), f.code);
const before = gameRows(f);
await f.talk.sendTableTalk(
f.code,
f.auth[0],
draft('Human owner can still talk.'),
1000,
);
await assert.rejects(
f.talk.sendTableTalk(
f.code,
f.auth[0],
draft('Bot recipient', f.auth[2].playerId),
2000,
),
);
await assert.rejects(
f.talk.sendTableTalk(f.code, f.auth[2], draft('Bot sender'), 2000),
);
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[2], null));
assert.deepEqual(gameRows(f), before);
game.status = 'finished';
f.sqlite
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
.run(JSON.stringify(game), f.code);
await f.talk.sendTableTalk(
f.code,
f.auth[0],
draft('Postgame discussion.'),
3000,
);
} finally {
f.sqlite.close();
}
});
void test('a credential rotation after a committed insert still returns that exact successful receipt', async () => {
const f = await fixture();
try {
const input = draft('Committed before rotation.');
f.hooks.afterStatement = async (sql) => {
if (!sql.startsWith('INSERT INTO room_messages')) return;
delete f.hooks.afterStatement;
f.sqlite
.prepare('UPDATE seats SET revoked = 1 WHERE token_hash = ?')
.run(f.auth[0].tokenHash);
};
assert.deepEqual(
await f.talk.sendTableTalk(f.code, f.auth[0], input, 1000),
{ id: input.id, replayed: false },
);
assert.equal(
(await f.talk.readTableTalk(f.code, f.auth[1], null)).messages[0].id,
input.id,
);
await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], input, 2000));
assert.equal(
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
1,
);
} finally {
f.sqlite.close();
}
});