QuanAI只读快照

共享会话

改進代碼以提升AI的思考能力。增加後臺運算功能,以及增量搜尋功能。同時還有哪些地方可以改進的一並說明和更改。明確給出所…

分享于 2026年9月3日 02:01
提问者

改進代碼以提升AI的思考能力。增加後臺運算功能,以及增量搜尋功能。同時還有哪些地方可以改進的一並說明和更改。明確給出所要修改的地方和所要替換的代碼。

trirhombus2894.htmltext/plain · 162.4 KB预览下载
QuanAI

我檢查了整個 trirhombus2894.html。目前 AI 的主要問題不只是演算法強度,而是存在幾個會直接拖慢搜尋、造成座標碰撞及讓主執行緒卡死的核心問題。

一、目前最重要的問題

  1. 棋盤陣列尺寸錯誤

    • 程式使用 12 bit 儲存座標索引,理論上應支援 0~4095
    • hasHoleAfterPlacement() 也使用 64×64 的解碼方式。
    • 但目前實際只配置了 1024 格、32×32。
    • 這會讓座標超過約 ±15 後產生碰撞或錯誤。
  2. getValidMoves() 在測試落子時錯誤擴大邊界

    • 現有搜尋用 set() 暫時放棋,又用 set(..., 0) 移除。
    • 但是邊界只會擴大、不會縮回。
    • MCTS 搜尋一段時間後,搜尋範圍會被擴展到整個棋盤。
  3. 每一次模擬落子都重新計算分數

    • tryPlacePiece() 不論 UI 落子或 MCTS 模擬,均呼叫 calculateScores()
    • MCTS 一次 rollout 會呼叫很多次,浪費大量運算。
  4. 每次 clone 都重新建立鄰居表

    • 現在每次 rootState.clone() 都建立新的鄰居表。
    • 鄰居表其實所有棋局完全相同,應共用一份。
  5. AI 在主執行緒運算

    • Minimax 遞迴時,畫面、按鈕、動畫和「中止 AI」都可能無法即時反應。
    • await setTimeout(0) 只能在遞迴返回後讓出主執行緒,不能解決深層搜尋阻塞。
  6. MCTS 不保留之前搜尋結果

    • 每一手都從零開始。
    • 沒有跨局面共用節點統計,也沒有 Minimax transposition table。
  7. restartGame() 沒有重建 gameLogic

    • 棋盤 SVG 雖然清空,但核心棋盤有機會保留上一局資料。
    • 分數也沒有可靠地重設。

以下修改會加入:

  • Web Worker 後臺執行。
  • 增量式 MCTS,約每 12ms 一個批次。
  • 迭代加深 Minimax。
  • MCTS 節點統計跨回合重用。
  • Minimax transposition table。
  • 增量 frontier 合法著法生成。
  • 共用鄰居表。
  • 可跳過模擬過程中的分數計算。
  • 即時中止後臺 Worker。
  • 修正 64×64 棋盤索引。
  • 修正重新開始及資源釋放問題。

二、修改 HexGridGame

修改位置

找到:

js
class HexGridGame {

不必替換整個類別。依序替換以下方法;沒有列出的 calculateScores()evaluateBluePieceRec()isRight()fastHoleCheck() 等原方法可以保留。


2.1 替換 constructor,並在其後加入共用表和增量 frontier

將原本的 constructor()_getIndex()_idxToCoord()initNeighborsTable()get()set()getNeighbors() 全部替換成:

js
constructor() {
  const cellCount = HexGridGame.CELL_COUNT;

  this.grid = new Int32Array(cellCount);

  // 所有棋局共用同一份鄰居表,不再於每次 clone 時重建。
  this.neighbors = HexGridGame.getSharedNeighbors();

  this.bpLookup = new Array(cellCount).fill(null);
  this.memo1 = new Map();
  this.memo2 = new Map();

  this.history = [];
  this.bluePieces = [];
  this.totalPieces = 0;

  this.minX = Infinity;
  this.maxX = -Infinity;
  this.minY = Infinity;
  this.maxY = -Infinity;

  this.visited = new Uint8Array(cellCount);
  this.bfsQueue = new Uint16Array(cellCount);

  // 增量合法著法生成需要的 frontier。
  this.frontierCount = new Uint8Array(cellCount);
  this.frontier = new Set();

  // 兩組 Zobrist hash,供 MCTS 節點重用及 Minimax TT 使用。
  this.hashA = 0;
  this.hashB = 0;
}

static get SIDE() {
  return 64;
}

static get OFFSET() {
  return 31;
}

static get MIN_COORD() {
  return -31;
}

static get MAX_COORD() {
  return 32;
}

static get CELL_COUNT() {
  return 4096;
}

static coordToIndex(x, y) {
  if (
    x < HexGridGame.MIN_COORD ||
    x > HexGridGame.MAX_COORD ||
    y < HexGridGame.MIN_COORD ||
    y > HexGridGame.MAX_COORD
  ) {
    return -1;
  }

  return (y + HexGridGame.OFFSET) * HexGridGame.SIDE + (x + HexGridGame.OFFSET);
}

static getSharedNeighbors() {
  if (HexGridGame._sharedNeighbors) {
    return HexGridGame._sharedNeighbors;
  }

  const table = new Int32Array(HexGridGame.CELL_COUNT * 3);
  table.fill(-1);

  for (let idx = 0; idx < HexGridGame.CELL_COUNT; idx++) {
    const y = Math.floor(idx / HexGridGame.SIDE) - HexGridGame.OFFSET;
    const x = (idx % HexGridGame.SIDE) - HexGridGame.OFFSET;
    const base = idx * 3;
    const isRight = (y & 1) === ((x - 1) & 1);

    table[base] = HexGridGame.coordToIndex(x, y - 1);
    table[base + 1] = HexGridGame.coordToIndex(x, y + 1);
    table[base + 2] = HexGridGame.coordToIndex(
      isRight ? x - 1 : x + 1,
      y
    );
  }

  HexGridGame._sharedNeighbors = table;
  return table;
}

static makeZobrist(seed) {
  const result = new Uint32Array(HexGridGame.CELL_COUNT * 3);
  let value = seed >>> 0;

  for (let i = 0; i < result.length; i++) {
    value ^= value << 13;
    value ^= value >>> 17;
    value ^= value << 5;
    result[i] = value >>> 0;
  }

  return result;
}

static getZobristA() {
  if (!HexGridGame._zobristA) {
    HexGridGame._zobristA = HexGridGame.makeZobrist(0x9e3779b9);
  }
  return HexGridGame._zobristA;
}

static getZobristB() {
  if (!HexGridGame._zobristB) {
    HexGridGame._zobristB = HexGridGame.makeZobrist(0x85ebca6b);
  }
  return HexGridGame._zobristB;
}

_getIndex(x, y) {
  return HexGridGame.coordToIndex(x, y);
}

_idxToCoord(idx) {
  if (idx < 0 || idx >= HexGridGame.CELL_COUNT) {
    return null;
  }

  return {
    x: (idx % HexGridGame.SIDE) - HexGridGame.OFFSET,
    y: Math.floor(idx / HexGridGame.SIDE) - HexGridGame.OFFSET
  };
}

// 保留名稱,避免其他舊程式呼叫時出錯。
initNeighborsTable() {
  this.neighbors = HexGridGame.getSharedNeighbors();
}

get(x, y) {
  const idx = this._getIndex(x, y);
  return idx < 0 ? 0 : this.grid[idx];
}

_writeIndex(idx, value, trackBounds = true) {
  if (idx < 0 || idx >= HexGridGame.CELL_COUNT) {
    return false;
  }

  const oldValue = this.grid[idx];

  if (oldValue === value) {
    return true;
  }

  const zobristA = HexGridGame.getZobristA();
  const zobristB = HexGridGame.getZobristB();

  if (oldValue !== 0) {
    const zIndex = idx * 3 + oldValue - 1;
    this.hashA = (this.hashA ^ zobristA[zIndex]) >>> 0;
    this.hashB = (this.hashB ^ zobristB[zIndex]) >>> 0;
  }

  if (value !== 0) {
    const zIndex = idx * 3 + value - 1;
    this.hashA = (this.hashA ^ zobristA[zIndex]) >>> 0;
    this.hashB = (this.hashB ^ zobristB[zIndex]) >>> 0;
  }

  const base = idx * 3;

  if (oldValue === 0 && value !== 0) {
    this.frontier.delete(idx);
    this.frontierCount[idx] = 0;
    this.grid[idx] = value;

    for (let i = 0; i < 3; i++) {
      const neighborIdx = this.neighbors[base + i];

      if (
        neighborIdx >= 0 &&
        neighborIdx < HexGridGame.CELL_COUNT &&
        this.grid[neighborIdx] === 0
      ) {
        this.frontierCount[neighborIdx]++;
        this.frontier.add(neighborIdx);
      }
    }
  } else if (oldValue !== 0 && value === 0) {
    this.grid[idx] = 0;

    let occupiedNeighbors = 0;

    for (let i = 0; i < 3; i++) {
      const neighborIdx = this.neighbors[base + i];

      if (neighborIdx < 0 || neighborIdx >= HexGridGame.CELL_COUNT) {
        continue;
      }

      if (this.grid[neighborIdx] === 0) {
        if (this.frontierCount[neighborIdx] > 0) {
          this.frontierCount[neighborIdx]--;
        }

        if (this.frontierCount[neighborIdx] === 0) {
          this.frontier.delete(neighborIdx);
        }
      } else {
        occupiedNeighbors++;
      }
    }

    this.frontierCount[idx] = occupiedNeighbors;

    if (occupiedNeighbors > 0) {
      this.frontier.add(idx);
    } else {
      this.frontier.delete(idx);
    }
  } else {
    // 非零棋子類型直接替換。一般流程不會使用,但保留完整性。
    this.grid[idx] = value;
  }

  if (trackBounds && value !== 0) {
    const coord = this._idxToCoord(idx);

    if (coord.x < this.minX) this.minX = coord.x;
    if (coord.x > this.maxX) this.maxX = coord.x;
    if (coord.y < this.minY) this.minY = coord.y;
    if (coord.y > this.maxY) this.maxY = coord.y;
  }

  return true;
}

set(x, y, value, trackBounds = true) {
  const idx = this._getIndex(x, y);
  if (idx < 0) return false;
  return this._writeIndex(idx, value, trackBounds);
}

getNeighbors(x, y) {
  const idx = this._getIndex(x, y);

  if (idx < 0) {
    return [];
  }

  const result = [];
  const base = idx * 3;

  for (let i = 0; i < 3; i++) {
    const coord = this._idxToCoord(this.neighbors[base + i]);
    if (coord) result.push(coord);
  }

  return result;
}

positionKey(player, workPieces) {
  return [
    this.hashA.toString(36),
    this.hashB.toString(36),
    player,
    workPieces[1][0],
    workPieces[1][1],
    workPieces[1][2],
    workPieces[2][0],
    workPieces[2][1],
    workPieces[2][2]
  ].join(':');
}

這項修改同時解決:

