Files
Toolbox/src/server/thought_lab/labs/bot_chess/service.js
T
yangxiangyuan 3ba267b38a feat(server, thought-lab): 优化百炼API配置并新增机器人象棋实验室
- 优化服务端百炼API配置,将硬编码的API地址改为通过环境变量读取,支持自定义部署端点
- 新增机器人象棋思想实验室,包含完整对战逻辑、管理接口与前端页面,配套测试用例
- 优化思想实验室路由注册,新增公开路由绑定能力支持非浏览器客户端调用
2026-08-18 13:23:36 +08:00

365 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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
}