// ============================================================ // 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 }