  • 32×32 和 64×64 不一致。
  • 座標越界碰撞。
  • clone 重建鄰居表。
  • 無法建立局面 hash。
  • 每次掃描整個邊界才能產生合法著法。

2.2 替換 tryPlacePiece()undo()

將原本兩個方法替換為:

js
tryPlacePiece(
  pieceType,
  p1,
  p2,
  isImport = false,
  withScores = true
) {
  const idx1 = this._getIndex(p1.x, p1.y);
  const idx2 = this._getIndex(p2.x, p2.y);

  if (
    pieceType < 1 ||
    pieceType > 3 ||
    idx1 < 0 ||
    idx2 < 0 ||
    idx1 === idx2 ||
    this.grid[idx1] !== 0 ||
    this.grid[idx2] !== 0
  ) {
    return {
      success: false,
      reason: '干涉',
      scores: null
    };
  }

  const previousBounds = {
    minX: this.minX,
    maxX: this.maxX,
    minY: this.minY,
    maxY: this.maxY
  };

  this._writeIndex(idx1, pieceType, true);
  this._writeIndex(idx2, pieceType, true);
  this.totalPieces++;

  let hasHole = false;

  if (isImport) {
    hasHole = this.hasHoleAfterPlacement([p1, p2]);
  } else {
    hasHole =
      this.totalPieces < 5
        ? false
        : this.fastHoleCheck(pieceType, p1, p2);
  }

  if (hasHole) {
    this._writeIndex(idx2, 0, false);
    this._writeIndex(idx1, 0, false);
    this.totalPieces--;

    this.minX = previousBounds.minX;
    this.maxX = previousBounds.maxX;
    this.minY = previousBounds.minY;
    this.maxY = previousBounds.maxY;

    return {
      success: false,
      reason: '空洞',
      scores: null
    };
  }

  let bpObj = null;

  if (pieceType === 1) {
    const mainP = p1.x < p2.x ? p1 : p2;
    bpObj = { x: mainP.x, y: mainP.y };

    this.bluePieces.push(bpObj);
    this.bpLookup[idx1] = bpObj;
    this.bpLookup[idx2] = bpObj;
  }

  this.history.push({
    pieceType,
    p1: { x: p1.x, y: p1.y },
    p2: { x: p2.x, y: p2.y },
    idx1,
    idx2,
    bpObj,
    previousBounds
  });

  return {
    success: true,
    scores: withScores ? this.calculateScores() : null
  };
}

undo(withScores = true) {
  if (this.history.length === 0) {
    return withScores ? { p1Score: 0, p2Score: 0 } : null;
  }

  const lastMove = this.history.pop();

  if (lastMove.pieceType === 1) {
    this.bluePieces.pop();
    this.bpLookup[lastMove.idx1] = null;
    this.bpLookup[lastMove.idx2] = null;
  }

  // 先清除棋格,再恢復落子前的精確邊界。
  this._writeIndex(lastMove.idx2, 0, false);
  this._writeIndex(lastMove.idx1, 0, false);
  this.totalPieces--;

  this.minX = lastMove.previousBounds.minX;
  this.maxX = lastMove.previousBounds.maxX;
  this.minY = lastMove.previousBounds.minY;
  this.maxY = lastMove.previousBounds.maxY;

  return withScores ? this.calculateScores() : null;
}

AI 模擬時使用:

js
state.tryPlacePiece(pieceType, p1, p2, false, false);
state.undo(false);

如此不會在每個搜尋節點重新計算分數。

正常 UI 落子不必修改,因為 withScores 預設仍是 true


2.3 替換 clone(),增加 snapshot 和 copyFrom

將原本 clone() 替換成以下整段:

js
exportSnapshot() {
  return {
    grid: this.grid.slice(),
    bluePieces: this.bluePieces.map(piece => ({
      x: piece.x,
      y: piece.y
    })),
    totalPieces: this.totalPieces,
    minX: this.minX,
    maxX: this.maxX,
    minY: this.minY,
    maxY: this.maxY
  };
}

loadSnapshot(snapshot) {
  this.grid.fill(0);
  this.frontierCount.fill(0);
  this.frontier.clear();
  this.bpLookup.fill(null);
  this.bluePieces = [];
  this.history = [];
  this.hashA = 0;
  this.hashB = 0;

  for (let idx = 0; idx < HexGridGame.CELL_COUNT; idx++) {
    const value = snapshot.grid[idx] || 0;
    if (value !== 0) {
      this._writeIndex(idx, value, false);
    }
  }

  this.totalPieces = snapshot.totalPieces;
  this.minX = snapshot.minX;
  this.maxX = snapshot.maxX;
  this.minY = snapshot.minY;
  this.maxY = snapshot.maxY;

  this.bluePieces = snapshot.bluePieces.map(piece => ({
    x: piece.x,
    y: piece.y
  }));

  for (const bp of this.bluePieces) {
    const idx1 = this._getIndex(bp.x, bp.y);
    const idx2 = this._getIndex(bp.x + 1, bp.y);

    if (idx1 >= 0) this.bpLookup[idx1] = bp;
    if (idx2 >= 0) this.bpLookup[idx2] = bp;
  }

  return this;
}

static fromSnapshot(snapshot) {
  return new HexGridGame().loadSnapshot(snapshot);
}

copyFrom(other) {
  this.grid.set(other.grid);
  this.frontierCount.set(other.frontierCount);

  this.frontier.clear();
  for (const idx of other.frontier) {
    this.frontier.add(idx);
  }

  this.totalPieces = other.totalPieces;
  this.minX = other.minX;
  this.maxX = other.maxX;
  this.minY = other.minY;
  this.maxY = other.maxY;
  this.hashA = other.hashA;
  this.hashB = other.hashB;

  this.bluePieces = other.bluePieces.map(piece => ({
    x: piece.x,
    y: piece.y
  }));

  this.bpLookup.fill(null);

  for (const bp of this.bluePieces) {
    const idx1 = this._getIndex(bp.x, bp.y);
    const idx2 = this._getIndex(bp.x + 1, bp.y);

    if (idx1 >= 0) this.bpLookup[idx1] = bp;
    if (idx2 >= 0) this.bpLookup[idx2] = bp;
  }

  this.history.length = 0;
  this.memo1.clear();
  this.memo2.clear();

  return this;
}

clone() {
  return new HexGridGame().copyFrom(this);
}

copyFrom() 的用途是讓 Worker 的 MCTS 重複使用同一個模擬物件,減少垃圾回收。


2.4 替換 getValidMoves()

將原本整個 getValidMoves() 替換為:

js
getValidMoves(player, pPiecesLeft) {
  const p1Remaining =
    pPiecesLeft[1][0] +
    pPiecesLeft[1][1] +
    pPiecesLeft[1][2];

  const p2Remaining =
    pPiecesLeft[2][0] +
    pPiecesLeft[2][1] +
    pPiecesLeft[2][2];

  const movesPlayed = 36 - (p1Remaining + p2Remaining);

  // 第一手固定為中央藍棋。
  if (movesPlayed === 0) {
    if (pPiecesLeft[player][0] <= 0) {
      return [];
    }

    const idx1 = this._getIndex(0, 0);
    const idx2 = this._getIndex(1, 0);

    return [(idx1 << 12) | idx2];
  }

  // 增量搜尋:只檢查目前棋群外圍相鄰的空格。
  const candidates = Array.from(this.frontier);
  const validMoves = [];
  const seen = new Set();

  for (let pid = 0; pid < 3; pid++) {
    if (pPiecesLeft[player][pid] <= 0) {
      continue;
    }

    // 第二手必須為紅色。
    if (movesPlayed === 1 && pid !== 1) {
      continue;
    }

    const pieceType = pid + 1;

    for (let i = 0; i < candidates.length; i++) {
      const firstIdx = candidates[i];

      if (
        firstIdx < 0 ||
        firstIdx >= HexGridGame.CELL_COUNT ||
        this.grid[firstIdx] !== 0
      ) {
        continue;
      }

      const firstCoord = this._idxToCoord(firstIdx);
      const base = firstIdx * 3;
      const isRight = this.isRight(firstCoord.x, firstCoord.y);

      let secondIdx;

      if (pid === 0) {
        secondIdx = this.neighbors[base + 2];
      } else if (pid === 1) {
        secondIdx = this.neighbors[base + (isRight ? 0 : 1)];
      } else {
        secondIdx = this.neighbors[base + (isRight ? 1 : 0)];
      }

      if (
        secondIdx < 0 ||
        secondIdx >= HexGridGame.CELL_COUNT ||
        this.grid[secondIdx] !== 0
      ) {
        continue;
      }

      const lowIdx = Math.min(firstIdx, secondIdx);
      const highIdx = Math.max(firstIdx, secondIdx);
      const encodedMove = (pid << 24) | (lowIdx << 12) | highIdx;

      if (seen.has(encodedMove)) {
        continue;
      }

      seen.add(encodedMove);

      const p1 = this._idxToCoord(firstIdx);
      const p2 = this._idxToCoord(secondIdx);

      // 測試棋格時不更新邊界。
      this._writeIndex(firstIdx, pieceType, false);
      this._writeIndex(secondIdx, pieceType, false);
      this.totalPieces++;

      const hasHole =
        this.totalPieces < 5
          ? false
          : this.fastHoleCheck(pieceType, p1, p2);

      this.totalPieces--;
      this._writeIndex(secondIdx, 0, false);
      this._writeIndex(firstIdx, 0, false);

      if (!hasHole) {
        validMoves.push(encodedMove);
      }
    }
  }

  return validMoves;
}

這是真正的「增量合法著法搜尋」:不再掃描整個 minX~maxX 矩形,只檢查棋群 frontier。


2.5 修改 hasHoleAfterPlacement()

該方法裡目前有兩處:

js
nIdx < 1024

全部改成:

js
nIdx < HexGridGame.CELL_COUNT

原本這兩行解碼:

js
let cy = (currIdx >> 6) - 31;
let cx = (currIdx & 63) - 31;

請保留。它們本來就是 64×64 的正確解碼。


2.6 避免 UI 預檢時重算分數

找到 processBoardClick() 裡:

js
gameLogic.undo();
attemptDrop(bestTri);

改成:

js
gameLogic.undo(false);
attemptDrop(bestTri);

三、加入 Web Worker 後臺 AI

架構

流程图
正在绘制流程图…

以下 Worker:

  • 在背景執行,不阻塞動畫和按鈕。
  • MCTS 每約 12ms 暫停一次,處理訊息。
  • MCTS 保留同一手搜尋樹。
  • Worker 不銷毀時,後續回合可重用相同局面的節點統計。
  • Minimax 使用跨搜尋 transposition table。
  • 使用 heuristic rollout,而不是完全隨機 rollout。
  • 「中止 AI」可以直接終止 Worker。

3.1 替換舊 AI 引擎

找到:

js
// === 殘局絕對計算:Iterative Deepening Minimax 引擎 ===

從這行開始,一直到舊的 triggerAITurn() 結束為止全部刪除。

也就是保留其後這行:

js
// === 玩家類型與 AI 設定面板邏輯 ===

在這兩部分之間放入以下程式。

Worker 搜尋核心

js
function aiWorkerMain() {
  const minimaxTable = new Map();
  const mctsStats = new Map();

  let activeRequestId = 0;

  self.onmessage = event => {
    const message = event.data;

    if (message.type === 'stop') {
      if (message.requestId === activeRequestId) {
        activeRequestId = 0;
      }
      return;
    }

    if (message.type !== 'search') {
      return;
    }

    activeRequestId = message.requestId;

    runSearch(message).catch(error => {
      self.postMessage({
        type: 'error',
        requestId: message.requestId,
        message: error instanceof Error ? error.message : String(error)
      });
    });
  };

  async function runSearch(message) {
    const state = HexGridGame.fromSnapshot(message.snapshot);

    const pieces = {
      1: Int8Array.from(message.pieces[1]),
      2: Int8Array.from(message.pieces[2])
    };

    const config = {
      time: Math.max(100, Number(message.config.time) || 4000),
      c: Math.max(0.01, Number(message.config.c) || 2.4),
      heuristicN: Math.max(
        0,
        Number(message.config.heuristicN) || 0
      ),
      heuristicChecks: Math.max(
        1,
        Number(message.config.heuristicChecks) || 6
      ),
      pureMinimaxN: Math.max(
        0,
        Number(message.config.pureMinimaxN) || 0
      )
    };

    if (minimaxTable.size > 150000) {
      minimaxTable.clear();
    }

    if (mctsStats.size > 200000) {
      mctsStats.clear();
    }

    const remaining = countRemaining(pieces);
    const mode =
      remaining <= config.pureMinimaxN ? 'minimax' : 'mcts';

    const context = {
      requestId: message.requestId,
      rootPlayer: message.player,
      startTime: performance.now(),
      deadline: performance.now() + config.time,
      nodes: 0,
      reused: 0,
      aborted: false,
      config
    };

    let result;

    if (mode === 'minimax') {
      result = runIterativeMinimax(
        state,
        message.player,
        pieces,
        context
      );
    } else {
      result = await runIncrementalMCTS(
        state,
        message.player,
        pieces,
        context
      );
    }

    if (activeRequestId !== message.requestId) {
      return;
    }

    self.postMessage({
      type: 'done',
      requestId: message.requestId,
      mode,
      elapsed: performance.now() - context.startTime,
      reused: context.reused,
      ...result
    });
  }

  function countRemaining(pieces) {
    return (
      pieces[1][0] +
      pieces[1][1] +
      pieces[1][2] +
      pieces[2][0] +
      pieces[2][1] +
      pieces[2][2]
    );
  }

  function boardDiff(state) {
    const scores = state.calculateScores();
    return scores.p1Score - scores.p2Score;
  }

  function decodeMove(move) {
    return {
      pid: (move >>> 24) & 0xff,
      p1Idx: (move >>> 12) & 0xfff,
      p2Idx: move & 0xfff
    };
  }

  function applyEncodedMove(state, move, player, pieces) {
    const decoded = decodeMove(move);

    if (pieces[player][decoded.pid] <= 0) {
      return false;
    }

    const result = state.tryPlacePiece(
      decoded.pid + 1,
      state._idxToCoord(decoded.p1Idx),
      state._idxToCoord(decoded.p2Idx),
      false,
      false
    );

    if (!result.success) {
      return false;
    }

    pieces[player][decoded.pid]--;
    return true;
  }

  function undoEncodedMove(state, move, player, pieces) {
    const decoded = decodeMove(move);
    pieces[player][decoded.pid]++;
    state.undo(false);
  }

  function moveDelta(state, move, player, baseDiff = null) {
    const before = baseDiff === null ? boardDiff(state) : baseDiff;
    const decoded = decodeMove(move);

    const result = state.tryPlacePiece(
      decoded.pid + 1,
      state._idxToCoord(decoded.p1Idx),
      state._idxToCoord(decoded.p2Idx),
      false,
      false
    );

    if (!result.success) {
      return -Infinity;
    }

    const after = boardDiff(state);
    state.undo(false);

    // 對目前行動者而言,越大越好。
    return player === 1 ? after - before : before - after;
  }

  function orderMoves(state, moves, player, ttBestMove = null) {
    if (moves.length <= 1) {
      return moves.slice();
    }

    const result = [];
    const selected = new Set();
    const scoreLimit = Math.min(moves.length, 24);
    const baseDiff = boardDiff(state);

    if (ttBestMove !== null) {
      const ttIndex = moves.indexOf(ttBestMove);
      if (ttIndex >= 0) selected.add(ttIndex);
    }

    for (let i = 0; i < scoreLimit; i++) {
      selected.add(
        Math.floor((i * moves.length) / scoreLimit)
      );
    }

    for (let i = 0; i < moves.length; i++) {
      let score = -1000000;

      if (selected.has(i)) {
        score = moveDelta(state, moves[i], player, baseDiff);
      }

      if (moves[i] === ttBestMove) {
        score += 1000000000;
      }

      result.push({
        move: moves[i],
        score
      });
    }

    result.sort((a, b) => b.score - a.score);
    return result.map(item => item.move);
  }

  function alphaBeta(
    state,
    depth,
    alpha,
    beta,
    player,
    pieces,
    context
  ) {
    context.nodes++;

    if (
      (context.nodes & 127) === 0 &&
      performance.now() >= context.deadline
    ) {
      context.aborted = true;
    }

    if (context.aborted) {
      return boardDiff(state);
    }

    if (depth === 0) {
      return boardDiff(state);
    }

    const key = state.positionKey(player, pieces);
    const alphaOriginal = alpha;
    const betaOriginal = beta;
    const cached = minimaxTable.get(key);

    if (cached && cached.depth >= depth) {
      if (cached.flag === 'exact') {
        return cached.score;
      }

      if (cached.flag === 'lower') {
        alpha = Math.max(alpha, cached.score);
      } else if (cached.flag === 'upper') {
        beta = Math.min(beta, cached.score);
      }

      if (alpha >= beta) {
        return cached.score;
      }
    }

    const moves = state.getValidMoves(player, pieces);

    if (moves.length === 0) {
      return boardDiff(state);
    }

    const orderedMoves = orderMoves(
      state,
      moves,
      player,
      cached ? cached.bestMove : null
    );

    const nextPlayer = player === 1 ? 2 : 1;
    let bestMove = orderedMoves[0];
    let bestScore = player === 1 ? -Infinity : Infinity;

    for (let i = 0; i < orderedMoves.length; i++) {
      const move = orderedMoves[i];

      if (!applyEncodedMove(state, move, player, pieces)) {
        continue;
      }

      const score = alphaBeta(
        state,
        depth - 1,
        alpha,
        beta,
        nextPlayer,
        pieces,
        context
      );

      undoEncodedMove(state, move, player, pieces);

      if (context.aborted) {
        break;
      }

      if (player === 1) {
        if (score > bestScore) {
          bestScore = score;
          bestMove = move;
        }

        alpha = Math.max(alpha, bestScore);
      } else {
        if (score < bestScore) {
          bestScore = score;
          bestMove = move;
        }

        beta = Math.min(beta, bestScore);
      }

      if (alpha >= beta) {
        break;
      }
    }

    if (!context.aborted && Number.isFinite(bestScore)) {
      let flag = 'exact';

      if (bestScore <= alphaOriginal) {
        flag = 'upper';
      } else if (bestScore >= betaOriginal) {
        flag = 'lower';
      }

      minimaxTable.set(key, {
        depth,
        score: bestScore,
        flag,
        bestMove
      });
    }

    return Number.isFinite(bestScore)
      ? bestScore
      : boardDiff(state);
  }

  function runIterativeMinimax(
    rootState,
    rootPlayer,
    pieces,
    context
  ) {
    const rootMoves = rootState.getValidMoves(rootPlayer, pieces);

    if (rootMoves.length === 0) {
      return {
        bestMove: null,
        score: boardDiff(rootState),
        nodes: 0,
        completedDepth: 0
      };
    }

    const maxDepth = countRemaining(pieces);
    let bestMove = rootMoves[0];
    let bestScore = boardDiff(rootState);
    let completedDepth = 0;
    let lastProgressTime = context.startTime;

    for (let depth = 1; depth <= maxDepth; depth++) {
      context.aborted = false;

      const rootKey = rootState.positionKey(rootPlayer, pieces);
      const cached = minimaxTable.get(rootKey);

      const moves = orderMoves(
        rootState,
        rootMoves,
        rootPlayer,
        cached ? cached.bestMove : bestMove
      );

      let currentBestMove = null;
      let currentBestScore =
        rootPlayer === 1 ? -Infinity : Infinity;

      let alpha = -Infinity;
      let beta = Infinity;
      const nextPlayer = rootPlayer === 1 ? 2 : 1;

      for (let i = 0; i < moves.length; i++) {
        const move = moves[i];

        if (performance.now() >= context.deadline) {
          context.aborted = true;
          break;
        }

        if (!applyEncodedMove(rootState, move, rootPlayer, pieces)) {
          continue;
        }

        const score = alphaBeta(
          rootState,
          depth - 1,
          alpha,
          beta,
          nextPlayer,
          pieces,
          context
        );

        undoEncodedMove(rootState, move, rootPlayer, pieces);

        if (context.aborted) {
          break;
        }

        if (rootPlayer === 1) {
          if (score > currentBestScore) {
            currentBestScore = score;
            currentBestMove = move;
          }

          alpha = Math.max(alpha, currentBestScore);
        } else {
          if (score < currentBestScore) {
            currentBestScore = score;
            currentBestMove = move;
          }

          beta = Math.min(beta, currentBestScore);
        }

        const now = performance.now();

        if (now - lastProgressTime >= 100) {
          self.postMessage({
            type: 'progress',
            requestId: context.requestId,
            mode: 'minimax',
            elapsed: now - context.startTime,
            depth,
            nodes: context.nodes,
            score: Number.isFinite(currentBestScore)
              ? currentBestScore
              : bestScore
          });

          lastProgressTime = now;
        }
      }

      if (
        !context.aborted &&
        currentBestMove !== null &&
        Number.isFinite(currentBestScore)
      ) {
        bestMove = currentBestMove;
        bestScore = currentBestScore;
        completedDepth = depth;

        minimaxTable.set(
          rootState.positionKey(rootPlayer, pieces),
          {
            depth,
            score: bestScore,
            flag: 'exact',
            bestMove
          }
        );
      }

      if (
        context.aborted ||
        performance.now() >= context.deadline
      ) {
        break;
      }
    }

    return {
      bestMove,
      score: bestScore,
      nodes: context.nodes,
      completedDepth
    };
  }

  function getSharedStats(key, context) {
    let stats = mctsStats.get(key);

    if (stats) {
      context.reused++;
      return stats;
    }

    stats = {
      visits: 0,
      valueSum: 0
    };

    mctsStats.set(key, stats);
    return stats;
  }

  class MCTSNode {
    constructor(
      move,
      parent,
      playerToMove,
      key,
      moves,
      prior,
      context
    ) {
      this.move = move;
      this.parent = parent;
      this.playerToMove = playerToMove;
      this.key = key;
      this.children = [];
      this.unexpanded = moves;
      this.prior = prior;
      this.stats = getSharedStats(key, context);
    }
  }

  function selectChild(node, player, explorationConstant) {
    let bestChild = null;
    let bestValue = -Infinity;
    const parentVisits = Math.max(1, node.stats.visits);

    for (const child of node.children) {
      const visits = child.stats.visits;

      const qP1 =
        visits > 0
          ? child.stats.valueSum / visits
          : 0;

      // P1 選擇較大的 P1 價值;P2 選擇較小的 P1 價值。
      const exploitation = player === 1 ? qP1 : -qP1;

      const exploration =
        explorationConstant *
        child.prior *
        Math.sqrt(parentVisits + 1) /
        (1 + visits);

      const value = exploitation + exploration;

      if (value > bestValue) {
        bestValue = value;
        bestChild = child;
      }
    }

    return bestChild;
  }

  function chooseHeuristicIndex(
    state,
    moves,
    player,
    maxChecks
  ) {
    if (moves.length <= 1) {
      return {
        index: 0,
        delta: moves.length === 1
          ? moveDelta(state, moves[0], player)
          : 0
      };
    }

    const checkCount = Math.min(
      moves.length,
      Math.max(1, maxChecks)
    );

    const selected = new Set();

    while (selected.size < checkCount) {
      selected.add(
        Math.floor(Math.random() * moves.length)
      );
    }

    const baseDiff = boardDiff(state);
    let bestIndex = selected.values().next().value;
    let bestDelta = -Infinity;

    for (const index of selected) {
      const delta = moveDelta(
        state,
        moves[index],
        player,
        baseDiff
      );

      if (delta > bestDelta) {
        bestDelta = delta;
        bestIndex = index;
      }
    }

    return {
      index: bestIndex,
      delta: bestDelta
    };
  }

  function chooseRolloutMove(
    state,
    moves,
    player,
    pieces,
    config
  ) {
    const remaining = countRemaining(pieces);
    const isEndgame = remaining <= config.heuristicN;

    // 中前盤保留少量隨機性,避免 rollout 全部走相同路線。
    if (!isEndgame && Math.random() < 0.22) {
      return moves[Math.floor(Math.random() * moves.length)];
    }

    const maxChecks = isEndgame
      ? Math.min(config.heuristicChecks, 24)
      : Math.min(config.heuristicChecks, 8);

    const picked = chooseHeuristicIndex(
      state,
      moves,
      player,
      maxChecks
    );

    return moves[picked.index];
  }

  function copyPieces(source, target) {
    target[1][0] = source[1][0];
    target[1][1] = source[1][1];
    target[1][2] = source[1][2];

    target[2][0] = source[2][0];
    target[2][1] = source[2][1];
    target[2][2] = source[2][2];
  }

  function runOneMCTSIteration(
    root,
    rootState,
    rootPlayer,
    rootPieces,
    scratchState,
    simulationPieces,
    context
  ) {
    scratchState.copyFrom(rootState);
    copyPieces(rootPieces, simulationPieces);

    let node = root;
    let simulationPlayer = rootPlayer;

    // Selection
    while (
      node.unexpanded.length === 0 &&
      node.children.length > 0
    ) {
      const child = selectChild(
        node,
        simulationPlayer,
        context.config.c
      );

      if (!child) break;

      if (
        !applyEncodedMove(
          scratchState,
          child.move,
          simulationPlayer,
          simulationPieces
        )
      ) {
        return false;
      }

      node = child;
      simulationPlayer =
        simulationPlayer === 1 ? 2 : 1;
    }

    // Expansion
    if (node.unexpanded.length > 0) {
      const picked = chooseHeuristicIndex(
        scratchState,
        node.unexpanded,
        simulationPlayer,
        Math.min(context.config.heuristicChecks, 10)
      );

      const move = node.unexpanded.splice(
        picked.index,
        1
      )[0];

      if (
        !applyEncodedMove(
          scratchState,
          move,
          simulationPlayer,
          simulationPieces
        )
      ) {
        return false;
      }

      const nextPlayer =
        simulationPlayer === 1 ? 2 : 1;

      const childKey = scratchState.positionKey(
        nextPlayer,
        simulationPieces
      );

      const childMoves = scratchState.getValidMoves(
        nextPlayer,
        simulationPieces
      );

      const boundedDelta = Math.max(
        -4,
        Math.min(4, picked.delta)
      );

      const prior = Math.exp(boundedDelta * 0.3);

      const child = new MCTSNode(
        move,
        node,
        nextPlayer,
        childKey,
        childMoves,
        prior,
        context
      );

      node.children.push(child);
      node = child;
      simulationPlayer = nextPlayer;
    }

    // Rollout
    while (true) {
      const moves = scratchState.getValidMoves(
        simulationPlayer,
        simulationPieces
      );

      if (moves.length === 0) {
        break;
      }

      const move = chooseRolloutMove(
        scratchState,
        moves,
        simulationPlayer,
        simulationPieces,
        context.config
      );

      if (
        !applyEncodedMove(
          scratchState,
          move,
          simulationPlayer,
          simulationPieces
        )
      ) {
        break;
      }

      simulationPlayer =
        simulationPlayer === 1 ? 2 : 1;
    }

    const difference = boardDiff(scratchState);

    // 固定以 P1 視角儲存,跨回合才可安全重用。
    const rewardP1 = Math.tanh(difference / 6);

    while (node) {
      node.stats.visits++;
      node.stats.valueSum += rewardP1;
      node = node.parent;
    }

    return true;
  }

  function findBestMCTSChild(root, rootPlayer) {
    let bestChild = null;
    let bestVisits = -1;
    let bestValue = -Infinity;

    for (const child of root.children) {
      const visits = child.stats.visits;
      const qP1 =
        visits > 0
          ? child.stats.valueSum / visits
          : 0;

      const playerValue = rootPlayer === 1 ? qP1 : -qP1;

      if (
        visits > bestVisits ||
        (visits === bestVisits && playerValue > bestValue)
      ) {
        bestVisits = visits;
        bestValue = playerValue;
        bestChild = child;
      }
    }

    return {
      child: bestChild,
      value: bestValue
    };
  }

  async function runIncrementalMCTS(
    rootState,
    rootPlayer,
    rootPieces,
    context
  ) {
    const rootMoves = rootState.getValidMoves(
      rootPlayer,
      rootPieces
    );

    if (rootMoves.length === 0) {
      return {
        bestMove: null,
        iterations: 0,
        winRate: 50
      };
    }

    const rootKey = rootState.positionKey(
      rootPlayer,
      rootPieces
    );

    const root = new MCTSNode(
      null,
      null,
      rootPlayer,
      rootKey,
      rootMoves.slice(),
      1,
      context
    );

    const scratchState = new HexGridGame();

    const simulationPieces = {
      1: new Int8Array(3),
      2: new Int8Array(3)
    };

    let iterations = 0;
    let lastProgressTime = context.startTime;

    while (
      activeRequestId === context.requestId &&
      performance.now() < context.deadline
    ) {
      // 每個增量批次最多運算約 12ms。
      const sliceEnd = Math.min(
        context.deadline,
        performance.now() + 12
      );

      while (performance.now() < sliceEnd) {
        const success = runOneMCTSIteration(
          root,
          rootState,
          rootPlayer,
          rootPieces,
          scratchState,
          simulationPieces,
          context
        );

        if (success) {
          iterations++;
        } else if (
          root.unexpanded.length === 0 &&
          root.children.length === 0
        ) {
          break;
        }
      }

      const now = performance.now();

      if (now - lastProgressTime >= 100) {
        const currentBest = findBestMCTSChild(
          root,
          rootPlayer
        );

        const value = Number.isFinite(currentBest.value)
          ? currentBest.value
          : 0;

        self.postMessage({
          type: 'progress',
          requestId: context.requestId,
          mode: 'mcts',
          elapsed: now - context.startTime,
          iterations,
          reused: context.reused,
          winRate: ((value + 1) * 50)
        });

        lastProgressTime = now;
      }

      // 讓 Worker 處理 stop 或下一個 search 訊息。
      await new Promise(resolve => setTimeout(resolve, 0));
    }

    const best = findBestMCTSChild(root, rootPlayer);

    if (!best.child) {
      return {
        bestMove: rootMoves[0],
        iterations,
        winRate: 50
      };
    }

    return {
      bestMove: best.child.move,
      iterations,
      winRate: (best.value + 1) * 50
    };
  }
}

3.2 主執行緒 Worker 控制器

緊接著上面的 aiWorkerMain() 後加入:

js
let aiWorker = null;
let aiWorkerRequestId = 0;
let activeAiSearch = null;

function createAiWorker() {
  const source = `
    'use strict';
    const HexGridGame = ${HexGridGame.toString()};
    (${aiWorkerMain.toString()})();
  `;

  const blob = new Blob([source], {
    type: 'text/javascript'
  });

  const url = URL.createObjectURL(blob);
  const worker = new Worker(url);

  URL.revokeObjectURL(url);

  worker.onmessage = handleAiWorkerMessage;
  worker.onerror = handleAiWorkerError;

  return worker;
}

function getAiWorker() {
  if (!aiWorker) {
    aiWorker = createAiWorker();
  }

  return aiWorker;
}

function cancelAiWorkerSearch() {
  abortAiSearch = true;
  activeAiSearch = null;
  isAiThinking = false;
  aiWorkerRequestId++;

  if (aiWorker) {
    aiWorker.terminate();
    aiWorker = null;
  }

  setAiThinkingState(false);
}

function handleAiWorkerError(event) {
  console.error('AI Worker error:', event.message || event);

  const search = activeAiSearch;

  activeAiSearch = null;
  isAiThinking = false;

  if (aiWorker) {
    aiWorker.terminate();
    aiWorker = null;
  }

  if (!search) {
    return;
  }

  const fallbackMoves = gameLogic.getValidMoves(
    currentPlayer,
    piecesLeft
  );

  if (fallbackMoves.length > 0) {
    showNotification(
      currentLang === 'zh'
        ? '後臺搜尋發生錯誤,使用備用著法'
        : 'Background search failed; using fallback move'
    );

    playEncodedAiMove(fallbackMoves[0]);
  } else {
    finishGameBecauseNoMoves();
  }
}

function handleAiWorkerMessage(event) {
  const message = event.data;
  const search = activeAiSearch;

  if (
    !search ||
    message.requestId !== search.requestId
  ) {
    return;
  }

  const box = document.getElementById('test-box');

  if (message.type === 'progress') {
    if (message.mode === 'minimax') {
      box.innerText =
        `${getText('minimaxSearch')}\n` +
        `${getText('time')}: ${(message.elapsed / 1000).toFixed(1)}s / ${(search.timeLimit / 1000).toFixed(1)}s\n` +
        `${getText('depth')}: ${message.depth}\n` +
        `${getText('nodes')}: ${message.nodes.toLocaleString()}`;
    } else {
      const reusedText =
        currentLang === 'zh' ? '重用統計' : 'Reused stats';

      box.innerText =
        `${getText('aiThinking')}\n` +
        `${getText('time')}: ${(message.elapsed / 1000).toFixed(1)}s / ${(search.timeLimit / 1000).toFixed(1)}s\n` +
        `${getText('simCount')}: ${message.iterations.toLocaleString()}\n` +
        `${getText('winRate')}: ${message.winRate.toFixed(1)}%\n` +
        `${reusedText}: ${message.reused.toLocaleString()}`;
    }

    box.style.opacity = '1';
    return;
  }

  if (message.type === 'error') {
    handleAiWorkerError({
      message: message.message
    });
    return;
  }

  if (message.type !== 'done') {
    return;
  }

  activeAiSearch = null;
  isAiThinking = false;

  // 防止搜尋完成前玩家已悔棋、導入棋譜或換人。
  if (
    abortAiSearch ||
    gameState !== 'playing' ||
    currentPlayer !== search.player ||
    historyIndex !== search.historyIndex
  ) {
    return;
  }

  if (message.bestMove === null) {
    finishGameBecauseNoMoves();
    return;
  }

  if (message.mode === 'minimax') {
    const score = Number(message.score) || 0;

    const scoreText =
      score > 0
        ? `${getText('p1Lead')} ${score}`
        : score < 0
          ? `${getText('p2Lead')} ${-score}`
          : `${getText('tie')} 0`;

    box.innerText =
      `${getText('searchDone')}: ${(message.elapsed / 1000).toFixed(1)}s\n` +
      `${getText('depth')}: ${message.completedDepth}\n` +
      `${getText('totalNodes')}: ${message.nodes.toLocaleString()}\n` +
      `${getText('bestResult')}: ${scoreText}`;
  } else {
    box.innerText =
      `${getText('searchDone')}: ${(message.elapsed / 1000).toFixed(1)}s\n` +
      `${getText('simCount')}: ${message.iterations.toLocaleString()}\n` +
      `${getText('winRate')}: ${message.winRate.toFixed(1)}%`;
  }

  box.style.opacity = '1';

  playEncodedAiMove(message.bestMove);
}

function createTriFromIndex(idx) {
  const coord = gameLogic._idxToCoord(idx);

  if (!coord) return null;

  const halfWidth = 31.1769;
  const isRight = gameLogic.isRight(coord.x, coord.y);

  const cx = isRight
    ? (coord.x - 1) * halfWidth + 10.392
    : (coord.x - 1) * halfWidth + 20.784;

  return {
    idx: coord.x,
    N: coord.y,
    isRight,
    cx,
    cy: coord.y * 18
  };
}

function playEncodedAiMove(move) {
  const pid = (move >>> 24) & 0xff;
  const p1Idx = (move >>> 12) & 0xfff;
  const p2Idx = move & 0xfff;

  if (
    pid < 0 ||
    pid > 2 ||
    piecesLeft[currentPlayer][pid] <= 0
  ) {
    return false;
  }

  let tri = createTriFromIndex(p1Idx);

  if (!tri) return false;

  let pair = getPairTri(tri, pid);
  let pairIdx = gameLogic._getIndex(pair.idx, pair.N);

  // 編碼中的第一個索引經過排序,未必是原始基準三角形。
  if (pairIdx !== p2Idx) {
    tri = createTriFromIndex(p2Idx);

    if (!tri) return false;

    pair = getPairTri(tri, pid);
    pairIdx = gameLogic._getIndex(pair.idx, pair.N);

    if (pairIdx !== p1Idx) {
      console.error('AI returned invalid piece geometry:', move);
      return false;
    }
  }

  const tri2 = getPairTri(tri, pid);

  // 在主棋盤再驗證一次,防止收到過期 Worker 結果。
  const validation = gameLogic.tryPlacePiece(
    pid + 1,
    { x: tri.idx, y: tri.N },
    { x: tri2.idx, y: tri2.N },
    false,
    false
  );

  if (!validation.success) {
    return false;
  }

  gameLogic.undo(false);

  currentSelectedPiece = pid;
  selectedBoxElement = null;
  isFirstMove = false;

  attemptDrop(tri);
  return true;
}

function finishGameBecauseNoMoves() {
  isAiThinking = false;
  setAiThinkingState(false);

  showNotification(getText('noValidMoves'));

  /*
   * 如果規則是「無合法著法直接結束」,保留以下處理。
   * 如果規則是允許 pass,則應改為切換 currentPlayer。
   */
  gameState = 'ended';
  updateUI();

  setTimeout(showRoundEnd, 300);
}

function triggerAITurn() {
  if (
    gameState !== 'playing' ||
    isAiThinking ||
    playerTypes[currentPlayer] === 'human'
  ) {
    return;
  }

  isAiThinking = true;
  abortAiSearch = false;
  setAiThinkingState(true);

  const roundStart = currentRound === 2 ? 36 : 0;
  const movesInRound = historyIndex - roundStart + 1;

  if (movesInRound === 0) {
    isAiThinking = false;
    autoPlayFirstBlue(currentPlayer);
    return;
  }

  const playerType = playerTypes[currentPlayer];
  const config = aiConfigs[playerType] || aiConfigs.custom;
  const requestId = ++aiWorkerRequestId;

  activeAiSearch = {
    requestId,
    player: currentPlayer,
    historyIndex,
    timeLimit: config.time
  };

  const snapshot = gameLogic.exportSnapshot();

  try {
    getAiWorker().postMessage(
      {
        type: 'search',
        requestId,
        player: currentPlayer,
        pieces: {
          1: Array.from(piecesLeft[1]),
          2: Array.from(piecesLeft[2])
        },
        config: {
          time: config.time,
          c: config.c,
          heuristicN: config.heuristicN,
          heuristicChecks: config.heuristicChecks,
          pureMinimaxN: config.pureMinimaxN
        },
        snapshot
      },
      [snapshot.grid.buffer]
    );
  } catch (error) {
    handleAiWorkerError({
      message: error instanceof Error
        ? error.message
        : String(error)
    });
  }
}

四、替換「中止 AI」函數

將原本的 forceHumanPlayer() 替換為:

js
function forceHumanPlayer() {
  cancelAiWorkerSearch();

  playerTypes[1] = 'human';
  playerTypes[2] = 'human';

  document.getElementById('ptype-1').innerHTML =
    aiConfigs.human.icon;

  document.getElementById('ptype-2').innerHTML =
    aiConfigs.human.icon;

  document.getElementById('ptype-1').style.color =
    '#0056b3';

  document.getElementById('ptype-2').style.color =
    '#a70000';

  updateUI();
}

這樣按下中止按鈕時會直接終止 Worker,而不是只修改一個主執行緒無法及時讀到的布林值。


五、修正重新開始遊戲

目前 restartGame() 沒有可靠重建 gameLogic

將整個函數替換成:

js
function restartGame() {
  cancelAiWorkerSearch();

  document.getElementById(
    'game-over-screen'
  ).style.display = 'none';

  gameState = 'playing';

  currentScale = 1;
  panX = 240;
  panY = 240;

  initBoardState(false);
  applyTransform();

  if (playerTypes[currentPlayer] !== 'human') {
    setAiThinkingState(true);
    setTimeout(triggerAITurn, 100);
  } else {
    showNotification(getText('firstBluePrompt'));
  }
}

六、修正滑鼠滾輪縮放方向

找到:

js
zoomAtCursor(e.clientX, e.clientY, e.deltaY < 0 ? -0.1 : 0.1);

替換為:

js
zoomAtCursor(
  e.clientX,
  e.clientY,
  e.deltaY < 0 ? 0.1 : -0.1
);

原本滑鼠向上滾反而縮小,與「+」按鈕方向相反。


七、修正語言切換

找到 toggleLanguage() 中:

js
currentLang = currentLang === 'zh' ? 'en' : 'zh';

替換為:

js
currentLang = inputLang;

否則未來若增加第三種語言,或由程式主動指定語言時會出錯。


八、修正 SVG 下載的 Object URL 洩漏

confirmDownloadSVG() 裡這部分:

js
const a = document.createElement('a');
a.href = URL.createObjectURL(
  new Blob([currentDlSvgSource], {
    type: 'image/svg+xml;charset=utf-8'
  })
);
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);

