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