QuanAI只读快照

共享会话

🎮 AI對戰修復改進

分享于 2026年8月18日 09:12
提问者

修復與改進 當我兩方都用AI來對戰時,發現一方會直接下出使對方得分的「單連」,或者使對方下回合就能立即得分。檢查代碼,程式是否會弄錯這一手棋應給哪一方加分。

同時,我發現AI控制的一方,第一種棋子會用得最快,第二種棋子次之,第三種棋子總是最後剩下。說明AI在搜尋時,都是以第一到第二再到第三種棋子為順序進行搜尋和排序的。現在要把這個次序打亂,使每種棋子的使用機率相等。

MCTS 前的全單接/全雙接戰術搜索:easy,hard和expert都統一用 2 ply,並且不限定時間,一定要全部搜尋完成才開始MCTS。

把所有的AI等級的minimax都統一改為「最後三回合」,即後手方的最後兩回合或先手方的最後一回合。當到了minimax階段,將不再需要全單接/全雙接戰術搜索2ply,直接用minimax搜尋涵蓋。去掉AI設置中的「搜尋深度」。

回答要求:

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

trigolden_game2649.htmltext/plain · 230.6 KB预览下载
QuanAI

以下修改可直接套用到 trigolden_game2649.html。核心遊戲規則、正式落子、仲裁、棋譜與計分流程不需要重寫。

一、問題判定

1. 正式計分歸屬沒有顛倒

目前 adjudicateSearchTurn() 中:

js
if (state.scoringMode === 0) {
  scorer = best.dinger.owner;
} else if (state.scoringMode === 1) {
  scorer = best.victim.owner;
}

與規則一致:

  • SCORING_MODE === 0:分數給頂棋方 dinger.owner
  • SCORING_MODE === 1:分數給鑫棋方 victim.owner

正式落子和 AI 模擬也都使用同一個 adjudicateSearchTurn(),因此不是「實際把分數加錯玩家」。

真正問題有兩個:

  1. analyseSearchMove() 只要發現單接/雙接,就會增加排序分數,沒有判斷分數到底給己方還是對方。
  2. 原本的 2/3 ply 戰術預搜尋只搜尋「本手已經形成單接/雙接」的根候選;普通候選如果會讓對手下一手立即得分,根本不會進入 2 ply 比較。

因此 AI 可能:

  • 把「送分給對方的單接」當成戰術好棋;
  • 沒有檢查普通落子是否放出對方下一手的單接/雙接。

下面的修改會同時修正。


二、移除 AI 設定中的「搜尋深度」

修改 1:刪除 HTML 中的搜尋深度設定列

在 AI 設定對話框內,刪除整段:

html
<div class="ai-param-row">
  <div class="ai-param-title" id="ai-param-title-n">搜尋深度(回合數)</div>
  <div class="ai-param-values">
    <input type="number" id="ai-n-2" value="3" step="1" oninput="handleAIParamInput(2)" />
    <input type="number" id="ai-n-1" value="6" step="1" oninput="handleAIParamInput(1)" />
  </div>
</div>

修改 2:刪除對應 i18n 項目

刪除:

js
'ai-param-title-n': { zh: '搜尋深度(回合數)', en: 'Search Depth (Turns)' },

保留:

js
'ai-param-title-time': { zh: '限時 (秒)', en: 'Time Limit (s)' },

三、修改 AI 設定資料,只保留時間

找到:

js
const MCTS_C = 1.414;
const CUSTOM_AI_DEFAULT = { time: 5, n: 4 };
let aiConfig = {
  1: false,
  2: true,
  type: { 1: 'human', 2: 'easy' },
  lastAIType: { 1: 'easy', 2: 'easy' }, // 新增:記憶上一次/預設使用的 AI 級別
  params: {
    easy: { time: 3, n: 3 },
    hard: { time: 7, n: 4 },
    expert: { time: 12, n: 5 },
    custom: { ...CUSTOM_AI_DEFAULT }
  },
  settings: {
    1: { time: 18, n: 6 },
    2: { time: 3, n: 3 }
  }
};

整段替換為:

js
const MCTS_C = 1.414;

/*
 * 所有 AI 統一:
 * 1. MCTS 前的戰術預搜尋固定為 2 ply。
 * 2. 最後三個完整回合改用 minimax。
 */
const TACTICAL_PRESEARCH_PLY = 2;
const ENDGAME_MINIMAX_TURNS = 3;

