Files
skrible-2/test/sim.js
T
2026-07-18 20:41:22 -05:00

183 lines
8.4 KiB
JavaScript

// 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); });