// ==UserScript== // @name Xiangqi.com 本地皮卡鱼引擎对接 // @name:en Xiangqi.com Local Pikafish Engine // @namespace awesome-xiangqi // @version 1.7.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 FIRST_MOVE_EXTRA_SEC = 5; const VERSION = "1.7.0"; const PATCH_MARK = Symbol.for("xiangqi.pikafish.ws.patched"); const uW = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const storage = (() => { const mem = new Map(); const safe = { get(k) { try { return localStorage.getItem(k); } catch (_) { return mem.has(k) ? mem.get(k) : null; } }, set(k, v) { mem.set(k, String(v)); try { localStorage.setItem(k, String(v)); } catch (_) {} }, }; return { get: (k, d = null) => { const v = safe.get(k); return v == null ? d : v; }, set: safe.set, }; })(); const cfg = { enabled: storage.get("pf_enabled", "0") === "1", useCloud: storage.get("pf_usecloud", "1") === "1", cloudAlways: storage.get("pf_cloudalways", "0") === "1", movetime: Math.max(50, +storage.get("pf_movetime", 800) || 800), depth: Math.max(0, +storage.get("pf_depth", 0) || 0), firstDelay: Math.max(0, +storage.get("pf_firstdelay", 0) || 0), moveDelay: Math.max(0, +storage.get("pf_movedelay", 1) || 1), // 每步最小间隔秒(深度/云库秒回时保证节奏) sideOverride: ["auto", "red", "black"].includes( storage.get("pf_side", "auto"), ) ? storage.get("pf_side", "auto") : "auto", }; function saveCfg() { storage.set("pf_enabled", cfg.enabled ? "1" : "0"); storage.set("pf_usecloud", cfg.useCloud ? "1" : "0"); storage.set("pf_cloudalways", cfg.cloudAlways ? "1" : "0"); storage.set("pf_movetime", cfg.movetime); storage.set("pf_depth", cfg.depth); storage.set("pf_firstdelay", cfg.firstDelay); storage.set("pf_movedelay", cfg.moveDelay); storage.set("pf_side", cfg.sideOverride); } const G = { id: null, moves: [], fen: null, mySide: null, over: false, thinking: false, firstMoveDeadline: 0, lastMoveAt: 0, reqId: 0, turnTimer: 0, sideCache: { at: 0, side: null }, evCount: 0, }; const ui = {}; const clampInt = (v, min, max, dflt) => { const n = Number.parseInt(v, 10); if (Number.isNaN(n)) return dflt; return Math.min(max, Math.max(min, n)); }; const sideName = (s) => (s === "red" ? "红方" : s === "black" ? "黑方" : "?"); const setStatus = (msg, keepScore = false) => { if (!ui.status) return; if (ui.status.__txt !== msg) { ui.status.__txt = msg; ui.status.textContent = msg; } if (!keepScore && ui.score) ui.score.textContent = ""; }; const setScore = (txt) => { if (ui.score && ui.score.textContent !== txt) ui.score.textContent = txt; }; const setSideHint = (txt) => { if (ui.sideHint && ui.sideHint.textContent !== txt) ui.sideHint.textContent = txt; }; 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( encodeURIComponent( atob(m[1].replace(/-/g, "+").replace(/_/g, "/")), ), ), ); if (payload && payload.sub) return payload.sub; } } catch (_) {} 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 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 (_) { return null; } } function detectSideFromBoard(force = false) { const now = Date.now(); if (!force && now - G.sideCache.at < 1500 && G.sideCache.side) { G.mySide = G.sideCache.side; return G.mySide; } try { // 直接全文档查棋子: 收窄到 .board/[class*=board]/svg 容易命中无棋子的节点导致识别失败 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"; G.sideCache = { at: now, side: G.mySide }; setSideHint(`我方: ${sideName(G.mySide)}`); return G.mySide; } catch (_) { return null; } } function detectSide(d) { const want = cfg.sideOverride !== "auto" ? cfg.sideOverride : jwtUsername(); const r = d.rplayer || {}; const b = d.bplayer || {}; G.mySide = null; G.sideCache = { at: 0, side: null }; if (want && (r.username === want || r.identity === want)) G.mySide = "red"; else if (want && (b.username === want || b.identity === want)) G.mySide = "black"; if (!G.mySide && cfg.sideOverride === "auto") detectSideFromBoard(true); setSideHint( G.mySide ? `我方: ${sideName(G.mySide)}` : "未识别执子, 请在下方手动指定", ); } let liveWS = null; async function engineBestmove({ moves, fen, movetime, depth, useCloud, cloudAlways, signal }) { const timeoutMs = Math.max(15000, movetime * 4 + 2000); const ctrl = new AbortController(); const timer = setTimeout( () => ctrl.abort(new Error("引擎请求超时")), timeoutMs, ); const onAbort = () => ctrl.abort(signal.reason); if (signal) { if (signal.aborted) ctrl.abort(signal.reason); else signal.addEventListener("abort", onAbort, { once: true }); } try { const resp = await fetch(ENGINE_URL + "/bestmove", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ moves, fen: fen || undefined, movetime, depth: depth > 0 ? depth : undefined, cloud: useCloud !== false, cloud_always: !!cloudAlways, }), signal: ctrl.signal, }); const text = await resp.text(); let out = null; try { out = text ? JSON.parse(text) : null; } catch (_) {} if (!resp.ok) throw new Error((out && out.error) || `HTTP ${resp.status}`); if ( !out || typeof out !== "object" || !out.ok || typeof out.move !== "string" || !out.move ) { throw new Error((out && out.error) || "引擎返回异常"); } return out; } finally { clearTimeout(timer); if (signal) signal.removeEventListener("abort", onAbort); } } function sendMove(move, thinkMs) { if (!liveWS || liveWS.readyState !== WebSocket.OPEN) return false; const frame = NS + `["g_v2.move",${JSON.stringify({ game_id: G.id, move, thinking_time: Math.max(0, Math.round(thinkMs || 0)), })}]`; liveWS.send(frame); return true; } function scheduleTurn(delay = 0) { if (G.turnTimer) return; G.turnTimer = setTimeout( () => { G.turnTimer = 0; maybeMove(); }, Math.max(0, delay), ); } async function maybeMove(force = false) { if ((!cfg.enabled && !force) || G.over || G.thinking || !G.id) return; if (!G.mySide) detectSideFromBoard(); if (!G.mySide) return; if (!isMyTurn()) { if (force) setStatus("现在不是你走棋"); return; } 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) { setStatus(`红方首步延迟中: ${Math.ceil(remain / 1000)}s`); scheduleTurn(Math.min(remain, 1000)); return; } } // 每步最小间隔(深度/云库秒回时保持行棋节奏), 计入 thinking_time G.thinking = true; if (!force && cfg.moveDelay > 0) { const remainMs = cfg.moveDelay * 1000 - (Date.now() - (G.lastMoveAt || 0)); if (remainMs > 0) { setStatus(`行棋节奏延迟: ${Math.ceil(remainMs / 1000)}s`, true); await new Promise((r) => setTimeout(r, remainMs)); if (G.over || !isMyTurn()) { G.thinking = false; return; } } } const reqId = ++G.reqId; try { const out = await engineBestmove({ moves: G.moves.slice(), fen: G.fen, movetime: cfg.movetime, depth: cfg.depth, useCloud: cfg.useCloud, cloudAlways: cfg.cloudAlways, }); if (reqId !== G.reqId) return; if (G.over) throw new Error("对局已结束"); const move = out.move.trim(); // 列 a-i, 行 1-10 (两位行号合法) if (!/^[a-i](?:10|[1-9])[a-i](?:10|[1-9])$/i.test(move)) throw new Error(`非法走法: ${move}`); if (G.moves[G.moves.length - 1] === move) throw new Error("引擎重复走子"); if (!isMyTurn()) { setStatus("轮次已变化, 放弃本次走子"); return; } if (!sendMove(move, Date.now() - t0)) throw new Error("未找到 socket 连接"); G.moves.push(move); G.lastMoveAt = Date.now(); setScore( `${out.source === "cloud" ? "☁ 云库" : "♞ 引擎"} · 评分 ${out.score ?? "?"} · ${out.ms ?? "?"}ms`, ); setStatus(`已走: ${move}`); } catch (e) { if (reqId === G.reqId) setStatus(`引擎错误: ${e && e.message ? e.message : e}`, true); } finally { if (reqId === G.reqId) G.thinking = false; } } function resetGame() { G.over = false; G.thinking = false; G.firstMoveDeadline = 0; G.sideCache = { at: 0, side: null }; } function pushMoveIfNew(mv) { if (mv && G.moves[G.moves.length - 1] !== mv) { G.moves.push(mv); return true; } return false; } function onGameEvent(name, data) { const d = (data && data.data) || {}; if (name === "game.get.success") { G.id = d.id || G.id; G.moves = Array.isArray(d.move_history) ? d.move_history.map((m) => String((m && m.move) || m).split(" ")[0]) : []; G.fen = d.fen || null; resetGame(); detectSide(d); setStatus( `已同步对局 ${G.id}(${G.moves.length} 步, 我方${sideName(G.mySide)})`, ); scheduleTurn(50); } else if (name === "game.start.success") { resetGame(); scheduleTurn(600); scheduleTurn(1800); } else if (name === "game.move.opponent") { const mv = String(d.move || "").split(" ")[0]; if (d.fen) G.fen = d.fen; if (mv && pushMoveIfNew(mv)) { setStatus(`对手: ${mv}`); scheduleTurn(80); } } else if (name === "game.move.success") { const mv = String(d.move || "").split(" ")[0]; if (d.fen) G.fen = d.fen; if (mv && pushMoveIfNew(mv)) setStatus(`已走: ${mv}`); G.thinking = false; } else if (name === "game.server.end") { G.over = true; G.thinking = false; setStatus(`对局结束(end_reason=${d.end_reason})`); } } function onInFrame(raw, ws) { liveWS = ws; const ev = parseFrame(raw); if (!ev) return; G.evCount++; try { onGameEvent(ev.name, ev.data); } catch (e) { console.error("[pf]", e); } } function patchWebSocket() { const SP = uW.WebSocket && uW.WebSocket.prototype; if (!SP || SP[PATCH_MARK]) return; const wrappedMap = new WeakMap(); const origSend = SP.send; SP.send = function (d) { if (typeof d === "string" && d.startsWith(NS)) liveWS = this; return origSend.apply(this, arguments); }; const origAEL = SP.addEventListener; SP.addEventListener = function (type, fn, opts) { if (type === "message" && typeof fn === "function") { let wrapped = wrappedMap.get(fn); if (!wrapped) { wrapped = function (ev) { onInFrame(String(ev.data), this); fn.call(this, ev); }; wrappedMap.set(fn, wrapped); } return origAEL.call(this, type, wrapped, opts); } return origAEL.apply(this, arguments); }; const origREL = SP.removeEventListener; SP.removeEventListener = function (type, fn, opts) { if ( type === "message" && typeof fn === "function" && wrappedMap.has(fn) ) { const wrapped = wrappedMap.get(fn); wrappedMap.delete(fn); return origREL.call(this, type, wrapped, opts); } return origREL.apply(this, arguments); }; 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 = typeof fn === "function" ? fn : null; const wrapped = typeof fn === "function" ? function (ev) { onInFrame(String(ev.data), this); fn.call(this, ev); } : null; omDesc.set.call(this, wrapped); }, }); } SP[PATCH_MARK] = true; } patchWebSocket(); setInterval(() => { if (cfg.enabled && !document.hidden) scheduleTurn(0); }, 1000); try { uW.__pf = { version: VERSION, G, cfg, maybeMove, detectSide, detectSideFromBoard, saveCfg, }; } catch (_) {} function buildPanel() { if (ui.panel) return; const panel = document.createElement("div"); panel.id = "pf-panel"; panel.innerHTML = `
🐴 皮卡鱼
每步用时(ms)
深度(0=用时间)
首步延迟(秒+5)
每步延迟(秒)
我方执子
等待对局…
`; document.documentElement.appendChild(panel); ui.panel = 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.addEventListener("change", () => { cfg.enabled = cb.checked; saveCfg(); if (cfg.enabled) scheduleTurn(0); }); const cloudCb = panel.querySelector("#pf-cloud"); cloudCb.checked = cfg.useCloud; cloudCb.addEventListener("change", () => { cfg.useCloud = cloudCb.checked; saveCfg(); if (cfg.enabled) scheduleTurn(0); }); const caCb = panel.querySelector("#pf-cloudalways"); caCb.checked = cfg.cloudAlways; caCb.addEventListener("change", () => { cfg.cloudAlways = caCb.checked; saveCfg(); }); const mt = panel.querySelector("#pf-mt"); mt.value = cfg.movetime; mt.addEventListener("change", () => { cfg.movetime = clampInt(mt.value, 50, 600000, 800); mt.value = cfg.movetime; saveCfg(); }); const dp = panel.querySelector("#pf-depth"); dp.value = cfg.depth; dp.addEventListener("change", () => { cfg.depth = clampInt(dp.value, 0, 40, 0); dp.value = cfg.depth; saveCfg(); }); const fd = panel.querySelector("#pf-delay"); fd.value = cfg.firstDelay; fd.addEventListener("change", () => { cfg.firstDelay = clampInt(fd.value, 0, 120, 0); fd.value = cfg.firstDelay; G.firstMoveDeadline = 0; saveCfg(); }); const mv = panel.querySelector("#pf-mvdelay"); mv.value = cfg.moveDelay; mv.addEventListener("change", () => { cfg.moveDelay = clampInt(mv.value, 0, 60, 1); mv.value = cfg.moveDelay; saveCfg(); }); const sel = panel.querySelector("#pf-side"); sel.value = cfg.sideOverride; sel.addEventListener("change", () => { cfg.sideOverride = sel.value; saveCfg(); }); panel .querySelector("#pf-now") .addEventListener("click", () => maybeMove(true)); const ping = async () => { try { const r = await fetch(ENGINE_URL + "/health", { cache: "no-store" }); dot.style.background = r.ok ? "#2ecc71" : "#e74c3c"; } catch (_) { dot.style.background = "#7f8c8d"; } }; ping(); setInterval(ping, 5000); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", buildPanel, { once: true }); } else { buildPanel(); } })();