Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
4.6 kB · 114 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115// 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<void> } { 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<void>((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)})