Add durable public and private table discussion
Humans can discuss plans publicly or message another human seat privately while pending game decisions stay intact. The conversation panel provides plain text, explicit recipient names and factions, saved history, private pagination and exact retries through refresh. Human AI-control owners can still talk; bots do not interpret messages or negotiate. Existing game actions execute agreements. An additive indexed D1 message table stores discussion separately from game JSON, versioning, readiness and bot continuation. Every read/insert checks active seat credentials and membership; direct audiences and cursor lookups are filtered before limiting. Public history has no private activity counts or sequential IDs. Recovery/handover inherits seat history while fencing old credentials. Independent review found two retry races: a rotation between a committed insert and receipt read, and stale unmounted completion deleting a newer retry record. Both are fixed with regressions. A prior uncertain retry stays pending on later rejection. The nine focused offline cases and two HTTP cases cover production SQL, concurrent/altered receipts, audience/cursor privacy, rotation/membership races, actual saved-kit recovery and unchanged sealed setup state. Types, lint, all 4963 offline tests, production build and 48 HTTP tests pass on stable source. An initial check caught an invalid checklist status label; that was corrected before the successful final2 verification. Desktop browser checks send public/private messages, receive a peer reply, retain private history after refresh and remove it on channel switch. Literal markup and URLs remain text. Recipient and bystander API reads confirm the browser messages' audiences. Screenshot capture was unavailable and the viewport override did not take effect; mobile, full keyboard/visual and injected browser network-failure acceptance remain open. No final rules, mode, AI or publication gate changes. All 1373 pre-existing rooms, 2883 seats and auxiliary rows are unchanged. SQLite integrity/foreign keys pass; migration 0007 adds messages without replacing data. The healthy server was reused without restart. Private evidence: 20260921-table-talk. Verified source tree: 236dd55b6c15d55dbced735be67427e5fbd7fa348d583f119e67a03894ec21b0
This commit is contained in:
1 parent
b2fcef8d51
commit
7814f740ef
19 files changed
+2303
-17
No files matched your search
@@ -13,6 +13,8 @@ export function unitStore(runBots = bots.runBots, migrationThrough?: string) {
|
||||
const sqlite = new DatabaseSync(':memory:');
|
||||
const hooks: {
|
||||
beforeWrite?: () => Promise<void>;
|
||||
beforeStatement?: (sql: string) => Promise<void>;
|
||||
afterStatement?: (sql: string) => Promise<void>;
|
||||
beforeBatch?: () => Promise<void>;
|
||||
} = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TableTalk } from '../components/table-talk';
|
||||
import { createGame, newPlayer, viewGame } from '../game/engine';
|
||||
import {
|
||||
validTalkDraft,
|
||||
clearTalkDraft,
|
||||
restoreTalkDraft,
|
||||
talkDraftKey,
|
||||
mergeTalkPages,
|
||||
} from '../lib/table-talk';
|
||||
void test('message retry validation preserves exact text and recipient, rejects injected identities and invalid payloads', () => {
|
||||
const draft = {
|
||||
id: crypto.randomUUID(),
|
||||
recipientId: crypto.randomUUID(),
|
||||
text: ' line one\n<script>literal text</script> ',
|
||||
};
|
||||
assert.deepEqual(restoreTalkDraft(JSON.stringify(draft)), draft);
|
||||
for (const value of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
{ ...draft, senderId: 'forged' },
|
||||
{ ...draft, id: 1 },
|
||||
{ ...draft, text: '\u0000' },
|
||||
{ ...draft, text: ' '.repeat(20) },
|
||||
{ ...draft, text: 'a'.repeat(1001) },
|
||||
{ ...draft, recipientId: 'table' },
|
||||
])
|
||||
assert.equal(validTalkDraft(value), false);
|
||||
assert.equal(restoreTalkDraft('not JSON'), null);
|
||||
assert.notEqual(
|
||||
talkDraftKey('AAAAAAAA', 'one'),
|
||||
talkDraftKey('AAAAAAAA', 'two'),
|
||||
);
|
||||
assert.notEqual(
|
||||
talkDraftKey('AAAAAAAA', 'one'),
|
||||
talkDraftKey('BBBBBBBB', 'one'),
|
||||
);
|
||||
});
|
||||
void test('discussion entry has a named disclosure and no server-rendered private history', () => {
|
||||
const game = createGame(
|
||||
'ABCDEFGH',
|
||||
newPlayer('one', 'Human', 'atreides'),
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const html = renderToStaticMarkup(
|
||||
createElement(TableTalk, { game: viewGame(game, 'one') }),
|
||||
);
|
||||
assert.match(html, /Table discussion/);
|
||||
assert.match(html, /aria-expanded="false"/);
|
||||
assert.doesNotMatch(html, /textarea|<script|href="https?:/);
|
||||
const message = {
|
||||
id: 'one',
|
||||
senderId: 'a',
|
||||
senderName: 'A',
|
||||
senderFaction: 'atreides',
|
||||
recipientId: null,
|
||||
recipientName: null,
|
||||
text: 'one',
|
||||
createdAt: 1,
|
||||
};
|
||||
assert.deepEqual(
|
||||
mergeTalkPages([message], [message, { ...message, id: 'two' }]).map(
|
||||
(m) => m.id,
|
||||
),
|
||||
['one', 'two'],
|
||||
);
|
||||
});
|
||||
|
||||
void test('late completion cannot erase a newer message retry record', () => {
|
||||
const older = {
|
||||
id: crypto.randomUUID(),
|
||||
recipientId: null,
|
||||
text: 'Old send',
|
||||
};
|
||||
const newer = { ...older, id: crypto.randomUUID(), text: 'New send' };
|
||||
let saved: string | null = JSON.stringify(newer);
|
||||
const storage = {
|
||||
getItem: () => saved,
|
||||
removeItem: () => {
|
||||
saved = null;
|
||||
},
|
||||
};
|
||||
assert.equal(clearTalkDraft(storage, 'key', older), false);
|
||||
assert.deepEqual(restoreTalkDraft(saved), newer);
|
||||
assert.equal(clearTalkDraft(storage, 'key', newer), true);
|
||||
assert.equal(saved, null);
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
// @dune-suite integration
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { GameView } from '../game/engine';
|
||||
import type { TalkPage } from '../lib/table-talk';
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
async function request(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
cookie?: string,
|
||||
origin?: string,
|
||||
) {
|
||||
const response = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
headers: {
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
const raw = await response.text();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = { error: 'Non-JSON response' };
|
||||
}
|
||||
return {
|
||||
status: response.status,
|
||||
cache: response.headers.get('cache-control'),
|
||||
cookie: response.headers.get('set-cookie')?.split(';')[0],
|
||||
data: parsed as GameView &
|
||||
TalkPage & {
|
||||
view: GameView;
|
||||
id: string;
|
||||
replayed: boolean;
|
||||
error?: string;
|
||||
},
|
||||
};
|
||||
}
|
||||
async function fixture() {
|
||||
const a = await request('/api/rooms', {
|
||||
name: 'Discussion QA sender',
|
||||
faction: 'atreides',
|
||||
advanced: false,
|
||||
expansions: [],
|
||||
});
|
||||
assert.equal(a.status, 201);
|
||||
assert.ok(a.cookie);
|
||||
const path = `/api/rooms/${a.data.code}`;
|
||||
const b = await request(path, {
|
||||
type: 'join',
|
||||
name: 'Discussion QA recipient',
|
||||
faction: 'emperor',
|
||||
});
|
||||
const c = await request(path, {
|
||||
type: 'join',
|
||||
name: 'Discussion QA observer',
|
||||
faction: 'guild',
|
||||
});
|
||||
assert.equal(b.status, 200);
|
||||
assert.ok(b.cookie);
|
||||
assert.equal(c.status, 200);
|
||||
assert.ok(c.cookie);
|
||||
let view = c.data;
|
||||
for (const seat of [a, b, c]) {
|
||||
const result = await request(
|
||||
path,
|
||||
{ version: view.version, action: { type: 'ready' } },
|
||||
seat.cookie,
|
||||
);
|
||||
assert.equal(result.status, 200);
|
||||
view = result.data;
|
||||
}
|
||||
const started = await request(
|
||||
path,
|
||||
{ version: view.version, action: { type: 'start' } },
|
||||
a.cookie,
|
||||
);
|
||||
assert.equal(started.status, 200);
|
||||
const me = started.data.players.find((p) => p.id === a.data.me)!;
|
||||
const sealed = await request(
|
||||
path,
|
||||
{
|
||||
version: started.data.version,
|
||||
action: { type: 'traitor', leader: me.traitorChoices![0] },
|
||||
},
|
||||
a.cookie,
|
||||
);
|
||||
assert.equal(sealed.status, 200);
|
||||
return { a, b, c, path, version: sealed.data.version };
|
||||
}
|
||||
void test('discussion HTTP preserves sealed setup and room version; public and direct channels enforce audience and origin', async () => {
|
||||
const f = await fixture(),
|
||||
endpoint = f.path + '/messages';
|
||||
const before = (await request(f.path, undefined, f.a.cookie)).data;
|
||||
const direct = {
|
||||
id: crypto.randomUUID(),
|
||||
recipientId: f.b.data.me,
|
||||
text: 'Private QA offer only for the recipient.',
|
||||
};
|
||||
assert.equal((await request(endpoint, direct)).status, 409);
|
||||
assert.equal(
|
||||
(await request(endpoint, direct, f.a.cookie, 'https://foreign.invalid'))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const sent = await request(endpoint, direct, f.a.cookie);
|
||||
assert.equal(sent.status, 200);
|
||||
assert.equal(sent.cache, 'no-store');
|
||||
assert.equal(sent.data.id, direct.id);
|
||||
const recipient = await request(
|
||||
endpoint + `?recipient=${f.a.data.me}`,
|
||||
undefined,
|
||||
f.b.cookie,
|
||||
);
|
||||
assert.equal(recipient.status, 200);
|
||||
assert.equal(recipient.cache, 'no-store');
|
||||
assert.deepEqual(
|
||||
recipient.data.messages.map((m) => m.text),
|
||||
[direct.text],
|
||||
);
|
||||
const outsider = await request(
|
||||
endpoint + `?recipient=${f.a.data.me}`,
|
||||
undefined,
|
||||
f.c.cookie,
|
||||
);
|
||||
assert.deepEqual(outsider.data, { messages: [], before: null });
|
||||
assert.deepEqual(
|
||||
(await request(endpoint + `?before=${direct.id}`, undefined, f.c.cookie))
|
||||
.data,
|
||||
{ messages: [], before: null },
|
||||
);
|
||||
const publicText = {
|
||||
id: crypto.randomUUID(),
|
||||
recipientId: null,
|
||||
text: 'Public QA discussion <script>literal only</script>.',
|
||||
};
|
||||
assert.equal((await request(endpoint, publicText, f.b.cookie)).status, 200);
|
||||
for (const seat of [f.a, f.b, f.c]) {
|
||||
const read = await request(endpoint, undefined, seat.cookie);
|
||||
assert.deepEqual(
|
||||
read.data.messages.map((m) => m.text),
|
||||
[publicText.text],
|
||||
);
|
||||
assert.equal(JSON.stringify(read.data).includes(direct.text), false);
|
||||
assert.equal(JSON.stringify(read.data).includes('session_hash'), false);
|
||||
assert.equal(JSON.stringify(read.data).includes('sequence'), false);
|
||||
}
|
||||
assert.deepEqual((await request(f.path, undefined, f.a.cookie)).data, before);
|
||||
for (const body of [
|
||||
{ ...direct, senderId: f.c.data.me },
|
||||
{ ...direct, recipientId: f.a.data.me },
|
||||
{ ...direct, text: 'x'.repeat(1001) },
|
||||
])
|
||||
assert.ok((await request(endpoint, body, f.a.cookie)).status >= 400);
|
||||
assert.equal(
|
||||
(await request(endpoint + '?recipient=invalid', undefined, f.a.cookie))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(await request(endpoint + '?unknown=1', undefined, f.a.cookie)).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
void test('discussion HTTP retries save once and seat recovery inherits conversation while fencing old cookies', async () => {
|
||||
const f = await fixture(),
|
||||
endpoint = f.path + '/messages';
|
||||
const input = {
|
||||
id: crypto.randomUUID(),
|
||||
recipientId: f.b.data.me,
|
||||
text: 'Saved before recovery; exact retries do not duplicate.',
|
||||
};
|
||||
const result = await Promise.all([
|
||||
request(endpoint, input, f.a.cookie),
|
||||
request(endpoint, input, f.a.cookie),
|
||||
]);
|
||||
assert.ok(result.every((r) => r.status === 200));
|
||||
assert.deepEqual(
|
||||
result.map((r) => r.data.replayed).sort((a, b) => Number(a) - Number(b)),
|
||||
[false, true],
|
||||
);
|
||||
assert.equal(
|
||||
(await request(endpoint, { ...input, text: 'Changed receipt' }, f.a.cookie))
|
||||
.status,
|
||||
409,
|
||||
);
|
||||
const recoverySecret = 'a7'.repeat(32);
|
||||
const key = await request(
|
||||
f.path + '/control',
|
||||
{ type: 'setRecoveryKey', version: f.version, recoverySecret },
|
||||
f.a.cookie,
|
||||
);
|
||||
assert.equal(key.status, 200);
|
||||
const recovered = await request(f.path + '/control', {
|
||||
type: 'recoverSeat',
|
||||
playerId: f.a.data.me,
|
||||
recoverySecret,
|
||||
operationId: crypto.randomUUID(),
|
||||
newSessionToken:
|
||||
crypto.randomUUID().replaceAll('-', '') +
|
||||
crypto.randomUUID().replaceAll('-', ''),
|
||||
});
|
||||
assert.equal(recovered.status, 200);
|
||||
assert.ok(recovered.cookie);
|
||||
assert.equal((await request(endpoint, undefined, f.a.cookie)).status, 409);
|
||||
assert.equal((await request(endpoint, input, f.a.cookie)).status, 409);
|
||||
const restored = await request(
|
||||
endpoint + `?recipient=${f.b.data.me}`,
|
||||
undefined,
|
||||
recovered.cookie,
|
||||
);
|
||||
assert.equal(restored.status, 200);
|
||||
assert.deepEqual(
|
||||
restored.data.messages.map((m) => m.id),
|
||||
[input.id],
|
||||
);
|
||||
assert.equal((await request(endpoint, input, recovered.cookie)).status, 409);
|
||||
assert.deepEqual(
|
||||
(
|
||||
await request(
|
||||
endpoint + `?recipient=${f.a.data.me}`,
|
||||
undefined,
|
||||
f.c.cookie,
|
||||
)
|
||||
).data,
|
||||
{ messages: [], before: null },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
import * as contract from '../lib/table-talk';
|
||||
import type * as Talk from '../db/table-talk';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
async function fixture() {
|
||||
const store = unitStore();
|
||||
assert.ok(store.sqlite instanceof DatabaseSync);
|
||||
const a = await store.rooms.createRoom(
|
||||
'Atreides speaker',
|
||||
'atreides',
|
||||
false,
|
||||
[],
|
||||
);
|
||||
const b = await store.rooms.joinRoom(
|
||||
a.view.code,
|
||||
'Emperor listener',
|
||||
'emperor',
|
||||
);
|
||||
const c = await store.rooms.joinRoom(a.view.code, 'Guild observer', 'guild');
|
||||
const auth = await Promise.all(
|
||||
[a, b, c].map((seat) => store.rooms.authenticate(a.view.code, seat.token!)),
|
||||
);
|
||||
function load() {
|
||||
const exports = {};
|
||||
runInNewContext(
|
||||
ts.transpileModule(
|
||||
readFileSync(new URL('../db/table-talk.ts', import.meta.url), 'utf8'),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
},
|
||||
).outputText,
|
||||
{
|
||||
exports,
|
||||
JSON,
|
||||
require: (name: string) => {
|
||||
if (name === 'cloudflare:workers')
|
||||
return { env: { DB: store.database } };
|
||||
if (name === '../lib/table-talk') return contract;
|
||||
throw new Error(`Unexpected module: ${name}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
const loaded = exports as typeof Talk;
|
||||
return {
|
||||
sendTableTalk: async (...args: Parameters<typeof loaded.sendTableTalk>) =>
|
||||
structuredClone(await loaded.sendTableTalk(...args)),
|
||||
readTableTalk: async (...args: Parameters<typeof loaded.readTableTalk>) =>
|
||||
structuredClone(await loaded.readTableTalk(...args)),
|
||||
};
|
||||
}
|
||||
return { ...store, code: a.view.code, auth, talk: load(), load };
|
||||
}
|
||||
const draft = (text: string, recipientId: string | null = null) => ({
|
||||
id: crypto.randomUUID(),
|
||||
text,
|
||||
recipientId,
|
||||
});
|
||||
const gameRows = (f: Awaited<ReturnType<typeof fixture>>) =>
|
||||
f.sqlite.prepare('SELECT * FROM rooms').all();
|
||||
|
||||
void test('public and private discussion preserves game rows and hides direct activity from third seats', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const before = gameRows(f);
|
||||
const publicText = draft('Can we discuss an alliance?');
|
||||
const privateText = draft(
|
||||
'PRIVATE OFFER: Arrakeen for Sietch Tabr.',
|
||||
f.auth[1].playerId,
|
||||
);
|
||||
await f.talk.sendTableTalk(f.code, f.auth[0], publicText, 1000);
|
||||
await f.talk.sendTableTalk(f.code, f.auth[0], privateText, 2000);
|
||||
const publicPage = await f.talk.readTableTalk(f.code, f.auth[2], null);
|
||||
assert.deepEqual(
|
||||
publicPage.messages.map((m) => m.text),
|
||||
[publicText.text],
|
||||
);
|
||||
assert.equal(publicPage.before, null);
|
||||
assert.equal(JSON.stringify(publicPage).includes('PRIVATE'), false);
|
||||
assert.deepEqual(
|
||||
await f.talk.readTableTalk(f.code, f.auth[2], f.auth[0].playerId),
|
||||
{ messages: [], before: null },
|
||||
);
|
||||
for (const [viewer, peer] of [
|
||||
[0, 1],
|
||||
[1, 0],
|
||||
]) {
|
||||
const page = await f.talk.readTableTalk(
|
||||
f.code,
|
||||
f.auth[viewer],
|
||||
f.auth[peer].playerId,
|
||||
);
|
||||
assert.equal(page.messages[0].text, privateText.text);
|
||||
for (const key of [
|
||||
'sequence',
|
||||
'senderSessionHash',
|
||||
'sender_session_hash',
|
||||
'tokenHash',
|
||||
])
|
||||
assert.equal(JSON.stringify(page).includes(key), false);
|
||||
}
|
||||
assert.deepEqual(
|
||||
await f.load().readTableTalk(f.code, f.auth[1], f.auth[0].playerId),
|
||||
await f.talk.readTableTalk(f.code, f.auth[1], f.auth[0].playerId),
|
||||
);
|
||||
assert.deepEqual(gameRows(f), before);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('exact message retries are idempotent under races, and altered or foreign receipts cannot disclose or mutate', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = draft('One sealed offer.', f.auth[1].playerId);
|
||||
const results = await Promise.all([
|
||||
f.talk.sendTableTalk(f.code, f.auth[0], input, 5000),
|
||||
f.talk.sendTableTalk(f.code, f.auth[0], input, 5000),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
results.map((r) => r.replayed).sort((a, b) => Number(a) - Number(b)),
|
||||
[false, true],
|
||||
);
|
||||
for (const [auth, changed] of [
|
||||
[f.auth[0], { ...input, text: 'Altered' }],
|
||||
[f.auth[0], { ...input, recipientId: null }],
|
||||
[f.auth[2], input],
|
||||
[f.auth[0], { ...input, senderId: f.auth[1].playerId }],
|
||||
] as const)
|
||||
await assert.rejects(f.talk.sendTableTalk(f.code, auth, changed, 6000));
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.talk.sendTableTalk(f.code, f.auth[0], input, 5000)).replayed,
|
||||
true,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.talk.sendTableTalk(f.code, f.auth[0], draft('Too fast'), 5500),
|
||||
);
|
||||
await f.talk.sendTableTalk(
|
||||
f.code,
|
||||
f.auth[0],
|
||||
draft('After interval'),
|
||||
6000,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('SQL fences stale sender credentials and recipients at commit; new seat owner inherits history without reviving old sends', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const old = draft('Before recovery', f.auth[1].playerId);
|
||||
await f.talk.sendTableTalk(f.code, f.auth[0], old, 1000);
|
||||
f.hooks.beforeStatement = async (sql) => {
|
||||
if (!sql.startsWith('INSERT INTO room_messages')) return;
|
||||
delete f.hooks.beforeStatement;
|
||||
f.sqlite
|
||||
.prepare('UPDATE seats SET revoked = 1 WHERE token_hash = ?')
|
||||
.run(f.auth[0].tokenHash);
|
||||
};
|
||||
await assert.rejects(
|
||||
f.talk.sendTableTalk(f.code, f.auth[0], draft('Stale controller'), 2000),
|
||||
);
|
||||
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[0], null));
|
||||
const fresh = { ...f.auth[0], tokenHash: 'new-test-session' };
|
||||
f.sqlite
|
||||
.prepare(
|
||||
'INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)',
|
||||
)
|
||||
.run(fresh.tokenHash, f.code, fresh.playerId);
|
||||
assert.equal(
|
||||
(await f.talk.readTableTalk(f.code, fresh, f.auth[1].playerId))
|
||||
.messages[0].text,
|
||||
old.text,
|
||||
);
|
||||
await assert.rejects(f.talk.sendTableTalk(f.code, fresh, old, 3000));
|
||||
f.hooks.beforeStatement = async (sql) => {
|
||||
if (!sql.startsWith('INSERT INTO room_messages')) return;
|
||||
delete f.hooks.beforeStatement;
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
game.players = game.players.filter((p) => p.id !== f.auth[1].playerId);
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), f.code);
|
||||
};
|
||||
await assert.rejects(
|
||||
f.talk.sendTableTalk(
|
||||
f.code,
|
||||
fresh,
|
||||
draft('Recipient removed', f.auth[1].playerId),
|
||||
4000,
|
||||
),
|
||||
);
|
||||
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[1], null));
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('pagination filters before limiting and never accepts a hidden message as a cursor', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const ids: string[] = [];
|
||||
let privateId = '';
|
||||
for (let index = 0; index < 54; index++) {
|
||||
const message = draft(`Public ${index}`);
|
||||
ids.push(message.id);
|
||||
await f.talk.sendTableTalk(f.code, f.auth[0], message, index * 2000);
|
||||
const secret = draft(`Private ${index}`, f.auth[1].playerId);
|
||||
privateId = secret.id;
|
||||
await f.talk.sendTableTalk(
|
||||
f.code,
|
||||
f.auth[0],
|
||||
secret,
|
||||
index * 2000 + 1000,
|
||||
);
|
||||
}
|
||||
const first = await f.talk.readTableTalk(f.code, f.auth[2], null);
|
||||
assert.equal(first.messages.length, 50);
|
||||
assert.deepEqual(
|
||||
first.messages.map((m) => m.id),
|
||||
ids.slice(4),
|
||||
);
|
||||
assert.equal(first.before, ids[4]);
|
||||
const older = await f.talk.readTableTalk(
|
||||
f.code,
|
||||
f.auth[2],
|
||||
null,
|
||||
first.before,
|
||||
);
|
||||
assert.deepEqual(
|
||||
older.messages.map((m) => m.id),
|
||||
ids.slice(0, 4),
|
||||
);
|
||||
assert.equal(older.before, null);
|
||||
assert.deepEqual(
|
||||
await f.talk.readTableTalk(f.code, f.auth[2], null, privateId),
|
||||
{ messages: [], before: null },
|
||||
);
|
||||
assert.deepEqual(
|
||||
await f.talk.readTableTalk(f.code, f.auth[2], null, crypto.randomUUID()),
|
||||
{ messages: [], before: null },
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('discussion remains independent of sealed decisions, AI ownership and game completion', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
for (const auth of f.auth)
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
auth,
|
||||
(await f.rooms.readRoom(f.code)).version,
|
||||
{ type: 'ready' },
|
||||
);
|
||||
await f.rooms.act(
|
||||
f.code,
|
||||
f.auth[0],
|
||||
(await f.rooms.readRoom(f.code)).version,
|
||||
{ type: 'start' },
|
||||
);
|
||||
const game = await f.rooms.readRoom(f.code);
|
||||
game.players[0].autopilot = 'Hard';
|
||||
game.players[2].bot = 'Easy';
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), f.code);
|
||||
const before = gameRows(f);
|
||||
await f.talk.sendTableTalk(
|
||||
f.code,
|
||||
f.auth[0],
|
||||
draft('Human owner can still talk.'),
|
||||
1000,
|
||||
);
|
||||
await assert.rejects(
|
||||
f.talk.sendTableTalk(
|
||||
f.code,
|
||||
f.auth[0],
|
||||
draft('Bot recipient', f.auth[2].playerId),
|
||||
2000,
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
f.talk.sendTableTalk(f.code, f.auth[2], draft('Bot sender'), 2000),
|
||||
);
|
||||
await assert.rejects(f.talk.readTableTalk(f.code, f.auth[2], null));
|
||||
assert.deepEqual(gameRows(f), before);
|
||||
game.status = 'finished';
|
||||
f.sqlite
|
||||
.prepare('UPDATE rooms SET state = ? WHERE code = ?')
|
||||
.run(JSON.stringify(game), f.code);
|
||||
await f.talk.sendTableTalk(
|
||||
f.code,
|
||||
f.auth[0],
|
||||
draft('Postgame discussion.'),
|
||||
3000,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a credential rotation after a committed insert still returns that exact successful receipt', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const input = draft('Committed before rotation.');
|
||||
f.hooks.afterStatement = async (sql) => {
|
||||
if (!sql.startsWith('INSERT INTO room_messages')) return;
|
||||
delete f.hooks.afterStatement;
|
||||
f.sqlite
|
||||
.prepare('UPDATE seats SET revoked = 1 WHERE token_hash = ?')
|
||||
.run(f.auth[0].tokenHash);
|
||||
};
|
||||
assert.deepEqual(
|
||||
await f.talk.sendTableTalk(f.code, f.auth[0], input, 1000),
|
||||
{ id: input.id, replayed: false },
|
||||
);
|
||||
assert.equal(
|
||||
(await f.talk.readTableTalk(f.code, f.auth[1], null)).messages[0].id,
|
||||
input.id,
|
||||
);
|
||||
await assert.rejects(f.talk.sendTableTalk(f.code, f.auth[0], input, 2000));
|
||||
assert.equal(
|
||||
f.sqlite.prepare('SELECT count(*) AS n FROM room_messages').get()!.n,
|
||||
1,
|
||||
);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user