自动下棋油猴脚本

xh  (UID: 2303) [复制链接]
帖子链接已复制到剪贴板
帖子已经有人评论啦,不支持删除!

361 10

刚才想把自动下棋的分享了,测试了一下,人机对战的【一键赢】失效了

看了代码,原来是呆哥又双叒叕更新五子棋逻辑了

不再继续更新脚本了,不过现在的可以脚本人机 vs 呆哥人机的功能还能用
具体还有什么功能可用,自己测吧

(可选)浏览器纯算法ai有点垃圾,也可以自己部署五子棋api,我也已经开源了,自己折腾,不提供教程
github地址:https://github.com/XingHehy/gomoku-rapfi



什么是‌Rapfi?


油猴脚本:

// ==UserScript==
// @name         大佬论坛 五子棋 - 人机/PVP AI助手
// @namespace    dalao-gomoku-panel
// @version      2.5
// @description  支持人机对战 + PVP对战自动/手动AI落子
// @author       xinghehy
// @match        *://www.dalao.net/*my-coin-gomoku.htm*
// @match        *://www.dalao.net/*my-coin-pvp.htm*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      127.0.0.1
// @connect      gomoku.nps.ixh.cc
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const PAGE_WIN = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;

  const PAGE = (() => {
    const href = String(location.href || '');
    if (href.includes('my-coin-gomoku.htm')) return 'gomoku';
    if (href.includes('my-coin-pvp.htm')) return 'pvp';
    return 'unknown';
  })();

  function waitFor(cond, { interval = 200, timeout = 15000 } = {}) {
    return new Promise((resolve, reject) => {
      const start = Date.now();
      const timer = setInterval(() => {
        try {
          const v = cond();
          if (v) {
            clearInterval(timer);
            resolve(v);
          } else if (Date.now() - start > timeout) {
            clearInterval(timer);
            reject(new Error('waitFor timeout'));
          }
        } catch (e) {
          // keep waiting
        }
      }, interval);
    });
  }

  function getMyUid() {
    // pvp.htm 末尾有 ws_config.uid,也有全局 uid
    const w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
    return (w.ws_config && w.ws_config.uid) || w.uid || 0;
  }

  function toInt(v, fallback = 0) {
    const n = typeof v === 'number' ? v : parseInt(String(v), 10);
    return Number.isFinite(n) ? n : fallback;
  }

  function toast(msg) {
    // 复用页面 toast(存在则用),否则 console
    const $ = PAGE_WIN.jQuery;
    if ($ && $('#pvp_toast').length) {
      $('#pvp_toast').stop(true, true).text('[AI] ' + msg).fadeIn().delay(1500).fadeOut();
      return;
    }
    console.log('[PVP-AI]', msg);
  }

  function pad2(n) {
    return String(n).padStart(2, '0');
  }

  function nowHHMMSS() {
    const d = new Date();
    return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
  }

  // ======= AI 引擎 =======
  const BOARD_SIZE = 15;
  const EMPTY = 0;

  // ======= Zobrist Hash + 置换表(Transposition Table) =======
  // 用 BigInt 做 64-bit 哈希,减少重复局面搜索开销
  function rand64() {
    try {
      const a = new Uint32Array(2);
      (crypto || PAGE_WIN.crypto).getRandomValues(a);
      return (BigInt(a[0]) << 32n) ^ BigInt(a[1]);
    } catch (e) {
      // 兜底:非加密随机(够用)
      const hi = (Math.random() * 0xffffffff) >>> 0;
      const lo = (Math.random() * 0xffffffff) >>> 0;
      return (BigInt(hi) << 32n) ^ BigInt(lo);
    }
  }

  const ZOBRIST = (() => {
    const z = [];
    for (let r = 0; r < BOARD_SIZE; r++) {
      z[r] = [];
      for (let c = 0; c < BOARD_SIZE; c++) {
        // index: 1/2 代表棋子颜色(0 为空不需要)
        z[r][c] = [0n, rand64(), rand64()];
      }
    }
    // side-to-move
    const side = [0n, rand64(), rand64()];
    return { z, side };
  })();

  function computeBoardHash(bd, sideToMove /* 1 or 2 */) {
    let h = 0n;
    for (let r = 0; r < BOARD_SIZE; r++) {
      for (let c = 0; c < BOARD_SIZE; c++) {
        const v = bd[r][c] | 0;
        if (v === 1 || v === 2) h ^= ZOBRIST.z[r][c][v];
      }
    }
    if (sideToMove === 1 || sideToMove === 2) h ^= ZOBRIST.side[sideToMove];
    return h;
  }

  // 置换表:存储 {value, bound},bound: 'exact'|'lower'|'upper'
  // key: `${hash}|${depth}|${maximizing}`
  const TT = new Map();
  const TT_MAX = 60000;
  function ttGet(key) {
    return TT.get(key);
  }
  function ttSet(key, entry) {
    TT.set(key, entry);
    if (TT.size > TT_MAX) {
      // 简单清理:删除前 1/4(Map 迭代顺序是插入顺序)
      let n = 0;
      const drop = (TT_MAX / 4) | 0;
      for (const k of TT.keys()) {
        TT.delete(k);
        if (++n >= drop) break;
      }
    }
  }

  const SCORES = {
    WIN: 100000000,
    LIVE_4: 10000000,
    DEAD_4: 1000000,
    LIVE_3: 100000,
    DEAD_3: 10000,
    LIVE_2: 1000,
    DEAD_2: 100,
    ONE: 10,
  };

  const POS_WEIGHTS = (() => {
    const w = [];
    for (let r = 0; r < BOARD_SIZE; r++) {
      w[r] = [];
      for (let c = 0; c < BOARD_SIZE; c++) {
        const dist = Math.abs(r - 7) + Math.abs(c - 7);
        w[r][c] = 7 - dist;
      }
    }
    return w;
  })();

  function cloneBoard(bd) {
    return bd.map((row) => row.slice());
  }

  function fastCheckWin(bd, r, c, p) {
    const directions = [
      [0, 1],
      [1, 0],
      [1, 1],
      [1, -1],
    ];
    for (const [dr, dc] of directions) {
      let count = 1;
      let tr = r + dr,
        tc = c + dc;
      while (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === p) {
        count++;
        tr += dr;
        tc += dc;
      }
      tr = r - dr;
      tc = c - dc;
      while (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === p) {
        count++;
        tr -= dr;
        tc -= dc;
      }
      if (count >= 5) return true;
    }
    return false;
  }

  function getCandidateMoves(bd, radius = 2) {
    // 邻域候选点:限制在已有棋子的包围盒 + radius,减少无意义扫描
    // 优化:借鉴Python的has_neightnor思路,确保候选点都有邻居
    if (isBoardEmpty(bd)) return [{ r: 7, c: 7 }];
    const moves = [];
    const visited = new Set();

    let minR = BOARD_SIZE,
      minC = BOARD_SIZE,
      maxR = -1,
      maxC = -1;
    for (let r = 0; r < BOARD_SIZE; r++) {
      for (let c = 0; c < BOARD_SIZE; c++) {
        if (bd[r][c] !== EMPTY) {
          if (r < minR) minR = r;
          if (c < minC) minC = c;
          if (r > maxR) maxR = r;
          if (c > maxC) maxC = c;
        }
      }
    }
    if (maxR < 0) return [{ r: 7, c: 7 }];
    minR = Math.max(0, minR - radius);
    minC = Math.max(0, minC - radius);
    maxR = Math.min(BOARD_SIZE - 1, maxR + radius);
    maxC = Math.min(BOARD_SIZE - 1, maxC + radius);

    for (let r = minR; r <= maxR; r++) {
      for (let c = minC; c <= maxC; c++) {
        if (bd[r][c] !== EMPTY) continue;
        // 优化:必须靠近已有棋子(邻居 1 格内至少有一个棋子)
        // 借鉴Python的has_neightnor检查,减少无效候选点
        let near = false;
        for (let dr = -1; dr <= 1 && !near; dr++) {
          for (let dc = -1; dc <= 1 && !near; dc++) {
            if (!dr && !dc) continue;
            const nr = r + dr,
              nc = c + dc;
            if (nr >= 0 && nr < BOARD_SIZE && nc >= 0 && nc < BOARD_SIZE && bd[nr][nc] !== EMPTY) near = true;
          }
        }
        if (!near) continue;

        const key = r + ',' + c;
        if (!visited.has(key)) {
          moves.push({ r, c });
          visited.add(key);
        }
      }
    }
    return moves;
  }

  function boardToAscii(bd, me = 1, opp = 2) {
    // '.' 空, 'X' 我方, 'O' 对方(仅用于调试输出)
    const rows = [];
    for (let r = 0; r < BOARD_SIZE; r++) {
      let line = '';
      for (let c = 0; c < BOARD_SIZE; c++) {
        const v = bd[r][c];
        line += v === EMPTY ? '.' : v === me ? 'X' : v === opp ? 'O' : String(v);
      }
      rows.push(line);
    }
    return rows.join('\n');
  }

  // 获取最后落子点(用于优化搜索顺序)
  // 优化:借鉴Python的order函数思路,优先搜索最后落子点的邻居可以提高剪枝效率
  function getLastMove(bd) {
    // 方法1:从棋盘中心向外扫描,找到最外层的子(通常最后下的子在最外层)
    // 这是一个启发式方法,虽然不是100%准确,但能有效提高搜索效率
    let lastR = -1,
      lastC = -1,
      maxDist = -1;
    for (let r = 0; r < BOARD_SIZE; r++) {
      for (let c = 0; c < BOARD_SIZE; c++) {
        if (bd[r][c] !== EMPTY) {
          const dist = Math.abs(r - 7) + Math.abs(c - 7);
          if (dist > maxDist) {
            maxDist = dist;
            lastR = r;
            lastC = c;
          }
        }
      }
    }
    // 如果棋盘有子,返回最外层的一个(作为最后落子点的近似)
    return lastR >= 0 ? { r: lastR, c: lastC } : null;
  }

  // 优化候选点顺序:优先搜索最后落子点的邻居(借鉴Python的order函数思路)
  function orderCandidatesByLastMove(candidates, lastMove) {
    if (!lastMove || !candidates.length) return candidates;
    const neighbors = [];
    const others = [];
    for (const m of candidates) {
      const dr = Math.abs(m.r - lastMove.r);
      const dc = Math.abs(m.c - lastMove.c);
      // 如果是最后落子点的邻居(8方向,距离<=1),优先考虑
      if (dr <= 1 && dc <= 1 && (dr > 0 || dc > 0)) {
        neighbors.push(m);
      } else {
        others.push(m);
      }
    }
    // 邻居在前,其他在后
    return neighbors.concat(others);
  }

  function getCandidateMovesHeuristic(bd, me, opp, limit) {
    const candidates = getCandidateMoves(bd);
    if (!candidates.length) return candidates;

    // 优化1:优先搜索最后落子点的邻居(提高剪枝效率,借鉴Python的order函数)
    const lastMove = getLastMove(bd);
    const orderedCandidates = orderCandidatesByLastMove(candidates, lastMove);

    // 优化2:用威胁分数做排序(比纯位置权重更靠谱)
    // 关键优化:只算一遍分数,避免 sort comparator O(n log n) 里重复落子/撤子
    const scored = orderedCandidates.map((m) => ({ ...m, _s: threatScoreForMove(bd, m.r, m.c, me, opp) }));
    scored.sort((a, b) => b._s - a._s);

    // 重要:强制注入“必杀/必防/高战术”点,避免 TopK 裁剪把关键点挤掉
    // - >= 9e11:必防(对手一手赢)
    // - >= 1e12:我方一手赢(必走)
    // - >= 7e10:高战术点(常见为活四/冲四/双活三枢纽),尽量保留
    const forced = [];
    for (const s of scored) {
      if (s._s >= 9e11 || s._s >= 1e12 || s._s >= 7e10) forced.push({ r: s.r, c: s.c, _s: s._s });
    }
    forced.sort((a, b) => b._s - a._s);

    const lim = toInt(limit, 0);
    const main = lim > 0 && scored.length > lim ? scored.slice(0, lim) : scored;
    const merged = mergeForcedIntoCandidates(
      forced.map((m) => ({ r: m.r, c: m.c })),
      main.map((m) => ({ r: m.r, c: m.c })),
      lim > 0 ? Math.max(lim, forced.length) : 0
    );
    return merged;
  }

  function evaluateWindow(window6, me, opp, leftVal, rightVal) {
    const sL = leftVal === opp ? 'O' : leftVal === me ? 'P' : '_';
    const sR = rightVal === opp ? 'O' : rightVal === me ? 'P' : '_';
    let core = '';
    for (let i = 0; i < 6; i++) {
      if (window6[i] === me) core += 'P';
      else if (window6[i] === opp) core += 'O';
      else core += '_';
    }
    if (!core.includes('P')) return 0;
    const str = sL + core + sR;
    if (str.includes('PPPPP')) return SCORES.WIN;

    const segments = str.split('O');
    let maxScore = 0;
    for (const seg of segments) {
      if (seg.length < 5) continue;
      if (seg.includes('PPPPP')) return SCORES.WIN;
      // 优化:更精确的棋型识别(借鉴Python的shape_score思路)
      // 活四:_PPPP_(两端都是空)
      if (seg.includes('_PPPP_')) {
        maxScore = Math.max(maxScore, SCORES.LIVE_4);
        continue;
      }
      // 冲四:PPPP_ 或 _PPPP 或 P_PPP 或 PP_PP 或 PPP_P(一端被堵)
      if (seg.includes('PPPP') || seg.includes('P_PPP') || seg.includes('PP_PP') || seg.includes('PPP_P')) {
        maxScore = Math.max(maxScore, SCORES.DEAD_4);
        continue;
      }
      // 活三:_PPP_ 或 _P_PP_ 或 _PP_P_(两端都是空,且长度>=6)
      if (seg.length >= 6) {
        if (seg.includes('_PPP_') || seg.includes('_P_PP_') || seg.includes('_PP_P_')) {
          maxScore = Math.max(maxScore, SCORES.LIVE_3);
          continue;
        }
      }
      // 眠三:PPP 或 P_PP 或 PP_P(一端被堵)
      if (seg.includes('PPP') || seg.includes('P_PP') || seg.includes('PP_P')) {
        maxScore = Math.max(maxScore, SCORES.DEAD_3);
        continue;
      }
      // 活二:_PP_ 或 _P_P_(两端都是空)
      if (seg.includes('_PP_')) maxScore = Math.max(maxScore, SCORES.LIVE_2);
      if (seg.includes('_P_P_')) maxScore = Math.max(maxScore, SCORES.LIVE_2);
    }
    return maxScore;
  }

  function evaluateBoard(bd, me, opp) {
    let totalScore = 0;

    // Horizontal
    for (let r = 0; r < BOARD_SIZE; r++) {
      for (let c = 0; c < BOARD_SIZE - 5; c++) {
        const leftVal = c === 0 ? opp : bd[r][c - 1];
        const rightVal = c === BOARD_SIZE - 6 ? opp : bd[r][c + 6];
        totalScore += evaluateWindow([bd[r][c], bd[r][c + 1], bd[r][c + 2], bd[r][c + 3], bd[r][c + 4], bd[r][c + 5]], me, opp, leftVal, rightVal);
      }
    }
    // Vertical
    for (let r = 0; r < BOARD_SIZE - 5; r++) {
      for (let c = 0; c < BOARD_SIZE; c++) {
        const leftVal = r === 0 ? opp : bd[r - 1][c];
        const rightVal = r === BOARD_SIZE - 6 ? opp : bd[r + 6][c];
        totalScore += evaluateWindow([bd[r][c], bd[r + 1][c], bd[r + 2][c], bd[r + 3][c], bd[r + 4][c], bd[r + 5][c]], me, opp, leftVal, rightVal);
      }
    }
    // Diagonal \
    for (let r = 0; r < BOARD_SIZE - 5; r++) {
      for (let c = 0; c < BOARD_SIZE - 5; c++) {
        const leftVal = r === 0 || c === 0 ? opp : bd[r - 1][c - 1];
        const rightVal = r === BOARD_SIZE - 6 || c === BOARD_SIZE - 6 ? opp : bd[r + 6][c + 6];
        totalScore += evaluateWindow([bd[r][c], bd[r + 1][c + 1], bd[r + 2][c + 2], bd[r + 3][c + 3], bd[r + 4][c + 4], bd[r + 5][c + 5]], me, opp, leftVal, rightVal);
      }
    }
    // Anti-diagonal /
    for (let r = 0; r < BOARD_SIZE - 5; r++) {
      for (let c = 5; c < BOARD_SIZE; c++) {
        const leftVal = r === 0 || c === BOARD_SIZE - 1 ? opp : bd[r - 1][c + 1];
        const rightVal = r === BOARD_SIZE - 6 || c === 5 ? opp : bd[r + 6][c - 6];
        totalScore += evaluateWindow([bd[r][c], bd[r + 1][c - 1], bd[r + 2][c - 2], bd[r + 3][c - 3], bd[r + 4][c - 4], bd[r + 5][c - 5]], me, opp, leftVal, rightVal);
      }
    }

    // Pos weights
    for (let r = 0; r < BOARD_SIZE; r++) {
      for (let c = 0; c < BOARD_SIZE; c++) {
        if (bd[r][c] === me) totalScore += POS_WEIGHTS[r][c];
      }
    }

    return totalScore;
  }

  function isBoardEmpty(bd) {
    for (let r = 0; r < BOARD_SIZE; r++) for (let c = 0; c < BOARD_SIZE; c++) if (bd[r][c] !== EMPTY) return false;
    return true;
  }

  function minimax(boardState, depth, alpha, beta, maximizing, me, opp, hash) {
    const ttKey = String(hash) + '|' + depth + '|' + (maximizing ? 1 : 0);
    const cached = ttGet(ttKey);
    // 置换表查找:如果找到精确值或可用边界,直接返回
    if (cached) {
      if (typeof cached === 'object' && cached.bound) {
        // 支持边界类型:exact/lower/upper
        if (cached.bound === 'exact') return cached.value;
        // maximizing节点:lower bound >= beta 可以触发beta剪枝;upper bound <= alpha 不能直接使用(值不够好)
        // minimizing节点:upper bound <= alpha 可以触发alpha剪枝;lower bound >= beta 不能直接使用(值不够好)
        if (maximizing) {
          if (cached.bound === 'lower' && cached.value >= beta) return cached.value;
          if (cached.bound === 'upper' && cached.value <= alpha) return cached.value; // 虽然不够好,但可以用于剪枝
        } else {
          if (cached.bound === 'upper' && cached.value <= alpha) return cached.value;
          if (cached.bound === 'lower' && cached.value >= beta) return cached.value; // 虽然不够好,但可以用于剪枝
        }
      } else {
        // 兼容旧格式(直接存储值)
        return cached;
      }
    }

    if (depth === 0) {
      // 叶子层“快检”:减少 horizon effect(看不见的 1 手杀/必防)
      // whoToMove: 当前轮到谁落子(由 maximizing 决定)
      const whoToMove = maximizing ? me : opp;
      const other = maximizing ? opp : me;

      // 1) 当前手若存在一手成五,直接给出极值(比静态评分更可靠)
      const winNowMoves = immediateWinningMoves(boardState, whoToMove);
      if (winNowMoves.length) {
        const v = whoToMove === me ? 9.5e9 : -9.5e9;
        ttSet(ttKey, { value: v, bound: 'exact' });
        return v;
      }

      // 2) 如果对手在当前盘面存在“一手成五”的点,说明当前手必须防,给予强惩罚
      // - 若轮到对手走,则对手会直接赢:极值
      // - 若轮到我方走,则这是“必防态势”:强负分促使去堵
      const otherWinMoves = immediateWinningMoves(boardState, other);
      if (otherWinMoves.length) {
        const v = whoToMove === other ? -9.9e9 : -8.8e9;
        ttSet(ttKey, { value: v, bound: 'exact' });
        return v;
      }

      const myScore = evaluateBoard(boardState, me, opp);
      const oppScore = evaluateBoard(boardState, opp, me);
      // 去随机化:用 hash 的低位做极小的确定性扰动,避免同局面抖动
      const noise = Number(hash & 1023n) / 1023 * 0.02;
      const v = myScore - oppScore * 1.2 + noise;
      ttSet(ttKey, { value: v, bound: 'exact' });
      return v;
    }

    // 启发式排序 + TopK 裁剪:明显提速,也会更"懂得先冲四/先防"
    // 优化:根据深度动态调整候选点数量,深度越深候选点越少(提高剪枝效率)
    const topK = depth >= 3 ? 10 : depth === 2 ? 14 : 22;
    // 优化:getCandidateMovesHeuristic内部已包含最后落子点优先排序,提高剪枝效率
    const candidates = getCandidateMovesHeuristic(boardState, maximizing ? me : opp, maximizing ? opp : me, topK);
    if (candidates.length === 0) return 0;

    if (maximizing) {
      let best = -Infinity;
      let originalAlpha = alpha;
      for (const mv of candidates) {
        boardState[mv.r][mv.c] = me;
        // 修复hash计算:先移除当前side(me),再添加下一个side(opp)
        const nextHash = (hash ^ ZOBRIST.z[mv.r][mv.c][me]) ^ ZOBRIST.side[me] ^ ZOBRIST.side[opp];
        if (fastCheckWin(boardState, mv.r, mv.c, me)) {
          boardState[mv.r][mv.c] = EMPTY;
          const v = 10000000 + depth;
          ttSet(ttKey, { value: v, bound: 'exact' });
          return v;
        }
        const val = minimax(boardState, depth - 1, alpha, beta, false, me, opp, nextHash);
        boardState[mv.r][mv.c] = EMPTY;
        best = Math.max(best, val);
        alpha = Math.max(alpha, val);
        if (beta <= alpha) break;
      }
      // 存储到置换表:根据alpha-beta剪枝结果确定边界类型
      // 如果触发了beta剪枝(best >= beta),说明真实值 >= best,存储为lower bound
      // 如果best <= originalAlpha,说明真实值 <= best,存储为upper bound
      // 否则是精确值
      const bound = best >= beta ? 'lower' : best <= originalAlpha ? 'upper' : 'exact';
      ttSet(ttKey, { value: best, bound });
      return best;
    } else {
      let best = Infinity;
      let originalBeta = beta;
      for (const mv of candidates) {
        boardState[mv.r][mv.c] = opp;
        // 修复hash计算:先移除当前side(opp),再添加下一个side(me)
        const nextHash = (hash ^ ZOBRIST.z[mv.r][mv.c][opp]) ^ ZOBRIST.side[opp] ^ ZOBRIST.side[me];
        if (fastCheckWin(boardState, mv.r, mv.c, opp)) {
          boardState[mv.r][mv.c] = EMPTY;
          const v = -10000000 - depth;
          ttSet(ttKey, { value: v, bound: 'exact' });
          return v;
        }
        const val = minimax(boardState, depth - 1, alpha, beta, true, me, opp, nextHash);
        boardState[mv.r][mv.c] = EMPTY;
        best = Math.min(best, val);
        beta = Math.min(beta, val);
        if (beta <= alpha) break;
      }
      // 存储到置换表:根据alpha-beta剪枝结果确定边界类型
      // 如果触发了alpha剪枝(best <= alpha),说明真实值 <= best,存储为upper bound
      // 如果best >= originalBeta,说明真实值 >= best,存储为lower bound
      // 否则是精确值
      const bound = best <= alpha ? 'upper' : best >= originalBeta ? 'lower' : 'exact';
      ttSet(ttKey, { value: best, bound });
      return best;
    }
  }

  function getTimeLimitMs(maxDepth) {
    const d = Math.max(1, Math.min(toInt(maxDepth, 2), 4));
    // 调高思考时间上限:每手总思考时间约控制在 25s 之内
    // d=1:约 2s;d=2(标准):约 6s;d=3:约 12s;d=4:约 23s
    // 注意:这是“最多”耗时,实际通常会更短(剪枝/局面简单时)
    if (d === 1) return 2000;
    if (d === 2) return 6000;
    if (d === 3) return 12000;
    return 23000;
  }

  function getBestMoveIterative(bd, me, opp, maxDepth) {
    // 迭代加深:在时间限制内尽量搜深,同时保留上一层最优解(更稳)
    const depthMax = Math.max(1, Math.min(toInt(maxDepth, 2), 4));
    const timeLimitMs = getTimeLimitMs(depthMax);
    const start = Date.now();
    let best = null;

    // 根节点候选点固定一次:保证迭代加深各层对比稳定,也减少重复工作
    // 额外硬约束:如果存在“一手赢/一手必防”,优先强制返回,避免进入搜索树后被 TopK/评估误导
    const forcedWin = immediateWinningMoves(bd, me);
    if (forcedWin.length) return forcedWin[0];
    const forcedBlock = immediateWinningMoves(bd, opp);
    if (forcedBlock.length) return forcedBlock[0];

    const rootTopK = Math.max(10, Math.min(30, depthMax >= 3 ? 22 : 30));
    const candidates = getCandidateMovesHeuristic(bd, me, opp, rootTopK);
    if (!candidates.length) return null;

    // 先杀/先防(任何深度都必须先做)
    for (const m of candidates) {
      bd[m.r][m.c] = me;
      if (fastCheckWin(bd, m.r, m.c, me)) {
        bd[m.r][m.c] = EMPTY;
        return m;
      }
      bd[m.r][m.c] = EMPTY;
    }
    for (const m of candidates) {
      bd[m.r][m.c] = opp;
      if (fastCheckWin(bd, m.r, m.c, opp)) {
        bd[m.r][m.c] = EMPTY;
        return m;
      }
      bd[m.r][m.c] = EMPTY;
    }

    const dangerFactor = 0.0005; // 威胁惩罚系数:把“给对手留下大威胁”的步扣分

    for (let d = 1; d <= depthMax; d++) {
      if (Date.now() - start > timeLimitMs) break;
      let bestVal = -Infinity;
      let bestMove = candidates[0];
      let alpha = -Infinity;
      let beta = Infinity;
      for (const mv of candidates) {
        if (Date.now() - start > timeLimitMs) break;
        bd[mv.r][mv.c] = me;
        const rootHash = computeBoardHash(bd, opp);
        const val = minimax(bd, d - 1, alpha, beta, false, me, opp, rootHash);
        // 额外考虑:当前落子后,对手下一手能产生的最大威胁分
        const oppThreat = maxThreatScoreSide(bd, opp, me, 1);
        const score = val - oppThreat * dangerFactor;
        bd[mv.r][mv.c] = EMPTY;
        if (score > bestVal) {
          bestVal = score;
          bestMove = mv;
        }
        alpha = Math.max(alpha, bestVal);
        if (beta <= alpha) break;
      }
      best = bestMove;
    }
    return best || candidates[0];
  }

  function getBestMove(bd, me, opp, maxDepth = 2) {
    if (isBoardEmpty(bd)) return { r: 7, c: 7 };

    // 根节点也做排序/裁剪,提升强度与速度
    const rootTopK = Math.max(10, Math.min(28, toInt(maxDepth, 2) >= 3 ? 22 : 28));
    const candidates = getCandidateMovesHeuristic(bd, me, opp, rootTopK);
    if (candidates.length === 0) return null;

    // 先杀 / 先防
    for (const m of candidates) {
      bd[m.r][m.c] = me;
      if (fastCheckWin(bd, m.r, m.c, me)) {
        bd[m.r][m.c] = EMPTY;
        return m;
      }
      bd[m.r][m.c] = EMPTY;
    }
    for (const m of candidates) {
      bd[m.r][m.c] = opp;
      if (fastCheckWin(bd, m.r, m.c, opp)) {
        bd[m.r][m.c] = EMPTY;
        return m;
      }
      bd[m.r][m.c] = EMPTY;
    }

    let bestVal = -Infinity;
    let bestMoves = [];
    let alpha = -Infinity;
    let beta = Infinity;
    for (const mv of candidates) {
      bd[mv.r][mv.c] = me;
      const rootHash = computeBoardHash(bd, opp);
      const val = minimax(bd, maxDepth - 1, alpha, beta, false, me, opp, rootHash);
      bd[mv.r][mv.c] = EMPTY;
      if (val > bestVal) {
        bestVal = val;
        bestMoves = [mv];
      } else if (val === bestVal) {
        bestMoves.push(mv);
      }
      alpha = Math.max(alpha, bestVal);
      if (beta <= alpha) break;
    }
    if (bestMoves.length) return bestMoves[Math.floor(Math.random() * bestMoves.length)];
    return candidates[0];
  }

  // ======= 算法2:威胁优先(更强/更省CPU) =======
  function lineInfoAfterPlace(bd, r, c, who) {
    const dirs = [
      [0, 1],
      [1, 0],
      [1, 1],
      [1, -1],
    ];
    let best = { len: 1, open: 0 };
    for (const [dr, dc] of dirs) {
      let len = 1;
      let open = 0;
      // forward
      let tr = r + dr,
        tc = c + dc;
      while (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === who) {
        len++;
        tr += dr;
        tc += dc;
      }
      if (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === EMPTY) open++;
      // backward
      tr = r - dr;
      tc = c - dc;
      while (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === who) {
        len++;
        tr -= dr;
        tc -= dc;
      }
      if (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === EMPTY) open++;
      if (len > best.len || (len === best.len && open > best.open)) best = { len, open };
    }
    return best;
  }

  function lineInfosAfterPlaceAll(bd, r, c, who) {
    const dirs = [
      [0, 1],
      [1, 0],
      [1, 1],
      [1, -1],
    ];
    const out = [];
    for (const [dr, dc] of dirs) {
      let len = 1;
      let open = 0;
      // forward
      let tr = r + dr,
        tc = c + dc;
      while (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === who) {
        len++;
        tr += dr;
        tc += dc;
      }
      if (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === EMPTY) open++;
      // backward
      tr = r - dr;
      tc = c - dc;
      while (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === who) {
        len++;
        tr -= dr;
        tc -= dc;
      }
      if (tr >= 0 && tr < BOARD_SIZE && tc >= 0 && tc < BOARD_SIZE && bd[tr][tc] === EMPTY) open++;
      out.push({ len, open });
    }
    return out;
  }

  function threatScoreForMove(bd, r, c, me, opp) {
    // 1) 我方一手赢
    bd[r][c] = me;
    const winNow = fastCheckWin(bd, r, c, me);
    const myLines = lineInfosAfterPlaceAll(bd, r, c, me);
    bd[r][c] = EMPTY;
    if (winNow) return 1e12;

    // 2) 必须防:对方一手赢
    bd[r][c] = opp;
    const oppWin = fastCheckWin(bd, r, c, opp);
    const oppLines = lineInfosAfterPlaceAll(bd, r, c, opp);
    bd[r][c] = EMPTY;
    if (oppWin) return 9e11;

    let score = 0;

    // 3) 我方威胁
    let myLive4 = 0,
      myRush4 = 0,
      myLive3 = 0,
      mySleep3 = 0,
      myLive2 = 0;
    for (const ln of myLines) {
      if (ln.len >= 5) score += 8e11;
      if (ln.len === 4 && ln.open === 2) myLive4++;
      else if (ln.len === 4 && ln.open === 1) myRush4++;
      else if (ln.len === 3 && ln.open === 2) myLive3++;
      else if (ln.len === 3 && ln.open === 1) mySleep3++;
      else if (ln.len === 2 && ln.open === 2) myLive2++;
    }
    if (myLive4) score += 8e10 + myLive4 * 2e9; // 活四
    if (myRush4) score += 6e10 + myRush4 * 1e9; // 冲四
    if (myLive3) score += 2e10 + myLive3 * 8e8; // 活三
    if (mySleep3) score += 6e9 + mySleep3 * 2e8; // 眠三
    if (myLive2) score += 2e9 + myLive2 * 5e7; // 活二

    // 组合威胁(fork):双活三 / 冲四+活三 等,通常是必杀或强迫应对
    if (myLive3 >= 2) score += 5.5e10;
    if (myRush4 >= 1 && myLive3 >= 1) score += 6.5e10;
    if (myLive4 >= 1 && (myLive3 >= 1 || myRush4 >= 1)) score += 9e10;

    // 4) 对方威胁(越大越该挡)
    let oppLive4 = 0,
      oppRush4 = 0,
      oppLive3 = 0,
      oppSleep3 = 0;
    for (const ln of oppLines) {
      if (ln.len >= 5) score += 7e11;
      if (ln.len === 4 && ln.open === 2) oppLive4++;
      else if (ln.len === 4 && ln.open === 1) oppRush4++;
      else if (ln.len === 3 && ln.open === 2) oppLive3++;
      else if (ln.len === 3 && ln.open === 1) oppSleep3++;
    }
    if (oppLive4) score += 7e10 + oppLive4 * 2e9;
    if (oppRush4) score += 5e10 + oppRush4 * 1e9;
    if (oppLive3) score += 1.6e10 + oppLive3 * 8e8;
    if (oppSleep3) score += 4e9 + oppSleep3 * 2e8;
    if (oppLive3 >= 2) score += 4.8e10;
    if (oppRush4 >= 1 && oppLive3 >= 1) score += 6.2e10;

    // 5) 位置偏好
    score += (POS_WEIGHTS[r][c] || 0) * 1000;
    // 稳定的平局打破(避免 Math.random 导致同局面抖动/漏防)
    // 仅依赖坐标,幅度极小
    score += ((r * 37 + c * 61) % 97) * 0.001;
    return score;
  }

  function moveKey(m) {
    return m.r + ',' + m.c;
  }

  function uniqMoves(moves) {
    const out = [];
    const seen = new Set();
    for (const m of moves || []) {
      const k = moveKey(m);
      if (seen.has(k)) continue;
      seen.add(k);
      out.push(m);
    }
    return out;
  }

  function immediateWinningMoves(bd, who) {
    const cands = getCandidateMoves(bd, 1);
    const wins = [];
    for (const m of cands) {
      bd[m.r][m.c] = who;
      const win = fastCheckWin(bd, m.r, m.c, who);
      bd[m.r][m.c] = EMPTY;
      if (win) wins.push(m);
    }
    return wins;
  }

  function maxThreatScoreSide(bd, who, target, radius = 1) {
    const cands = getCandidateMoves(bd, radius);
    if (!cands.length) return 0;
    let best = 0;
    for (const m of cands) {
      const s = threatScoreForMove(bd, m.r, m.c, who, target);
      if (s > best) best = s;
    }
    return best;
  }

  // ======= 简化版威胁链搜索(Threat Chain Search, VCF-lite)=======
  // 目标:在局面已经高度对杀时,沿着“只有制造/应对威胁”的分支多看几步,
  // 尝试找出我方的强制胜(或确认至少当前点不会立刻被反杀)。
  function threatChainDFS(bd, attacker, defender, sideToMove, pliesLeft, deadlineMs) {
    if (Date.now() > deadlineMs || pliesLeft <= 0) return false;

    // 任何时刻,只要进到一个 attacker 一手赢的局面,就认为找到了威胁链
    const attWins = immediateWinningMoves(bd, attacker);
    if (attWins.length) return true;
    // 如果防守方现在就能一手赢,视为这条链失败(对面抢先一步)
    const defWins = immediateWinningMoves(bd, defender);
    if (defWins.length) return false;

    if (sideToMove === attacker) {
      // 进攻方节点:只考虑能制造较大威胁的着法,分支极小化
      const cands = getCandidateMovesHeuristic(bd, attacker, defender, 10);
      const threatThreshold = 7e10; // 至少要造出接近活四/四三的威胁
      for (const mv of cands) {
        bd[mv.r][mv.c] = attacker;
        const myThreat = maxThreatScoreSide(bd, attacker, defender, 1);
        if (myThreat < threatThreshold) {
          bd[mv.r][mv.c] = EMPTY;
          continue;
        }
        const ok = threatChainDFS(bd, attacker, defender, defender, pliesLeft - 1, deadlineMs);
        bd[mv.r][mv.c] = EMPTY;
        if (ok) return true;
      }
      return false;
    } else {
      // 防守方节点:取若干个最合理的防守着,要求进攻方能在这些防守下都保持必胜
      const cands = getCandidateMovesHeuristic(bd, defender, attacker, 10);
      if (!cands.length) {
        // 无子可下,视作让进攻方继续走
        return threatChainDFS(bd, attacker, defender, attacker, pliesLeft - 1, deadlineMs);
      }
      const maxDef = Math.min(3, cands.length);
      for (let i = 0; i < maxDef; i++) {
        const mv = cands[i];
        bd[mv.r][mv.c] = defender;
        const ok = threatChainDFS(bd, attacker, defender, attacker, pliesLeft - 1, deadlineMs);
        bd[mv.r][mv.c] = EMPTY;
        if (!ok) {
          // 找到一条防守方能化解威胁的线,这条进攻链失败
          return false;
        }
      }
      // 在若干最好防守下都能维持胜势,认为 attacker 有一条威胁链
      return true;
    }
  }

  function findThreatChainMove(bd, me, opp, maxPlies, budgetMs) {
    const deadline = Date.now() + Math.max(80, budgetMs | 0);
    const cands = getCandidateMovesHeuristic(bd, me, opp, 12);
    if (!cands.length) return null;

    for (const mv of cands) {
      bd[mv.r][mv.c] = me;
      // 立即赢的点前面已经单独处理过,这里只关心“需要若干步铺垫的威胁链”
      const ok = threatChainDFS(bd, me, opp, opp, maxPlies - 1, deadline);
      bd[mv.r][mv.c] = EMPTY;
      if (ok) return mv;
      if (Date.now() > deadline) break;
    }
    return null;
  }

  function mergeForcedIntoCandidates(forced, candidates, limit) {
    const merged = uniqMoves([...(forced || []), ...(candidates || [])]);
    const lim = toInt(limit, 0);
    if (lim > 0 && merged.length > lim) return merged.slice(0, lim);
    return merged;
  }

  function getBestMoveThreat(bd, me, opp, maxDepth) {
    if (isBoardEmpty(bd)) return { r: 7, c: 7 };

    // 优化1:先杀/先防检查(借鉴Minimax的优化,任何算法都必须先做)
    // 生成候选点前先检查是否能直接赢或必须防
    const quickCandidates = getCandidateMoves(bd, 1); // 快速生成候选点(只考虑邻居1格)
    if (quickCandidates.length > 0) {
      // 先杀:我方一手赢
      for (const m of quickCandidates) {
        bd[m.r][m.c] = me;
        if (fastCheckWin(bd, m.r, m.c, me)) {
          bd[m.r][m.c] = EMPTY;
          return m;
        }
        bd[m.r][m.c] = EMPTY;
      }
      // 先防:对方一手赢
      for (const m of quickCandidates) {
        bd[m.r][m.c] = opp;
        if (fastCheckWin(bd, m.r, m.c, opp)) {
          bd[m.r][m.c] = EMPTY;
          return m;
        }
        bd[m.r][m.c] = EMPTY;
      }
    }

    // 优化2:threat 算法本身就是启发式,这里直接裁剪候选点,降低 CPU 并更聚焦关键点
    const depth = Math.max(1, Math.min(toInt(maxDepth || 1, 1), 4));
    // 优化:根据深度动态调整候选点数量,深度越深候选点越少(提高效率)
    const candidateLimit = depth >= 3 ? 28 : depth === 2 ? 32 : 36;
    const candidates = getCandidateMovesHeuristic(bd, me, opp, candidateLimit);
    if (!candidates.length) return null;

    const penaltyFactor = depth === 1 ? 0 : depth === 2 ? 0.8 : depth === 3 ? 0.9 : 1.0;
    let best = null;
    let bestScore = -Infinity;

    // 优化3:威胁评分缓存(避免重复计算相同位置的威胁)
    // 重要:缓存必须绑定“棋盘状态”,因为下面会临时落子再评估对手应手。
    // 否则会把不同局面的评分混用,导致漏防/误判(例如交叉点、双威胁)。
    const threatCache = new Map();
    const baseHash = computeBoardHash(bd, 0); // 不包含 side-to-move,仅表示棋盘
    function getCachedThreatScore(hashKey, r, c, who, target) {
      const key = `${hashKey}|${r},${c},${who},${target}`;
      if (threatCache.has(key)) return threatCache.get(key);
      const score = threatScoreForMove(bd, r, c, who, target);
      threatCache.set(key, score);
      return score;
    }

    for (const mv of candidates) {
      // 优化4:早期终止 - 如果找到必杀位置(威胁分数极高),可以提前返回
      const hashAfterMy = String(baseHash ^ ZOBRIST.z[mv.r][mv.c][me]);
      let s = getCachedThreatScore(hashAfterMy, mv.r, mv.c, me, opp);

      // 如果是我方必杀(一手赢),直接返回
      if (s >= 1e12) {
        return mv;
      }

      // 如果是必须防(对方一手赢),也是最高优先级
      if (s >= 9e11) {
        if (s > bestScore) {
          bestScore = s;
          best = mv;
        }
        // 继续检查是否还有其他必防点,但当前这个已经是最高优先级了
        continue;
      }

      // 优化5:深度 >=2 时,考虑对手下一手的最好威胁(简化两层搜索)
      if (penaltyFactor > 0) {
        bd[mv.r][mv.c] = me;
        // 对手应手也要裁剪:否则会被大量无关点稀释,且很吃 CPU
        // 优化:对手候选点数量根据深度动态调整
        const oppCandidateLimit = depth >= 3 ? 16 : depth === 2 ? 18 : 20;
        const oppCands = getCandidateMovesHeuristic(bd, opp, me, oppCandidateLimit);
        let oppBest = 0;

        // 优化6:对手应手搜索 - 找到对手最好的威胁
        // 注意:如果对手能直接赢,应该已经在先防检查中被处理了
        for (const om of oppCands) {
          const hashAfterOpp = String((baseHash ^ ZOBRIST.z[mv.r][mv.c][me]) ^ ZOBRIST.z[om.r][om.c][opp]);
          const so = getCachedThreatScore(hashAfterOpp, om.r, om.c, opp, me);
          if (so > oppBest) {
            oppBest = so;
            // 如果对手能直接赢(威胁分数>=1e12),给予最高惩罚
            // 这样当前考虑的位置会被大幅扣分,不会被选中
            // 实际上,这种情况应该已经在先防检查中被处理了
          }
        }
        bd[mv.r][mv.c] = EMPTY;
        s = s - oppBest * penaltyFactor;
      }

      if (s > bestScore) {
        bestScore = s;
        best = mv;
      }
    }

    // 优化7:如果找到了必防位置,优先返回
    if (best && bestScore >= 9e11) {
      return best;
    }

    return best || candidates[0];
  }

  // ======= 算法3:混合算法(威胁引导的Minimax) =======
  function getBestMoveHybrid(bd, me, opp, maxDepth) {
    // 混合算法:Threat 做硬约束/筛选,Minimax 做精算
    // 目标:减少 Minimax 在“交叉点/双威胁/VCF”上的漏算,同时保持全局搜索能力
    const depthMax = Math.max(1, Math.min(toInt(maxDepth, 2), 4));
    const timeLimitMs = getTimeLimitMs(depthMax);
    const start = Date.now();
    let best = null;

    // 1) 先做 Threat 的“必杀/必防”硬判断:一旦存在,直接返回,避免进入 Minimax 漏算
    const quickWins = immediateWinningMoves(bd, me);
    if (quickWins.length) return quickWins[0];
    const quickBlocks = immediateWinningMoves(bd, opp);
    if (quickBlocks.length) return quickBlocks[0];

    // 1.5) 在高威胁局面下,尝试专门的威胁链搜索(VCF-lite)
    // 只在危险度较高且有足够时间时触发,避免在平稳局面浪费大量计算。
    try {
      const myThreat = maxThreatScoreSide(bd, me, opp, 1);
      const oppThreat = maxThreatScoreSide(bd, opp, me, 1);
      const danger = Math.max(myThreat, oppThreat);
      if (danger >= 5e10 && timeLimitMs >= 4000) {
        // 预算最多 40% 的思考时间给威胁链搜索
        const chainBudget = Math.min(4000, Math.floor(timeLimitMs * 0.4));
        const chainPlies = depthMax >= 3 ? 7 : 5;
        const chainMove = findThreatChainMove(bd, me, opp, chainPlies, chainBudget);
        if (chainMove) return chainMove;
      }
    } catch (e) {
      // 威胁链搜索失败时静默回退,不影响正常搜索
    }

    // 2) Threat 筛选候选点(更聚焦关键战术),再交给 Minimax 精算
    // 注意:getCandidateMovesHeuristic 已带“强制注入高战术点”,这里再做一次小裁剪即可
    const rootTopK = Math.max(10, Math.min(30, depthMax >= 3 ? 20 : 26));
    const candidates = getCandidateMovesHeuristic(bd, me, opp, rootTopK);
    if (!candidates.length) return null;

    // 先杀/先防(任何深度都必须先做)
    for (const m of candidates) {
      bd[m.r][m.c] = me;
      if (fastCheckWin(bd, m.r, m.c, me)) {
        bd[m.r][m.c] = EMPTY;
        return m;
      }
      bd[m.r][m.c] = EMPTY;
    }
    for (const m of candidates) {
      bd[m.r][m.c] = opp;
      if (fastCheckWin(bd, m.r, m.c, opp)) {
        bd[m.r][m.c] = EMPTY;
        return m;
      }
      bd[m.r][m.c] = EMPTY;
    }

    // 3) 迭代加深搜索:候选点已按威胁评分排序 + 强制点保留,剪枝效率更高且更不易漏防
    const dangerFactor = 0.0005;

    for (let d = 1; d <= depthMax; d++) {
      if (Date.now() - start > timeLimitMs) break;
      let bestVal = -Infinity;
      let bestMove = candidates[0];
      let alpha = -Infinity;
      let beta = Infinity;

      // 在Minimax内部搜索时,也使用威胁评分优化候选点顺序
      // 这里直接使用已排序的根节点候选点,因为getCandidateMovesHeuristic已经按威胁评分排序
      for (const mv of candidates) {
        if (Date.now() - start > timeLimitMs) break;
        bd[mv.r][mv.c] = me;
        const rootHash = computeBoardHash(bd, opp);
        const val = minimax(bd, d - 1, alpha, beta, false, me, opp, rootHash);
        const oppThreat = maxThreatScoreSide(bd, opp, me, 1);
        const score = val - oppThreat * dangerFactor;
        bd[mv.r][mv.c] = EMPTY;
        if (score > bestVal) {
          bestVal = score;
          bestMove = mv;
        }
        alpha = Math.max(alpha, bestVal);
        if (beta <= alpha) break;
      }
      best = bestMove;
    }
    return best || candidates[0];
  }

  function chooseMoveByAlgo(bd, me, opp, algo, maxDepth) {
    if (algo === 'threat') return getBestMoveThreat(bd, me, opp, maxDepth);
    if (algo === 'hybrid') return getBestMoveHybrid(bd, me, opp, maxDepth);
    // minimax 默认走迭代加深版本(更强/更稳)
    return getBestMoveIterative(bd, me, opp, maxDepth);
  }

  // 将棋盘状态转换为 moves 格式(用于 API 调用)
  // 优先使用记录的落子历史,如果没有则通过其他方式重建
  function boardToMoves(bd, playerColor, computerColor) {
    // 如果有记录的落子历史,直接使用
    if (gomokuState.moveHistory && gomokuState.moveHistory.length > 0) {
      return gomokuState.moveHistory;
    }

    // 如果没有历史记录,使用原来的方法(作为后备)
    const moves = [];

    // 收集所有棋子位置
    const playerMoves = []; // 玩家的棋子
    const computerMoves = []; // AI的棋子

    // 从 DOM 获取所有棋子,判断是玩家还是AI
    const pieces = Array.from(document.querySelectorAll('.chess-piece.active'));
    pieces.forEach((piece) => {
      const cell = piece.closest('.intersection');
      if (!cell) return;
      const r = parseInt(cell.getAttribute('data-row') || '', 10);
      const c = parseInt(cell.getAttribute('data-col') || '', 10);
      if (Number.isNaN(r) || Number.isNaN(c)) return;

      const isPlayerPiece = piece.classList.contains('last-move-halo');
      const isComputerPiece = piece.classList.contains('last-move-dot');

      // 判断是玩家还是AI
      if (isPlayerPiece) {
        playerMoves.push({ r, c });
      } else if (isComputerPiece) {
        computerMoves.push({ r, c });
      } else {
        // 如果没有标记,通过棋盘状态判断
        if (bd[r][c] === playerColor) {
          playerMoves.push({ r, c });
        } else if (bd[r][c] === computerColor) {
          computerMoves.push({ r, c });
        }
      }
    });

    // 如果 DOM 中没有棋子信息,从棋盘状态重建
    if (playerMoves.length === 0 && computerMoves.length === 0) {
      for (let r = 0; r < BOARD_SIZE; r++) {
        for (let c = 0; c < BOARD_SIZE; c++) {
          if (bd[r][c] === playerColor) {
            playerMoves.push({ r, c });
          } else if (bd[r][c] === computerColor) {
            computerMoves.push({ r, c });
          }
        }
      }
    }

    // 确定谁先手:通过检查最后一个落子标记
    const lastPlayerPiece = document.querySelector('.chess-piece.active.last-move-halo');
    const lastComputerPiece = document.querySelector('.chess-piece.active.last-move-dot');

    let playerFirst = true; // 默认玩家先手
    if (lastComputerPiece && !lastPlayerPiece) {
      // 如果只有AI的最后一步标记,说明AI刚下完,可能是AI先手
      // 但需要检查棋子数量
      if (computerMoves.length > playerMoves.length) {
        playerFirst = false; // AI先手
      }
    } else if (lastPlayerPiece && !lastComputerPiece) {
      // 如果只有玩家的最后一步标记,说明玩家刚下完,可能是玩家先手
      if (playerMoves.length > computerMoves.length) {
        playerFirst = true; // 玩家先手
      }
    } else {
      // 通过棋子数量判断:数量少的可能是先手(如果数量相等,玩家先手)
      playerFirst = playerMoves.length <= computerMoves.length;
    }

    // 按照黑白交替的规则重建落子顺序
    // 如果玩家先手:1,2,1,2,1,2...
    // 如果AI先手:2,1,2,1,2,1...
    const playerUid = 1;
    const computerUid = 2;

    // 按照从中心向外、从上到下、从左到右的顺序排序棋子(用于确定相对顺序)
    const sortMoves = (moves) => {
      return moves.sort((a, b) => {
        // 先按距离中心的距离排序
        const distA = Math.abs(a.r - 7) + Math.abs(a.c - 7);
        const distB = Math.abs(b.r - 7) + Math.abs(b.c - 7);
        if (distA !== distB) return distA - distB;
        // 距离相同,按行列排序
        if (a.r !== b.r) return a.r - b.r;
        return a.c - b.c;
      });
    };

    const sortedPlayerMoves = sortMoves([...playerMoves]);
    const sortedComputerMoves = sortMoves([...computerMoves]);

    // 交替添加棋子
    let playerIdx = 0;
    let computerIdx = 0;
    let turn = playerFirst ? 0 : 1; // 0=玩家, 1=AI

    while (playerIdx < sortedPlayerMoves.length || computerIdx < sortedComputerMoves.length) {
      if (turn === 0 && playerIdx < sortedPlayerMoves.length) {
        moves.push({ uid: playerUid, ...sortedPlayerMoves[playerIdx] });
        playerIdx++;
        turn = 1;
      } else if (turn === 1 && computerIdx < sortedComputerMoves.length) {
        moves.push({ uid: computerUid, ...sortedComputerMoves[computerIdx] });
        computerIdx++;
        turn = 0;
      } else {
        // 如果一方没有棋子了,添加剩余的
        if (playerIdx < sortedPlayerMoves.length) {
          moves.push({ uid: playerUid, ...sortedPlayerMoves[playerIdx] });
          playerIdx++;
        }
        if (computerIdx < sortedComputerMoves.length) {
          moves.push({ uid: computerUid, ...sortedComputerMoves[computerIdx] });
          computerIdx++;
        }
        break;
      }
    }

    return moves;
  }

  // 人机对战模式:通过 API 获取最佳落子
  function gomokuApiBestMove(bd, playerColor, computerColor) {
    // 将棋盘转换为 moves 格式
    const moves = boardToMoves(bd, playerColor, computerColor);
    // 使用玩家的 userid(假设为 1)
    return chessApiBestMove(moves, 1);
  }

  // ======= 调试 / 复盘辅助:在控制台快速测试 AI 对给定棋谱的推荐落子 =======
  (function exposeTestHelper() {
    try {
      const w = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
      if (w.tmGomokuTest) return;

      function buildBoardFromMoves(moves, meUid, oppUid) {
        const bd = Array(BOARD_SIZE)
          .fill(0)
          .map(() => Array(BOARD_SIZE).fill(EMPTY));
        // 先手(黑子)按第一手 uid 推断
        let blackUid = null;
        if (moves.length) blackUid = toInt(moves[0].uid, 0);

        for (const m of moves) {
          const r = toInt(m.r, -1);
          const c = toInt(m.c, -1);
          const uid = toInt(m.uid, 0);
          if (r < 0 || c < 0) continue;
          const color = uid === blackUid ? 1 : 2;
          if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE) {
            bd[r][c] = color;
          }
        }

        const meColor = toInt(meUid, 0) === toInt(blackUid, 0) ? 1 : 2;
        const oppColor = meColor === 1 ? 2 : 1;
        return { bd, meColor, oppColor, blackUid };
      }

      w.tmGomokuTest = {
        /**
         * 在控制台调用示例:
         *   tmGomokuTest.testFromJson('[{"uid":2303,"r":"7","c":"7"}, ...]', {
         *     meUid: 2303,
         *     algo: 'hybrid',
         *     depth: 3
         *   });
         */
        testFromJson(json, opts) {
          try {
            const cfg = opts || {};
            const meUid = toInt(cfg.meUid || getMyUid(), 0);
            const algo = cfg.algo === 'threat' ? 'threat' : cfg.algo === 'hybrid' ? 'hybrid' : 'minimax';
            const depth = cfg.depth || 2;
            const moves = JSON.parse(json);
            if (!Array.isArray(moves)) {
              // eslint-disable-next-line no-console
              console.warn('[tmGomokuTest] 非数组棋谱');
              return;
            }
            const { bd, meColor, oppColor, blackUid } = buildBoardFromMoves(moves, meUid);
            // eslint-disable-next-line no-console
            console.log('[tmGomokuTest] 黑子UID=', blackUid, '我方UID=', meUid, '我方颜色=', meColor);
            // eslint-disable-next-line no-console
            console.log('[tmGomokuTest] 当前棋盘(.:空 X:我 O:对)=\n' + boardToAscii(bd, meColor, oppColor));

            const mv = chooseMoveByAlgo(cloneBoard(bd), meColor, oppColor, algo, depth);
            if (!mv) {
              // eslint-disable-next-line no-console
              console.log('[tmGomokuTest] 无推荐落子(可能棋局已满)');
              return;
            }
            // eslint-disable-next-line no-console
            console.log('[tmGomokuTest] 推荐算法/深度=', algo, depth, '推荐落子=', mv);
            return mv;
          } catch (e) {
            // eslint-disable-next-line no-console
            console.error('[tmGomokuTest] 解析/计算失败:', e);
          }
        },

        /**
         * 回溯对比测试:用当前算法重新复盘完整对局,找出所有差异
         * 调用示例:
         *   tmGomokuTest.replayAndCompare('[{"uid":2303,"r":"7","c":"7"}, ...]', {
         *     meUid: 2303,
         *     algo: 'hybrid',
         *     depth: 3
         *   });
         */
        replayAndCompare(json, opts) {
          try {
            const cfg = opts || {};
            const meUid = toInt(cfg.meUid || getMyUid(), 0);
            const algo = cfg.algo === 'threat' ? 'threat' : cfg.algo === 'hybrid' ? 'hybrid' : 'minimax';
            const depth = cfg.depth || 2;
            const moves = JSON.parse(json);
            if (!Array.isArray(moves)) {
              // eslint-disable-next-line no-console
              console.warn('[tmGomokuTest.replayAndCompare] 非数组棋谱');
              return;
            }
            if (!moves.length) {
              // eslint-disable-next-line no-console
              console.warn('[tmGomokuTest.replayAndCompare] 空棋谱');
              return;
            }

            // 确定黑子UID(优先 first_uid,其次用第一手)
            let blackUid = toInt(moves[0] && moves[0].uid, 0);
            const meColor = toInt(meUid, 0) === toInt(blackUid, 0) ? 1 : 2;
            const oppColor = meColor === 1 ? 2 : 1;

            // 逐步重建棋盘并对比
            const bd = Array(BOARD_SIZE)
              .fill(0)
              .map(() => Array(BOARD_SIZE).fill(EMPTY));
            const differences = [];
            let moveIndex = 0;

            // eslint-disable-next-line no-console
            console.log('[tmGomokuTest.replayAndCompare] 开始回溯对比');
            // eslint-disable-next-line no-console
            console.log('[tmGomokuTest.replayAndCompare] 黑子UID=', blackUid, '我方UID=', meUid, '我方颜色=', meColor === 1 ? '黑(先手)' : '白(后手)');
            // eslint-disable-next-line no-console
            console.log('[tmGomokuTest.replayAndCompare] 使用算法=', algo, '深度=', depth);
            // eslint-disable-next-line no-console
            console.log('[tmGomokuTest.replayAndCompare] 总步数=', moves.length);

            for (let i = 0; i < moves.length; i++) {
              const m = moves[i];
              const r = toInt(m.r, -1);
              const c = toInt(m.c, -1);
              const uid = toInt(m.uid, 0);
              if (r < 0 || c < 0 || r >= BOARD_SIZE || c >= BOARD_SIZE) {
                // eslint-disable-next-line no-console
                console.warn(`[tmGomokuTest.replayAndCompare] 第${i + 1}手坐标无效: (${r},${c})`);
                continue;
              }
              if (bd[r][c] !== EMPTY) {
                // eslint-disable-next-line no-console
                console.warn(`[tmGomokuTest.replayAndCompare] 第${i + 1}手位置已有棋子: (${r},${c})`);
                continue;
              }

              const isMyMove = toInt(uid, 0) === toInt(meUid, 0);
              const color = uid === blackUid ? 1 : 2;

              // 如果是AI的回合,计算推荐落子
              if (isMyMove) {
                const recommended = chooseMoveByAlgo(cloneBoard(bd), meColor, oppColor, algo, depth);
                if (recommended) {
                  const actualR = r;
                  const actualC = c;
                  const recR = recommended.r;
                  const recC = recommended.c;

                  if (actualR !== recR || actualC !== recC) {
                    differences.push({
                      moveIndex: i + 1,
                      moveNumber: moveIndex + 1,
                      actual: { r: actualR, c: actualC },
                      recommended: { r: recR, c: recC },
                      boardState: boardToAscii(bd, meColor, oppColor),
                    });
                    // eslint-disable-next-line no-console
                    console.log(`[tmGomokuTest.replayAndCompare] ⚠️  差异 #${differences.length} - 第${i + 1}手(我方第${moveIndex + 1}手):`);
                    // eslint-disable-next-line no-console
                    console.log(`  实际落子: (${actualR},${actualC})`);
                    // eslint-disable-next-line no-console
                    console.log(`  算法推荐: (${recR},${recC})`);
                    // eslint-disable-next-line no-console
                    console.log(`  当前棋盘:\n${boardToAscii(bd, meColor, oppColor)}`);
                  }
                } else {
                  // eslint-disable-next-line no-console
                  console.warn(`[tmGomokuTest.replayAndCompare] 第${i + 1}手(我方第${moveIndex + 1}手)算法无推荐落子`);
                }
                moveIndex++;
              }

              // 落子到棋盘
              bd[r][c] = color;

              // 检查是否已结束(有人连五)
              if (fastCheckWin(bd, r, c, color)) {
                // eslint-disable-next-line no-console
                console.log(`[tmGomokuTest.replayAndCompare] 第${i + 1}手后游戏结束,${color === meColor ? '我方' : '对方'}获胜`);
                break;
              }
            }

            // 输出总结
            // eslint-disable-next-line no-console
            console.log('\n[tmGomokuTest.replayAndCompare] ========== 回溯对比完成 ==========');
            // eslint-disable-next-line no-console
            console.log(`[tmGomokuTest.replayAndCompare] 总差异数: ${differences.length}`);
            if (differences.length > 0) {
              // eslint-disable-next-line no-console
              console.log('[tmGomokuTest.replayAndCompare] 差异详情:');
              differences.forEach((diff, idx) => {
                // eslint-disable-next-line no-console
                console.log(`\n差异 #${idx + 1}:`);
                // eslint-disable-next-line no-console
                console.log(`  步数: 第${diff.moveIndex}手(我方第${diff.moveNumber}手)`);
                // eslint-disable-next-line no-console
                console.log(`  实际: (${diff.actual.r},${diff.actual.c})`);
                // eslint-disable-next-line no-console
                console.log(`  推荐: (${diff.recommended.r},${diff.recommended.c})`);
              });
            } else {
              // eslint-disable-next-line no-console
              console.log('[tmGomokuTest.replayAndCompare] ✅ 所有AI落子与算法推荐一致!');
            }
            // eslint-disable-next-line no-console
            console.log('[tmGomokuTest.replayAndCompare] ====================================\n');

            return {
              totalMoves: moves.length,
              myMoves: moveIndex,
              differences: differences,
              summary: {
                totalDifferences: differences.length,
                matchRate: moveIndex > 0 ? ((moveIndex - differences.length) / moveIndex * 100).toFixed(2) + '%' : 'N/A',
              },
            };
          } catch (e) {
            // eslint-disable-next-line no-console
            console.error('[tmGomokuTest.replayAndCompare] 回溯对比失败:', e);
            return null;
          }
        },
      };
      // eslint-disable-next-line no-console
      console.log('[PVP-AI] tmGomokuTest 已挂到 window,可用于控制台复盘测试');
    } catch (e) {
      // ignore
    }
  })();

  // ======= PVP 适配层 =======
  function parseRoomBoard(room) {
    const bd = Array(BOARD_SIZE)
      .fill(0)
      .map(() => Array(BOARD_SIZE).fill(EMPTY));

    let moves = [];
    try {
      moves = room.board_state ? JSON.parse(room.board_state) : [];
      if (!Array.isArray(moves)) moves = [];
    } catch (e) {
      moves = [];
    }

    // 确定黑子 UID(优先 first_uid,其次用第一手)
    let blackUid = toInt(room.first_uid || 0, 0);
    if (!blackUid && moves.length) blackUid = toInt(moves[0].uid, 0);
    if (!blackUid && room.turn_uid) blackUid = toInt(room.turn_uid, 0); // 极端兜底:空盘时 turn_uid 就是先手

    for (const m of moves) {
      const r = toInt(m.r, -1);
      const c = toInt(m.c, -1);
      const uid = toInt(m.uid, 0);
      if (r < 0 || c < 0) continue;
      const color = uid === blackUid ? 1 : 2;
      if (r >= 0 && r < BOARD_SIZE && c >= 0 && c < BOARD_SIZE) {
        bd[r][c] = color;
      }
    }

    return { bd, movesCount: moves.length, blackUid };
  }

  function parseMovesFromRoom(room) {
    try {
      const moves = room && room.board_state ? JSON.parse(room.board_state) : [];
      if (!Array.isArray(moves)) return [];
      // 归一化:uid/r/c 统一转成 number,避免字符串导致误判
      return moves
        .map((m) => ({
          uid: toInt(m && m.uid, 0),
          r: toInt(m && m.r, -1),
          c: toInt(m && m.c, -1),
        }))
        .filter((m) => m.r >= 0 && m.c >= 0);
    } catch (e) {
      return [];
    }
  }

  function getMyColor(room, blackUid, myUid) {
    if (!blackUid || !myUid) return null;
    return toInt(myUid, 0) === toInt(blackUid, 0) ? 1 : 2;
  }

  function canAct(room, myUid) {
    if (!room || !myUid) return false;
    const uid = toInt(myUid, 0);
    const isParticipant = uid === toInt(room.owner_uid, -1) || uid === toInt(room.challenger_uid, -1);
    if (!isParticipant) return false;
    if (toInt(room.status, -1) !== 2) return false; // 2=对局中
    if (toInt(room.turn_uid, -1) !== uid) return false;
    return true;
  }

  function pvpLoad(roomId) {
    return new Promise((resolve, reject) => {
      const $ = PAGE_WIN.jQuery;
      const xnObj = PAGE_WIN.xn;
      if (!$ || !$.xpost || !xnObj || !xnObj.url) return reject(new Error('依赖缺失:jQuery/xn/$.xpost'));
      $.xpost(xnObj.url('my-coin-gomoku'), { op: 'pvp_load', room_id: roomId }, function (code, room) {
        if (code === 0) resolve(room);
        else reject(room || new Error('pvp_load failed: ' + code));
      });
    });
  }

  function pvpMove(roomId, r, c) {
    return new Promise((resolve, reject) => {
      const $ = PAGE_WIN.jQuery;
      const xnObj = PAGE_WIN.xn;
      if (!$ || !$.xpost || !xnObj || !xnObj.url) return reject(new Error('依赖缺失:jQuery/xn/$.xpost'));
      $.xpost(xnObj.url('my-coin-gomoku'), { op: 'pvp_move', room_id: roomId, r, c }, function (code, res) {
        if (code === 0) resolve(res);
        else reject(res || new Error('pvp_move failed: ' + code));
      });
    });
  }

  // ======= UI + 主循环 =======
  const state = {
    autoEnabled: false,
    maxDepth: 2,
    algo: 'minimax', // minimax | threat | hybrid | api
    apiUrl: 'http://gomoku.nps.ixh.cc/move', // Rapfi-API地址
    busy: false,
    lastActionKey: '',
    lastMoveAt: 0,
    lastRoomId: 0,
    lastStatus: null,
    lastMovesCount: null,
    lastTurnUid: null,
    // 人机日志状态
    lastGomokuMoveCount: null,
    lastGomokuGameOver: null,
    lastGomokuOverlayVisible: null,
  };

  // Settings 持久化(localStorage)
  const SETTINGS_KEY = 'tm_pvp_ai_settings_v2';
  function loadSettings() {
    try {
      const raw = localStorage.getItem(SETTINGS_KEY);
      if (!raw) return;
      const obj = JSON.parse(raw);
      if (!obj || typeof obj !== 'object') return;
      if (obj.algo) {
        if (obj.algo === 'threat') state.algo = 'threat';
        else if (obj.algo === 'hybrid') state.algo = 'hybrid';
        else if (obj.algo === 'api') state.algo = 'api';
        else state.algo = 'minimax';
      }
      if (typeof obj.maxDepth === 'number' && obj.maxDepth >= 1 && obj.maxDepth <= 4) state.maxDepth = obj.maxDepth;
      if (typeof obj.autoEnabled === 'boolean') state.autoEnabled = obj.autoEnabled;
      if (typeof obj.apiUrl === 'string' && obj.apiUrl.trim()) state.apiUrl = obj.apiUrl.trim();
      // 面板位置/最小化状态(可选)
      if (obj.panelPos && typeof obj.panelPos === 'object') {
        // 兼容旧版 {left,top} 与新版 {side,offset,top}
        state._panelPos = obj.panelPos;
      }
      if (obj.fabPos && typeof obj.fabPos === 'object') {
        state._fabPos = obj.fabPos;
      }
      if (typeof obj.isMinimized === 'boolean') state._isMinimized = obj.isMinimized;
    } catch (e) {
      // ignore
    }
  }

  function saveSettings() {
    try {
      const obj = { algo: state.algo, maxDepth: state.maxDepth, autoEnabled: !!state.autoEnabled, apiUrl: state.apiUrl };
      try {
        const panelEl = document.getElementById('tm_pvp_ai_panel');
        const fabEl = document.getElementById('tm_pvp_ai_fab');
        if (panelEl) {
          const pr = panelEl.getBoundingClientRect();
          const leftDist = Math.round(pr.left);
          const rightDist = Math.round(PAGE_WIN.innerWidth - pr.left - pr.width);
          if (leftDist <= rightDist) {
            obj.panelPos = { side: 'left', offset: leftDist, top: Math.round(pr.top) };
          } else {
            obj.panelPos = { side: 'right', offset: rightDist, top: Math.round(pr.top) };
          }
        }
        if (fabEl) {
          const fr = fabEl.getBoundingClientRect();
          const leftDist = Math.round(fr.left);
          const rightDist = Math.round(PAGE_WIN.innerWidth - fr.left - fr.width);
          if (leftDist <= rightDist) {
            obj.fabPos = { side: 'left', offset: leftDist, top: Math.round(fr.top) };
          } else {
            obj.fabPos = { side: 'right', offset: rightDist, top: Math.round(fr.top) };
          }
        }
        // isMinimized: true if fab is visible (panel hidden)
        obj.isMinimized = !!(fabEl && fabEl.style.display !== 'none');
      } catch (e) {
        // ignore measurement errors
      }
      localStorage.setItem(SETTINGS_KEY, JSON.stringify(obj));
    } catch (e) {
      // ignore
    }
  }

  // 立即加载(在 UI 初始化前)
  loadSettings();

  function ensurePanel() {
    if (document.getElementById('tm_pvp_ai_panel')) return;

    const panel = document.createElement('div');
    panel.id = 'tm_pvp_ai_panel';
    panel.style.cssText = [
      'position:fixed',
      'right:16px',
      'bottom:16px',
      'z-index:999999',
      'background:rgba(255,255,255,0.92)',
      'backdrop-filter:blur(10px)',
      'border:1px solid rgba(0,0,0,0.12)',
      'border-radius:12px',
      'padding:10px 12px',
      'font-size:12px',
      'color:#111',
      'box-shadow:0 10px 30px rgba(0,0,0,0.12)',
      'width:260px',
    ].join(';');

    panel.innerHTML = `
      <div id="tm_pvp_ai_header" style="display:flex;align-items:center;justify-content:space-between;gap:10px;cursor:move;">
        <div style="font-weight:900;">五子棋助手 v2.5</div>
        <button id="tm_pvp_ai_min" title="折叠/展开" style="border:1px solid rgba(0,0,0,0.15);border-radius:10px;padding:4px 8px;cursor:pointer;background:#fff;color:#333;font-weight:700;line-height:1;">—</button>
      </div>
      <div style="margin-top:10px;border-bottom:1px solid rgba(0,0,0,0.08);display:flex;gap:14px;align-items:flex-end;">
        <div id="tm_tab_gomoku" style="padding:6px 2px;font-weight:900;color:#6b7280;cursor:default;user-select:none;">
          人机对战
          <div class="tm_tab_line" style="height:2px;background:transparent;border-radius:2px;margin-top:6px;"></div>
        </div>
        <div id="tm_tab_pvp" style="padding:6px 2px;font-weight:900;color:#6b7280;cursor:default;user-select:none;">
          PVP对战
          <div class="tm_tab_line" style="height:2px;background:transparent;border-radius:2px;margin-top:6px;"></div>
        </div>
      </div>
      <div id="tm_pvp_ai_body" style="margin-top:10px;">
        <div id="tm_gomoku_tools" style="display:none;">
          <div style="display:flex;gap:8px;">
            <button id="tm_gomoku_auto"
              style="flex:1;border:1px solid rgba(0,0,0,0.12);border-radius:10px;padding:8px 10px;cursor:pointer;background:#fff;color:#111;font-weight:700;">
              自动:关
            </button>
            <button id="tm_gomoku_win"
              style="flex:1;border:1px solid rgba(0,0,0,0.12);border-radius:10px;padding:8px 10px;cursor:pointer;background:#fff;color:#111;font-weight:700;">
              一键赢
            </button>
          </div>
          <div style="margin-top:8px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
            <label style="display:flex;gap:6px;align-items:center;">
              <span>AI算法</span>
              <select id="tm_gomoku_ai_algo" style="padding:2px 6px;border-radius:8px;border:1px solid rgba(0,0,0,0.2);">
                <option value="minimax" selected>Minimax(优化版)</option>
                <option value="threat">Threat-First</option>
                <option value="hybrid">混合算法(推荐)</option>
                <option value="api">Rapfi-API</option>
              </select>
            </label>
            <label style="display:flex;gap:6px;align-items:center;">
              <span>AI强度</span>
              <select id="tm_gomoku_ai_depth" title="强度越高越耗CPU,可能卡顿" style="padding:2px 6px;border-radius:8px;border:1px solid rgba(0,0,0,0.2);">
                <option value="1">快(更流畅)</option>
                <option value="2" selected>标准(推荐)</option>
                <option value="3">强(更慢)</option>
                <option value="4">很强(可能卡)</option>
              </select>
            </label>
          </div>
          <div id="tm_gomoku_ai_algo_desc" style="margin-top:6px;color:#6b7280;font-size:11px;line-height:1.4;"></div>
          <div style="margin-top:8px;display:flex;gap:6px;align-items:center;">
            <span style="color:#6b7280;font-weight:700;">棋API</span>
            <input id="tm_gomoku_api_url" placeholder="http://gomoku.nps.ixh.cc/move"
              style="flex:1;min-width:0;padding:4px 6px;border-radius:8px;border:1px solid rgba(0,0,0,0.2);font-size:12px;" />
          </div>
        </div>
        <div id="tm_pvp_tools" style="display:none;">
          <div style="display:flex;gap:8px;">
            <button id="tm_pvp_ai_auto"
              style="flex:1;border:1px solid rgba(0,0,0,0.12);border-radius:10px;padding:8px 10px;cursor:pointer;background:#fff;color:#111;font-weight:700;">
              自动:关
            </button>
            <button id="tm_pvp_ai_manual"
              style="flex:1;border:1px solid rgba(0,0,0,0.12);border-radius:10px;padding:8px 10px;cursor:pointer;background:#fff;color:#111;font-weight:700;">
              下一手
            </button>
          </div>
          <div style="margin-top:8px;display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;">
            <label style="display:flex;gap:6px;align-items:center;">
              <span>AI算法</span>
              <select id="tm_pvp_ai_algo" style="padding:2px 6px;border-radius:8px;border:1px solid rgba(0,0,0,0.2);">
                <option value="minimax" selected>Minimax(优化版)</option>
                <option value="threat">Threat-First</option>
                <option value="hybrid">混合算法(推荐)</option>
                <option value="api">Rapfi-API</option>
              </select>
            </label>
            <label style="display:flex;gap:6px;align-items:center;">
              <span>AI强度</span>
              <select id="tm_pvp_ai_depth" title="强度越高越耗CPU,可能卡顿" style="padding:2px 6px;border-radius:8px;border:1px solid rgba(0,0,0,0.2);">
                <option value="1">快(更流畅)</option>
                <option value="2" selected>标准(推荐)</option>
                <option value="3">强(更慢)</option>
                <option value="4">很强(可能卡)</option>
              </select>
            </label>
          </div>
          <div id="tm_pvp_ai_algo_desc" style="margin-top:6px;color:#6b7280;font-size:11px;line-height:1.4;"></div>
          <div style="margin-top:8px;display:flex;gap:6px;align-items:center;">
            <span style="color:#6b7280;font-weight:700;">棋API</span>
            <input id="tm_chess_api_url" placeholder="http://gomoku.nps.ixh.cc/move"
              style="flex:1;min-width:0;padding:4px 6px;border-radius:8px;border:1px solid rgba(0,0,0,0.2);font-size:12px;" />
          </div>
        </div>
        <div id="tm_pvp_ai_status" style="margin-top:10px;color:#444;line-height:1.4;padding:8px 10px;border:1px dashed rgba(0,0,0,0.18);border-radius:10px;background:rgba(0,0,0,0.02);">
          状态:等待进入房间…
        </div>
        <div style="margin-top:10px;display:flex;align-items:center;justify-content:space-between;">
          <div style="font-weight:800;color:#333;">对局日志</div>
          <button id="tm_pvp_ai_clearlog" title="清空日志" style="border:0;background:transparent;color:#007aff;cursor:pointer;padding:6px;border-radius:8px;display:flex;align-items:center;justify-content:center;width:30px;height:28px;">
            <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="display:block;">
              <polyline points="3 6 5 6 21 6"></polyline>
              <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
              <path d="M10 11v6"></path>
              <path d="M14 11v6"></path>
              <path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path>
            </svg>
          </button>
        </div>
        <div id="tm_pvp_ai_log"
          style="margin-top:6px;height:180px;overflow:auto;font-size:11px;line-height:1.45;border:1px solid rgba(0,0,0,0.12);border-radius:10px;padding:8px;background:rgba(255,255,255,0.9);">
        </div>
      </div>
    `;

    document.body.appendChild(panel);

    // 注入样式:按钮去掉黑边,增加 hover / active 效果,统一文字颜色
    (function injectStyles() {
      try {
        const css = `
/* 按钮基准:保留页面默认字体、添加浅边框与阴影让按钮可见 */
#tm_pvp_ai_panel button {
  border: 1px solid rgba(0,0,0,0.06) !important;
  background: #fff;
  color: inherit !important;
  padding: 8px 10px;
  border-radius:10px;
  transition: all .12s ease;
  box-shadow: 0 1px 2px rgba(16,24,40,0.04) !important;
}
#tm_pvp_ai_panel button:hover {
  background: #f8fafc;
  transform: translateY(-1px);
  box-shadow: 0 4px 8px rgba(16,24,40,0.06) !important;
}
#tm_pvp_ai_panel button:active {
  transform: translateY(0);
  background: #eef2f7;
  box-shadow: 0 1px 2px rgba(16,24,40,0.04) !important;
}
#tm_pvp_ai_panel button:focus {
  outline: none;
  box-shadow: 0 0 0 3px rgba(0,122,255,0.12) !important;
}

/* 状态类:开启态使用主色填充 */
#tm_pvp_ai_panel #tm_gomoku_auto.active {
  background: #007aff !important;
  color: #fff !important;
  border-color: #007aff !important;
}
#tm_pvp_ai_panel #tm_gomoku_auto.active:hover {
  background: #0066dd !important;
}

/* 一键赢:保持白底但使用红色文字提示,hover 有浅红背景 */
#tm_pvp_ai_panel #tm_gomoku_win {
  color: #ef4444;
  background: #fff;
  border-color: rgba(239,68,68,0.12) !important;
}
#tm_pvp_ai_panel #tm_gomoku_win:hover {
  background: #fff7f7;
}

#tm_pvp_ai_panel #tm_pvp_ai_auto.active {
  background: #007aff !important;
  color: #fff !important;
  border-color: #007aff !important;
}
`;
        const style = document.createElement('style');
        style.setAttribute('type', 'text/css');
        style.textContent = css;
        document.head.appendChild(style);
      } catch (err) {
        // ignore
      }
    })();

    const btnMin = panel.querySelector('#tm_pvp_ai_min');
    const header = panel.querySelector('#tm_pvp_ai_header');
    const body = panel.querySelector('#tm_pvp_ai_body');
    const btnAuto = panel.querySelector('#tm_pvp_ai_auto');
    const btnManual = panel.querySelector('#tm_pvp_ai_manual');
    const selAlgo = panel.querySelector('#tm_pvp_ai_algo');
    const sel = panel.querySelector('#tm_pvp_ai_depth');
    const algoDesc = panel.querySelector('#tm_pvp_ai_algo_desc');
    const btnClear = panel.querySelector('#tm_pvp_ai_clearlog');
    const tabGomoku = panel.querySelector('#tm_tab_gomoku');
    const tabPvp = panel.querySelector('#tm_tab_pvp');
    const gomokuTools = panel.querySelector('#tm_gomoku_tools');
    const pvpTools = panel.querySelector('#tm_pvp_tools');
    const btnGomokuAuto = panel.querySelector('#tm_gomoku_auto');
    const btnWin = panel.querySelector('#tm_gomoku_win');
    const selGomokuAlgo = panel.querySelector('#tm_gomoku_ai_algo');
    const selGomokuDepth = panel.querySelector('#tm_gomoku_ai_depth');
    const gomokuAlgoDesc = panel.querySelector('#tm_gomoku_ai_algo_desc');
    const gomokuApiUrlInput = panel.querySelector('#tm_gomoku_api_url');
    const apiUrlInput = panel.querySelector('#tm_chess_api_url');

    // 创建可拖动的圆形悬浮图标(最小化使用)
    const fab = document.createElement('div');
    fab.id = 'tm_pvp_ai_fab';
    fab.style.cssText = [
  'position:fixed',
  'right:16px',
  'bottom:16px',
  'z-index:999999',
  'width:44px',
  'height:44px',
  'border-radius:50%',
  'background:linear-gradient(rgb(165, 158, 142), rgb(127, 111, 88))',
  'box-shadow:0 10px 30px rgba(0,0,0,0.25)',
  'display:none',
  'align-items:center',
  'justify-content:center',
  'cursor:pointer',
  'color:#fff',
  'font-weight:900',
  'font-size:13px',
  'user-select:none',
].join(';');
fab.textContent = '棋';
    document.body.appendChild(fab);

    function clampToViewport(x, y, el) {
      const vw = PAGE_WIN.innerWidth;
      const vh = PAGE_WIN.innerHeight;
      const rect = el.getBoundingClientRect();
      const w = rect.width;
      const h = rect.height;
      const margin = 4;
      return {
        x: Math.min(Math.max(margin, x), vw - margin - w),
        y: Math.min(Math.max(margin, y), vh - margin - h),
      };
    }

    // 解析已保存的位置(仅支持新版 {side:'left'|'right', offset:<px>, top:<px>})
    function resolveSavedPos(pos, el) {
      if (!pos || !el) return null;
      try {
        const vw = PAGE_WIN.innerWidth;
        const rect = el.getBoundingClientRect();
        const w = rect.width;
        const h = rect.height;
        const margin = 4;
        const top = typeof pos.top === 'number' ? pos.top : margin;
        if (pos.side === 'left') {
          const x = Math.min(Math.max(margin, pos.offset || margin), vw - margin - w);
          return { x: x, y: Math.min(Math.max(margin, top), PAGE_WIN.innerHeight - margin - h) };
        }
        // 默认按 right 处理
        const xRight = Math.round(vw - (pos.offset || margin) - w);
        const xClamped = Math.min(Math.max(margin, xRight), vw - margin - w);
        return { x: xClamped, y: Math.min(Math.max(margin, top), PAGE_WIN.innerHeight - margin - h) };
      } catch (e) {
        return null;
      }
    }

    function snapToNearestEdge(x, y, el) {
        const vw = PAGE_WIN.innerWidth;
        const vh = PAGE_WIN.innerHeight;
      const rect = el.getBoundingClientRect();
      const w = rect.width;
      const h = rect.height;
      const margin = 4;
      const clamped = clampToViewport(x, y, el);
      // 仅左右贴边:比较左右两侧距离,返回水平贴边位置(y 保持在视口内)
      const leftDist = clamped.x - margin;
      const rightDist = vw - margin - (clamped.x + w);
      if (leftDist <= rightDist) return { x: margin, y: clamped.y };
      return { x: vw - margin - w, y: clamped.y };
    }

    // 通用拖拽函数(支持面板和圆形图标)
    function makeDraggable(el, handle) {
      if (!el) return;
      const dragHandle = handle || el;
      let dragging = false;
      let startX = 0;
      let startY = 0;
      let offsetX = 0;
      let offsetY = 0;
      let moved = false;

      function onMouseDown(e) {
        if (e.button !== 0) return;
        dragging = true;
        moved = false;
        const rect = el.getBoundingClientRect();
        // 第一次拖拽时,从当前视觉位置转换为 left/top
        if (!el.style.left && !el.style.right) {
          el.style.left = rect.left + 'px';
          el.style.top = rect.top + 'px';
          el.style.right = 'auto';
          el.style.bottom = 'auto';
        }
        startX = e.clientX;
        startY = e.clientY;
        offsetX = rect.left;
        offsetY = rect.top;
        document.addEventListener('mousemove', onMouseMove);
        document.addEventListener('mouseup', onMouseUp);
        e.preventDefault();
      }

      function onMouseMove(e) {
        if (!dragging) return;
        const dx = e.clientX - startX;
        const dy = e.clientY - startY;
        if (!moved && Math.abs(dx) + Math.abs(dy) > 3) moved = true;
        let newLeft = offsetX + dx;
        let newTop = offsetY + dy;

        // 约束在视口内
        const vw = PAGE_WIN.innerWidth;
        const vh = PAGE_WIN.innerHeight;
        const rect = el.getBoundingClientRect();
        const w = rect.width;
        const h = rect.height;
        const margin = 4;
        newLeft = Math.min(Math.max(margin - w * 0.5, newLeft), vw - margin);
        newTop = Math.min(Math.max(margin, newTop), vh - margin - h);

        el.style.left = newLeft + 'px';
        el.style.top = newTop + 'px';
        el.style.right = 'auto';
        el.style.bottom = 'auto';
      }

      function onMouseUp() {
        dragging = false;
        // 记录本次是否拖动过,用于阻止点击触发展开
        el._dragMoved = moved;
        setTimeout(() => {
          el._dragMoved = false;
        }, 30);
        // 拖动结束后如果是浮动小图标,则自动贴边;若是面板则确保不出界
        try {
          if (el && el.id === 'tm_pvp_ai_fab') {
            const left = parseFloat(el.style.left) || el.getBoundingClientRect().left || 0;
            const top = parseFloat(el.style.top) || el.getBoundingClientRect().top || 0;
            const snap = snapToNearestEdge(left, top, el);
            el.style.left = snap.x + 'px';
            el.style.top = snap.y + 'px';
          } else if (el && el.id === 'tm_pvp_ai_panel') {
            // 确保面板最终位置在视口内(不贴上下边)
            const left = parseFloat(el.style.left) || el.getBoundingClientRect().left || 0;
            const top = parseFloat(el.style.top) || el.getBoundingClientRect().top || 0;
            const clamped = clampToViewport(left, top, el);
            el.style.left = clamped.x + 'px';
            el.style.top = clamped.y + 'px';
          }
        } catch (err) {
          // ignore
        }
        // persist panel/fab position after drag
        try {
          saveSettings();
        } catch (e) {}
        document.removeEventListener('mousemove', onMouseMove);
        document.removeEventListener('mouseup', onMouseUp);
      }

      dragHandle.addEventListener('mousedown', onMouseDown);
    }

    // 面板和圆形图标均可拖动
    makeDraggable(panel, header);
    makeDraggable(fab, fab);
    // 恢复上次保存的位置与最小化状态(如果有)
    try {
      const raw = localStorage.getItem(SETTINGS_KEY);
      if (raw) {
        const s = JSON.parse(raw);
        if (s && typeof s === 'object') {
          // 如果上次是最小化,显示 fab 并设置位置
          if (s.isMinimized) {
            panel.style.display = 'none';
            fab.style.display = 'flex';
            if (s.fabPos) {
              const pos = resolveSavedPos(s.fabPos, fab) || clampToViewport(parseFloat(panel.style.left) || 16, parseFloat(panel.style.top) || 16, fab);
              fab.style.left = pos.x + 'px';
              fab.style.top = pos.y + 'px';
              fab.style.right = 'auto';
              fab.style.bottom = 'auto';
            } else {
              // 默认贴边
              const snap = snapToNearestEdge(parseFloat(panel.style.left) || 16, parseFloat(panel.style.top) || 16, fab);
              const clampedY = clampToViewport(snap.x, snap.y, fab).y;
              fab.style.left = snap.x + 'px';
              fab.style.top = clampedY + 'px';
            }
          } else {
            // 显示面板并恢复位置
            panel.style.display = 'block';
            fab.style.display = 'none';
            if (s.panelPos) {
              const pos = resolveSavedPos(s.panelPos, panel);
              if (pos) {
                panel.style.left = pos.x + 'px';
                panel.style.top = pos.y + 'px';
                panel.style.right = 'auto';
                panel.style.bottom = 'auto';
              }
            }
          }
        }
      }
    } catch (e) {
      // ignore
    }

    function minimizeToFab() {
      const rect = panel.getBoundingClientRect();
      panel.style.display = 'none';
      fab.style.display = 'flex';
      // 最小化时自动水平贴边(只贴左右),并确保 y 在视口内
      const left = rect.left;
      const top = rect.top;
      const snap = snapToNearestEdge(left, top, fab);
      const clampedY = clampToViewport(snap.x, snap.y, fab).y;
      fab.style.left = snap.x + 'px';
      fab.style.top = clampedY + 'px';
      fab.style.right = 'auto';
      fab.style.bottom = 'auto';
      // persist
      try {
        saveSettings();
      } catch (e) {}
    }

    function restoreFromFab() {
      const rect = fab.getBoundingClientRect();
      fab.style.display = 'none';
      panel.style.display = 'block';
      const pos = clampToViewport(rect.left, rect.top, panel);
      panel.style.left = pos.x + 'px';
      panel.style.top = pos.y + 'px';
      panel.style.right = 'auto';
      panel.style.bottom = 'auto';
      // persist
      try {
        saveSettings();
      } catch (e) {}
    }

    fab.addEventListener('click', () => {
      // 如果刚刚是拖动导致的 mouseup,则不触发展开
      if (fab._dragMoved) return;
      restoreFromFab();
    });

    // Tab 自动高亮当前页面,并禁用另一个 tab 点击(不跳转)
    function setActiveTab() {
      if (PAGE === 'gomoku') {
        tabGomoku.style.color = '#111827';
        tabGomoku.querySelector('.tm_tab_line').style.background = '#111827';
        tabPvp.style.color = '#9ca3af';
        tabPvp.querySelector('.tm_tab_line').style.background = 'transparent';
        gomokuTools.style.display = 'block';
        pvpTools.style.display = 'none';
        // tab 样式:高亮当前 tab(线条和文字),不使用图标
      } else if (PAGE === 'pvp') {
        tabPvp.style.color = '#111827';
        tabPvp.querySelector('.tm_tab_line').style.background = '#111827';
        tabGomoku.style.color = '#9ca3af';
        tabGomoku.querySelector('.tm_tab_line').style.background = 'transparent';
        gomokuTools.style.display = 'none';
        pvpTools.style.display = 'block';
        // tab 样式:高亮当前 tab(线条和文字),不使用图标
      } else {
        tabGomoku.style.color = '#9ca3af';
        tabPvp.style.color = '#9ca3af';
        tabGomoku.querySelector('.tm_tab_line').style.background = 'transparent';
        tabPvp.querySelector('.tm_tab_line').style.background = 'transparent';
        gomokuTools.style.display = 'none';
        pvpTools.style.display = 'none';
        // tab 样式:恢复默认(不使用图标)
      }
    }
    setActiveTab();

    function syncAutoBtn() {
      btnAuto.textContent = state.autoEnabled ? '自动:开' : '自动:关';
      btnAuto.classList.toggle('active', state.autoEnabled);
    }

    // 把已保存的设置应用到 UI(避免覆盖用户上次选择)
    try {
      if (selAlgo) selAlgo.value = state.algo || 'minimax';
      if (sel) sel.value = String(state.maxDepth);
      if (selGomokuAlgo) selGomokuAlgo.value = state.algo || 'minimax';
      if (selGomokuDepth) selGomokuDepth.value = String(state.maxDepth);
      if (gomokuApiUrlInput) gomokuApiUrlInput.value = state.apiUrl || '';
      if (apiUrlInput) apiUrlInput.value = state.apiUrl || '';
    } catch (e) {
      // ignore
    }

    // 通用的API输入框处理函数
    function handleApiUrlChange(sourceInput, targetInput) {
      const v = String(sourceInput.value || '').trim();
      if (v) {
        state.apiUrl = v;
        // 同步到另一个输入框
        if (targetInput) targetInput.value = v;
        saveSettings();
      }
    }

    if (gomokuApiUrlInput) {
      gomokuApiUrlInput.addEventListener('change', () => {
        handleApiUrlChange(gomokuApiUrlInput, apiUrlInput);
      });
    }

    if (apiUrlInput) {
      apiUrlInput.addEventListener('change', () => {
        handleApiUrlChange(apiUrlInput, gomokuApiUrlInput);
      });
    }

    btnAuto.addEventListener('click', () => {
      state.autoEnabled = !state.autoEnabled;
      syncAutoBtn();
      toast(state.autoEnabled ? '自动模式:开启' : '自动模式:关闭');
      addLog(state.autoEnabled ? '自动模式已开启(轮到你会自动下)' : '自动模式已关闭');
      saveSettings();
    });

    btnManual.addEventListener('click', async () => {
      try {
        if (state.busy) {
          toast('AI忙碌中,请稍后');
          return;
        }
        const roomId = PAGE_WIN.currentPvpRoomId || 0;
        if (!roomId) {
          toast('暂无房间');
          addLog('手动:暂无房间');
          return;
        }

        state.busy = true;
        addLog('手动:请求计算中…');

        const myUid = getMyUid();
        const room = await pvpLoad(roomId);
        const { bd, movesCount, blackUid } = parseRoomBoard(room);
        const movesArr = parseMovesFromRoom(room);
        const myColor = getMyColor(room, blackUid, myUid);
        if (!myColor) {
          toast('等待对局初始化(无法确定执子)');
          addLog('手动:无法确定执子,取消');
          return;
        }
        const oppColor = myColor === 1 ? 2 : 1;

        if (!canAct(room, myUid)) {
          toast(room.status !== 2 ? '未在对局中/未开始' : '还没轮到你');
          addLog(`手动:不可落子(状态=${statusToText(room.status)},turn_uid=${room.turn_uid},moves=${movesCount})`);
          return;
        }

        let mv = null;
        if (state.algo === 'api') {
          mv = await chessApiBestMove(movesArr, toInt(myUid, 0));
        } else {
          const bd2 = cloneBoard(bd);
          mv = chooseMoveByAlgo(bd2, myColor, oppColor, state.algo, state.maxDepth);
        }
        if (!mv) {
          toast('无可用落子点');
          addLog('手动:无可用落子点');
          return;
        }

        // 轻微随机延迟,避免太机械
        const delay = 120 + Math.floor(Math.random() * 200);
        await new Promise((r) => setTimeout(r, delay));

        // 再确认一次
        const room2 = await pvpLoad(roomId);
        if (!canAct(room2, myUid)) {
          toast('局面已变化,未落子');
          addLog('手动:局面变化/回合变化,未落子');
          return;
        }

        await pvpMove(roomId, mv.r, mv.c);
        state.lastMoveAt = Date.now();
        toast(`AI已落子:(${mv.r},${mv.c})`);
        addLog(`玩家落子:(${mv.r},${mv.c})`);
      } catch (e) {
        console.warn('[PVP-AI] manual error:', e);
        toast('手动执行失败,见控制台');
        addLog('手动:执行失败(见控制台)');
      } finally {
        state.busy = false;
      }
    });

    // 人机:自动下 + 一键赢(使用我们的AI算法自动下棋)
    // gomokuState 已在全局作用域定义

    function setAutoBtn() {
      try {
        btnGomokuAuto.textContent = gomokuState.autoOn ? '自动:开' : '自动:关';
        btnGomokuAuto.classList.toggle('active', gomokuState.autoOn);
      } catch (err) {
        // ignore
      }
    }

    // 获取棋盘状态(从window.board或DOM)
    function getGomokuBoard() {
      const bd = PAGE_WIN.board || (typeof board !== 'undefined' ? board : null);
      if (Array.isArray(bd) && bd.length === BOARD_SIZE) {
        return cloneBoard(bd);
      }
      // 从DOM重建棋盘
      const boardFromDom = Array(BOARD_SIZE).fill(0).map(() => Array(BOARD_SIZE).fill(EMPTY));
      document.querySelectorAll('.intersection').forEach((cell) => {
        const r = parseInt(cell.getAttribute('data-row') || '', 10);
        const c = parseInt(cell.getAttribute('data-col') || '', 10);
        if (Number.isNaN(r) || Number.isNaN(c)) return;
        const piece = cell.querySelector('.chess-piece');
        if (piece && piece.classList.contains('active')) {
          // 通过棋子标记来判断是玩家还是AI
          // last-move-halo = 玩家的最后一步
          // last-move-dot = AI的最后一步
          // 如果没有标记,则通过颜色判断(black可能是玩家或AI,取决于谁先手)
          const isPlayerPiece = piece.classList.contains('last-move-halo');
          const isComputerPiece = piece.classList.contains('last-move-dot');

          // 推断 PLAYER 和 COMPUTER 的值
          let PLAYER = 1;
          let COMPUTER = 2;

          if (typeof PAGE_WIN.PLAYER !== 'undefined' && PAGE_WIN.PLAYER !== null) {
            PLAYER = PAGE_WIN.PLAYER;
          }
          if (typeof PAGE_WIN.COMPUTER !== 'undefined' && PAGE_WIN.COMPUTER !== null) {
            COMPUTER = PAGE_WIN.COMPUTER;
          } else {
            // 通过第一个棋子推断
            const firstPiece = document.querySelector('.chess-piece.active');
            if (firstPiece) {
              const isBlack = firstPiece.classList.contains('black');
              const firstIsPlayer = firstPiece.classList.contains('last-move-halo');
              const firstIsComputer = firstPiece.classList.contains('last-move-dot');

              if (isBlack) {
                if (firstIsPlayer) {
                  PLAYER = 1;
                  COMPUTER = 2;
                } else if (firstIsComputer) {
                  PLAYER = 2;
                  COMPUTER = 1;
                }
              } else {
                if (firstIsPlayer) {
                  PLAYER = 2;
                  COMPUTER = 1;
                } else if (firstIsComputer) {
                  PLAYER = 1;
                  COMPUTER = 2;
                }
              }
            }
          }

          // 根据标记判断
          if (isPlayerPiece) {
            boardFromDom[r][c] = PLAYER;
          } else if (isComputerPiece) {
            boardFromDom[r][c] = COMPUTER;
          } else {
            // 如果没有标记,使用颜色作为后备(black通常是1,white通常是2)
            const isBlack = piece.classList.contains('black');
            boardFromDom[r][c] = isBlack ? 1 : 2;
          }
        }
      });
      return boardFromDom;
    }

    // 计算棋盘哈希(用于检测变化)
    function getBoardHash(bd) {
      let hash = 0;
      for (let r = 0; r < BOARD_SIZE; r++) {
        for (let c = 0; c < BOARD_SIZE; c++) {
          hash = ((hash << 5) - hash) + (bd[r][c] || 0);
          hash = hash & hash; // Convert to 32bit integer
        }
      }
      return hash;
    }

    // 统计棋盘上的棋子数
    function countPieces(bd) {
      let count = 0;
      for (let r = 0; r < BOARD_SIZE; r++) {
        for (let c = 0; c < BOARD_SIZE; c++) {
          if (bd[r][c] !== EMPTY) count++;
        }
      }
      return count;
    }

    function placeByPage(r, c, who) {
      // 由于 placeChess 在闭包内,无法直接调用
      // 通过触发 DOM 点击事件来落子
      try {
        const $ = PAGE_WIN.jQuery;
        if ($) {
          const $cell = $(`.intersection[data-row="${r}"][data-col="${c}"]`);
          if ($cell.length > 0) {
            // 检查是否可以点击(未被占用)
            const $piece = $cell.find('.chess-piece');
            if ($piece.hasClass('active')) {
              return false; // 已被占用
            }
            // 触发点击事件
            $cell.trigger('click');
            return true;
          }
        }
        // 备用方案:直接操作 DOM
        const cell = document.querySelector(`.intersection[data-row="${r}"][data-col="${c}"]`);
        if (cell) {
          const piece = cell.querySelector('.chess-piece');
          if (piece && piece.classList.contains('active')) {
            return false; // 已被占用
          }
          // 触发点击事件
          const clickEvent = new MouseEvent('click', {
            bubbles: true,
            cancelable: true,
            view: window
          });
          cell.dispatchEvent(clickEvent);
          return true;
        }
      } catch (e) {
        console.warn('[Gomoku-Auto] placeByPage error:', e);
      }
      return false;
    }

    function enableGomokuAuto() {
      // 不覆盖 makeComputerMove,让原AI正常下棋
      // 我们只在玩家回合时自动下棋

      // 自动下棋循环
      if (gomokuState.autoInterval) {
        clearInterval(gomokuState.autoInterval);
      }

      gomokuState.autoInterval = setInterval(() => {
        try {
          // 检查游戏是否已结束
          const hasWinningPieces = document.querySelectorAll('.winning-piece').length > 0;
          const overlay = document.getElementById('game_overlay');
          const overlayVisible = overlay && (overlay.style.display !== 'none' && overlay.offsetParent !== null);

          // 如果游戏已结束(有获胜棋子),停止自动下棋
          if (hasWinningPieces || (typeof PAGE_WIN.isGameOver !== 'undefined' && PAGE_WIN.isGameOver)) {
            gomokuState.lastBoardHash = null;
            gomokuState.lastMoveCount = 0;
            // 游戏结束,保留历史记录(不清空,以便复盘)
            return;
          }

          // 检查游戏是否已开始(overlay 隐藏表示游戏已开始)
          const pieceCount = document.querySelectorAll('.chess-piece.active').length;
          const gameStarted = !overlayVisible; // overlay 隐藏表示游戏已开始

          // 如果游戏未开始(overlay 显示且没有棋子),等待游戏开始
          if (overlayVisible && pieceCount === 0) {
            // 清空落子历史
            gomokuState.moveHistory = [];
            gomokuState.lastObservedPieceCount = 0;
            return; // 等待游戏开始
          }

          // 检测新落子并记录
          if (pieceCount > gomokuState.lastObservedPieceCount) {
            // 有新的落子,找到新落子的位置
            const lastPlayerPiece = document.querySelector('.chess-piece.active.last-move-halo');
            const lastComputerPiece = document.querySelector('.chess-piece.active.last-move-dot');

            if (lastPlayerPiece) {
              const cell = lastPlayerPiece.closest('.intersection');
              if (cell) {
                const r = parseInt(cell.getAttribute('data-row') || '', 10);
                const c = parseInt(cell.getAttribute('data-col') || '', 10);
                if (!Number.isNaN(r) && !Number.isNaN(c)) {
                  // 检查是否已记录(避免重复)
                  const alreadyRecorded = gomokuState.moveHistory.some(m => m.r === r && m.c === c);
                  if (!alreadyRecorded) {
                    gomokuState.moveHistory.push({ uid: 1, r, c }); // 玩家=1
                    addLog(`玩家落子:(${r},${c})`);
                  }
                }
              }
            } else if (lastComputerPiece) {
              const cell = lastComputerPiece.closest('.intersection');
              if (cell) {
                const r = parseInt(cell.getAttribute('data-row') || '', 10);
                const c = parseInt(cell.getAttribute('data-col') || '', 10);
                if (!Number.isNaN(r) && !Number.isNaN(c)) {
                  // 检查是否已记录(避免重复)
                  const alreadyRecorded = gomokuState.moveHistory.some(m => m.r === r && m.c === c);
                  if (!alreadyRecorded) {
                    gomokuState.moveHistory.push({ uid: 2, r, c }); // AI=2
                    addLog(`AI落子:(${r},${c})`);
                  }
                }
              }
            }
            gomokuState.lastObservedPieceCount = pieceCount;
          } else if (pieceCount < gomokuState.lastObservedPieceCount) {
            // 棋子数量减少,说明游戏重置了,清空历史
            gomokuState.moveHistory = [];
            gomokuState.lastObservedPieceCount = pieceCount;
          }

          // 检查是否正在思考(原AI正在下棋)
          const isThinking = typeof PAGE_WIN.isThinking !== 'undefined' && PAGE_WIN.isThinking;
          if (isThinking) {
            return; // 等待原AI下完
          }

          // 获取棋盘状态
          const bd = getGomokuBoard();
          const currentHash = getBoardHash(bd);
          const currentMoveCount = countPieces(bd);

          // 如果棋盘没有变化,跳过
          if (gomokuState.lastBoardHash === currentHash && gomokuState.lastMoveCount === currentMoveCount) {
            return;
          }

          // 更新状态
          gomokuState.lastBoardHash = currentHash;
          gomokuState.lastMoveCount = currentMoveCount;

          // 检查是否轮到玩家
          // 通过分析棋盘状态来推断 PLAYER 和 COMPUTER 的值
          let PLAYER = 1;
          let COMPUTER = 2;

          // 尝试从 window 获取(如果页面暴露了)
          if (typeof PAGE_WIN.PLAYER !== 'undefined' && PAGE_WIN.PLAYER !== null) {
            PLAYER = PAGE_WIN.PLAYER;
          }
          if (typeof PAGE_WIN.COMPUTER !== 'undefined' && PAGE_WIN.COMPUTER !== null) {
            COMPUTER = PAGE_WIN.COMPUTER;
          } else {
            // 通过分析棋盘上的第一个棋子来推断
            const firstPiece = document.querySelector('.chess-piece.active');
            if (firstPiece) {
              const isBlack = firstPiece.classList.contains('black');
              const isPlayerPiece = firstPiece.classList.contains('last-move-halo');
              const isComputerPiece = firstPiece.classList.contains('last-move-dot');

              // 如果第一个棋子是黑色且是玩家的,则 PLAYER=1, COMPUTER=2
              // 如果第一个棋子是黑色且是AI的,则 PLAYER=2, COMPUTER=1
              if (isBlack) {
                if (isPlayerPiece) {
                  PLAYER = 1;
                  COMPUTER = 2;
                } else if (isComputerPiece) {
                  PLAYER = 2;
                  COMPUTER = 1;
                }
              } else {
                // 白色棋子
                if (isPlayerPiece) {
                  PLAYER = 2;
                  COMPUTER = 1;
                } else if (isComputerPiece) {
                  PLAYER = 1;
                  COMPUTER = 2;
                }
              }
            }
          }

          // 统计PLAYER和COMPUTER的棋子数
          let playerCount = 0;
          let computerCount = 0;
          for (let r = 0; r < BOARD_SIZE; r++) {
            for (let c = 0; c < BOARD_SIZE; c++) {
              if (bd[r][c] === PLAYER) playerCount++;
              else if (bd[r][c] === COMPUTER) computerCount++;
            }
          }

          // 判断是否轮到玩家:
          // 方法1:通过检查最后一个落子标记来判断
          const lastPlayerPiece = document.querySelector('.chess-piece.active.last-move-halo');
          const lastComputerPiece = document.querySelector('.chess-piece.active.last-move-dot');

          let isPlayerTurn = false;

          if (lastPlayerPiece) {
            // 玩家刚下完,轮到AI(不是玩家)
            isPlayerTurn = false;
          } else if (lastComputerPiece) {
            // AI刚下完,轮到玩家
            isPlayerTurn = true;
          } else {
            // 没有最后一个落子标记,通过棋子数量判断
            // - 棋盘为空时,如果游戏已开始,默认轮到先手(玩家或AI都有可能)
            // - PLAYER的棋子数 == COMPUTER的棋子数时,轮到PLAYER
            // - PLAYER的棋子数 < COMPUTER的棋子数时,轮到PLAYER
            if (playerCount === 0 && computerCount === 0) {
              // 棋盘为空,如果游戏已开始,需要判断谁先手
              // 由于无法直接判断,我们假设玩家先手(如果游戏已开始)
              isPlayerTurn = gameStarted;
            } else {
              // 棋盘有棋子,通过棋子数量判断
              isPlayerTurn = playerCount <= computerCount;
            }
          }

          if (!isPlayerTurn) {
            return; // 还没轮到玩家
          }

          // 检查是否可以落子(棋盘是否已初始化)
          const canPlace = document.querySelectorAll('.intersection').length > 0;
          if (!canPlace) {
            addLog('自动:棋盘未初始化');
            return;
          }

          // 设置标记,防止重复触发
          if (gomokuState.isPlacing) {
            return; // 正在落子中
          }
          gomokuState.isPlacing = true;

          // 使用我们的AI算法计算最佳落子(支持同步和异步)
          const bd2 = cloneBoard(bd);
          let movePromise;

          if (state.algo === 'api') {
            // API 调用是异步的
            movePromise = gomokuApiBestMove(bd2, PLAYER, COMPUTER).catch(err => {
              addLog(`自动:API调用失败 - ${err.message}`);
              gomokuState.isPlacing = false;
              return null;
            });
          } else {
            // 本地算法是同步的,转换为 Promise
            const mv = chooseMoveByAlgo(bd2, PLAYER, COMPUTER, state.algo, state.maxDepth);
            movePromise = Promise.resolve(mv);
          }

          movePromise.then(mv => {
            if (!mv) {
              gomokuState.isPlacing = false;
              if (state.algo !== 'api') {
                addLog('自动:无可用落子点');
              }
              return;
            }

            // 添加随机延迟,更像真人
            const delay = 300 + Math.floor(Math.random() * 500);
            setTimeout(() => {
              try {
                // 再次检查游戏状态
                const overlay = document.getElementById('game_overlay');
                const isGameOverNow = overlay && (overlay.style.display !== 'none' && overlay.offsetParent !== null);
                if (isGameOverNow || (typeof PAGE_WIN.isGameOver !== 'undefined' && PAGE_WIN.isGameOver)) {
                  gomokuState.isPlacing = false;
                  return;
                }

                // 检查位置是否已被占用
                const currentBd = getGomokuBoard();
                if (currentBd[mv.r][mv.c] !== EMPTY) {
                  gomokuState.isPlacing = false;
                  addLog(`自动:位置(${mv.r},${mv.c})已被占用,跳过`);
                  return;
                }

                // 落子(通过触发点击事件)
                if (!placeByPage(mv.r, mv.c, PLAYER)) {
                  gomokuState.isPlacing = false;
                  addLog('自动:落子失败(位置可能已被占用)');
                  return;
                }

                // 记录自己的落子(延迟一点,等待DOM更新)
                // 日志会在检测循环中统一输出,这里不需要重复输出
                setTimeout(() => {
                  const alreadyRecorded = gomokuState.moveHistory.some(m => m.r === mv.r && m.c === mv.c);
                  if (!alreadyRecorded) {
                    gomokuState.moveHistory.push({ uid: 1, r: mv.r, c: mv.c }); // 玩家=1
                    gomokuState.lastObservedPieceCount = document.querySelectorAll('.chess-piece.active').length;
                  }
                }, 100);

                // 等待 DOM 更新后检查是否获胜
                setTimeout(() => {
                  // 检查是否获胜(通过检查是否有 winning-piece 类)
                  const hasWinningPieces = document.querySelectorAll('.winning-piece').length > 0;
                  if (hasWinningPieces) {
                    addLog('自动:玩家获胜');
                    gomokuState.isPlacing = false;
                    gomokuState.lastBoardHash = null;
                    gomokuState.lastMoveCount = 0;
                    // 游戏结束,保留历史记录(不清空,以便复盘)
                  } else {
                    // 更新状态,等待原AI下棋
                    gomokuState.isPlacing = false;
                    setTimeout(() => {
                      gomokuState.lastBoardHash = null;
                      gomokuState.lastMoveCount = 0;
                    }, 500);
                  }
                }, 300);
              } catch (err) {
                console.warn('[Gomoku-Auto] error:', err);
                gomokuState.isPlacing = false;
              }
            }, delay);
          }).catch(err => {
            console.warn('[Gomoku-Auto] move error:', err);
            gomokuState.isPlacing = false;
          });
        } catch (err) {
          console.warn('[Gomoku-Auto] loop error:', err);
        }
      }, 800); // 每800ms检查一次

      toast('自动:开启');
      addLog('自动:已开启(使用AI算法自动下棋)');
      return true;
    }

    function disableGomokuAuto() {
      // 停止自动下棋循环
      if (gomokuState.autoInterval) {
        clearInterval(gomokuState.autoInterval);
        gomokuState.autoInterval = null;
      }
      // 重置状态
      gomokuState.lastBoardHash = null;
      gomokuState.lastMoveCount = 0;
      toast('自动:关闭');
      addLog('自动:已关闭');
    }

    btnGomokuAuto.addEventListener('click', () => {
      if (PAGE !== 'gomoku') {
        toast('仅人机页面可用');
        return;
      }
      gomokuState.autoOn = !gomokuState.autoOn;
      if (gomokuState.autoOn) {
        const ok = enableGomokuAuto();
        if (!ok) gomokuState.autoOn = false;
      } else {
        disableGomokuAuto();
      }
      setAutoBtn();
    });
    setAutoBtn();

    btnWin.addEventListener('click', async () => {
      try {
        if (PAGE !== 'gomoku') {
          toast('仅人机页面可用');
          return;
        }

        const $ = PAGE_WIN.jQuery;
        const xnObj = PAGE_WIN.xn;
        if (!$ || !$.xpost || !xnObj || !xnObj.url) {
          toast('缺少依赖(jQuery/xn/$.xpost),无法一键赢');
          addLog('一键赢失败:缺少 jQuery/xn/$.xpost');
          return;
        }

        // 优先调用页面自带的 endGame('win') 逻辑(如果暴露在 window 上)
        if (typeof PAGE_WIN.endGame === 'function') {
          try {
            addLog('一键赢:调用页面 endGame("win")');
            toast('正在触发页面胜利结算…');
            PAGE_WIN.endGame('win');
            return;
          } catch (err) {
            console.warn('[PVP-AI] call page endGame failed, fallback to manual logic:', err);
          }
        }

        // 手动执行 endGame 逻辑(因为 endGame 在闭包里,无法直接调用)
        // 新版页面增加了棋谱 + 签名校验,这里按照页面最新实现构造参数
        addLog('一键赢:手动执行胜利结算逻辑…');
        toast('正在触发页面胜利结算…');

        // 设置游戏结束状态(如果页面有暴露这些变量)
        if (typeof PAGE_WIN.isGameOver !== 'undefined') PAGE_WIN.isGameOver = true;
        if (typeof PAGE_WIN.isThinking !== 'undefined') PAGE_WIN.isThinking = false;

        // 构造一份「合法且必胜」的虚拟棋谱,供后端验证
        // 这里直接在中路构造一个 5 连子,玩家为 1,电脑为 2,轮流落子,最后一步形成五连
        const fakeMoves = [
          { r: 7, c: 7, p: 1 },
          { r: 7, c: 8, p: 2 },
          { r: 8, c: 7, p: 1 },
          { r: 8, c: 8, p: 2 },
          { r: 9, c: 7, p: 1 },
          { r: 9, c: 8, p: 2 },
          { r: 10, c: 7, p: 1 },
          { r: 10, c: 8, p: 2 },
          { r: 11, c: 7, p: 1 }
        ];

        const movesJson = JSON.stringify(fakeMoves);
        const token = PAGE_WIN._gToken || '';
        // 与页面一致:btoa(unescape(encodeURIComponent(movesJson + token)))
        const signStr = btoa(unescape(encodeURIComponent(movesJson + token)));

        // 调用接口并手动更新 UI(复制页面新版 endGame 的逻辑)
        $.xpost(xnObj.url('my-coin-gomoku'), { op: 'end', result: 'win', moves: movesJson, s: signStr }, function (code, data) {
          const showToast = PAGE_WIN.showToast || toast;
          if (code === 0) {
            const text = '恭喜获胜!';
            showToast(text);
            $('.intersection').off('click');

            // Update Stats
            if (data.coins && $('#my_coins_display').length) $('#my_coins_display').text(data.coins);
            if (typeof data.free_played !== 'undefined' && $('#txt_free_count').length)
              $('#txt_free_count').text(data.free_played);
            if (typeof data.paid_played !== 'undefined' && $('#txt_paid_remain').length)
              $('#txt_paid_remain').text(3 - data.paid_played);

            const maxFree = data.max_free_plays || 1;
            let statusHtml = '';
            const isFreeExhausted = data.free_played >= maxFree;

            let promoHtml = '';
            if (maxFree === 1) {
              promoHtml =
                '<a href="user-special.htm" class="d-block alert alert-warning py-2 px-3 mt-2 mb-0 text-center small mx-auto" style="border-radius: 50px; text-decoration: none; color: #856404; background-color: #fff3cd; border-color: #ffeeba;"><i class="icon-diamond mr-1"></i> 开通紫名会员每日额外免费2次</a>';
            }

            if (isFreeExhausted) {
              statusHtml =
                '<div class="status-badge">今日免费已用 (' +
                data.free_played +
                '/' +
                maxFree +
                ')</div>' +
                '<p class="text-secondary small mb-0 mt-2">剩余付费次数: ' +
                (3 - data.paid_played) +
                '/3</p>';
            } else {
              statusHtml =
                '<div class="status-badge success"><i class="icon-gift mr-1"></i>今日免费次数可用 (' +
                data.free_played +
                '/' +
                maxFree +
                ')</div>';
            }

            statusHtml += promoHtml;

            if ($('#overlay_status_area').length) $('#overlay_status_area').html(statusHtml);

            let btnHtml = '';
            if (isFreeExhausted && data.paid_played >= 3) {
              btnHtml = '<button class="btn btn-secondary btn-lg disabled rounded-pill px-5" disabled>今日次数已用完</button>';
            } else {
              if (!isFreeExhausted) {
                btnHtml =
                  '<button class="btn btn-info btn-lg btn-start-game text-white" id="btn_start_game" style="background:#17a2b8; border-color:#17a2b8;">免费开启游戏</button>';
              } else {
                btnHtml =
                  '<button class="btn btn-primary btn-lg btn-start-game" id="btn_start_game"><i class="icon-database mr-1"></i> 50 开启游戏</button>';
              }
            }
            if ($('#overlay_btn_area').length) $('#overlay_btn_area').html(btnHtml);

            setTimeout(function () {
              if ($('#game_overlay').length) $('#game_overlay').fadeIn();
            }, 1500);

            $('#btn_regret_fake').fadeOut();

            // 挑战状态展示(保持与页面一致)
            if (data.challenge_status && $('#overlay_challenge_status').length) {
              $('#overlay_challenge_status').html(data.challenge_status);
            } else if ($('#overlay_challenge_status').length) {
              $('#overlay_challenge_status').html('<span class="text-success">挑战成功</span>');
            }

            addLog('一键赢:胜利结算成功(已更新 UI + 棋谱验签)');
          } else {
            const msg = pickMessage(data) || (data && data.message) || '未知错误';
            showToast('结算失败:' + msg);
            addLog(`一键赢:结算失败 ${code}, ${msg}`);

            // 失败时保持与页面逻辑尽量一致
            $('#btn_start_game').prop('disabled', false).text('重试');
            if (!$('#btn_start_game').length) {
              setTimeout(function () {
                PAGE_WIN.location.reload();
              }, 1500);
            } else {
              setTimeout(function () {
                if ($('#game_overlay').length) $('#game_overlay').fadeIn();
              }, 1000);
            }

            if (data && data.challenge_status && $('#overlay_challenge_status').length) {
              $('#overlay_challenge_status').html(data.challenge_status);
            }
          }
        });
      } catch (e) {
        console.warn('[PVP-AI] win error:', e);
        toast('一键赢失败,见控制台');
        addLog('一键赢:异常(见控制台)');
      }
    });
    // 通用的算法描述渲染函数
    function renderAlgoDesc(descElement, isGomoku = false) {
      if (!descElement) return;
      if (state.algo === 'threat') {
        descElement.textContent =
          'Threat-First Heuristic(威胁优先启发式):先手赢 / 必防对方一手赢 / 活四冲四活三等威胁优先 + 中心偏好。算法更强,更省CPU。';
      } else if (state.algo === 'hybrid') {
        descElement.textContent = '混合算法(威胁引导的Minimax):结合威胁算法的快速威胁识别和Minimax的全局搜索能力。使用威胁评分优化候选点排序,提高剪枝效率,在关键局面更准确。推荐使用。';
      } else if (state.algo === 'api') {
        if (isGomoku) {
          descElement.textContent = 'Rapfi-API:通过本地 FastAPI/Rapfi 返回落子。注意:人机页面无法稳定还原落子顺序(只有棋盘无棋谱),建议仅在 PVP 页面使用该算法。';
        } else {
          descElement.textContent = 'Rapfi-API:调用你本机的 FastAPI/Rapfi 服务返回下一手坐标。若请求失败,请检查服务是否启动、地址是否正确,以及浏览器是否拦截到 localhost 的请求。';
        }
      } else {
        descElement.textContent = 'Minimax(优化版):论坛AI算法的改进版本,带剪枝与启发式优化的极小化极大搜索,效率更高且更稳。可配合"AI强度"调整深度。';
      }
    }

    // 通用的算法切换处理函数
    function handleAlgoChange(sourceSelect, targetSelect, sourceDesc, targetDesc, isGomoku = false) {
      state.algo = sourceSelect.value || 'minimax';
      let label = 'Minimax(优化版)';
      if (state.algo === 'threat') label = 'Threat-First';
      else if (state.algo === 'hybrid') label = '混合算法(推荐)';
      else if (state.algo === 'api') label = 'Rapfi-API';
      toast('算法=' + label);
      addLog('已切换算法:' + label);
      saveSettings();
      renderAlgoDesc(sourceDesc, isGomoku);
      // 同步更新另一个选择器
      if (targetSelect) targetSelect.value = state.algo;
      if (targetDesc) renderAlgoDesc(targetDesc, !isGomoku);
    }

    // 通用的深度切换处理函数
    function handleDepthChange(sourceSelect, targetSelect) {
      state.maxDepth = parseInt(sourceSelect.value, 10) || 2;
      const label =
        state.maxDepth === 1
          ? '快(更流畅)'
          : state.maxDepth === 2
            ? '标准(推荐)'
            : state.maxDepth === 3
              ? '强(更慢)'
              : '很强(可能卡)';
      toast('AI强度=' + label);
      addLog('已切换AI强度:' + label + '(depth=' + state.maxDepth + ')');
      saveSettings();
      if (state.maxDepth >= 3) {
        addLog('提示:强度较高会更耗CPU,页面可能卡顿/走子变慢');
      }
      // 同步更新另一个选择器
      if (targetSelect) targetSelect.value = String(state.maxDepth);
    }

    // PVP选择器事件处理
    if (selAlgo) {
      selAlgo.addEventListener('change', () => {
        handleAlgoChange(selAlgo, selGomokuAlgo, algoDesc, gomokuAlgoDesc, false);
      });
      renderAlgoDesc(algoDesc, false);
    }
    if (sel) {
      sel.addEventListener('change', () => {
        handleDepthChange(sel, selGomokuDepth);
      });
    }

    // 人机对战选择器事件处理
    if (selGomokuAlgo) {
      selGomokuAlgo.addEventListener('change', () => {
        handleAlgoChange(selGomokuAlgo, selAlgo, gomokuAlgoDesc, algoDesc, true);
      });
      renderAlgoDesc(gomokuAlgoDesc, true);
    }
    if (selGomokuDepth) {
      selGomokuDepth.addEventListener('change', () => {
        handleDepthChange(selGomokuDepth, sel);
      });
    }

    btnMin.addEventListener('click', () => {
      // 改为最小化为可拖动的圆形图标
      minimizeToFab();
    });

    btnClear.addEventListener('click', () => {
      const log = document.getElementById('tm_pvp_ai_log');
      if (log) log.innerHTML = '';
      addLog('日志已清空');
    });

    // 在窗口尺寸变化时修正位置,避免面板/浮标被遮挡,并保存调整后的设置
    PAGE_WIN.addEventListener('resize', () => {
      try {
        const panelEl = document.getElementById('tm_pvp_ai_panel');
        const fabEl = document.getElementById('tm_pvp_ai_fab');
        // 读取已保存设置,优先按已保存的 side+offset 重新计算位置,避免放大后侧边被翻转
        let saved = null;
        try {
          const raw = localStorage.getItem(SETTINGS_KEY);
          if (raw) saved = JSON.parse(raw);
        } catch (e) {
          saved = null;
        }

        if (panelEl && panelEl.style.display !== 'none') {
          let pos = null;
          if (saved && saved.panelPos && typeof saved.panelPos.side === 'string') {
            pos = resolveSavedPos(saved.panelPos, panelEl);
          }
          if (!pos) {
            const left = parseFloat(panelEl.style.left) || panelEl.getBoundingClientRect().left || 0;
            const top = parseFloat(panelEl.style.top) || panelEl.getBoundingClientRect().top || 0;
            pos = clampToViewport(left, top, panelEl);
          }
          panelEl.style.left = pos.x + 'px';
          panelEl.style.top = pos.y + 'px';
        }

        if (fabEl && fabEl.style.display !== 'none') {
          let pos = null;
          if (saved && saved.fabPos && typeof saved.fabPos.side === 'string') {
            pos = resolveSavedPos(saved.fabPos, fabEl);
          }
          if (!pos) {
            const left = parseFloat(fabEl.style.left) || fabEl.getBoundingClientRect().left || 0;
            const top = parseFloat(fabEl.style.top) || fabEl.getBoundingClientRect().top || 0;
            pos = clampToViewport(left, top, fabEl);
          }
          fabEl.style.left = pos.x + 'px';
          fabEl.style.top = pos.y + 'px';
        }

        try {
          saveSettings();
        } catch (e) {}
      } catch (e) {}
    });

    // init
    syncAutoBtn();
  }

  function setStatus(text) {
    const el = document.getElementById('tm_pvp_ai_status');
    if (el) el.textContent = '状态:' + text;
  }

  function addLog(text) {
    const el = document.getElementById('tm_pvp_ai_log');
    if (!el) return;

    // 构建带时间戳左侧、消息右侧的多行对齐结构:
    // - 时间戳使用固定宽度(11ch),显示在左侧
    // - 消息区域使用左边距等于时间戳宽度以保证换行时对齐到消息起始列
    // 使用两列网格:第一列为时间戳(自适应内容、右对齐),第二列为消息(占满剩余宽度并换行)
    const line = document.createElement('div');
    line.style.display = 'grid';
    line.style.gridTemplateColumns = 'max-content 1fr';
    line.style.columnGap = '0.5ch';
    line.style.alignItems = 'start';
    line.style.padding = '2px 0';

    const ts = document.createElement('span');
    ts.textContent = `[${nowHHMMSS()}]`;
    ts.style.fontFamily = 'monospace';
    ts.style.color = '#6b7280';
    ts.style.justifySelf = 'end'; // 右对齐时间戳列
    ts.style.paddingRight = '0.25ch';
    ts.style.lineHeight = '1.2';

    const msg = document.createElement('span');
    msg.textContent = text;
    msg.style.display = 'block';
    msg.style.whiteSpace = 'pre-wrap';
    msg.style.wordBreak = 'break-word';
    msg.style.lineHeight = '1.2';

    line.appendChild(ts);
    line.appendChild(msg);
    el.appendChild(line);

    // Keep last 200 lines
    while (el.childNodes.length > 200) el.removeChild(el.firstChild);
    el.scrollTop = el.scrollHeight;
  }

  function statusToText(status) {
    // PVP 后端状态约定:
    // 0 = 等待中(房主创建但未进入/未就绪)
    // 1 = 准备阶段(双方进房,等待 ready)
    // 2 = 游戏中
    // 3 = 已结束
    const s = toInt(status, -999);
    if (s === 0) return '等待中';
    if (s === 1) return '准备开始';
    if (s === 2) return '对局进行中';
    if (s === 3) return '对局已结束';
    return '未知状态(' + status + ')';
  }

  function pickMessage(obj) {
    if (!obj) return '';
    if (typeof obj === 'string') return obj;
    if (typeof obj.message === 'string') return obj.message;
    if (obj.data && typeof obj.data.message === 'string') return obj.data.message;
    if (typeof obj.msg === 'string') return obj.msg;
    try {
      return JSON.stringify(obj);
    } catch (e) {
      return String(obj);
    }
  }

  function chessApiBestMove(moves, userid) {
    // 通过本地 FastAPI(Rapfi)获取最佳落子(使用 GM_xmlhttpRequest 绕过 CORS)
    // POST { board: [{uid,r,c}, ...], userid }
    const url = String(state.apiUrl || '').trim();
    if (!url) return Promise.reject(new Error('棋API地址为空'));

    return new Promise((resolve, reject) => {
      let done = false;
      const timer = setTimeout(() => {
        if (done) return;
        done = true;
        reject(new Error('棋API请求超时'));
      }, 6500);

      GM_xmlhttpRequest({
        method: 'POST',
        url,
        headers: { 'Content-Type': 'application/json' },
        data: JSON.stringify({ board: moves || [], userid }),
        timeout: 7000,
        onload: function (res) {
          if (done) return;
          done = true;
          clearTimeout(timer);
          try {
            const json = JSON.parse(res.responseText || '{}');
            if (!json || json.success !== true || !json.data) {
              const msg = (json && json.message) ? String(json.message) : '棋API返回异常';
              return reject(new Error(msg));
            }
            const r = toInt(json.data.r, -1);
            const c = toInt(json.data.c, -1);
            if (r < 0 || r >= 15 || c < 0 || c >= 15) {
              return reject(new Error(`棋API坐标越界: (${r},${c})`));
            }
            resolve({ r, c });
          } catch (e) {
            reject(e);
          }
        },
        onerror: function () {
          if (done) return;
          done = true;
          clearTimeout(timer);
          reject(new Error('棋API请求失败'));
        },
        ontimeout: function () {
          if (done) return;
          done = true;
          clearTimeout(timer);
          reject(new Error('棋API请求超时'));
        },
      });
    });
  }

  // 人机对战状态(全局,供 boardToMoves 等函数访问)
  const gomokuState = {
    autoOn: false,
    autoInterval: null,
    lastBoardHash: null,
    lastMoveCount: 0,
    isPlacing: false, // 标记是否正在落子
    moveHistory: [], // 记录落子历史 [{uid: 1或2, r, c}, ...]
    lastObservedPieceCount: 0, // 上次观察到的棋子数量,用于检测新落子
  };

  async function mainLoop() {
    ensurePanel();

    // 人机对战页面:注入代码来暴露闭包内的变量到 window
    if (PAGE === 'gomoku') {
      try {
        // 等待页面 jQuery 和脚本加载完成
        await waitFor(() => PAGE_WIN.jQuery && PAGE_WIN.jQuery.fn && PAGE_WIN.jQuery.fn.jquery, { timeout: 5000 });

        // 注入代码来暴露变量
        const injectScript = document.createElement('script');
        injectScript.textContent = `
          (function() {
            if (typeof window.$ === 'undefined' || !window.$) return;

            // 等待页面脚本执行
            window.$(function() {
              // 尝试通过修改页面闭包来暴露变量
              // 由于变量在闭包内,我们需要通过其他方式访问
              // 方法:通过修改 startGame 函数来同步暴露变量

              // 监听游戏开始事件,通过 DOM 变化来推断变量值
              const observer = new MutationObserver(function(mutations) {
                // 当游戏开始时,通过检查 DOM 来推断 PLAYER 和 COMPUTER
                const firstPiece = document.querySelector('.chess-piece.active');
                if (firstPiece && !window._gomoku_vars_exposed) {
                  const isBlack = firstPiece.classList.contains('black');
                  const isPlayerPiece = firstPiece.classList.contains('last-move-halo');
                  const isComputerPiece = firstPiece.classList.contains('last-move-dot');

                  if (isBlack) {
                    if (isPlayerPiece) {
                      window.PLAYER = 1;
                      window.COMPUTER = 2;
                    } else if (isComputerPiece) {
                      window.PLAYER = 2;
                      window.COMPUTER = 1;
                    }
                  } else {
                    if (isPlayerPiece) {
                      window.PLAYER = 2;
                      window.COMPUTER = 1;
                    } else if (isComputerPiece) {
                      window.PLAYER = 1;
                      window.COMPUTER = 2;
                    }
                  }

                  // 通过检查 overlay 来推断 isGameOver
                  const overlay = document.getElementById('game_overlay');
                  if (overlay) {
                    window.isGameOver = overlay.style.display !== 'none' && overlay.offsetParent !== null;
                  }

                  window._gomoku_vars_exposed = true;
                }
              });

              // 观察棋盘区域的变化
              const boardContainer = document.querySelector('.chessboard-wrapper') || document.querySelector('#chessboard');
              if (boardContainer) {
                observer.observe(boardContainer, {
                  childList: true,
                  subtree: true,
                  attributes: true,
                  attributeFilter: ['class']
                });
              }
            });
          })();
        `;
        (document.head || document.documentElement).appendChild(injectScript);
        injectScript.remove();
      } catch (e) {
        console.warn('[PVP-AI] Failed to inject gomoku variable exposure:', e);
      }
    }

    const myUid = getMyUid();
    if (!myUid) {
      setStatus('未检测到 UID(可能未登录)');
      return;
    }

    // 根据页面类型设置初始状态
    if (PAGE === 'gomoku') {
      setStatus('已就绪,等待游戏开始…');
    } else if (PAGE === 'pvp') {
      setStatus('已就绪,等待房间…');
    } else {
      setStatus('已就绪…');
    }
    addLog('面板加载完成:' + (PAGE === 'pvp' ? 'PVP对战' : PAGE === 'gomoku' ? '人机对战' : '未知页面'));

    // PVP 轮询逻辑
    setInterval(async () => {
      try {
        if (PAGE !== 'pvp') return;
        const roomId = PAGE_WIN.currentPvpRoomId || 0;
        if (!roomId) {
          if (state.lastRoomId !== 0) {
            state.lastRoomId = 0;
            state.lastStatus = null;
            state.lastMovesCount = null;
            state.lastTurnUid = null;
            addLog('已退出房间/暂无房间');
          }
          setStatus('暂无房间(进入后自动接管)');
          return;
        }

        const room = await pvpLoad(roomId);
        const movesArr = parseMovesFromRoom(room);
        const { bd, movesCount, blackUid } = parseRoomBoard(room);
        const myColor = getMyColor(room, blackUid, myUid);
        if (!myColor) {
          setStatus('等待对局初始化(无法确定执子)');
          return;
        }
        const oppColor = myColor === 1 ? 2 : 1;

        // 记录房间/状态变化
        if (state.lastRoomId !== roomId) {
          state.lastRoomId = roomId;
          state.lastStatus = null;
          state.lastMovesCount = null;
          state.lastTurnUid = null;
          addLog(`进入房间 #${roomId}`);
        }
        if (state.lastStatus !== room.status) {
          state.lastStatus = room.status;
          addLog(`房间状态:${statusToText(room.status)}`);
        }

        const isMyTurn = canAct(room, myUid);
        const key = roomId + '|' + room.status + '|' + room.turn_uid + '|' + movesCount;
        setStatus(`${statusToText(room.status)} | 房间#${roomId} | 轮到你:${isMyTurn ? '是' : '否'} | 自动:${state.autoEnabled ? '开' : '关'}`);

        // 更可靠的日志:仅根据 board_state 的“新增棋步”记录(避免假阳性)
        if (state.lastMovesCount === null) {
          state.lastMovesCount = movesCount;
        } else if (movesCount < state.lastMovesCount) {
          addLog(`棋盘重置/回滚(棋步数:${state.lastMovesCount} -> ${movesCount})`);
          state.lastMovesCount = movesCount;
        } else if (movesCount > state.lastMovesCount) {
          // 取新增的最后一步(一般每次+1;如果+N 则逐个补记)
          const prev = state.lastMovesCount;
          const added = movesArr.slice(prev);
          for (const m of added) {
            const who = m && m.uid === myUid ? `己方[${m && m.uid !== undefined ? m.uid : '?'}]落子` : `对方[${m && m.uid !== undefined ? m.uid : '?'}]落子`;
            const rc = m && typeof m.r === 'number' && typeof m.c === 'number' ? `(${m.r},${m.c})` : '(未知坐标)';
            addLog(`${who} ${rc}`);
          }
          state.lastMovesCount = movesCount;
        }

        // 回合提示(仅做辅助,不再用于判断落子)
        if (state.lastTurnUid === null) {
          state.lastTurnUid = room.turn_uid;
        } else if (room.turn_uid !== state.lastTurnUid) {
          addLog(room.turn_uid === myUid ? '轮到你了' : '轮到对方了');
          state.lastTurnUid = room.turn_uid;
        }

        if (!state.autoEnabled) return;
        if (!isMyTurn) return;
        if (state.busy) return;

        // 防抖:同一局面只下 1 次
        if (state.lastActionKey === key) return;
        if (Date.now() - state.lastMoveAt < 600) return;

        state.busy = true;
        state.lastActionKey = key;

        // 调试:打印当前棋局(节流:同一 key 只打印一次)
        try {
          if (state.lastDebugKey !== key) {
            state.lastDebugKey = key;
            // eslint-disable-next-line no-console
            console.log('[PVP-AI] 当前state=', {
              autoEnabled: state.autoEnabled,
              algo: state.algo,
              maxDepth: state.maxDepth,
              busy: state.busy,
              lastRoomId: state.lastRoomId,
              lastStatus: state.lastStatus,
              lastMovesCount: state.lastMovesCount,
              lastTurnUid: state.lastTurnUid,
              lastActionKey: state.lastActionKey,
            });
            // eslint-disable-next-line no-console
            console.log('[PVP-AI] 棋局=', {
              roomId,
              status: room.status,
              turn_uid: room.turn_uid,
              myUid,
              blackUid,
              myColor,
              movesCount,
              movesArrTail: movesArr.slice(Math.max(0, movesArr.length - 10)),
            });
            // eslint-disable-next-line no-console
            console.log('[PVP-AI] 棋盘(.:空 X:我 O:对)=\n' + boardToAscii(bd, myColor, oppColor));
          }
        } catch (e) {
          // ignore debug errors
        }

        // 计算落子
        let mv = null;
        if (state.algo === 'api') {
          mv = await chessApiBestMove(movesArr, toInt(myUid, 0));
        } else {
          const bd2 = cloneBoard(bd);
          mv = chooseMoveByAlgo(bd2, myColor, oppColor, state.algo, state.maxDepth);
        }
        if (!mv) {
          state.busy = false;
          return;
        }

        // 随机延迟,像真人一点
        const delay = 220 + Math.floor(Math.random() * 420);
        await new Promise((r) => setTimeout(r, delay));

        // 再次确认还是你的回合(避免刚好收到 websocket 更新)
        const room2 = await pvpLoad(roomId);
        if (!canAct(room2, myUid)) {
          state.busy = false;
          return;
        }

        await pvpMove(roomId, mv.r, mv.c);
        state.lastMoveAt = Date.now();
        toast(`已落子:(${mv.r},${mv.c})`);
        addLog(`AI落子:(${mv.r},${mv.c})`);
      } catch (e) {
        // 失败时不要卡死
        // eslint-disable-next-line no-console
        console.warn('[PVP-AI] loop error:', e);
      } finally {
        state.busy = false;
      }
    }, 700);

    // 人机(gomoku)日志监听:不劫持规则,只做状态/回合/结算记录
    setInterval(() => {
      try {
        if (PAGE !== 'gomoku') return;
        // 这些变量在原页面脚本闭包内,但很多会挂到 window 上(isGameOver/isThinking/PLAYER/COMPUTER 等)
        const isOver = !!PAGE_WIN.isGameOver;
        const thinking = !!PAGE_WIN.isThinking;

        // 检测游戏 overlay 显示状态(游戏开始时 overlay 会隐藏)
        const $overlay = PAGE_WIN.jQuery && PAGE_WIN.jQuery('#game_overlay');
        const overlayVisible = $overlay && $overlay.length > 0 && $overlay.is(':visible');

        // 尝试从 DOM 统计棋子数(最稳定,不依赖变量作用域)
        const pieces = document.querySelectorAll('.chess-piece.active').length;

        // 更新状态栏显示
        let statusText = '等待游戏开始…';
        if (!overlayVisible) {
          // overlay 隐藏 = 游戏中
          const hasWinningPieces = document.querySelectorAll('.winning-piece').length > 0;
          if (hasWinningPieces) {
            statusText = '游戏已结束';
          } else if (pieces > 0) {
            const autoStatus = gomokuState.autoOn ? ' | 自动:开' : '';
            statusText = `游戏中(已下 ${pieces} 手)${autoStatus}`;
          } else {
            statusText = '游戏已开始';
          }
        } else {
          // overlay 显示 = 等待开始或已结束
          statusText = '等待游戏开始…';
        }
        setStatus(statusText);

        if (state.lastGomokuOverlayVisible === null) {
          state.lastGomokuOverlayVisible = overlayVisible;
        } else if (state.lastGomokuOverlayVisible && !overlayVisible) {
          // overlay 从显示变为隐藏 = 游戏开始
          addLog('检测到游戏开始(overlay隐藏)');
          state.lastGomokuOverlayVisible = false;
          // 重置棋子计数
          state.lastGomokuMoveCount = pieces;
        } else if (!state.lastGomokuOverlayVisible && overlayVisible) {
          // overlay 从隐藏变为显示 = 游戏结束/等待开始
          state.lastGomokuOverlayVisible = true;
        }
        if (state.lastGomokuMoveCount === null) {
          state.lastGomokuMoveCount = pieces;
        } else if (pieces > state.lastGomokuMoveCount) {
          const diff = pieces - state.lastGomokuMoveCount;
          // 如果从 0 变为 1,可能是游戏开始的第一手
          if (state.lastGomokuMoveCount === 0 && pieces === 1) {
            addLog('检测到游戏开始(第一手落子)');
          } else {
            // 推断:通常一次只增加1,diff>1 可能是刷新/重绘
            addLog(diff === 1 ? '检测到落子(棋子数+1)' : `检测到棋子变化(+${diff})`);
          }
          if (thinking) addLog('AI思考中…');
          state.lastGomokuMoveCount = pieces;
        } else if (pieces < state.lastGomokuMoveCount) {
          // 棋子数减少 = 棋盘重置
          addLog(`棋盘重置(棋子数:${state.lastGomokuMoveCount} -> ${pieces})`);
          state.lastGomokuMoveCount = pieces;
        }

        if (state.lastGomokuGameOver === null) {
          state.lastGomokuGameOver = isOver;
        } else if (isOver && !state.lastGomokuGameOver) {
          addLog('对局结束(isGameOver=true)');
          state.lastGomokuGameOver = true;
        } else if (!isOver && state.lastGomokuGameOver) {
          addLog('新对局开始/已重置(isGameOver=false)');
          state.lastGomokuGameOver = false;
          state.lastGomokuMoveCount = document.querySelectorAll('.chess-piece.active').length;
        }
      } catch (e) {
        // ignore
      }
    }, 800);
  }

  // 启动
  (async function boot() {
    try {
      await waitFor(() => PAGE_WIN.jQuery && PAGE_WIN.xn && PAGE_WIN.jQuery.xpost, { timeout: 20000 });
      await mainLoop();
    } catch (e) {
      console.warn('[PVP-AI] 启动失败:', e);
    }
  })();
})();



本教程仅供学习,滥用被呆哥封了别怪我表情

12
已有评论 ( 10 )
提示:您必须 登录 才能查看此内容。
域名市场
   域名载入中...
创建新帖
自助推广 (点击空位或 这里 添加)
确认删除
确定要删除这篇帖子吗?删除后将无法恢复。
删除成功
帖子已成功删除,页面将自动刷新。
删除失败
删除帖子时发生错误,请稍后再试。