// ============================================================ // test_bot_chess_referee.js —— bot_chess 规则引擎与对局核心单测 // 运行:node dev_test_scripts/unit/test_bot_chess_referee.js // // 覆盖:FEN 解析/往返、走子校验(蹩马腿/塞象眼/炮架/将帅照面/送将)、 // 飞将直取、子力判和、以及 service 层建赛-开局-走子-非法拒绝的端到端。 // 测试使用真实 SQLite(bot_chess.db),跑完自动删除测试比赛。 // ============================================================ const assert = require('assert') const r = require('../../src/server/thought_lab/labs/bot_chess/referee') const service = require('../../src/server/thought_lab/labs/bot_chess/service') const db = require('../../src/server/thought_lab/labs/bot_chess/db') let passed = 0, failed = 0 const ok = (name) => { passed++; console.log(' ✔ ' + name) } const bad = (name, e) => { failed++; console.log(' ✘ ' + name + ' —— ' + (e && e.message)) } const t = async (name, fn) => { try { await fn(); ok(name) } catch (e) { bad(name, e) } } const main = async () => { // ---------- 1. FEN 往返 ---------- await t('FEN 解析 + 序列化往返一致', () => { const { board, turn } = r.parseFen(r.START_FEN) assert.strictEqual(turn, 'w') assert.strictEqual(r.toFen(board, 'w').split(' ')[0], r.START_FEN.split(' ')[0]) }) await t('开局双方各 16 子(红 16 / 黑 16)', () => { const { board } = r.parseFen(r.START_FEN) const list = r.boardToPieceList(board) assert.strictEqual(list.red.length, 16) assert.strictEqual(list.black.length, 16) }) // ---------- 2. 走子合法性 ---------- await t('标准开局:红炮二平五 h2-e2 合法', () => { const { board } = r.parseFen(r.START_FEN) const v = r.validateMove(board, 'red', { r: 2, c: 7 }, { r: 2, c: 4 }) assert.strictEqual(v.ok, true) }) await t('标准开局:黑马 b9-c7 合法(无蹩马腿)', () => { const { board } = r.parseFen(r.START_FEN) const v = r.validateMove(board, 'black', { r: 9, c: 1 }, { r: 7, c: 2 }) assert.strictEqual(v.ok, true) }) await t('蹩马腿:腿点被占 → BLOCKED_HORSE', () => { const fen = '4k4/9/9/9/9/9/9/1N7/1p7/4K4 w - - 0 1' const { board } = r.parseFen(fen) // 红马 b2 想跳 a0,腿点 b1 有黑卒 → 蹩马腿 const v = r.validateMove(board, 'red', { r: 2, c: 1 }, { r: 0, c: 0 }) assert.strictEqual(v.ok, false) assert.strictEqual(v.code, r.E.BLOCKED_HORSE) }) await t('马腿清空后同一着法合法', () => { const fen = '3k5/9/9/9/9/9/9/1N7/9/4K4 w - - 0 1' const { board } = r.parseFen(fen) const v = r.validateMove(board, 'red', { r: 2, c: 1 }, { r: 0, c: 0 }) assert.strictEqual(v.ok, true) }) await t('炮吃子必须有且仅有一个炮架', () => { // 红炮 d2,红车 f2 作架,黑卒 h2 → 隔一子吃,合法 const withScreen = '3k5/9/9/9/9/9/9/3C1R1p1/9/4K4 w - - 0 1' let { board } = r.parseFen(withScreen) let v = r.validateMove(board, 'red', { r: 2, c: 3 }, { r: 2, c: 7 }) assert.strictEqual(v.ok, true) // 无炮架直接吃 → CANNON_SCREEN const noScreen = '3k5/9/9/9/9/9/9/3C3p1/9/4K4 w - - 0 1' ;({ board } = r.parseFen(noScreen)) v = r.validateMove(board, 'red', { r: 2, c: 3 }, { r: 2, c: 7 }) assert.strictEqual(v.ok, false) assert.strictEqual(v.code, r.E.CANNON_SCREEN) }) await t('送将:移开自家遮挡车 → SELF_CHECK', () => { // 黑车 e9 打红帅 e0,红车 e1 挡着;红车横移 e1-f1 让出直线 → 送将 const fen = '4r4/9/9/9/9/9/9/9/4R4/4K4 w - - 0 1' const { board } = r.parseFen(fen) const v = r.validateMove(board, 'red', { r: 1, c: 4 }, { r: 1, c: 5 }) assert.strictEqual(v.ok, false) assert.strictEqual(v.code, r.E.SELF_CHECK) }) await t('将帅照面:走完双方帅将同列无遮挡 → FLYING_GENERAL', () => { const fen = '4k4/9/9/9/9/9/9/9/9/4K4 w - - 0 1' const { board } = r.parseFen(fen) const v = r.validateMove(board, 'red', { r: 0, c: 4 }, { r: 1, c: 4 }) assert.strictEqual(v.ok, false) assert.strictEqual(v.code, r.E.FLYING_GENERAL) }) await t('飞将直取敌帅:合法且立即获胜(king_captured)', () => { const fen = '4k4/9/9/9/9/9/9/9/9/4K4 w - - 0 1' const { board } = r.parseFen(fen) const v = r.validateMove(board, 'red', { r: 0, c: 4 }, { r: 9, c: 4 }) assert.strictEqual(v.ok, true) if (v.ok) { const after = r.applyMove(board, { r: 0, c: 4 }, { r: 9, c: 4 }).board const res = r.resolveAfterMove(after, 'red') assert.strictEqual(res.ended, true) assert.strictEqual(res.kind, 'king_captured') assert.strictEqual(res.winner, 'red') } }) await t('不能吃自己棋子 → CAPTURE_OWN', () => { const { board } = r.parseFen(r.START_FEN) const v = r.validateMove(board, 'red', { r: 0, c: 0 }, { r: 0, c: 1 }) assert.strictEqual(v.ok, false) assert.strictEqual(v.code, r.E.CAPTURE_OWN) }) // ---------- 3. 子力判和 ---------- await t('子力不足:双方只剩帅士相 → 和', () => { const fen = '4k4/9/9/9/9/9/9/9/4K4/9 w - - 0 1' const { board } = r.parseFen(fen) assert.strictEqual(r.insufficientMaterial(board), true) }) await t('存在车/马/炮/兵任一即非子力和', () => { const { board } = r.parseFen(r.START_FEN) assert.strictEqual(r.insufficientMaterial(board), false) }) // ---------- 4. service 端到端(建赛 → 开局 → 走子 → 非法拒绝)---------- let cleanMatchId = 0 await t('service:创建比赛 + 开局 + 合法走子 + 非法拒绝', async () => { const snap1 = service.createMatch({ title: '单测比赛', red_bot: '测试红', black_bot: '测试黑', best_of: 1 }) cleanMatchId = snap1.match.id assert.strictEqual(snap1.match.status, 'created') const started = service.startMatch(snap1.match.id) assert.strictEqual(started.ok, true) let res = service.submitMove(snap1.match.id, 'red', 'h2e2') assert.strictEqual(res.ok, true) assert.strictEqual(res.game_finished, false) // 非法走子:黑方兵不能后退(b6-b5 不是黑方合理)→ 用明显非法:黑车 b9 斜走 res = service.submitMove(snap1.match.id, 'black', 'b9c8') // 车斜走 → 非法 assert.strictEqual(res.ok, false) // 非法走子后局面不动:依旧黑手 const snap2 = service.buildSnapshot(snap1.match.id) assert.strictEqual(snap2.game.turn, 'b') // 轮次保护:红方连走两次被拒 res = service.submitMove(snap1.match.id, 'red', 'b0c2') assert.strictEqual(res.ok, false) assert.strictEqual(res.code, 'NOT_YOUR_TURN') // 黑方合法走子 res = service.submitMove(snap1.match.id, 'black', 'h9g7') assert.strictEqual(res.ok, true) // 日志可导出 const md = service.buildMarkdownLog(snap1.match.id) assert.ok(md.includes('h2-e2') || md.includes('红炮 h2-e2') || md.includes('比赛')) }) await t('service:犯规累计到上限判负', async () => { const snap = service.createMatch({ title: '犯规测试', red_bot: 'R', black_bot: 'B', best_of: 1 }) service.startMatch(snap.match.id) let r2 = service.submitMove(snap.match.id, 'red', 'h2e2') assert.strictEqual(r2.ok, true) // 黑方连续 3 次犯规(超时/非法由监督器调用 applyFoul) service.applyFoul(snap.match.id, 'black', '测试犯规1') service.applyFoul(snap.match.id, 'black', '测试犯规2') const r3 = service.applyFoul(snap.match.id, 'black', '测试犯规3') assert.strictEqual(r3.ok, true) const snap2 = service.buildSnapshot(snap.match.id) assert.strictEqual(snap2.game.status, 'finished') assert.strictEqual(snap2.game.winner, 'red') db.deleteMatch(snap.match.id) }) await t('service:终局后自动换先开下一局 / 结算', async () => { // best_of=3:第 1 局红胜 → 红黑对调开第 2 局 const snap = service.createMatch({ title: '换先测试', red_bot: 'R', black_bot: 'B', best_of: 3 }) service.startMatch(snap.match.id) // 快速杀棋:飞将直取(构造后手动写入局面不好走 service API,这里用 applyFoul 制造判负) service.forfeitSide(snap.match.id, 'black', '测试判负') const snap2 = service.buildSnapshot(snap.match.id) assert.strictEqual(snap2.game.game_no, 2) assert.strictEqual(snap2.game.status, 'playing') assert.strictEqual(snap2.game.turn, 'w') // 第 2 局黑方(原黑 bot)执红 assert.strictEqual(snap2.match.score_red, 1) db.deleteMatch(snap.match.id) }) if (cleanMatchId) db.deleteMatch(cleanMatchId) console.log(`\n结果:${passed} 通过 / ${failed} 失败`) process.exit(failed ? 1 : 0) } main().catch(e => { console.error('测试运行异常:', e); process.exit(1) })