替換為:

js
const blob = new Blob([currentDlSvgSource], {
  type: 'image/svg+xml;charset=utf-8'
});

const url = URL.createObjectURL(blob);
const a = document.createElement('a');

a.href = url;
a.download = filename;

document.body.appendChild(a);
a.click();
document.body.removeChild(a);

setTimeout(() => {
  URL.revokeObjectURL(url);
}, 0);

九、更新複製棋譜功能

document.execCommand('copy') 已被淘汰。

copyExport() 替換為:

js
async function copyExport() {
  const input = document.getElementById('export-text');

  try {
    if (
      navigator.clipboard &&
      window.isSecureContext
    ) {
      await navigator.clipboard.writeText(input.value);
    } else {
      input.focus();
      input.select();

      const success = document.execCommand('copy');

      if (!success) {
        throw new Error('copy failed');
      }
    }

    showToast(getText('copySuccess'));
  } catch (error) {
    input.focus();
    input.select();

    showToast(
      currentLang === 'zh'
        ? '無法自動複製,請手動複製'
        : 'Automatic copy failed; please copy manually'
    );
  }
}

十、加入棋譜導入合法性驗證

現在的導入功能只解析文字,沒有完整驗證:

  • 棋子是否重疊。
  • 是否超過庫存。
  • 第一手是否中央藍棋。
  • 第二手是否紅棋。
  • 是否與盤面相連。
  • 是否形成空洞。
  • 玩家順序是否正確。

