From 7814f740ef72ff04ea1491465f0eda099c30c33f Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:52:31 +1000 Subject: [PATCH] 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 --- README.md | 6 + app/api/rooms/[code]/messages/route.ts | 73 +++ app/globals.css | 15 + app/page.tsx | 2 + components/table-talk.tsx | 388 +++++++++++++ db/schema.ts | 34 ++ db/table-talk.ts | 142 +++++ docs/CURRENT_STATUS.md | 30 +- docs/IMPLEMENTATION_STATUS.md | 14 + docs/TABLE_DISCUSSION.md | 71 +++ drizzle/0007_clammy_sprite.sql | 18 + drizzle/meta/0007_snapshot.json | 736 +++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + game/reference.ts | 24 + lib/table-talk.ts | 74 +++ tests/fixture-nexus-room-store.ts | 13 +- tests/table-talk-client.test.ts | 92 ++++ tests/table-talk-http.test.ts | 232 ++++++++ tests/table-talk-recovery.test.ts | 349 ++++++++++++ 19 files changed, 2303 insertions(+), 17 deletions(-) create mode 100644 app/api/rooms/[code]/messages/route.ts create mode 100644 components/table-talk.tsx create mode 100644 db/table-talk.ts create mode 100644 docs/TABLE_DISCUSSION.md create mode 100644 drizzle/0007_clammy_sprite.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 lib/table-talk.ts create mode 100644 tests/table-talk-client.test.ts create mode 100644 tests/table-talk-http.test.ts create mode 100644 tests/table-talk-recovery.test.ts diff --git a/README.md b/README.md index 7e63cdf4..fba6b02b 100644 --- a/README.md +++ b/README.md @@ -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 **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 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) diff --git a/app/api/rooms/[code]/messages/route.ts b/app/api/rooms/[code]/messages/route.ts new file mode 100644 index 00000000..93c1a9b3 --- /dev/null +++ b/app/api/rooms/[code]/messages/route.ts @@ -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); diff --git a/app/globals.css b/app/globals.css index cb87c76c..474ec036 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1927,3 +1927,18 @@ footer { .spice-card-front > button { 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; } diff --git a/app/page.tsx b/app/page.tsx index 07170638..ed502013 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ 'use client'; import Link from 'next/link'; import { useState, useEffect, useCallback, useRef } from 'react'; +import { TableTalk } from '@/components/table-talk'; import { GameTable } from '@/components/game-table'; import { SeatHandoverSetup, @@ -675,6 +676,7 @@ export default function Home() { exitDisabled={handoverPending || aiPermissionPending} />
+ {(entryAttempt || entryProblem) && (

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 ( +

+

+ +

+ {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. +

+ + +

+ {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}{' '} + + + )} + {history && ( +

+ Browsing saved history.{' '} + +

+ )} + {shownPage.before && ( + + )} +
    + {shownPage.messages.map((message) => ( +
  1. +
    + + {message.senderName} + + +
    +

    {message.text}

    +
  2. + ))} +
+ {shownPage.messages.length === 0 && ( +

No messages loaded in this conversation.

+ )} +
{ + event.preventDefault(); + void submit(); + }} + > + +