const CUSTOM_AI_DEFAULT = { time: 5 };

let aiConfig = {
  1: false,
  2: true,
  type: { 1: 'human', 2: 'easy' },
  lastAIType: { 1: 'easy', 2: 'easy' },

  /*
   * AI 等級只再決定正式搜尋時間。
   * 戰術預搜尋深度和 minimax 啟動時機不再由設定控制。
   */
  params: {
    easy: { time: 3 },
    hard: { time: 7 },
    expert: { time: 12 },
    custom: { ...CUSTOM_AI_DEFAULT }
  },

  settings: {
    1: { time: 18 },
    2: { time: 3 }
  }
};

四、替換 AI 設定相關函式

修改 3:替換 updateAIConfig()

整個函式替換為:

js
function updateAIConfig() {
  [1, 2].forEach(p => {
    const type = document.getElementById('ai-p' + p + '-type').value;

    aiConfig.type[p] = type;
    aiConfig[p] = type !== 'human';

    if (type !== 'human') {
      aiConfig.lastAIType[p] = type;
    }

    if (type === 'easy' || type === 'hard' || type === 'expert') {
      const preset = aiConfig.params[type];
      document.getElementById('ai-time-' + p).value = preset.time;
    }

    const time =
      parseFloat(document.getElementById('ai-time-' + p).value) ||
      CUSTOM_AI_DEFAULT.time;

    aiConfig.settings[p] = { time };

    if (type === 'custom') {
      aiConfig.params.custom = { time };
    }
  });

  updateUI();
  checkAndTriggerAI();
}

修改 4:替換 detectAIPresetType()

js
function detectAIPresetType(p) {
  const time = parseFloat(document.getElementById('ai-time-' + p).value);

  for (const key of ['easy', 'hard', 'expert']) {
    const preset = aiConfig.params[key];

    if (time === preset.time) {
      return key;
    }
  }

  return 'custom';
}

修改 5:替換 handleAITypeSelect()

js
function handleAITypeSelect(p) {
  const type = document.getElementById('ai-p' + p + '-type').value;

  if (type === 'easy' || type === 'hard' || type === 'expert') {
    const preset = aiConfig.params[type];

    document.getElementById('ai-time-' + p).value = preset.time;
    aiConfig.params.custom = { ...CUSTOM_AI_DEFAULT };
  } else if (type === 'custom') {
    document.getElementById('ai-time-' + p).value =
      aiConfig.params.custom.time;
  } else {
    aiConfig.params.custom = { ...CUSTOM_AI_DEFAULT };
  }

  updateAIParamInputsState(p);
}

修改 6:替換 updateAIParamInputsState()

js
// 人類控制的一方不需要 AI 限時,將輸入框清空並停用。
function updateAIParamInputsState(p) {
  const type = document.getElementById('ai-p' + p + '-type').value;
  const isHuman = type === 'human';
  const timeInput = document.getElementById('ai-time-' + p);

  if (!timeInput) return;

  timeInput.disabled = isHuman;

  if (isHuman) {
    timeInput.value = '';
  }
}

修改 7:替換 syncAIDialogFromConfirmed()

js
function syncAIDialogFromConfirmed() {
  [1, 2].forEach(p => {
    const val = aiConfig.type[p] || 'human';

    document.getElementById('ai-p' + p + '-type').value = val;

    document
      .querySelectorAll(`#ai-list-${p} .ai-type-option`)
      .forEach(el => {
        el.classList.toggle(
          'selected',
          el.getAttribute('data-val') === val
        );
      });

    updateAISelectedDisplay(p);

    document.getElementById('ai-time-' + p).value =
      aiConfig.settings[p].time;

    updateAIParamInputsState(p);

    const dropdown = document.getElementById('ai-dropdown-' + p);

    if (dropdown) {
      dropdown.classList.remove('open');
    }
  });
}

handleAIParamInput() 可以保留,因為它會自動使用修改後的 detectAIPresetType()

修改完成後,程式中不應再有以下內容:

text
ai-n-1
ai-n-2
ai-param-title-n
CUSTOM_AI_DEFAULT.n
aiConfig.settings[p].n
configuredDepth

五、明確集中計分方判定

雖然原本計分方沒有寫反,但建議集中成一個函式,避免 AI 模擬和正式計分將來出現不同判定。

