From b06a78227f0852d8be45bd850ce5b8bf486dabc9 Mon Sep 17 00:00:00 2001 From: Jason Dekarske Date: Fri, 10 Jul 2026 23:16:07 -0700 Subject: [PATCH] test(e2e): multi-client throws and collision multi-path QA Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- e2e/collision_trajectory_qa.cjs | 72 +++++++++++++++++++++++++++++++++ e2e/e2e_end_score.cjs | 5 +-- e2e/e2e_persistence.cjs | 17 +++----- e2e/e2e_room_full.cjs | 12 ++---- e2e/e2e_score.cjs | 12 +++--- e2e/e2e_test.cjs | 23 +++++------ 6 files changed, 100 insertions(+), 41 deletions(-) create mode 100644 e2e/collision_trajectory_qa.cjs diff --git a/e2e/collision_trajectory_qa.cjs b/e2e/collision_trajectory_qa.cjs new file mode 100644 index 0000000..ffdd0d2 --- /dev/null +++ b/e2e/collision_trajectory_qa.cjs @@ -0,0 +1,72 @@ +const WebSocket = require('ws') +const room = 'COLQA' + Math.floor(Math.random() * 1000) +const url = 'ws://127.0.0.1:3000/ws?room=' + room + +function connect() { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url) + const messages = [] + ws.on('open', () => resolve({ ws, messages })) + ws.on('message', (data) => messages.push(JSON.parse(data.toString()))) + ws.on('error', reject) + }) +} + +function waitFor(messages, pred, timeout = 10000) { + const start = Date.now() + return new Promise((resolve, reject) => { + const check = () => { + if (pred()) return resolve(undefined) + if (Date.now() - start > timeout) return reject(new Error('timeout')) + setTimeout(check, 50) + } + check() + }) +} + +;(async () => { + const c = await connect() + await waitFor(c.messages, () => { + const last = c.messages[c.messages.length - 1] + return last && last.type === 'game_state' && last.phase === 'playing' + }) + + // First throw: weight 7 so it stays in house. + let state = c.messages[c.messages.length - 1] + c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.0, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 })) + await waitFor(c.messages, () => c.messages.filter(m => m.type === 'game_state').length > 1) + state = c.messages[c.messages.length - 1] + console.log('After first throw stones:', state.stones.map(s => ({ id: s.id, x: s.x, y: s.y }))) + if (state.stones.length !== 1) throw new Error('expected first stone in play') + + const trajCountBefore = c.messages.filter(m => m.type === 'trajectory').length + console.log('trajectory count before second throw:', trajCountBefore) + + // Second throw aimed slightly off-center so it hits the first stone. + c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.25, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 })) + await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectory').length > trajCountBefore) + + const traj = c.messages.filter(m => m.type === 'trajectory').pop() + console.log('Trajectory paths count:', traj.paths.length) + for (const p of traj.paths) { + console.log('stone_id', p.stone_id, 'path length', p.path.length, 'first', p.path[0], 'last', p.path[p.path.length - 1]) + } + + const ids = traj.paths.map(p => p.stone_id).sort((a, b) => a - b) + if (ids.length !== 2 || ids[0] !== 1 || ids[1] !== 2) throw new Error('expected both stone ids in trajectory, got ' + JSON.stringify(ids)) + for (const p of traj.paths) { + if (p.path.length < 5) throw new Error('path too short for stone ' + p.stone_id) + } + + // Verify the first stone actually moved because of collision. + const stone1Path = traj.paths.find(p => p.stone_id === 1).path + const first = stone1Path[0] + const last = stone1Path[stone1Path.length - 1] + const dist = Math.sqrt((last[0]-first[0])**2 + (last[1]-first[1])**2) + console.log('stone1 moved', dist, 'm') + if (dist < 0.05) throw new Error('expected first stone to move after collision') + + console.log('COLLISION TRAJECTORY QA PASSED') + c.ws.close() + process.exit(0) +})().catch(e => { console.error(e); process.exit(1) }) diff --git a/e2e/e2e_end_score.cjs b/e2e/e2e_end_score.cjs index 21f2f50..99ecd62 100644 --- a/e2e/e2e_end_score.cjs +++ b/e2e/e2e_end_score.cjs @@ -9,7 +9,7 @@ function connect(name, room) { ws.on('message', (data) => { const msg = JSON.parse(data.toString()) messages.push(msg) - if (msg.type === 'joined') resolve({ ws, messages, team: msg.team }) + if (msg.type === 'joined') resolve({ ws, messages }) }) ws.on('error', reject) }) @@ -47,9 +47,8 @@ function waitFor(messages, pred, timeout = 30000) { }, 5000) const turn = getTurn() if (!turn) throw new Error('no turn') - const thrower = turn === p1.team ? p1 : p2 const broomX = (Math.random() - 0.5) * 0.6 - thrower.ws.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000) } diff --git a/e2e/e2e_persistence.cjs b/e2e/e2e_persistence.cjs index 61d31de..e49567a 100644 --- a/e2e/e2e_persistence.cjs +++ b/e2e/e2e_persistence.cjs @@ -12,7 +12,7 @@ function connect(name) { messages.push(msg) console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '') if (msg.type === 'joined') { - resolve({ ws, messages, team: msg.team }) + resolve({ ws, messages }) } }) ws.on('open', () => console.log(`[${name}] open`)) @@ -38,27 +38,23 @@ function latestState(messages) { ;(async () => { const p1 = await connect('p1') - await waitFor(() => p1.messages.some(m => m.type === 'waiting')) - const p2 = await connect('p2') - await waitFor(() => p2.messages.some(m => m.type === 'game_state')) + await waitFor(() => p1.messages.some(m => m.type === 'game_state')) // Determine the current player and throw two stones in the same end. let state = latestState(p1.messages) console.log('Initial state', state) - // First throw: yellow aims slightly left of center. + // First throw: current turn team. let turn = state.turn_team - let turnPlayer = turn === p1.team ? p1 : p2 - turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000) state = latestState(p1.messages) console.log('After first throw:', state) - // Second throw: red aims slightly right of center. + // Second throw: other team. turn = state.turn_team - turnPlayer = turn === p1.team ? p1 : p2 - turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 })) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 })) await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000) state = latestState(p1.messages) @@ -77,7 +73,6 @@ function latestState(messages) { console.log('PERSISTENCE E2E PASSED') p1.ws.close() - p2.ws.close() process.exit(0) })().catch(err => { console.error(err) diff --git a/e2e/e2e_room_full.cjs b/e2e/e2e_room_full.cjs index 8ff8269..400a587 100644 --- a/e2e/e2e_room_full.cjs +++ b/e2e/e2e_room_full.cjs @@ -28,15 +28,11 @@ function waitFor(messages, pred, timeout = 10000) { const room = 'ROOMFULL' + Math.floor(Math.random() * 1000) const p1 = await connect('p1', room) const p2 = await connect('p2', room) - await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) const p3 = await connect('p3', room) - await waitFor(p3.messages, () => p3.messages.some(m => m.type === 'error'), 2000) - const p3error = p3.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) - const p1error = p1.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) - const p2error = p2.messages.some(m => m.type === 'error' && m.message.includes('Room is full')) - console.log({ p3error, p1error, p2error }) - if (!p3error) throw new Error('p3 should get room full error') - if (p1error || p2error) throw new Error('existing players should not see room-full error') + await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000) + await waitFor(p2.messages, () => p2.messages.some(m => m.type === 'game_state'), 5000) + await waitFor(p3.messages, () => p3.messages.some(m => m.type === 'game_state'), 5000) + console.log('Multiple observers can watch the same room state') p1.ws.close() p2.ws.close() p3.ws.close() diff --git a/e2e/e2e_score.cjs b/e2e/e2e_score.cjs index de2354f..81b37a9 100644 --- a/e2e/e2e_score.cjs +++ b/e2e/e2e_score.cjs @@ -9,7 +9,7 @@ function connect(name) { ws.on('message', (data) => { const msg = JSON.parse(data.toString()) messages.push(msg) - if (msg.type === 'joined') resolve({ ws, messages, team: msg.team }) + if (msg.type === 'joined') resolve({ ws, messages }) }) ws.on('error', reject) }) @@ -29,26 +29,24 @@ function waitFor(condFn, timeout = 5000) { ;(async () => { const p1 = await connect('p1') - const p2 = await connect('p2') await waitFor(() => p1.messages.some(m => m.type === 'game_state')) let state = p1.messages.find(m => m.type === 'game_state') console.log('start turn', state.turn_team, 'hammer', state.hammer) - const thrower1 = state.turn_team === p1.team ? p1 : p2 - thrower1.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) + const thrower1 = state.turn_team + p1.ws.send(JSON.stringify({ type: 'throw', team: thrower1, broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) await new Promise(r => setTimeout(r, 500)) state = p1.messages.slice(-1)[0] console.log('After first throw:', state) - const thrower2 = state.turn_team === p1.team ? p1 : p2 - thrower2.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 })) + const thrower2 = state.turn_team + p1.ws.send(JSON.stringify({ type: 'throw', team: thrower2, broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 })) await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) await new Promise(r => setTimeout(r, 500)) console.log('Final stones', p1.messages.slice(-1)[0].stones) p1.ws.close() - p2.ws.close() process.exit(0) })().catch(e => { console.error(e) diff --git a/e2e/e2e_test.cjs b/e2e/e2e_test.cjs index dce482d..192136f 100644 --- a/e2e/e2e_test.cjs +++ b/e2e/e2e_test.cjs @@ -12,7 +12,7 @@ function connect(name) { messages.push(msg) console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team}` : '') if (msg.type === 'joined') { - resolve({ ws, messages, team: msg.team }) + resolve({ ws, messages }) } }) ws.on('open', () => console.log(`[${name}] open`)) @@ -21,31 +21,30 @@ function connect(name) { }) } -async function waitFor(condFn, timeout = 5000) { +function waitFor(condFn, timeout = 5000) { const start = Date.now() - while (!condFn()) { - if (Date.now() - start > timeout) throw new Error('Timeout waiting') - await new Promise(r => setTimeout(r, 50)) - } + return new Promise((resolve, reject) => { + const check = () => { + if (condFn()) return resolve(undefined) + if (Date.now() - start > timeout) return reject(new Error('Timeout waiting')) + setTimeout(check, 50) + } + check() + }) } ;(async () => { const p1 = await connect('p1') - await waitFor(() => p1.messages.some(m => m.type === 'waiting')) - const p2 = await connect('p2') - await waitFor(() => p2.messages.some(m => m.type === 'joined')) await waitFor(() => p1.messages.some(m => m.type === 'game_state')) const state = p1.messages.find(m => m.type === 'game_state') console.log('Game state', state) const turn = state.turn_team - const turnPlayer = turn === p1.team ? p1 : p2 - turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) + p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 })) await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) console.log('Final state after throw:', p1.messages.slice(-1)[0]) p1.ws.close() - p2.ws.close() process.exit(0) })().catch(err => { console.error(err)