共享会话
🧠 AI 棋力結構修復
分享于 2026年8月16日 09:26目前 AI 棋力弱,有以下三個結構性原因:
搜索盤面與計分狀態沒有被完整封裝,假設盤面的對手得分經常算錯。
MCTS 深層每節點通常只探索一個隨機戰術候選,並沒有真的比較對手所有單接/雙接。
根節點和深層節點都以硬性優先序刪除大量候選,搜尋無法推翻錯誤啟發式。
修復:
建立完整 SearchState
按回合更新假設 scoredVictims
用假設盤面生成並評估對方完整合法回合
在 MCTS 前加入 2~3 ply 的全單接/全雙接戰術搜索
把硬篩選改成排序/progressive widening
統一人類、AI、仲裁與搜索的合法性判定
明確給出所要修改的地方和所要替換的代碼。
QuanAI我會先分段讀取整份 HTML,定位目前的盤面狀態、計分、合法性判定、候選生成與 MCTS 實作,再逐處給出可直接替換的代碼。
下面的修改會把「真實對局」與「搜尋中的假設對局」統一到同一套狀態、計分及合法性引擎,並完成以下修正:
SearchState完整保存盤面、剩餘棋子、分數、scoredVictims、行動方及計分模式。- 每次假設回合落子後,立即更新假設分數和假設
scoredVictims。 - Minimax/MCTS 深層節點生成該盤面的全部合法單子/雙子回合。
- MCTS 前增加完整單接/雙接的 2~3 ply 戰術搜索。
- 不再用單接、雙接、共活等硬性優先序刪除候選,只用排序。
- MCTS 使用 progressive widening。
- 人類確認、AI、仲裁、導入棋譜及搜尋共用
validateTurnOnState()和generateLegalTurns()。
一、加入統一的 SearchState、計分及合法性引擎
修改位置
找到:
// 取得單一棋子的所有合法落子方式(用於「本方只剩最後一手棋」的情況,只需落下一個棋子)在這段註解之前插入以下完整代碼:
// =============================================================================
// 統一 SearchState/合法回合/假設計分引擎
// =============================================================================
const SEARCH_TERMINAL_VALUE = 1000000;
const MCTS_PW_K = 1.8;
const MCTS_PW_ALPHA = 0.55;
class SearchTimeout extends Error {
constructor() {
super('SEARCH_TIMEOUT');
}
}
function getPlayerTileIds(player) {
return [0, 1, 2].map(i => 'tile' + (player === 1 ? i : i + 3));
}
function cloneSearchState(state) {
return {
boardPieces: state.boardPieces.slice(),
counts: { ...state.counts },
scores: { ...state.scores },
scoredVictims: new Set(state.scoredVictims),
mover: state.mover,
turnNumber: state.turnNumber,
scoringMode: state.scoringMode,
startingPlayer: state.startingPlayer,
gameNumber: state.gameNumber,
lastTurnPieces: state.lastTurnPieces.slice(),
nextHypId: state.nextHypId
};
}
/*
* piecesCount 在人類暫存落子時已經扣掉 tempPieces。
* SearchState 的 boardPieces 不包含 tempPieces,因此建立「落子前狀態」時
* 必須把 tempPieces 的數量加回去。
*/
function createSearchStateFromGlobals(mover = currentPlayer) {
const counts = { ...piecesCount };
tempPieces.forEach(piece => {
counts[piece.svgId] = (counts[piece.svgId] || 0) + 1;
});
let nextHypId = -1;
for (const piece of pieces) {
if (typeof piece.id === 'number' && piece.id < 0) {
nextHypId = Math.min(nextHypId, piece.id - 1);
}
}
return {
boardPieces: pieces.slice(),
counts,
scores: { ...scores },
scoredVictims: new Set(scoredVictims),
mover,
turnNumber,
scoringMode: SCORING_MODE,
startingPlayer,
gameNumber,
lastTurnPieces: lastTurnPieces.slice(),
nextHypId
};
}
function createSearchStateForBoard(mover, boardPieces, extraHypPieces = []) {
const counts = { ...piecesCount };
tempPieces.forEach(piece => {
counts[piece.svgId] = (counts[piece.svgId] || 0) + 1;
});
/*
* boardPieces 通常已經包含 extraHypPieces;這裡只用 extraHypPieces
* 扣除搜尋路徑已經消耗的棋子數。
*/
extraHypPieces.forEach(piece => {
if (counts[piece.svgId] !== undefined) {
counts[piece.svgId]--;
}
});
let nextHypId = -1;
for (const piece of boardPieces) {
if (typeof piece.id === 'number' && piece.id < 0) {
nextHypId = Math.min(nextHypId, piece.id - 1);
}
}
return {
boardPieces: boardPieces.slice(),
counts,
scores: { ...scores },
scoredVictims: new Set(scoredVictims),
mover,
turnNumber,
scoringMode: SCORING_MODE,
startingPlayer,
gameNumber,
lastTurnPieces: lastTurnPieces.slice(),
nextHypId
};
}
function getStateRemainingTotal(state, player) {
return getPlayerTileIds(player).reduce((sum, tileId) => {
return sum + Math.max(0, state.counts[tileId] || 0);
}, 0);
}
function isSearchGameComplete(state) {
return Object.values(state.counts).every(count => count <= 0);
}
function getRequiredPiecesForState(state) {
if (state.boardPieces.length === 0) return 1;
return getStateRemainingTotal(state, state.mover) === 1 ? 1 : 2;
}
function exactVerticesEqual(a, b) {
return (
a &&
b &&
a.length === b.length &&
a.every((vertex, index) => pointsEqual(vertex, b[index]))
);
}
function piecePlacementSignature(piece) {
return [
piece.svgId,
piece.isFlipped ? 1 : 0,
piece.targetId ?? 'root',
piece.myEdge ?? 'root',
piece.targetEdge ?? piece.edgeOnOpp ?? 'root',
piece.vertices.map(vertex => vertex.join(',')).join('/')
].join('|');
}
function turnSignature(move) {
return move
.map(piecePlacementSignature)
.sort()
.join('||');
}
/*
* 純計分函式。
*
* 重要:
* 1. 使用 state.scoredVictims,而不是全域 scoredVictims。
* 2. 不改寫 state。
* 3. 回傳本回合應新增的 scoredVictims key。
* 4. 同一鑫棋端點有多個頂棋時,仍取樹距離最近者。
*/
function adjudicateSearchTurn(state, placedPieces) {
const allPieces = state.boardPieces.concat(placedPieces);
const idMap = new Map();
const newIds = new Set(placedPieces.map(piece => piece.id));
const dingMap = new Map();
allPieces.forEach(piece => {
preparePieceSAT(piece);
idMap.set(piece.id, piece);
});
for (const dinger of allPieces) {
for (const victim of allPieces) {
if (dinger.id === victim.id) continue;
if (checkSATCollision(getSAT(dinger), getSAT(victim)) === 'separated') continue;
const dingVertices = [];
for (const vertex of dinger.vertices) {
let touchesEdge = false;
for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
if (
pointOnOpenSegment(
vertex,
victim.vertices[edgeIndex],
victim.vertices[(edgeIndex + 1) % 4]
)
) {
touchesEdge = true;
break;
}
}
if (touchesEdge) dingVertices.push(vertex);
}
if (dingVertices.length === 0) continue;
const treeResult = getTreeDistance(dinger, victim, allPieces, idMap);
if (!treeResult || treeResult.dist < 0) continue;
for (const vertex of dingVertices) {
const key = victim.id + '_' + vertex.join('_');
if (state.scoredVictims.has(key)) continue;
if (!dingMap.has(key)) dingMap.set(key, []);
dingMap.get(key).push({
dinger,
victim,
dist: treeResult.dist,
path: treeResult.path
});
}
}
}
const gain = { 1: 0, 2: 0 };
const newScoredKeys = [];
const records = [];
const rings = [];
for (const [key, candidates] of dingMap) {
let best = null;
for (const candidate of candidates) {
if (!best || candidate.dist < best.dist) {
best = candidate;
}
}
if (!best) continue;
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;
}
const involvesNew =
newIds.has(best.dinger.id) || newIds.has(best.victim.id);
if (best.dist > 0 && scorer > 0) {
gain[scorer] += best.dist;
rings.push({
path: best.path,
scorer
});
}
/*
* 與正式 actionCheck 原規則一致:
* 即使距離為 0,只要已選出該端點的頂棋,也要封存該 key。
*/
newScoredKeys.push(key);
records.push({
key,
dinger: best.dinger,
victim: best.victim,
dist: best.dist,
score: best.dist,
scorer,
path: best.path,
involvesNew
});
}
return {
gain,
newScoredKeys,
records,
rings,
hasScoringStructure: records.some(
record => record.involvesNew && record.score > 0
)
};
}
function validateOpeningPiece(state, piece) {
if (piece.owner !== state.mover) {
return { ok: false, reason: 'wrong-owner' };
}
if (!piece.svgId || !SHAPE_MAP[piece.svgId]) {
return { ok: false, reason: 'invalid-piece-type' };
}
if ((state.counts[piece.svgId] || 0) <= 0) {
return { ok: false, reason: 'no-piece-left' };
}
const expected = buildShapeVertices(
ZERO,
0,
SHAPES[SHAPE_MAP[piece.svgId]],
!!piece.isFlipped
).vertices;
if (!exactVerticesEqual(piece.vertices, expected)) {
return { ok: false, reason: 'opening-not-centered' };
}
return { ok: true };
}
/*
* 所有人類 ghost、AI ghost、仲裁 ghost 及搜尋 ghost 共用的單子幾何判定。
*/
function validatePlacementGeometry(piece, boardPieces, player) {
if (!piece || piece.owner !== player) {
return { ok: false, reason: 'wrong-owner' };
}
if (!piece.svgId || !SHAPE_MAP[piece.svgId]) {
return { ok: false, reason: 'invalid-piece-type' };
}
const target = boardPieces.find(
boardPiece => boardPiece.id === piece.targetId
);
if (!target || target.owner === player) {
return { ok: false, reason: 'invalid-target' };
}
const targetEdge =
piece.targetEdge !== undefined
? piece.targetEdge
: piece.edgeOnOpp;
if (
!Number.isInteger(piece.myEdge) ||
piece.myEdge < 0 ||
piece.myEdge > 3 ||
!Number.isInteger(targetEdge) ||
targetEdge < 0 ||
targetEdge > 3
) {
return { ok: false, reason: 'invalid-edge' };
}
if (
!segmentsPerfectlyMatch(
piece.vertices[piece.myEdge],
piece.vertices[(piece.myEdge + 1) % 4],
target.vertices[targetEdge],
target.vertices[(targetEdge + 1) % 4]
)
) {
return { ok: false, reason: 'declared-edge-mismatch' };
}
preparePieceSAT(piece);
if (!isValidGhost(piece, boardPieces)) {
return { ok: false, reason: 'geometry-conflict' };
}
return { ok: true };
}
/*
* 正式回合合法性:
* - 開局一子;
* - 最後剩一子時一子;
* - 一般回合兩子;
* - 每子都要貼既有對方棋子;
* - 同目標時必須是不同邊;
* - 跨目標時,完整回合必須形成尚未計分且距離 > 0 的頂鑫結構。
*
* 「是否讓對方下一回合無棋可下」不在此硬刪除。
* 搜尋會把它評估為對方仲裁獲勝,而不是用啟發式直接刪除候選。
*/
function validateTurnOnState(state, move) {
if (!Array.isArray(move)) {
return { ok: false, reason: 'invalid-move' };
}
const required = getRequiredPiecesForState(state);
if (move.length !== required) {
return {
ok: false,
reason: 'wrong-piece-count',
required
};
}
if (state.boardPieces.length === 0) {
return validateOpeningPiece(state, move[0]);
}
const usage = {};
for (const piece of move) {
usage[piece.svgId] = (usage[piece.svgId] || 0) + 1;
if (usage[piece.svgId] > (state.counts[piece.svgId] || 0)) {
return { ok: false, reason: 'no-piece-left' };
}
const result = validatePlacementGeometry(
piece,
state.boardPieces,
state.mover
);
if (!result.ok) return result;
}
if (move.length === 1) {
return { ok: true };
}
const first = move[0];
const second = move[1];
if (!twoGhostsCompatible(first, second)) {
return { ok: false, reason: 'pair-conflict' };
}
const firstTargetEdge =
first.targetEdge !== undefined
? first.targetEdge
: first.edgeOnOpp;
const secondTargetEdge =
second.targetEdge !== undefined
? second.targetEdge
: second.edgeOnOpp;
if (first.targetId === second.targetId) {
if (firstTargetEdge === secondTargetEdge) {
return { ok: false, reason: 'same-target-edge' };
}
return { ok: true };
}
const adjudication = adjudicateSearchTurn(state, move);
if (!adjudication.hasScoringStructure) {
return {
ok: false,
reason: 'cross-target-without-score'
};
}
return {
ok: true,
adjudication
};
}
/*
* 在一個 SearchState 上完成整個回合:
* - 扣棋子;
* - 加入盤面;
* - 更新分數;
* - 更新 scoredVictims;
* - 換手。
*/
function applySearchTurnToState(
state,
move,
options = {}
) {
const shouldValidate = options.validate !== false;
if (shouldValidate) {
const validation = validateTurnOnState(state, move);
if (!validation.ok) {
throw new Error('Illegal search turn: ' + validation.reason);
}
}
const next = cloneSearchState(state);
const usedIds = new Set(
next.boardPieces.map(piece => piece.id)
);
const placedPieces = move.map(source => {
const piece = {
...source,
vertices: source.vertices,
satBounds: source.satBounds
};
if (
piece.id === undefined ||
piece.id === null ||
usedIds.has(piece.id)
) {
piece.id = next.nextHypId--;
}
usedIds.add(piece.id);
preparePieceSAT(piece);
return piece;
});
const negativeIds = placedPieces
.map(piece => piece.id)
.filter(id => typeof id === 'number' && id < 0);
if (negativeIds.length > 0) {
next.nextHypId =
Math.min(next.nextHypId, ...negativeIds) - 1;
}
const adjudication = adjudicateSearchTurn(
state,
placedPieces
);
for (const piece of placedPieces) {
next.counts[piece.svgId]--;
}
next.boardPieces.push(...placedPieces);
next.scores[1] += adjudication.gain[1];
next.scores[2] += adjudication.gain[2];
adjudication.newScoredKeys.forEach(key => {
next.scoredVictims.add(key);
});
next.lastTurnPieces = placedPieces.slice();
next.turnNumber++;
next.mover = next.mover === 1 ? 2 : 1;
return {
state: next,
placedPieces,
adjudication
};
}
/*
* 在指定 SearchState 上生成全部合法單子落點。
*/
function generateLegalSinglePlacements(
state,
player = state.mover
) {
const result = [];
const seen = new Set();
const tileIds = getPlayerTileIds(player).filter(
tileId => (state.counts[tileId] || 0) > 0
);
const opponentPieces = state.boardPieces.filter(
piece => piece.owner !== player
);
let idCursor = state.nextHypId;
for (const target of opponentPieces) {
for (const tileId of tileIds) {
for (const flip of [false, true]) {
for (
let targetEdge = 0;
targetEdge < 4;
targetEdge++
) {
for (let myEdge = 0; myEdge < 4; myEdge++) {
const attached = attachByEdge(
SHAPE_MAP[tileId],
flip,
myEdge,
target.vertices[targetEdge],
target.vertices[(targetEdge + 1) % 4],
!!target.isFlipped
);
if (!attached) continue;
const ghost = {
id: idCursor--,
vertices: attached.vertices,
type: SHAPE_MAP[tileId],
owner: player,
svgId: tileId,
isFlipped: flip,
edgeOnOpp: targetEdge,
targetId: target.id,
myEdge,
targetEdge,
parentId: target.id,
level:
(target.level !== undefined
? target.level
: 0) + 1
};
preparePieceSAT(ghost);
const validation = validatePlacementGeometry(
ghost,
state.boardPieces,
player
);
if (!validation.ok) continue;
const signature =
piecePlacementSignature(ghost);
if (seen.has(signature)) continue;
seen.add(signature);
result.push(ghost);
}
}
}
}
}
return result;
}
function pairFitsInventory(state, first, second) {
if (first.svgId !== second.svgId) {
return (
(state.counts[first.svgId] || 0) >= 1 &&
(state.counts[second.svgId] || 0) >= 1
);
}
return (state.counts[first.svgId] || 0) >= 2;
}
/*
* 生成該 SearchState 下的完整合法回合。
*
* 同目標:列出所有不同邊的合規組合。
* 跨目標:列出所有符合頂鑫例外的合規組合。
*
* 不使用單接/雙接/共活優先序刪除任何候選。
*/
function generateLegalTurns(state) {
const required = getRequiredPiecesForState(state);
if (required === 1) {
if (state.boardPieces.length === 0) return [];
return generateLegalSinglePlacements(
state,
state.mover
).map(ghost => [ghost]);
}
const singles = generateLegalSinglePlacements(
state,
state.mover
);
if (singles.length < 2) return [];
/*
* 跨目標例外的預篩:
* 若兩子跨目標,至少要:
* - 其中一子單獨已形成可計分頂鑫;或
* - 兩子互相形成頂鑫。
*
* 通過預篩後仍會呼叫 validateTurnOnState 做完整驗證。
*/
const singleScoring = singles.map(ghost => {
return adjudicateSearchTurn(state, [ghost])
.hasScoringStructure;
});
const moves = [];
const seen = new Set();
for (let i = 0; i < singles.length; i++) {
for (let j = i + 1; j < singles.length; j++) {
const first = singles[i];
const second = singles[j];
if (!pairFitsInventory(state, first, second)) {
continue;
}
if (!twoGhostsCompatible(first, second)) {
continue;
}
if (first.targetId === second.targetId) {
if (
(first.targetEdge ?? first.edgeOnOpp) ===
(second.targetEdge ?? second.edgeOnOpp)
) {
continue;
}
} else {
const possiblyScores =
singleScoring[i] ||
singleScoring[j] ||
formsTriGolden(first, second);
if (!possiblyScores) continue;
}
const move = [first, second];
const validation = validateTurnOnState(
state,
move
);
if (!validation.ok) continue;
const signature = turnSignature(move);
if (seen.has(signature)) continue;
seen.add(signature);
moves.push(move);
}
}
return moves;
}
function evaluateSearchState(
state,
rootPlayer,
rootBaseDiff
) {
const opponent = rootPlayer === 1 ? 2 : 1;
const currentDiff =
state.scores[rootPlayer] - state.scores[opponent];
return currentDiff - rootBaseDiff;
}
function evaluateNoMoveTerminal(
state,
rootPlayer,
rootBaseDiff
) {
/*
* 棋子全部下完是正常終局,以分數判定。
* 尚未完局但行動方沒有合法回合,行動方可仲裁獲勝。
*/
if (isSearchGameComplete(state)) {
return evaluateSearchState(
state,
rootPlayer,
rootBaseDiff
);
}
return state.mover === rootPlayer
? SEARCH_TERMINAL_VALUE
: -SEARCH_TERMINAL_VALUE;
}
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) {
if (
adjudicateSearchTurn(state, [piece])
.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,
isTactical:
singleJieCount > 0 || isDoubleJie
};
}
/*
* 只排序、不刪除。
* 戰術搜索分數最高,其次才看當前回合的直接淨得分。
*/
function orderMovesForSearch(
state,
moves,
tacticalScores = null
) {
return moves
.map((move, originalIndex) => {
const signature = turnSignature(move);
const analysis = analyseSearchMove(state, move);
const tacticalScore =
tacticalScores &&
tacticalScores.has(signature)
? tacticalScores.get(signature)
: null;
let orderScore =
analysis.moverGain * 100 +
analysis.singleJieCount * 15 +
(analysis.isDoubleJie ? 25 : 0);
if (tacticalScore !== null) {
const moverPerspective =
state.mover ===
tacticalScores.rootPlayer
? tacticalScore
: -tacticalScore;
orderScore += moverPerspective * 1000;
}
return {
move,
orderScore,
originalIndex
};
})
.sort(
(a, b) =>
b.orderScore - a.orderScore ||
a.originalIndex - b.originalIndex
)
.map(item => item.move);
}
/*
* 戰術搜索只延伸「單接/雙接」,但每一個節點會先生成全部合法回合,
* 再完整保留其中所有戰術回合,不再隨機只抽一個。
*/
function tacticalAlphaBeta(
state,
depth,
alpha,
beta,
rootPlayer,
rootBaseDiff,
deadline
) {
if (performance.now() >= deadline) {
throw new SearchTimeout();
}
if (isSearchGameComplete(state) || depth <= 0) {
return evaluateSearchState(
state,
rootPlayer,
rootBaseDiff
);
}
const legalMoves = generateLegalTurns(state);
if (legalMoves.length === 0) {
return evaluateNoMoveTerminal(
state,
rootPlayer,
rootBaseDiff
);
}
const tacticalChildren = [];
for (const move of legalMoves) {
if (performance.now() >= deadline) {
throw new SearchTimeout();
}
const analysis = analyseSearchMove(state, move);
if (analysis.isTactical) {
tacticalChildren.push({
move,
analysis
});
}
}
if (tacticalChildren.length === 0) {
return evaluateSearchState(
state,
rootPlayer,
rootBaseDiff
);
}
tacticalChildren.sort(
(a, b) => b.analysis.moverGain - a.analysis.moverGain
);
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,
deadline
);
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,
deadline
);
best = Math.min(best, value);
beta = Math.min(beta, best);
if (beta <= alpha) break;
}
return best;
}
async function runTacticalPresearch(
rootState,
rootMoves,
targetDepth,
deadline
) {
const rootPlayer = rootState.mover;
const opponent = rootPlayer === 1 ? 2 : 1;
const rootBaseDiff =
rootState.scores[rootPlayer] -
rootState.scores[opponent];
const fallbackScores = new Map();
fallbackScores.rootPlayer = rootPlayer;
const tacticalRoots = [];
for (let i = 0; i < rootMoves.length; i++) {
const move = rootMoves[i];
const analysis = analyseSearchMove(
rootState,
move
);
if (analysis.isTactical) {
tacticalRoots.push({
move,
analysis
});
fallbackScores.set(
turnSignature(move),
evaluateSearchState(
analysis.childState,
rootPlayer,
rootBaseDiff
)
);
}
await aiMaybeYield(
t('ai-thinking-candidates', {
phase:
`完整單接/雙接預掃描 ` +
`(${i + 1}/${rootMoves.length})`
})
);
}
if (tacticalRoots.length === 0) {
return fallbackScores;
}
const completedScores = new Map();
completedScores.rootPlayer = rootPlayer;
try {
for (let i = 0; i < tacticalRoots.length; i++) {
if (performance.now() >= deadline) {
throw new SearchTimeout();
}
const item = tacticalRoots[i];
const value = tacticalAlphaBeta(
item.analysis.childState,
targetDepth - 1,
-Infinity,
Infinity,
rootPlayer,
rootBaseDiff,
deadline
);
completedScores.set(
turnSignature(item.move),
value
);
await aiMaybeYield(
t('ai-thinking-candidates', {
phase:
`${targetDepth} ply 全單接/全雙接戰術搜索 ` +
`(${i + 1}/${tacticalRoots.length})`
})
);
}
return completedScores;
} catch (error) {
if (!(error instanceof SearchTimeout)) {
throw error;
}
/*
* 不使用不完整的深層搜索結果,避免先被搜索到的候選得到不公平優勢。
*/
return fallbackScores;
}
}
async function runStateMinimax(
rootState,
rootMoves,
maxDepth,
deadline,
tacticalScores
) {
const rootPlayer = rootState.mover;
const opponent = rootPlayer === 1 ? 2 : 1;
const rootBaseDiff =
rootState.scores[rootPlayer] -
rootState.scores[opponent];
let searchCount = 0;
let committedBestMove = rootMoves[0];
let committedBestValue = 0;
function alphaBetaState(
state,
depth,
alpha,
beta
) {
searchCount++;
if (cancelAi || performance.now() >= deadline) {
throw new SearchTimeout();
}
if (isSearchGameComplete(state) || depth <= 0) {
return evaluateSearchState(
state,
rootPlayer,
rootBaseDiff
);
}
let legalMoves = generateLegalTurns(state);
if (legalMoves.length === 0) {
return evaluateNoMoveTerminal(
state,
rootPlayer,
rootBaseDiff
);
}
legalMoves = orderMovesForSearch(
state,
legalMoves
);
const maximizing = state.mover === rootPlayer;
if (maximizing) {
let best = -Infinity;
for (const move of legalMoves) {
const child = applySearchTurnToState(
state,
move,
{ validate: false }
).state;
best = Math.max(
best,
alphaBetaState(
child,
depth - 1,
alpha,
beta
)
);
alpha = Math.max(alpha, best);
if (beta <= alpha) break;
}
return best;
}
let best = Infinity;
for (const move of legalMoves) {
const child = applySearchTurnToState(
state,
move,
{ validate: false }
).state;
best = Math.min(
best,
alphaBetaState(
child,
depth - 1,
alpha,
beta
)
);
beta = Math.min(beta, best);
if (beta <= alpha) break;
}
return best;
}
for (let depth = 1; depth <= maxDepth; depth++) {
let iterationBestMove = committedBestMove;
let iterationBestValue = -Infinity;
let iterationCompleted = true;
try {
for (const move of rootMoves) {
if (
cancelAi ||
performance.now() >= deadline
) {
throw new SearchTimeout();
}
const child = applySearchTurnToState(
rootState,
move,
{ validate: false }
).state;
const value = alphaBetaState(
child,
depth - 1,
-Infinity,
Infinity
);
if (value > iterationBestValue) {
iterationBestValue = value;
iterationBestMove = move;
}
}
} catch (error) {
if (!(error instanceof SearchTimeout)) {
throw error;
}
iterationCompleted = false;
}
/*
* 只提交完整搜索完全部根候選的深度。
*/
if (!iterationCompleted) break;
committedBestMove = iterationBestMove;
committedBestValue = iterationBestValue;
showMessage(
t('ai-minimax-progress', {
depth,
time: (
(performance.now() -
(deadline - 1)) /
1000
).toFixed(1)
}),
0,
true
);
await new Promise(resolve =>
setTimeout(resolve, 0)
);
}
return {
bestMove: committedBestMove,
value: committedBestValue,
searchCount
};
}
async function runStateMCTS(
rootState,
rootMoves,
maxDepth,
deadline,
tacticalScores,
cValue
) {
const rootPlayer = rootState.mover;
const opponent = rootPlayer === 1 ? 2 : 1;
const rootBaseDiff =
rootState.scores[rootPlayer] -
rootState.scores[opponent];
let searchCount = 0;
function makeNode(
state,
parent = null,
move = null,
rootMove = null
) {
return {
state,
parent,
move,
rootMove,
children: [],
visits: 0,
valueSum: 0,
orderedMoves: null,
nextMoveIndex: 0,
terminalValue: null
};
}
function ensureNodeMoves(node, isRoot = false) {
if (node.orderedMoves !== null) return;
if (isSearchGameComplete(node.state)) {
node.orderedMoves = [];
node.terminalValue = evaluateSearchState(
node.state,
rootPlayer,
rootBaseDiff
);
return;
}
let moves = isRoot
? rootMoves.slice()
: generateLegalTurns(node.state);
if (moves.length === 0) {
node.orderedMoves = [];
node.terminalValue = evaluateNoMoveTerminal(
node.state,
rootPlayer,
rootBaseDiff
);
return;
}
node.orderedMoves = orderMovesForSearch(
node.state,
moves,
isRoot ? tacticalScores : null
);
}
function selectUcbChild(node) {
const selectingForRoot =
node.state.mover === rootPlayer;
let bestChild = null;
let bestValue = -Infinity;
for (const child of node.children) {
if (child.visits === 0) return child;
let exploit =
child.valueSum / child.visits;
/*
* 所有 value 都從 root 玩家角度保存。
* 輪到對手選擇時,對手會偏好較低的 root value。
*/
if (!selectingForRoot) {
exploit = -exploit;
}
const explore =
cValue *
Math.sqrt(
Math.log(Math.max(1, node.visits)) /
child.visits
);
const ucb = exploit + explore;
if (ucb > bestValue) {
bestValue = ucb;
bestChild = child;
}
}
return bestChild;
}
function normalizeLeafValue(rawValue) {
if (rawValue >= SEARCH_TERMINAL_VALUE / 2) {
return 1;
}
if (rawValue <= -SEARCH_TERMINAL_VALUE / 2) {
return -1;
}
return Math.tanh(rawValue / 6);
}
const root = makeNode(rootState);
root.orderedMoves = orderMovesForSearch(
rootState,
rootMoves,
tacticalScores
);
while (
performance.now() < deadline &&
!cancelAi
) {
const path = [root];
let node = root;
let depth = 0;
while (depth < maxDepth) {
ensureNodeMoves(node, node === root);
if (node.terminalValue !== null) break;
if (node.orderedMoves.length === 0) break;
/*
* Progressive widening:
* visits 增加時才逐步開放更多排序後的候選。
* 候選沒有被刪除,只是延後展開。
*/
const allowedChildren = Math.min(
node.orderedMoves.length,
Math.max(
1,
Math.ceil(
MCTS_PW_K *
Math.pow(
node.visits + 1,
MCTS_PW_ALPHA
)
)
)
);
if (
node.nextMoveIndex <
allowedChildren
) {
const move =
node.orderedMoves[
node.nextMoveIndex++
];
const childState =
applySearchTurnToState(
node.state,
move,
{ validate: false }
).state;
const child = makeNode(
childState,
node,
move,
node.rootMove || move
);
node.children.push(child);
node = child;
path.push(node);
depth++;
break;
}
if (node.children.length === 0) break;
const selected = selectUcbChild(node);
if (!selected) break;
node = selected;
path.push(node);
depth++;
}
let rawLeafValue;
ensureNodeMoves(node, node === root);
if (node.terminalValue !== null) {
rawLeafValue = node.terminalValue;
} else {
rawLeafValue = evaluateSearchState(
node.state,
rootPlayer,
rootBaseDiff
);
}
const leafValue =
normalizeLeafValue(rawLeafValue);
for (const pathNode of path) {
pathNode.visits++;
pathNode.valueSum += leafValue;
}
searchCount++;
if (searchCount % 10 === 0) {
showMessage(
t('ai-mcts-progress', {
time: '—',
count: searchCount
}),
0,
true
);
await new Promise(resolve =>
setTimeout(resolve, 0)
);
}
}
if (root.children.length === 0) {
return {
bestMove: root.orderedMoves[0],
value: 0,
searchCount
};
}
const bestChild = root.children.reduce(
(best, child) => {
if (child.visits > best.visits) {
return child;
}
if (
child.visits === best.visits &&
child.valueSum /
Math.max(1, child.visits) >
best.valueSum /
Math.max(1, best.visits)
) {
return child;
}
return best;
},
root.children[0]
);
const meanValue =
bestChild.valueSum /
Math.max(1, bestChild.visits);
return {
bestMove: bestChild.rootMove,
/*
* 顯示成 0~1 勝率。
*/
value: (meanValue + 1) / 2,
searchCount
};
}二、替換任意盤面的單子生成函式
修改位置
找到並完整刪除原本這兩個函式:
function getAllValidSingleMovesOnBoard(...)以及:
function getAllValidSingleMoves(...)替換成:
function getAllValidSingleMovesOnBoard(
player,
boardPieces,
extraHypPieces = []
) {
const state = createSearchStateForBoard(
player,
boardPieces,
extraHypPieces
);
return generateLegalSinglePlacements(
state,
player
);
}
function getAllValidSingleMoves(player) {
const state = createSearchStateFromGlobals(player);
return generateLegalSinglePlacements(
state,
player
);
}三、替換 simulateScoreGain
修改位置
找到原本:
function simulateScoreGain(candidatePieces) {
...
}完整替換成:
function simulateScoreGain(
candidatePieces,
explicitState = null
) {
const state =
explicitState ||
createSearchStateFromGlobals(currentPlayer);
return adjudicateSearchTurn(
state,
candidatePieces
).gain;
}這個相容包裝保留給既有的死活統計函式使用,但新的 Minimax/MCTS 不再把多回合棋子壓成一個陣列一次計分,而是每回合呼叫 applySearchTurnToState(),因此 scoredVictims 會逐回合正確更新。
四、替換 getAllValidMoves
修改位置
在文件後段找到原本完整的:
function getAllValidMoves(player) {
...
}整個替換成:
function getAllValidMoves(player) {
const state = createSearchStateFromGlobals(player);
return generateLegalTurns(state);
}至此,仲裁和 AI 根節點會使用完全相同的合法回合生成器。
五、讓人類打勾按鈕也使用統一合法性判定
修改位置
在原始 updateUI() 中找到這一段:
if (tempPieces.length === requiredPieces) {
// 六、檢查是否落在同一棋子上,或有頂鑫結構
let canCheck = true;
if (requiredPieces === 2) {
let p1 = tempPieces[0],
p2 = tempPieces[1];
if (p1.targetId !== p2.targetId) {
let formed = simulateScoringTriGolden(pieces.concat(tempPieces), [p1.id, p2.id]);
if (!formed) {
canCheck = false;
showMessage('msg-same-piece', 2400);
}
}
}
if (canCheck) check.classList.remove('disabled');
else check.classList.add('disabled');
} else {
check.classList.add('disabled');
}完整替換成:
if (tempPieces.length === requiredPieces) {
const humanState =
createSearchStateFromGlobals(currentPlayer);
const validation = validateTurnOnState(
humanState,
tempPieces
);
if (validation.ok) {
check.classList.remove('disabled');
} else {
check.classList.add('disabled');
if (
validation.reason ===
'cross-target-without-score' ||
validation.reason ===
'same-target-edge'
) {
showMessage('msg-same-piece', 2400);
}
}
} else {
check.classList.add('disabled');
}六、替換正式落子 actionCheck
修改位置
找到原本完整的:
function actionCheck(that = false) {
...
}整個替換成:
function actionCheck(that = false) {
if (
that &&
that.classList.contains('disabled')
) {
return;
}
/*
* 正式落子前固定連續正 ID。
*/
tempPieces.forEach((piece, index) => {
piece.id = pieces.length + index + 1;
preparePieceSAT(piece);
});
const beforeState =
createSearchStateFromGlobals(currentPlayer);
const validation = validateTurnOnState(
beforeState,
tempPieces
);
/*
* UI、棋譜導入或其它程式入口都不能繞過正式合法性判定。
*/
if (!validation.ok) {
if (
validation.reason ===
'cross-target-without-score' ||
validation.reason ===
'same-target-edge'
) {
showMessage('msg-same-piece', 2400);
} else {
showMessage(
currentLang === 'zh'
? '此落子不符合規則。'
: 'This turn is illegal.',
2400,
true
);
}
updateUI();
renderBoard();
return;
}
allRings = [];
const applied = applySearchTurnToState(
beforeState,
tempPieces,
{ validate: false }
);
/*
* 正式狀態直接採用統一引擎的結果。
*/
pieces = applied.state.boardPieces;
piecesCount = { ...applied.state.counts };
scores = { ...applied.state.scores };
scoredVictims = new Set(
applied.state.scoredVictims
);
lastTurnPieces =
applied.placedPieces.slice();
allRings = applied.adjudication.rings.slice();
turnNumber = applied.state.turnNumber;
currentPlayer = applied.state.mover;
nextPieceId = pieces.length + 1;
tempPieces = [];
selectedTile = null;
targetOpponentPieceId = null;
ghosts = [];
if (allRings.length > 0) {
setTimeout(() => {
allRings = [];
renderBoard();
}, 1200);
}
applyRotation();
updateUI();
renderBoard();
checkEndGame();
saveState();
/*
* 舊的死活統計仍可保留供顯示或其它功能使用,
* 但已不再充當搜尋的合法性引擎。
*/
scheduleLifeTrackingUpdate(
lastTurnPieces,
pieces
);
}這一修改也使棋譜導入的 actionCheck() 不能再繞過合法性判定。
七、替換整個 startAI
修改位置
找到原本完整的:
async function startAI() {
...
}將整個函式替換為:
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;
}
const timeLimit =
Math.max(
0.2,
aiConfig.settings[aiPlayer].time || 3
) * 1000;
const configuredDepth = Math.max(
2,
aiConfig.settings[aiPlayer].n || 3
);
const startTime = performance.now();
const overallDeadline =
startTime + timeLimit;
let searchCount = 0;
lastAIYieldTime = 0;
await aiYield(t('ai-thinking-step1'));
const rootState =
createSearchStateFromGlobals(aiPlayer);
/*
* 根節點使用全部合法回合,不再先用單接、雙接、
* 破壞對方或共活規則刪除候選。
*/
let rootMoves = generateLegalTurns(rootState);
if (rootMoves.length === 0) {
aiThinking = false;
aiTriggerArbitrationSuccess();
return;
}
if (cancelAi) {
aiThinking = false;
return;
}
/*
* MCTS 前的全單接/全雙接戰術搜索。
*
* easy/hard:2 ply
* expert 或深度 >= 5:3 ply
*/
const tacticalDepth =
aiConfig.type[aiPlayer] === 'expert' ||
configuredDepth >= 5
? 3
: 2;
/*
* 戰術搜索最多使用約 40% 總時間。
* 若完整深度來不及完成,runTacticalPresearch
* 會丟棄不完整深度,只保留完整的直接戰術評估。
*/
const tacticalDeadline = Math.min(
overallDeadline,
startTime +
Math.max(250, timeLimit * 0.4)
);
await aiYield(
t('ai-thinking-candidates', {
phase:
`${tacticalDepth} ply ` +
'全單接/全雙接戰術搜索'
})
);
const tacticalScores =
await runTacticalPresearch(
rootState,
rootMoves,
tacticalDepth,
tacticalDeadline
);
/*
* 只按戰術結果排序,所有候選仍然保留。
*/
rootMoves = orderMovesForSearch(
rootState,
rootMoves,
tacticalScores
);
if (cancelAi) {
aiThinking = false;
return;
}
const totalPiecesRemaining =
Object.values(rootState.counts).reduce(
(sum, count) =>
sum + Math.max(0, count),
0
);
const turnsLeft = Math.ceil(
totalPiecesRemaining / 2
);
const useMinimax =
turnsLeft <= configuredDepth;
let result;
if (performance.now() >= overallDeadline) {
result = {
bestMove: rootMoves[0],
value: 0,
searchCount: 0
};
} else if (useMinimax) {
result = await runStateMinimax(
rootState,
rootMoves,
turnsLeft,
overallDeadline,
tacticalScores
);
} else {
result = await runStateMCTS(
rootState,
rootMoves,
configuredDepth,
overallDeadline,
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() - startTime) /
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;
}這段替換掉的舊邏輯
新的 startAI() 已不再使用以下硬篩選:
getSingleJieMethods(...)
getMutualDingPairs(...)
findDisruptingMoves(...)
filterMovesKeepingOpponentAlive(...)
moveKeepsOpponentSafe(...)
buildCandidateMovesForNode(...)
buildCandidateMovesFromGhosts(...)
estimateOpponentBestReplyScore(...)這些函式暫時可以留在文件中,但新的 AI 不會再呼叫它們。確認新版運作正常後,可以把這些只服務舊搜尋的函式刪除。
尤其要注意:不要把下列舊候選建構代碼重新接回新版 startAI():
candidateMoves = mutualMoves;
candidateMoves = disrupting;
candidateMoves = filterMovesKeepingOpponentAlive(...);因為這些都是硬性刪除候選,正是第三個結構性問題。
八、修正仲裁預覽,使最後一子也能使用同一合法性引擎
新版 getAllValidMoves() 可能在最後一手回傳 [ghost],原本的 applyArbMove() 寫死為兩子。
修改位置
找到:
function applyArbMove(index) {
...
}完整替換成:
function applyArbMove(index) {
const move = arbValidMoves[index];
if (!move) return;
tempPieces.forEach(piece => {
piecesCount[piece.svgId]++;
});
const previewPieces = move.map(
(source, pieceIndex) => {
const piece = JSON.parse(
JSON.stringify(source)
);
piece.id =
'p_arb_' + (pieceIndex + 1);
piecesCount[piece.svgId]--;
return piece;
}
);
tempPieces = previewPieces;
renderBoard();
updateUI();
}九、建議順便修正 Minimax 進度時間顯示
上面的 runStateMinimax() 不知道搜尋開始時間,若要顯示正確用時,可再做一個小修改。
找到:
async function runStateMinimax(
rootState,
rootMoves,
maxDepth,
deadline,
tacticalScores
) {改為:
async function runStateMinimax(
rootState,
rootMoves,
maxDepth,
deadline,
tacticalScores
) {
const minimaxStartTime = performance.now();然後在同一函式內找到:
time: (
(performance.now() -
(deadline - 1)) /
1000
).toFixed(1)替換成:
time: (
(performance.now() -
minimaxStartTime) /
1000
).toFixed(1)十、修正完成後的資料流
十一、修正後對應六項要求
| 要求 | 修改後位置 |
|---|---|
建立完整 SearchState | createSearchStateFromGlobals()、cloneSearchState() |
按回合更新假設 scoredVictims | applySearchTurnToState() |
| 用假設盤面生成對方完整合法回合 | generateLegalTurns(state) |
| MCTS 前加入 2~3 ply 全單接/全雙接搜索 | runTacticalPresearch()、tacticalAlphaBeta() |
| 硬篩選改成排序/progressive widening | orderMovesForSearch()、runStateMCTS() |
| 統一人類、AI、仲裁與搜索合法性 | validateTurnOnState()、新版 actionCheck()、getAllValidMoves() |
最關鍵的行為差異是:現在某個假設回合得過分後,該端點會立即寫入該分支自己的 state.scoredVictims;對手下一層再搜索時,不會把同一端點重複計分,也不會錯用真實盤面的全域 scoredVictims。