adjudicateSearchTurn() 前面加入:

js
/*
 * scoringMode:
 * 0 = 分數給頂棋方
 * 1 = 分數給鑫棋方
 * 2 = 舊相容模式,只計同方頂鑫
 */
function getTriGoldenScorer(scoringMode, dinger, victim) {
  if (scoringMode === 0) {
    return dinger.owner;
  }

  if (scoringMode === 1) {
    return victim.owner;
  }

  if (scoringMode === 2 && dinger.owner === victim.owner) {
    return dinger.owner;
  }

  return 0;
}

然後在 adjudicateSearchTurn() 中找到:

js
let scorer = 0;

if (state.scoringMode === 0) {
  scorer = best.dinger.owner;
} else if (state.scoringMode === 1) {
  scorer = best.victim.owner;
} else if (state.scoringMode === 2 && best.dinger.owner === best.victim.owner) {
  scorer = best.dinger.owner;
}

替換為:

js
const scorer = getTriGoldenScorer(
  state.scoringMode,
  best.dinger,
  best.victim
);

這項修改不改變現有正確計分結果,只把規則集中化。


六、打亂三種棋子的搜尋優先順序

搜尋結果有明確優劣時,仍應選擇較好的棋;不能為了表面上的 1/3 機率而故意選差棋。

要修正的是:

  • 同評分候選不再固定按照第一、第二、第三種棋;
  • 每次排序時,三種棋成為第一優先種類的機率各為 1/3
  • 同種類、同評分的具體落點也隨機打亂。

修改 8:在 getPlayerTileIds() 後加入洗牌函式

找到:

js
function getPlayerTileIds(player) {
  return [0, 1, 2].map(i => 'tile' + (player === 1 ? i : i + 3));
}

在它後面加入:

js
/*
 * Fisher-Yates 洗牌。
 * 不使用 array.sort(() => Math.random() - 0.5),避免不均勻洗牌。
 */
function shuffledCopy(items) {
  const result = items.slice();

  for (let i = result.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [result[i], result[j]] = [result[j], result[i]];
  }

  return result;
}

修改 9:打亂建立活法索引時的棋種順序

enumerateEdgeMethodsIncremental() 中找到:

js
const tileIds = getPlayerTileIds(forPlayer);

替換為:

js
const tileIds = shuffledCopy(getPlayerTileIds(forPlayer));

修改 10:打亂合法單子結果

generateLegalSinglePlacements() 最後找到:

js
return result;

替換為:

js
return shuffledCopy(result);

修改 11:打亂共活回合結果

generateAllCoLiveTurns() 最後找到:

js
return moves;

替換為:

js
return shuffledCopy(moves);

修改 12:打亂完整合法回合結果

generateLegalTurns() 最後找到:

js
return result;

替換為:

js
return shuffledCopy(result);

注意只替換上述三個指定函式結尾的 return,不要全域取代所有 return result


七、修復「送分單接被當成好棋」

修改 13:替換 analyseSearchMove()

整個函式替換為:

js
function analyseSearchMove(state, move) {
  const mover = state.mover;
  const opponent = mover === 1 ? 2 : 1;

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

  /*
   * 必須看淨得分,而不能只看有沒有形成頂鑫。
   *
   * 正數:本手對行動方有利。
   * 負數:本手實際是在送分給對方。
   */
  const moverGain =
    applied.adjudication.gain[mover] -
    applied.adjudication.gain[opponent];

  let singleJieCount = 0;

  for (const piece of move) {
    const singleAdjudication = adjudicateSearchTurn(state, [piece]);

    if (singleAdjudication.hasScoringStructure) {
      singleJieCount++;
    }
  }

  const isDoubleJie =
    move.length === 2 &&
    formsTriGolden(move[0], move[1]) &&
    applied.adjudication.hasScoringStructure;

  return {
    childState: applied.state,
    adjudication: applied.adjudication,
    moverGain,
    singleJieCount,
    isDoubleJie,

    /*
     * 戰術搜索仍應涵蓋所有單接/雙接,
     * 包括送分棋,讓 minimax 能把它判成壞棋;
     * 但排序時不再因「存在結構」而一律加分。
     */
    isTactical:
      applied.adjudication.hasScoringStructure ||
      singleJieCount > 0 ||
      isDoubleJie
  };
}

修改 14:替換 orderMovesForSearch()

