Files
Dune/db/table-talk.ts
T
Quintine 7814f740ef 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
2026-09-21 09:52:31 +10:00

143 lines
5.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}