// End-to-end smoke test for the Phase 1 lobby flow (run with: tsx scripts/smoke-lobby.ts while the // api is running). Creates a room over HTTP, connects four players over WebSocket, has the host // start the round, and asserts the projected #event stream is correct AND per-viewer redacted: // every client sees four joins, and each sees ONLY their own role at roundStarted (exactly one // saboteur total across the four views). import { WebSocket } from 'ws' import { decodeFrame, messageFrame } from '../wire/index.js' const BASE = process.env.BASE ?? 'http://localhost:3000' const WS = BASE.replace('http', 'ws') const dids = ['did:example:alice', 'did:example:bob', 'did:example:carol', 'did:example:dave'] interface Frame { t: string body: any } function connect(code: string, did: string): { ws: WebSocket; frames: Frame[]; ready: Promise } { const frames: Frame[] = [] const ws = new WebSocket(`${WS}/ws?code=${code}&did=${encodeURIComponent(did)}&handle=${did.split(':').pop()}`) ws.on('message', (data: Buffer) => { const { header, body } = decodeFrame(new Uint8Array(data)) if (header.op === 1) frames.push({ t: header.t, body }) else frames.push({ t: '#error', body }) }) const ready = new Promise((resolve, reject) => { ws.on('open', () => resolve()) ws.on('error', reject) }) return { ws, frames, ready } } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) async function main() { // 1. create room const res = await fetch(`${BASE}/api/rooms`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ players: dids, roundDurationSec: 600, resourceCount: 4 }), }) const created = await res.json() if (!created.code) throw new Error('room create failed: ' + JSON.stringify(created)) console.log(`created room ${created.code}, host ${created.hostDid}`) // 2. connect all four players, one at a time so joins propagate in order const conns = [] for (const did of dids) { const c = connect(created.code, did) await c.ready conns.push({ did, ...c }) await sleep(80) } await sleep(150) // 3. host starts the round conns[0]!.ws.send(messageFrame('#start', {})) await sleep(250) // 4. assertions let ok = true const fail = (m: string) => { ok = false console.error(' ✗ ' + m) } const sabotageurs: string[] = [] for (const c of conns) { const snaps = c.frames.filter((f) => f.t === '#snapshot') const joins = c.frames.filter( (f) => f.t === '#event' && f.body.action.$type === 'town.deception.defs#playerJoined', ) const starts = c.frames.filter( (f) => f.t === '#event' && f.body.action.$type === 'town.deception.defs#roundStarted', ) if (snaps.length < 1) fail(`${c.did}: no snapshot`) // each client should observe all four joins broadcast after it subscribed... except joins that // happened before it connected are already in its snapshot. So total visible players == 4. const lastSnap = snaps[snaps.length - 1]! const seenViaSnap = lastSnap.body.state.players.length const seenViaJoins = new Set(joins.map((j) => j.body.action.did)) const totalSeen = new Set([...lastSnap.body.state.players.map((p: any) => p.did), ...seenViaJoins]) if (totalSeen.size !== 4) fail(`${c.did}: expected to know 4 players, knows ${totalSeen.size}`) if (starts.length !== 1) { fail(`${c.did}: expected exactly 1 roundStarted, got ${starts.length}`) continue } const roles = starts[0]!.body.action.roles ?? [] if (roles.length !== 1) fail(`${c.did}: roundStarted should reveal exactly 1 role (self), got ${roles.length}`) else if (roles[0].did !== c.did) fail(`${c.did}: revealed role is for ${roles[0].did}, not self`) else { console.log(` ✓ ${c.did} sees own role: ${roles[0].role}` + (seenViaSnap >= 0 ? '' : '')) if (roles[0].role === 'saboteur') sabotageurs.push(c.did) } } if (sabotageurs.length !== 1) fail(`expected exactly 1 saboteur across all views, got ${sabotageurs.length}: ${sabotageurs}`) else console.log(` ✓ exactly one saboteur: ${sabotageurs[0]}`) // 5. verify a worker's view does NOT leak other roles (redaction) const phaseAfter = conns[0]!.frames.filter((f) => f.t === '#event' && f.body.action.$type === 'town.deception.defs#roundStarted').length if (phaseAfter) console.log(' ✓ round transition propagated to host view') for (const c of conns) c.ws.close() console.log(ok ? '\nSMOKE PASS' : '\nSMOKE FAIL') process.exit(ok ? 0 : 1) } main().catch((e) => { console.error(e) process.exit(1) })