// 多人端對端整合測試:連真實伺服器,驗證「多張出牌 → 下家挑戰 → 手牌更新」流程。 // 使用:先啟動 server(node server/index.js),再跑 node scripts/test_socket_e2e.js const { io } = require('../client/node_modules/socket.io-client'); const URL = 'http://localhost:3000'; const clients = {}; function connect(name) { return new Promise((resolve, reject) => { const s = io(URL, { transports: ['websocket'], reconnection: false, timeout: 8000 }); s._hand = null; s._name = name; s._lastError = null; s.on('connect', () => { clients[s.id] = s; resolve(s); }); s.on('connect_error', (e) => reject(new Error(name + ' connect_error: ' + e.message))); s.on('yourHand', (h) => { s._hand = h || []; }); s.on('error', (d) => { s._lastError = d && d.message; }); }); } function once(socket, ev, timeout = 8000) { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error(`${socket._name} wait ${ev} timeout`)), timeout); socket.once(ev, (d) => { clearTimeout(t); resolve(d); }); }); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); let failures = 0; function ok(cond, msg) { if (cond) console.log(' PASS ' + msg); else { failures++; console.log(' FAIL ' + msg); } } (async () => { console.log('[1] 三玩家連線+房間'); const a = await connect('A'); const b = await connect('B'); const c = await connect('C'); const roomCreated = once(a, 'roomCreated'); a.emit('createRoom'); const room = await roomCreated; const roomId = room.id; ok(roomId, '建立房間 roomId=' + roomId); b.emit('joinRoom', { roomId }); await once(a, 'playerJoined'); c.emit('joinRoom', { roomId }); await once(a, 'playerJoined'); ok(true, 'B、C 加入'); console.log('[2] 開始遊戲 + 私密手牌'); const started = [once(a, 'gameStarted'), once(b, 'gameStarted'), once(c, 'gameStarted')]; a.emit('startGame', { roomId }); const [gsA] = await Promise.all(started); ok(gsA.currentPlayerId && ['Q','K','A'].includes(gsA.targetCard), 'gameStarted 狀態正確'); ok(gsA.players.length === 3, '3 位玩家'); await sleep(300); ok(a._hand.length === 5 && b._hand.length === 5 && c._hand.length === 5, '每位玩家收到 5 張私密手牌(yourHand)'); console.log('[3] 目前玩家多張出牌'); const cur = clients[gsA.currentPlayerId]; ok(!!cur, '定位目前玩家'); const playedEvt = once(a, 'cardPlayed'); cur.emit('playCard', { roomId, cardIndices: [0, 1] }); const played = await playedEvt; // 資訊邊界:cardPlayed 只公開張數,不洩漏實際牌值 ok(played.playedGroup && played.playedGroup.cardCount === 2, '出牌 2 張並廣播(只含張數)'); ok(!('cards' in (played.playedGroup || {})), 'cardPlayed 不洩漏實際牌值'); const currentNow = played.gameState.currentPlayerId; ok(currentNow !== cur.id, '換下一位'); await sleep(300); ok(cur._hand.length === 3, `出牌者剩 3 張 (yourHand 同步,實際=${cur._hand.length})`); console.log('[4] 非我的回合出牌 → 被拒絕'); const notTurn = [a, b, c].find((s) => s.id !== cur.id && s.id !== currentNow); ok(!!notTurn, '找到非回合玩家'); notTurn._lastError = null; notTurn.emit('playCard', { roomId, cardIndices: [0] }); await sleep(300); ok(!!notTurn._lastError, '非回合者被拒絕(' + (notTurn._lastError || 'none') + ')'); console.log('[5] 目前玩家(下家)質疑 → 收到結果與新狀態'); const currentSocket = clients[currentNow]; const challengedEvt = once(a, 'challenged'); currentSocket.emit('challenge', { roomId }); const ch = await challengedEvt; ok(ch.result && typeof ch.result.isBluff === 'boolean', '質疑結果帶 isBluff=' + ch.result.isBluff); ok(ch.result.shooter, '有開槍者(shooter=' + ch.result.shooter + ')'); ok(ch.gameState.roundNumber >= 2, '質疑後進入下一局(round=' + ch.gameState.roundNumber + ')'); await sleep(300); ok(ch.gameState.players.every((p) => { const sh = clients[p.id]; return !sh || (sh._hand.length === p.handSize); }), 'yourHand 與公開狀態一致(' + ch.gameState.players.map((p) => p.handSize).join(',') + ')'); const aliveCount = ch.gameState.players.filter((p) => p.alive).length; console.log(`[6] 結果快照:第 ${ch.gameState.roundNumber} 局,存活 ${aliveCount}/3,isBluff=${ch.result.isBluff}`); [a, b, c].forEach((s) => s.close()); await sleep(300); console.log(failures === 0 ? '\n整合測試全部通過' : `\n有 ${failures} 項失敗`); process.exit(failures ? 1 : 0); })().catch((e) => { console.error('[FATAL]', e.message); process.exit(1); });