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,365 @@
// ============================================================
// bot_chess/service.js —— 对局核心业务(建赛/换先/走子/终局/犯规/结算/日志)
// 被 index.js(HTTP API)与 orchestrator.js(裁判编排)共用,保证“机械裁判”
// 只有这一处实现,规则意见一律出自 referee.js 引擎,不信任任何 LLM 叙述。
// ============================================================
const fs = require('fs')
const path = require('path')
const r = require('./referee')
const db = require('./db')
const CONFIG_PATH = path.join(__dirname, 'config.json')
const readConfig = () => {
try {
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
} catch {}
return {}
}
const SIDE_OTHER = { red: 'black', black: 'red' }
const sideOfTurn = t => (t === 'b' ? 'black' : 'red')
const turnOfSide = s => (s === 'red' ? 'w' : 'b')
const CN_R = { 'K': '帅', 'A': '士', 'B': '相', 'N': '马', 'R': '车', 'C': '炮', 'P': '兵' }
const CN_B = { 'K': '将', 'A': '仕', 'B': '象', 'N': '馬', 'R': '車', 'C': '砲', 'P': '卒' }
const safeStr = v => String(v || '')
// 某局中某方对应的 bot 名(偶局红黑对调)
const sideBotForGame = (match, gameNo, side) => {
const swap = (Number(gameNo) || 0) % 2 === 0
if (side === 'red') return swap ? safeStr(match.black_bot) : safeStr(match.red_bot)
return swap ? safeStr(match.red_bot) : safeStr(match.black_bot)
}
// ---------------- 创建 / 开局 ----------------
// 创建比赛并生成第一局(返回 match 快照;token 仅此处/建赛接口返回)
const createMatch = (opts) => {
const cfg = readConfig()
const bestOf = Math.max(1, Math.min(9, Number(opts.best_of) || Number(cfg.best_of) || 3))
const match = db.createMatch({
title: opts.title,
red_bot: opts.red_bot || '红方Bot',
black_bot: opts.black_bot || '黑方Bot',
red_model: opts.red_model || cfg.red_model || 'qwen-plus-latest',
black_model: opts.black_model || cfg.black_model || 'qwen-plus-latest',
best_of: bestOf
})
const game = db.createGame(match.id, 1)
db.updateMatchStatus(match.id, { current_game_id: game.id })
db.addEvent({ match_id: match.id, game_id: game.id, kind: 'match_created', content: `比赛“${match.title}”创建:${match.red_bot} vs ${match.black_bot},${bestOf}局${bestOf === 1 ? '' : '(胜出需赢' + Math.ceil(bestOf / 2) + '局)'}` })
db.addEvent({ match_id: match.id, game_id: game.id, kind: 'game_started', content: `第 1 局开始:${sideBotForGame(match, 1, 'red')} 执红先行` })
return buildSnapshot(match.id)
}
// 开局(created -> active),并触发监督器接管超时判罚
const startMatch = (matchId) => {
const match = db.getMatch(matchId)
if (!match) return { ok: false, error: 'match_not_found' }
if (match.status !== 'created') return { ok: false, error: 'match_not_created', code: 'MATCH_NOT_CREATED' }
db.updateMatchStatus(matchId, { status: 'active', started_at: db.nowBeijing() })
db.addEvent({ match_id: matchId, kind: 'start', content: '比赛开始' })
return { ok: true, snapshot: buildSnapshot(matchId) }
}
// ---------------- 走子(机械裁判主入口)----------------
const submitMove = (matchId, side, moveStr, opts = {}) => {
const cfg = readConfig()
const match = db.getMatch(matchId)
if (!match) return { ok: false, code: 'MATCH_NOT_FOUND', error: '比赛不存在' }
if (match.status === 'finished' || match.status === 'aborted') {
return { ok: false, code: 'GAME_OVER', error: `比赛已${match.status === 'finished' ? '结束' : '终止'}` }
}
if (match.status !== 'active') return { ok: false, code: 'MATCH_NOT_STARTED', error: '比赛尚未开始' }
const game = db.getGameByCur(matchId)
if (!game || game.status !== 'playing') return { ok: false, code: 'GAME_OVER', error: '本局已结束,等待下一局' }
if (side !== sideOfTurn(game.turn)) {
return { ok: false, code: 'NOT_YOUR_TURN', error: `还没轮到你(${side}),当前轮到 ${sideOfTurn(game.turn)}` }
}
const mv = r.parseMove(moveStr)
if (!mv) return { ok: false, code: 'BAD_MOVE_FORMAT', error: '着法格式应为 h2-e2 或 h2e2' }
const fen = r.parseFen(game.fen)
const v = r.validateMove(fen.board, side, mv.from, mv.to)
if (!v.ok) {
// 非法走子:局面不动,仅返回可读错误(不自动判罚;判罚由监督器/编排器决定)
return { ok: false, code: v.code, reason: v.reason, error: `非法走子:${v.reason}`,
fen: game.fen, turn: game.turn, side_to_move: sideOfTurn(game.turn), status: game.status }
}
// 模拟落子
const applied = r.applyMove(fen.board, mv.from, mv.to)
const piece = fen.board[mv.from.r][mv.from.c]
const pInfo = r.pieceInfo(piece)
const enemy = SIDE_OTHER[side]
const isCheck = r.inCheck(applied.board, enemy)
const res = r.resolveAfterMove(applied.board, side)
// 和棋辅助判定(仅当引擎未直接决出胜负时)
let draw = '', drawKind = ''
if (!res.ended) {
if (r.insufficientMaterial(applied.board)) { draw = 'draw'; drawKind = 'no_material' }
else {
const isCapturePawn = !!applied.captured || (pInfo && pInfo.type === 'P')
const np = isCapturePawn ? 0 : (Number(game.no_capture_plies) || 0) + 1
const limit = Number(cfg.draw_ply_limit) || 120
if (np >= limit) { draw = 'draw'; drawKind = 'no_advance_limit' }
else {
const key = r.positionKey(applied.board)
const history = db.listMoves(game.id).map(m => r.positionKey(r.parseFen(m.fen_after).board))
let cnt = 1
for (const k of history) if (k === key) cnt++
const need = Number(cfg.repetition_required) || 3
if (cnt >= need) { draw = 'draw'; drawKind = 'repetition' }
}
}
}
const moveNo = (Number(game.move_count) || 0) + 1
const newTurn = SIDE_OTHER[side]
const newFen = r.toFen(applied.board, turnOfSide(newTurn))
const cn = (side === 'red' ? CN_R : CN_B)[pInfo.type] || pInfo.type
const san = `${side === 'red' ? '红' : '黑'}${cn} ${fmtSq(mv.from)}-${fmtSq(mv.to)}`
const mvRow = db.addMove({
match_id: matchId, game_id: game.id, move_no: moveNo, side,
from_sq: fmtSq(mv.from), to_sq: fmtSq(mv.to),
piece: pInfo.type, captured: applied.captured ? (applied.captured.toUpperCase()) : '',
is_check: isCheck ? 1 : 0, fen_before: game.fen, fen_after: newFen, san
})
if (res.ended || draw) {
const result = draw ? 'draw' : res.result
const winner = draw ? '' : res.winner
const kind = draw ? drawKind : res.kind
const content = draw
? `判和:${drawKind === 'no_material' ? '子力不足' : drawKind === 'repetition' ? '三次重复局面' : '自然限着(连续无吃子/进兵超限)'}`
: `${winner === 'red' ? '红方' : '黑方'}胜:${kind === 'checkmate' ? '将死' : kind === 'stalemate' ? '困毙(无棋可动)' : '飞将直取'}`
finishGame(matchId, game.id, { result, winner, kind, content, lastMoveRow: mvRow })
return { ok: true, accepted: true, fen_after: newFen, check: res.check, game_finished: true,
result, winner, kind, snapshot: buildSnapshot(matchId) }
}
db.updateGame(game.id, {
fen: newFen, turn: turnOfSide(newTurn), move_count: moveNo,
no_capture_plies: applied.captured || (pInfo && pInfo.type === 'P') ? 0 : (Number(game.no_capture_plies) || 0) + 1
})
db.addEvent({
match_id: matchId, game_id: game.id, kind: 'move',
side, content: `${san}${applied.captured ? ` 吃${(side === 'red' ? CN_R : CN_B)[applied.captured.toUpperCase()] || applied.captured}` : ''}${isCheck ? ' 将军' : ''} (${moveNo})`
})
return { ok: true, accepted: true, fen_after: newFen, check: res.check, game_finished: false, snapshot: buildSnapshot(matchId) }
}
// 坐标转字符串(parseMove 返回的对象含 r/c)
const fmtSq = p => (p === undefined || p === null) ? '' : ('abcdefghi'[p.c] + String(p.r))
// ---------------- 终局 / 换先 / 整场结算 ----------------
const finishGame = (matchId, gameId, { result, winner, kind, content, lastMoveRow }) => {
const match = db.getMatch(matchId)
const game = db.getGame(gameId)
if (!match || !game || game.status !== 'playing') return
db.updateGame(gameId, { status: 'finished', result, winner, finished_at: db.nowBeijing() })
let gw = winner
if (String(result).startsWith('forfeit_')) gw = result === 'forfeit_red' ? 'black' : 'red'
if (String(result).startsWith('abort_')) { gw = ''; content = content || '对局中止' }
const addScore = (side) => {
const m2 = db.getMatch(matchId)
if (side === 'red') db.updateMatchStatus(matchId, { score_red: (m2.score_red || 0) + 1 })
else db.updateMatchStatus(matchId, { score_black: (m2.score_black || 0) + 1 })
}
if (gw === 'red') addScore('red')
else if (gw === 'black') addScore('black')
else if (!winner && !gw) { /* 平局不加分 */ }
db.addEvent({ match_id: matchId, game_id: gameId, kind: 'game_finished', side: gw, content: `${game.game_no} 局结束:${content || result}` })
// 判断整场是否需继续下一局
const m2 = db.getMatch(matchId)
const winNeed = Math.ceil(Number(m2.best_of) / 2)
const nextNo = (Number(game.game_no) || 0) + 1
const scored = Math.max(m2.score_red || 0, m2.score_black || 0)
if (scored >= winNeed) {
const champ = (m2.score_red || 0) >= winNeed ? 'red' : (m2.score_black || 0) >= winNeed ? 'black' : ''
const champBot = champ === 'red' ? m2.red_bot : m2.black_bot
db.updateMatchStatus(matchId, { status: 'finished', winner: champ, finished_at: db.nowBeijing() })
db.addEvent({ match_id: matchId, kind: 'match_finished', side: champ, content: `比赛结束:${champBot} 夺冠 ${m2.score_red}:${m2.score_black}` })
} else if (nextNo > Number(m2.best_of)) {
const champ = m2.score_red > m2.score_black ? 'red' : m2.score_black > m2.score_red ? 'black' : 'draw'
db.updateMatchStatus(matchId, { status: 'finished', winner: champ, finished_at: db.nowBeijing() })
db.addEvent({ match_id: matchId, kind: 'match_finished', side: champ, content: `比赛结束(满${m2.best_of}局):${champ === 'draw' ? '平局' : (champ === 'red' ? m2.red_bot : m2.black_bot) + ' 胜'}` })
} else {
// 开下一局,红黑对调
const ng = db.createGame(matchId, nextNo)
db.updateMatchStatus(matchId, { current_game_id: ng.id })
const nm = db.getMatch(matchId)
db.addEvent({ match_id: matchId, game_id: ng.id, kind: 'game_started', content: `第 ${nextNo} 局开始:${sideBotForGame(nm, nextNo, 'red')} 执红先行(红黑对调)` })
}
}
// 认输 / 判负 / 中止(编排器或管理员调用;机械层负责落库)
const forfeitSide = (matchId, side, reason) => {
const match = db.getMatch(matchId)
const game = db.getGameByCur(matchId)
if (!match || !game || game.status !== 'playing') return { ok: false, error: '不是对局中' }
const result = side === 'red' ? 'forfeit_red' : 'forfeit_black'
finishGame(matchId, game.id, {
result, winner: SIDE_OTHER[side],
content: `${side === 'red' ? '红方' : '黑方'}判负:${reason || '判负'}`
})
return { ok: true, snapshot: buildSnapshot(matchId) }
}
const abortMatch = (matchId, reason) => {
const match = db.getMatch(matchId)
if (!match) return { ok: false, error: 'match_not_found' }
const game = db.getGameByCur(matchId)
if (game && game.status === 'playing') {
db.updateGame(game.id, { status: 'aborted', result: 'aborted', finished_at: db.nowBeijing() })
}
db.updateMatchStatus(matchId, { status: 'aborted', winner: '', finished_at: db.nowBeijing() })
db.addEvent({ match_id: matchId, game_id: game ? game.id : 0, kind: 'abort', content: `比赛中止:${reason || '手动中止'}` })
return { ok: true }
}
// 犯规累计判罚(超时/非法走子超限/失联均由监督器或编排器调用)
const applyFoul = (matchId, side, reason) => {
const cfg = readConfig()
const match = db.getMatch(matchId)
if (!match || match.status !== 'active') return { ok: false, error: '比赛不活动中' }
const game = db.getGameByCur(matchId)
if (!game || game.status !== 'playing') return { ok: false, error: '本局已结束' }
const curSide = sideOfTurn(game.turn)
if (side !== curSide) return { ok: false, error: '犯规方与当前行棋方不一致' }
db.addFoul(game.id, side, reason)
db.addEvent({ match_id: matchId, game_id: game.id, kind: 'foul', side, content: `${side === 'red' ? '红方' : '黑方'}犯规:${reason}` })
const limit = Number(cfg.foul_limit) || 3
const g2 = db.getGame(game.id)
const cnt = side === 'red' ? g2.red_fouls : g2.black_fouls
if (cnt >= limit) {
const result = side === 'red' ? 'forfeit_red' : 'forfeit_black'
finishGame(matchId, game.id, { result, winner: SIDE_OTHER[side], content: `${side === 'red' ? '红方' : '黑方'}犯规达 ${limit} 次判负` })
}
return { ok: true, snapshot: buildSnapshot(matchId) }
}
// ---------------- 快照与日志 ----------------
const buildSnapshot = (matchId) => {
const match = db.getMatch(matchId)
if (!match) return null
const game = match.current_game_id ? db.getGame(match.current_game_id) : null
const games = db.getGamesByMatch(matchId).map(g => ({
game_no: g.game_no, status: g.status, result: g.result, winner: g.winner,
move_count: g.move_count, red_fouls: g.red_fouls, black_fouls: g.black_fouls,
fen: g.fen, turn: g.turn, created_at: g.created_at, finished_at: g.finished_at
}))
let board = null
if (game) {
const fen = r.parseFen(game.fen)
board = {
fen: game.fen,
ascii: r.boardToAscii(fen.board, game.turn),
pieces: r.boardToPieceList(fen.board),
side_to_move: sideOfTurn(game.turn)
}
}
const lastMoves = game ? db.listMoves(game.id, 10).reverse() : []
return {
match: {
id: match.id, title: match.title, red_bot: match.red_bot, black_bot: match.black_bot,
red_model: match.red_model, black_model: match.black_model, best_of: match.best_of,
status: match.status, score_red: match.score_red, score_black: match.score_black,
winner: match.winner, created_at: match.created_at, started_at: match.started_at,
finished_at: match.finished_at, current_game_no: game ? game.game_no : 0
},
game: game ? {
id: game.id, game_no: game.game_no, status: game.status, result: game.result,
winner: game.winner, move_count: game.move_count, turn: game.turn,
red_fouls: game.red_fouls, black_fouls: game.black_fouls, created_at: game.created_at, finished_at: game.finished_at
} : null,
board,
games,
last_moves: lastMoves.map(m => m.san || `${m.side} ${m.from_sq}-${m.to_sq}`)
}
}
// Markdown 比赛日志(导出用)
const buildMarkdownLog = (matchId) => {
const match = db.getMatch(matchId)
if (!match) return ''
const games = db.getGamesByMatch(matchId)
const events = db.listEvents(matchId)
const L = []
L.push(`# 机器人象棋 比赛日志`)
L.push('')
L.push(`- 比赛:${match.title}`)
L.push(`- 红方:${match.red_bot}(${match.red_model}) / 黑方:${match.black_bot}(${match.black_model})`)
L.push(`- 赛制:${match.best_of} 局${match.best_of === 1 ? '' : `(两胜制,胜出需 ${Math.ceil(match.best_of / 2)} 局)`}`)
L.push(`- 状态:${match.status === 'finished' ? '已结束' : match.status === 'aborted' ? '已中止' : match.status === 'active' ? '进行中' : '已创建'}`)
L.push(`- 比分:红 ${match.score_red} : ${match.score_black} 黑`)
L.push(`- 结果:${match.winner === 'red' ? '红方胜' : match.winner === 'black' ? '黑方胜' : match.winner === 'draw' ? '平局' : '未定'}`)
L.push(`- 创建:${match.created_at}${match.started_at ? '' : ''}${match.finished_at ? ` / 结束:${match.finished_at}` : ''}`)
L.push('')
L.push(`## 事件流水`)
L.push('')
for (const ev of events) {
L.push(`- [${ev.created_at}] ${ev.kind === 'move' ? `手 ${ev.content}` : ev.content}`)
}
L.push('')
for (const g of games) {
L.push(`## 第 ${g.game_no} 局`)
L.push('')
const color = (Number(g.game_no) % 2 === 1)
? `红:${match.red_bot} / 黑:${match.black_bot}`
: `红:${match.black_bot} / 黑:${match.red_bot}(换先)`
L.push(`- ${color}`)
L.push(`- 状态:${g.status === 'finished' ? '已结束' : g.status === 'aborted' ? '已中止' : '进行中'};结果:${g.result || '未定'}${g.winner ? `(${g.winner === 'red' ? '红胜' : '黑胜'})` : ''}`)
L.push(`- 犯规:红 ${g.red_fouls} 次 / 黑 ${g.black_fouls} 次${g.finished_at ? ` / 结束于 ${g.finished_at}` : ''}`)
L.push('')
L.push(`| # | 手 | 着法 | 吃子 | 将军 | 着后FEN |`)
L.push(`|---|-----|------|------|-------|---------|`)
const moves = db.listMoves(g.id)
for (const m of moves) {
L.push(`| ${m.move_no} | ${m.side === 'red' ? '红' : '黑'} | ${m.san || `${m.from_sq}-${m.to_sq}`} | ${m.captured ? `吃${m.captured}` : ''} | ${m.is_check ? '将军' : ''} | ${m.fen_after} |`)
}
L.push('')
}
L.push('---')
L.push('*由 TRAE-Toolbox bot_chess lab 机械裁判生成*')
return L.join('\n')
}
// ---------------- 超时监督器(进程级,防 bot 失联卡死)----------------
let supervisorStarted = false
const ensureSupervisor = () => {
if (supervisorStarted) return
supervisorStarted = true
setInterval(() => {
try {
const cfg = readConfig()
const timeout = Math.max(30, Number(cfg.move_timeout_seconds) || 180) * 1000
const matches = db.listMatches(100)
for (const m of matches) {
if (m.status !== 'active') continue
const game = db.getGameByCur(m.id)
if (!game || game.status !== 'playing') continue
const last = db.lastMoveTime(game.id) || m.started_at
if (!last) continue
const lastMs = new Date(String(last).replace(' ', 'T') + '+08:00').getTime()
if (!Number.isFinite(lastMs)) continue
if (Date.now() - lastMs > timeout) {
const side = sideOfTurn(game.turn)
applyFoul(m.id, side, '超时未走子')
}
}
} catch (e) { /* 监督器容错,不打断主流程 */ }
}, 15000).unref()
}
module.exports = {
readConfig, SIDE_OTHER, sideOfTurn, turnOfSide, sideBotForGame,
createMatch, startMatch, submitMove, finishGame, forfeitSide, abortMatch, applyFoul,
buildSnapshot, buildMarkdownLog, ensureSupervisor
}