// Headless integration test: simulates 3 clients playing part of a game. const WebSocket = require('ws'); const URL = 'ws://localhost:3000'; const log = (who, ...a) => console.log(`[${who}]`, ...a); class Client { constructor(name) { this.name = name; this.msgs = []; this.id = null; this.room = null; this.ws = new WebSocket(URL); this.ws.on('open', () => this.send({ t: 'hello', name, avatar: { color: 1, eyes: 1, mouth: 1, extra: 1 } })); this.ws.on('message', (raw) => { const m = JSON.parse(raw.toString()); this.msgs.push(m); this['on_' + m.t]?.(m); }); } send(m) { this.ws.send(JSON.stringify(m)); } last(t) { return [...this.msgs].reverse().find(m => m.t === t); } all(t) { return this.msgs.filter(m => m.t === t); } close() { this.ws.close(); } } const wait = (ms) => new Promise(r => setTimeout(r, ms)); async function waitFor(fn, what, timeout = 8000) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { const v = fn(); if (v) return v; await wait(50); } throw new Error('timeout waiting for ' + what); } (async () => { const host = new Client('Alice'); await waitFor(() => host.last('welcome'), 'welcome'); // list rooms (empty) host.send({ t: 'list_rooms' }); await waitFor(() => host.last('room_list'), 'room_list'); console.log('✔ room_list (empty):', host.last('room_list').rooms.length === 0); // create private room host.send({ t: 'create_room', isPublic: false }); const joined = await waitFor(() => host.last('joined'), 'joined'); const code = joined.room.code; console.log('✔ created private room', code, '| phase:', joined.room.phase, '| host is self:', joined.room.hostId === joined.room.selfId); // private room should not appear in public list host.send({ t: 'list_rooms' }); await waitFor(() => host.all('room_list').length >= 2, 'room_list 2'); console.log('✔ private room hidden from list:', host.last('room_list').rooms.length === 0); // second player joins with wrong code const bob = new Client('Bob'); await waitFor(() => bob.last('welcome'), 'bob welcome'); bob.send({ t: 'join_room', code: 'ZZZZ' }); await waitFor(() => bob.last('join_error'), 'join_error'); console.log('✔ bad code rejected:', bob.last('join_error').reason); bob.send({ t: 'join_room', code }); const bJoined = await waitFor(() => bob.last('joined'), 'bob joined'); console.log('✔ bob joined; players:', bJoined.room.players.map(p => p.name).join(',')); const carol = new Client('Carol'); await waitFor(() => carol.last('welcome'), 'carol welcome'); carol.send({ t: 'join_room', code }); await waitFor(() => carol.last('joined'), 'carol joined'); await waitFor(() => host.all('player_joined').length === 2, 'host sees 2 joins'); console.log('✔ host sees joins; host players:', host.last('player_joined').player.name); // chat in lobby bob.send({ t: 'chat', text: 'hello everyone' }); await waitFor(() => host.last('chat'), 'chat'); console.log('✔ lobby chat:', host.last('chat').name + ':', host.last('chat').text); // typing indicator bob.send({ t: 'typing' }); await waitFor(() => host.last('typing'), 'typing'); console.log('✔ typing relayed:', host.last('typing').id === bJoined.room.selfId); // non-host cannot start bob.send({ t: 'start_game' }); await wait(300); console.log('✔ non-host start ignored:', !bob.last('game_starting')); // host changes settings host.send({ t: 'set_settings', settings: { rounds: 2, drawTime: 30 } }); await waitFor(() => bob.last('settings'), 'settings sync'); console.log('✔ settings synced: rounds=2 drawTime=30 ->', bob.last('settings').settings.rounds === 2 && bob.last('settings').settings.drawTime === 30); // host starts game host.send({ t: 'start_game' }); await waitFor(() => host.last('game_starting'), 'game_starting'); console.log('✔ game starting, round', host.last('game_starting').round); // word choice: drawer (Alice, first) gets choose_words const cw = await waitFor(() => host.last('choose_words'), 'choose_words'); console.log('✔ drawer got 3 words:', cw.words.length === 3, '->', cw.words.join(' / ')); await waitFor(() => bob.last('word_choice'), 'bob word_choice'); console.log('✔ others see word_choice for drawer:', bob.last('word_choice').drawerName); // drawer picks first word const word = cw.words[0]; host.send({ t: 'pick_word', word }); const ds = await waitFor(() => host.last('draw_start'), 'draw_start'); const bds = await waitFor(() => bob.last('draw_start'), 'bob draw_start'); console.log('✔ draw_start: drawer sees word:', ds.word === word, '| guesser sees mask:', bds.mask.replace(/_/g, '▯'), '| word hidden:', bds.word === null); // non-drawer cannot draw bob.send({ t: 'draw_begin', tool: 'brush', color: '#000', size: 10, points: [0.1, 0.1] }); await wait(200); console.log('✔ non-drawer draw rejected:', !carol.all('draw_begin').length); // drawer draws a stroke host.send({ t: 'draw_begin', tool: 'brush', color: '#ff0000', size: 12, points: [0.1, 0.1] }); host.send({ t: 'draw_points', points: [0.2, 0.2, 0.3, 0.3] }); host.send({ t: 'draw_end' }); await waitFor(() => bob.last('draw_end'), 'stroke relayed'); const strokeOp = bob.last('draw_begin').op; console.log('✔ stroke relayed live:', strokeOp.tool, strokeOp.color, '| pts appended:', bob.last('draw_points').points.length === 4); // fill + clear + undo host.send({ t: 'draw_begin', tool: 'fill', color: '#00ff00', size: 1, points: [0.5, 0.5] }); host.send({ t: 'draw_end' }); await waitFor(() => bob.all('draw_begin').length >= 2, 'fill relayed'); console.log('✔ fill relayed'); host.send({ t: 'undo' }); await waitFor(() => bob.last('undo'), 'undo relayed'); console.log('✔ undo relayed, opId:', bob.last('undo').opId); // wrong guess broadcast, close guess flagged, correct guess hidden bob.send({ t: 'chat', text: 'banana' }); await waitFor(() => carol.all('chat').some(m => m.text === 'banana'), 'wrong guess visible'); console.log('✔ wrong guess visible to others'); // make a close guess (edit distance 1) const closeGuess = word.length >= 4 ? word.slice(0, -1) : word + 'x'; bob.send({ t: 'chat', text: closeGuess }); await waitFor(() => host.all('chat_sys').some(m => m.kind === 'close'), 'close notice'); console.log('✔ close guess flagged:', host.all('chat_sys').find(m => m.kind === 'close').text); bob.send({ t: 'chat', text: word }); await waitFor(() => bob.all('chat_sys').some(m => m.kind === 'good'), 'self good'); await waitFor(() => host.all('chat_sys').some(m => m.text.includes('guessed the word')), 'others good'); const leaked = carol.all('chat').some(m => m.text === word); console.log('✔ correct guess NOT leaked as chat:', !leaked); const su = await waitFor(() => bob.last('score_update'), 'score_update'); const bobScore = su.players.find(p => p.name === 'Bob').score; console.log('✔ bob scored:', bobScore, 'pts (time-based)'); // carol guesses too -> everyone guessed -> turn ends early carol.send({ t: 'chat', text: word }); const te = await waitFor(() => host.last('turn_end'), 'turn_end', 10000); console.log('✔ turn_end reason:', te.reason, '| word revealed:', te.word === word); const aliceDelta = te.scores.find(s => s.name === 'Alice').delta; console.log('✔ drawer earned points:', aliceDelta > 0, `(${aliceDelta})`); console.log('✔ score deltas present:', te.scores.map(s => `${s.name}+${s.delta}`).join(', ')); // hint test: next turn with 30s draw time; wait for hint at ~45% (13.5s) too long; instead check hint math indirectly: mask format ok console.log('✔ mask format ok:', /^[_ a-z]+$/.test(bds.mask) && bds.mask.length === word.length); // host kicks carol host.send({ t: 'kick', playerId: carol.last('joined').room.selfId }); await waitFor(() => carol.last('kicked'), 'kicked'); await waitFor(() => host.all('player_left').length >= 1, 'host sees kick'); console.log('✔ host kick works:', carol.last('kicked').reason); // drawer leaves mid-game -> turn skipped (bob becomes alone -> abort to lobby) host.close(); await waitFor(() => bob.last('back_to_lobby') || bob.all('player_left').length >= 1, 'bob sees leave/abort', 8000); console.log('✔ drawer leave handled; bob back_to_lobby:', !!bob.last('back_to_lobby')); bob.close(); await wait(300); console.log('\nALL TESTS DONE'); process.exit(0); })().catch(e => { console.error('TEST FAILED:', e.message); process.exit(1); });