diff --git a/components/table-talk.tsx b/components/table-talk.tsx
new file mode 100644
index 00000000..84b6f9b7
--- /dev/null
+++ b/components/table-talk.tsx
@@ -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(null);
+ const [pageChannel, setPageChannel] = useState(null);
+ const [page, setPage] = useState({ 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;
+ async function read() {
+ if (readingHistory.current) {
+ timer = setTimeout(read, 5000);
+ return;
+ }
+ try {
+ const result = await requestJson(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(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 (
+
+
+ setOpen((value) => !value)}
+ >
+ Table discussion {open ? '−' : '+'}
+
+
+ {open && (
+
+
+ Talk through plans and offers. Use game controls to transfer spice,
+ form alliances or make a Truthtrance commitment. Messages do not
+ perform game actions.
+
+
Conversation
+
{
+ setRecipient(event.target.value);
+ setText('');
+ setNotice('');
+ }}
+ >
+ Everyone at the table
+ {peers.map((player) => (
+
+ Private · {player.name} · {faction(player.faction).name}
+
+ ))}
+ {recipient && !target && (
+ Previous seat
+ )}
+
+
+ {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.
+
+ {readError && (
+
+ {readError}{' '}
+ setRefresh((value) => value + 1)}
+ >
+ Refresh messages
+
+
+ )}
+ {history && (
+
+ Browsing saved history.{' '}
+ setRefresh((value) => value + 1)}
+ >
+ Return to latest messages
+
+
+ )}
+ {shownPage.before && (
+
void older()}
+ >
+ {loading ? 'Loading…' : 'Load older messages'}
+
+ )}
+
+ {shownPage.messages.map((message) => (
+
+
+
+ {message.senderName}
+
+
+ {new Date(message.createdAt).toLocaleString()}
+
+
+ {message.text}
+
+ ))}
+
+ {shownPage.messages.length === 0 && (
+
No messages loaded in this conversation.
+ )}
+
+ {notice &&
{notice} }
+ {record && !busy && (
+
+ Discarding retry details cannot undo a message already sent.
+
+ )}
+ {record && !busy && (
+
{
+ 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
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/db/schema.ts b/db/schema.ts
index 5c749954..84d5436d 100644
--- a/db/schema.ts
+++ b/db/schema.ts
@@ -3,6 +3,8 @@ import {
text,
integer,
primaryKey,
+ index,
+ uniqueIndex,
} from 'drizzle-orm/sqlite-core';
export const rooms = sqliteTable('rooms', {
code: text('code').primaryKey(),
@@ -118,3 +120,35 @@ export const roomEntryReceipts = sqliteTable('room_entry_receipts', {
.references(() => rooms.code, { onDelete: 'cascade' }),
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,
+ ),
+ ],
+);
diff --git a/db/table-talk.ts b/db/table-talk.ts
new file mode 100644
index 00000000..be0bfa83
--- /dev/null
+++ b/db/table-talk.ts
@@ -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 {
+ 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 };
+}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index 36859bb9..b8311405 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,26 +6,24 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
-Latest delivered checkpoint: `66c41b2`, [Kwisatz Haderach inspection](KWISATZ_INSPECTION.md).
-Types, lint, **4,950 offline tests** and production build passed. All 1,373 rooms,
+Latest delivered checkpoint: `b2fcef8`, [Storm and Spice Blow practice](INTERACTIVE_INTRODUCTION.md).
+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
-reused. Earlier checkpoints include `ed53eb7` for Storm Cards, `67e429e` for
-Charity/Revival practice, `c4ff75a` for alliances, `b7ec940` for bidding,
-`b9718ac` for lobby AI configuration and `b21b2bc` for one-use AI permission.
+reused. Earlier checkpoints include `66c41b2` for Kwisatz Haderach inspection,
+`ed53eb7` for Storm Cards and `67e429e` for Charity/Revival practice.
The `8f7a62c` policy checkpoint removed the usage cutoff and deferred AI refinement
until feature completion, with three adjacent 75% targets. See the
[implementation history](IMPLEMENTATION_STATUS.md).
-Current work: [Storm and Spice Blow practice](INTERACTIVE_INTRODUCTION.md) expands
-the introduction to thirteen lessons and eleven independent Basic examples.
-Sealed dials, reveal, sector traversal, shelter, spice accumulation/storm loss,
-first-turn worm return and later-worm Nexus timing reuse shared live calculations.
-Version 6 preserves old lesson identities and outcomes. Full faction, Fremen-ride,
-Advanced and expansion teaching remain unfinished. Source-bound checks, review,
-browser and preservation/Git evidence belong to the private checkpoint.
-A bounded Duke review confirmed revival/loan paths still cross recorded custody,
-return and cycle questions; no duplicate ruling or invented outcome was added.
-Broader multiplayer and component acceptance remain unfinished.
+Current work: [table discussion](TABLE_DISCUSSION.md) adds public conversation and
+private human-seat messages, separate from game state and decision continuation.
+Durable history, audience-filtered pagination, exact retries and active-session
+fences support communication through refresh and seat recovery. AI conversation
+and prose-deal enforcement remain unfinished. Source-bound checks, independent
+review, browser and preservation/Git evidence belong to the private checkpoint.
+A fresh bounded Terror/Kull audit confirmed existing pending rulings; independent
+multiplayer communication proceeds without inventing their outcomes. The full
+rules, multiplayer and component acceptance gates remain open.
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
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. |
| 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. |
-| 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
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index 51229135..56705bf6 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,19 @@
# 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
The [interactive introduction](INTERACTIVE_INTRODUCTION.md) now has thirteen
diff --git a/docs/TABLE_DISCUSSION.md b/docs/TABLE_DISCUSSION.md
new file mode 100644
index 00000000..554f8bc1
--- /dev/null
+++ b/docs/TABLE_DISCUSSION.md
@@ -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.
diff --git a/drizzle/0007_clammy_sprite.sql b/drizzle/0007_clammy_sprite.sql
new file mode 100644
index 00000000..1e4f795b
--- /dev/null
+++ b/drizzle/0007_clammy_sprite.sql
@@ -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`);
\ No newline at end of file
diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json
new file mode 100644
index 00000000..6b9177e6
--- /dev/null
+++ b/drizzle/meta/0007_snapshot.json
@@ -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": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index e4934390..89153993 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -50,6 +50,13 @@
"when": 1789883506588,
"tag": "0006_thick_imperial_guard",
"breakpoints": true
+ },
+ {
+ "idx": 7,
+ "version": "6",
+ "when": 1789947127493,
+ "tag": "0007_clammy_sprite",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/game/reference.ts b/game/reference.ts
index 6f9240c3..c40901dc 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -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'] },
],
},
+ {
+ 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',
title: 'Sound effects',
diff --git a/lib/table-talk.ts b/lib/table-talk.ts
new file mode 100644
index 00000000..11ebf88a
--- /dev/null
+++ b/lib/table-talk.ts
@@ -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;
+ 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,
+ 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;
+}
diff --git a/tests/fixture-nexus-room-store.ts b/tests/fixture-nexus-room-store.ts
index 664437e8..990bd2dd 100644
--- a/tests/fixture-nexus-room-store.ts
+++ b/tests/fixture-nexus-room-store.ts
@@ -13,6 +13,8 @@ export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
const sqlite = new DatabaseSync(':memory:');
const hooks: {
beforeWrite?: () => Promise;
+ beforeStatement?: (sql: string) => Promise;
+ afterStatement?: (sql: string) => Promise;
beforeBatch?: () => Promise;
} = {};
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;
}
async run() {
+ await hooks.beforeStatement?.(this.sql);
const continuation = this.sql.startsWith('UPDATE rooms SET state');
if (continuation) await hooks.beforeWrite?.();
const changes = Number(
@@ -41,6 +44,7 @@ export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
);
if (continuation)
writes.push({ expected: Number(this.values[3]), changes });
+ await hooks.afterStatement?.(this.sql);
return { meta: { changes } };
}
}
@@ -90,5 +94,12 @@ export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
);
return exports as typeof Rooms;
}
- return { rooms: loadRooms(), restart: loadRooms, sqlite, hooks, writes };
+ return {
+ rooms: loadRooms(),
+ restart: loadRooms,
+ sqlite,
+ hooks,
+ writes,
+ database,
+ };
}
diff --git a/tests/table-talk-client.test.ts b/tests/table-talk-client.test.ts
new file mode 100644
index 00000000..af8dbaa8
--- /dev/null
+++ b/tests/table-talk-client.test.ts
@@ -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 ',
+ };
+ 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|.',
+ };
+ 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 },
+ );
+});
diff --git a/tests/table-talk-recovery.test.ts b/tests/table-talk-recovery.test.ts
new file mode 100644
index 00000000..7430fd9a
--- /dev/null
+++ b/tests/table-talk-recovery.test.ts
@@ -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) =>
+ structuredClone(await loaded.sendTableTalk(...args)),
+ readTableTalk: async (...args: Parameters) =>
+ 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>) =>
+ 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();
+ }
+});