Something went wrong. Try again.
A Social Deduction / Deception Game
Something went wrong. Try again.
12 kB · 281 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282// 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 = 0const 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<Client> { 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<boolean> { 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<string> { 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<void> { 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<void> { 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<void> { 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<string>() 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)})