// ==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 = `
🐴 皮卡鱼
每步用时(ms)
深度(0=用时间)
首步延迟(秒+5)
我方执子
等待对局…
`; 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(); } })();