submitImport() 前加入:

js
function validateImportedHistory(history) {
  if (history.length > 72) {
    throw new Error('棋譜超過兩局最大手數');
  }

  let logic = new HexGridGame();
  let inventory = {
    1: [6, 6, 6],
    2: [6, 6, 6]
  };

  for (let i = 0; i < history.length; i++) {
    if (i === 36) {
      logic = new HexGridGame();
      inventory = {
        1: [6, 6, 6],
        2: [6, 6, 6]
      };
    }

    const move = history[i];
    const roundMove = i % 36;
    const firstPlayer = i < 36 ? 1 : 2;

    const expectedPlayer =
      roundMove % 2 === 0
        ? firstPlayer
        : firstPlayer === 1
          ? 2
          : 1;

    if (move.player !== expectedPlayer) {
      throw new Error(`第 ${i + 1} 手玩家順序錯誤`);
    }

    if (inventory[move.player][move.pid] <= 0) {
      throw new Error(`第 ${i + 1} 手棋子庫存不足`);
    }

    if (roundMove === 0) {
      const isCentralBlue =
        move.pid === 0 &&
        (
          (
            move.t1.idx === 0 &&
            move.t1.N === 0 &&
            move.t2.idx === 1 &&
            move.t2.N === 0
          ) ||
          (
            move.t2.idx === 0 &&
            move.t2.N === 0 &&
            move.t1.idx === 1 &&
            move.t1.N === 0
          )
        );

      if (!isCentralBlue) {
        throw new Error(`第 ${i + 1} 手必須是中央藍棋`);
      }
    }

    if (roundMove === 1 && move.pid !== 1) {
      throw new Error(`第 ${i + 1} 手必須是紅棋`);
    }

    const makeTri = source => ({
      idx: source.idx,
      N: source.N,
      isRight: logic.isRight(source.idx, source.N)
    });

    const firstPair = getPairTri(
      makeTri(move.t1),
      move.pid
    );

    const secondPair = getPairTri(
      makeTri(move.t2),
      move.pid
    );

    const geometryValid =
      (
        firstPair.idx === move.t2.idx &&
        firstPair.N === move.t2.N
      ) ||
      (
        secondPair.idx === move.t1.idx &&
        secondPair.N === move.t1.N
      );

    if (!geometryValid) {
      throw new Error(`第 ${i + 1} 手棋子幾何不正確`);
    }

    if (roundMove > 0) {
      const neighbors = [
        ...logic.getNeighbors(move.t1.idx, move.t1.N),
        ...logic.getNeighbors(move.t2.idx, move.t2.N)
      ];

      const connected = neighbors.some(neighbor =>
        logic.get(neighbor.x, neighbor.y) !== 0
      );

      if (!connected) {
        throw new Error(`第 ${i + 1} 手未與棋盤相連`);
      }
    }

    const result = logic.tryPlacePiece(
      move.pid + 1,
      {
        x: move.t1.idx,
        y: move.t1.N
      },
      {
        x: move.t2.idx,
        y: move.t2.N
      },
      true,
      false
    );

    if (!result.success) {
      throw new Error(
        `第 ${i + 1} 手不合法:${result.reason}`
      );
    }

    inventory[move.player][move.pid]--;
  }

  return true;
}

