Files

217 lines
8.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 引擎單元測試(用 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('牌組總張數 = 人數 x52/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);