精简: 移除油猴脚本与HTTP服务端, 仅保留在线机器人(云库/深度/延迟功能全部收敛到 bot)
This commit is contained in:
1 parent
f5b3a6f823
commit
0a4acf61f2
6 files changed
+6
-773
No files matched your search
@@ -1 +1,5 @@
|
|||||||
# メタタオ: uv sync
|
# AwesomeXiangQi
|
||||||
|
|
||||||
|
Xiangqi.com 在线对弈自动机器人: 云库(chessdb.cn)优先 + 本地皮卡鱼兜底。
|
||||||
|
|
||||||
|
使用: uv sync 后 uv run online_bot.py, 参数见 uv run online_bot.py --help
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""本地引擎服务(云库优先 + 皮卡鱼兜底)。供油猴脚本调用。"""
|
|
||||||
from xiangqi_bot.server import main
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -8,5 +8,4 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
xiangqi-server = "xiangqi_bot.server:main"
|
|
||||||
xiangqi-bot = "xiangqi_bot.bot:main"
|
xiangqi-bot = "xiangqi_bot.bot:main"
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
// ==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 = `
|
|
||||||
<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;gap:8px}
|
|
||||||
#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"><label><input type="checkbox" id="pf-cloud"> 使用云库</label>
|
|
||||||
<label><input type="checkbox" id="pf-cloudalways"> 全程</label></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><input type="number" id="pf-mvdelay" min="0" max="60" 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.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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -1 +1 @@
|
|||||||
from xiangqi_bot import coords, cloud, pikafish, server, bot
|
# -*- coding: utf-8 -*-
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""本地 HTTP 引擎服务: 云库优先, 皮卡鱼兜底。供油猴脚本调用。"""
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
|
|
||||||
from .cloud import CloudBook
|
|
||||||
from .coords import fen_from_moves, site_to_ucci, ucci_to_site
|
|
||||||
from .pikafish import Pikafish
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s %(message)s')
|
|
||||||
log = logging.getLogger('xiangqi.server')
|
|
||||||
|
|
||||||
|
|
||||||
class Service:
|
|
||||||
def __init__(self, args):
|
|
||||||
self.args = args
|
|
||||||
self.cloud = CloudBook(min_score=args.cloud_min_score)
|
|
||||||
self.engine = None # 惰性启动(首个请求前/启动时)
|
|
||||||
|
|
||||||
async def startup(self, app):
|
|
||||||
self.engine = await Pikafish(
|
|
||||||
threads=self.args.threads, hash_mb=self.args.hash,
|
|
||||||
movetime=self.args.movetime,
|
|
||||||
log=(lambda m: log.debug(m)) if self.args.quiet else (lambda m: log.info(m)),
|
|
||||||
).start()
|
|
||||||
|
|
||||||
async def shutdown(self, app):
|
|
||||||
if self.engine:
|
|
||||||
await self.engine.quit()
|
|
||||||
await self.cloud.close()
|
|
||||||
|
|
||||||
async def bestmove(self, moves_site, fen=None, movetime=None, depth=None,
|
|
||||||
req_cloud=None, cloud_always=None):
|
|
||||||
moves_ucci = [site_to_ucci(m) for m in moves_site]
|
|
||||||
fen = fen or fen_from_moves(moves_ucci)
|
|
||||||
|
|
||||||
# 1. 云库优先(请求可显式关闭; 全程云库模式无视分数门槛和深度强制)
|
|
||||||
use_cloud = req_cloud if req_cloud is not None else True
|
|
||||||
if use_cloud:
|
|
||||||
try:
|
|
||||||
cloud_hit = await self.cloud.best(
|
|
||||||
fen, moves_count=len(moves_ucci),
|
|
||||||
any_score=bool(cloud_always))
|
|
||||||
except Exception as e:
|
|
||||||
log.warning('cloud error: %s', e)
|
|
||||||
cloud_hit = None
|
|
||||||
if cloud_hit:
|
|
||||||
mv, score, note = cloud_hit
|
|
||||||
return {'ok': True, 'source': 'cloud', 'move': ucci_to_site(mv),
|
|
||||||
'score': f'cp {score}', 'note': note, 'ms': 0}
|
|
||||||
|
|
||||||
# 2. 皮卡鱼兜底
|
|
||||||
try:
|
|
||||||
mv, score = await self.engine.bestmove(moves=moves_ucci, fen=fen,
|
|
||||||
movetime=movetime, depth=depth)
|
|
||||||
except Exception:
|
|
||||||
log.exception('engine crashed, restarting')
|
|
||||||
await self.engine.restart()
|
|
||||||
raise
|
|
||||||
return {'ok': True, 'source': 'engine', 'move': ucci_to_site(mv),
|
|
||||||
'score': score, 'note': None}
|
|
||||||
|
|
||||||
|
|
||||||
@web.middleware
|
|
||||||
async def cors_middleware(request, handler):
|
|
||||||
"""油猴脚本从 https 页面跨域调用本服务: 处理 OPTIONS 预检并补 CORS 头。"""
|
|
||||||
if request.method == 'OPTIONS':
|
|
||||||
resp = web.Response(status=204)
|
|
||||||
else:
|
|
||||||
resp = await handler(request)
|
|
||||||
resp.headers['Access-Control-Allow-Origin'] = '*'
|
|
||||||
resp.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS'
|
|
||||||
resp.headers['Access-Control-Allow-Headers'] = 'Content-Type'
|
|
||||||
return resp
|
|
||||||
|
|
||||||
|
|
||||||
def make_app(args):
|
|
||||||
svc = Service(args)
|
|
||||||
|
|
||||||
async def health(_):
|
|
||||||
return web.json_response({
|
|
||||||
'ok': True,
|
|
||||||
'engine': svc.engine.path if svc.engine else None,
|
|
||||||
'cloud': {'min_score': svc.cloud.min_score,
|
|
||||||
'hit': svc.cloud.ok_count, 'miss': svc.cloud.miss_count},
|
|
||||||
})
|
|
||||||
|
|
||||||
async def bestmove(request):
|
|
||||||
try:
|
|
||||||
req = await request.json()
|
|
||||||
except Exception:
|
|
||||||
return web.json_response({'ok': False, 'error': 'bad json'}, status=400)
|
|
||||||
moves = req.get('moves', [])
|
|
||||||
if not isinstance(moves, list) or not all(isinstance(m, str) for m in moves):
|
|
||||||
return web.json_response({'ok': False, 'error': 'moves 格式错误'}, status=400)
|
|
||||||
try:
|
|
||||||
out = await svc.bestmove(
|
|
||||||
moves,
|
|
||||||
fen=req.get('fen'),
|
|
||||||
movetime=req.get('movetime'),
|
|
||||||
depth=req.get('depth'),
|
|
||||||
req_cloud=req.get('cloud'),
|
|
||||||
cloud_always=req.get('cloud_always'),
|
|
||||||
)
|
|
||||||
return web.json_response(out)
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response({'ok': False, 'error': str(e)}, status=500)
|
|
||||||
|
|
||||||
app = web.Application(middlewares=[cors_middleware])
|
|
||||||
app.router.add_get('/health', health)
|
|
||||||
app.router.add_post('/bestmove', bestmove)
|
|
||||||
app.on_startup.append(svc.startup)
|
|
||||||
app.on_cleanup.append(svc.shutdown)
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
p = argparse.ArgumentParser(description='皮卡鱼 + 云库本地服务')
|
|
||||||
p.add_argument('--port', type=int, default=9377)
|
|
||||||
p.add_argument('--movetime', type=int, default=800, help='每步思考毫秒(深度未指定时)')
|
|
||||||
p.add_argument('--depth', type=int, default=None, help='固定深度(优先于 movetime)')
|
|
||||||
p.add_argument('--threads', type=int, default=None, help='线程数(默认=CPU核心数)')
|
|
||||||
p.add_argument('--hash', type=int, default=256, help='哈希表 MB')
|
|
||||||
p.add_argument('--cloud-min-score', type=int, default=15,
|
|
||||||
help='云库着法采纳阈值(厘兵, 红方视角)')
|
|
||||||
p.add_argument('--quiet', action='store_true', help='不打印引擎 info')
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
app = make_app(args)
|
|
||||||
mode = f'depth={args.depth}' if args.depth else f'movetime={args.movetime}ms'
|
|
||||||
print(f'[server] http://127.0.0.1:{args.port} ({mode}, '
|
|
||||||
f'threads={args.threads or "max"}, hash={args.hash}MB, '
|
|
||||||
f'cloud_min_score={args.cloud_min_score})', flush=True)
|
|
||||||
web.run_app(app, host='127.0.0.1', port=args.port, print=None)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
Reference in new issue
Block a user