QuanAI只读快照

共享会话

🎮 MCTS效能檢查與修復

分享于 2026年8月16日 00:47
提问者

繼續檢查MCTS還有哪些地方可以提升效能。每個棋子在落下時,是否有直接計算和加上SAT5軸數據?每種「活法」是否都有這個數據?每一層MCTS運算是否都有增量運算並保存每個棋子及每條邊的活法的這些數據?

同時在測試時,發現minimax速度過快,根本都沒有用完時間,但第二局玩家二作為先手下完最後兩手棋後,玩家一還剩一手棋,卻也能形成一個單接,說明minimax根本沒算完,或者沒算到最後一手棋是否存在單接。查找原因和修復。

回答要求:

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

trigolden_game254.htmltext/plain · 243.6 KB预览下载
QuanAI

以下是對目前程式的檢查結果與可直接替換/插入的修復方案。修改均維持現有「死活/活法/單接/互頂/共活」流程;不改變合法性規則,只補齊快取、搜尋末局單子、模擬 ID 與 MCTS 節點快取。


一、現況結論

項目現況結論
每顆正式落子是否立即計算 SAT5?getSAT()惰性計算;只有第一次被碰撞檢查、計分或活法檢查使用時才寫入 satBounds。首手尤其可能尚未計算。否,應改為落子前/落子確認時預先計算。
每個活法是否有 SAT5?enumerateEdgeMethods() 生成 ghost 後會經過 isValidGhost();而 isValidGhost() 會呼叫 getSAT(gp)。通常有,但屬於副作用,不是保證。大部分有,但應明確保證。
真實盤面是否增量維護死活/活法?incrementalAddPiece() 會移除受影響活法、新增新棋四邊活法、更新共活與單接/互頂。是,真實盤面已是增量更新。
每層 MCTS 是否保存每條邊的活法資料?每個節點只保存 ghostsByPlayer: {1: [], 2: []} 扁平陣列;沒有按「目標棋子+邊」保存,也沒有結構共享。否。已有增量方向,但仍可改善。
每層 MCTS 是否應直接複製完整 methodRegistry不應。完整複製 methodRegistrymutualcoLive 會造成大量 Map 複製與失效資料。不應直接複製;應保存按邊分組、可結構共享的活法 ghost 快取。
minimax 最後一手單接是否會搜尋?不會。 minimax 子節點只會呼叫兩子候選生成器;當對手只剩一子,回傳空候選,直接結束評分。這是你發現「第二局 P2 先手、P1 最後一子仍可單接」的主因。
minimax 為何異常快?1. 子節點遇到最後單子直接當終局。2. validMvs = validMvs.slice(0, 6) 硬切分支。3. minimax 沒累計 searchCount,畫面永遠顯示 0。不是正常的提前完成,而是搜尋樹被截斷。
模擬棋子是否都有唯一 ID?getAllValidMoves() 和舊的 getAllValidSingleMoves() 建出的 ghost 沒有 id;兩個 ghost 都是 undefined 時,simulateScoreGain()A.id === B.id 會錯誤跳過兩者互動。這是 minimax 模擬分數與樹距離的隱藏正確性 bug,必修。

二、修復 1:SAT5 改為明確預先快取

修改位置 A

找到目前的:

