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,140 @@
// ============================================================
// test_bot_chess_api_smoke.js —— bot_chess HTTP 冒烟集成测试
// 运行:node dev_test_scripts/integration/test_bot_chess_api_smoke.js
//
// 临时起一个 express 实例,真实绑定 thought_lab 全部路由(含 bot_chess
// bindPublicRoutes 公开路由),验证:鉴权穿透、建赛、state、走子、
// 非法走子错误码、轮次保护、MD 日志、删除。全程不碰 8976/8977 端口。
// ============================================================
const express = require('express')
const http = require('http')
const thoughtLab = require('../../src/server/thought_lab')
const COOKIE = 'nav_gate=1'
let passed = 0, failed = 0
const app = express()
app.use(express.json())
thoughtLab.bindRoutes(app)
const server = http.createServer(app)
async function req(method, path, body) {
const opts = { method, headers: { Cookie: COOKIE } }
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json'
opts.body = JSON.stringify(body)
}
const resp = await fetch(`http://127.0.0.1:${server.address().port}${path}`, opts)
let data = null
try { data = await resp.json() } catch (e) { data = null }
return { status: resp.status, data }
}
const ok = (name) => { passed++; console.log(' ✔ ' + name) }
const bad = (name, e) => { failed++; console.log(' ✘ ' + name + ' —— ' + (e || 'fail')) }
const chk = (cond, msg) => { if (!cond) throw new Error(msg) }
const main = async () => {
await new Promise(res => server.listen(0, res))
// 1) lab 列表包含 bot_chess(首页页签渲染)
{
const r = await req('GET', '/api/thought_lab/labs')
chk(r.data && r.data.ok, 'labs 接口不 ok')
const lab = (r.data.labs || []).find(l => l.id === 'bot_chess')
chk(!!lab, '列表无 bot_chess')
ok('lab 列表包含 bot_chess(' + lab.name + ')')
}
// 2) 创建比赛
let matchId = 0, redToken = '', blackToken = ''
{
const r = await req('POST', '/api/thought_lab/bot_chess/matches', {
title: '冒烟测试', red_bot: '红Bot', black_bot: '黑Bot', best_of: 1
})
chk(r.data && r.data.ok, '创建比赛失败:' + JSON.stringify(r.data))
chk(r.data.match_id > 0, '无 match_id')
chk(r.data.red_token && r.data.black_token, '未返回 token')
matchId = r.data.match_id
redToken = r.data.red_token
blackToken = r.data.black_token
ok(`创建比赛 #${matchId}(红/黑 token 已签发)`)
}
// 3) 未开局时走子 → MATCH_NOT_STARTED
{
const r = await req('POST', `/api/thought_lab/bot_chess/matches/${matchId}/moves`, { move: 'h2-e2', side: 'red' })
chk(r.status === 400 && r.data.code === 'MATCH_NOT_STARTED', '未开局应拒绝走子:' + JSON.stringify(r.data))
ok('未开局走子被拒(MATCH_NOT_STARTED)')
}
// 4) 开局
{
const r = await req('POST', `/api/thought_lab/bot_chess/matches/${matchId}/start`)
chk(r.data && r.data.ok, '开局失败')
ok('开局成功')
}
// 5) 红方合法走子 h2-e2
{
const r = await req('POST', `/api/thought_lab/bot_chess/matches/${matchId}/moves`, { move: 'h2e2', side: 'red' })
chk(r.data && r.data.ok && r.data.accepted, '红方 h2e2 应被接受:' + JSON.stringify(r.data))
ok('红方 h2e2 合法走子被接受')
}
// 6) 轮次保护:红方再次走子 → NOT_YOUR_TURN
{
const r = await req('POST', `/api/thought_lab/bot_chess/matches/${matchId}/moves`, { move: 'b0c2', side: 'red' })
chk(r.data && r.data.code === 'NOT_YOUR_TURN', '轮次保护失效:' + JSON.stringify(r.data))
ok('红方连走被拒(NOT_YOUR_TURN)')
}
// 7) 非法走子:黑马斜走 b9c8(马走日,斜线非法)→ 400 + 错误码
{
const r = await req('POST', `/api/thought_lab/bot_chess/matches/${matchId}/moves`, { move: 'b9c8', side: 'black' })
chk(r.status === 400 && r.data.code === 'ILLEGAL_MOVE', '非法走子应 400+code:' + JSON.stringify(r.data))
ok('非法走子返回 400 + ILLEGAL_MOVE')
}
// 8) bot token 鉴权:裸 token 走子/查状态
{
const r = await req('GET', `/api/thought_lab/bot_chess/matches/${matchId}/state`)
chk(r.data && r.data.ok, 'state 失败')
chk(r.data.board && r.data.board.fen.includes(' '), 'state 无 FEN')
chk(r.data.game && (r.data.game.side_to_move === 'red' || r.data.game.side_to_move === 'black'), 'state 无 side_to_move')
ok('state 接口返回局面(FEN/棋盘/turn/side_to_move)')
}
// 9) 黑方合法走子 + 状态推进
{
const r = await req('POST', `/api/thought_lab/bot_chess/matches/${matchId}/moves`, { move: 'h9g7', side: 'black' })
chk(r.data && r.data.ok, '黑方 h9g7 应接受:' + JSON.stringify(r.data))
ok('黑方 h9g7 合法走子被接受')
}
// 10) MD 日志可导出
{
const resp = await fetch(`http://127.0.0.1:${server.address().port}/api/thought_lab/bot_chess/matches/${matchId}/log.md`, { headers: { Cookie: COOKIE } })
const md = await resp.text()
chk(resp.status === 200 && md.includes('机器人象棋 比赛日志'), 'MD 日志异常')
chk(md.includes('h2'), 'MD 日志应包含着法 h2')
ok('MD 日志导出成功(含着法记录)')
}
// 11) 清理
{
const r = await req('DELETE', `/api/thought_lab/bot_chess/matches/${matchId}`)
chk(r.data && r.data.ok, '删除失败')
// token 不应再被识别
const r2 = await req('POST', `/api/thought_lab/bot_chess/matches/${matchId}/moves`, { move: 'h2e2', side: 'red' })
chk(r2.status === 404 || !r2.data.ok, '删除后仍可访问')
ok('比赛删除成功')
}
server.close()
console.log(`\n结果:${passed} 通过 / ${failed} 失败`)
process.exit(failed ? 1 : 0)
}
main().catch(e => { console.error('冒烟测试异常:', e); server && server.close(); process.exit(1) })
@@ -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) })