183 lines
8.4 KiB
JavaScript
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); });
|