// End-to-end smoke test for the round-phase wire protocol (run with: tsx scripts/smoke-round.ts // while the api is running). It exercises the live WebSocket path that the engine-driven invariant // in wire/wire.test.ts proves structurally: // - collect → move → deposit: an observer's projected `resources` increments (deposit enrichment) // - movement broadcast: the moving worker (and observers) see position updates stream in // - sabotage: hidden from a worker observer (station reads `working`) but visible to the saboteur // - round end: every client's snapshot reveals all roles (exactly one saboteur) // - replay: a client reconnecting with cursor=0 folds to the same state as a fresh snapshot // // Stations are seeded server-side by rooms.defaultStations(); all players spawn inside the first // resource station's area, so #chooseAction{collect} is reachable immediately. import { WebSocket } from 'ws' import { decodeFrame, messageFrame, applyWireEvent } from '../wire/index.js' import type { ProjectedState } from '../lexgen/types/town/deception/defs.js' const BASE = process.env.BASE ?? 'http://localhost:3000' const WS = BASE.replace('http', 'ws') const NAMES = ['alice', 'bob', 'carol', 'dave'] const dids = NAMES.map((n) => `did:example:${n}`) const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) let failures = 0 const ok = (m: string) => console.log(` ✓ ${m}`) const fail = (m: string) => { failures++ console.error(` ✗ ${m}`) } interface Client { did: string ws: WebSocket state: () => ProjectedState | null send: (t: string, body?: unknown) => void close: () => void } function connect(code: string, did: string, cursor?: number): Promise { let state: ProjectedState | null = null const handle = did.split(':').pop()! const q = `code=${code}&did=${encodeURIComponent(did)}&handle=${handle}${cursor != null ? `&cursor=${cursor}` : ''}` const ws = new WebSocket(`${WS}/ws?${q}`) ws.on('message', (data: Buffer) => { const { header, body } = decodeFrame(new Uint8Array(data)) if (header.op !== 1) return const msg = body as { seq: number; serverTime: number; state?: ProjectedState; action?: any } if (header.t === '#snapshot' && msg.state) state = msg.state else if (header.t === '#event' && msg.action && state) { const next = applyWireEvent(state, msg.action) if (next) { next.seq = msg.seq state = next } } }) const client: Client = { did, ws, state: () => state, send: (t, b = {}) => ws.send(messageFrame(t, b)), close: () => ws.close(), } return new Promise((resolve, reject) => { ws.on('open', () => resolve(client)) ws.on('error', reject) }) } async function waitFor(pred: () => boolean, ms: number, label: string): Promise { const deadline = Date.now() + ms while (Date.now() < deadline) { if (pred()) return true await sleep(50) } fail(`timed out waiting for ${label}`) return false } function self(c: Client) { return c.state()?.players.find((p) => p.did === c.did) } function station(c: Client, id: string) { return c.state()?.stations.find((s) => s.id === id) } function inArea(p: { x: number; y: number }, a: { x: number; y: number; w: number; h: number }) { return p.x >= a.x && p.x <= a.x + a.w && p.y >= a.y && p.y <= a.y + a.h } async function createRoom(roundDurationSec: number): Promise { const res = await fetch(`${BASE}/api/rooms`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ players: dids, roundDurationSec, resourceCount: 4 }), }) const data = await res.json() if (!data.code) throw new Error('room create failed: ' + JSON.stringify(data)) return data.code } async function startAll(clients: Client[]): Promise { clients[0]!.send('#start') // alice is host (roster[0]) await waitFor(() => clients.every((c) => c.state()?.phase === 'round'), 3000, 'round to start') } // --- Scenario A: collect → move → deposit, sabotage redaction, replay --------- async function scenarioMechanics(): Promise { console.log('scenario: round mechanics') const code = await createRoom(120) // long round; we don't wait for it to end here const clients: Client[] = [] for (const did of dids) { clients.push(await connect(code, did)) await sleep(60) } await sleep(150) await startAll(clients) const saboteur = clients.find((c) => self(c)?.role === 'saboteur') const workers = clients.filter((c) => self(c)?.role === 'worker') if (!saboteur || workers.length !== 3) { fail(`expected 1 saboteur + 3 workers, got sab=${!!saboteur} workers=${workers.length}`) clients.forEach((c) => c.close()) return } ok(`roles assigned (saboteur ${saboteur.did})`) const W = workers[0]! const O = workers[1]! // observer worker, stays at spawn // 1. collect real wood at res-wood W.send('#chooseAction', { stationId: 'res-wood', actionType: 'collect' }) if (await waitFor(() => self(W)?.carrying?.resourceId === 'wood', 4000, 'W to collect wood')) { ok('W collected wood') const seen = O.state()?.players.find((p) => p.did === W.did)?.carrying if (seen?.resourceId === 'wood' && seen.authentic === undefined) ok('observer sees carry type but not authenticity') else fail(`observer carry view wrong: ${JSON.stringify(seen)}`) } // 2. move to the deposit station (tests movement broadcast: W folds its own playerMoved) const depositArea = { x: 88, y: 328, w: 64, h: 64 } W.send('#move', { vec: { x: 120, y: 360 } }) const arrived = await waitFor(() => { const p = self(W)?.pos return !!p && inArea(p, depositArea) }, 8000, 'W to reach the deposit station') if (arrived) { ok('W moved to deposit (movement broadcast observed)') // observer also saw W move away from spawn const oPos = O.state()?.players.find((p) => p.did === W.did)?.pos if (oPos && inArea(oPos, depositArea)) ok('observer saw W move to deposit') else fail(`observer did not see W move: ${JSON.stringify(oPos)}`) } // 3. deposit → resources.wood increments for everyone W.send('#deposit') if (await waitFor(() => (O.state()?.resources.find((r) => r.resourceId === 'wood')?.amount ?? 0) >= 1, 4000, 'deposit to bank wood')) { ok('observer sees resources.wood incremented after deposit') if (self(W)?.carrying == null) ok('depositor hands cleared') else fail('depositor still carrying after deposit') } // 4. saboteur sabotages res-wood → hidden from the worker observer, visible to the saboteur saboteur.send('#chooseAction', { stationId: 'res-wood', actionType: 'sabotage' }) await waitFor(() => station(saboteur, 'res-wood')?.state === 'sabotaged', 10000, 'sabotage to resolve') if (station(saboteur, 'res-wood')?.state === 'sabotaged') ok('saboteur sees res-wood sabotaged') else fail('saboteur did not see own sabotage') if (station(O, 'res-wood')?.state === 'working') ok('worker observer sees res-wood as working (sabotage hidden)') else fail(`sabotage leaked to observer: ${station(O, 'res-wood')?.state}`) // 5a. snapshot ≡ fold (live): a fresh observer's authoritative snapshot must equal the state the // live observer reached by incrementally folding the enriched #event stream. await sleep(400) // let any trailing movement frames settle so both views are at rest const fresh = await connect(code, O.did) await waitFor(() => fresh.state() != null, 3000, 'fresh snapshot') await sleep(200) if (stable(normalize(fresh.state())) === stable(normalize(O.state()))) ok('fresh snapshot ≡ live folded state (enrichment stays consistent with projection)') else { fail('fresh snapshot diverged from the incrementally-folded state') dumpDiff(normalize(fresh.state()), normalize(O.state())) } fresh.close() // 5b. replay path: resuming at cursor=0 re-reduces the log and re-enriches identically — assert // the stream is well-formed and the deposit's actionResolved still carries its `effect`. const raw = await collectReplay(code, O.did) const events = raw.filter((f) => f.t === '#event').map((f) => f.body.action) if (events[0]?.$type === 'town.deception.defs#gameCreated') ok('replay stream starts at gameCreated') else fail(`replay stream did not start at gameCreated (${events[0]?.$type})`) if (events.some((a) => a.$type === 'town.deception.defs#actionResolved' && a.effect)) ok('replayed actionResolved carries enrichment (effect)') else fail('replayed actionResolved missing effect — replay re-enrichment broken') clients.forEach((c) => c.close()) } /** Connect with cursor=0 and collect the re-reduced replay frames for a viewer (no folding). */ function collectReplay(code: string, did: string): Promise<{ t: string; body: any }[]> { const frames: { t: string; body: any }[] = [] const ws = new WebSocket(`${WS}/ws?code=${code}&did=${encodeURIComponent(did)}&cursor=0`) return new Promise((resolve, reject) => { ws.on('message', (data: Buffer) => { const { header, body } = decodeFrame(new Uint8Array(data)) if (header.op === 1) frames.push({ t: header.t, body }) }) ws.on('open', () => setTimeout(() => { ws.close() resolve(frames) }, 600)) ws.on('error', reject) }) } // --- Scenario B: round-end reveal -------------------------------------------- async function scenarioReveal(): Promise { console.log('scenario: round-end reveal') const code = await createRoom(3) // short round; ends on its own const clients: Client[] = [] for (const did of dids) { clients.push(await connect(code, did)) await sleep(60) } await sleep(150) await startAll(clients) await waitFor(() => clients.every((c) => c.state()?.phase === 'ended'), 8000, 'round to end') let revealedEverywhere = true let saboteurs = new Set() for (const c of clients) { const players = c.state()?.players ?? [] if (!players.every((p) => p.role != null)) revealedEverywhere = false players.filter((p) => p.role === 'saboteur').forEach((p) => saboteurs.add(p.did)) } if (revealedEverywhere) ok('every client sees all roles revealed at round end') else fail('some roles still hidden after round end') if (saboteurs.size === 1) ok(`exactly one saboteur revealed (${[...saboteurs][0]})`) else fail(`expected one revealed saboteur, got ${saboteurs.size}`) clients.forEach((c) => c.close()) } function normalize(s: ProjectedState | null) { if (!s) return null return { ...s, seq: 0, now: 0, startedAt: 0, endsAt: 0 } } /** Order-insensitive stringify: object keys are emitted sorted so we compare structure, not order. */ function stable(v: any): string { return JSON.stringify(v, (_k, val) => { if (val && typeof val === 'object' && !Array.isArray(val)) { return Object.fromEntries(Object.keys(val).sort().map((k) => [k, val[k]])) } return val }) } function dumpDiff(a: any, b: any) { for (const k of Object.keys(a)) { const av = JSON.stringify(a[k]) const bv = JSON.stringify(b[k]) if (av !== bv) { console.error(` [diff] ${k}:`) console.error(` fresh : ${av}`) console.error(` folded: ${bv}`) } } } async function main() { await scenarioMechanics() await scenarioReveal() console.log(failures === 0 ? '\nSMOKE-ROUND PASS' : `\nSMOKE-ROUND FAIL (${failures})`) process.exit(failures === 0 ? 0 : 1) } main().catch((e) => { console.error(e) process.exit(1) })