js
function calculateSAT(vertices) {

到:

js
function getSAT(p) {
  if (!p.satBounds) p.satBounds = calculateSAT(p.vertices);
  return p.satBounds;
}

整段替換為:

js
const SAT5_AXES = Array.from({ length: 5 }, (_, k) => {
  const angle = (k * Math.PI) / 5;
  return { x: -Math.sin(angle), y: Math.cos(angle) };
});

// 計算五條固定 SAT 軸上的投影區間。
// 軸是全域固定的,因此每顆棋子只需要保存五組 min / max,不必重複保存軸向量。
function calculateSAT(vertices) {
  const numericVertices = vertices.map(getNumericPt);

  return SAT5_AXES.map(axis => {
    let min = Infinity;
    let max = -Infinity;

    for (const pt of numericVertices) {
      const projection = pt.x * axis.x + pt.y * axis.y;
      if (projection < min) min = projection;
      if (projection > max) max = projection;
    }

    return { min, max };
  });
}

// 棋子頂點在本程式中建立後不會再被原地修改;因此 satBounds 可安全快取。
// 所有正式棋子、暫存棋子、活法 ghost、MCTS 假設棋子都應經此函式進入搜尋/合法性流程。
function preparePieceSAT(piece) {
  if (!piece || !piece.vertices) return piece;
  if (!piece.satBounds) piece.satBounds = calculateSAT(piece.vertices);
  return piece;
}

function getSAT(piece) {
  return preparePieceSAT(piece).satBounds;
}

修改位置 B

找到 actionCheck() 開頭中這段:

js
tempPieces.forEach((tp, idx) => {
  tp.id = pieces.length + idx + 1;
});

替換為:

js
tempPieces.forEach((tp, idx) => {
  tp.id = pieces.length + idx + 1;

  // 正式落子前即固定 SAT5 資料。
  // 後續計分、活法增量更新與 MCTS 根節點均可直接使用,不再等待第一次碰撞檢查才惰性建立。
  preparePieceSAT(tp);
});

這樣首手、導入棋譜、AI 落子、人類落子都會在正式進入 pieces 前保存 satBounds


修改位置 C

enumerateEdgeMethods() 中,找到 ghost 建構完成後的:

js
if (isValidGhost(ghost, allPieces)) methods.push(ghost);

替換為:

js
preparePieceSAT(ghost);

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

這保證:

  • 每個登記進 methodRegistry 的活法;
  • 每個真實盤面邊的活法;
  • 每個新棋邊在 MCTS 深層節點衍生出的活法;

都帶有 SAT5 資料。


三、修復 2:為假設棋子建立唯一 ID

這是 minimax 分數模擬的重要修正。

目前 getAllValidMoves() 生成的 gp 沒有 id。當一個候選步有兩個 gp 時,兩者都是 undefined,以下判斷會誤判兩者是同一顆棋:

js
if (A.id === B.id) continue;

結果是兩顆候選棋之間的頂鑫、互頂、樹距離與計分可能被跳過。


修改位置 A

在全域變數區,接在:

js
let nextPieceId = 1;

後面插入:

js
// 僅供活法、minimax、MCTS、模擬評分使用的負 ID。
// 與正式棋子正 ID、methodRegistry 的 ID 區間分開,避免候選棋互相被誤判為同一顆。
let nextHypothesisPieceId = -1000000000;

function allocateHypothesisPieceId() {
  return nextHypothesisPieceId--;
}

修改位置 B

getAllValidMoves(player) 裡,找到:

js
let gp = {
  vertices: res.vertices,

改為:

js
let gp = {
  id: allocateHypothesisPieceId(),
  vertices: res.vertices,

修改位置 C

在新建的單子候選函式(下一節會提供完整替換)中,也必須使用:

js
id: allocateHypothesisPieceId(),

修改位置 D

enumerateEdgeMethods() 裡,找到原本:

js
id: -100000 - nextMethodId - methods.length,

替換為:

js
id: allocateHypothesisPieceId(),

buildEdgeRecord() 後續仍會把正式 methodRegistry 活法改成:

js
g.id = -100000 - id;

所以不會影響真實盤面 method ID;這裡只是避免「尚未註冊、但已在 MCTS 節點中使用」的活法 ghost 撞 ID。


四、修復 3:最後一手單子必須進入 minimax

問題根因

第二局中 P2 是先手時,局面可能是:

text
P2:剩兩子,正常下兩子
P1:剩一子,最後單子

此時 P2 的 minimax 根節點會展開 P1。

但目前 P1 子節點仍使用:

js
buildCandidateMovesForNode(mover, hypPieces, false)

而這個函式只生成「兩子同落一個對方棋子」的候選。P1 只剩一子時,它必然回傳空陣列,因此 minimax 把它錯誤當成終局,完全看不到 P1 最後的單接。


修改位置 A:替換單子候選函式

找到原本完整的:

js
function getAllValidSingleMoves(player) {

整個函式替換為以下兩個函式:

js
// 在「指定盤面」上生成 player 的所有合法單子。
// boardPieces 可包含 minimax / MCTS 路徑中的假設棋子;extraHypPieces 用於正確扣除搜尋路徑已消耗的棋子數。
function getAllValidSingleMovesOnBoard(player, boardPieces, extraHypPieces = []) {
  const candidates = [];
  const counts = getRemainingCounts(player, extraHypPieces);

  const myTiles = [0, 1, 2].map(x => 'tile' + (player === 1 ? x : x + 3));
  const availTypes = myTiles.filter(tileId => counts[tileId] > 0);
  const opponentPieces = boardPieces.filter(piece => piece.owner !== player);

  for (const opponentPiece of opponentPieces) {
    for (const tileId of availTypes) {
      for (const flip of [false, true]) {
        for (let targetEdge = 0; targetEdge < 4; targetEdge++) {
          for (let myEdge = 0; myEdge < 4; myEdge++) {
            const result = attachByEdge(
              SHAPE_MAP[tileId],
              flip,
              myEdge,
              opponentPiece.vertices[targetEdge],
              opponentPiece.vertices[(targetEdge + 1) % 4],
              !!opponentPiece.isFlipped
            );

            if (!result) continue;

            const ghost = {
              id: allocateHypothesisPieceId(),
              vertices: result.vertices,
              type: SHAPE_MAP[tileId],
              owner: player,
              svgId: tileId,
              isFlipped: flip,
              edgeOnOpp: targetEdge,
              targetId: opponentPiece.id,
              myEdge,
              targetEdge,
              parentId: opponentPiece.id,
              level: (opponentPiece.level !== undefined ? opponentPiece.level : 0) + 1
            };

            preparePieceSAT(ghost);

            if (isValidGhost(ghost, boardPieces)) {
              candidates.push(ghost);
            }
          }
        }
      }
    }
  }

  return candidates;
}

// 真實盤面使用的相容包裝;保留既有呼叫方式。
function getAllValidSingleMoves(player) {
  return getAllValidSingleMovesOnBoard(player, pieces, []);
}

修改位置 B:加入剩餘棋子總數工具函式

找到:

js
function getRemainingCounts(forPlayer, extraHypPieces) {

該函式結束後,插入:

js
function getRemainingPieceTotal(forPlayer, extraHypPieces = []) {
  return Object.values(getRemainingCounts(forPlayer, extraHypPieces)).reduce((sum, count) => sum + count, 0);
}

修改位置 C:替換 buildCandidateMovesForNode

找到目前的:

js
function buildCandidateMovesForNode(mover, extraHypPieces, quick) {
  let ghosts = collectLiveGhostsForPlayer(mover, extraHypPieces);
  return buildCandidateMovesFromGhosts(mover, extraHypPieces, ghosts, quick);
}

完整替換為:

js
function buildCandidateMovesForNode(mover, extraHypPieces, quick) {
  // 搜尋樹內若該方只剩最後一子,必須生成單子候選。
  // 用 [ghost] 統一包裝,讓 minimax / MCTS 的「一個回合一個 move 陣列」模型保持一致。
  if (getRemainingPieceTotal(mover, extraHypPieces) === 1) {
    const boardPieces = pieces.concat(extraHypPieces);
    return getAllValidSingleMovesOnBoard(mover, boardPieces, extraHypPieces).map(ghost => [ghost]);
  }

  const ghosts = collectLiveGhostsForPlayer(mover, extraHypPieces);
  return buildCandidateMovesFromGhosts(mover, extraHypPieces, ghosts, quick);
}

修改位置 D:避免三元素 move 與單子 move 被錯誤串入搜尋路徑

getAllValidMoves() 現有回傳格式是:

js
[g1, g2, g1.targetId]

第三個元素只是舊格式遺留的 target ID,不能進入 hypPieces

startAI() 的 minimax 區塊、alphaBeta() 前面,插入:

js
function getMovePieces(move) {
  // 單子:[g]
  // 一般兩子:[g1, g2]
  // getAllValidMoves 舊格式:[g1, g2, targetId]
  return move.length === 1 ? [move[0]] : [move[0], move[1]];
}

function appendSearchMove(hypPieces, move) {
  return hypPieces.concat(getMovePieces(move));
}

然後在 alphaBeta() 內,找到:

js
let eval = alphaBeta(depth - 1, alpha, beta, false, hypPieces.concat([mv[0], mv[1]]), nextMover);

替換為:

js
let eval = alphaBeta(depth - 1, alpha, beta, false, appendSearchMove(hypPieces, mv), nextMover);

以及找到:

js
let eval = alphaBeta(depth - 1, alpha, beta, true, hypPieces.concat([mv[0], mv[1]]), nextMover);

替換為:

js
let eval = alphaBeta(depth - 1, alpha, beta, true, appendSearchMove(hypPieces, mv), nextMover);

修改位置 E:移除 minimax 的硬切六分支

找到:

js
// 限制分支係數以防超時
validMvs = validMvs.slice(0, 6);

直接刪除。

保留原本的時間截止判斷:

js
if (cancelAi || performance.now() - searchStartTime > alphaBetaTimeLimit) return 0;

這才是正確的時間控制方式。

固定只取前六個候選,不是 alpha-beta 的正確剪枝,而是直接漏算合法回應。尤其殘局候選本來不多時,不應再人為截斷。


修改位置 F:讓 minimax 顯示真正搜尋節點數

alphaBeta() 函式第一行之後:

js
function alphaBeta(depth, alpha, beta, isMaximizer, hypPieces, mover) {

插入:

js
searchCount++;

形成:

js
function alphaBeta(depth, alpha, beta, isMaximizer, hypPieces, mover) {
  searchCount++;

  if (cancelAi || performance.now() - searchStartTime > alphaBetaTimeLimit) return 0;

目前 minimax 沒有增加 searchCount,因此即使實際搜尋,完成訊息也會顯示 0,容易誤判為沒搜尋。


五、修復 4:最後單子 AI 不應隨機漏掉最佳單接

目前 placeAISingleMove() 雖然先找可得分單接,但多個得分落點之間完全隨機,亦可能選到「己方得分較少或對方淨得分更多」的下法。

找到 placeAISingleMove(player) 中的:

js
let scoringMoves = moves.filter(g => {
  let gain = simulateScoreGain([g]);
  return gain[player] > 0;
});
let pool = scoringMoves.length > 0 ? scoringMoves : moves;

let move = pool[Math.floor(Math.random() * pool.length)];

替換為:

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

const evaluatedMoves = moves.map(move => {
  const gain = simulateScoreGain([move]);

  return {
    move,
    // 與 minimax 的葉節點一致:以己方淨得分判斷。
    value: gain[player] - gain[opponent]
  };
});

const bestValue = Math.max(...evaluatedMoves.map(item => item.value));
const bestMoves = evaluatedMoves.filter(item => item.value === bestValue);

// 同分時保留隨機性;不同分時一定選擇最佳單接。
const move = bestMoves[Math.floor(Math.random() * bestMoves.length)].move;

這不會改變最後單子合法性,只會使 AI 最後一手在所有合法單子中選擇最佳淨得分。


六、修復 5:最後單子不能被「共活過濾」誤判為無路

目前:

js
moveLeavesOpponentArbitrationTrap(...)

只檢查對手能否湊出兩子共活。若對手只剩最後一子,即使存在合法單接,也可能被錯誤視為「對手無路」。

修改位置

找到:

js
function moveLeavesOpponentArbitrationTrap(g1, g2, oppPlayer, realBoardPieces, extraHypPieces) {

在函式內、這段之後:

js
let counts = getRemainingCounts(oppPlayer, extraHypPieces);

立刻插入:

js
// 對方只剩最後一子時,合法性規則改為「存在任一合法單子」;不能再以兩子共活判斷。
if (getRemainingPieceTotal(oppPlayer, extraHypPieces) === 1) {
  const afterPieces = realBoardPieces.concat(extraHypPieces, [g1, g2]);
  const finalSingles = getAllValidSingleMovesOnBoard(oppPlayer, afterPieces, extraHypPieces);

  return finalSingles.length === 0;
}

這一段非常重要:它讓 P2 最後兩子落下後,P1 若尚有最後單接,該局面會被正確視為「P1 仍有合法後續」。


七、修復 6:MCTS 節點改為按邊保存活法快取

目前 MCTS 節點保存的是:

js
ghostsByPlayer: { 1: [...], 2: [...] }

這是扁平陣列。它雖然比重建 methodRegistry 好,但仍有兩個問題:

  1. 無法知道每個 ghost 屬於哪顆目標棋子的哪條邊;
  2. 每層都重新建立扁平陣列,不利於共享未受影響的邊活法。

正確方式不是每層複製完整 methodRegistry,而是保存:

text
forPlayer
  └── targetPieceId_targetEdge
      └── 此邊目前存活的 ghost methods

未受新增棋子影響的邊直接共用原陣列;受影響的邊才建立新陣列。


修改位置 A:在 deriveIncrementalGhosts() 前插入以下工具函式

js
function edgeGhostKey(ghost) {
  return edgeKey(ghost.targetId, ghost.targetEdge);
}

function groupGhostsByEdge(ghosts) {
  const result = new Map();

  for (const ghost of ghosts) {
    const key = edgeGhostKey(ghost);

    if (!result.has(key)) result.set(key, []);
    result.get(key).push(ghost);
  }

  return result;
}

function flattenEdgeGhosts(edgeGhostMap) {
  const result = [];

  for (const ghosts of edgeGhostMap.values()) {
    result.push(...ghosts);
  }

  return result;
}

// 對父節點每條邊的活法做增量失效檢查。
// 完全未受影響的邊直接共用原 ghosts 陣列;只有受影響的邊才新建陣列。
function filterEdgeGhostMapByNewPieces(parentEdgeGhostMap, newPieces) {
  const result = new Map();

  for (const [key, ghosts] of parentEdgeGhostMap) {
    const survivors = ghosts.filter(ghost => survivesHypotheticalPieces(ghost, newPieces));

    if (survivors.length === 0) continue;

    result.set(key, survivors.length === ghosts.length ? ghosts : survivors);
  }

  return result;
}

function appendGhostToEdgeMap(edgeGhostMap, ghost) {
  const key = edgeGhostKey(ghost);
  const oldList = edgeGhostMap.get(key) || [];

  // 不修改父節點共享的舊陣列。
  edgeGhostMap.set(key, oldList.concat([ghost]));
}

修改位置 B:用以下函式完整替換原本的 deriveIncrementalGhosts(...)

js
function deriveIncrementalEdgeGhosts(parentEdgeGhostsByPlayer, mover, newPieces, boardPieces) {
  const opponent = mover === 1 ? 2 : 1;

  // 新棋對雙方既有活法都可能造成失效。
  const moverEdgeGhosts = filterEdgeGhostMapByNewPieces(parentEdgeGhostsByPlayer[mover], newPieces);
  const opponentEdgeGhosts = filterEdgeGhostMapByNewPieces(parentEdgeGhostsByPlayer[opponent], newPieces);

  // mover 新落下的棋子四邊,會產生 opponent 的新活法。
  for (const newPiece of newPieces) {
    for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
      const newGhosts = enumerateEdgeMethods(newPiece, edgeIndex, boardPieces);

      for (const ghost of newGhosts) {
        preparePieceSAT(ghost);
        appendGhostToEdgeMap(opponentEdgeGhosts, ghost);
      }
    }
  }

  return {
    [mover]: moverEdgeGhosts,
    [opponent]: opponentEdgeGhosts
  };
}

修改位置 C:替換 MCTS 的 makeMCTSNode

找到:

js
function makeMCTSNode(mover, extraHypPieces, ghostsByPlayer) {

完整替換為:

js
function makeMCTSNode(mover, extraHypPieces, edgeGhostsByPlayer) {
  return {
    mover,
    extraHypPieces,

    // 每位玩家:Map<targetPieceId_targetEdge, ghost[]>
    // 每條邊只保存目前仍存活的活法;未改變的邊可與父節點結構共享。
    edgeGhostsByPlayer,

    children: [],
    visits: 0,
    valueSum: 0,
    untriedMoves: null,
    isTerminal: false,
    move: null,

    // 本節點由父節點落下的棋子;可為一子或兩子。
    lastPlaced: []
  };
}

修改位置 D:替換 MCTS 根節點快取建立部分

找到:

js
let rootGhostsByPlayer = {
  1: collectLiveGhostsForPlayer(1, []),
  2: collectLiveGhostsForPlayer(2, [])
};
let rootNode = makeMCTSNode(currentPlayer, [], rootGhostsByPlayer);

替換為:

js
const rootEdgeGhostsByPlayer = {
  1: groupGhostsByEdge(collectLiveGhostsForPlayer(1, [])),
  2: groupGhostsByEdge(collectLiveGhostsForPlayer(2, []))
};

const rootNode = makeMCTSNode(currentPlayer, [], rootEdgeGhostsByPlayer);

修改位置 E:替換 MCTS Expansion 中固定兩子展開的部分

找到目前這段:

js
let gc1 = Object.assign({}, mv[0], { id: fakeIdCounter-- });
let gc2 = Object.assign({}, mv[1], { id: fakeIdCounter-- });
let childExtra = node.extraHypPieces.concat([gc1, gc2]);
let childMover = node.mover === 1 ? 2 : 1;

let childGhostsByPlayer = deriveIncrementalGhosts(
  node.ghostsByPlayer,
  node.mover,
  gc1,
  gc2,
  pieces.concat(childExtra)
);

let child = makeMCTSNode(childMover, childExtra, childGhostsByPlayer);

完整替換為:

js
const placedPieces = getMovePieces(mv).map(ghost =>
  preparePieceSAT(
    Object.assign({}, ghost, {
      id: fakeIdCounter--
    })
  )
);

const childExtra = node.extraHypPieces.concat(placedPieces);
const childMover = node.mover === 1 ? 2 : 1;

const childEdgeGhostsByPlayer = deriveIncrementalEdgeGhosts(
  node.edgeGhostsByPlayer,
  node.mover,
  placedPieces,
  pieces.concat(childExtra)
);

const child = makeMCTSNode(childMover, childExtra, childEdgeGhostsByPlayer);
child.lastPlaced = placedPieces;

修改位置 F:替換 MCTS 子節點候選建立部分

找到:

js
let nextCandidates = buildCandidateMovesFromGhosts(
  childMover,
  childExtra,
  childGhostsByPlayer[childMover],
  true
);

替換為:

js
let nextCandidates = buildCandidateMovesForNode(childMover, childExtra, true);

若要完全利用剛新增的按邊快取、不再經 collectLiveGhostsForPlayer() 掃 registry,則替換為:

js
let nextCandidates;

if (getRemainingPieceTotal(childMover, childExtra) === 1) {
  nextCandidates = getAllValidSingleMovesOnBoard(
    childMover,
    pieces.concat(childExtra),
    childExtra
  ).map(ghost => [ghost]);
} else {
  nextCandidates = buildCandidateMovesFromGhosts(
    childMover,
    childExtra,
    flattenEdgeGhosts(child.edgeGhostsByPlayer[childMover]),
    true
  );
}

應使用第二個版本;它才會真正沿用 MCTS 節點的增量快取。


修改位置 G:替換對手立即反擊計算的固定兩子假設

找到:

js
if (node.mover !== currentPlayer && node.extraHypPieces.length >= 2) {
  let lastTwo = node.extraHypPieces.slice(-2);
  let boardBefore = pieces.concat(node.extraHypPieces.slice(0, -2));
  leafValue -= estimateOpponentBestReplyScore(lastTwo[0], lastTwo[1], node.mover, boardBefore);
}

替換為:

js
if (node.mover !== currentPlayer && node.lastPlaced.length === 2) {
  const boardBefore = pieces.concat(node.extraHypPieces.slice(0, -node.lastPlaced.length));

  leafValue -= estimateOpponentBestReplyScore(
    node.lastPlaced[0],
    node.lastPlaced[1],
    node.mover,
    boardBefore
  );
}

單子回合沒有「兩子形成雙接」的立即反擊估計需求,因此不應把單子錯當作兩子。


八、修復 7:修正 generateGhosts() 重複驗證與重複插入

目前 generateGhosts() 中有:

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

這會造成:

  • 同一 ghost 被合法性檢查兩次;
  • 同一 ghost 被加入兩次;
  • UI 出現重複可選 ghost;
  • 人類操作與渲染成本增加。

找到以上兩段,替換為:

js
preparePieceSAT(gp);

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

九、修復後的末局流程

修復後,第二局 P2 先手時,末局邏輯會變成:

流程图
正在绘制流程图…

十、建議驗證項目

完成以上替換後,至少測試以下情境。

1. SAT5 快取

在正式落子後執行:

js
console.assert(pieces.every(piece => Array.isArray(piece.satBounds) && piece.satBounds.length === 5));

預期所有正式棋子皆為 true


2. 活法 SAT5

computeAllLifeStatus()scheduleLifeTrackingUpdate() 完成後:

js
console.assert(
  [...methodRegistry.values()].every(method => Array.isArray(method.ghost.satBounds) && method.ghost.satBounds.length === 5)
);

預期所有活法 ghost 皆為 true


3. P2 先手、P1 最後單子

在第二局 P2 先手、盤面總棋數為 51 時:

js
console.assert(currentPlayer === 2);
console.assert(getRemainingPieceTotal(1, []) === 1);

P2 minimax 展開後,P1 子節點應能得到:

js
buildCandidateMovesForNode(1, hypotheticalPiecesAfterP2Move, false)

其回傳值應為:

js
[
  [singleGhost1],
  [singleGhost2],
  ...
]

而非:

js
[]

4. minimax 搜尋統計

完成一手 minimax 後,畫面上的:

text
搜尋次數: N

其中 N 應大於 0,不再固定顯示 0。


最重要的修復順序

若要先做最小且必要的修正,建議依序套用:

  1. 修復唯一假設 ID。
  2. 修復 buildCandidateMovesForNode() 的最後單子生成。
  3. 修復 moveLeavesOpponentArbitrationTrap() 的最後單子判定。
  4. 刪除 minimax 的 slice(0, 6)
  5. 預先保存 SAT5。
  6. MCTS 改為按邊保存、結構共享活法快取。
  7. 刪除 generateGhosts() 的重複 isValidGhost()

其中第 1~4 項是本次「P2 下完最後兩子後,P1 還有單接但 minimax 未算到」的直接正確性修復。