// 引擎單元測試(用 Node 內建 assert,無外部依賴) // 使用:node scripts/test_engine.js const assert = require('assert'); const GameEngine = require('../server/game/engine.js'); const results = []; function test(name, fn) { try { fn(); results.push(['ok', name]); } catch (e) { results.push(['fail', name, e.message]); } } // 1) 發牌與牌組 test('init 4 人:每人 5 張、目標牌正確、round=1', () => { const e = new GameEngine(); const st = e.init(['p1', 'p2', 'p3', 'p4']); assert.strictEqual(st.players.length, 4); for (const p of st.players) assert.strictEqual(p.handSize, 5); assert.ok(['Q', 'K', 'A'].includes(st.targetCard)); assert.strictEqual(st.roundNumber, 1); assert.ok(st.currentPlayerId); }); test('init 8 人:每人 5 張', () => { const e = new GameEngine(); const st = e.init(Array.from({ length: 8 }, (_, i) => 'p' + i)); assert.strictEqual(st.players.length, 8); for (const p of st.players) assert.strictEqual(p.handSize, 5); }); test('牌組總張數 = 人數 x5(2/3/5/8)', () => { for (const n of [2, 3, 5, 8]) { const e = new GameEngine(); const deck = e.createDeck(n); assert.strictEqual(deck.length, n * 5, `n=${n}`); } }); // 2) 出牌驗證 test('出牌:0 張 → 錯誤', () => { const e = new GameEngine(); e.init(['a', 'b']); assert.ok(e.playCard(e.currentPlayerId, []).error); }); test('出牌:4 張 → 錯誤', () => { const e = new GameEngine(); e.init(['a', 'b']); assert.ok(e.playCard(e.currentPlayerId, [0, 1, 2, 3]).error); }); test('出牌:索引超出範圍 → 錯誤', () => { const e = new GameEngine(); e.init(['a', 'b']); assert.ok(e.playCard(e.currentPlayerId, [99]).error); }); test('出牌:重複索引 → 錯誤', () => { const e = new GameEngine(); e.init(['a', 'b']); assert.ok(e.playCard(e.currentPlayerId, [0, 0]).error); }); test('出牌:非你的回合 → 錯誤', () => { const e = new GameEngine(); e.init(['a', 'b']); const other = Array.from(e.players.keys()).find((k) => k !== e.currentPlayerId); assert.ok(e.playCard(other, [0]).error); }); test('出牌:合法出 1-3 張並移除、換下一位', () => { const e = new GameEngine(); e.init(['a', 'b', 'c']); const pid = e.currentPlayerId; const before = e.getPlayerHand(pid).length; const r = e.playCard(pid, [0]); assert.strictEqual(r.error, undefined); assert.strictEqual(e.getPlayerHand(pid).length, before - 1); assert.strictEqual(r.nextPlayerId, e.currentPlayerId); }); // 3) 質疑:整組判定 test('質疑:吹牛組(含非目標牌)→ 質疑成功,出牌者開槍', () => { const e = new GameEngine(); e.init(['a', 'b', 'c']); e.targetCard = 'A'; e.getPlayer('a').hand = ['A', 'K', 'JOKER']; e.getPlayer('b').hand = ['A', 'A', 'A']; e.getPlayer('c').hand = ['A', 'A']; e.currentPlayerId = 'a'; const r = e.playCard('a', [0, 1]); // A, K assert.strictEqual(r.error, undefined); const c = e.challenge('b'); assert.strictEqual(c.isBluff, true); assert.strictEqual(c.shooter, 'a'); }); test('質疑:真牌組(全目標牌)→ 質疑失敗,質疑者開槍', () => { const e = new GameEngine(); e.init(['a', 'b', 'c']); e.targetCard = 'A'; e.getPlayer('a').hand = ['A', 'Q', 'Q']; e.getPlayer('b').hand = ['A', 'A', 'A']; e.getPlayer('c').hand = ['A', 'A']; e.currentPlayerId = 'a'; e.playCard('a', [0]); // A const c = e.challenge('b'); assert.strictEqual(c.isBluff, false); assert.strictEqual(c.shooter, 'b'); }); test('質疑:Joker 視為萬能牌 → 質疑失敗', () => { // 用 3 人局:a 出 JOKER 後仍有玩家( c )持牌,b 才能主動質疑 const e = new GameEngine(); e.init(['a', 'b', 'c']); e.targetCard = 'K'; e.getPlayer('a').hand = ['JOKER', 'Q']; e.getPlayer('b').hand = ['K', 'K']; e.getPlayer('c').hand = ['Q', 'Q', 'Q']; e.currentPlayerId = 'a'; e.playCard('a', [0]); // JOKER(宣稱 K) const c = e.challenge('b'); assert.strictEqual(c.isBluff, false); // JOKER 是萬能牌 → 質疑失敗 }); // 3.5) 資訊邊界:公開狀態不洩漏實際牌值 test('資訊邊界:出牌後 gameState 只含張數、不含真實牌值', () => { const e = new GameEngine(); e.init(['a', 'b', 'c']); const pid = e.currentPlayerId; e.playCard(pid, [0]); const st = e.getGameState(); for (const g of st.playedGroups) { assert.strictEqual(typeof g.cardCount, 'number'); assert.strictEqual(g.cards, undefined, 'playedGroups 不應洩漏 cards'); } assert.ok(!Array.isArray(st.playedCards) || st.playedCards.length === 0, 'playedCards 不應含牌值'); }); test('質疑時才揭露真實牌值(challengedGroup)', () => { const e = new GameEngine(); e.init(['a', 'b', 'c']); e.targetCard = 'A'; e.currentPlayerId = 'a'; e.playCard('a', [0]); const lastGroup = e.playGroups[e.playGroups.length - 1]; const before = [...lastGroup.cards]; // 引擎內部可讀 const st = e.getGameState(); // 公開狀態沒有牌值 for (const g of st.playedGroups) assert.strictEqual(g.cards, undefined); const r = e.challenge('b'); assert.ok(Array.isArray(r.challengedGroup) && r.challengedGroup.length === before.length, '質疑結果才揭露牌值'); }); // 4) 系統質疑 test('系統質疑:輪到某玩家且其他人都沒牌 → 自動打出並質疑', () => { const e = new GameEngine(); e.init(['a', 'b', 'c']); e.targetCard = 'A'; e.getPlayer('a').hand = ['K', 'K']; e.getPlayer('b').hand = ['A']; e.getPlayer('c').hand = []; e.currentPlayerId = 'b'; const r = e.playCard('b', [0]); // b 出 A,手牌清空 assert.ok(r.systemChallenge, '應觸發系統質疑'); assert.strictEqual(r.systemChallenge.playerId, 'a'); assert.strictEqual(r.systemChallenge.isBluff, true); // K 不是 A assert.strictEqual(r.systemChallenge.shooter, 'a'); }); test('系統質疑:若剩餘全為真牌 → 無人開槍並重開一手', () => { const e = new GameEngine(); e.init(['a', 'b', 'c']); e.targetCard = 'A'; e.getPlayer('a').hand = ['A', 'A']; e.getPlayer('b').hand = ['A']; e.getPlayer('c').hand = []; e.currentPlayerId = 'b'; const r = e.playCard('b', [0]); assert.ok(r.systemChallenge); assert.strictEqual(r.systemChallenge.isBluff, false); assert.strictEqual(r.systemChallenge.shooter, null); // 重開一手後 a 仍有 5 張 assert.strictEqual(e.getPlayerHand('a').length, 5); }); // 5) 重開一手與死亡 test('質疑後重開一手:存活者各 5 張、死掉者 0 張', () => { const e = new GameEngine(); e.init(['a', 'b', 'c']); e.targetCard = 'A'; e.getPlayer('a').hand = ['A', 'K', 'JOKER']; e.getPlayer('b').hand = ['A', 'A', 'A']; e.getPlayer('c').hand = ['A', 'A']; e.currentPlayerId = 'a'; e.playCard('a', [0, 1]); // A, K 吹牛 // 讓 a 必死 e.getPlayer('a').revolver = { chambers: [true, false, false, false, false, false], currentChamber: 0 }; const c = e.challenge('b'); assert.strictEqual(c.shooter, 'a'); assert.strictEqual(e.getPlayer('a').alive, false); assert.strictEqual(e.getPlayerHand('a').length, 0); assert.strictEqual(e.getPlayerHand('b').length, 5); assert.ok(e.roundNumber >= 2); }); // 6) 完整模擬:必定會結束 test('完整隨機模擬可正常結束,存活者 <= 1', () => { const e = new GameEngine(); e.init(['p1', 'p2', 'p3', 'p4']); let guard = 0; while (!e.isGameOver && guard < 3000) { guard++; const pid = e.currentPlayerId; if (!pid) break; const hand = e.getPlayerHand(pid); if (hand.length === 0) break; const n = 1 + Math.floor(Math.random() * Math.min(3, hand.length)); const idx = Array.from({ length: n }, (_, i) => i); e.playCard(pid, idx); if (Math.random() < 0.35 && e.playGroups.length > 0) { e.challenge(e.currentPlayerId); } } assert.ok(e.isGameOver || guard >= 3000 === false, '遊戲應結束(無死結)'); assert.ok(e.getAlivePlayers().length <= 1, '最多一位存活'); assert.ok(e.roundNumber > 0); }); // 印出結果 let failed = 0; for (const [status, name, msg] of results) { if (status === 'ok') console.log(' PASS ' + name); else { failed++; console.log(' FAIL ' + name + ' -> ' + msg); } } console.log(`\n${results.length - failed}/${results.length} 通過`); process.exit(failed ? 1 : 0);