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