原本這兩項是錯誤來源:

js
analysis.singleJieCount * 15
analysis.isDoubleJie ? 25 : 0

它們沒有分辨得分方。

將整個 orderMovesForSearch() 替換為:

js
/*
 * 只排序、不刪除候選。
 *
 * 第一排序條件:2 ply 戰術結果。
 * 第二排序條件:本回合實際淨得分。
 * 第三排序條件:每次重新洗牌的棋種優先權。
 * 第四排序條件:隨機值。
 */
function orderMovesForSearch(state, moves, tacticalScores = null) {
  /*
   * 每次排序都重新洗牌三種棋。
   * 因此三種棋取得最高同分優先權的機率各為 1/3。
   */
  const tilePriority = shuffledCopy(getPlayerTileIds(state.mover));
  const tileRank = new Map(
    tilePriority.map((tileId, index) => [tileId, index])
  );

  return moves
    .map((move, originalIndex) => {
      const signature = turnSignature(move);
      const analysis = analyseSearchMove(state, move);

      const tacticalScore =
        tacticalScores && tacticalScores.has(signature)
          ? tacticalScores.get(signature)
          : null;

      /*
       * 不再因為「有單接/雙接」一律增加正分。
       * 如果該結構把分數送給對手,moverGain 會是負數。
       */
      let orderScore = analysis.moverGain * 100;

      if (tacticalScore !== null) {
        const moverPerspective =
          state.mover === tacticalScores.rootPlayer
            ? tacticalScore
            : -tacticalScore;

        /*
         * 完整 2 ply 戰術結果優先於單純的即時得分。
         */
        orderScore += moverPerspective * 1000;
      }

      const usedTileRanks = [
        ...new Set(move.map(piece => piece.svgId))
      ].map(tileId =>
        tileRank.has(tileId)
          ? tileRank.get(tileId)
          : Number.MAX_SAFE_INTEGER
      );

      const moveTypeRank =
        usedTileRanks.length > 0
          ? Math.min(...usedTileRanks)
          : Number.MAX_SAFE_INTEGER;

      return {
        move,
        orderScore,
        moveTypeRank,
        randomTie: Math.random(),
        originalIndex
      };
    })
    .sort(
      (a, b) =>
        b.orderScore - a.orderScore ||
        a.moveTypeRank - b.moveTypeRank ||
        a.randomTie - b.randomTie ||
        a.originalIndex - b.originalIndex
    )
    .map(item => item.move);
}

這樣:

  • 己方得 2 分、對方得 0 分:moverGain = +2
  • 己方得 0 分、對方得 2 分:moverGain = -2
  • 雙方各得 1 分:moverGain = 0

不會再把「送分單接」視為額外正面排序條件。


八、統一為完整、不限時的 2 ply 戰術預搜尋

目前 runTacticalPresearch() 有兩個問題:

  1. 有獨立截止時間,超時後退回不完整結果;
  2. 只搜尋本手已經是戰術棋的根候選,無法檢查普通候選是否放出對方立即得分。

修改 15:替換 tacticalAlphaBeta()

整個函式替換為:

js
/*
 * 戰術搜索只延伸單接/雙接。
 *
 * 不設 deadline,不會因時間不足返回部分結果。
 * alpha-beta 的剪枝仍是完整的數學搜尋結果,
 * 並不是超時或隨機省略候選。
 */
