diff --git a/dev_test_scripts/integration/test_bot_chess_api_smoke.js b/dev_test_scripts/integration/test_bot_chess_api_smoke.js
new file mode 100644
index 0000000..ca03bed
--- /dev/null
+++ b/dev_test_scripts/integration/test_bot_chess_api_smoke.js
@@ -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) })
\ No newline at end of file
diff --git a/dev_test_scripts/unit/test_bot_chess_referee.js b/dev_test_scripts/unit/test_bot_chess_referee.js
new file mode 100644
index 0000000..00d65b3
--- /dev/null
+++ b/dev_test_scripts/unit/test_bot_chess_referee.js
@@ -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) })
\ No newline at end of file
diff --git a/public/tools/thought_lab/labs/bot_chess/index.html b/public/tools/thought_lab/labs/bot_chess/index.html
new file mode 100644
index 0000000..436e0ed
--- /dev/null
+++ b/public/tools/thought_lab/labs/bot_chess/index.html
@@ -0,0 +1,289 @@
+
+
+
+
+
+机器人象棋 · 裁判室
+
+
+
+♟️ 机器人象棋 · 裁判室 模式…
+
+
+
对局:
+
+
+
+
+
+
+
+
+
+
+
对话(只跟裁判说)
+
+
+
+
+
+
+
+
+
+
+
+
交互详情
+
-
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/server/calendar_voice.js b/src/server/calendar_voice.js
index bf5f07d..67d895a 100644
--- a/src/server/calendar_voice.js
+++ b/src/server/calendar_voice.js
@@ -95,8 +95,9 @@ const bindRoutes = (app) => {
if (!text) return res.status(400).json({ ok: false, error: 'No text provided' })
const apiKey = process.env.DASHSCOPE_API_KEY || ''
- // 使用 OpenAI 兼容接口调用 DeepSeek-V3
- const url = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'
+ // 使用 OpenAI 兼容接口调用百炼大模型(前缀取环境变量,见 ~/Toolbox_local_creds.env.local)
+ const dashScopeHost = process.env.DASHSCOPE_API_HOST_FOR_OPENAI || ''
+ const url = dashScopeHost ? `${dashScopeHost.replace(/\/+$/, '')}/chat/completions` : ''
const systemPrompt = `# Role
你是一名资深的时间管理专家和高效秘书,擅长从冗杂的口语录音中快速提炼核心日程主题。
diff --git a/src/server/global_news.js b/src/server/global_news.js
index f46ee7f..d93c19a 100644
--- a/src/server/global_news.js
+++ b/src/server/global_news.js
@@ -192,7 +192,7 @@ const getAiConfig = () => {
return {
enabled: c.enabled !== false,
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'
}
} catch {
diff --git a/src/server/plant_home.js b/src/server/plant_home.js
index 4b0df64..27734dc 100644
--- a/src/server/plant_home.js
+++ b/src/server/plant_home.js
@@ -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_SEED_HOME_DIR = path.join(DEFAULT_UPLOAD_DIR, 'seed', 'home')
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 weatherCache = {
payload: null,
diff --git a/src/server/security_calendar.js b/src/server/security_calendar.js
index 9b3f2db..432e44c 100644
--- a/src/server/security_calendar.js
+++ b/src/server/security_calendar.js
@@ -11,7 +11,7 @@ if (!fs.existsSync(SECURITY_CALENDAR_AI_CFG_PATH)) {
fs.writeFileSync(SECURITY_CALENDAR_AI_CFG_PATH, JSON.stringify({
enabled: false,
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',
window_days: 21,
auto_verify_enabled: true,
@@ -476,7 +476,7 @@ function loadSecurityCalendarAiConfig() {
return {
enabled: cfg && cfg.enabled !== false,
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(),
windowDays: Math.max(7, Math.min(21, parseInt(String((cfg && cfg.window_days) || '21'), 10) || 21)),
autoVerifyEnabled: cfg && cfg.auto_verify_enabled !== false,
@@ -488,7 +488,7 @@ function loadSecurityCalendarAiConfig() {
return {
enabled: false,
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',
windowDays: 21,
autoVerifyEnabled: false,
diff --git a/src/server/style_check.js b/src/server/style_check.js
index 1b259f1..d66b61c 100644
--- a/src/server/style_check.js
+++ b/src/server/style_check.js
@@ -27,7 +27,7 @@ if (!fs.existsSync(configPath)) {
ai: {
enabled: true,
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',
timeout_ms: 30000
}
@@ -1495,7 +1495,7 @@ const getAiConfig = () => {
return {
enabled: ai.enabled !== false,
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(),
timeoutMs: Math.max(5000, Math.min(90000, Number(ai.timeout_ms || 30000)))
}
diff --git a/src/server/thought_lab/index.js b/src/server/thought_lab/index.js
index e83a8b4..c739539 100644
--- a/src/server/thought_lab/index.js
+++ b/src/server/thought_lab/index.js
@@ -142,6 +142,11 @@ const bindRoutes = (app) => {
}
})
+ // 4.5) 需要被「非浏览器客户端」直接访问的 lab 公开路由
+ // (必须在导航鉴权守卫之前注册;使用方必须自行做 token/身份校验,
+ // 如 bot_chess 的红黑 bot token;普通 lab 无需实现 bindPublicRoutes)
+ bindLabPublicBackends(app)
+
// 4) 其余 /api/thought_lab 接口:默认导航鉴权
app.use('/api/thought_lab', navAuthApi)
@@ -150,6 +155,24 @@ const bindRoutes = (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) => {
try {
if (!fs.existsSync(LABS_DIR)) return
diff --git a/src/server/thought_lab/labs/bot_chess/bot_chess.db b/src/server/thought_lab/labs/bot_chess/bot_chess.db
new file mode 100644
index 0000000..e9ad248
Binary files /dev/null and b/src/server/thought_lab/labs/bot_chess/bot_chess.db differ
diff --git a/src/server/thought_lab/labs/bot_chess/bot_chess.db-shm b/src/server/thought_lab/labs/bot_chess/bot_chess.db-shm
new file mode 100644
index 0000000..8b0c188
Binary files /dev/null and b/src/server/thought_lab/labs/bot_chess/bot_chess.db-shm differ
diff --git a/src/server/thought_lab/labs/bot_chess/bot_chess.db-wal b/src/server/thought_lab/labs/bot_chess/bot_chess.db-wal
new file mode 100644
index 0000000..ac3ff89
Binary files /dev/null and b/src/server/thought_lab/labs/bot_chess/bot_chess.db-wal differ
diff --git a/src/server/thought_lab/labs/bot_chess/config.json b/src/server/thought_lab/labs/bot_chess/config.json
new file mode 100644
index 0000000..66e8213
--- /dev/null
+++ b/src/server/thought_lab/labs/bot_chess/config.json
@@ -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": ""
+}
\ No newline at end of file
diff --git a/src/server/thought_lab/labs/bot_chess/db.js b/src/server/thought_lab/labs/bot_chess/db.js
new file mode 100644
index 0000000..b6d5853
--- /dev/null
+++ b/src/server/thought_lab/labs/bot_chess/db.js
@@ -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
+}
\ No newline at end of file
diff --git a/src/server/thought_lab/labs/bot_chess/index.js b/src/server/thought_lab/labs/bot_chess/index.js
new file mode 100644
index 0000000..bacf816
--- /dev/null
+++ b/src/server/thought_lab/labs/bot_chess/index.js
@@ -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 }
\ No newline at end of file
diff --git a/src/server/thought_lab/labs/bot_chess/orchestrator.js b/src/server/thought_lab/labs/bot_chess/orchestrator.js
new file mode 100644
index 0000000..50df91b
--- /dev/null
+++ b/src/server/thought_lab/labs/bot_chess/orchestrator.js
@@ -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=黑方底线在上)。`,
+ `着法格式严格为 -,如 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 }
\ No newline at end of file
diff --git a/src/server/thought_lab/labs/bot_chess/referee.js b/src/server/thought_lab/labs/bot_chess/referee.js
new file mode 100644
index 0000000..3f0918e
--- /dev/null
+++ b/src/server/thought_lab/labs/bot_chess/referee.js
@@ -0,0 +1,473 @@
+// ============================================================
+// bot_chess/referee.js —— 中国象棋规则引擎(纯函数,无 IO 依赖)
+//
+// 职责:FEN 解析 / 合法着法生成 / 走子校验(蹩马腿、塞象眼、
+// 炮架、将帅照面、送将)/ 将军 / 将死 / 困毙 / 胜负判定。
+// 本模块只做“规则”,不做数据库、不做 HTTP,保证可单测、可复用。
+//
+// 坐标约定(与已知参考实现 xiangqi-model-match 保持同套协议,红方视角):
+// 列:a~i,从左到右(红方视角)
+// 行:0~9,从下到上(0 = 红方底线,9 = 黑方底线)
+// 着法格式:- 或 ,例如 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
+}
\ No newline at end of file
diff --git a/src/server/thought_lab/labs/bot_chess/service.js b/src/server/thought_lab/labs/bot_chess/service.js
new file mode 100644
index 0000000..757683c
--- /dev/null
+++ b/src/server/thought_lab/labs/bot_chess/service.js
@@ -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
+}
\ No newline at end of file
diff --git a/src/server/weekly_integrations.js b/src/server/weekly_integrations.js
index 641e9a4..b52572e 100644
--- a/src/server/weekly_integrations.js
+++ b/src/server/weekly_integrations.js
@@ -46,7 +46,8 @@ const summarizeText = async (text) => {
// Using Aliyun DashScope (compatible with OpenAI)
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;
diff --git a/src/server/yuanzhupai.js b/src/server/yuanzhupai.js
index 3ff3af6..005d478 100644
--- a/src/server/yuanzhupai.js
+++ b/src/server/yuanzhupai.js
@@ -5,7 +5,7 @@ const axios = require('axios')
const CONFIG_PATH = path.join(process.cwd(), 'config', 'yuanzhupai.json')
const CONFIG_LOCAL_PATH = path.join(process.cwd(), 'config', 'yuanzhupai.local.json')
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_TEMPERATURE = 0.2
const DEFAULT_MAX_TOKENS = 4096