feat(server, thought-lab): 优化百炼API配置并新增机器人象棋实验室
- 优化服务端百炼API配置,将硬编码的API地址改为通过环境变量读取,支持自定义部署端点 - 新增机器人象棋思想实验室,包含完整对战逻辑、管理接口与前端页面,配套测试用例 - 优化思想实验室路由注册,新增公开路由绑定能力支持非浏览器客户端调用
This commit is contained in:
@@ -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) })
|
||||
Reference in New Issue
Block a user