QuanAI只读快照

共享会话

修復與改進

分享于 2026年8月15日 05:47
提问者

修復與改進 一,程式會卡在「候選構建,共活硬性規則過濾」上

檢查代碼邏輯,當AI思考時,為何會卡住在候選構建上,整個思考過程是否都用到增量運算,以及5軸SAT,比如快速判斷「兩個棋子之間是否會發生重疊、邊重合、端點重合」等。

檢查整個思考過程還有哪裡是降低效能的。

當AI在思考時應盡量把它所思考的東西在消息框中展示出來。

二,檢查邏輯,AI是否明白對方上回合必須保證有一個棋子能合規落下兩個己方棋子

即使本方這回合可以「單接」後下一手落在其它地方,但對方在上回合結束時仍需保證本方這回合可以有至少一種方式能落兩個己方棋子到同一個對方棋子的兩個不同的邊上。

回答要求:

基於當前正確的邏輯進行完美替換,不要影響到當前正確的流程,明確給出所要修改同地方和所要替換的代碼。

trigolden_game251.htmltext/plain · 227.3 KB预览下载
QuanAI

結論:卡住的主因與規則漏洞

1. 為何會卡在「候選構建/共活硬性規則過濾」

目前不是所有 AI 運算都是真正增量式。

雖然你已經有:

  • edgeRecords
  • methodRegistry
  • incrementalAddPiece
  • 5 軸 SAT 快速排除
  • wouldOpponentRetainCoLive

但在最重的硬性過濾中,仍會落回:

js
getAllValidMoves(oppPlayer)

getAllValidMoves() 會:

  1. 枚舉所有對方棋子 × 4 邊 × 3 種棋 × 正反面 × 4 自己貼邊;
  2. 對每個候選呼叫 isValidGhost()
  3. 對所有合法單子做兩兩配對;
  4. 跨目標時反覆呼叫 simulateScoreGain()
  5. simulateScoreGain() 又掃描整盤棋、計算頂鑫與樹距離。

因此原本「過濾每一個候選」的流程,可能變成:

候選數量 × 全盤合法下法構建 × 全盤計分模擬

這就是卡死在:

AI 思考中:候選建構/共活硬性規則過濾

的根本原因。


2. 5 軸 SAT 現況

目前程式確實已使用 5 軸 SAT:

js
checkSATCollision(getSAT(A), getSAT(B))

用途正確:

判斷項目現有方法
面重疊快速排除5 軸 SAT
邊重合segmentsOverlapAsEdges()
完全貼邊segmentsPerfectlyMatch()
頂點重合pointsEqual()
頂點頂到邊中段/頂鑫pointOnOpenSegment()

這個方向是正確的:SAT 負責快速排除,精確代數座標負責最終規則判定。

但目前這些檢查被分散在:

  • isValidGhost
  • twoGhostsCompatible
  • ghostConflictsWithPiece
  • incrementalAddPiece
  • getAllValidMoves

造成重複掃描與邏輯成本偏高。


3. 「上一回合必須保證兩子同目標不同邊」目前確實有漏洞

目前下列地方錯誤地使用:

js
getAllValidMoves(player)

來判斷對方是否仍有合規下法:

  • startAI()
  • executeArbitration()
  • aiTriggerArbitrationSuccess()
  • hasLegalContinuation()
  • moveLeavesOpponentArbitrationTrap()

getAllValidMoves() 包含了「頂鑫例外」的跨目標下法。

所以會出現這個錯誤局面:

對方上一回合沒有保證你能把兩子落在同一個對方棋子的兩條不同邊。
但你剛好仍能用「單接/頂鑫例外」把第二子落在別處。
現有程式便錯誤認為對方已履行規則 5。

這不符合你寫在規則中的要求。

正確設計必須分開:

功能應使用的下法集合
本回合正常下法與頂鑫例外getAllValidMoves()
判斷上一回合是否履行「兩邊保證」只允許同一目標棋子、不同邊的兩子組合
仲裁只檢查兩邊保證
AI 避免送對方仲裁勝只檢查兩邊保證

下面的修改會保留目前的:

  • 人類手動下子流程;
  • actionCheck() 中的頂鑫例外;
  • 同目標兩子下法;
  • AI 的單接/互頂/頂鑫候選邏輯。

只修正「仲裁、保證規則、AI 硬性過濾」所使用的判定基準。


必須修改 1:新增「兩邊保證」專用合法下法函式

位置

找到:

js
function enumerateEdgeMethods(piece, edgeIdx, allPieces) {

將整個函式替換為以下版本。

js
function enumerateEdgeMethods(piece, edgeIdx, allPieces, extraHypPieces = []) {
  let methods = [];
  let opp = piece.owner === 1 ? 2 : 1;
  let { availTypes } = getMoveInventory(opp, extraHypPieces);

  for (let tt of availTypes) {
    for (let flip of [false, true]) {
      for (let j = 0; j < 4; j++) {
        let res = attachByEdge(
          SHAPE_MAP[tt],
          flip,
          j,
          piece.vertices[edgeIdx],
          piece.vertices[(edgeIdx + 1) % 4],
          !!piece.isFlipped
        );

        if (!res) continue;

        let ghost = {
          id: -100000 - nextMethodId - methods.length,
          vertices: res.vertices,
          type: SHAPE_MAP[tt],
          owner: opp,
          svgId: tt,
          isFlipped: flip,
          myEdge: j,
          edgeOnOpp: edgeIdx,
          targetEdge: edgeIdx,
          targetId: piece.id,
          parentId: piece.id,
          level: (piece.level || 0) + 1
        };

        if (isValidGhost(ghost, allPieces)) {
          methods.push(ghost);
        }
      }
    }
  }

  return methods;
}

位置

getAllValidMoves(player) 前面插入以下完整區塊。

js
function getMoveInventory(player, extraHypPieces = []) {
  const myTiles = [0, 1, 2].map(x => 'tile' + (player === 1 ? x : x + 3));
  const extra = extraHypPieces || [];
  const counts = {};

  myTiles.forEach(tile => {
    const staged = tempPieces.filter(p => p && p.svgId === tile).length;
    const usedInHypothesis = extra.filter(
      p => p && p.owner === player && p.svgId === tile
    ).length;

    counts[tile] = Math.max(0, (piecesCount[tile] || 0) + staged - usedInHypothesis);
  });

  return {
    myTiles,
    counts,
    availTypes: myTiles.filter(tile => counts[tile] > 0)
  };
}

function ghostPlacementKey(g) {
  let key = [
    g.owner,
    g.svgId,
    g.targetId,
    g.targetEdge !== undefined ? g.targetEdge : g.edgeOnOpp,
    g.myEdge,
    g.isFlipped ? 1 : 0
  ];

  for (let v of g.vertices) {
    key.push(v[0], v[1], v[2], v[3]);
  }

  return key.join(',');
}

function pairPlacementKey(g1, g2) {
  let a = ghostPlacementKey(g1);
  let b = ghostPlacementKey(g2);
  return a < b ? a + '||' + b : b + '||' + a;
}

function canUseGhostPair(g1, g2, counts) {
  if (!g1 || !g2) return false;
  return g1.svgId !== g2.svgId || counts[g1.svgId] >= 2;
}

function getAllSingleMovesForBoard(player, options = {}) {
  const existingPieces = options.existingPieces || pieces;
  const extraHypPieces = options.extraHypPieces || [];
  const results = [];
  const seen = new Set();

  for (let opp of existingPieces) {
    if (opp.owner === player) continue;

    for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
      let edgeMoves = enumerateEdgeMethods(opp, edgeIdx, existingPieces, extraHypPieces);

      for (let g of edgeMoves) {
        let key = ghostPlacementKey(g);
        if (seen.has(key)) continue;
        seen.add(key);
        results.push(g);
      }
    }
  }

  return results;
}

/*
  規則 5 的權威集合:
  - 兩子必須貼在「同一個對方棋子」
  - 必須是「兩條不同邊」
  - 不包含頂鑫例外的跨目標下法
*/
function getAllSameTargetMoves(player, options = {}) {
  const existingPieces = options.existingPieces || pieces;
  const extraHypPieces = options.extraHypPieces || [];
  const { counts } = getMoveInventory(player, extraHypPieces);
  const maxMoves = Number.isFinite(options.maxMoves) ? options.maxMoves : Infinity;

  let validPairs = [];

  for (let opp of existingPieces) {
    if (opp.owner === player) continue;

    let targetGhosts = [];
    let seenGhosts = new Set();

    for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
      let edgeMoves = enumerateEdgeMethods(opp, edgeIdx, existingPieces, extraHypPieces);

      for (let g of edgeMoves) {
        let sig = ghostPlacementKey(g);
        if (seenGhosts.has(sig)) continue;
        seenGhosts.add(sig);

        for (let old of targetGhosts) {
          if (old.targetEdge === g.targetEdge) continue;
          if (!canUseGhostPair(old, g, counts)) continue;
          if (!twoGhostsCompatible(old, g)) continue;

          validPairs.push([old, g, opp.id]);

          if (validPairs.length >= maxMoves) {
            return validPairs;
          }
        }

        targetGhosts.push(g);
      }
    }
  }

  return validPairs;
}

/* 只找第一組;給硬性規則過濾與 Minimax 使用,避免建立整個陣列。 */
function findAnySameTargetMove(player, options = {}) {
  let moves = getAllSameTargetMoves(player, {
    ...options,
    maxMoves: 1
  });

  return moves.length > 0 ? moves[0] : null;
}

function findAnySingleMove(player, options = {}) {
  const existingPieces = options.existingPieces || pieces;
  const extraHypPieces = options.extraHypPieces || [];

  for (let opp of existingPieces) {
    if (opp.owner === player) continue;

    for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
      let edgeMoves = enumerateEdgeMethods(opp, edgeIdx, existingPieces, extraHypPieces);
      if (edgeMoves.length > 0) return edgeMoves[0];
    }
  }

  return null;
}

function getRequiredPieceCount(player, extraHypPieces = []) {
  const { counts } = getMoveInventory(player, extraHypPieces);
  const remaining = Object.values(counts).reduce((sum, n) => sum + n, 0);

  if (remaining <= 0) return 0;

  // 開局第一手是特殊單子。
  if (pieces.length === 0 && (!extraHypPieces || extraHypPieces.length === 0)) {
    return 1;
  }

  // 最後一子是特殊單子。
  return remaining === 1 ? 1 : 2;
}

