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
93 lines
2.7 KiB
TypeScript
93 lines
2.7 KiB
TypeScript
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);
|
|
});
|