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,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) })
@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>机器人象棋 · 裁判室</title>
<style>
:root { --bg:#10141a; --panel:#1a2130; --line:#2a3346; --txt:#dbe4f0; --sub:#8a97ad; --accent:#4f8cff; --red:#e0665e; --black:#7f96b8; --ok:#37c871; --warn:#e0a03c; }
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--txt); font:14px/1.6 "Microsoft YaHei", sans-serif; padding:16px; max-width:1100px; margin:0 auto; }
h1 { font-size:18px; display:flex; align-items:center; gap:10px; }
.chip { font-size:11px; padding:2px 8px; border-radius:10px; border:1px solid var(--line); color:var(--sub); }
.bar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin:12px 0; background:var(--panel); padding:10px 12px; border-radius:8px; border:1px solid var(--line);}
.bar select { background:#0d1117; color:var(--txt); border:1px solid var(--line); border-radius:6px; padding:5px 8px; }
button { background:#232d42; color:var(--txt); border:1px solid var(--line); border-radius:6px; padding:5px 12px; cursor:pointer; }
button:hover { border-color:var(--accent); }
button:disabled { opacity:.5; cursor:not-allowed; }
button.danger { border-color:var(--red); color:var(--red); }
.grid { display:grid; grid-template-columns: 1fr 1fr; gap:12px; }
@media (max-width:900px){ .grid{grid-template-columns:1fr;} }
.card { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px; display:flex; flex-direction:column; }
.card h2 { font-size:14px; margin-bottom:8px; color:var(--accent); }
.kv { display:flex; gap:8px; flex-wrap:wrap; font-size:12px; color:var(--sub); margin-bottom:6px;}
.kv b { color:var(--txt); }
pre.board { background:#0d1117; border:1px solid var(--line); border-radius:6px; padding:10px; font-size:12px; overflow:auto; white-space:pre; font-family:Consolas,monospace; flex:1; }
#chat { flex:1; min-height:300px; display:flex; flex-direction:column; gap:8px; overflow-y:auto; border:1px solid var(--line); border-radius:8px; padding:10px; background:#0d1117; }
.msg { max-width:92%; padding:8px 10px; border-radius:8px; white-space:pre-wrap; word-break:break-word; font-size:13px; }
.msg.user { align-self:flex-end; background:#234; border:1px solid var(--accent); }
.msg.ref { align-self:flex-start; background:#232d42; border:1px solid var(--line); }
.msg.tool { align-self:center; font-size:11px; color:var(--sub); background:transparent; border:1px dashed var(--line); }
.tool { background:#232d42; border:1px solid var(--line); border-radius:6px; color:var(--sub); font-size:11px; text-align:center; padding:2px 8px; align-self:center; }
.input-row { display:flex; gap:8px; margin-top:10px; }
.input-row input { flex:1; background:#0d1117; color:var(--txt); border:1px solid var(--line); border-radius:6px; padding:8px 10px; }
.modal { position:fixed; inset:0; background:rgba(0,0,0,.55); display:none; align-items:center; justify-content:center; z-index:50; }
.modal.show { display:flex; }
.modal-box { background:var(--panel); border:1px solid var(--line); border-radius:10px; max-width:560px; width:92%; max-height:80vh; overflow:auto; padding:16px; }
.modal-box h3 { font-size:15px; margin-bottom:8px; color:var(--accent); }
.modal-box pre { background:#0d1117; border:1px solid var(--line); border-radius:6px; padding:8px; font-size:12px; overflow:auto; white-space:pre-wrap; word-break:break-all; }
.modal-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:12px; }
.toast { position:fixed; left:50%; top:20px; transform:translateX(-50%); padding:8px 16px; border-radius:8px; font-size:13px; color:#fff; z-index:99; display:none; }
.toast.info { background:#2f6fd6; } .toast.ok { background:var(--ok); } .toast.err { background:var(--red); }
.rec { font-size:12px; color:var(--sub); }
</style>
</head>
<body>
<h1>♟️ 机器人象棋 · 裁判室 <span class="chip" id="modeChip">模式…</span></h1>
<div class="bar">
<span class="rec">对局:</span>
<select id="matchSel"><option value="">(选择比赛)</option></select>
<button id="btnRefresh" onclick="loadDetail()">刷新</button>
<a id="lnkLog" href="#" target="_blank"><button>导出MD日志</button></a>
<button id="btnAbort" class="danger" onclick="confirmAbort()">中止比赛</button>
<span id="scoreBox" class="rec"></span>
</div>
<div class="grid">
<div class="card">
<h2>当前局面</h2>
<div class="kv" id="kvBox">(无)</div>
<pre class="board" id="boardBox">—</pre>
</div>
<div class="card">
<h2>对话(只跟裁判说)</h2>
<div id="chat"></div>
<div class="input-row">
<input id="msgInput" placeholder="例:开始检测两个bot / 汇报一下局面 / 开始">
<button id="btnSend" onclick="sendMsg()">发送</button>
</div>
</div>
</div>
<div class="card" style="margin-top:12px;">
<h2>对局流水 <span class="chip" id="runLogStatus">等待选局</span></h2>
<pre class="board" id="runLog" style="max-height:260px;">—</pre>
</div>
<div class="modal" id="dlg"><div class="modal-box">
<h3 id="dlgTitle">交互详情</h3>
<pre id="dlgBody">-</pre>
<div class="modal-actions" id="dlgActions">
<button onclick="copyDlg()">复制</button>
<button onclick="closeDlg()">关闭</button>
</div>
</div></div>
<script>
const API = '/api/thought_lab/bot_chess'
let uiMode = 'debug'
let matches = []
let currentId = 0
// ---------- 双模式工具:Toast / 交互详情弹窗 / 危险确认 ----------
function toast(text, type) {
let el = document.querySelector('.toast')
if (!el) { el = document.createElement('div'); el.className = 'toast'; document.body.appendChild(el) }
el.textContent = text
el.className = 'toast ' + type
el.style.display = 'block'
clearTimeout(el._t)
el._t = setTimeout(() => { el.style.display = 'none' }, 2000)
}
const dlgTimer = { t: null }
function openDlg(title, body, { stay = false, danger = false } = {}) {
document.getElementById('dlgTitle').textContent = title
document.getElementById('dlgBody').textContent = body
document.getElementById('dlgActions').innerHTML = danger
? '<button class="danger" onclick="onDanger()">确认执行</button><button onclick="closeDlg()">取消</button>'
: '<button onclick="copyDlg()">复制</button><button onclick="closeDlg()">关闭</button>'
document.getElementById('dlg').classList.add('show')
clearTimeout(dlgTimer.t)
if (!stay) dlgTimer.t = setTimeout(closeDlg, uiMode === 'debug' ? 3000 : 2000)
}
function closeDlg() { document.getElementById('dlg').classList.remove('show'); clearTimeout(dlgTimer.t) }
function copyDlg() { const t = document.getElementById('dlgBody').textContent; (navigator.clipboard ? navigator.clipboard.writeText(t) : Promise.reject()).then(() => toast('已复制', 'ok')).catch(() => { prompt('复制内容:', t) }) }
let onDanger = () => {}
function confirmDanger(title, body, fn) { onDanger = fn; openDlg(title, body, { stay: true, danger: true }) }
// 异步交互包装:debug→详情报错弹窗,normal→成功 Toast、失败强弹窗
async function tracked(name, fn, { silent = false } = {}) {
const t0 = Date.now()
try {
const data = await fn()
const line = `触发:${name}\n时间:${new Date().toLocaleTimeString('zh-CN')}\n接口:${data._url || ''}\n请求:${data._req || ''}\n返回:${JSON.stringify(data._body || data)?.slice(0, 800)}\n耗时:${Date.now() - t0}ms`
if (uiMode === 'debug') { localDlg = line; openDlg(`交互详情 · ${name}`, line, { stay: false }) } else if (!silent) { toast('操作成功', 'ok') }
return data
} catch (e) {
const line = `触发:${name}\n时间:${new Date().toLocaleTimeString('zh-CN')}\n错误:${e.message || e}\n耗时:${Date.now() - t0}ms`
openDlg(`出错 · ${name}`, line, { stay: true })
return null
}
}
let localDlg = ''
// fetch 封装:记录 url / 请求体重用
const j = async (url, opts = {}) => {
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {})
const resp = await fetch(url, Object.assign({ headers }, opts))
let body = null
try { body = await resp.json() } catch (e) { body = null }
if (!resp.ok) throw new Error((body && body.error) || body && body.code || ('HTTP ' + resp.status))
return body
}
// ---------- 主流程 ----------
async function init() {
const meta = await j(API + '/meta').catch(() => ({ ui_mode: 'debug' }))
uiMode = meta.ui_mode || 'debug'
document.getElementById('modeChip').textContent = '模式 ' + (uiMode === 'debug' ? '研发调试' : '日常使用')
loadMatches()
setTimeout(loadDetail, 200)
// 页面可见时自动刷新;对局流水每 5s 增量拉取
setInterval(() => { if (document.visibilityState === 'visible') loadMatches(true) }, 20000)
setInterval(pollRunLog, 5000)
}
async function loadMatches(silent) {
const data = await j(API + '/matches').catch(() => null)
if (!data) return
matches = data.matches || []
const sel = document.getElementById('matchSel')
const prev = sel.value
sel.innerHTML = '<option value="">(选择比赛)</option>' + matches.map(m =>
`<option value="${m.id}">#${m.id} ${m.title}|${m.red_bot} vs ${m.black_bot}(${m.score_red}:${m.score_black},${m.status})</option>`
).join('')
if (prev) sel.value = prev
if (!prev && matches.length) sel.value = String(matches[0].id)
currentId = Number(sel.value || 0)
if (!silent) loadDetail()
}
async function loadDetail() {
currentId = Number(document.getElementById('matchSel').value || 0)
loadRunLogAll()
if (!currentId) { document.getElementById('boardBox').textContent = '请先创建/选择比赛'; document.getElementById('kvBox').textContent = '(无)'; return }
const s = await j(API + '/matches/' + currentId).catch(() => null)
if (!s) return
const m = s.match, g = s.game
const kv = []
kv.push(`<b>比分</b> ${m.red_bot} ${m.score_red}:${m.score_black} ${m.black_bot}`)
kv.push(`<b>赛制</b> ${m.best_of}局`)
kv.push(`<b>状态</b> ${m.status}`)
kv.push(`<b>模型</b> 红 ${m.red_model} / 黑 ${m.black_model}`)
if (m.winner) kv.push(`<b>结果</b> ${m.winner === 'red' ? '红胜' : m.winner === 'black' ? '黑胜' : '平局'}`)
if (g) {
kv.push(`<b>第${g.game_no}局</b> ${g.status}`)
kv.push(`<b>轮到</b> ${g.turn === 'b' ? '黑方' : '红方'}`)
kv.push(`<b>手数</b> ${g.move_count}`)
kv.push(`<b>犯规</b> 红${g.red_fouls}/黑${g.black_fouls}`)
}
document.getElementById('kvBox').innerHTML = kv.join(' ')
document.getElementById('boardBox').textContent = s.board ? s.board.ascii + '\n\nFEN: ' + s.board.fen + (g ? '' : '') : '(当前无局面)'
document.getElementById('lnkLog').href = API + '/matches/' + currentId + '/log.md'
}
document.getElementById('matchSel').addEventListener('change', loadDetail)
// 创建比赛
async function createMatch() {
await tracked('创建比赛', async () => {
const title = prompt('比赛名称', '模型对战')
if (title === null) throw new Error('已取消')
const rb = prompt('红方 bot 名称', '红方Bot')
const bb = prompt('黑方 bot 名称', '黑方Bot')
const bo = prompt('局数(三局两胜=3)', '3')
const d = await j(API + '/matches', { method: 'POST', body: JSON.stringify({ title, red_bot: rb, black_bot: bb, best_of: Number(bo) || 3 }) })
currentId = d.match_id
if (uiMode === 'debug') toast(`已创建 #${d.match_id},red_token 已返回(调试可见)`, 'ok')
loadMatches()
return { _url: 'POST /matches', _req: JSON.stringify({ title, red_bot: rb, black_bot: bb, best_of: bo }), _body: d }
})
}
// ---------- 对局流水(滚动日志)----------
let runLogLastId = 0
let runLogAuto = true // 是否自动跟随滚动到最新
function runLogLine(ev) {
const t = String(ev.created_at || '').slice(11, 19)
const tag = { move: '走子', foul: '犯规', game_started: '开赛', game_finished: '终局', match_created: '建赛', match_finished: '结算', start: '开始', abort: '中止', note: '备注' }[ev.kind] || ev.kind
const side = ev.side === 'red' ? '红' : ev.side === 'black' ? '黑' : ''
return `[${t}] [${tag}]${side ? '(' + side + ')' : ''} ${ev.content}`
}
function appendRunLog(lines) {
const box = document.getElementById('runLog')
if (!lines || !lines.length) return
const atBottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 20
let text = box.textContent === '—' ? '' : box.textContent
const arr = text ? text.split('\n') : []
for (const l of lines) arr.push(l)
box.textContent = arr.slice(-500).join('\n')
if (runLogAuto || atBottom) box.scrollTop = box.scrollHeight
document.getElementById('runLogStatus').textContent = `共 ${arr.length} 行`
}
async function loadRunLogAll() {
runLogLastId = 0
document.getElementById('runLog').textContent = '—'
if (!currentId) { document.getElementById('runLogStatus').textContent = '等待选局'; return }
const d = await j(API + `/matches/${currentId}/events?all=1`).catch(() => null)
if (!d || !d.events) return
appendRunLog(d.events.map(runLogLine))
runLogLastId = d.events.length ? d.events[d.events.length - 1].id : 0
}
async function pollRunLog() {
if (!currentId) return
const d = await j(API + `/matches/${currentId}/events?after=${runLogLastId}`).catch(() => null)
if (!d || !d.events || !d.events.length) return
appendRunLog(d.events.map(runLogLine))
runLogLastId = d.events[d.events.length - 1].id
// 比赛收关后静默停(日志已全量保留在页面)
const sEl = document.getElementById('runLogStatus')
if (sEl && d.events.some(e => e.kind === 'match_finished' || e.kind === 'abort')) sEl.textContent += ' · 已结束'
}
function appendMsg(role, text) {
const c = document.getElementById('chat')
const d = document.createElement('div')
d.className = 'msg ' + role
d.textContent = text
c.appendChild(d)
c.scrollTop = c.scrollHeight
return d
}
async function sendMsg() {
const input = document.getElementById('msgInput')
const text = input.value.trim()
if (!text) return
const btn = document.getElementById('btnSend')
btn.disabled = true; btn.textContent = '发送中…'
appendMsg('user', text)
input.value = ''
try {
const d = await tracked('裁判对话', () => j(API + '/chat', { method: 'POST', body: JSON.stringify({ message: text }) }), { silent: true })
if (!d) { appendMsg('tool', '(裁判接口出错,见弹窗)') }
else {
for (const ev of (d.tool_events || [])) appendMsg('tool', `[工具] ${ev.name} → ${String(ev.summary).slice(0, 120)}`)
appendMsg('ref', d.reply || '…')
if (d.match_id) { setTimeout(loadMatches, 500) }
}
} catch (e) { appendMsg('tool', '(发送失败:' + e.message + ')') }
btn.disabled = false; btn.textContent = '发送'
}
document.getElementById('msgInput').addEventListener('keydown', e => { if (e.key === 'Enter') sendMsg() })
// ---------- 危险操作(双模式均用确认弹窗)----------
function confirmAbort() {
if (!currentId) return toast('请先选择比赛', 'info')
confirmDanger('中止比赛', `确定中止 #${currentId} 比赛吗?(不可恢复,本场判中止)`, async () => {
await tracked('中止比赛', () => j(API + '/matches/' + currentId + '/abort', { method: 'POST', body: JSON.stringify({}) }))
closeDlg(); loadMatches()
})
}
init()
</script>
</body>
</html>
+3 -2
View File
@@ -95,8 +95,9 @@ const bindRoutes = (app) => {
if (!text) return res.status(400).json({ ok: false, error: 'No text provided' }) if (!text) return res.status(400).json({ ok: false, error: 'No text provided' })
const apiKey = process.env.DASHSCOPE_API_KEY || '' const apiKey = process.env.DASHSCOPE_API_KEY || ''
// 使用 OpenAI 兼容接口调用 DeepSeek-V3 // 使用 OpenAI 兼容接口调用百炼大模型(前缀取环境变量,见 ~/Toolbox_local_creds.env.local)
const url = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions' const dashScopeHost = process.env.DASHSCOPE_API_HOST_FOR_OPENAI || ''
const url = dashScopeHost ? `${dashScopeHost.replace(/\/+$/, '')}/chat/completions` : ''
const systemPrompt = `# Role const systemPrompt = `# Role
你是一名资深的时间管理专家和高效秘书,擅长从冗杂的口语录音中快速提炼核心日程主题。 你是一名资深的时间管理专家和高效秘书,擅长从冗杂的口语录音中快速提炼核心日程主题。
+1 -1
View File
@@ -192,7 +192,7 @@ const getAiConfig = () => {
return { return {
enabled: c.enabled !== false, enabled: c.enabled !== false,
apiKey: c.api_key || '', apiKey: c.api_key || '',
apiBase: c.api_base || 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', apiBase: c.api_base || (process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : ''),
model: c.model || 'qwen-plus-latest' model: c.model || 'qwen-plus-latest'
} }
} catch { } catch {
+1 -1
View File
@@ -15,7 +15,7 @@ const DEFAULT_IMAGE_DIR = path.join(DEFAULT_UPLOAD_DIR, 'images')
const DEFAULT_ATTACHMENT_DIR = path.join(DEFAULT_UPLOAD_DIR, 'attachments') const DEFAULT_ATTACHMENT_DIR = path.join(DEFAULT_UPLOAD_DIR, 'attachments')
const DEFAULT_SEED_HOME_DIR = path.join(DEFAULT_UPLOAD_DIR, 'seed', 'home') const DEFAULT_SEED_HOME_DIR = path.join(DEFAULT_UPLOAD_DIR, 'seed', 'home')
const QW_BASE = 'https://devapi.qweather.com' const QW_BASE = 'https://devapi.qweather.com'
const DASHSCOPE_BASE = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions' const DASHSCOPE_BASE = process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : ''
const BJ_TZ = 'Asia/Shanghai' const BJ_TZ = 'Asia/Shanghai'
const weatherCache = { const weatherCache = {
payload: null, payload: null,
+3 -3
View File
@@ -11,7 +11,7 @@ if (!fs.existsSync(SECURITY_CALENDAR_AI_CFG_PATH)) {
fs.writeFileSync(SECURITY_CALENDAR_AI_CFG_PATH, JSON.stringify({ fs.writeFileSync(SECURITY_CALENDAR_AI_CFG_PATH, JSON.stringify({
enabled: false, enabled: false,
api_key: '', api_key: '',
api_base: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', api_base: process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : '',
model: 'qwen-plus-latest', model: 'qwen-plus-latest',
window_days: 21, window_days: 21,
auto_verify_enabled: true, auto_verify_enabled: true,
@@ -476,7 +476,7 @@ function loadSecurityCalendarAiConfig() {
return { return {
enabled: cfg && cfg.enabled !== false, enabled: cfg && cfg.enabled !== false,
apiKey: String((cfg && cfg.api_key) || '').trim(), apiKey: String((cfg && cfg.api_key) || '').trim(),
apiBase: String((cfg && cfg.api_base) || 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').trim(), apiBase: String((cfg && cfg.api_base) || (process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : '')).trim(),
model: String((cfg && cfg.model) || 'qwen-plus-latest').trim(), model: String((cfg && cfg.model) || 'qwen-plus-latest').trim(),
windowDays: Math.max(7, Math.min(21, parseInt(String((cfg && cfg.window_days) || '21'), 10) || 21)), windowDays: Math.max(7, Math.min(21, parseInt(String((cfg && cfg.window_days) || '21'), 10) || 21)),
autoVerifyEnabled: cfg && cfg.auto_verify_enabled !== false, autoVerifyEnabled: cfg && cfg.auto_verify_enabled !== false,
@@ -488,7 +488,7 @@ function loadSecurityCalendarAiConfig() {
return { return {
enabled: false, enabled: false,
apiKey: '', apiKey: '',
apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', apiBase: process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : '',
model: 'qwen-plus-latest', model: 'qwen-plus-latest',
windowDays: 21, windowDays: 21,
autoVerifyEnabled: false, autoVerifyEnabled: false,
+2 -2
View File
@@ -27,7 +27,7 @@ if (!fs.existsSync(configPath)) {
ai: { ai: {
enabled: true, enabled: true,
api_key: '', api_key: '',
api_base: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', api_base: process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : '',
vision_model: 'qwen-vl-plus', vision_model: 'qwen-vl-plus',
timeout_ms: 30000 timeout_ms: 30000
} }
@@ -1495,7 +1495,7 @@ const getAiConfig = () => {
return { return {
enabled: ai.enabled !== false, enabled: ai.enabled !== false,
apiKey: String(ai.api_key || envKey || '').trim(), apiKey: String(ai.api_key || envKey || '').trim(),
apiBase: String(ai.api_base || 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions').trim(), apiBase: String(ai.api_base || (process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : '')).trim(),
visionModel: String(ai.vision_model || 'qwen-vl-plus').trim(), visionModel: String(ai.vision_model || 'qwen-vl-plus').trim(),
timeoutMs: Math.max(5000, Math.min(90000, Number(ai.timeout_ms || 30000))) timeoutMs: Math.max(5000, Math.min(90000, Number(ai.timeout_ms || 30000)))
} }
+23
View File
@@ -142,6 +142,11 @@ const bindRoutes = (app) => {
} }
}) })
// 4.5) 需要被「非浏览器客户端」直接访问的 lab 公开路由
// (必须在导航鉴权守卫之前注册;使用方必须自行做 token/身份校验,
// 如 bot_chess 的红黑 bot token;普通 lab 无需实现 bindPublicRoutes)
bindLabPublicBackends(app)
// 4) 其余 /api/thought_lab 接口:默认导航鉴权 // 4) 其余 /api/thought_lab 接口:默认导航鉴权
app.use('/api/thought_lab', navAuthApi) app.use('/api/thought_lab', navAuthApi)
@@ -150,6 +155,24 @@ const bindRoutes = (app) => {
bindLabBackends(app) bindLabBackends(app)
} }
const bindLabPublicBackends = (app) => {
try {
if (!fs.existsSync(LABS_DIR)) return
const dirs = fs.readdirSync(LABS_DIR, { withFileTypes: true })
for (const d of dirs) {
if (!d.isDirectory()) continue
const entry = path.join(LABS_DIR, d.name, 'index.js')
if (!fs.existsSync(entry)) continue
try {
const mod = require(entry)
if (mod && typeof mod.bindPublicRoutes === 'function') mod.bindPublicRoutes(app)
} catch (e) {
try { console.error(`[thought_lab] 绑定 lab 公开路由失败: ${d.name} ${String(e.message || e)}`) } catch {}
}
}
} catch {}
}
const bindLabBackends = (app) => { const bindLabBackends = (app) => {
try { try {
if (!fs.existsSync(LABS_DIR)) return if (!fs.existsSync(LABS_DIR)) return
Binary file not shown.
@@ -0,0 +1,49 @@
{
"_comment": "思想实验室 · 机器人象棋(bot_chess):三角色框架——裁判 LLM 主持(人只跟裁判对话),红/黑两个大模型 bot 通过 API 拿局面、提交走子,规则引擎 referee.js 机械化裁决(蹩马腿/塞象眼/炮架/将帅照面/送将/将军/将死/困毙/和棋),比赛三局两胜自动换先,全程落 SQLite 并可导出 MD 日志。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "bot_chess",
"name_comment": "首页卡片显示名称",
"name": "机器人象棋",
"icon_comment": "首页卡片图标(emoji)",
"icon": "♟️",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "裁判LLM主持 · 双模型红黑对弈 · 机械裁判API",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 40,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true,
"ui_mode_comment": "弹窗交互模式:debug=交互详情弹窗(3s 自动关闭+复制);normal=成功降级 Toast(2s),失败仍弹窗",
"ui_mode": "debug",
"referee_model_comment": "裁判 LLM 使用的百炼模型(当前为你的老账号 qwen3.8-max;示例默认 qwen-plus-latest)",
"referee_model": "qwen3.8-max",
"red_model_comment": "红方 bot 使用的百炼模型(deepseek-v4-flash-0731)",
"red_model": "deepseek-v4-flash-0731",
"black_model_comment": "黑方 bot 使用的百炼模型(qwen3.6-plus)",
"black_model": "qwen3.6-plus",
"api_base_comment": "百炼 OpenAI 兼容端点(无需改动)",
"api_base": "https://llm-hvo2xbwhxuk6p73d.cn-beijing.maas.aliyuncs.com/compatible-mode/v1/chat/completions",
"chat_base_url_comment": "bot driver 调本 lab API 的自回环地址:优先用此 HTTP 路径(与外部 bot 同一协议),连不上自动走进程内内存兜底,不会因端口对不上而卡死;内网/本机默认 http://localhost:8976",
"chat_base_url": "http://localhost:8976",
"best_of_comment": "默认比赛局数(三局两胜=3,奇数便于结算;1 表示单局)",
"best_of": 3,
"move_timeout_seconds_comment": "单步超时秒数:轮到一方超过该时长未落子记 1 次犯规",
"move_timeout_seconds": 180,
"foul_limit_comment": "同一方累计犯规达到该次数,本局判负(对局中非法走子/超时/无回复都算犯规)",
"foul_limit": 3,
"illegal_retry_comment": "单次走子非法后,bot 允许在该步内重想的次数(超出即记犯规并轮到对方,由监督器判定)",
"illegal_retry": 2,
"llm_timeout_ms_comment": "模型单次调用超时(毫秒):应大于 move_timeout_seconds,避免大模型(如 qwen3.6-plus)长思考被过早掐断,让超时判罚统一交给监督器",
"llm_timeout_ms": 240000,
"bot_use_tools_comment": "true=让 bot 用工具调用(get_status/submit_move)走子(更还原外部bot协议但更慢);false=局面直接塞进提示词,模型只回一行着法,driver 代为提交(更快更稳,推荐)",
"bot_use_tools": false,
"bot_prompt_ascii_comment": "true=给 bot 的提示词附带 ASCII 棋盘(信息全但 token 大、更慢);false=只给 FEN+双方棋子清单(省 token,推荐)",
"bot_prompt_ascii": false,
"draw_ply_limit_comment": "自然限着:连续 N 手无吃子且无进兵判和(不失子残局防死循环)",
"draw_ply_limit": 120,
"repetition_required_comment": "同一局面出现达到该次数自动判和(三次重复规则)",
"repetition_required": 3,
"enable_bot_auth_comment": "true:bot API 必须携带比赛发放的 Bearer token;false:本机调试直接放开(仅建议内网临时用)",
"enable_bot_auth": true,
"referee_token_comment": "裁判编排 token(管理接口/聊天用):留空时自动用环境变量 BOT_CHESS_REFEREE_TOKEN;两个都为空且 enable_bot_auth=true 时管理接口仅允许浏览器会话访问。生产勿硬编码,用 ~/Toolbox_local_creds.env.local 注入。",
"referee_token": ""
}
+279
View File
@@ -0,0 +1,279 @@
// ============================================================
// bot_chess/db.js —— 中国象棋比赛 SQLite 持久化(lab 独立,不共享其他模块)
//
// 数据库文件:本文件夹下 bot_chess.db(WAL 模式,better-sqlite3)
// 表:
// bot_chess_matches 比赛:标题、红/黑 bot 与模型、局数设计(best_of)、
// 比分、状态、红黑 token(供 bot 调 API 鉴权)、时间
// bot_chess_games 局:局号、当前 FEN、轮到谁、结果、双方犯规计数、
// 自然限着计数(无吃子回合)
// bot_chess_moves 步:本步记录(前后 FEN、是否将军、吃过什么)
// bot_chess_events 事件流:比赛/对局/走子/犯规/终局/聊天等(供 MD 日志与裁判叙事)
// 时间字段统一北京时间(Asia/Shanghai)
// ============================================================
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const DB_PATH = path.join(__dirname, 'bot_chess.db')
let db = null
// 北京时间格式化:YYYY-MM-DD HH:mm:ss
const BJ_FMT = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
hour12: false,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
})
const nowBeijing = () => {
const parts = BJ_FMT.formatToParts(new Date())
const get = t => (parts.find(p => p.type === t) || {}).value || ''
return `${get('year')}-${get('month')}-${get('day')} ${get('hour')}:${get('minute')}:${get('second')}`
}
const randomToken = (len = 32) => crypto.randomBytes(len).toString('hex')
const getDB = () => {
if (db) return db
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
initSchema(db)
return db
}
const initSchema = (database) => {
database.exec(`
CREATE TABLE IF NOT EXISTS bot_chess_matches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
red_bot TEXT NOT NULL DEFAULT '',
black_bot TEXT NOT NULL DEFAULT '',
red_model TEXT NOT NULL DEFAULT '',
black_model TEXT NOT NULL DEFAULT '',
best_of INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL DEFAULT 'created', -- created | active | finished | aborted
score_red INTEGER NOT NULL DEFAULT 0,
score_black INTEGER NOT NULL DEFAULT 0,
winner TEXT NOT NULL DEFAULT '', -- red | black | ''(未定)| draw
red_token TEXT NOT NULL DEFAULT '',
black_token TEXT NOT NULL DEFAULT '',
current_game_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT '',
finished_at TEXT NOT NULL DEFAULT ''
)
`)
database.exec(`
CREATE TABLE IF NOT EXISTS bot_chess_games (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id INTEGER NOT NULL,
game_no INTEGER NOT NULL, -- 第几局(1 起)
fen TEXT NOT NULL, -- 当前 FEN
turn TEXT NOT NULL DEFAULT 'w', -- 当前轮到 w=红 b=黑
status TEXT NOT NULL DEFAULT 'playing', -- playing | finished | aborted
result TEXT NOT NULL DEFAULT '', -- red_win | black_win | draw | forfeit_red | forfeit_black
winner TEXT NOT NULL DEFAULT '',
move_count INTEGER NOT NULL DEFAULT 0,
red_fouls INTEGER NOT NULL DEFAULT 0,
black_fouls INTEGER NOT NULL DEFAULT 0,
no_capture_plies INTEGER NOT NULL DEFAULT 0, -- 自然限着(无吃子/无进兵)计数
created_at TEXT NOT NULL,
finished_at TEXT NOT NULL DEFAULT ''
)
`)
database.exec(`
CREATE TABLE IF NOT EXISTS bot_chess_moves (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id INTEGER NOT NULL,
game_id INTEGER NOT NULL,
move_no INTEGER NOT NULL,
side TEXT NOT NULL, -- red | black
from_sq TEXT NOT NULL,
to_sq TEXT NOT NULL,
piece TEXT NOT NULL DEFAULT '',
captured TEXT NOT NULL DEFAULT '',
is_check INTEGER NOT NULL DEFAULT 0,
fen_before TEXT NOT NULL,
fen_after TEXT NOT NULL,
san TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)
`)
database.exec(`
CREATE TABLE IF NOT EXISTS bot_chess_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
match_id INTEGER NOT NULL,
game_id INTEGER NOT NULL DEFAULT 0,
kind TEXT NOT NULL, -- match_created | start | game_started | move | foul |
-- game_finished | match_finished | abort | forfeit | chat | note
side TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)
`)
database.exec(`CREATE INDEX IF NOT EXISTS idx_bc_games_match ON bot_chess_games(match_id, game_no)`)
database.exec(`CREATE INDEX IF NOT EXISTS idx_bc_moves_game ON bot_chess_moves(game_id, move_no)`)
database.exec(`CREATE INDEX IF NOT EXISTS idx_bc_events_match ON bot_chess_events(match_id)`)
}
// ---------------- matches ----------------
const rowMatch = (row) => row || null
const createMatch = ({ title, red_bot, black_bot, red_model, black_model, best_of }) => {
const now = nowBeijing()
const r = getDB().prepare(`
INSERT INTO bot_chess_matches
(title, red_bot, black_bot, red_model, black_model, best_of,
status, red_token, black_token, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'created', ?, ?, ?)
`).run(
String(title || '').trim() || '电脑对战',
red_bot, black_bot, red_model, black_model,
Math.max(1, Math.min(9, Number(best_of) || 3)),
randomToken(), randomToken(), now
)
return getMatch(Number(r.lastInsertRowid))
}
const getMatch = (id) => rowMatch(getDB().prepare(`SELECT * FROM bot_chess_matches WHERE id = ?`).get(id))
const listMatches = (limit = 50) => getDB().prepare(`
SELECT * FROM bot_chess_matches ORDER BY id DESC LIMIT ?
`).all(limit).map(rowMatch)
const updateMatchStatus = (id, { status, winner, score_red, score_black, finished_at, started_at, current_game_id }) => {
const sets = []
const vals = []
if (status !== undefined) { sets.push('status = ?'); vals.push(status) }
if (winner !== undefined) { sets.push('winner = ?'); vals.push(winner) }
if (score_red !== undefined) { sets.push('score_red = ?'); vals.push(score_red) }
if (score_black !== undefined) { sets.push('score_black = ?'); vals.push(score_black) }
if (finished_at !== undefined) { sets.push('finished_at = ?'); vals.push(finished_at) }
if (started_at !== undefined) { sets.push('started_at = ?'); vals.push(started_at) }
if (current_game_id !== undefined) { sets.push('current_game_id = ?'); vals.push(current_game_id) }
if (!sets.length) return getMatch(id)
vals.push(id)
getDB().prepare(`UPDATE bot_chess_matches SET ${sets.join(', ')} WHERE id = ?`).run(...vals)
return getMatch(id)
}
const deleteMatch = (id) => {
const tx = getDB().transaction(() => {
getDB().prepare(`DELETE FROM bot_chess_moves WHERE match_id = ?`).run(id)
getDB().prepare(`DELETE FROM bot_chess_events WHERE match_id = ?`).run(id)
getDB().prepare(`DELETE FROM bot_chess_games WHERE match_id = ?`).run(id)
getDB().prepare(`DELETE FROM bot_chess_matches WHERE id = ?`).run(id)
})
tx()
return true
}
// 根据 bot token 找到它所属的比赛(用于 /state、/moves 接口鉴权与身份绑定)
const findMatchByToken = (token) => {
if (!token) return null
const row = getDB().prepare(`
SELECT * FROM bot_chess_matches WHERE red_token = ? OR black_token = ?
`).get(token, token)
if (!row) return null
const side = row.red_token === token ? 'red' : 'black'
return { match: rowMatch(row), side }
}
// ---------------- games ----------------
const rowGame = (row) => row || null
const createGame = (matchId, gameNo) => {
const now = nowBeijing()
const r = getDB().prepare(`
INSERT INTO bot_chess_games (match_id, game_no, fen, turn, status, created_at)
VALUES (?, ?, ?, 'w', 'playing', ?)
`).run(matchId, gameNo, require('./referee').START_FEN, now)
return getGame(Number(r.lastInsertRowid))
}
const getGame = (id) => rowGame(getDB().prepare(`SELECT * FROM bot_chess_games WHERE id = ?`).get(id))
const getGamesByMatch = (matchId) => getDB().prepare(`
SELECT * FROM bot_chess_games WHERE match_id = ? ORDER BY game_no ASC
`).all(matchId).map(rowGame)
const updateGame = (id, patch) => {
const sets = []
const vals = []
const keys = ['fen', 'turn', 'status', 'result', 'winner', 'move_count',
'red_fouls', 'black_fouls', 'no_capture_plies', 'finished_at']
for (const k of keys) {
if (patch[k] !== undefined) { sets.push(`${k} = ?`); vals.push(patch[k]) }
}
if (!sets.length) return getGame(id)
vals.push(id)
getDB().prepare(`UPDATE bot_chess_games SET ${sets.join(', ')} WHERE id = ?`).run(...vals)
return getGame(id)
}
const addFoul = (gameId, side, reason) => {
const g = getGame(gameId)
if (!g) return null
const key = side === 'red' ? 'red_fouls' : 'black_fouls'
const n = (g[key] || 0) + 1
return updateGame(gameId, { [key]: n })
}
// ---------------- moves ----------------
const getGameByCur = (matchId) => {
const m = getMatch(matchId)
if (!m || !m.current_game_id) return null
return getGame(m.current_game_id)
}
const rowMove = (row) => row || null
const addMove = ({ match_id, game_id, move_no, side, from_sq, to_sq, piece, captured, is_check, fen_before, fen_after, san }) => {
const now = nowBeijing()
const r = getDB().prepare(`
INSERT INTO bot_chess_moves
(match_id, game_id, move_no, side, from_sq, to_sq, piece, captured, is_check, fen_before, fen_after, san, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(match_id, game_id, move_no, side, from_sq, to_sq, piece, captured, is_check ? 1 : 0, fen_before, fen_after, san, now)
return rowMove(getDB().prepare(`SELECT * FROM bot_chess_moves WHERE id = ?`).get(Number(r.lastInsertRowid)))
}
const listMoves = (gameId, limit = 1000) => getDB().prepare(`
SELECT * FROM bot_chess_moves WHERE game_id = ? ORDER BY move_no ASC LIMIT ?
`).all(gameId, limit).map(rowMove)
// 某局的最后一次走子时间(超时监督用)
const lastMoveTime = (gameId) => {
const row = getDB().prepare(`
SELECT created_at FROM bot_chess_moves WHERE game_id = ? ORDER BY id DESC LIMIT 1
`).get(gameId)
return row ? row.created_at : ''
}
// ---------------- events ----------------
const rowEvent = (row) => row || null
const addEvent = ({ match_id, game_id = 0, kind, side = '', content = '' }) => {
const now = nowBeijing()
const r = getDB().prepare(`
INSERT INTO bot_chess_events (match_id, game_id, kind, side, content, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(match_id, game_id, kind, side, content, now)
return rowEvent(getDB().prepare(`SELECT * FROM bot_chess_events WHERE id = ?`).get(Number(r.lastInsertRowid)))
}
const listEvents = (matchId, gameId = 0, limit = 500) => {
if (gameId) {
return getDB().prepare(`
SELECT * FROM bot_chess_events WHERE match_id = ? AND (game_id = ? OR kind IN ('match_created','start','match_finished','abort'))
ORDER BY id ASC LIMIT ?
`).all(matchId, gameId, limit).map(rowEvent)
}
return getDB().prepare(`SELECT * FROM bot_chess_events WHERE match_id = ? ORDER BY id ASC LIMIT ?`)
.all(matchId, limit).map(rowEvent)
}
// 增量拉取事件流(前端“滚动日志”轮询用):只返回 id > after 的事件
const listEventsAfter = (matchId, afterId = 0, limit = 200) => getDB().prepare(`
SELECT id, match_id, game_id, kind, side, content, created_at
FROM bot_chess_events
WHERE match_id = ? AND id > ?
ORDER BY id ASC LIMIT ?
`).all(matchId, Number(afterId) || 0, limit).map(rowEvent)
module.exports = {
nowBeijing, randomToken,
createMatch, getMatch, listMatches, updateMatchStatus, deleteMatch, findMatchByToken,
createGame, getGame, getGamesByMatch, updateGame, addFoul, getGameByCur,
addMove, listMoves, lastMoveTime,
addEvent, listEvents, listEventsAfter
}
@@ -0,0 +1,261 @@
// ============================================================
// bot_chess/index.js —— HTTP API 路由(核心对局 API + 裁判聊天 API)
//
// 鉴权设计(本 lab 需要被“非浏览器”的 bot 客户端访问,因此通过
// thought_lab 预留的 bindPublicRoutes 在导航鉴权守卫之前注册):
// - 全局导航鉴权关闭时:全部放行(与容器行为一致)
// - 浏览器会话(nav_gate cookie 或同源 Referer):放行(聊天页用)
// - Bearer token:匹配 referee_token(管理/聊天)或比赛红黑 token(bot)
// 非法走子一律返回结构化错误码,局面不动;犯规判罚由监督器/编排器执行,
// 本层只做“可靠裁判”,不做惩罚。
// ============================================================
const fs = require('fs')
const path = require('path')
const express = require('express')
const service = require('./service')
const db = require('./db')
const r = require('./referee')
const B = '/api/thought_lab/bot_chess'
const CONFIG_PATH = path.join(__dirname, 'config.json')
const FLAGS_PATH = path.join(process.cwd(), 'config', 'flags.json')
const NAV_AUTH_COOKIE = 'nav_gate'
const readConfig = () => {
try {
if (fs.existsSync(CONFIG_PATH)) return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'))
} catch {}
return {}
}
const isNavAuthEnabled = () => {
try {
const f = JSON.parse(fs.readFileSync(FLAGS_PATH, 'utf-8'))
return (f && f.navAuth && f.navAuth.enable_auth) !== false
} catch { return true }
}
const parseCookie = (raw) => {
const out = {}
try {
String(raw || '').split(';').forEach(item => {
const idx = item.indexOf('=')
if (idx < 1) return
out[item.slice(0, idx).trim()] = item.slice(idx + 1).trim()
})
} catch {}
return out
}
const refereeToken = () => String(process.env.BOT_CHESS_REFEREE_TOKEN || readConfig().referee_token || '')
const bearer = (req) => {
const h = String(req.headers.authorization || '')
if (/^Bearer\s+/i.test(h)) return h.replace(/^Bearer\s+/i, '').trim()
return ''
}
const sameOrigin = (req) => {
try {
const referer = String(req.headers.referer || '')
const host = String(req.headers.host || '')
return !!referer && !!host && new URL(referer).host === host
} catch { return false }
}
const isBrowser = (req) => parseCookie(req.headers.cookie || '')[NAV_AUTH_COOKIE] === '1' || sameOrigin(req)
// 管理接口鉴权:浏览器 / referee token
const adminAuth = (req, res, next) => {
if (!isNavAuthEnabled() || isBrowser(req)) return next()
if (refereeToken() && bearer(req) === refereeToken()) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
}
// bot 接口鉴权:浏览器(调试)/ 比赛 token(绑定红/黑身份)
const botAuth = (req, res, next) => {
if (!isNavAuthEnabled() || isBrowser(req)) { req.bot = { side: String(req.query.side || req.body.side || '') }; return next() }
const cfg = readConfig()
if (cfg.enable_bot_auth === false) { req.bot = { side: String(req.query.side || '') }; return next() }
const t = bearer(req)
const found = t ? db.findMatchByToken(t) : null
if (!found) return res.status(401).json({ ok: false, error: 'unauthorized' })
req.bot = { matchId: found.match.id, side: found.side, token: t }
req.bot.forceSide = String(req.query.side || '') || found.side
next()
}
const ok = (res, data) => res.json(Object.assign({ ok: true }, data))
const fail = (res, status, code, error) => res.status(status).json({ ok: false, code, error })
// ---------------- 路由注册(守卫之前,lab 公开路由)----------------
const bindPublicRoutes = (app) => {
const router = express.Router()
service.ensureSupervisor() // 超时判罚监督器(进程级,仅启动一次)
// 元信息(前端弹窗双模式依据)
router.get('/meta', (req, res) => ok(res, { ui_mode: String(readConfig().ui_mode || 'debug') }))
// ---- 管理接口 ----
router.get('/matches', adminAuth, (req, res) => {
try {
const list = db.listMatches(60).map(m => ({
id: m.id, title: m.title, red_bot: m.red_bot, black_bot: m.black_bot,
best_of: m.best_of, status: m.status, score_red: m.score_red, score_black: m.score_black,
winner: m.winner, created_at: m.created_at, current_game_no: (service.buildSnapshot(m.id) || {}).match ? service.buildSnapshot(m.id).match.current_game_no : 0
}))
return ok(res, { matches: list })
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
// 创建比赛(返回快照 + 红黑 token——token 只在本响应出现,后续不再暴露)
router.post('/matches', adminAuth, (req, res) => {
try {
const body = req.body || {}
if (!String(body.title || '').trim() && !String(body.red_bot || '').trim() && !String(body.black_bot || '').trim()) {
return fail(res, 400, 'BAD_REQUEST', 'title / red_bot / black_bot 至少提供一项')
}
const snap = service.createMatch(body)
const m = db.getMatch(snap.match.id)
return ok(res, {
match_id: snap.match.id,
red_token: m.red_token,
black_token: m.black_token,
snapshot: snap
})
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
router.get('/matches/:id', adminAuth, (req, res) => {
try {
const snap = service.buildSnapshot(Number(req.params.id))
if (!snap) return fail(res, 404, 'NOT_FOUND', '比赛不存在')
return ok(res, snap)
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
router.get('/matches/:id/state', botAuth, (req, res) => {
try {
const mid = req.bot && req.bot.matchId ? Number(req.bot.matchId) : Number(req.params.id)
const snap = service.buildSnapshot(mid)
if (!snap) return fail(res, 404, 'NOT_FOUND', '比赛不存在')
const curSide = snap.board && snap.board.side_to_move
let legal = []
if (curSide && snap.game && snap.game.status === 'playing') {
const fen = r.parseFen(snap.board.fen)
legal = r.legalMoves(fen.board, curSide)
.map(m => `${'abcdefghi'[m.from.c]}${m.from.r}-${'abcdefghi'[m.to.c]}${m.to.r}`)
.slice(0, 120)
}
// 显式给出 bot 需要的轮次字段(game.side_to_move),与内存兜底行为一致
const game = snap.game ? Object.assign({}, snap.game, { side_to_move: curSide }) : null
return ok(res, Object.assign({}, snap, { game, your_side: req.bot && req.bot.side, legal_moves_of_side_to_move: legal }))
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
// 提交走子(bot 主接口;非法走子返回 400 + 错误码,局面不动)
router.post('/matches/:id/moves', botAuth, (req, res) => {
try {
const matchId = req.bot && req.bot.matchId ? Number(req.bot.matchId) : Number(req.params.id)
// 身份:优先角色 token 绑定的 side;浏览器调试允许 body.side 指定
const side = String((req.bot && (req.bot.side || req.bot.forceSide)) || (req.body && req.body.side) || '').trim() || ''
if (side !== 'red' && side !== 'black') return fail(res, 400, 'BAD_SIDE', 'side 必须为 red 或 black(通过 token 或 body.side 指定)')
const move = String((req.body && req.body.move) || '').trim()
if (!move) return fail(res, 400, 'BAD_MOVE_FORMAT', '缺少 move 字段')
const result = service.submitMove(matchId, side, move)
if (!result.ok) {
const status = result.code === 'GAME_OVER' || result.code === 'MATCH_NOT_FOUND' ? 409 : 400
return fail(res, status, result.code, result.error || result.reason)
}
// 去掉冗余快照,返回精简结果给 bot
return ok(res, {
accepted: true, side, move,
fen_after: result.fen_after,
check: result.check || '',
game_finished: !!result.game_finished,
result: result.result || '', winner: result.winner || '',
state: result.snapshot
})
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
router.post('/matches/:id/start', adminAuth, (req, res) => {
try {
const r2 = service.startMatch(Number(req.params.id))
if (!r2.ok) return fail(res, 400, r2.code || 'BAD_REQUEST', r2.error)
return ok(res, { snapshot: r2.snapshot })
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
router.post('/matches/:id/foul', adminAuth, (req, res) => {
try {
const body = req.body || {}
const r2 = service.applyFoul(Number(req.params.id), body.side, body.reason || '违规')
if (!r2.ok) return fail(res, 400, 'BAD_REQUEST', r2.error)
return ok(res, { snapshot: r2.snapshot })
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
router.post('/matches/:id/forfeit', adminAuth, (req, res) => {
try {
const body = req.body || {}
const r2 = service.forfeitSide(Number(req.params.id), body.side, body.reason || '判负')
if (!r2.ok) return fail(res, 400, 'BAD_REQUEST', r2.error)
return ok(res, { snapshot: r2.snapshot })
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
router.post('/matches/:id/abort', adminAuth, (req, res) => {
try {
const body = req.body || {}
service.abortMatch(Number(req.params.id), body.reason || '手动中止')
return ok(res, {})
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
router.delete('/matches/:id', adminAuth, (req, res) => {
try {
db.deleteMatch(Number(req.params.id))
return ok(res, {})
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
// 事件流水(滚动日志轮询:?after= 增量;?all=1 全量)
router.get('/matches/:id/events', adminAuth, (req, res) => {
try {
const mid = Number(req.params.id)
if (!db.getMatch(mid)) return fail(res, 404, 'NOT_FOUND', '比赛不存在')
const after = req.query.all ? 0 : Number(req.query.after || 0)
const events = db.listEventsAfter(mid, after)
return ok(res, { events })
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
// 导出 MD 日志(可下载)
router.get('/matches/:id/log.md', adminAuth, (req, res) => {
try {
const md = service.buildMarkdownLog(Number(req.params.id))
if (!md) return fail(res, 404, 'NOT_FOUND', '比赛不存在')
res.setHeader('Content-Type', 'text/markdown; charset=utf-8')
if (String(req.query.download) === '1') {
res.setHeader('Content-Disposition', `attachment; filename="bot_chess_match_${req.params.id}.md"`)
}
return res.send(md)
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
// ---- 裁判聊天接口 ----
router.get('/chat/history', adminAuth, (req, res) => {
const orchestrator = require('./orchestrator')
return ok(res, { messages: orchestrator.chatHistory() })
})
router.post('/chat', adminAuth, async (req, res) => {
try {
const orchestrator = require('./orchestrator')
const message = String((req.body && req.body.message) || '').trim()
if (!message) return fail(res, 400, 'BAD_REQUEST', 'message 不能为空')
const out = await orchestrator.processUserMessage(message)
return ok(res, out)
} catch (e) { return fail(res, 500, 'INTERNAL', String(e.message || e)) }
})
app.use(B, router)
// 注意:无 bindRoutes —— 本 lab 全部接口都在守卫之前注册并由自身鉴权接管
}
module.exports = { bindPublicRoutes }
@@ -0,0 +1,544 @@
// ============================================================
// bot_chess/orchestrator.js —— 三角色编排层
// 1) 裁判 LLM:人只跟裁判对话。裁判通过“工具调用”(tool calling)操作棋台:
// check_bots(检测两个 bot)/ new_match / match_status / start_match /
// adjudicate(判罚/中止)/ match_log / recent_events
// 2) 两个 bot 会话(红/黑):完全隔离的对话记忆,通过 API(Bearer token)
// 拿局面(get_status) + 提交走子(submit_move),非法走子错误回喂后用工具重试。
// 3) 比赛运行完全由 service.js 机械引擎裁决,本模块只做“组织与叙事”,
// 不再做任何合法性判断(规则只认 referee.js)。
// 说明:裁判会话、bot 会话均保存在内存(重启后聊天上下文丢失,但对局数据在 SQLite)。
// ============================================================
const fs = require('fs')
const path = require('path')
const axios = require('axios')
const service = require('./service')
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 DEFAULT_AI_BASE = process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : ''
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
const safeParse = (s, fb) => { try { return JSON.parse(s) } catch { return fb } }
// ---------------- 百炼客户端(OpenAI 兼容 + 工具调用)----------------
const aiInfo = () => {
const cfg = readConfig()
return {
apiBase: String(cfg.api_base || DEFAULT_AI_BASE),
apiKey: String(process.env.DASHSCOPE_API_KEY || cfg.api_key || ''),
refereeModel: String(cfg.referee_model || 'qwen-plus-latest'),
redModel: String(cfg.red_model || 'qwen-plus-latest'),
blackModel: String(cfg.black_model || 'qwen-plus-latest')
}
}
// 单轮聊天(支持 tools;返回 message 原文以便处理 tool_calls)
const chatOnce = async ({ messages, model, tools, temperature = 0.7, maxTokens = 4096 }) => {
const ai = aiInfo()
if (!ai.apiKey) throw new Error('dashscope_api_key_missing')
const timeoutMs = Math.max(60000, Number(readConfig().llm_timeout_ms) || 240000)
const payload = { model: model || ai.refereeModel, messages, temperature }
if (tools && tools.length) payload.tools = tools
if (maxTokens) payload.max_tokens = maxTokens
const resp = await axios.post(ai.apiBase, payload, {
timeout: timeoutMs,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ai.apiKey}` }
})
const msg = (resp && resp.data && resp.data.choices && resp.data.choices[0] && resp.data.choices[0].message) || {}
return { content: String(msg.content || ''), tool_calls: msg.tool_calls || [] }
}
// 工具调用主循环:模型要工具就执行、回注结果,再要就再执行,直到给出最终文本
const chatWithTools = async ({ messages, model, tools, runTool, maxRounds = 8 }) => {
const hist = (messages || []).map(m => m)
const toolEvents = []
for (let i = 0; i < maxRounds; i++) {
const msg = await chatOnce({ messages: hist, model, tools })
if (msg.tool_calls && msg.tool_calls.length) {
hist.push({
role: 'assistant',
content: msg.content || null,
tool_calls: msg.tool_calls.map(tc => ({
id: tc.id, type: 'function',
function: { name: tc.function && tc.function.name, arguments: tc.function && tc.function.arguments }
}))
})
for (const tc of msg.tool_calls) {
let result
try {
const args = safeParse(tc.function.arguments, {})
result = await runTool(tc.function.name, args)
} catch (e) {
result = { ok: false, error: String(e.message || e) }
}
toolEvents.push({ name: tc.function.name, ok: !!result.ok, summary: String(result.summary || result.error || 'ok') })
hist.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) })
}
continue
}
return { reply: msg.content || '', toolEvents }
}
return { reply: '(裁判工具执行轮次过多,请重试或换一种说法)', toolEvents }
}
// ---------------- 公共工具定义(裁判 LLM 用)----------------
const refereeTools = () => [
{
type: 'function',
function: {
name: 'check_bots', description: '检测红/黑两个 bot 模型是否可用(会各问一句话,看能否应答)。用于开局前就绪检查。',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'new_match', description: '创建一场比赛(只创建,不开局)。返回比赛 ID、红黑 bot 与赛制。',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: '比赛名称' },
red_bot: { type: 'string', description: '红方 bot 名称' },
black_bot: { type: 'string', description: '黑方 bot 名称' },
best_of: { type: 'integer', description: '局数,默认 3(三局两胜)' }
},
required: []
}
}
},
{
type: 'function',
function: {
name: 'match_status', description: '查看当前比赛的比分、当前局局面(FEN)、轮到谁、犯规计数与最近着法。',
parameters: { type: 'object', properties: { match_id: { type: 'integer', description: '比赛ID,缺省用最近创建的比赛' } } }
}
},
{
type: 'function',
function: {
name: 'start_match', description: '让比赛开始:两个 bot 自动对弈(先红后黑,引擎逐手裁决)。开局前请先确认 bot 已就绪且用户同意。',
parameters: { type: 'object', properties: { match_id: { type: 'integer', description: '比赛ID' } }, required: [] }
}
},
{
type: 'function',
function: {
name: 'adjudicate', description: '裁定:判某方负(forfeit,如无限犯规/失联)或中止整场(abort)。',
parameters: {
type: 'object',
properties: {
action: { type: 'string', enum: ['forfeit', 'abort'] },
side: { type: 'string', enum: ['red', 'black'], description: '被判负的一方(forfeit 时必填)' },
match_id: { type: 'integer' },
reason: { type: 'string', description: '裁定原因' }
},
required: ['action']
}
}
},
{
type: 'function',
function: {
name: 'match_log', description: '导出比赛日志(Markdown),用于复盘总结。',
parameters: { type: 'object', properties: { match_id: { type: 'integer' } } }
}
},
{
type: 'function',
function: {
name: 'recent_events', description: '查看最近事件流水(建赛/开局/走子/犯规/终局),用于向用户播报局势。',
parameters: { type: 'object', properties: { match_id: { type: 'integer' } } }
}
}
]
// ---------------- 主持人状态(内存)----------------
const judge = {
activeMatchId: 0,
session: [], // 裁判会话消息(含 system)
startedMatches: new Set() // 已启动过 driver 的比赛(防重复)
}
// 裁判系统提示词(协议讲解 + 行为约束)
const JUDGE_SYSTEM = () => {
const cfg = readConfig()
return [
`你是「机器人象棋」比赛主持人兼裁判(TRAE-Toolbox bot_chess lab)。`,
`背景:红黑两个大模型 bot 在同一个机制下自动对弈;你的职责是调度、播报与裁定,最终合法性一律由代码引擎把关,你不用也不能代替引擎判规则。`,
`规则要点(引擎已实现,你只需了解):`,
`- 中国象棋;坐标:列 a-i(左到右),行 0-9(0=红方底线,9=黑方底线);着法格式如 h2-e2(炮二平五)。棋子:红 R车N马B相A士K帅C炮P兵;黑小写。`,
`- 赛制:默认三局两胜,每局红黑对调(第 1 局红方 bot 执红先行)。`,
`- 胜负与和:将死/困毙/飞将直取判胜;连续 120 手无吃子无进兵、三次重复局面、子力不足判和。`,
`- 犯规:超时(单步 ${Number(cfg.move_timeout_seconds) || 180} 秒)、非法走子、失联都会累计犯规,${Number(cfg.foul_limit) || 3} 次判负本局。`,
`行为约束:`,
`1. 开局流程:用户说“检测/开始检测”时,先调用 check_bots 检查两个 bot;把检测结果汇报给用户,并说明比赛设计(谁执红谁执黑、几局几胜);必须征得用户明确同意后再调用 new_match + start_match。`,
`2. 比赛自动进行时不要打扰用户;只有比分变化、犯规、终局等关键事件才播报。`,
`3. 用 adjudicate 做裁定前先向用户说明原因。`,
`4. 语言:简洁中文,像专业裁判主持。`
].join('\n')
}
// ---------------- 工具执行器(裁判用)----------------
const pickMatchId = (args, fallback) => Number(args && args.match_id) || judge.activeMatchId || fallback || 0
// terse:给 LLM 看的摘要(不暴露 token)
const terse = (snap) => {
if (!snap) return { ok: false, error: 'snapshot_empty' }
const m = snap.match
return {
ok: true, summary: `比赛#${m.id}《${m.title}》:${m.red_bot}(红) vs ${m.black_bot}(黑)`,
match_id: m.id, status: m.status, score: `红 ${m.score_red} : ${m.score_black} 黑`,
winner: m.winner || '未定',
game: snap.game ? { game_no: snap.game.game_no, status: snap.game.status, result: snap.game.result || '进行中', side_to_move: snap.game.turn === 'b' ? 'black' : 'red', move_count: snap.game.move_count, fouls: `红 ${snap.game.red_fouls} / 黑 ${snap.game.black_fouls}` } : null,
last_moves: snap.last_moves && snap.last_moves.slice(0, 8),
fen: snap.board ? snap.board.fen : null
}
}
const judgeExecutor = async (name, args) => {
const cfg = readConfig()
switch (name) {
case 'check_bots': {
const ai = aiInfo()
const probe = async (model, sideName) => {
if (!model) return { ok: false, error: '未配置模型' }
try {
const r = await chatOnce({
model, maxTokens: 120, temperature: 0.3,
messages: [
{ role: 'system', content: `你是${sideName}方中国象棋 bot,用一句话自报家门即可。` },
{ role: 'user', content: '检测连接:你的名字?你准备好了吗?' }
]
})
return { ok: true, reply: (r.content || '').slice(0, 120) }
} catch (e) {
return { ok: false, error: String(e.message || e) }
}
}
const redModel = cfg.red_model || ai.redModel
const blackModel = cfg.black_model || ai.blackModel
const red = await probe(redModel, '红')
const black = await probe(blackModel, '黑')
return {
ok: red.ok && black.ok, summary: `红(${redModel}):${red.ok ? '就绪' : '失败 ' + red.error};黑(${blackModel}):${black.ok ? '就绪' : '失败 ' + black.error}`,
red: { model: redModel, ready: red.ok, reply: red.reply, error: red.error },
black: { model: blackModel, ready: black.ok, reply: black.reply, error: black.error }
}
}
case 'new_match': {
const snap = service.createMatch({
title: args.title, red_bot: args.red_bot, black_bot: args.black_bot,
best_of: args.best_of || cfg.best_of
})
judge.activeMatchId = snap.match.id
return terse(snap)
}
case 'match_status': {
const id = pickMatchId(args)
const snap = id ? service.buildSnapshot(id) : null
return snap ? terse(snap) : { ok: false, error: '没有可用的比赛,请先 new_match' }
}
case 'start_match': {
const id = pickMatchId(args)
if (!id) return { ok: false, error: '没有比赛可开始,请先 new_match' }
const started = service.startMatch(id)
if (!started.ok) return started
spawnBots(id)
return { ok: true, summary: `比赛#${id} 已开始,两 bot 自动对弈中`, match_id: id, snapshot: terse(service.buildSnapshot(id)) }
}
case 'adjudicate': {
const id = pickMatchId(args)
if (args.action === 'forfeit') {
if (!args.side) return { ok: false, error: 'forfeit 需要指定 side' }
const r = service.forfeitSide(id, args.side, args.reason || '裁判裁定')
return { ok: r.ok, summary: `${args.side} 判负`, snapshot: r.ok ? terse(r.snapshot) : undefined, error: r.error }
}
if (args.action === 'abort') {
const r = service.abortMatch(id, args.reason || '裁判裁定中止')
return { ok: r.ok, summary: '比赛已中止', error: r.error }
}
return { ok: false, error: '未知裁定动作' }
}
case 'match_log': {
const id = pickMatchId(args)
const md = id ? service.buildMarkdownLog(id) : ''
if (!md) return { ok: false, error: '无日志' }
return { ok: true, summary: `日志 ${md.length} 字符;URL: ${cfg.chat_base_url || 'http://localhost:8976'}/api/thought_lab/bot_chess/matches/${id}/log.md`, md: md.slice(0, 6000) }
}
case 'recent_events': {
const id = pickMatchId(args)
const evs = id ? db.listEvents(id) : []
return { ok: true, summary: `共 ${evs.length} 条事件`, events: evs.slice(-30).map(e => `[${e.created_at}] ${e.side ? '[' + e.side + '] ' : ''}${e.content}`) }
}
default:
return { ok: false, error: `未知工具 ${name}` }
}
}
// ---------------- 对外:裁判对话接口 ----------------
const processUserMessage = async (text) => {
const msg = String(text || '').trim()
if (!msg) return { reply: '' }
if (!judge.session.length) judge.session.push({ role: 'system', content: JUDGE_SYSTEM() })
judge.session.push({ role: 'user', content: msg })
try {
const { reply, toolEvents } = await chatWithTools({
messages: judge.session,
model: aiInfo().refereeModel,
tools: refereeTools(),
runTool: judgeExecutor,
maxRounds: 10
})
judge.session.push({ role: 'assistant', content: reply })
// 内存裁剪:只保留最近 40 条
if (judge.session.length > 44) {
judge.session = [judge.session[0], ...judge.session.slice(judge.session.length - 40)]
}
return { reply, tool_events: toolEvents, match_id: judge.activeMatchId || 0 }
} catch (e) {
const errText = String(e.message || e)
const friendly = errText.includes('dashscope_api_key_missing')
? '尚未配置百炼 API Key(请设置环境变量 DASHSCOPE_API_KEY)'
: `调用裁判模型失败:${errText}`
return { reply: friendly, tool_events: [], error: errText }
}
}
const chatHistory = () => judge.session
.filter(m => m.role !== 'system')
.map(m => ({ role: m.role, content: m.content }))
.slice(-40)
// ---------------- 两个 bot 会话(互相隔离)----------------
const BOT_SYSTEM = (side) => [
`你是中国象棋机器人,本场执${side === 'red' ? '红' : '黑'}。你的对手是另一个大模型,由机械裁判裁决,规则不会由你或我说了算。`,
`坐标:列 a~i(左到右,红方视角);行 0~9(0=红方底线在下,9=黑方底线在上)。`,
`着法格式严格为 <from>-<to>,如 h2-e2(红方代表炮二平五)。`,
`须知:必须走合法着法(别蹩马腿、别送将、别让将帅照面);如果裁判返回“着法非法+错误码”,仔细看原因后换一步,绝不重复同一手、绝不编造已落子。`,
`策略:开局可考虑中炮/屏风马等常规布局;先保护将帅,别把帅/将暴露在空列上(会被对方飞将直取)。`,
`只输出针对当前局面的着法;不在你的回合时输出“等待”。不要输出长解释。`
].join('\n')
const botStateForSide = async (matchId, token, side) => {
// HTTP 优先(与外部 bot 同一协议);失败自动兜底为进程内快照,避免
// chat_base_url 端口对不上时 driver 空转到超时判负
const cfg = readConfig()
try {
const r = await axios.get(`${cfg.chat_base_url || 'http://localhost:8976'}/api/thought_lab/bot_chess/matches/${matchId}/state?side=${side}`, {
timeout: 15000,
headers: { Authorization: `Bearer ${token}` }
})
if (r.data && r.data.ok) return r.data
} catch (e) { /* 走兜底 */ }
const snap = service.buildSnapshot(matchId)
if (!snap) return { ok: false, error: 'empty' }
const game = snap.game
return {
ok: true, http: false, your_side: side,
match: snap.match,
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, side_to_move: game.turn === 'b' ? 'black' : 'red'
} : null,
board: snap.board,
last_moves: snap.last_moves
}
}
const botSubmitFallback = (matchId, side, move) => {
// 进程内兜底走子(返回结构与 HTTP 版对齐)
const res = service.submitMove(matchId, side, move)
if (res.ok && res.accepted) {
return { ok: true, accepted: true, fen_after: res.fen_after, check: res.check || '', game_finished: !!res.game_finished, result: res.result || '', winner: res.winner || '' }
}
if (res.ok) return { ok: true, accepted: false, code: res.code || 'N/A', reason: res.reason || res.error }
return { ok: false, code: res.code, reason: res.reason || res.error, fen: res.fen, turn: res.turn }
}
const botSubmit = async (matchId, token, side, move) => {
const cfg = readConfig()
try {
const r = await axios.post(`${cfg.chat_base_url || 'http://localhost:8976'}/api/thought_lab/bot_chess/matches/${matchId}/moves`, { move }, {
timeout: 15000,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }
})
if (r.data) return r.data
} catch (e) { /* 兜底 */ }
return botSubmitFallback(matchId, side, move)
}
const botFoul = (matchId, side, reason) => {
// 犯规是编排层的赛道判断,直接走进程内机械层,不绕 HTTP(避免依赖 referee token 配置)
return service.applyFoul(matchId, side, reason)
}
// 单边 bot 对弈循环
const driveBotSession = async (matchId, side) => {
const ai = aiInfo()
const match = db.getMatch(matchId)
if (!match) return
const token = side === 'red' ? match.red_token : match.black_token
const model = side === 'red' ? (match.red_model || ai.redModel) : (match.black_model || ai.blackModel)
const msgs = [{ role: 'system', content: BOT_SYSTEM(side) }]
let illegalStreak = 0
const cfg = readConfig()
const retryLimit = Math.max(1, Number(cfg.illegal_retry) || 2)
addJudgeEvent(matchId, `[driver] ${side} 启动:模型=${model},API=${cfg.chat_base_url || 'http://localhost:8976'}(HTTP 失败自动内存兜底)`)
while (true) {
// 整场级退出条件
const st = await botStateForSide(matchId, token, side)
if (!st.ok) { await sleep(3000); continue }
const m = st.match, g = st.game
if (!m || m.status !== 'active' || !g || g.status !== 'playing') {
if (m && m.status === 'finished') { addJudgeEvent(matchId, `[driver] ${side} 检测到比赛结束,退出`); break }
await sleep(2000); continue
}
// 轮到谁:统一用 turn 字段判断(HTTP 与内存兜底都提供),不依赖 side_to_move 可选字段
const turnSide = g.turn === 'b' ? 'black' : 'red'
if (turnSide !== side) { await sleep(1200); continue } // 未轮到
// 组装 bot 提示:局面给结构化信息(不给 bot 解析 FEN 的机会)
const summary = describeForBot(st, side, { ascii: cfg.bot_prompt_ascii === true })
const myLast = st.last_moves && st.last_moves.length ? st.last_moves[st.last_moves.length - 1] : ''
const moveNo = (Number(g.move_count) || 0) + 1
const useTools = cfg.bot_use_tools === true
let accepted = false
if (!useTools) {
// ---------- 文本着法模式(快/稳,推荐):局面进提示词,模型只回一行着法 ----------
const ask = `轮到你了:第 ${moveNo} 手,执${side === 'red' ? '红' : '黑'}。\n${summary}${myLast ? '\n上一步对方着法:' + myLast : ''}\n\n现在请输出你的着法。格式要求:严格一行 起点-终点(坐标),例如 h2-e2 / b0c2。禁止解释、禁止写中文记谱(如“炮二平五”)、禁止提棋盘、禁止其他格式。若确实没轮到你,只输出“等待”。`
msgs.push({ role: 'user', content: ask })
let mv = ''
let rawReply = ''
try {
// 放宽输出的 token 上限,避免模型长篇分析被截断导致着法丢失
const once = await chatOnce({ messages: msgs, model, maxTokens: 1024, temperature: 0.4 })
rawReply = String(once.content || '').trim()
mv = extractMove(rawReply)
} catch (e) {
addJudgeEvent(matchId, `[driver] ${side} 调模型失败:${String(e.message || e).slice(0, 120)}(将重试)`)
await sleep(1500)
}
if (mv) {
const res = await botSubmit(matchId, token, side, mv)
if (res && res.ok && res.accepted) {
accepted = true
// 己方着法回注,让模型在下一轮能"记得"自己上一步(保持记忆链)
msgs.push({ role: 'assistant', content: mv })
} else {
// 非法着法:回喂错误让模型重想
msgs.push({ role: 'user', content: `你刚才提交的 ${mv} 非法:${res && res.reason || res && res.error || ''}(错误码 ${res && res.code || '?'})。请重新给一行合法着法,格式 起点-终点(坐标)。` })
addJudgeEvent(matchId, `[driver] ${side} 着法被拒:${mv}(${res && res.code || '?'})`)
}
} else if (rawReply) {
// 模型回复了内容但没坐标:回喂原文并纠正格式,给它一次自我修正机会
msgs.push({ role: 'user', content: `你刚才的回复是:「${rawReply.slice(0, 120)}」——这不是标准着法(可能误用了中文记谱或分析)。请只输出一行坐标着法,格式:起点-终点(如 h2-e2)。` })
addJudgeEvent(matchId, `[driver] ${side} 回复无坐标着法,已回喂纠错:${rawReply.slice(0, 60)}`)
} else {
addJudgeEvent(matchId, `[driver] ${side} 未得到着法,本手作废重试`)
}
} else {
// ---------- 工具调用模式(还原外部 bot 协议,更慢)----------
msgs.push({ role: 'user', content: `(第 ${moveNo} 手,轮到你)${summary}${myLast ? '\n上一步对方着法:' + myLast : ''}\n请使用 get_status 确认后,用 submit_move 提交你的着法。` })
const toolScope = { accepted: false }
const botExecutor = async (name, args) => {
if (name === 'get_status') return botStateForSide(matchId, token, side)
if (name === 'submit_move') {
const mv = String(args.move || '').trim()
const res = await botSubmit(matchId, token, side, mv)
if (res && res.ok && res.accepted) {
toolScope.accepted = true
return { ok: true, summary: '着法已接受', fen_after: res.fen_after, game_finished: !!res.game_finished, result: res.result || '', check: res.check || '' }
}
return { ok: false, summary: '着法未通过', code: res && res.code, reason: res && res.reason || res && res.error, fen: res && res.fen }
}
return { ok: false, error: `未知工具 ${name}` }
}
const botTools = [
{ type: 'function', function: { name: 'get_status', description: '获取当前局面(FEN/棋盘/是否轮到你)。', parameters: { type: 'object', properties: {} } } },
{ type: 'function', function: { name: 'submit_move', description: '提交你的着法(格式如 h2-e2)。', parameters: { type: 'object', properties: { move: { type: 'string' } }, required: ['move'] } } }
]
const botTurn = await chatWithTools({ messages: msgs, model, tools: botTools, runTool: botExecutor, maxRounds: 8 })
.catch(e => {
addJudgeEvent(matchId, `[driver] ${side} 调模型失败:${String(e.message || e).slice(0, 120)}(将重试)`)
return { reply: '', toolEvents: [], error: String(e.message || e) }
})
const lastMsg = String(botTurn.reply || '')
if (lastMsg) msgs.push({ role: 'assistant', content: lastMsg })
if (!toolScope.accepted) {
const mv = extractMove(lastMsg)
if (mv) {
const res = await botSubmit(matchId, token, side, mv)
accepted = !!(res && res.ok && res.accepted)
if (!accepted) msgs.push({ role: 'user', content: `你提交的 ${mv} 非法,请重新给一步合法着法。` })
} else {
msgs.push({ role: 'user', content: '你还需要提交一步合法着法(使用 submit_move 工具)。' })
}
} else {
accepted = true
}
}
if (accepted) {
illegalStreak = 0
// 本手结束:直接从下一轮循环读最新状态(含可能的新局)
await sleep(300)
continue
}
// 没提交成功 → 非法重试累积(超限记犯规,由机械层判)
illegalStreak++
if (illegalStreak > retryLimit) {
await botFoul(matchId, side, `连续 ${illegalStreak} 次未提交合法着法`)
addJudgeEvent(matchId, `${side} 连续非法走子超限,已记犯规`)
illegalStreak = 0
await sleep(1500)
}
// 裁剪 bot 会话避免上下文膨胀拖慢模型(保留 system + 最近 8 条,局面每轮完整重发)
if (msgs.length > 12) msgs.splice(1, msgs.length - 10)
}
}
const extractMove = (text) => {
const m = String(text || '').match(/([a-i][0-9])[-:]([a-i][0-9])/)
if (m) return `${m[1]}-${m[2]}`
const m2 = String(text || '').match(/([a-i][0-9])([a-i][0-9])/)
return m2 ? `${m2[1]}-${m2[2]}` : ''
}
const addJudgeEvent = (matchId, content) => {
try { if (matchId) db.addEvent({ match_id: matchId, kind: 'note', content }) } catch {}
}
// 启动两个 bot 会话(幂等:每场只启动一次)
const spawnBots = (matchId) => {
if (judge.startedMatches.has(matchId)) return
judge.startedMatches.add(matchId)
driveBotSession(matchId, 'red')
driveBotSession(matchId, 'black')
addJudgeEvent(matchId, '两个 bot driver 已启动')
}
// 便于 index.js 在服务内预热
const describeForBot = (st, side, opts) => {
if (!st || !st.board) return '(当前无局面)'
const lines = []
if (opts && opts.ascii && st.board.ascii) lines.push(st.board.ascii)
const redList = (st.board.pieces.red || []).map(p => `${p.cn || p.piece}@${p.pos}`).join(', ')
const blackList = (st.board.pieces.black || []).map(p => `${p.cn || p.piece}@${p.pos}`).join(', ')
lines.push(`FEN: ${st.board.fen}`)
lines.push(`红方子力:${redList}`)
lines.push(`黑方子力:${blackList}`)
lines.push(`当前轮到:${st.board.side_to_move === 'red' ? '红' : '黑'};你执${side === 'red' ? '红' : '黑'}。`)
return lines.join('\n')
}
module.exports = { processUserMessage, chatHistory, spawnBots, chatWithTools, chatOnce }
@@ -0,0 +1,473 @@
// ============================================================
// bot_chess/referee.js —— 中国象棋规则引擎(纯函数,无 IO 依赖)
//
// 职责:FEN 解析 / 合法着法生成 / 走子校验(蹩马腿、塞象眼、
// 炮架、将帅照面、送将)/ 将军 / 将死 / 困毙 / 胜负判定。
// 本模块只做“规则”,不做数据库、不做 HTTP,保证可单测、可复用。
//
// 坐标约定(与已知参考实现 xiangqi-model-match 保持同套协议,红方视角):
// 列:a~i,从左到右(红方视角)
// 行:0~9,从下到上(0 = 红方底线,9 = 黑方底线)
// 着法格式:<from>-<to> 或 <from><to>,例如 h2-e2 / h2e2(炮二平五)
//
// FEN 约定(国际通用中国象棋 FEN,如 pychess / xqf 格式):
// 第 1 段从上到下共 10 行,每行 9 格;
// 大写 = 红子,小写 = 黑子;/ 分行;数字 = 连续空格数;
// r=车 n=马 b=象 a=士 k=帅/将 c=炮 p=兵/卒(标准 FEN:马=n、象=b)
// 第 1 行是棋盘顶部(黑方底线),即 FEN 第 i 行 ↔ 内部坐标第 (9-i) 行
// 第 2 段:w/b —— w=红方走,b=黑方走
// 例:rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1
// ============================================================
const START_FEN = 'rnbakabnr/9/1c5c1/p1p1p1p1p/9/9/P1P1P1P1P/1C5C1/9/RNBAKABNR w - - 0 1'
const ROWS = 10 // 行数 0..9
const COLS = 9 // 列数 0..8
const FILES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']
// 阵营:内部统一用 'red' 红 / 'black' 黑;FEN turn 用 'w'(红走)/ 'b'(黑走)
const IN = { red: 'red', black: 'black' }
const TURN_SIDE = { w: 'red', b: 'black' } // FEN turn -> side
const SIDE_TURN = { red: 'w', black: 'b' } // side -> FEN turn
// 错误码(提交走子非法时返回给调用方 / bot 可读)
const E = {
OUT_OF_BOARD: 'OUT_OF_BOARD', // 起/终点越界
NO_PIECE: 'NO_PIECE', // 起点没有我方棋子
WRONG_SIDE: 'WRONG_SIDE', // 起点是对手棋子
CAPTURE_OWN: 'CAPTURE_OWN', // 落点是我方棋子
ILLEGAL_MOVE: 'ILLEGAL_MOVE', // 不符合该子走法(通用)
BLOCKED_HORSE: 'BLOCKED_HORSE', // 蹩马腿
BLOCKED_ELEPHANT: 'BLOCKED_ELEPHANT', // 塞象眼
CANNON_SCREEN: 'CANNON_SCREEN', // 炮吃子需且仅需隔一个炮架
FLYING_GENERAL: 'FLYING_GENERAL', // 将帅照面(走完两将同线无遮挡)
SELF_CHECK: 'SELF_CHECK', // 送将(走完自己被将军)
}
// ---------- 基础工具 ----------
const inBoard = (r, c) => r >= 0 && r < ROWS && c >= 0 && c < COLS
const colToFile = c => FILES[c] || ''
const fileToCol = f => FILES.indexOf(f)
const sq = (r, c) => colToFile(c) + String(r) // 坐标串,如 h2
const parseSquare = s => { // 'h2' -> {r,c}
const m = String(s || '').trim().match(/^([a-i])([0-9])$/)
if (!m) return null
return { r: Number(m[2]), c: fileToCol(m[1]) }
}
// 着法串:'h2-e2' 或 'h2e2' -> {from:{r,c}, to:{r,c}},非法返回 null
const parseMove = s => {
const m = String(s || '').trim().match(/^([a-i])([0-9])[-:]?([a-i])([0-9])$/)
if (!m) return null
return {
from: { r: Number(m[2]), c: fileToCol(m[1]) },
to: { r: Number(m[4]), c: fileToCol(m[3]) }
}
}
// ---------- 棋盘表示:board[r][c],r=0 红底线,r=9 黑底线 ----------
// 棋盘格子存单字符:红 'KA BNRCP'(K帅 A士 B相 N马 R车 C炮 P兵)黑同小写;空=''
// 解析 FEN 为棋盘(board 为 10x9 二维数组)
const parseFen = (fen) => {
const parts = String(fen || START_FEN).trim().split(/\s+/)
const rows = (parts[0] || '').split('/')
if (rows.length !== ROWS) throw new Error('fen_bad_rows')
const board = []
for (let i = 0; i < ROWS; i++) { // i=0 是 FEN 第 1 行(顶部=黑底线)
const row = rows[i]
const ourRow = ROWS - 1 - i // 顶部行 ↔ 内部第 9 行
const line = []
let c = 0
for (const ch of row) {
if (ch >= '1' && ch <= '9') { c += Number(ch); continue }
line[boardFillIndex(c)] = ch
c++
}
// line 为稀疏数组,补齐为 9 格
const full = []
for (let x = 0; x < COLS; x++) full[x] = line[x] || ''
board[ourRow] = full
}
const turn = String(parts[1] || 'w').toLowerCase() === 'b' ? 'b' : 'w'
return { board, turn }
}
// 辅助:把字符写进稀疏行数组的补位逻辑(等价于 line.push,但显式按列索引)
const boardFillIndex = (c) => c
// 棋盘序列化为 FEN
const toFen = (board, turn) => {
const lines = []
for (let ourRow = ROWS - 1; ourRow >= 0; ourRow--) { // 从第 9 行(顶) 反向输出
let line = ''
let empty = 0
for (let c = 0; c < COLS; c++) {
const ch = board[ourRow][c]
if (!ch) { empty++; continue }
if (empty) { line += empty; empty = 0 }
line += ch
}
if (empty) line += empty
if (line === '') line = '9' // FEN 空行 = 9 个空位
lines.push(line)
}
return `${lines.join('/')} ${turn || 'w'} - - 0 1`
}
// 位置去重复用的“局面指纹”(只含摆子,不含轮次计数)
const positionKey = (board) => {
const rows = []
for (let r = 0; r < ROWS; r++) rows.push(board[r].join(''))
return rows.join('/')
}
// ---------- 棋子信息 ----------
const pieceInfo = (ch) => {
if (!ch) return null
const isRed = ch === ch.toUpperCase()
return { side: isRed ? 'red' : 'black', type: ch.toUpperCase() }
}
const findKing = (board, side) => {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const p = pieceInfo(board[r][c])
if (p && p.side === side && p.type === 'K') return { r, c }
}
}
return null
}
const isEnemy = (board, r, c, side) => {
const p = pieceInfo(board[r][c])
return !!p && p.side !== side
}
const isOwn = (board, r, c, side) => {
const p = pieceInfo(board[r][c])
return !!p && p.side === side
}
// 宫格(帅/士活动区):红 0..2 行,黑 7..9 行,列 3..5
const inPalace = (r, c, side) => {
if (c < 3 || c > 5) return false
return side === 'red' ? (r >= 0 && r <= 2) : (r >= 7 && r <= 9)
}
// ---------- 伪合法着法(单子合法走法,未过滤“走完被将军/将帅照面”)----------
// 返回 { tc, tr, code } 数组;code 为 null 表示合法
const pseudoTargets = (board, r, c) => {
const p = pieceInfo(board[r][c])
const out = []
if (!p) return out
const { side, type } = p
const add = (tr, tc, code) => { if (!inBoard(tr, tc)) return; if (isOwn(board, tr, tc, side)) return; out.push({ tr, tc, code }) }
const pathClear = (tr, tc) => {
const dr = Math.sign(tr - r), dc = Math.sign(tc - c)
let cr = r + dr, cc = c + dc
while (cr !== tr || cc !== tc) {
if (board[cr][cc]) return false
cr += dr; cc += dc
}
return true
}
if (type === 'R') { // 车:横竖走,路径无子
for (const [dr, dc] of [[1,0],[-1,0],[0,1],[0,-1]]) {
let tr = r + dr, tc = c + dc
while (inBoard(tr, tc)) {
add(tr, tc, isEnemy(board, tr, tc, side) || !board[tr][tc] ? null : 'CAPTURE_OWN')
if (board[tr][tc]) break
tr += dr; tc += dc
}
}
} else if (type === 'N') { // 马:日字,蹩马腿
const steps = [[1,2],[2,1],[2,-1],[1,-2],[-1,-2],[-2,-1],[-2,1],[-1,2]]
for (const [dr, dc] of steps) {
const tr = r + dr, tc = c + dc
if (!inBoard(tr, tc)) continue
const legR = r + (Math.abs(dr) === 2 ? dr / 2 : 0)
const legC = c + (Math.abs(dc) === 2 ? dc / 2 : 0)
add(tr, tc, board[legR][legC] ? 'BLOCKED_HORSE' : null)
}
} else if (type === 'B') { // 象:田字,塞象眼,不过河
if ((side === 'red' && r <= 4) || (side === 'black' && r >= 5)) {
for (const [dr, dc] of [[2,2],[2,-2],[-2,2],[-2,-2]]) {
const tr = r + dr, tc = c + dc
if (!inBoard(tr, tc)) continue
const eyeR = r + dr / 2, eyeC = c + dc / 2
add(tr, tc, board[eyeR][eyeC] ? 'BLOCKED_ELEPHANT' : null)
}
}
} else if (type === 'A') { // 士:仕宫斜一步
if (inPalace(r, c, side)) {
for (const [dr, dc] of [[1,1],[1,-1],[-1,1],[-1,-1]]) {
add(r + dr, c + dc, null)
}
}
} else if (type === 'K') { // 帅/将:宫中斜一步(直线),外加“将帅照面直吃”
if (inPalace(r, c, side)) {
for (const [dr, dc] of [[1,0],[-1,0],[0,1],[0,-1]]) add(r + dr, c + dc, null)
}
// 飞将直吃:同列前方若直通敌将则允许吃
const dr = side === 'red' ? 1 : -1
let tr = r + dr
while (inBoard(tr, c) && !board[tr][c]) tr += dr
if (inBoard(tr, c)) {
const tp = pieceInfo(board[tr][c])
if (tp && tp.side !== side && tp.type === 'K') add(tr, c, null)
}
} else if (type === 'C') { // 炮:走子如车(落点为空),吃子需隔一个炮架
for (const [dr, dc] of [[1,0],[-1,0],[0,1],[0,-1]]) {
let tr = r + dr, tc = c + dc
// 非吃:空行走到空位
while (inBoard(tr, tc) && !board[tr][tc]) { add(tr, tc, null); tr += dr; tc += dc }
// 已碰到第一个子:找第二个子当目标
if (!inBoard(tr, tc)) continue
tr += dr; tc += dc
while (inBoard(tr, tc) && !board[tr][tc]) { tr += dr; tc += dc }
if (inBoard(tr, tc)) add(tr, tc, isEnemy(board, tr, tc, side) ? null : 'CAPTURE_OWN')
}
} else if (type === 'P') { // 兵/卒:未过河只能前进,过河后可横走(不能后退)
const fwd = side === 'red' ? 1 : -1
const crossed = side === 'red' ? r >= 5 : r <= 4 // 已过河
add(r + fwd, c, null)
if (crossed) { add(r, c + 1, null); add(r, c - 1, null) }
}
return out
}
// 某方所有伪合法落点(用于快速判定“是否被攻击”)
const attackedBy = (board, side, pos) => {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const p = pieceInfo(board[r][c])
if (!p || p.side !== side) continue
const targets = pseudoTargets(board, r, c)
for (const t of targets) {
if (!t.code && t.tr === pos.r && t.tc === pos.c) return true
}
}
}
return false
}
// 是否被将军
const inCheck = (board, side) => {
const king = findKing(board, side)
if (!king) return false
const enemy = side === 'red' ? 'black' : 'red'
return attackedBy(board, enemy, king)
}
// 落子(返回新棋盘 + 被吃的子)
const applyMove = (board, from, to) => {
const nb = board.map(row => row.slice())
const moving = nb[from.r][from.c]
nb[to.r][to.c] = moving
nb[from.r][from.c] = ''
return { board: nb, captured: board[to.r][to.c] || '' }
}
// 将帅是否照面(两将同列且中间无子)
const kingsFace = (board) => {
const rk = findKing(board, 'red'), bk = findKing(board, 'black')
if (!rk || !bk || rk.c !== bk.c) return false
const lo = Math.min(rk.r, bk.r), hi = Math.max(rk.r, bk.r)
for (let r = lo + 1; r < hi; r++) if (board[r][rk.c]) return false
return true
}
// ---------- 着法合法性校验(对外主入口)----------
// 返回 { ok:true } 或 { ok:false, code, reason }
const validateMove = (board, side, from, to) => {
if (!inBoard(from.r, from.c) || !inBoard(to.r, to.c)) return { ok: false, code: E.OUT_OF_BOARD, reason: '越界' }
const p = pieceInfo(board[from.r][from.c])
if (!p) return { ok: false, code: E.NO_PIECE, reason: '起点无棋子' }
if (p.side !== side) return { ok: false, code: E.WRONG_SIDE, reason: '不能走对方棋子' }
if (isOwn(board, to.r, to.c, side)) return { ok: false, code: E.CAPTURE_OWN, reason: '落点是自己棋子' }
// 1) 单子走法(含蹩马腿/塞象眼/炮架)
let pseudo = false, issue = null
for (const t of pseudoTargets(board, from.r, from.c)) {
if (t.tr === to.r && t.tc === to.c) { pseudo = true; issue = t.code || ''; break }
}
if (!pseudo) {
// 给 bot 更友好的错误细分(注意:from/to 为 {r,c} 结构)
const dr = Math.abs(to.r - from.r), dc = Math.abs(to.c - from.c)
if (p.type === 'N' && ((dr === 1 && dc === 2) || (dr === 2 && dc === 1))) {
const legR = from.r + (dr === 2 ? (to.r - from.r) / 2 : 0)
const legC = from.c + (dc === 2 ? (to.c - from.c) / 2 : 0)
if (board[legR] && board[legR][legC]) return { ok: false, code: E.BLOCKED_HORSE, reason: '蹩马腿' }
}
if (p.type === 'B' && dr === 2 && dc === 2) {
const eyeR = from.r + (to.r - from.r) / 2, eyeC = from.c + (to.c - from.c) / 2
if (board[eyeR] && board[eyeR][eyeC]) return { ok: false, code: E.BLOCKED_ELEPHANT, reason: '塞象眼' }
}
if (p.type === 'C' && (dr === 0 || dc === 0)) {
// 同线但拦截数不对 → 炮架问题
const blocked = countBetween(board, from, to)
if (blocked !== 1) {
return { ok: false, code: E.CANNON_SCREEN, reason: `炮吃/走子拦截数不对(需隔 1 个炮架),当前 ${blocked} 个` }
}
}
return { ok: false, code: E.ILLEGAL_MOVE, reason: '不符合该子走法' }
}
if (issue === 'BLOCKED_HORSE') return { ok: false, code: E.BLOCKED_HORSE, reason: '蹩马腿' }
if (issue === 'BLOCKED_ELEPHANT') return { ok: false, code: E.BLOCKED_ELEPHANT, reason: '塞象眼' }
if (issue === 'CAPTURE_OWN') return { ok: false, code: E.CAPTURE_OWN, reason: '落点是自己棋子' }
// 2) 模拟落子后的局面合法性
const nb = applyMove(board, from, to).board
if (kingsFace(nb)) {
return { ok: false, code: E.FLYING_GENERAL, reason: '将帅照面' }
}
if (inCheck(nb, side)) {
return { ok: false, code: E.SELF_CHECK, reason: '送将(走完己方被将军)' }
}
return { ok: true }
}
// 两点间棋子数(同线时有效)
const countBetween = (board, from, to) => {
if (from.r === to.r) {
let n = 0
const lo = Math.min(from.c, to.c) + 1, hi = Math.max(from.c, to.c)
for (let c = lo; c < hi; c++) if (board[from.r][c]) n++
return n
}
if (from.c === to.c) {
let n = 0
const lo = Math.min(from.r, to.r) + 1, hi = Math.max(from.r, to.r)
for (let r = lo; r < hi; r++) if (board[r][from.c]) n++
return n
}
return -1
}
// ---------- 合法着法生成(过滤自将/照面的完整合法移动)----------
// 返回 [{ from:{r,c}, to:{r,c}, code:null }]
const legalMoves = (board, side) => {
const out = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const p = pieceInfo(board[r][c])
if (!p || p.side !== side) continue
for (const t of pseudoTargets(board, r, c)) {
if (t.code) continue
const nb = applyMove(board, { r, c }, { r: t.tr, c: t.tc }).board
if (kingsFace(nb)) continue
if (inCheck(nb, side)) continue
out.push({ from: { r, c }, to: { r: t.tr, c: t.tc } })
}
}
}
return out
}
const hasAnyLegalMove = (board, side) => legalMoves(board, side).length > 0
// ---------- 走完一步后的终局判定 ----------
// 传入:走子后的棋盘 + 刚走的一方。返回:
// { ended, result, winner, kind, check }
// result: red_win | black_win | draw | ''(未结束)
// kind: king_captured | checkmate | stalemate | ''
// check: 是否将军对方 + 被将军方('red'|'black'|'')
const resolveAfterMove = (board, movedSide) => {
const enemy = movedSide === 'red' ? 'black' : 'red'
const enemyKing = findKing(board, enemy)
if (!enemyKing) {
return { ended: true, result: `${movedSide}_win`, winner: movedSide, kind: 'king_captured', check: '' }
}
const enemyCheck = inCheck(board, enemy)
const enemyMoves = legalMoves(board, enemy)
if (enemyCheck && enemyMoves.length === 0) {
return { ended: true, result: `${movedSide}_win`, winner: movedSide, kind: 'checkmate', check: enemy }
}
if (!enemyCheck && enemyMoves.length === 0) {
// 中国象棋规则:无棋可动(困毙)判负
return { ended: true, result: `${movedSide}_win`, winner: movedSide, kind: 'stalemate', check: '' }
}
return { ended: false, result: '', winner: '', kind: '', check: enemyCheck ? enemy : '' }
}
// 和棋辅助判定(由调用方在掌握历史后调用)
// 1) 子力不足:场上无车/马/炮/兵(只剩帅±士相)→ 理论上无法杀棋 → 和
const insufficientMaterial = (board) => {
let attack = false
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const p = pieceInfo(board[r][c])
if (!p) continue
if (p.type === 'R' || p.type === 'N' || p.type === 'C' || p.type === 'P') { attack = true; break }
}
if (attack) break
}
return !attack
}
// ---------- 展示与对照 ----------
// ASCII 棋盘(日志/聊天用),行 9=黑方底线在顶部,红方视角
const boardToAscii = (board, turn) => {
const SYM = { K: '帅', A: '士', B: '相', N: '马', R: '车', C: '炮', P: '兵' }
const lines = []
lines.push(' a b c d e f g h i')
lines.push(' ┌─────────────────────────────┐')
for (let r = ROWS - 1; r >= 0; r--) {
const cells = []
for (let c = 0; c < COLS; c++) {
const ch = board[r][c]
if (!ch) cells.push('·')
else {
const p = pieceInfo(ch)
let name = SYM[p.type] || p.type
cells.push(p.side === 'red' ? `红${name}` : `黑${name}`)
}
}
const cellStr = cells.join(' ')
// 行号统一宽度
lines.push(` ${String(r).padStart(3)} │ ${cellStr} │`)
}
lines.push(' └─────────────────────────────┘')
const cur = turn === 'b' ? '黑方' : '红方'
lines.push(`当前轮:${cur}(${turn})`)
return lines.join('\n')
}
// 双方棋子清单(给 bot 的结构化描述,避免 bot 解析 FEN 出错)
const boardToPieceList = (board) => {
const SYM = { K: '帅', A: '士', B: '相', N: '马', R: '车', C: '炮', P: '兵' }
const red = [], black = []
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const ch = board[r][c]
if (!ch) continue
const p = pieceInfo(ch)
const item = { pos: sq(r, c), piece: p.type, cn: SYM[p.type] }
if (p.side === 'red') red.push(item); else black.push(item)
}
}
return { red, black }
}
module.exports = {
START_FEN,
E,
parseFen,
toFen,
positionKey,
parseMove,
parseSquare,
pieceInfo,
findKing,
validateMove,
legalMoves,
hasAnyLegalMove,
applyMove,
inCheck,
kingsFace,
pseudoTargets,
resolveAfterMove,
insufficientMaterial,
boardToAscii,
boardToPieceList,
ROWS,
COLS
}
@@ -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
}
+2 -1
View File
@@ -46,7 +46,8 @@ const summarizeText = async (text) => {
// Using Aliyun DashScope (compatible with OpenAI) // Using Aliyun DashScope (compatible with OpenAI)
const apiKey = process.env.DASHSCOPE_API_KEY || ''; const apiKey = process.env.DASHSCOPE_API_KEY || '';
const url = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'; const dashScopeHost = process.env.DASHSCOPE_API_HOST_FOR_OPENAI || '';
const url = dashScopeHost ? `${dashScopeHost.replace(/\/+$/, '')}/chat/completions` : '';
const systemPrompt = defaultWeeklySystemPrompt; const systemPrompt = defaultWeeklySystemPrompt;
+1 -1
View File
@@ -5,7 +5,7 @@ const axios = require('axios')
const CONFIG_PATH = path.join(process.cwd(), 'config', 'yuanzhupai.json') const CONFIG_PATH = path.join(process.cwd(), 'config', 'yuanzhupai.json')
const CONFIG_LOCAL_PATH = path.join(process.cwd(), 'config', 'yuanzhupai.local.json') const CONFIG_LOCAL_PATH = path.join(process.cwd(), 'config', 'yuanzhupai.local.json')
const SCENARIOS_DIR = path.join(process.cwd(), 'config', 'scenarios') const SCENARIOS_DIR = path.join(process.cwd(), 'config', 'scenarios')
const DASHSCOPE_BASE = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions' const DASHSCOPE_BASE = process.env.DASHSCOPE_API_HOST_FOR_OPENAI ? `${process.env.DASHSCOPE_API_HOST_FOR_OPENAI.replace(/\/+$/, '')}/chat/completions` : ''
const DEFAULT_MODEL = 'qwen-plus-latest' const DEFAULT_MODEL = 'qwen-plus-latest'
const DEFAULT_TEMPERATURE = 0.2 const DEFAULT_TEMPERATURE = 0.2
const DEFAULT_MAX_TOKENS = 4096 const DEFAULT_MAX_TOKENS = 4096