/*
  仲裁與「上一回合兩邊保證」專用。
  若本方尚有兩子,絕不接受跨目標頂鑫例外來替代規則 5 的保證。
*/
function getAllRequiredTurnMoves(player, options = {}) {
  const extraHypPieces = options.extraHypPieces || [];
  const required = getRequiredPieceCount(player, extraHypPieces);

  if (required === 1) {
    return getAllSingleMovesForBoard(player, options).map(g => [g]);
  }

  if (required === 2) {
    return getAllSameTargetMoves(player, options);
  }

  return [];
}

function hasRequiredTurnGuarantee(player, options = {}) {
  const extraHypPieces = options.extraHypPieces || [];
  const required = getRequiredPieceCount(player, extraHypPieces);

  if (required === 0) return true;

  if (required === 1) {
    return !!findAnySingleMove(player, options);
  }

  return !!findAnySameTargetMove(player, options);
}

必須修改 2:取代原本的 getAllValidSingleMoves

位置

找到原本很長的:

js
function getAllValidSingleMoves(player) {

將整個函式替換為:

js
function getAllValidSingleMoves(player, options = {}) {
  return getAllSingleMovesForBoard(player, options);
}

必須修改 3:替換 getAllValidMoves,修正假設局面庫存與重複計分

位置

找到:

js
function getAllValidMoves(player) {

將整個函式替換為:

js
function getAllValidMoves(player, options = {}) {
  const existingPieces = options.existingPieces || pieces;
  const extraHypPieces = options.extraHypPieces || [];
  const maxPairs = Number.isFinite(options.maxPairs) ? options.maxPairs : Infinity;

  let validPairs = [];
  let { counts, availTypes } = getMoveInventory(player, extraHypPieces);
  let oppPieces = existingPieces.filter(p => p.owner !== player);

  let validGhosts = [];
  let seenGhosts = new Set();

  for (let opp of oppPieces) {
    for (let t of availTypes) {
      for (let flip of [false, true]) {
        for (let i = 0; i < 4; i++) {
          for (let j = 0; j < 4; j++) {
            let res = attachByEdge(
              SHAPE_MAP[t],
              flip,
              j,
              opp.vertices[i],
              opp.vertices[(i + 1) % 4],
              !!opp.isFlipped
            );

            if (!res) continue;

            let gp = {
              vertices: res.vertices,
              type: SHAPE_MAP[t],
              owner: player,
              svgId: t,
              isFlipped: flip,
              edgeOnOpp: i,
              targetId: opp.id,
              myEdge: j,
              targetEdge: i,
              parentId: opp.id,
              level: (opp.level !== undefined ? opp.level : 0) + 1
            };

            if (!isValidGhost(gp, existingPieces)) continue;

            let sig = ghostPlacementKey(gp);
            if (seenGhosts.has(sig)) continue;

            seenGhosts.add(sig);
            validGhosts.push(gp);
          }
        }
      }
    }
  }

  /*
    原本程式在每一個跨目標配對中,
    都反覆對同一顆 g1 / g2 呼叫 simulateScoreGain。
    改為每顆單子最多算一次。
  */
  let singleScoreCache = new Map();

  function ghostScoresForPlayer(g) {
    let key = ghostPlacementKey(g);

    if (!singleScoreCache.has(key)) {
      singleScoreCache.set(key, simulateScoreGain([g])[player] > 0);
    }

    return singleScoreCache.get(key);
  }

  for (let i = 0; i < validGhosts.length; i++) {
    for (let j = i + 1; j < validGhosts.length; j++) {
      let g1 = validGhosts[i];
      let g2 = validGhosts[j];

      if (!canUseGhostPair(g1, g2, counts)) continue;
      if (!twoGhostsCompatible(g1, g2)) continue;

      if (g1.targetId === g2.targetId) {
        if (g1.edgeOnOpp !== g2.edgeOnOpp) {
          validPairs.push([g1, g2, g1.targetId]);

          if (validPairs.length >= maxPairs) {
            return validPairs;
          }
        }

        continue;
      }

      /*
        保留原本 AI 的頂鑫例外判斷語義:
        - 任一單子能為本方得分;
        - 或兩個新子彼此形成頂鑫。
      */
      let g1Scores = ghostScoresForPlayer(g1);
      let g2Scores = ghostScoresForPlayer(g2);

      if (g1Scores || g2Scores || formsTriGolden(g1, g2)) {
        validPairs.push([g1, g2, g1.targetId]);

        if (validPairs.length >= maxPairs) {
          return validPairs;
        }
      }
    }
  }

  return validPairs;
}

這個替換保留目前的頂鑫例外流程,但修正兩項問題:

  1. 搜尋假設局面時,會扣除路徑上已使用的棋子庫存;
  2. 同一個單子不會被跨目標配對重複執行大量 simulateScoreGain()

必須修改 4:AI 根節點改用增量活法索引建立候選

位置

在:

js
function getMutualDingPairs(forPlayer) {

函式結束後,插入以下函式:

js
function getRegistrySameTargetMoves(player) {
  if (!liveTrackingReady) return [];

  const { counts } = getMoveInventory(player);
  const moves = [];
  const seen = new Set();

  for (let [id, m] of methodRegistry) {
    if (m.forPlayer !== player) continue;

    for (let [otherId] of m.coLive) {
      if (id >= otherId) continue;

      let other = methodRegistry.get(otherId);
      if (!other || other.forPlayer !== player) continue;
      if (m.ownerEdgePieceId !== other.ownerEdgePieceId) continue;
      if (m.edgeIdx === other.edgeIdx) continue;
      if (!canUseGhostPair(m.ghost, other.ghost, counts)) continue;
      if (!twoGhostsCompatible(m.ghost, other.ghost)) continue;

      let key = pairPlacementKey(m.ghost, other.ghost);
      if (seen.has(key)) continue;

      seen.add(key);
      moves.push([m.ghost, other.ghost, m.ownerEdgePieceId]);
    }
  }

  return moves;
}

/*
  根節點保留:
  1. 所有規則 5 的正常兩子同目標走法;
  2. 可為 AI 自己得分的單接跨目標例外;
  3. 可為 AI 自己得分的互頂跨目標例外。

  不再把所有單子做 G² 暴力配對。
*/
function getRegistryAIMovePool(player, baselineMoves) {
  const { counts } = getMoveInventory(player);
  const result = [];
  const seen = new Set();

  function addMove(g1, g2, targetId) {
    if (!g1 || !g2) return;
    if (!canUseGhostPair(g1, g2, counts)) return;
    if (!twoGhostsCompatible(g1, g2)) return;

    let key = pairPlacementKey(g1, g2);
    if (seen.has(key)) return;

    seen.add(key);
    result.push([g1, g2, targetId]);
  }

  baselineMoves.forEach(m => addMove(m[0], m[1], m[2]));

  let methods = [];
  methodRegistry.forEach(m => {
    if (m.forPlayer === player) methods.push(m);
  });

  /*
    頂鑫例外一:
    第一子本身能為 AI 得分,第二子可落到其他目標。
  */
  let ownScoringSingles = methods.filter(m => m.isSingleJie && m.jieScore > 0);

  for (let first of ownScoringSingles) {
    for (let second of methods) {
      if (first.id === second.id) continue;
      if (first.ghost.targetId === second.ghost.targetId) continue;

      addMove(first.ghost, second.ghost, first.ghost.targetId);
    }
  }

  /*
    頂鑫例外二:
    兩顆候選子互頂,且這個互頂可為 AI 得分。
  */
  getMutualDingPairs(player).forEach(pair => {
    if (pair.a.ghost.targetId === pair.b.ghost.targetId) return;

    addMove(pair.a.ghost, pair.b.ghost, pair.a.ghost.targetId);
  });

  return result;
}

位置

startAI() 裡,找到這段:

js
let startTime = performance.now();
let searchCount = 0;
lastAIYieldTime = 0;
await aiYield(t('ai-thinking-step1'));

// ===== 步驟一:判斷是否至少有一個對方棋子能夠合規落下兩個己方棋子 =====
let sameTargetMoves = getAllValidMoves(currentPlayer);

直到後面這段:

js
let oppPlayerNum = currentPlayer === 1 ? 2 : 1;
let sameGhostPlacement = (g1, g2) =>

之間的舊程式碼,替換為:

js
let startTime = performance.now();
let searchCount = 0;
lastAIYieldTime = 0;

/*
  先顯示狀態,再等待上一手的增量死活統計完成。
  這樣 UI 有機會先重繪,不會看起來像卡死。
*/
await aiYield(t('ai-thinking-lifestatus'));
await lifeTrackingUpdatePromise;

if (!liveTrackingReady) {
  await aiYield(t('ai-thinking-lifestatus'));
  computeAllLifeStatus();
}

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

/*
  這裡只能檢查規則 5 的「兩邊保證」。
  絕對不能用 getAllValidMoves(),
  因為該函式包含頂鑫例外跨目標走法。
*/
await aiYield(t('ai-thinking-guarantee'));

let sameTargetMoves = getRegistrySameTargetMoves(aiPlayer);

/*
  Registry 理論上已完成;這個精確 fallback 是保險,
  用於 import / undo / 非同步更新剛好被中止的情況。
*/
if (sameTargetMoves.length === 0) {
  sameTargetMoves = getAllSameTargetMoves(aiPlayer);
}

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

/*
  正常兩子同目標走法一定保留;
  再補入對 AI 有實際價值的頂鑫例外走法。
*/
let validMoves = getRegistryAIMovePool(aiPlayer, sameTargetMoves);

if (validMoves.length === 0) {
  validMoves = sameTargetMoves;
}

await aiYield(
  t('ai-thinking-candidates', {
    phase:
      currentLang === 'zh'
        ? `增量候選完成:正常 ${sameTargetMoves.length} 種,總候選 ${validMoves.length} 種`
        : `incremental pool ready: baseline ${sameTargetMoves.length}, total ${validMoves.length}`
  })
);

let bestMove = validMoves[0];
let expectedValue = 0;

let oppPlayerNum = currentPlayer === 1 ? 2 : 1;
let sameGhostPlacement = (g1, g2) =>

並且刪除原本後面重複出現的:

js
await lifeTrackingUpdatePromise;
if (!liveTrackingReady) {
  await aiYield(t('ai-thinking-lifestatus'));
  computeAllLifeStatus();
}

那一段,避免重複等待。


必須修改 5:修正「共活硬性規則過濾」的權威判定

先新增訊息文字

i18n 裡、'ai-thinking-candidates' 後面插入:

js
'ai-thinking-guarantee': {
  zh: 'AI 思考中:檢查上一回合是否保證本方可在同一對方棋子的兩條不同邊落下兩子…',
  en: 'AI thinking: checking the previous turn’s two-distinct-edges guarantee…'
},
'ai-thinking-colive-filter': {
  zh: 'AI 思考中:共活硬性規則過濾 {done}/{total}|保留 {kept}|既有共活 {fast}|新邊共活 {fresh}|精確驗證 {exact}',
  en: 'AI thinking: co-live filter {done}/{total} | kept {kept} | existing {fast} | new-edge {fresh} | exact {exact}'
},

位置

在死活追蹤變數附近:

js
let edgeRecords = new Map();
let methodRegistry = new Map();

後面新增:

js
let opponentGuaranteeCache = new Map();

function clearOpponentGuaranteeCache() {
  opponentGuaranteeCache.clear();
}

然後在 resetLifeTracking() 裡加入:

js
clearOpponentGuaranteeCache();

並在 actionCheck() 裡,找到:

js
pieces.push(...tempPieces);
lastTurnPieces = [...tempPieces];

中間加入:

js
pieces.push(...tempPieces);
clearOpponentGuaranteeCache();
lastTurnPieces = [...tempPieces];

位置

將以下四個函式整體替換:

  • wouldOpponentRetainCoLive
  • pieceHasFreshCoLive
  • moveLeavesOpponentArbitrationTrap
  • filterMovesKeepingOpponentAlive

替換為:

js
function wouldOpponentRetainCoLive(g1, g2, oppPlayer, extraHypPieces = []) {
  let hyp = extraHypPieces.concat([g1, g2]);
  let { counts } = getMoveInventory(oppPlayer, hyp);

  for (let [, m] of methodRegistry) {
    if (m.forPlayer !== oppPlayer || m.coLive.size === 0) continue;
    if (!survivesHypotheticalPieces(m.ghost, hyp)) continue;

    for (let [otherId] of m.coLive) {
      let other = methodRegistry.get(otherId);

      if (!other) continue;
      if (!canUseGhostPair(m.ghost, other.ghost, counts)) continue;
      if (!survivesHypotheticalPieces(other.ghost, hyp)) continue;

      return true;
    }
  }

  return false;
}

function pieceHasFreshCoLive(piece, allPieces, extraHypPieces = []) {
  let opp = piece.owner === 1 ? 2 : 1;
  let { counts } = getMoveInventory(opp, extraHypPieces);
  let seen = new Set();
  let ghosts = [];

  for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
    let edgeMoves = enumerateEdgeMethods(piece, edgeIdx, allPieces, extraHypPieces);

    for (let g of edgeMoves) {
      let sig = ghostPlacementKey(g);
      if (seen.has(sig)) continue;
      seen.add(sig);

      for (let old of ghosts) {
        if (old.targetEdge === g.targetEdge) continue;
        if (!canUseGhostPair(old, g, counts)) continue;
        if (twoGhostsCompatible(old, g)) return true;
      }

      ghosts.push(g);
    }
  }

  return false;
}

function opponentGuaranteeCacheKey(g1, g2, oppPlayer, extraHypPieces) {
  let pair = [ghostPlacementKey(g1), ghostPlacementKey(g2)].sort().join('||');
  let extra = (extraHypPieces || [])
    .map(ghostPlacementKey)
    .sort()
    .join('||');

  return oppPlayer + '::' + pair + '::' + extra;
}

/*
  true = 此手會讓對手在規則 5 下可仲裁獲勝。
  注意:頂鑫跨目標例外不能作為「兩邊保證」的替代。
*/
function probeOpponentGuaranteeAfterMove(g1, g2, oppPlayer, realBoardPieces, extraHypPieces = []) {
  let key = opponentGuaranteeCacheKey(g1, g2, oppPlayer, extraHypPieces);
  let cached = opponentGuaranteeCache.get(key);

  if (cached) {
    return { ...cached, cached: true };
  }

  let allHyp = extraHypPieces.concat([g1, g2]);
  let required = getRequiredPieceCount(oppPlayer, allHyp);
  let result;

  // 棋局已結束,無下一回合需要保證。
  if (required === 0) {
    result = { trap: false, source: 'terminal' };
    opponentGuaranteeCache.set(key, result);
    return result;
  }

  // 後手最後一子只需驗證是否有一個合法單子。
  if (required === 1) {
    let hasSingle = withHypotheticalPieces(allHyp, () =>
      hasRequiredTurnGuarantee(oppPlayer, { extraHypPieces: allHyp })
    );

    result = {
      trap: !hasSingle,
      source: 'exact'
    };

    opponentGuaranteeCache.set(key, result);
    return result;
  }

  /*
    快速路徑 1:
    現有 registry 裡已有一組對方共活,且落下 g1/g2 後仍存活。
  */
  if (wouldOpponentRetainCoLive(g1, g2, oppPlayer, extraHypPieces)) {
    result = { trap: false, source: 'existing' };
    opponentGuaranteeCache.set(key, result);
    return result;
  }

  /*
    快速路徑 2:
    新下的 g1 或 g2 本身可為對手提供全新的兩邊共活。
  */
  let afterPieces = realBoardPieces.concat(allHyp);

  if (
    pieceHasFreshCoLive(g1, afterPieces, allHyp) ||
    pieceHasFreshCoLive(g2, afterPieces, allHyp)
  ) {
    result = { trap: false, source: 'fresh' };
    opponentGuaranteeCache.set(key, result);
    return result;
  }

  /*
    最終精確 fallback:
    只檢查規則 5 的同目標、不同邊兩子組合;
    不再錯誤呼叫包含頂鑫例外的 getAllValidMoves()。
  */
  let hasGuaranteedTurn = withHypotheticalPieces(allHyp, () =>
    hasRequiredTurnGuarantee(oppPlayer, { extraHypPieces: allHyp })
  );

  result = {
    trap: !hasGuaranteedTurn,
    source: 'exact'
  };

  opponentGuaranteeCache.set(key, result);
  return result;
}

function moveLeavesOpponentArbitrationTrap(g1, g2, oppPlayer, realBoardPieces, extraHypPieces = []) {
  return probeOpponentGuaranteeAfterMove(
    g1,
    g2,
    oppPlayer,
    realBoardPieces,
    extraHypPieces
  ).trap;
}

function filterMovesKeepingOpponentAlive(moves, oppPlayer, realBoardPieces, extraHypPieces = []) {
  if (!moves || moves.length === 0) return [];

  let kept = [];

  for (let mv of moves) {
    let probe = probeOpponentGuaranteeAfterMove(
      mv[0],
      mv[1],
      oppPlayer,
      realBoardPieces,
      extraHypPieces
    );

    if (!probe.trap) kept.push(mv);
  }

  // 所有走法都會讓對方仲裁勝時,維持原流程:仍須選一手落子。
  return kept.length > 0 ? kept : moves;
}

/*
  根節點專用非同步版本:
  每隔約 80ms 讓出主執行緒,讓訊息框與取消 AI 按鈕真正可見、可回應。
*/
async function filterMovesKeepingOpponentAliveWithProgress(
  moves,
  oppPlayer,
  realBoardPieces,
  extraHypPieces = []
) {
  if (!moves || moves.length === 0) return [];

  let kept = [];
  let stats = {
    existing: 0,
    fresh: 0,
    exact: 0
  };

  await aiYield(
    t('ai-thinking-colive-filter', {
      done: 0,
      total: moves.length,
      kept: 0,
      fast: 0,
      fresh: 0,
      exact: 0
    })
  );

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

    let mv = moves[i];

    let probe = probeOpponentGuaranteeAfterMove(
      mv[0],
      mv[1],
      oppPlayer,
      realBoardPieces,
      extraHypPieces
    );

    if (!probe.trap) kept.push(mv);

    if (probe.source === 'existing') stats.existing++;
    else if (probe.source === 'fresh') stats.fresh++;
    else if (probe.source === 'exact') stats.exact++;

    await aiMaybeYield(
      t('ai-thinking-colive-filter', {
        done: i + 1,
        total: moves.length,
        kept: kept.length,
        fast: stats.existing,
        fresh: stats.fresh,
        exact: stats.exact
      }),
      80
    );
  }

  return kept.length > 0 ? kept : moves;
}

注意:這裡的 await 都在 withHypotheticalPieces() 執行完、全域 pieces 已還原後才發生。
不要在 withHypotheticalPieces(candidatePieces, fn)fn 內部加入 await


位置

startAI() 內,將所有「根節點」的:

js
candidateMoves = filterMovesKeepingOpponentAlive(candidateMoves, oppPlayerNum, pieces);

替換為:

js
candidateMoves = await filterMovesKeepingOpponentAliveWithProgress(
  candidateMoves,
  oppPlayerNum,
  pieces
);

只替換 startAI() 裡的呼叫。

不要替換 buildCandidateMovesForNode() 裡的同步版本;那是 Minimax/MCTS 內部節點,不能直接加 await

另外,在 startAI() 的:

js
let bestScore = -Infinity;

前面加入:

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

if (!candidateMoves || candidateMoves.length === 0) {
  candidateMoves = validMoves;
}

必須修改 6:仲裁與 AI 仲裁改用「兩邊保證」集合

位置 1:hasLegalContinuation

找到:

js
function hasLegalContinuation(candidatePieces, forPlayer) {

替換為:

js
function hasLegalContinuation(candidatePieces, forPlayer) {
  return withHypotheticalPieces(candidatePieces, () =>
    hasRequiredTurnGuarantee(forPlayer, {
      extraHypPieces: candidatePieces
    })
  );
}

位置 2:aiTriggerArbitrationSuccess

找到:

js
arbValidMoves = getAllValidMoves(currentPlayer);

替換為:

js
arbValidMoves = getAllRequiredTurnMoves(currentPlayer);

位置 3:executeArbitration

找到:

js
arbValidMoves = getAllValidMoves(currentPlayer);

替換為:

js
arbValidMoves = getAllRequiredTurnMoves(currentPlayer);

這是最重要的規則修正之一。

現在即使玩家存在「跨目標頂鑫例外」走法,只要不存在:

同一個對方棋子的兩條不同邊,各能合法落下一子

仲裁仍會正確判定上一回合違規。


位置 4:applyArbMove

原本只支援兩子仲裁預覽。替換為以下版本,順便正確支援最後一手單子:

js
function applyArbMove(index) {
  let move = arbValidMoves[index];
  if (!move || move.length === 0) return;

  tempPieces.forEach(tp => {
    piecesCount[tp.svgId]++;
  });

  let previewPieces = move.map((g, idx) => {
    let copy = JSON.parse(JSON.stringify(g));
    copy.id = 'p_arb_' + (idx + 1);
    return copy;
  });

  previewPieces.forEach(g => {
    piecesCount[g.svgId]--;
  });

  tempPieces = previewPieces;

  renderBoard();
  updateUI();
}

必須修改 7:搜尋假設局面時扣除已使用棋子

位置

在:

js
function buildCandidateMovesForNode(mover, extraHypPieces) {

找到:

js
let validMoves = withHypotheticalPieces(extraHypPieces, () => getAllValidMoves(mover));

替換為:

js
const SEARCH_NODE_PAIR_LIMIT = 96;

let validMoves = withHypotheticalPieces(extraHypPieces, () =>
  getAllValidMoves(mover, {
    extraHypPieces,
    maxPairs: SEARCH_NODE_PAIR_LIMIT
  })
);

這個修改修正了原本 Minimax/MCTS 的問題:

路徑上已假設使用過某種棋子,但 piecesCount 沒有扣除,AI 會把已不存在的棋又拿來下。

96 只限制 AI 搜尋樹的內部展開量;不影響人類合法下法、仲裁與 AI 根節點真正可選的走法。


位置

在:

js
function collectLiveGhostsForPlayer(forPlayer, extraHypPieces) {

找到:

js
enumerateEdgeMethods(p, e, boardPieces).forEach(g => ghosts.push(g));

替換為:

js
enumerateEdgeMethods(p, e, boardPieces, extraHypPieces).forEach(g => ghosts.push(g));

必須修改 8:isValidGhost() 減少重複 SAT 與重複掃描

位置

將整個:

js
function isValidGhost(gp, existingPieces) {

替換為:

js
function isValidGhost(gp, existingPieces) {
  let edgeOverlapCount = 0;
  let perfectMatchCount = 0;
  let attachedVertices = [false, false, false, false];
  let gpSat = getSAT(gp);
  let nearbyPieces = [];

  for (let exist of existingPieces) {
    let col = checkSATCollision(gpSat, getSAT(exist));

    if (col === 'separated') continue;
    if (col === 'overlap') return false;

    nearbyPieces.push(exist);

    for (let ei = 0; ei < 4; ei++) {
      for (let ej = 0; ej < 4; ej++) {
        let gpS = gp.vertices[ei];
        let gpE = gp.vertices[(ei + 1) % 4];
        let exS = exist.vertices[ej];
        let exE = exist.vertices[(ej + 1) % 4];

        if (!segmentsOverlapAsEdges(gpS, gpE, exS, exE)) continue;

        edgeOverlapCount++;
        if (edgeOverlapCount > 1) return false;

        if (segmentsPerfectlyMatch(gpS, gpE, exS, exE)) {
          perfectMatchCount++;
          if (perfectMatchCount > 1) return false;

          attachedVertices[ei] = true;
          attachedVertices[(ei + 1) % 4] = true;
        }
      }
    }
  }

  if (edgeOverlapCount !== 1 || perfectMatchCount !== 1) {
    return false;
  }

  /*
    只檢查 SAT 接觸到的棋子;
    已被 SAT 判定 separated 的棋子不可能與 gp 的端點重合。
  */
  for (let vi = 0; vi < 4; vi++) {
    if (attachedVertices[vi]) continue;

    for (let exist of nearbyPieces) {
      for (let vj = 0; vj < 4; vj++) {
        if (pointsEqual(gp.vertices[vi], exist.vertices[vj])) {
          return false;
        }
      }
    }
  }

  return true;
}

這不改變規則,只減少:

  • 重複 SAT;
  • 已經確定不合法後仍繼續掃描;
  • 第二輪端點檢查又把整盤 SAT 重算一次。

必須修改 9:修正 generateGhosts() 重複加入同一顆幽靈子

位置

generateGhosts() 中,目前有兩段完全相同的:

js
if (isValidGhost(gp, allP)) {
  ghosts.push(gp);
}

將兩段合併,只保留一次:

js
if (isValidGhost(gp, allP)) {
  ghosts.push(gp);
}

原本會:

  • 同一個幽靈子做兩次合法性檢查;
  • 同一個幽靈子 push 兩次;
  • 增加畫面元素、點擊項與無謂計算。

必須修改 10:計分時加入 SAT 早退與樹索引

10-1:actionCheck() 加 SAT 早退

actionCheck() 中,找到:

js
for (let A of allNewP) {
  for (let B of allNewP) {
    if (A.id === B.id) continue;

替換為:

js
for (let A of allNewP) {
  for (let B of allNewP) {
    if (A.id === B.id) continue;
    if (checkSATCollision(getSAT(A), getSAT(B)) === 'separated') continue;

10-2:getTreeDistance() 使用 Map,避免反覆 .find()

將整個:

js
function getTreeDistance(A, B, allP) {

替換為:

js
function getTreeDistance(A, B, allP, pieceById = null) {
  const byId = pieceById || new Map(allP.map(p => [p.id, p]));

  let pathA = [A.id];
  let currA = A;

  while (currA.parentId !== undefined && currA.parentId !== null) {
    currA = byId.get(currA.parentId);
    if (!currA) break;
    pathA.push(currA.id);
  }

  let pathB = [B.id];
  let currB = B;

  while (currB.parentId !== undefined && currB.parentId !== null) {
    currB = byId.get(currB.parentId);
    if (!currB) break;
    pathB.push(currB.id);
  }

  let lca = null;
  let i = pathA.length - 1;
  let j = pathB.length - 1;

  while (i >= 0 && j >= 0 && pathA[i] === pathB[j]) {
    lca = pathA[i];
    i--;
    j--;
  }

  if (lca === null) return null;

  let dist = i + 1 + (j + 1) - 1;

  let finalPath = [];

  for (let k = 0; k <= j + 1; k++) {
    let p = byId.get(pathB[k]);
    if (p) finalPath.push(p);
  }

  for (let k = i; k >= 0; k--) {
    let p = byId.get(pathA[k]);
    if (p) finalPath.push(p);
  }

  return {
    dist,
    path: finalPath
  };
}

然後在以下三個地方,各加入一次:

js
let pieceById = new Map(allNewP.map(p => [p.id, p]));

並將:

js
getTreeDistance(A, B, allNewP)

替換為:

js
getTreeDistance(A, B, allNewP, pieceById)

需要修改的三個函式:

  • simulateScoreGain()
  • simulateScoringTriGolden()
  • actionCheck()

這會移除計分熱點中大量的:

js
allP.find(...)

必須修改 11:修正增量追蹤中「舊活法得分狀態不更新」

目前 incrementalAddPiece() 的註解說:

既有活法彼此之間的互頂關係不會因為新棋子加入而改變

「互頂幾何關係」大致成立,但:

舊活法可能因為新棋子加入,而新形成單接/頂鑫得分。

所以 m.isSingleJiem.jieScore 可能會過期。

位置

在:

js
function incrementalAddPiece(newPiece, allPiecesIncludingNew) {
  let affectedPieceIds = new Set();

後面加入:

js
let scoringAffectedMethodIds = new Set();

在第一個 edgeRecords.forEach(...) 內,找到原本的:

js
let invalid =
  shapesOverlap(g, newPiece) ||

替換為:

js
let col = checkSATCollision(getSAT(g), getSAT(newPiece));

if (col === 'separated') return;

let invalid =
  col === 'overlap' ||

並在:

js
if (invalid) toRemove.push(id);

替換為:

js
if (invalid) {
  toRemove.push(id);
} else {
  /*
    候選子與新棋 SAT 接觸但仍合法,
    它可能新形成「頂點頂到邊」的單接得分。
  */
  scoringAffectedMethodIds.add(id);
}

接著在第一個 edgeRecords.forEach(...) 結束後、第二個 edgeRecords.forEach(...) 開始前,加入:

js
scoringAffectedMethodIds.forEach(id => {
  let m = methodRegistry.get(id);
  if (!m) return;

  let gain = simulateScoreGain([m.ghost]);

  if (gain[1] > 0 || gain[2] > 0) {
    m.isSingleJie = true;
    m.jieScore = gain[m.forPlayer];
    m.jieScoreOpp = gain[m.forPlayer === 1 ? 2 : 1];
  } else {
    m.isSingleJie = false;
    m.jieScore = 0;
    m.jieScoreOpp = 0;
  }
});

這仍是增量更新:只重新計算與新棋 SAT 接觸的既有活法,不會重新掃描整個 methodRegistry


修改後的行為保證

套用以上修改後:

  1. AI 不會把「跨目標頂鑫例外」誤當成對方履行規則 5。
  2. 對方上一回合若沒有保證本方可在同一個對方棋子的兩條不同邊落兩子,AI 會正確仲裁。
  3. 人類玩家仍可依原有 actionCheck() 使用頂鑫例外。
  4. 仲裁會正確判定「正常兩邊保證」,不會被單接例外誤導。
  5. 根節點候選改用已存在的 methodRegistrycoLive 增量資料。
  6. 共活硬性過濾不再對每個候選重建完整 getAllValidMoves()
  7. 訊息框會顯示:
    • 死活統計等待;
    • 上回合兩邊保證檢查;
    • 候選池數量;
    • 共活過濾進度;
    • 快速共活、新邊共活、精確 fallback 次數;
    • Minimax 深度或 MCTS 次數。
  8. Minimax/MCTS 的假設局面會扣除路徑上已使用的棋子,不會憑空重複使用庫存。

建議測試順序

  1. 正常局面:確認兩子同目標不同邊下法完全不受影響。
  2. 只有跨目標單接例外、沒有同目標兩邊下法的局面
    • 點仲裁;
    • 應顯示仲裁成功;
    • AI 輪到時也應自動仲裁成功。
  3. AI 候選很多的中盤局面
    • 訊息框應持續更新 共活硬性規則過濾 x/y
    • 打叉取消 AI 應可中止。
  4. 後手最後一子局面
    • 仲裁預覽不應因只有單子而出錯。
  5. 開局、匯入、Undo、Redo 後交給 AI
    • 應先顯示死活統計,再開始候選建構。