修复 Kimi 改坏的两处: 走法正则拒绝两位行号(h10g8), 执子识别 DOM 范围收窄误命中 svg

This commit is contained in:
AwesomeXiangQi committed 2026-09-13 14:12:01 +08:00
1 parent 32ddbdabed
commit dad361e4b0
1 file changed
+414 -209
+414 -209
View File
@@ -2,7 +2,7 @@
// @name Xiangqi.com 本地皮卡鱼引擎对接 // @name Xiangqi.com 本地皮卡鱼引擎对接
// @name:en Xiangqi.com Local Pikafish Engine // @name:en Xiangqi.com Local Pikafish Engine
// @namespace awesome-xiangqi // @namespace awesome-xiangqi
// @version 1.3.0 // @version 1.4.1
// @description 在 play.xiangqi.com 对局中调用本地皮卡鱼引擎(http://127.0.0.1:9377)自动/手动走子 // @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 // @description:en Hook the site WebSocket and let a local Pikafish engine play for you
// @match https://play.xiangqi.com/* // @match https://play.xiangqi.com/*
@@ -11,294 +11,476 @@
// ==/UserScript== // ==/UserScript==
(function () { (function () {
'use strict'; "use strict";
const ENGINE_URL = 'http://127.0.0.1:9377'; const ENGINE_URL = "http://127.0.0.1:9377";
const NS = '42/xiangqi,'; const NS = "42/xiangqi,";
const uW = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; const FIRST_MOVE_EXTRA_SEC = 5;
const VERSION = "1.4.1";
const PATCH_MARK = Symbol.for("xiangqi.pikafish.ws.patched");
const uW = typeof unsafeWindow !== "undefined" ? unsafeWindow : window;
// ---------- 坐标转换: 引擎 UCCI(行0-9) <-> 站点(行1-10) ---------- const storage = (() => {
const ucciToSite = m => m.replace(/\d+/g, d => String(+d + 1)); const mem = new Map();
// 站点 -> 引擎在服务端做; 这里只需要 ucciToSite const safe = {
get(k) {
// ---------- 对局状态 ---------- try {
const G = { return localStorage.getItem(k);
id: null, } catch (_) {
moves: [], // 站点坐标走法序列, 如 ['b3e3', 'h10g8'] return mem.has(k) ? mem.get(k) : null;
fen: null, // 服务器推送的当前局面 FEN }
mySide: null, // 'red' | 'black' | null(未知) },
over: false, set(k, v) {
thinking: false, mem.set(k, String(v));
firstMoveDeadline: null, // 红方首步延迟截止时间戳 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 = { const cfg = {
enabled: localStorage.getItem('pf_enabled') === '1', enabled: storage.get("pf_enabled", "0") === "1",
movetime: +(localStorage.getItem('pf_movetime') || 800), movetime: Math.max(50, +storage.get("pf_movetime", 800) || 800),
depth: +(localStorage.getItem('pf_depth') || 0), // 0 = 按用时; >0 = 固定深度 depth: Math.max(0, +storage.get("pf_depth", 0) || 0),
firstDelay: +(localStorage.getItem('pf_firstdelay') || 0), // 红方首步延迟秒数(实际=此值+5) firstDelay: Math.max(0, +storage.get("pf_firstdelay", 0) || 0),
sideOverride: localStorage.getItem('pf_side') || 'auto', // auto|red|black sideOverride: ["auto", "red", "black"].includes(
storage.get("pf_side", "auto"),
)
? storage.get("pf_side", "auto")
: "auto",
}; };
function saveCfg() { function saveCfg() {
localStorage.setItem('pf_enabled', cfg.enabled ? '1' : '0'); storage.set("pf_enabled", cfg.enabled ? "1" : "0");
localStorage.setItem('pf_movetime', cfg.movetime); storage.set("pf_movetime", cfg.movetime);
localStorage.setItem('pf_depth', cfg.depth); storage.set("pf_depth", cfg.depth);
localStorage.setItem('pf_firstdelay', cfg.firstDelay); storage.set("pf_firstdelay", cfg.firstDelay);
localStorage.setItem('pf_side', cfg.sideOverride); storage.set("pf_side", cfg.sideOverride);
} }
// ---------- 工具 ---------- const G = {
id: null,
moves: [],
fen: null,
mySide: null,
over: false,
thinking: false,
firstMoveDeadline: 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() { function jwtUsername() {
try { try {
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i); const k = localStorage.key(i);
if (!/jwt/i.test(k)) continue; if (!/jwt/i.test(k || "")) continue;
const v = String(localStorage.getItem(k)); const v = String(localStorage.getItem(k) || "");
const m = v.match(/^eyJ[\w-]+\.(eyJ[\w-]+)\./); const m = v.match(/^eyJ[\w-]+\.(eyJ[\w-]+)\./);
if (!m) continue; if (!m) continue;
const payload = JSON.parse(decodeURIComponent(escape(atob(m[1].replace(/-/g, '+').replace(/_/g, '/'))))); const payload = JSON.parse(
if (payload.sub) return payload.sub; decodeURIComponent(
encodeURIComponent(
atob(m[1].replace(/-/g, "+").replace(/_/g, "/")),
),
),
);
if (payload && payload.sub) return payload.sub;
} }
} catch (e) { /* ignore */ } } catch (_) {}
return null; return null;
} }
function isMyTurn() { function isMyTurn() {
if (!G.mySide || G.over) return false; if (!G.mySide || G.over) return false;
return G.mySide === 'red' ? G.moves.length % 2 === 0 : G.moves.length % 2 === 1; 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) { function parseFrame(raw) {
if (typeof raw !== 'string' || !raw.startsWith(NS)) return null; if (typeof raw !== "string" || !raw.startsWith(NS)) return null;
const body = raw.slice(NS.length); const body = raw.slice(NS.length);
const m = body.match(/^(\d+)?(\[[\s\S]*\])$/); const m = body.match(/^(\d+)?(\[[\s\S]*\])$/);
if (!m) return null; if (!m) return null;
try { try {
const arr = JSON.parse(m[2]); const arr = JSON.parse(m[2]);
return { name: arr[0], data: arr.length > 1 ? arr[1] : {} }; return { name: arr[0], data: arr.length > 1 ? arr[1] : {} };
} catch (e) { return null; } } catch (_) {
return null;
}
} }
// ---------- 对局事件 ---------- function detectSideFromBoard(force = false) {
function onGameEvent(name, data) { const now = Date.now();
if (name === 'game.get.success') { if (!force && now - G.sideCache.at < 1500 && G.sideCache.side) {
const d = data.data || {}; G.mySide = G.sideCache.side;
G.id = d.id || G.id; return G.mySide;
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') { try {
// 我方走子被服务器接受; 若该走法不在本地序列(例如手动点的), 记录之 // 直接全文档查棋子: 收窄到 .board/[class*=board]/svg 容易命中无棋子的节点导致识别失败
const d = data.data || {}; const avgY = (cls) => {
if (d.fen) G.fen = d.fen; const els = [...document.querySelectorAll("img[alt]")].filter((i) => {
const mv = String(d.move || '').split(' ')[0]; if (i.closest("#pf-panel")) return false;
if (mv && G.moves[G.moves.length - 1] !== mv) { const a = i.alt || "";
G.moves.push(mv); return (
status(`已走: ${mv}`); a.includes(cls) &&
} /pawn|rook|cannon|horse|elephant|advisor|king/i.test(a)
G.thinking = false; );
} else if (name === 'game.server.end') { });
G.over = true; G.thinking = false; if (!els.length) return null;
status(`对局结束(end_reason=${(data.data || {}).end_reason})`); 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) { function detectSide(d) {
const want = cfg.sideOverride !== 'auto' ? cfg.sideOverride : jwtUsername(); const want = cfg.sideOverride !== "auto" ? cfg.sideOverride : jwtUsername();
const r = d.rplayer || {}, b = d.bplayer || {}; const r = d.rplayer || {};
const b = d.bplayer || {};
G.mySide = null; G.mySide = null;
if (r.username === want || r.identity === want) G.mySide = 'red'; G.sideCache = { at: 0, side: null };
else if (b.username === want || b.identity === want) G.mySide = 'black'; if (want && (r.username === want || r.identity === want)) G.mySide = "red";
if (!G.mySide && cfg.sideOverride === 'auto') detectSideFromBoard(); else if (want && (b.username === want || b.identity === want))
if (G.mySide) ui.sideHint.textContent = `我方: ${G.mySide === 'red' ? '红方' : '黑方'}`; G.mySide = "black";
else ui.sideHint.textContent = '未识别执子, 请在下方手动指定'; if (!G.mySide && cfg.sideOverride === "auto") detectSideFromBoard(true);
setSideHint(
G.mySide ? `我方: ${sideName(G.mySide)}` : "未识别执子, 请在下方手动指定",
);
} }
// 兜底: 站点总是把我方棋子渲染在下半区, 直接从棋盘 DOM 判断颜色 let liveWS = null;
function detectSideFromBoard() {
async function engineBestmove({ moves, fen, movetime, depth, 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 { try {
const avgy = cls => { const resp = await fetch(ENGINE_URL + "/bestmove", {
const els = [...document.querySelectorAll('img[alt]')].filter(i => { method: "POST",
if (i.closest('#pf-panel')) return false; headers: { "Content-Type": "application/json" },
const a = i.alt || ''; body: JSON.stringify({
return a.includes(cls) && /pawn|rook|cannon|horse|elephant|advisor|king/i.test(a); moves,
fen: fen || undefined,
movetime,
depth: depth > 0 ? depth : undefined,
}),
signal: ctrl.signal,
}); });
if (!els.length) return null; const text = await resp.text();
return els.reduce((s, p) => s + p.getBoundingClientRect().y, 0) / els.length; let out = null;
}; try {
const redY = avgy('red'); out = text ? JSON.parse(text) : null;
const blkY = avgy('brown') ?? avgy('black'); } catch (_) {}
if (redY == null || blkY == null) return null; if (!resp.ok)
G.mySide = redY > blkY ? 'red' : 'black'; throw new Error((out && out.error) || `HTTP ${resp.status}`);
if (ui.sideHint) ui.sideHint.textContent = `我方: ${G.mySide === 'red' ? '红方' : '黑方'}`; if (
return G.mySide; !out ||
} catch (e) { return null; } 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) {
// 红方首步延迟: 实际等待 = 设置秒数 + 5 秒 if (!liveWS || liveWS.readyState !== WebSocket.OPEN) return false;
const FIRST_MOVE_EXTRA_SEC = 5; 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;
}
async function maybeMove(force) { function scheduleTurn(delay = 0) {
if (!cfg.enabled && !force) return; if (G.turnTimer) return;
if (G.over || G.thinking || !G.id) 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) detectSideFromBoard();
if (!G.mySide) return; if (!G.mySide) return;
if (!force && !isMyTurn()) return; if (!isMyTurn()) {
if (force && !isMyTurn()) { status('现在不是你走棋'); return; } if (force) setStatus("现在不是你走棋");
return;
}
// 计时起点包含首步延迟等待, 上报的 thinking_time 才真实
const t0 = Date.now(); const t0 = Date.now();
if (G.moves.length === 0 && G.mySide === "red" && !force) {
// 整局第一步且我执红: 延迟出招(手动"立即走子"不受限)
if (G.moves.length === 0 && G.mySide === 'red' && !force) {
const totalMs = (cfg.firstDelay + FIRST_MOVE_EXTRA_SEC) * 1000; const totalMs = (cfg.firstDelay + FIRST_MOVE_EXTRA_SEC) * 1000;
if (!G.firstMoveDeadline) G.firstMoveDeadline = Date.now() + totalMs; if (!G.firstMoveDeadline) G.firstMoveDeadline = Date.now() + totalMs;
const remain = G.firstMoveDeadline - Date.now(); const remain = G.firstMoveDeadline - Date.now();
if (remain > 0) { if (remain > 0) {
status(`红方首步延迟中: ${Math.ceil(remain / 1000)}s`); setStatus(`红方首步延迟中: ${Math.ceil(remain / 1000)}s`);
scheduleTurn(Math.min(remain, 1000));
return; return;
} }
} }
const reqId = ++G.reqId;
G.thinking = true; G.thinking = true;
status('皮卡鱼思考中…', true); setStatus("皮卡鱼思考中…", true);
try { try {
const resp = await fetch(ENGINE_URL + '/bestmove', { const out = await engineBestmove({
method: 'POST', moves: G.moves.slice(),
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
moves: G.moves,
fen: G.fen, fen: G.fen,
movetime: cfg.movetime, movetime: cfg.movetime,
depth: cfg.depth > 0 ? cfg.depth : undefined, depth: cfg.depth,
}),
}); });
const out = await resp.json(); if (reqId !== G.reqId) return;
if (!out.ok) throw new Error(out.error || ('HTTP ' + resp.status)); if (G.over) throw new Error("对局已结束");
if (G.over) throw new Error('对局已结束'); const move = out.move.trim();
const move = out.move; // 站点坐标 // 列 a-i, 行 1-10 (两位行号合法)
if (G.moves[G.moves.length - 1] === move) throw new Error('引擎重复走子'); if (!/^[a-i](?:10|[1-9])[a-i](?:10|[1-9])$/i.test(move))
if (!isMyTurn()) { status('轮次已变化, 放弃本次走子'); G.thinking = false; return; } 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.moves.push(move);
sendMove(move, Date.now() - t0); setScore(
ui.score.textContent = `${out.source === 'cloud' ? '☁ 云库' : '♞ 引擎'} · 评分 ${out.score || '?'} · ${out.ms}ms`; `${out.source === "cloud" ? "☁ 云库" : "♞ 引擎"} · 评分 ${out.score ?? "?"} · ${out.ms ?? "?"}ms`,
status(`已走: ${move}`); );
setStatus(`已走: ${move}`);
} catch (e) { } catch (e) {
status('引擎错误: ' + e.message, true); if (reqId === G.reqId)
G.thinking = false; setStatus(`引擎错误: ${e && e.message ? e.message : e}`, true);
} finally {
if (reqId === G.reqId) G.thinking = false;
} }
} }
let liveWS = null; function resetGame() {
function sendMove(move, thinkMs) { G.over = false;
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; 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})`);
}
} }
// ---------- WebSocket 钩子 (原型级补丁, 对已存在/未来的连接都生效) ----------
function onInFrame(raw, ws) { function onInFrame(raw, ws) {
liveWS = ws; liveWS = ws;
const ev = parseFrame(raw); const ev = parseFrame(raw);
if (ev) { if (!ev) return;
G.evCount = (G.evCount || 0) + 1; G.evCount++;
try { onGameEvent(ev.name, ev.data); } catch (e) { console.error('[pf]', e); } try {
onGameEvent(ev.name, ev.data);
} catch (e) {
console.error("[pf]", e);
} }
} }
function patchWebSocket() { function patchWebSocket() {
const SP = uW.WebSocket && uW.WebSocket.prototype; const SP = uW.WebSocket && uW.WebSocket.prototype;
if (!SP || SP.__pfPatched) return; if (!SP || SP[PATCH_MARK]) return;
const wrappedMap = new WeakMap();
const origSend = SP.send; const origSend = SP.send;
SP.send = function (d) { SP.send = function (d) {
if (typeof d === 'string' && d.startsWith(NS)) liveWS = this; if (typeof d === "string" && d.startsWith(NS)) liveWS = this;
return origSend.apply(this, arguments); return origSend.apply(this, arguments);
}; };
// 包一层 addEventListener('message') 以捕获收帧
const wrappedMap = new WeakMap();
const origAEL = SP.addEventListener; const origAEL = SP.addEventListener;
SP.addEventListener = function (type, fn, opts) { SP.addEventListener = function (type, fn, opts) {
if (type === 'message' && typeof fn === 'function' && !wrappedMap.has(fn)) { if (type === "message" && typeof fn === "function") {
const wrapped = function (ev) { onInFrame(String(ev.data), this); }; let wrapped = wrappedMap.get(fn);
if (!wrapped) {
wrapped = function (ev) {
onInFrame(String(ev.data), this);
fn.call(this, ev);
};
wrappedMap.set(fn, wrapped); 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, wrapped, opts);
return origAEL.call(this, type, wrappedMap.get(fn), opts);
} }
return origAEL.apply(this, arguments); return origAEL.apply(this, arguments);
}; };
// 兼容 onmessage 属性赋值方式 const origREL = SP.removeEventListener;
const omDesc = Object.getOwnPropertyDescriptor(SP, 'onmessage'); SP.removeEventListener = function (type, fn, opts) {
if (omDesc && omDesc.set) { if (
Object.defineProperty(SP, 'onmessage', { type === "message" &&
configurable: true, typeof fn === "function" &&
get() { return this.__pfOrigOM || null; }, wrappedMap.has(fn)
set(fn) { ) {
this.__pfOrigOM = fn; const wrapped = wrappedMap.get(fn);
const wrapped = function (ev) { wrappedMap.delete(fn);
onInFrame(String(ev.data), this); return origREL.call(this, type, wrapped, opts);
if (typeof fn === 'function') fn.call(this, ev); }
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); omDesc.set.call(this, wrapped);
}, },
}); });
} }
SP.__pfPatched = true; SP[PATCH_MARK] = true;
} }
patchWebSocket(); patchWebSocket();
// 常规兜底轮询: 每秒检查是否轮到我方(覆盖事件时序抖动) setInterval(() => {
setInterval(() => { if (cfg.enabled) maybeMove(); }, 1000); if (cfg.enabled && !document.hidden) scheduleTurn(0);
}, 1000);
// 调试/测试句柄 try {
try { uW.__pf = { G, cfg, maybeMove, detectSide, detectSideFromBoard }; } catch (e) { /* ignore */ } uW.__pf = {
version: VERSION,
G,
cfg,
maybeMove,
detectSide,
detectSideFromBoard,
saveCfg,
};
} catch (_) {}
// ---------- 悬浮面板 ----------
const ui = {};
function buildPanel() { function buildPanel() {
const panel = document.createElement('div'); if (ui.panel) return;
panel.id = 'pf-panel'; const panel = document.createElement("div");
panel.id = "pf-panel";
panel.innerHTML = ` panel.innerHTML = `
<style> <style>
#pf-panel{position:fixed;top:64px;right:12px;z-index:99999;width:210px; #pf-panel{position:fixed;top:64px;right:12px;z-index:99999;width:210px;
background:#1e2228;color:#dde3ea;border:1px solid #3a414b;border-radius:8px; 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)} 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 .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; #pf-panel button{background:#c0392b;color:#fff;border:0;border-radius:4px;
padding:3px 10px;cursor:pointer;font-size:12px} padding:3px 10px;cursor:pointer;font-size:12px}
#pf-panel input[type=number]{width:60px;background:#12151a;color:#dde3ea; #pf-panel input[type=number]{width:60px;background:#12151a;color:#dde3ea;
@@ -323,47 +505,70 @@
<div id="pf-score"></div>`; <div id="pf-score"></div>`;
document.documentElement.appendChild(panel); document.documentElement.appendChild(panel);
ui.status = panel.querySelector('#pf-status'); ui.panel = panel;
ui.score = panel.querySelector('#pf-score'); ui.status = panel.querySelector("#pf-status");
ui.sideHint = panel.querySelector('#pf-side-hint'); ui.score = panel.querySelector("#pf-score");
const dot = panel.querySelector('#pf-dot'); ui.sideHint = panel.querySelector("#pf-side-hint");
const dot = panel.querySelector("#pf-dot");
const cb = panel.querySelector('#pf-enabled'); const cb = panel.querySelector("#pf-enabled");
cb.checked = cfg.enabled; cb.checked = cfg.enabled;
cb.onchange = () => { cfg.enabled = cb.checked; saveCfg(); }; cb.addEventListener("change", () => {
cfg.enabled = cb.checked;
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(); saveCfg();
}; if (cfg.enabled) scheduleTurn(0);
});
const sel = panel.querySelector('#pf-side'); 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 sel = panel.querySelector("#pf-side");
sel.value = cfg.sideOverride; sel.value = cfg.sideOverride;
sel.onchange = () => { cfg.sideOverride = sel.value; saveCfg(); }; sel.addEventListener("change", () => {
cfg.sideOverride = sel.value;
saveCfg();
});
panel.querySelector('#pf-now').onclick = () => maybeMove(true); panel
.querySelector("#pf-now")
.addEventListener("click", () => maybeMove(true));
setInterval(async () => { const ping = async () => {
try { try {
const r = await fetch(ENGINE_URL + '/health'); const r = await fetch(ENGINE_URL + "/health", { cache: "no-store" });
dot.style.background = r.ok ? '#2ecc71' : '#e74c3c'; dot.style.background = r.ok ? "#2ecc71" : "#e74c3c";
} catch (e) { dot.style.background = '#7f8c8d'; } } catch (_) {
}, 5000); dot.style.background = "#7f8c8d";
}
};
ping();
setInterval(ping, 5000);
} }
if (document.readyState === 'loading') { if (document.readyState === "loading") {
document.addEventListener('DOMContentLoaded', buildPanel); document.addEventListener("DOMContentLoaded", buildPanel, { once: true });
} else { } else {
buildPanel(); buildPanel();
} }