重构: aiohttp 全异步 + chessdb 云库 + uv 管理

This commit is contained in:
AwesomeXiangQi committed 2026-09-13 13:50:11 +08:00
commit 8d996b8c5f
13 files changed
+1726

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
// ==UserScript==
// @name Xiangqi.com 本地皮卡鱼引擎对接
// @name:en Xiangqi.com Local Pikafish Engine
// @namespace awesome-xiangqi
// @version 1.3.0
// @description 在 play.xiangqi.com 对局中调用本地皮卡鱼引擎(http://127.0.0.1:9377)自动/手动走子
// @description:en Hook the site WebSocket and let a local Pikafish engine play for you
// @match https://play.xiangqi.com/*
// @run-at document-start
// @grant unsafeWindow
// ==/UserScript==
(function () {
'use strict';
const ENGINE_URL = 'http://127.0.0.1:9377';
const NS = '42/xiangqi,';
const uW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
// ---------- 坐标转换: 引擎 UCCI(行0-9) <-> 站点(行1-10) ----------
const ucciToSite = m => m.replace(/\d+/g, d => String(+d + 1));
// 站点 -> 引擎在服务端做; 这里只需要 ucciToSite
// ---------- 对局状态 ----------
const G = {
id: null,
moves: [], // 站点坐标走法序列, 如 ['b3e3', 'h10g8']
fen: null, // 服务器推送的当前局面 FEN
mySide: null, // 'red' | 'black' | null(未知)
over: false,
thinking: false,
firstMoveDeadline: null, // 红方首步延迟截止时间戳
};
const cfg = {
enabled: localStorage.getItem('pf_enabled') === '1',
movetime: +(localStorage.getItem('pf_movetime') || 800),
depth: +(localStorage.getItem('pf_depth') || 0), // 0 = 按用时; >0 = 固定深度
firstDelay: +(localStorage.getItem('pf_firstdelay') || 0), // 红方首步延迟秒数(实际=此值+5)
sideOverride: localStorage.getItem('pf_side') || 'auto', // auto|red|black
};
function saveCfg() {
localStorage.setItem('pf_enabled', cfg.enabled ? '1' : '0');
localStorage.setItem('pf_movetime', cfg.movetime);
localStorage.setItem('pf_depth', cfg.depth);
localStorage.setItem('pf_firstdelay', cfg.firstDelay);
localStorage.setItem('pf_side', cfg.sideOverride);
}
// ---------- 工具 ----------
function jwtUsername() {
try {
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (!/jwt/i.test(k)) continue;
const v = String(localStorage.getItem(k));
const m = v.match(/^eyJ[\w-]+\.(eyJ[\w-]+)\./);
if (!m) continue;
const payload = JSON.parse(decodeURIComponent(escape(atob(m[1].replace(/-/g, '+').replace(/_/g, '/')))));
if (payload.sub) return payload.sub;
}
} catch (e) { /* ignore */ }
return null;
}
function isMyTurn() {
if (!G.mySide || G.over) return false;
return G.mySide === 'red' ? G.moves.length % 2 === 0 : G.moves.length % 2 === 1;
}
function status(msg, keepScore) {
ui.status.textContent = msg;
if (!keepScore) ui.score.textContent = '';
}
// ---------- 帧解析 ----------
function parseFrame(raw) {
if (typeof raw !== 'string' || !raw.startsWith(NS)) return null;
const body = raw.slice(NS.length);
const m = body.match(/^(\d+)?(\[[\s\S]*\])$/);
if (!m) return null;
try {
const arr = JSON.parse(m[2]);
return { name: arr[0], data: arr.length > 1 ? arr[1] : {} };
} catch (e) { return null; }
}
// ---------- 对局事件 ----------
function onGameEvent(name, data) {
if (name === 'game.get.success') {
const d = data.data || {};
G.id = d.id || G.id;
G.moves = (d.move_history || []).map(m => String(m.move || m).split(' ')[0]);
G.fen = d.fen || null;
G.over = false; G.thinking = false;
G.firstMoveDeadline = null; // 新对局重置首步延迟
detectSide(d);
status(`已同步对局 ${G.id}(${G.moves.length} 步, 我方${G.mySide === 'red' ? '红' : G.mySide === 'black' ? '黑' : '?'})`);
maybeMove();
} else if (name === 'game.start.success') {
G.over = false; G.thinking = false;
G.firstMoveDeadline = null;
setTimeout(maybeMove, 600);
setTimeout(maybeMove, 1800); // 等棋盘渲染出后再试一次
} else if (name === 'game.move.opponent') {
const d = data.data || {};
const mv = String(d.move || '').split(' ')[0];
if (d.fen) G.fen = d.fen;
if (mv && G.moves[G.moves.length - 1] !== mv) {
G.moves.push(mv);
status(`对手: ${mv}`);
maybeMove();
}
} else if (name === 'game.move.success') {
// 我方走子被服务器接受; 若该走法不在本地序列(例如手动点的), 记录之
const d = data.data || {};
if (d.fen) G.fen = d.fen;
const mv = String(d.move || '').split(' ')[0];
if (mv && G.moves[G.moves.length - 1] !== mv) {
G.moves.push(mv);
status(`已走: ${mv}`);
}
G.thinking = false;
} else if (name === 'game.server.end') {
G.over = true; G.thinking = false;
status(`对局结束(end_reason=${(data.data || {}).end_reason})`);
}
}
function detectSide(d) {
const want = cfg.sideOverride !== 'auto' ? cfg.sideOverride : jwtUsername();
const r = d.rplayer || {}, b = d.bplayer || {};
G.mySide = null;
if (r.username === want || r.identity === want) G.mySide = 'red';
else if (b.username === want || b.identity === want) G.mySide = 'black';
if (!G.mySide && cfg.sideOverride === 'auto') detectSideFromBoard();
if (G.mySide) ui.sideHint.textContent = `我方: ${G.mySide === 'red' ? '红方' : '黑方'}`;
else ui.sideHint.textContent = '未识别执子, 请在下方手动指定';
}
// 兜底: 站点总是把我方棋子渲染在下半区, 直接从棋盘 DOM 判断颜色
function detectSideFromBoard() {
try {
const avgy = cls => {
const els = [...document.querySelectorAll('img[alt]')].filter(i => {
if (i.closest('#pf-panel')) return false;
const a = i.alt || '';
return a.includes(cls) && /pawn|rook|cannon|horse|elephant|advisor|king/i.test(a);
});
if (!els.length) return null;
return els.reduce((s, p) => s + p.getBoundingClientRect().y, 0) / els.length;
};
const redY = avgy('red');
const blkY = avgy('brown') ?? avgy('black');
if (redY == null || blkY == null) return null;
G.mySide = redY > blkY ? 'red' : 'black';
if (ui.sideHint) ui.sideHint.textContent = `我方: ${G.mySide === 'red' ? '红方' : '黑方'}`;
return G.mySide;
} catch (e) { return null; }
}
// ---------- 引擎调用 ----------
// 红方首步延迟: 实际等待 = 设置秒数 + 5 秒
const FIRST_MOVE_EXTRA_SEC = 5;
async function maybeMove(force) {
if (!cfg.enabled && !force) return;
if (G.over || G.thinking || !G.id) return;
if (!G.mySide) detectSideFromBoard();
if (!G.mySide) return;
if (!force && !isMyTurn()) return;
if (force && !isMyTurn()) { status('现在不是你走棋'); return; }
// 计时起点包含首步延迟等待, 上报的 thinking_time 才真实
const t0 = Date.now();
// 整局第一步且我执红: 延迟出招(手动"立即走子"不受限)
if (G.moves.length === 0 && G.mySide === 'red' && !force) {
const totalMs = (cfg.firstDelay + FIRST_MOVE_EXTRA_SEC) * 1000;
if (!G.firstMoveDeadline) G.firstMoveDeadline = Date.now() + totalMs;
const remain = G.firstMoveDeadline - Date.now();
if (remain > 0) {
status(`红方首步延迟中: ${Math.ceil(remain / 1000)}s`);
return;
}
}
G.thinking = true;
status('皮卡鱼思考中…', true);
try {
const resp = await fetch(ENGINE_URL + '/bestmove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
moves: G.moves,
fen: G.fen,
movetime: cfg.movetime,
depth: cfg.depth > 0 ? cfg.depth : undefined,
}),
});
const out = await resp.json();
if (!out.ok) throw new Error(out.error || ('HTTP ' + resp.status));
if (G.over) throw new Error('对局已结束');
const move = out.move; // 站点坐标
if (G.moves[G.moves.length - 1] === move) throw new Error('引擎重复走子');
if (!isMyTurn()) { status('轮次已变化, 放弃本次走子'); G.thinking = false; return; }
G.moves.push(move);
sendMove(move, Date.now() - t0);
ui.score.textContent = `${out.source === 'cloud' ? '☁ 云库' : '♞ 引擎'} · 评分 ${out.score || '?'} · ${out.ms}ms`;
status(`已走: ${move}`);
} catch (e) {
status('引擎错误: ' + e.message, true);
G.thinking = false;
}
}
let liveWS = null;
function sendMove(move, thinkMs) {
if (!liveWS) { status('未找到 socket 连接'); return; }
const frame = NS + `["g_v2.move",${JSON.stringify({
game_id: G.id, move, thinking_time: thinkMs,
})}]`;
liveWS.send(frame);
G.thinking = false;
}
// ---------- WebSocket 钩子 (原型级补丁, 对已存在/未来的连接都生效) ----------
function onInFrame(raw, ws) {
liveWS = ws;
const ev = parseFrame(raw);
if (ev) {
G.evCount = (G.evCount || 0) + 1;
try { onGameEvent(ev.name, ev.data); } catch (e) { console.error('[pf]', e); }
}
}
function patchWebSocket() {
const SP = uW.WebSocket && uW.WebSocket.prototype;
if (!SP || SP.__pfPatched) return;
const origSend = SP.send;
SP.send = function (d) {
if (typeof d === 'string' && d.startsWith(NS)) liveWS = this;
return origSend.apply(this, arguments);
};
// 包一层 addEventListener('message') 以捕获收帧
const wrappedMap = new WeakMap();
const origAEL = SP.addEventListener;
SP.addEventListener = function (type, fn, opts) {
if (type === 'message' && typeof fn === 'function' && !wrappedMap.has(fn)) {
const wrapped = function (ev) { onInFrame(String(ev.data), this); };
wrappedMap.set(fn, wrapped);
return origAEL.call(this, type, wrapped, opts);
}
if (type === 'message' && typeof fn === 'function' && wrappedMap.has(fn)) {
return origAEL.call(this, type, wrappedMap.get(fn), opts);
}
return origAEL.apply(this, arguments);
};
// 兼容 onmessage 属性赋值方式
const omDesc = Object.getOwnPropertyDescriptor(SP, 'onmessage');
if (omDesc && omDesc.set) {
Object.defineProperty(SP, 'onmessage', {
configurable: true,
get() { return this.__pfOrigOM || null; },
set(fn) {
this.__pfOrigOM = fn;
const wrapped = function (ev) {
onInFrame(String(ev.data), this);
if (typeof fn === 'function') fn.call(this, ev);
};
omDesc.set.call(this, wrapped);
},
});
}
SP.__pfPatched = true;
}
patchWebSocket();
// 常规兜底轮询: 每秒检查是否轮到我方(覆盖事件时序抖动)
setInterval(() => { if (cfg.enabled) maybeMove(); }, 1000);
// 调试/测试句柄
try { uW.__pf = { G, cfg, maybeMove, detectSide, detectSideFromBoard }; } catch (e) { /* ignore */ }
// ---------- 悬浮面板 ----------
const ui = {};
function buildPanel() {
const panel = document.createElement('div');
panel.id = 'pf-panel';
panel.innerHTML = `
<style>
#pf-panel{position:fixed;top:64px;right:12px;z-index:99999;width:210px;
background:#1e2228;color:#dde3ea;border:1px solid #3a414b;border-radius:8px;
font:12px/1.6 system-ui,sans-serif;padding:10px 12px;box-shadow:0 4px 16px rgba(0,0,0,.4)}
#pf-panel .row{display:flex;align-items:center;justify-content:space-between;margin:4px 0}
#pf-panel button{background:#c0392b;color:#fff;border:0;border-radius:4px;
padding:3px 10px;cursor:pointer;font-size:12px}
#pf-panel input[type=number]{width:60px;background:#12151a;color:#dde3ea;
border:1px solid #3a414b;border-radius:4px;padding:2px 4px}
#pf-panel select{background:#12151a;color:#dde3ea;border:1px solid #3a414b;border-radius:4px}
#pf-panel .dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:4px}
#pf-panel .title{font-weight:600;margin-bottom:4px;display:flex;justify-content:space-between}
#pf-panel #pf-status{color:#9fb0c3;min-height:18px;word-break:break-all}
#pf-panel #pf-score{color:#e67e22;word-break:break-all}
</style>
<div class="title"><span>🐴 皮卡鱼</span><span class="dot" id="pf-dot" style="background:#666"></span></div>
<div class="row"><label><input type="checkbox" id="pf-enabled"> 自动走子</label>
<span id="pf-side-hint"></span></div>
<div class="row"><span>每步用时(ms)</span><input type="number" id="pf-mt" min="50" step="50"></div>
<div class="row"><span>深度(0=用时间)</span><input type="number" id="pf-depth" min="0" max="40" step="1"></div>
<div class="row"><span>首步延迟(秒+5)</span><input type="number" id="pf-delay" min="0" max="120" step="1"></div>
<div class="row"><span>我方执子</span>
<select id="pf-side"><option value="auto">自动识别</option>
<option value="red">红方</option><option value="black">黑方</option></select></div>
<div class="row"><button id="pf-now">立即走子</button></div>
<div id="pf-status">等待对局…</div>
<div id="pf-score"></div>`;
document.documentElement.appendChild(panel);
ui.status = panel.querySelector('#pf-status');
ui.score = panel.querySelector('#pf-score');
ui.sideHint = panel.querySelector('#pf-side-hint');
const dot = panel.querySelector('#pf-dot');
const cb = panel.querySelector('#pf-enabled');
cb.checked = cfg.enabled;
cb.onchange = () => { cfg.enabled = cb.checked; saveCfg(); };
const mt = panel.querySelector('#pf-mt');
mt.value = cfg.movetime;
mt.onchange = () => { cfg.movetime = Math.max(50, +mt.value || 800); saveCfg(); };
const dp = panel.querySelector('#pf-depth');
dp.value = cfg.depth;
dp.onchange = () => { cfg.depth = Math.max(0, +dp.value || 0); saveCfg(); };
const fd = panel.querySelector('#pf-delay');
fd.value = cfg.firstDelay;
fd.onchange = () => {
cfg.firstDelay = Math.max(0, +fd.value || 0);
G.firstMoveDeadline = null; // 重设后重新计时
saveCfg();
};
const sel = panel.querySelector('#pf-side');
sel.value = cfg.sideOverride;
sel.onchange = () => { cfg.sideOverride = sel.value; saveCfg(); };
panel.querySelector('#pf-now').onclick = () => maybeMove(true);
setInterval(async () => {
try {
const r = await fetch(ENGINE_URL + '/health');
dot.style.background = r.ok ? '#2ecc71' : '#e74c3c';
} catch (e) { dot.style.background = '#7f8c8d'; }
}, 5000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', buildPanel);
} else {
buildPanel();
}
})();