然後在 submitImport() 裡,找到:

js
document.getElementById('export-modal').style.display = 'none';

// 使用重繪函數直接渲染盤面
moveHistory = importedHistory;

替換成:

js
// 先完整驗證,驗證通過後才修改正式棋局。
validateImportedHistory(importedHistory);

document.getElementById(
  'export-modal'
).style.display = 'none';

moveHistory = importedHistory;

並在 submitImport() 取得 tokens 後加入:

js
if (tokens.length > 72) {
  throw new Error('棋譜超過 72 手');
}

導入前也應直接中止 Worker,因此原本這行可以保留:

js
forceHumanPlayer();

十一、建議調整 AI 預設參數

目前簡單 AI 也思考 12 秒,對手機比較長。建議改為:

js
simple: {
  time: 2500,
  c: 2.0,
  heuristicN: 4,
  heuristicChecks: 5,
  pureMinimaxN: 3,
  icon: '...'
},
hard: {
  time: 6000,
  c: 2.6,
  heuristicN: 8,
  heuristicChecks: 10,
  pureMinimaxN: 4,
  icon: '...'
},
expert: {
  time: 12000,
  c: 3.1,
  heuristicN: 12,
  heuristicChecks: 18,
  pureMinimaxN: 6,
  icon: '...'
},
custom: {
  time: 20000,
  c: 3.6,
  heuristicN: 16,
  heuristicChecks: 24,
  pureMinimaxN: 7,
  icon: '...'
}

