共享会话
🎮 MCTS效能檢查與修復
分享于 2026年8月16日 00:47繼續檢查MCTS還有哪些地方可以提升效能。每個棋子在落下時,是否有直接計算和加上SAT5軸數據?每種「活法」是否都有這個數據?每一層MCTS運算是否都有增量運算並保存每個棋子及每條邊的活法的這些數據?
同時在測試時,發現minimax速度過快,根本都沒有用完時間,但第二局玩家二作為先手下完最後兩手棋後,玩家一還剩一手棋,卻也能形成一個單接,說明minimax根本沒算完,或者沒算到最後一手棋是否存在單接。查找原因和修復。
回答要求:
基於當前正確的邏輯進行完美替換,不要影響到當前正確的流程,明確給出所要修改同地方和所要替換的代碼。
QuanAI以下是對目前程式的檢查結果與可直接替換/插入的修復方案。修改均維持現有「死活/活法/單接/互頂/共活」流程;不改變合法性規則,只補齊快取、搜尋末局單子、模擬 ID 與 MCTS 節點快取。
一、現況結論
| 項目 | 現況 | 結論 |
|---|---|---|
| 每顆正式落子是否立即計算 SAT5? | getSAT() 是惰性計算;只有第一次被碰撞檢查、計分或活法檢查使用時才寫入 satBounds。首手尤其可能尚未計算。 | 否,應改為落子前/落子確認時預先計算。 |
| 每個活法是否有 SAT5? | enumerateEdgeMethods() 生成 ghost 後會經過 isValidGhost();而 isValidGhost() 會呼叫 getSAT(gp)。通常有,但屬於副作用,不是保證。 | 大部分有,但應明確保證。 |
| 真實盤面是否增量維護死活/活法? | incrementalAddPiece() 會移除受影響活法、新增新棋四邊活法、更新共活與單接/互頂。 | 是,真實盤面已是增量更新。 |
| 每層 MCTS 是否保存每條邊的活法資料? | 每個節點只保存 ghostsByPlayer: {1: [], 2: []} 扁平陣列;沒有按「目標棋子+邊」保存,也沒有結構共享。 | 否。已有增量方向,但仍可改善。 |
每層 MCTS 是否應直接複製完整 methodRegistry? | 不應。完整複製 methodRegistry、mutual、coLive 會造成大量 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
找到目前的:
function calculateSAT(vertices) {到:
function getSAT(p) {
if (!p.satBounds) p.satBounds = calculateSAT(p.vertices);
return p.satBounds;
}整段替換為:
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() 開頭中這段:
tempPieces.forEach((tp, idx) => {
tp.id = pieces.length + idx + 1;
});替換為:
tempPieces.forEach((tp, idx) => {
tp.id = pieces.length + idx + 1;
// 正式落子前即固定 SAT5 資料。
// 後續計分、活法增量更新與 MCTS 根節點均可直接使用,不再等待第一次碰撞檢查才惰性建立。
preparePieceSAT(tp);
});這樣首手、導入棋譜、AI 落子、人類落子都會在正式進入 pieces 前保存 satBounds。
修改位置 C
在 enumerateEdgeMethods() 中,找到 ghost 建構完成後的:
if (isValidGhost(ghost, allPieces)) methods.push(ghost);替換為:
preparePieceSAT(ghost);
if (isValidGhost(ghost, allPieces)) {
methods.push(ghost);
}這保證:
- 每個登記進
methodRegistry的活法; - 每個真實盤面邊的活法;
- 每個新棋邊在 MCTS 深層節點衍生出的活法;
都帶有 SAT5 資料。
三、修復 2:為假設棋子建立唯一 ID
這是 minimax 分數模擬的重要修正。
目前 getAllValidMoves() 生成的 gp 沒有 id。當一個候選步有兩個 gp 時,兩者都是 undefined,以下判斷會誤判兩者是同一顆棋:
if (A.id === B.id) continue;結果是兩顆候選棋之間的頂鑫、互頂、樹距離與計分可能被跳過。
修改位置 A
在全域變數區,接在:
let nextPieceId = 1;後面插入:
// 僅供活法、minimax、MCTS、模擬評分使用的負 ID。
// 與正式棋子正 ID、methodRegistry 的 ID 區間分開,避免候選棋互相被誤判為同一顆。
let nextHypothesisPieceId = -1000000000;
function allocateHypothesisPieceId() {
return nextHypothesisPieceId--;
}修改位置 B
在 getAllValidMoves(player) 裡,找到:
let gp = {
vertices: res.vertices,改為:
let gp = {
id: allocateHypothesisPieceId(),
vertices: res.vertices,修改位置 C
在新建的單子候選函式(下一節會提供完整替換)中,也必須使用:
id: allocateHypothesisPieceId(),修改位置 D
在 enumerateEdgeMethods() 裡,找到原本:
id: -100000 - nextMethodId - methods.length,替換為:
id: allocateHypothesisPieceId(),buildEdgeRecord() 後續仍會把正式 methodRegistry 活法改成:
g.id = -100000 - id;所以不會影響真實盤面 method ID;這裡只是避免「尚未註冊、但已在 MCTS 節點中使用」的活法 ghost 撞 ID。
四、修復 3:最後一手單子必須進入 minimax
問題根因
第二局中 P2 是先手時,局面可能是:
P2:剩兩子,正常下兩子
P1:剩一子,最後單子此時 P2 的 minimax 根節點會展開 P1。
但目前 P1 子節點仍使用:
buildCandidateMovesForNode(mover, hypPieces, false)而這個函式只生成「兩子同落一個對方棋子」的候選。P1 只剩一子時,它必然回傳空陣列,因此 minimax 把它錯誤當成終局,完全看不到 P1 最後的單接。
修改位置 A:替換單子候選函式
找到原本完整的:
function getAllValidSingleMoves(player) {整個函式替換為以下兩個函式:
// 在「指定盤面」上生成 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:加入剩餘棋子總數工具函式
找到:
function getRemainingCounts(forPlayer, extraHypPieces) {該函式結束後,插入:
function getRemainingPieceTotal(forPlayer, extraHypPieces = []) {
return Object.values(getRemainingCounts(forPlayer, extraHypPieces)).reduce((sum, count) => sum + count, 0);
}修改位置 C:替換 buildCandidateMovesForNode
找到目前的:
function buildCandidateMovesForNode(mover, extraHypPieces, quick) {
let ghosts = collectLiveGhostsForPlayer(mover, extraHypPieces);
return buildCandidateMovesFromGhosts(mover, extraHypPieces, ghosts, quick);
}完整替換為:
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() 現有回傳格式是:
[g1, g2, g1.targetId]第三個元素只是舊格式遺留的 target ID,不能進入 hypPieces。
在 startAI() 的 minimax 區塊、alphaBeta() 前面,插入:
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() 內,找到:
let eval = alphaBeta(depth - 1, alpha, beta, false, hypPieces.concat([mv[0], mv[1]]), nextMover);替換為:
let eval = alphaBeta(depth - 1, alpha, beta, false, appendSearchMove(hypPieces, mv), nextMover);以及找到:
let eval = alphaBeta(depth - 1, alpha, beta, true, hypPieces.concat([mv[0], mv[1]]), nextMover);替換為:
let eval = alphaBeta(depth - 1, alpha, beta, true, appendSearchMove(hypPieces, mv), nextMover);修改位置 E:移除 minimax 的硬切六分支
找到:
// 限制分支係數以防超時
validMvs = validMvs.slice(0, 6);直接刪除。
保留原本的時間截止判斷:
if (cancelAi || performance.now() - searchStartTime > alphaBetaTimeLimit) return 0;這才是正確的時間控制方式。
固定只取前六個候選,不是 alpha-beta 的正確剪枝,而是直接漏算合法回應。尤其殘局候選本來不多時,不應再人為截斷。
修改位置 F:讓 minimax 顯示真正搜尋節點數
在 alphaBeta() 函式第一行之後:
function alphaBeta(depth, alpha, beta, isMaximizer, hypPieces, mover) {插入:
searchCount++;形成:
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) 中的:
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)];替換為:
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:最後單子不能被「共活過濾」誤判為無路
目前:
moveLeavesOpponentArbitrationTrap(...)只檢查對手能否湊出兩子共活。若對手只剩最後一子,即使存在合法單接,也可能被錯誤視為「對手無路」。
修改位置
找到:
function moveLeavesOpponentArbitrationTrap(g1, g2, oppPlayer, realBoardPieces, extraHypPieces) {在函式內、這段之後:
let counts = getRemainingCounts(oppPlayer, extraHypPieces);立刻插入:
// 對方只剩最後一子時,合法性規則改為「存在任一合法單子」;不能再以兩子共活判斷。
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 節點保存的是:
ghostsByPlayer: { 1: [...], 2: [...] }這是扁平陣列。它雖然比重建 methodRegistry 好,但仍有兩個問題:
- 無法知道每個 ghost 屬於哪顆目標棋子的哪條邊;
- 每層都重新建立扁平陣列,不利於共享未受影響的邊活法。
正確方式不是每層複製完整 methodRegistry,而是保存:
forPlayer
└── targetPieceId_targetEdge
└── 此邊目前存活的 ghost methods未受新增棋子影響的邊直接共用原陣列;受影響的邊才建立新陣列。
修改位置 A:在 deriveIncrementalGhosts() 前插入以下工具函式
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(...)
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
找到:
function makeMCTSNode(mover, extraHypPieces, ghostsByPlayer) {完整替換為:
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 根節點快取建立部分
找到:
let rootGhostsByPlayer = {
1: collectLiveGhostsForPlayer(1, []),
2: collectLiveGhostsForPlayer(2, [])
};
let rootNode = makeMCTSNode(currentPlayer, [], rootGhostsByPlayer);替換為:
const rootEdgeGhostsByPlayer = {
1: groupGhostsByEdge(collectLiveGhostsForPlayer(1, [])),
2: groupGhostsByEdge(collectLiveGhostsForPlayer(2, []))
};
const rootNode = makeMCTSNode(currentPlayer, [], rootEdgeGhostsByPlayer);修改位置 E:替換 MCTS Expansion 中固定兩子展開的部分
找到目前這段:
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);完整替換為:
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 子節點候選建立部分
找到:
let nextCandidates = buildCandidateMovesFromGhosts(
childMover,
childExtra,
childGhostsByPlayer[childMover],
true
);替換為:
let nextCandidates = buildCandidateMovesForNode(childMover, childExtra, true);若要完全利用剛新增的按邊快取、不再經
collectLiveGhostsForPlayer()掃 registry,則替換為:
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:替換對手立即反擊計算的固定兩子假設
找到:
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);
}替換為:
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() 中有:
if (isValidGhost(gp, allP)) {
ghosts.push(gp);
}
if (isValidGhost(gp, allP)) {
ghosts.push(gp);
}這會造成:
- 同一 ghost 被合法性檢查兩次;
- 同一 ghost 被加入兩次;
- UI 出現重複可選 ghost;
- 人類操作與渲染成本增加。
找到以上兩段,替換為:
preparePieceSAT(gp);
if (isValidGhost(gp, allP)) {
ghosts.push(gp);
}九、修復後的末局流程
修復後,第二局 P2 先手時,末局邏輯會變成:
十、建議驗證項目
完成以上替換後,至少測試以下情境。
1. SAT5 快取
在正式落子後執行:
console.assert(pieces.every(piece => Array.isArray(piece.satBounds) && piece.satBounds.length === 5));預期所有正式棋子皆為 true。
2. 活法 SAT5
在 computeAllLifeStatus() 或 scheduleLifeTrackingUpdate() 完成後:
console.assert(
[...methodRegistry.values()].every(method => Array.isArray(method.ghost.satBounds) && method.ghost.satBounds.length === 5)
);預期所有活法 ghost 皆為 true。
3. P2 先手、P1 最後單子
在第二局 P2 先手、盤面總棋數為 51 時:
console.assert(currentPlayer === 2);
console.assert(getRemainingPieceTotal(1, []) === 1);P2 minimax 展開後,P1 子節點應能得到:
buildCandidateMovesForNode(1, hypotheticalPiecesAfterP2Move, false)其回傳值應為:
[
[singleGhost1],
[singleGhost2],
...
]而非:
[]4. minimax 搜尋統計
完成一手 minimax 後,畫面上的:
搜尋次數: N其中 N 應大於 0,不再固定顯示 0。
最重要的修復順序
若要先做最小且必要的修正,建議依序套用:
- 修復唯一假設 ID。
- 修復
buildCandidateMovesForNode()的最後單子生成。 - 修復
moveLeavesOpponentArbitrationTrap()的最後單子判定。 - 刪除 minimax 的
slice(0, 6)。 - 預先保存 SAT5。
- MCTS 改為按邊保存、結構共享活法快取。
- 刪除
generateGhosts()的重複isValidGhost()。
其中第 1~4 項是本次「P2 下完最後兩子後,P1 還有單接但 minimax 未算到」的直接正確性修復。