function tacticalAlphaBeta(
  state,
  depth,
  alpha,
  beta,
  rootPlayer,
  rootBaseDiff
) {
  /*
   * 取消 AI 是使用者主動中止,不屬於戰術搜索限時。
   * 正常未取消時一定完整完成。
   */
  if (cancelAi) {
    return evaluateSearchState(state, rootPlayer, rootBaseDiff);
  }

  if (isSearchGameComplete(state)) {
    return evaluateSearchState(state, rootPlayer, rootBaseDiff);
  }

  /*
   * 即使還有單接/雙接,只要沒有共活,
   * 行動方仍可依現有規則仲裁獲勝。
   */
  if (!hasArbitrationDefence(state)) {
    return evaluateArbitrationTerminal(
      state,
      rootPlayer,
      rootBaseDiff
    );
  }

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

  const legalMoves = generateLegalTurns(state);

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

  const tacticalChildren = [];

  for (const move of legalMoves) {
    const analysis = analyseSearchMove(state, move);

    if (analysis.isTactical) {
      tacticalChildren.push({
        move,
        analysis,
        randomTie: Math.random()
      });
    }
  }

  /*
   * 下一方沒有任何單接/雙接時,
   * 這條戰術線就在目前分數結束。
   */
  if (tacticalChildren.length === 0) {
    return evaluateSearchState(state, rootPlayer, rootBaseDiff);
  }

  /*
   * moverGain 永遠是當前行動方視角。
   * 不論目前是根玩家還是對手,先搜尋當前方最有利的戰術棋,
   * 可提高 alpha-beta 剪枝效率,但不改變完整結果。
   */
  tacticalChildren.sort(
    (a, b) =>
      b.analysis.moverGain - a.analysis.moverGain ||
      a.randomTie - b.randomTie
  );

  const maximizing = state.mover === rootPlayer;

  if (maximizing) {
    let best = -Infinity;

    for (const child of tacticalChildren) {
      const value = tacticalAlphaBeta(
        child.analysis.childState,
        depth - 1,
        alpha,
        beta,
        rootPlayer,
        rootBaseDiff
      );

      best = Math.max(best, value);
      alpha = Math.max(alpha, best);

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

    return best;
  }

  let best = Infinity;

  for (const child of tacticalChildren) {
    const value = tacticalAlphaBeta(
      child.analysis.childState,
      depth - 1,
      alpha,
      beta,
      rootPlayer,
      rootBaseDiff
    );

    best = Math.min(best, value);
    beta = Math.min(beta, best);

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

  return best;
}

修改 16:替換 runTacticalPresearch()

整個函式替換為:

js
/*
 * 對每一個根候選進行完整 2 ply 戰術搜索。
 *
 * 重要:
 * 根候選本身不必是單接/雙接。
 * 這樣才能檢查普通落子是否讓對手下一手立即單接/雙接得分。
 */
async function runTacticalPresearch(
  rootState,
  rootMoves,
  targetDepth
) {
  const rootPlayer = rootState.mover;
  const opponent = rootPlayer === 1 ? 2 : 1;

  const rootBaseDiff =
    rootState.scores[rootPlayer] -
    rootState.scores[opponent];

  const completedScores = new Map();
  completedScores.rootPlayer = rootPlayer;

  for (let i = 0; i < rootMoves.length; i++) {
    if (cancelAi) {
      return null;
    }

    const move = rootMoves[i];
    const analysis = analyseSearchMove(rootState, move);

    /*
     * targetDepth = 2:
     * 第一個 ply 是根玩家的本手;
     * 第二個 ply 是對手所有單接/雙接回應。
     */
    const value = tacticalAlphaBeta(
      analysis.childState,
      targetDepth - 1,
      -Infinity,
      Infinity,
      rootPlayer,
      rootBaseDiff
    );

    completedScores.set(turnSignature(move), value);

    await aiMaybeYield(
      t('ai-thinking-candidates', {
        phase:
          `${targetDepth} ply 全單接/全雙接戰術搜索 ` +
          `(${i + 1}/${rootMoves.length})`
      })
    );
  }

  /*
   * 不存在超時 fallback。
   * 未被使用者取消時,所有根候選都已完成搜索。
   */
  return completedScores;
}

此後可以刪除舊函式中的:

js
deadline
fallbackScores
tacticalRoots
performance.now() >= deadline
catch (error)
return fallbackScores

九、MCTS 不再使用設定中的搜尋深度

搜尋深度設定移除後,MCTS 不需要再被人工指定 maxDepth。棋局本身是有限且無循環的,因此可以讓每條 MCTS 路徑自然向終局延伸;仍由時間限制控制搜尋次數。

修改 17:修改 runStateMCTS() 函式宣告

找到:

js
async function runStateMCTS(rootState, rootMoves, maxDepth, deadline, tacticalScores, cValue) {

替換為:

js
async function runStateMCTS(
  rootState,
  rootMoves,
  deadline,
  tacticalScores,
  cValue
) {

修改 18:移除 MCTS 內的深度限制

在 MCTS 主迴圈內找到:

js
let node = root;
let depth = 0;

while (depth < maxDepth) {

替換為:

js
let node = root;

while (true) {

然後刪除該迴圈內兩處:

js
depth++;

一處在建立新 child 後:

js
node = child;
path.push(node);
depth++;
break;

改為:

js
node = child;
path.push(node);
break;

另一處在選擇既有 child 後:

js
node = selected;
path.push(node);
depth++;

改為:

js
node = selected;
path.push(node);

因為每一層都會實際減少剩餘棋子,棋局不存在循環,所以 while (true) 最終會在以下任一情況退出:

  • 正常完局;
  • 仲裁終局;
  • 沒有合法回合;
  • 本次展開一個新 child。

不會形成無限迴圈。


十、所有 AI 統一在最後三回合使用 minimax

修改 19:修改 runStateMinimax() 宣告

原本最後一個參數沒有實際使用。

找到:

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

替換為:

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

其餘函式內容可保持不變。

修改 20:加入剩餘回合計算函式

getRequiredPiecesForState() 後面加入:

js
/*
 * 計算從目前局面開始還剩多少個完整回合。
 *
 * 一般每回合使用兩子,整局最後一回合使用一子,
 * 因此剩餘回合數為 ceil(剩餘總棋子數 / 2)。
 *
 * 剩餘 5 子時:
 * 後手 2 子、先手 2 子、後手最後 1 子,共三回合。
 */
function getRemainingTurnCount(state) {
  const totalPiecesRemaining = Object.values(state.counts).reduce(
    (sum, count) => sum + Math.max(0, count),
    0
  );

  return Math.ceil(totalPiecesRemaining / 2);
}

這正好對應:

  • 倒數第 3 回合:後手方倒數第 2 回合;
  • 倒數第 2 回合:先手方最後 1 回合;
  • 倒數第 1 回合:後手方最後 1 回合。

也就是使用者要求的「後手方最後兩回合或先手方最後一回合」。


十一、替換完整 startAI()

這是最重要的一項。它會確保:

  • minimax 階段直接進 minimax,不執行戰術預搜尋;
  • MCTS 階段固定先完整完成 2 ply 戰術搜尋;
  • 戰術搜尋不受時間限制;
  • 完成後 MCTS 才開始計算自己的完整限時;
  • 所有 AI 都在最後三回合切換 minimax;
  • 不再讀取任何搜尋深度設定。

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

js
async function startAI() {
  aiThinking = true;
  cancelAi = false;
  updateUI();

  showMessage(t('ai-thinking-ellipsis'), 0, true);

  const aiPlayer = currentPlayer;

  /*
   * 開局中心棋仍保留原有的隨機種類、翻面及角度。
   */
  if (
    turnNumber === 1 &&
    pieces.length === 0 &&
    tempPieces.length === 0
  ) {
    placeAIFirstMove(aiPlayer);
    aiThinking = false;

    document.getElementById('message-box').style.display = 'none';
    return;
  }

  /*
   * AI 設定只保留正式 MCTS/minimax 的時間限制。
   * 戰術預搜尋不使用這個 deadline。
   */
  const timeLimit =
    Math.max(0.2, aiConfig.settings[aiPlayer].time || 3) * 1000;

  /*
   * 最後顯示的總用時,包含:
   * 增量索引、候選生成、戰術預搜尋及正式搜尋。
   */
  const totalStartTime = performance.now();

  let searchCount = 0;
  lastAIYieldTime = 0;

  await aiYield(t('ai-thinking-step1'));

  const rootState = createSearchStateFromGlobals(aiPlayer);

  await ensureStateIncremental(rootState);

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

  /*
   * 仲裁判定只看共活。
   */
  const arbitrationDefences =
    generateArbitrationDefenceTurns(rootState);

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

  /*
   * 正式搜索仍使用所有合法回合。
   */
  let rootMoves = generateLegalTurns(rootState);

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

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

  /*
   * 先打亂原始候選,消除持久索引建立順序造成的棋種偏差。
   */
  rootMoves = shuffledCopy(rootMoves);

  const turnsLeft = getRemainingTurnCount(rootState);

  /*
   * 所有 AI 統一只在最後三個完整回合進入 minimax。
   */
  const useMinimax =
    turnsLeft <= ENDGAME_MINIMAX_TURNS;

  let result;

  if (useMinimax) {
    /*
     * 已進入最後三回合:
     * 不再執行全單接/全雙接戰術預搜尋,
     * 直接由完整 minimax 涵蓋所有合法分支。
     */
    rootMoves = orderMovesForSearch(rootState, rootMoves);

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

    result = await runStateMinimax(
      rootState,
      rootMoves,
      turnsLeft,
      searchDeadline
    );
  } else {
    /*
     * 尚未進入 minimax:
     * easy、hard、expert、custom 全部統一先做完整 2 ply
     * 全單接/全雙接戰術搜尋。
     *
     * 此階段不設時間上限。
     */
    await aiYield(
      t('ai-thinking-candidates', {
        phase:
          `${TACTICAL_PRESEARCH_PLY} ply ` +
          '全單接/全雙接戰術搜索'
      })
    );

    const tacticalScores = await runTacticalPresearch(
      rootState,
      rootMoves,
      TACTICAL_PRESEARCH_PLY
    );

    if (cancelAi || !tacticalScores) {
      aiThinking = false;
      return;
    }

    /*
     * 所有根候選均完成 2 ply 戰術評估後才排序。
     * 不刪除候選。
     */
    rootMoves = orderMovesForSearch(
      rootState,
      rootMoves,
      tacticalScores
    );

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

    /*
     * 到這裡才開始 MCTS 的完整限時。
     * 戰術預搜尋耗時不會侵蝕 MCTS 時間。
     */
    const searchStartTime = performance.now();
    const searchDeadline = searchStartTime + timeLimit;

    result = await runStateMCTS(
      rootState,
      rootMoves,
      searchDeadline,
      tacticalScores,
      MCTS_C
    );
  }

  searchCount = result.searchCount || 0;

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

  const bestMove = result.bestMove || rootMoves[0];

  if (!bestMove) {
    aiThinking = false;
    aiTriggerArbitrationSuccess();
    return;
  }

  const totalTime = (
    (performance.now() - totalStartTime) /
    1000
  ).toFixed(1);

  const valueText = useMinimax
    ? t('ai-score-label', {
        score: Number.isFinite(result.value)
          ? result.value
          : 0
      })
    : t('ai-winrate-label', {
        rate: (
          Math.max(
            0,
            Math.min(1, result.value || 0)
          ) * 100
        ).toFixed(1)
      });

  showMessage(
    t('ai-done', {
      time: totalTime,
      count: searchCount,
      val: valueText
    }),
    3000,
    true
  );

  /*
   * 搜尋棋轉成正式暫存棋。
   * actionCheck() 仍會使用統一合法性和正式計分引擎再次驗證,
   * 因此不會繞過現有遊戲流程。
   */
  const actualMove = bestMove.map(source => {
    const piece = JSON.parse(JSON.stringify(source));

    piece.id = nextPieceId++;
    piecesCount[piece.svgId]--;

    return piece;
  });

  tempPieces = actualMove;

  updateUI();
  renderBoard();

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

  aiThinking = false;
}

十二、修改後的實際搜尋流程

流程图
正在绘制流程图…

十三、建議驗證項目

修改後至少測試以下情況:

  1. 頂棋計分模式

    • 新棋頂到舊棋時,確認分數加給新棋/頂棋所屬方。
    • 舊棋頂到新棋時,確認分數加給舊棋/頂棋所屬方。
  2. 鑫棋計分模式

    • 確認分數永遠加給被頂邊的 victim.owner
  3. 送分單接

    • 建立一個 AI 下單接只會讓對方得分的局面。
    • 該候選的 moverGain 應為負數,不再得到單接排序獎勵。
  4. 下一手立即得分

    • 建立兩個根候選:
      • 候選 A 本手不計分,但會讓對手下一手單接得分;
      • 候選 B 本手不計分,對手下一手沒有得分戰術。
    • 2 ply 戰術分數應判定 B 優於 A。
  5. minimax 啟動時機

    • 剩餘 7 子、4 回合時:使用 MCTS。
    • 剩餘 5 子、3 回合時:切換 minimax。
    • 這三回合依次為後手、先手、後手。
  6. 棋種順序

    • 同評分局面多次重新開始 AI 搜尋。
    • 三類棋不應再固定以 tile0/tile3tile1/tile4tile2/tile5 的順序展開。
    • 搜尋結果有明確策略優劣時,使用率不必硬性各占三分之一;只有同評分優先權才應均等,否則會損害 AI 正確性。

以上修改不改變正式 actionCheck()validateTurnOnState()applySearchTurnToState()、仲裁和棋譜流程;主要修正 AI 對計分結果的理解、候選排序、2 ply 覆蓋範圍、搜尋時機和棋種順序偏差。