目前的探索常數最高到 6.0,容易讓 AI 過度探索。改用 heuristic PUCT 後,通常不需要這麼高。


十二、預期改善

修改後通常可得到以下效果:

  • AI 思考時棋盤仍可平移、縮放,介面不會凍結。
  • 「中止 AI」可以立即生效。
  • MCTS 在同一次思考中持續增量累積,不會每批重新開始。
  • 後續相同或轉置局面可重用 MCTS 統計。
  • Minimax 可以重用已分析過的局面。
  • 合法著法生成由掃描矩形改為只掃描 frontier。
  • 模擬落子不再每一步重新計分。
  • clone 不再重建鄰居表。
  • 支援與現有 12-bit 編碼一致的 4096 格棋盤。
  • 修正搜尋過程中邊界不斷擴張的問題。
  • 修正重新開始後核心棋盤未清空的問題。

最後應至少測試:

  1. 人類對人類的落子、悔棋、前進。
  2. 人類對 AI。
  3. AI 對 AI。
  4. AI 搜尋時按下中止。
  5. 第一局結束後進入第二局。
  6. 重新開始後分數和盤面是否為零。
  7. 導入非法重疊棋譜。
  8. 導入形成空洞的棋譜。
  9. x/y > 15 的位置落子,確認不再發生座標碰撞。
  10. 專家 AI 搜尋期間確認主介面仍流暢。

如果網頁部署時使用 CSP,還需要允許 Blob Worker,例如在 CSP 的 worker-src 中允許 blob:;本地直接開啟這個單檔 HTML 通常不需要額外設定。

提问者

本遊戲只需1024 格、32×32就足夠了

QuanAI

對,若本遊戲的有效座標限定在:

text
x: -15 ~ 16
y: -15 ~ 16

那麼 32 × 32 = 1024 格完全足夠。上一版建議擴大到 4096 格不必要,應撤回。現有程式真正的問題,是部分程式按 32×32 編碼,部分空洞檢查卻按 64×64 解碼;正確做法是統一為 32×32。

Web Worker、增量搜尋、MCTS 重用和 Minimax 快取等改進仍然有效,只需全部使用 1024 格版本。


一、保留 1024 格,但必須修正座標越界碰撞

原本:

js
_getIndex(x, y) {
  return ((y + 15) << 5) | (x + 15);
}

存在問題:沒有檢查 xy 是否超出範圍。像 x = 17 之類的座標可能透過位元運算映射到其他合法格子,造成不同座標指向同一索引。

替換為

js
_getIndex(x, y) {
  if (
    !Number.isInteger(x) ||
    !Number.isInteger(y) ||
    x < -15 ||
    x > 16 ||
    y < -15 ||
    y > 16
  ) {
    return -1;
  }

  return ((y + 15) << 5) + (x + 15);
}

_idxToCoord(idx) {
  if (idx < 0 || idx >= 1024) {
    return null;
  }

  return {
    x: (idx & 31) - 15,
    y: (idx >>> 5) - 15
  };
}

注意:

  • 32 格需要左移 5 位。
  • x 使用 idx & 31
  • y 使用 idx >>> 5
  • 範圍是 -15~16,剛好對應索引 0~1023

二、修正空洞檢查的 64×64 解碼錯誤

hasHoleAfterPlacement() 中找到:

js
let cy = (currIdx >> 6) - 31;
let cx = (currIdx & 63) - 31;

這是 64×64 的解碼,和目前 32×32 棋盤不一致。

替換為

js
let cy = (currIdx >>> 5) - 15;
let cx = (currIdx & 31) - 15;

以下判斷:

js
nIdx >= 0 && nIdx < 1024

則保持不變。


三、建議直接替換整個空洞檢查方法

原本的空洞 BFS 在碰到外部區域後會立即 break,但共用的 visited 陣列尚未標記完整連通區。下一個起點可能因為無法穿過已訪問節點而誤判成空洞。

建議將整個 hasHoleAfterPlacement() 替換為:

