QuanAI只读快照

共享会话

改進當前程式的minimax搜尋

分享于 2026年8月19日 00:21
提问者

改進當前程式的minimax搜尋

  1. 節點共享 alpha

當前程式的根節點沒有共享 alpha,這是最直接、最值得先修的效能問題。

目前每個根候選都這樣搜尋:

alphaBetaState(child, depth - 1, -Infinity, Infinity)

也就是說:即使前面已經找到值為 10 的根走法,下一個根走法仍然以完整窗 [-∞, +∞] 搜尋。

這不影響正確性,但浪費了大量剪枝機會。

  1. 上一迭代最佳走法優先(PV ordering)

應使用上一輪的最佳走法作為 Principal Variation 優先走法。

每完成一層後:

將 committedBestMove 放到下一層 rootMoves 最前; 在每個非根節點,可儲存該局面的最佳走法,下一次優先嘗試。

  1. 將「最後單子」改成專用的精確求值

當前程式最後一子仍走一般 minimax 流程:

generateLegalTurns → orderMovesForSearch → analyseSearchMove → applySearchTurnToState → alphaBetaState → evaluateSearchState

其中 orderMovesForSearch() 對每個單子候選會做一次模擬:

const analysis = analyseSearchMove(state, move);

而真正往下遞迴時,又會再做一次:

const child = applySearchTurnToState(state, move, { validate: false }).state;

也就是最後一子的一個候選,可能被「模擬/計分」至少兩次。

現在改成:如果是全局最後一手棋,則加入專用分支。比較所有的本方的「單接」,選擇本方得分最高的(分差最大)。如果不存在本方的單接,則只要存在一個該棋子種類的活法則立即返回。

  1. 消息框應實時顯示當前搜搜尋進度

目前的消息框沒能實時顯示當前搜尋次數等,應實時給出分支數量,已搜尋分支和次數等信息。

回答要求:

不要影響當前正確的遊戲流程,明確給出所有要修改的地方和所要替換的代碼。

trigolden_game2652.htmltext/plain · 223.1 KB预览下载
QuanAI

我會先定位附件中的 minimax/alpha-beta、走法排序、最後一子判定、根迭代與消息框更新程式,再依原有資料結構給出可直接替換的完整代碼。

以下修改只涉及 minimax 搜尋、最後一子精確求值與進度顯示,不改變正式落子、合法性判定、計分、仲裁、MCTS、棋譜及 UI 操作流程。


一、修改 minimax 進度文字

i18n 中找到:

js
'ai-minimax-progress': {
  zh: '極小化極大搜尋 | 深度: {depth} | 用時: {time}s',
  en: 'Minimax | Depth: {depth} | Time: {time}s'
},

替換成:

js
'ai-minimax-progress': {
  zh: '極小化極大搜尋 | 深度: {depth} | 分支: {done}/{total} | 搜尋次數: {count} | 用時: {time}s',
  en: 'Minimax | Depth: {depth} | Branches: {done}/{total} | Searches: {count} | Time: {time}s'
},

這裡的資訊含義是:

  • depth:目前迭代深度
  • done / total:已完成的根分支/根分支總數
  • count:目前搜尋節點次數
  • time:minimax 已使用時間

二、加入 PV 排序、局面鍵及最後一子精確求值函式

找到:

js
function orderMovesForSearch(state, moves) {

保留整個 orderMovesForSearch() 不變。

在它結束後、runStateMinimax() 之前,加入以下完整程式:

js
      /*
       * Minimax 專用穩定走法簽名。
       *
       * 不使用假想棋子的 id、targetId 或 parentId,因為同一個局面在不同
       * iterative-deepening 層次中重新建立時,負數假想 ID 可能不同。
       *
       * 幾何位置相同、棋子種類相同、翻轉狀態相同,即視為同一個搜尋走法。
       */
      function minimaxPieceGeometrySignature(piece) {
        return [
          piece.owner,
          piece.svgId,
          piece.isFlipped ? 1 : 0,
          piece.vertices.map(vertex => vertex.join(',')).join('/')
        ].join('|');
      }

      function minimaxTurnGeometrySignature(move) {
        return move.map(minimaxPieceGeometrySignature).sort().join('||');
      }

      /*
       * 將 preferredMove 或 preferredSignature 所代表的走法移到最前面。
       *
       * 此函式只改變搜尋順序,不刪除任何候選,因此不影響 minimax 正確性。
       */
      function prioritizeSearchMoves(moves, preferredMoveOrSignature) {
        if (!preferredMoveOrSignature || moves.length <= 1) {
          return moves;
        }

        const preferredSignature =
          typeof preferredMoveOrSignature === 'string'
            ? preferredMoveOrSignature
            : minimaxTurnGeometrySignature(preferredMoveOrSignature);

        const preferredIndex = moves.findIndex(
          move => minimaxTurnGeometrySignature(move) === preferredSignature
        );

        if (preferredIndex <= 0) {
          return moves;
        }

        const result = moves.slice();
        const [preferredMove] = result.splice(preferredIndex, 1);
        result.unshift(preferredMove);

        return result;
      }

      /*
       * 建立非根節點的 PV ordering 局面鍵。
       *
       * scoredVictims 原本包含棋子 ID;這裡將 victim ID 轉成棋子幾何簽名,
       * 避免不同迭代中假想負 ID 不同而導致上一輪 PV 無法重用。
       *
       * 這個鍵只用於走法排序提示,不參與局面求值或合法性判定。
       */
      function getMinimaxPVStateKey(state) {
        if (state._minimaxPVStateKey) {
          return state._minimaxPVStateKey;
        }

        const pieceRows = state.boardPieces.map(piece => ({
          id: String(piece.id),
          signature: minimaxPieceGeometrySignature(piece)
        }));

        const idToGeometry = new Map(
          pieceRows.map(row => [row.id, row.signature])
        );

        const boardKey = pieceRows
          .map(row => row.signature)
          .sort()
          .join('##');

        const countsKey = Object.keys(state.counts)
          .sort()
          .map(tileId => tileId + ':' + (state.counts[tileId] || 0))
          .join(',');

        const scoredKey = Array.from(state.scoredVictims)
          .map(rawKey => {
            const key = String(rawKey);
            const separatorIndex = key.indexOf('_');

            if (separatorIndex < 0) {
              return key;
            }

            const victimId = key.slice(0, separatorIndex);
            const victimGeometry = idToGeometry.get(victimId);

            return (victimGeometry || victimId) + key.slice(separatorIndex);
          })
          .sort()
          .join('##');

        state._minimaxPVStateKey = [
          'mover=' + state.mover,
          'score=' + state.scores[1] + ':' + state.scores[2],
          'counts=' + countsKey,
          'board=' + boardKey,
          'scored=' + scoredKey
        ].join('|||');

        return state._minimaxPVStateKey;
      }

      /*
       * 判斷是否為全局最後一手:
       *
       * 1. 盤面不能是空盤,排除先手第一手;
       * 2. 雙方庫存合計只剩一顆;
       * 3. 這一顆屬於目前行動方。
       */
      function isGlobalFinalSingleTurn(state) {
        if (state.boardPieces.length === 0) {
          return false;
        }

        const totalRemaining = Object.values(state.counts).reduce(
          (sum, count) => sum + Math.max(0, count || 0),
          0
        );

        return (
          totalRemaining === 1 &&
          getStateRemainingTotal(state, state.mover) === 1
        );
      }

      /*
       * 全局最後一手的專用精確求值。
       *
       * 不經過:
       *
       * generateLegalTurns
       * -> orderMovesForSearch
       * -> analyseSearchMove
       * -> applySearchTurnToState
       * -> alphaBetaState
       *
       * 而是直接使用持久增量索引內已計算好的:
       *
       * index.singleScoring[player]
       * index.methods
       * method.gain
       *
       * 選擇規則:
       *
       * 1. 比較所有能讓行動方實際得分的單接;
       * 2. 優先選行動方分差 gain[mover] - gain[opponent] 最大者;
       * 3. 分差相同時選行動方得分較高者;
       * 4. 若不存在本方得分的單接,找到該剩餘棋種的第一個活法即返回;
       * 5. 回傳值仍然轉換為 rootPlayer 的視角。
       */
      function solveExactFinalSingleTurn(state, rootPlayer, rootBaseDiff) {
        if (!isGlobalFinalSingleTurn(state)) {
          return null;
        }

        const mover = state.mover;
        const opponent = mover === 1 ? 2 : 1;
        const rootOpponent = rootPlayer === 1 ? 2 : 1;

        const remainingTileId = getPlayerTileIds(mover).find(
          tileId => (state.counts[tileId] || 0) > 0
        );

        if (!remainingTileId) {
          return {
            move: null,
            value: evaluateArbitrationTerminal(
              state,
              rootPlayer,
              rootBaseDiff
            ),
            examinedCount: 0
          };
        }

        const index = ensureStateIncrementalSync(state);

        let examinedCount = 0;
        let bestMethod = null;
        let bestMoverNetGain = -Infinity;
        let bestMoverOwnGain = -Infinity;

        /*
         * 先只掃描單接索引。
         *
         * method.gain 已在建立/更新增量索引時精確計算,
         * 此處不再模擬落子。
         */
        const scoringKeys =
          index.singleScoring[mover] || new Set();

        for (const methodKey of scoringKeys) {
          const method = index.methods.get(methodKey);

          if (!method) continue;
          if (method.ghost.svgId !== remainingTileId) continue;
          if ((state.counts[method.ghost.svgId] || 0) <= 0) continue;

          examinedCount++;

          const moverOwnGain = method.gain[mover] || 0;
          const opponentGain = method.gain[opponent] || 0;

          /*
           * 「本方的單接」必須讓目前行動方實際得到分數。
           * 只替對手得分的不算本方單接,留到下面的活法保底分支。
           */
          if (moverOwnGain <= 0) {
            continue;
          }

          const moverNetGain = moverOwnGain - opponentGain;

          if (
            moverNetGain > bestMoverNetGain ||
            (moverNetGain === bestMoverNetGain &&
              moverOwnGain > bestMoverOwnGain)
          ) {
            bestMethod = method;
            bestMoverNetGain = moverNetGain;
            bestMoverOwnGain = moverOwnGain;
          }
        }

        /*
         * 沒有本方單接時,不再比較所有普通活法。
         * 只要找到該剩餘棋種的一個活法便立即返回。
         */
        if (!bestMethod) {
          const liveMethodKeys = index.byPlayer[mover] || new Set();

          for (const methodKey of liveMethodKeys) {
            const method = index.methods.get(methodKey);

            if (!method) continue;
            if (method.ghost.svgId !== remainingTileId) continue;
            if ((state.counts[method.ghost.svgId] || 0) <= 0) continue;

            examinedCount++;
            bestMethod = method;
            break;
          }
        }

        /*
         * 找不到任何活法,等同目前行動方可仲裁的終局。
         */
        if (!bestMethod) {
          return {
            move: null,
            value: evaluateArbitrationTerminal(
              state,
              rootPlayer,
              rootBaseDiff
            ),
            examinedCount
          };
        }

        const gain = bestMethod.gain || { 1: 0, 2: 0 };

        /*
         * evaluateSearchState(state) 是落子前的 root 分差;
         * 再加上最後一手對 root 分差造成的精確變化。
         */
        const value =
          evaluateSearchState(state, rootPlayer, rootBaseDiff) +
          (gain[rootPlayer] || 0) -
          (gain[rootOpponent] || 0);

        return {
          move: [bestMethod.ghost],
          value,
          examinedCount
        };
      }

三、完整替換 runStateMinimax()

找到目前整個:

js
async function runStateMinimax(rootState, rootMoves, maxDepth, deadline) {
  ...
}

將整個函式完整替換成以下版本:

js
      async function runStateMinimax(
        rootState,
        rootMoves,
        maxDepth,
        deadline
      ) {
        const minimaxStartTime = performance.now();
        const rootPlayer = rootState.mover;
        const opponent = rootPlayer === 1 ? 2 : 1;
        const rootBaseDiff =
          rootState.scores[rootPlayer] - rootState.scores[opponent];

        let searchCount = 0;
        let committedBestMove =
          rootMoves && rootMoves.length > 0 ? rootMoves[0] : null;
        let committedBestValue = 0;

        /*
         * 保存上一個已完成迭代中,各非根局面的最佳走法簽名。
         *
         * 下一個 iterative-deepening 深度重新遇到同一局面時,
         * 會把該走法移到 orderMovesForSearch() 結果的最前面。
         */
        const pvMoveByState = new Map();

        let progressDepth = 0;
        let progressRootDone = 0;
        let progressRootTotal =
          rootMoves && rootMoves.length > 0 ? rootMoves.length : 0;

        let lastMinimaxProgressTime = 0;
        const MINIMAX_PROGRESS_INTERVAL = 100;

        /*
         * 更新消息框並讓出主執行緒。
         *
         * 即使目前仍在同一個大型根分支內,
         * searchCount 也會持續增加並每約 100ms 重繪一次。
         */
        async function publishMinimaxProgress(force = false) {
          const now = performance.now();

          if (
            !force &&
            now - lastMinimaxProgressTime <
              MINIMAX_PROGRESS_INTERVAL
          ) {
            return;
          }

          showMessage(
            t('ai-minimax-progress', {
              depth: progressDepth,
              done: progressRootDone,
              total: progressRootTotal,
              count: searchCount,
              time: (
                (now - minimaxStartTime) /
                1000
              ).toFixed(1)
            }),
            0,
            true
          );

          /*
           * 必須真正讓出事件迴圈,否則 DOM 雖已修改,
           * 瀏覽器仍無法重繪消息框,也不能處理取消 AI 的點擊。
           */
          await new Promise(resolve => setTimeout(resolve, 0));

          lastMinimaxProgressTime = performance.now();
        }

        /*
         * 保險處理:若直接以全局最後一手呼叫本函式,
         * 仍走專用精確分支,不進入一般 alpha-beta。
         */
        const exactRootResult = solveExactFinalSingleTurn(
          rootState,
          rootPlayer,
          rootBaseDiff
        );

        if (exactRootResult) {
          return {
            bestMove: exactRootResult.move,
            value: exactRootResult.value,
            searchCount: Math.max(
              1,
              exactRootResult.examinedCount || 0
            )
          };
        }

        if (!rootMoves || rootMoves.length === 0) {
          return {
            bestMove: null,
            value: evaluateArbitrationTerminal(
              rootState,
              rootPlayer,
              rootBaseDiff
            ),
            searchCount
          };
        }

        async function alphaBetaState(
          state,
          depth,
          alpha,
          beta
        ) {
          searchCount++;

          if (cancelAi || performance.now() >= deadline) {
            throw new SearchTimeout();
          }

          /*
           * 定時更新搜尋次數。
           *
           * alphaBetaState 雖然改為 async,但只有超過節流間隔時
           * 才真正 setTimeout(0),避免每個節點都強制重繪。
           */
          if (
            searchCount === 1 ||
            performance.now() - lastMinimaxProgressTime >=
              MINIMAX_PROGRESS_INTERVAL
          ) {
            await publishMinimaxProgress(false);

            if (cancelAi || performance.now() >= deadline) {
              throw new SearchTimeout();
            }
          }

          /* 棋子全部下完是正常終局。 */
          if (isSearchGameComplete(state)) {
            return evaluateSearchState(
              state,
              rootPlayer,
              rootBaseDiff
            );
          }

          /*
           * 全局最後一手直接精確求值。
           *
           * 必須放在 hasArbitrationDefence() 及 depth 截斷之前,
           * 否則最後一手仍會生成全部合法回合或被當作普通葉節點。
           */
          const exactFinalResult = solveExactFinalSingleTurn(
            state,
            rootPlayer,
            rootBaseDiff
          );

          if (exactFinalResult) {
            if (exactFinalResult.move) {
              pvMoveByState.set(
                getMinimaxPVStateKey(state),
                minimaxTurnGeometrySignature(
                  exactFinalResult.move
                )
              );
            }

            return exactFinalResult.value;
          }

          /*
           * 上一方若沒有留下至少一組共活,
           * 行動方可立即仲裁獲勝。
           */
          if (!hasArbitrationDefence(state)) {
            return evaluateArbitrationTerminal(
              state,
              rootPlayer,
              rootBaseDiff
            );
          }

          if (depth <= 0) {
            return evaluateSearchState(
              state,
              rootPlayer,
              rootBaseDiff
            );
          }

          let legalMoves = generateLegalTurns(state);

          if (legalMoves.length === 0) {
            return evaluateArbitrationTerminal(
              state,
              rootPlayer,
              rootBaseDiff
            );
          }

          const statePVKey = getMinimaxPVStateKey(state);
          const previousPVMoveSignature =
            pvMoveByState.get(statePVKey);

          /*
           * 先執行原本的戰術排序,再把上一輪 PV 最佳走法
           * 移到最前面。
           */
          legalMoves = orderMovesForSearch(
            state,
            legalMoves
          );

          legalMoves = prioritizeSearchMoves(
            legalMoves,
            previousPVMoveSignature
          );

          if (cancelAi || performance.now() >= deadline) {
            throw new SearchTimeout();
          }

          const maximizing = state.mover === rootPlayer;

          if (maximizing) {
            let best = -Infinity;
            let bestMove = null;

            for (const move of legalMoves) {
              const child = applySearchTurnToState(
                state,
                move,
                {
                  validate: false
                }
              ).state;

              const value = await alphaBetaState(
                child,
                depth - 1,
                alpha,
                beta
              );

              if (value > best) {
                best = value;
                bestMove = move;
              }

              alpha = Math.max(alpha, best);

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

            if (bestMove) {
              pvMoveByState.set(
                statePVKey,
                minimaxTurnGeometrySignature(bestMove)
              );
            }

            return best;
          }

          let best = Infinity;
          let bestMove = null;

          for (const move of legalMoves) {
            const child = applySearchTurnToState(
              state,
              move,
              {
                validate: false
              }
            ).state;

            const value = await alphaBetaState(
              child,
              depth - 1,
              alpha,
              beta
            );

            if (value < best) {
              best = value;
              bestMove = move;
            }

            beta = Math.min(beta, best);

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

          if (bestMove) {
            pvMoveByState.set(
              statePVKey,
              minimaxTurnGeometrySignature(bestMove)
            );
          }

          return best;
        }

        /*
         * orderedRootMoves 會在每個完整迭代結束後,
         * 把 committedBestMove 放到最前面。
         */
        let orderedRootMoves = rootMoves.slice();

        for (let depth = 1; depth <= maxDepth; depth++) {
          /*
           * 根節點 PV ordering:
           * 上一層完整搜尋得到的最佳走法優先。
           */
          orderedRootMoves = prioritizeSearchMoves(
            orderedRootMoves,
            committedBestMove
          );

          let iterationBestMove =
            committedBestMove || orderedRootMoves[0];

          let iterationBestValue = -Infinity;
          let iterationCompleted = true;

          /*
           * 根節點共享 alpha。
           *
           * 前一個根候選取得的最佳值會傳給下一個根候選,
           * 不再讓每個候選都使用 [-Infinity, Infinity]。
           */
          let rootAlpha = -Infinity;

          progressDepth = depth;
          progressRootDone = 0;
          progressRootTotal = orderedRootMoves.length;

          await publishMinimaxProgress(true);

          try {
            for (const move of orderedRootMoves) {
              if (
                cancelAi ||
                performance.now() >= deadline
              ) {
                throw new SearchTimeout();
              }

              const child = applySearchTurnToState(
                rootState,
                move,
                {
                  validate: false
                }
              ).state;

              /*
               * 關鍵修改:
               *
               * 原本:
               * alphaBetaState(
               *   child,
               *   depth - 1,
               *   -Infinity,
               *   Infinity
               * )
               *
               * 現在:
               * 所有根候選共享 rootAlpha。
               */
              const value = await alphaBetaState(
                child,
                depth - 1,
                rootAlpha,
                Infinity
              );

              if (value > iterationBestValue) {
                iterationBestValue = value;
                iterationBestMove = move;
              }

              /*
               * 將目前根節點最佳值共享給下一個根候選。
               */
              rootAlpha = Math.max(
                rootAlpha,
                iterationBestValue
              );

              progressRootDone++;

              /*
               * 分支完成後嘗試更新一次。
               * 若距離上次更新不足 100ms,會被節流。
               */
              await publishMinimaxProgress(false);
            }
          } catch (error) {
            if (!(error instanceof SearchTimeout)) {
              throw error;
            }

            iterationCompleted = false;
          }

          /*
           * 只提交完整搜尋完所有根候選的深度。
           * 超時的未完成深度不覆蓋上一個可靠結果。
           */
          if (!iterationCompleted) {
            break;
          }

          committedBestMove = iterationBestMove;
          committedBestValue = iterationBestValue;

          /*
           * 將本層最佳走法放到下一層根候選最前面。
           */
          orderedRootMoves = prioritizeSearchMoves(
            orderedRootMoves,
            committedBestMove
          );

          await publishMinimaxProgress(true);
        }

        return {
          bestMove: committedBestMove,
          value: committedBestValue,
          searchCount
        };
      }

這個替換完成了三件事

1. 根節點共享 alpha

原本:

js
const value = alphaBetaState(
  child,
  depth - 1,
  -Infinity,
  Infinity
);

現在:

js
const value = await alphaBetaState(
  child,
  depth - 1,
  rootAlpha,
  Infinity
);

每個根候選完成後:

js
rootAlpha = Math.max(
  rootAlpha,
  iterationBestValue
);

2. 根節點與非根節點 PV ordering

根節點:

js
orderedRootMoves = prioritizeSearchMoves(
  orderedRootMoves,
  committedBestMove
);

非根節點:

js
const previousPVMoveSignature =
  pvMoveByState.get(statePVKey);

legalMoves = orderMovesForSearch(state, legalMoves);

legalMoves = prioritizeSearchMoves(
  legalMoves,
  previousPVMoveSignature
);

3. 搜尋過程定時讓出主執行緒

alphaBetaState() 改為非同步,每約 100ms:

js
await publishMinimaxProgress(false);

因此消息框可以在同一個大型根分支尚未完成時,繼續顯示增加中的搜尋次數。


四、替換 startAI() 中建立根候選及選擇搜尋器的部分

startAI() 中,找到從:

js
const arbitrationDefences = generateArbitrationDefenceTurns(rootState);

開始,一直到原本這一整段結束:

js
if (useMinimax) {
  ...
} else {
  ...
}

也就是在:

js
searchCount = result.searchCount || 0;

之前的整段程式。

完整替換成:

js
        const turnsLeft = getRemainingTurnCount(rootState);

        const useMinimax =
          turnsLeft <= ENDGAME_MINIMAX_TURNS;

        let rootMoves = [];
        let result;

        /*
         * 全局最後一手直接使用專用精確求值。
         *
         * 此分支不呼叫:
         *
         * generateArbitrationDefenceTurns
         * generateLegalTurns
         * orderMovesForSearch
         * analyseSearchMove
         * applySearchTurnToState
         * runStateMinimax
         *
         * 因此不會再對最後一子的每個候選重複模擬。
         */
        if (isGlobalFinalSingleTurn(rootState)) {
          const rootOpponent =
            aiPlayer === 1 ? 2 : 1;

          const rootBaseDiff =
            rootState.scores[aiPlayer] -
            rootState.scores[rootOpponent];

          const exactFinalResult =
            solveExactFinalSingleTurn(
              rootState,
              aiPlayer,
              rootBaseDiff
            );

          if (
            !exactFinalResult ||
            !exactFinalResult.move
          ) {
            aiThinking = false;
            aiTriggerArbitrationSuccess();
            return;
          }

          rootMoves = [exactFinalResult.move];

          result = {
            bestMove: exactFinalResult.move,
            value: exactFinalResult.value,
            searchCount: Math.max(
              1,
              exactFinalResult.examinedCount || 0
            )
          };

          showMessage(
            t('ai-minimax-progress', {
              depth: 1,
              done: 1,
              total: 1,
              count: result.searchCount,
              time: (
                (performance.now() - totalStartTime) /
                1000
              ).toFixed(1)
            }),
            0,
            true
          );

          /*
           * 讓最後一手的精確搜尋結果也能實際顯示,
           * 並讓取消事件有機會被處理。
           */
          await new Promise(resolve =>
            setTimeout(resolve, 0)
          );

          if (cancelAi) {
            aiThinking = false;
            return;
          }
        } else {
          /*
           * 非最後一手維持原本仲裁流程。
           */
          const arbitrationDefences =
            generateArbitrationDefenceTurns(rootState);

          if (
            !isSearchGameComplete(rootState) &&
            arbitrationDefences.length === 0
          ) {
            aiThinking = false;
            aiTriggerArbitrationSuccess();
            return;
          }

          rootMoves = generateLegalTurns(rootState);

          if (rootMoves.length === 0) {
            aiThinking = false;
            aiTriggerArbitrationSuccess();
            return;
          }

          if (cancelAi) {
            aiThinking = false;
            return;
          }

          rootMoves = shuffledCopy(rootMoves);

          /*
           * 保留原本的戰術排序。
           * minimax 進入下一層迭代後,再由 runStateMinimax()
           * 把上一輪 PV 最佳走法移到最前面。
           */
          rootMoves = orderMovesForSearch(
            rootState,
            rootMoves
          );

          if (cancelAi) {
            aiThinking = false;
            return;
          }

          const searchStartTime = performance.now();
          const searchDeadline =
            searchStartTime + timeLimit;

          if (useMinimax) {
            result = await runStateMinimax(
              rootState,
              rootMoves,
              turnsLeft,
              searchDeadline
            );
          } else {
            result = await runStateMCTS(
              rootState,
              rootMoves,
              searchDeadline,
              MCTS_C
            );
          }
        }

後面的原程式保持不變,從以下這一行繼續:

js
searchCount = result.searchCount || 0;

五、替換舊的 placeAISingleMove()

目前這個函式雖然沒有直接被新的 startAI() 最後一手流程呼叫,但它仍然保留了舊的「逐一 simulateScoreGain()」實作。

為避免將來其他入口呼叫它時又走回舊流程,建議完整替換。

找到:

js
function placeAISingleMove(player) {
  ...
}

替換成:

js
      /*
       * 最後一子相容入口。
       *
       * startAI() 現在會直接呼叫 solveExactFinalSingleTurn(),
       * 此函式保留給其它可能的舊入口使用。
       */
      function placeAISingleMove(player) {
        const state =
          createSearchStateFromGlobals(player);

        if (!isGlobalFinalSingleTurn(state)) {
          return false;
        }

        const opponent =
          player === 1 ? 2 : 1;

        const rootBaseDiff =
          state.scores[player] -
          state.scores[opponent];

        const exactResult =
          solveExactFinalSingleTurn(
            state,
            player,
            rootBaseDiff
          );

        if (!exactResult || !exactResult.move) {
          return false;
        }

        const source = exactResult.move[0];

        const piece =
          JSON.parse(JSON.stringify(source));

        piece.id = nextPieceId++;

        piecesCount[piece.svgId]--;
        tempPieces = [piece];

        updateUI();
        renderBoard();

        setTimeout(() => {
          actionCheck();
        }, 600);

        return true;
      }

六、修改後的搜尋流程

流程图
正在绘制流程图…

七、正確性及遊戲流程影響

這些修改不會改變以下流程:

  • validateTurnOnState() 正式合法性判定
  • applySearchTurnToState() 正式計分
  • actionCheck() 正式落子
  • 仲裁只承認共活的規則
  • MCTS 的選擇、展開及回傳
  • 人類落子流程
  • 棋譜匯入及匯出
  • undo/redo
  • committedSearchState 的持久增量索引
  • 最後實際落子仍會重新經過正式合法性與計分流程

根節點共享 alpha 只會讓「已經不可能超越目前最佳根走法」的候選更早剪枝,不會改變最佳走法。

PV ordering 也只改變候選嘗試順序,不會刪除候選,不會影響 minimax 結果。

最後一子則直接使用增量索引中已經由 adjudicateSearchTurn() 計算並持續維護的 method.gain,避免原本的:

text
排序時模擬一次
→ 遞迴前再套用一次
→ 葉節點再評估

同時根節點及非根節點遇到全局最後一手時,都會進入同一個專用精確分支。