feat(server, thought-lab): 优化百炼API配置并新增机器人象棋实验室

- 优化服务端百炼API配置,将硬编码的API地址改为通过环境变量读取,支持自定义部署端点
- 新增机器人象棋思想实验室,包含完整对战逻辑、管理接口与前端页面,配套测试用例
- 优化思想实验室路由注册,新增公开路由绑定能力支持非浏览器客户端调用
This commit is contained in:
yangxiangyuan
2026-08-18 13:23:36 +08:00
parent e4f84caa23
commit 3ba267b38a
20 changed files with 2639 additions and 11 deletions
@@ -0,0 +1,203 @@
// ============================================================
// 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) })