js
hasHoleAfterPlacement(coords) {
  if (this.totalPieces <= 1) {
    return false;
  }

  this.visited.fill(0);

  const emptyStarts = [];
  const startSet = new Set();

  // 收集新落下棋子的相鄰空格。
  for (let i = 0; i < coords.length; i++) {
    const idx = this._getIndex(coords[i].x, coords[i].y);

    if (idx < 0) {
      continue;
    }

    const base = idx * 3;

    for (let j = 0; j < 3; j++) {
      const neighborIdx = this.neighbors[base + j];

      if (
        neighborIdx >= 0 &&
        neighborIdx < 1024 &&
        this.grid[neighborIdx] === 0 &&
        !startSet.has(neighborIdx)
      ) {
        startSet.add(neighborIdx);
        emptyStarts.push(neighborIdx);
      }
    }
  }

  if (emptyStarts.length === 0) {
    return false;
  }

  const queue = this.bfsQueue;

  for (let i = 0; i < emptyStarts.length; i++) {
    const startIdx = emptyStarts[i];

    if (this.visited[startIdx] !== 0) {
      continue;
    }

    let head = 0;
    let tail = 0;
    let reachedOutside = false;

    queue[tail++] = startIdx;
    this.visited[startIdx] = 1;

    while (head < tail) {
      const currentIdx = queue[head++];

      // 正確的 32×32 解碼。
      const cy = (currentIdx >>> 5) - 15;
      const cx = (currentIdx & 31) - 15;

      /*
       * 只要空白區域能到達目前棋群包圍矩形以外,
       * 就代表它和外部連通,不是空洞。
       *
       * 不要立即 break,應繼續標記完整連通區,
       * 避免後續起點發生錯誤判斷。
       */
      if (
        cx < this.minX ||
        cx > this.maxX ||
        cy < this.minY ||
        cy > this.maxY
      ) {
        reachedOutside = true;
      }

      const base = currentIdx * 3;

      for (let j = 0; j < 3; j++) {
        const neighborIdx = this.neighbors[base + j];

        if (
          neighborIdx >= 0 &&
          neighborIdx < 1024 &&
          this.grid[neighborIdx] === 0 &&
          this.visited[neighborIdx] === 0
        ) {
          this.visited[neighborIdx] = 1;
          queue[tail++] = neighborIdx;
        }
      }
    }

    if (!reachedOutside) {
      return true;
    }
  }

  return false;
}

四、1024 格版本的共用鄰居表

仍建議讓所有 HexGridGame 實例共用鄰居表。MCTS 會頻繁 clone,如果每次建構都重新產生鄰居表會浪費運算。

替換 constructor 中相關部分

原本:

js
this.grid = new Int32Array(1024);
this.neighbors = new Int32Array(3072);
this.initNeighborsTable();

替換為:

js
this.grid = new Int32Array(1024);
this.neighbors = HexGridGame.getSharedNeighbors();

在類別中加入

js
static getSharedNeighbors() {
  if (HexGridGame._sharedNeighbors) {
    return HexGridGame._sharedNeighbors;
  }

  const table = new Int32Array(1024 * 3);
  table.fill(-1);

  const indexOf = (x, y) => {
    if (x < -15 || x > 16 || y < -15 || y > 16) {
      return -1;
    }

    return ((y + 15) << 5) + (x + 15);
  };

  for (let y = -15; y <= 16; y++) {
    for (let x = -15; x <= 16; x++) {
      const idx = indexOf(x, y);
      const base = idx * 3;
      const isRight = (y & 1) === ((x - 1) & 1);

      table[base] = indexOf(x, y - 1);
      table[base + 1] = indexOf(x, y + 1);
      table[base + 2] = indexOf(
        isRight ? x - 1 : x + 1,
        y
      );
    }
  }

  HexGridGame._sharedNeighbors = table;
  return table;
}

原本的 initNeighborsTable() 可以刪除;或者為了相容其他呼叫,替換為:

js
initNeighborsTable() {
  this.neighbors = HexGridGame.getSharedNeighbors();
}

五、上一版增量搜尋常數改回 1024 格

如果套用上一版增量 frontier、Zobrist hash 和 Worker 修改,將棋盤常數統一替換成:

js
static get SIDE() {
  return 32;
}

static get OFFSET() {
  return 15;
}

static get MIN_COORD() {
  return -15;
}

static get MAX_COORD() {
  return 16;
}

static get CELL_COUNT() {
  return 1024;
}

static coordToIndex(x, y) {
  if (
    !Number.isInteger(x) ||
    !Number.isInteger(y) ||
    x < HexGridGame.MIN_COORD ||
    x > HexGridGame.MAX_COORD ||
    y < HexGridGame.MIN_COORD ||
    y > HexGridGame.MAX_COORD
  ) {
    return -1;
  }

  return (
    (y + HexGridGame.OFFSET) * HexGridGame.SIDE +
    (x + HexGridGame.OFFSET)
  );
}

對應的反向轉換應為:

js
_idxToCoord(idx) {
  if (idx < 0 || idx >= HexGridGame.CELL_COUNT) {
    return null;
  }

  return {
    x: (idx & 31) - HexGridGame.OFFSET,
    y: (idx >>> 5) - HexGridGame.OFFSET
  };
}

以下陣列也應維持 1024:

js
this.grid = new Int32Array(1024);
this.bpLookup = new Array(1024).fill(null);
this.visited = new Uint8Array(1024);
this.bfsQueue = new Uint16Array(1024);
this.frontierCount = new Uint8Array(1024);

鄰居表:

js
new Int32Array(1024 * 3);

Zobrist 表:

js
new Uint32Array(1024 * 3);

六、修正搜尋期間邊界持續擴大的問題

即使只有 1024 格,這個問題仍然存在。

原本 getValidMoves() 測試著法時使用:

js
this.set(p1.x, p1.y, pieceType);
this.set(p2.x, p2.y, pieceType);
this.totalPieces++;

let hasHole =
  this.totalPieces < 5
    ? false
    : this.fastHoleCheck(pieceType, p1, p2);

this.set(p1.x, p1.y, 0);
this.set(p2.x, p2.y, 0);
this.totalPieces--;

set() 會擴大 minX/maxX/minY/maxY,但移除測試棋子時不會縮回。

在未加入 frontier/hash 的版本中,替換為

js
this.grid[p1Idx] = pieceType;
this.grid[p2Idx] = pieceType;
this.totalPieces++;

const hasHole =
  this.totalPieces < 5
    ? false
    : this.fastHoleCheck(pieceType, p1, p2);

this.totalPieces--;
this.grid[p2Idx] = 0;
this.grid[p1Idx] = 0;

因為這只是模擬測試,不應改變真實邊界。

如果已套用上一版 _writeIndex(),則使用:

js
this._writeIndex(p1Idx, pieceType, false);
this._writeIndex(p2Idx, pieceType, false);
this.totalPieces++;

const hasHole =
  this.totalPieces < 5
    ? false
    : this.fastHoleCheck(pieceType, p1, p2);

this.totalPieces--;
this._writeIndex(p2Idx, 0, false);
this._writeIndex(p1Idx, 0, false);

最後一個參數 false 表示不更新棋盤邊界。


七、撤銷落子時也要恢復邊界

tryPlacePiece() 正式落子前保存:

js
const previousBounds = {
  minX: this.minX,
  maxX: this.maxX,
  minY: this.minY,
  maxY: this.maxY
};

發現空洞、清除測試棋子後加入:

js
this.minX = previousBounds.minX;
this.maxX = previousBounds.maxX;
this.minY = previousBounds.minY;
this.maxY = previousBounds.maxY;

原本:

js
this.history.push({ pieceType, p1, p2, bpObj });

替換為:

js
this.history.push({
  pieceType,
  p1,
  p2,
  bpObj,
  previousBounds
});

undo() 清除棋子後加入:

js
if (lastMove.previousBounds) {
  this.minX = lastMove.previousBounds.minX;
  this.maxX = lastMove.previousBounds.maxX;
  this.minY = lastMove.previousBounds.minY;
  this.maxY = lastMove.previousBounds.maxY;
}

八、移動編碼不用修改

目前:

js
(pid << 24) | (p1Idx << 12) | p2Idx

1024 格索引實際只需要 10 bit,但目前預留 12 bit 不會造成問題,因此可以保留,避免同時修改所有編碼和解碼位置。

繼續使用:

js
const pid = (move >>> 24) & 0xff;
const p1Idx = (move >>> 12) & 0xfff;
const p2Idx = move & 0xfff;

雖然 0xfff 可讀出 12 bit,但實際合法值仍應為 0~1023

可以額外加入驗證:

js
if (p1Idx >= 1024 || p2Idx >= 1024) {
  return false;
}

九、Web Worker 和增量搜尋仍應保留

棋盤只有 1024 格,不代表主執行緒不會卡頓。真正耗時的是:

  • MCTS 大量 rollout。
  • 重複產生合法著法。
  • Minimax 遞迴。
  • 每個節點計算分數。
  • clone 和記憶體配置。

所以以下改進不需要撤銷:

  1. Web Worker 後臺運算。
  2. MCTS 分批增量搜尋。
  3. frontier 增量合法著法生成。
  4. Minimax transposition table。
  5. Zobrist hash。
  6. MCTS 局面統計重用。
  7. 模擬落子時使用 withScores = false
  8. 中止 AI 時直接終止 Worker。
  9. 重用模擬棋盤物件,減少垃圾回收。

只要把 Worker 中的 HexGridGame 統一為上述 1024 格版本即可。

最終應保持的尺寸

js
const BOARD_SIDE = 32;
const BOARD_OFFSET = 15;
const BOARD_CELL_COUNT = 1024;
const BOARD_MIN = -15;
const BOARD_MAX = 16;

總結來說,不需要擴大棋盤;應將錯誤的 64×64 空洞解碼改回 32×32,並為 _getIndex() 加入嚴格邊界檢查。這兩項是保留 1024 格時最重要的正確性修復。