共享会话
🎮 AI對戰修復改進
分享于 2026年8月18日 09:12修復與改進 當我兩方都用AI來對戰時,發現一方會直接下出使對方得分的「單連」,或者使對方下回合就能立即得分。檢查代碼,程式是否會弄錯這一手棋應給哪一方加分。
同時,我發現AI控制的一方,第一種棋子會用得最快,第二種棋子次之,第三種棋子總是最後剩下。說明AI在搜尋時,都是以第一到第二再到第三種棋子為順序進行搜尋和排序的。現在要把這個次序打亂,使每種棋子的使用機率相等。
MCTS 前的全單接/全雙接戰術搜索:easy,hard和expert都統一用 2 ply,並且不限定時間,一定要全部搜尋完成才開始MCTS。
把所有的AI等級的minimax都統一改為「最後三回合」,即後手方的最後兩回合或先手方的最後一回合。當到了minimax階段,將不再需要全單接/全雙接戰術搜索2ply,直接用minimax搜尋涵蓋。去掉AI設置中的「搜尋深度」。
回答要求:
不要影響當前正確的遊戲流程,明確給出所要修改的地方和所要替換的代碼。
QuanAI以下修改可直接套用到 trigolden_game2649.html。核心遊戲規則、正式落子、仲裁、棋譜與計分流程不需要重寫。
一、問題判定
1. 正式計分歸屬沒有顛倒
目前 adjudicateSearchTurn() 中:
if (state.scoringMode === 0) {
scorer = best.dinger.owner;
} else if (state.scoringMode === 1) {
scorer = best.victim.owner;
}與規則一致:
SCORING_MODE === 0:分數給頂棋方dinger.ownerSCORING_MODE === 1:分數給鑫棋方victim.owner
正式落子和 AI 模擬也都使用同一個 adjudicateSearchTurn(),因此不是「實際把分數加錯玩家」。
真正問題有兩個:
analyseSearchMove()只要發現單接/雙接,就會增加排序分數,沒有判斷分數到底給己方還是對方。- 原本的 2/3 ply 戰術預搜尋只搜尋「本手已經形成單接/雙接」的根候選;普通候選如果會讓對手下一手立即得分,根本不會進入 2 ply 比較。
因此 AI 可能:
- 把「送分給對方的單接」當成戰術好棋;
- 沒有檢查普通落子是否放出對方下一手的單接/雙接。
下面的修改會同時修正。
二、移除 AI 設定中的「搜尋深度」
修改 1:刪除 HTML 中的搜尋深度設定列
在 AI 設定對話框內,刪除整段:
<div class="ai-param-row">
<div class="ai-param-title" id="ai-param-title-n">搜尋深度(回合數)</div>
<div class="ai-param-values">
<input type="number" id="ai-n-2" value="3" step="1" oninput="handleAIParamInput(2)" />
<input type="number" id="ai-n-1" value="6" step="1" oninput="handleAIParamInput(1)" />
</div>
</div>修改 2:刪除對應 i18n 項目
刪除:
'ai-param-title-n': { zh: '搜尋深度(回合數)', en: 'Search Depth (Turns)' },保留:
'ai-param-title-time': { zh: '限時 (秒)', en: 'Time Limit (s)' },三、修改 AI 設定資料,只保留時間
找到:
const MCTS_C = 1.414;
const CUSTOM_AI_DEFAULT = { time: 5, n: 4 };
let aiConfig = {
1: false,
2: true,
type: { 1: 'human', 2: 'easy' },
lastAIType: { 1: 'easy', 2: 'easy' }, // 新增:記憶上一次/預設使用的 AI 級別
params: {
easy: { time: 3, n: 3 },
hard: { time: 7, n: 4 },
expert: { time: 12, n: 5 },
custom: { ...CUSTOM_AI_DEFAULT }
},
settings: {
1: { time: 18, n: 6 },
2: { time: 3, n: 3 }
}
};整段替換為:
const MCTS_C = 1.414;
/*
* 所有 AI 統一:
* 1. MCTS 前的戰術預搜尋固定為 2 ply。
* 2. 最後三個完整回合改用 minimax。
*/
const TACTICAL_PRESEARCH_PLY = 2;
const ENDGAME_MINIMAX_TURNS = 3;
const CUSTOM_AI_DEFAULT = { time: 5 };
let aiConfig = {
1: false,
2: true,
type: { 1: 'human', 2: 'easy' },
lastAIType: { 1: 'easy', 2: 'easy' },
/*
* AI 等級只再決定正式搜尋時間。
* 戰術預搜尋深度和 minimax 啟動時機不再由設定控制。
*/
params: {
easy: { time: 3 },
hard: { time: 7 },
expert: { time: 12 },
custom: { ...CUSTOM_AI_DEFAULT }
},
settings: {
1: { time: 18 },
2: { time: 3 }
}
};四、替換 AI 設定相關函式
修改 3:替換 updateAIConfig()
整個函式替換為:
function updateAIConfig() {
[1, 2].forEach(p => {
const type = document.getElementById('ai-p' + p + '-type').value;
aiConfig.type[p] = type;
aiConfig[p] = type !== 'human';
if (type !== 'human') {
aiConfig.lastAIType[p] = type;
}
if (type === 'easy' || type === 'hard' || type === 'expert') {
const preset = aiConfig.params[type];
document.getElementById('ai-time-' + p).value = preset.time;
}
const time =
parseFloat(document.getElementById('ai-time-' + p).value) ||
CUSTOM_AI_DEFAULT.time;
aiConfig.settings[p] = { time };
if (type === 'custom') {
aiConfig.params.custom = { time };
}
});
updateUI();
checkAndTriggerAI();
}修改 4:替換 detectAIPresetType()
function detectAIPresetType(p) {
const time = parseFloat(document.getElementById('ai-time-' + p).value);
for (const key of ['easy', 'hard', 'expert']) {
const preset = aiConfig.params[key];
if (time === preset.time) {
return key;
}
}
return 'custom';
}修改 5:替換 handleAITypeSelect()
function handleAITypeSelect(p) {
const type = document.getElementById('ai-p' + p + '-type').value;
if (type === 'easy' || type === 'hard' || type === 'expert') {
const preset = aiConfig.params[type];
document.getElementById('ai-time-' + p).value = preset.time;
aiConfig.params.custom = { ...CUSTOM_AI_DEFAULT };
} else if (type === 'custom') {
document.getElementById('ai-time-' + p).value =
aiConfig.params.custom.time;
} else {
aiConfig.params.custom = { ...CUSTOM_AI_DEFAULT };
}
updateAIParamInputsState(p);
}修改 6:替換 updateAIParamInputsState()
// 人類控制的一方不需要 AI 限時,將輸入框清空並停用。
function updateAIParamInputsState(p) {
const type = document.getElementById('ai-p' + p + '-type').value;
const isHuman = type === 'human';
const timeInput = document.getElementById('ai-time-' + p);
if (!timeInput) return;
timeInput.disabled = isHuman;
if (isHuman) {
timeInput.value = '';
}
}修改 7:替換 syncAIDialogFromConfirmed()
function syncAIDialogFromConfirmed() {
[1, 2].forEach(p => {
const val = aiConfig.type[p] || 'human';
document.getElementById('ai-p' + p + '-type').value = val;
document
.querySelectorAll(`#ai-list-${p} .ai-type-option`)
.forEach(el => {
el.classList.toggle(
'selected',
el.getAttribute('data-val') === val
);
});
updateAISelectedDisplay(p);
document.getElementById('ai-time-' + p).value =
aiConfig.settings[p].time;
updateAIParamInputsState(p);
const dropdown = document.getElementById('ai-dropdown-' + p);
if (dropdown) {
dropdown.classList.remove('open');
}
});
}handleAIParamInput() 可以保留,因為它會自動使用修改後的 detectAIPresetType()。
修改完成後,程式中不應再有以下內容:
ai-n-1
ai-n-2
ai-param-title-n
CUSTOM_AI_DEFAULT.n
aiConfig.settings[p].n
configuredDepth五、明確集中計分方判定
雖然原本計分方沒有寫反,但建議集中成一個函式,避免 AI 模擬和正式計分將來出現不同判定。
在 adjudicateSearchTurn() 前面加入:
/*
* scoringMode:
* 0 = 分數給頂棋方
* 1 = 分數給鑫棋方
* 2 = 舊相容模式,只計同方頂鑫
*/
function getTriGoldenScorer(scoringMode, dinger, victim) {
if (scoringMode === 0) {
return dinger.owner;
}
if (scoringMode === 1) {
return victim.owner;
}
if (scoringMode === 2 && dinger.owner === victim.owner) {
return dinger.owner;
}
return 0;
}然後在 adjudicateSearchTurn() 中找到:
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 scorer = getTriGoldenScorer(
state.scoringMode,
best.dinger,
best.victim
);這項修改不改變現有正確計分結果,只把規則集中化。
六、打亂三種棋子的搜尋優先順序
搜尋結果有明確優劣時,仍應選擇較好的棋;不能為了表面上的 1/3 機率而故意選差棋。
要修正的是:
- 同評分候選不再固定按照第一、第二、第三種棋;
- 每次排序時,三種棋成為第一優先種類的機率各為
1/3; - 同種類、同評分的具體落點也隨機打亂。
修改 8:在 getPlayerTileIds() 後加入洗牌函式
找到:
function getPlayerTileIds(player) {
return [0, 1, 2].map(i => 'tile' + (player === 1 ? i : i + 3));
}在它後面加入:
/*
* Fisher-Yates 洗牌。
* 不使用 array.sort(() => Math.random() - 0.5),避免不均勻洗牌。
*/
function shuffledCopy(items) {
const result = items.slice();
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}修改 9:打亂建立活法索引時的棋種順序
在 enumerateEdgeMethodsIncremental() 中找到:
const tileIds = getPlayerTileIds(forPlayer);替換為:
const tileIds = shuffledCopy(getPlayerTileIds(forPlayer));修改 10:打亂合法單子結果
在 generateLegalSinglePlacements() 最後找到:
return result;替換為:
return shuffledCopy(result);修改 11:打亂共活回合結果
在 generateAllCoLiveTurns() 最後找到:
return moves;替換為:
return shuffledCopy(moves);修改 12:打亂完整合法回合結果
在 generateLegalTurns() 最後找到:
return result;替換為:
return shuffledCopy(result);注意只替換上述三個指定函式結尾的 return,不要全域取代所有 return result。
七、修復「送分單接被當成好棋」
修改 13:替換 analyseSearchMove()
整個函式替換為:
function analyseSearchMove(state, move) {
const mover = state.mover;
const opponent = mover === 1 ? 2 : 1;
const applied = applySearchTurnToState(state, move, {
validate: false
});
/*
* 必須看淨得分,而不能只看有沒有形成頂鑫。
*
* 正數:本手對行動方有利。
* 負數:本手實際是在送分給對方。
*/
const moverGain =
applied.adjudication.gain[mover] -
applied.adjudication.gain[opponent];
let singleJieCount = 0;
for (const piece of move) {
const singleAdjudication = adjudicateSearchTurn(state, [piece]);
if (singleAdjudication.hasScoringStructure) {
singleJieCount++;
}
}
const isDoubleJie =
move.length === 2 &&
formsTriGolden(move[0], move[1]) &&
applied.adjudication.hasScoringStructure;
return {
childState: applied.state,
adjudication: applied.adjudication,
moverGain,
singleJieCount,
isDoubleJie,
/*
* 戰術搜索仍應涵蓋所有單接/雙接,
* 包括送分棋,讓 minimax 能把它判成壞棋;
* 但排序時不再因「存在結構」而一律加分。
*/
isTactical:
applied.adjudication.hasScoringStructure ||
singleJieCount > 0 ||
isDoubleJie
};
}修改 14:替換 orderMovesForSearch()
原本這兩項是錯誤來源:
analysis.singleJieCount * 15
analysis.isDoubleJie ? 25 : 0它們沒有分辨得分方。
將整個 orderMovesForSearch() 替換為:
/*
* 只排序、不刪除候選。
*
* 第一排序條件:2 ply 戰術結果。
* 第二排序條件:本回合實際淨得分。
* 第三排序條件:每次重新洗牌的棋種優先權。
* 第四排序條件:隨機值。
*/
function orderMovesForSearch(state, moves, tacticalScores = null) {
/*
* 每次排序都重新洗牌三種棋。
* 因此三種棋取得最高同分優先權的機率各為 1/3。
*/
const tilePriority = shuffledCopy(getPlayerTileIds(state.mover));
const tileRank = new Map(
tilePriority.map((tileId, index) => [tileId, index])
);
return moves
.map((move, originalIndex) => {
const signature = turnSignature(move);
const analysis = analyseSearchMove(state, move);
const tacticalScore =
tacticalScores && tacticalScores.has(signature)
? tacticalScores.get(signature)
: null;
/*
* 不再因為「有單接/雙接」一律增加正分。
* 如果該結構把分數送給對手,moverGain 會是負數。
*/
let orderScore = analysis.moverGain * 100;
if (tacticalScore !== null) {
const moverPerspective =
state.mover === tacticalScores.rootPlayer
? tacticalScore
: -tacticalScore;
/*
* 完整 2 ply 戰術結果優先於單純的即時得分。
*/
orderScore += moverPerspective * 1000;
}
const usedTileRanks = [
...new Set(move.map(piece => piece.svgId))
].map(tileId =>
tileRank.has(tileId)
? tileRank.get(tileId)
: Number.MAX_SAFE_INTEGER
);
const moveTypeRank =
usedTileRanks.length > 0
? Math.min(...usedTileRanks)
: Number.MAX_SAFE_INTEGER;
return {
move,
orderScore,
moveTypeRank,
randomTie: Math.random(),
originalIndex
};
})
.sort(
(a, b) =>
b.orderScore - a.orderScore ||
a.moveTypeRank - b.moveTypeRank ||
a.randomTie - b.randomTie ||
a.originalIndex - b.originalIndex
)
.map(item => item.move);
}這樣:
- 己方得 2 分、對方得 0 分:
moverGain = +2 - 己方得 0 分、對方得 2 分:
moverGain = -2 - 雙方各得 1 分:
moverGain = 0
不會再把「送分單接」視為額外正面排序條件。
八、統一為完整、不限時的 2 ply 戰術預搜尋
目前 runTacticalPresearch() 有兩個問題:
- 有獨立截止時間,超時後退回不完整結果;
- 只搜尋本手已經是戰術棋的根候選,無法檢查普通候選是否放出對方立即得分。
修改 15:替換 tacticalAlphaBeta()
整個函式替換為:
/*
* 戰術搜索只延伸單接/雙接。
*
* 不設 deadline,不會因時間不足返回部分結果。
* alpha-beta 的剪枝仍是完整的數學搜尋結果,
* 並不是超時或隨機省略候選。
*/
function tacticalAlphaBeta(
state,
depth,
alpha,
beta,
rootPlayer,
rootBaseDiff
) {
/*
* 取消 AI 是使用者主動中止,不屬於戰術搜索限時。
* 正常未取消時一定完整完成。
*/
if (cancelAi) {
return evaluateSearchState(state, rootPlayer, rootBaseDiff);
}
if (isSearchGameComplete(state)) {
return evaluateSearchState(state, rootPlayer, rootBaseDiff);
}
/*
* 即使還有單接/雙接,只要沒有共活,
* 行動方仍可依現有規則仲裁獲勝。
*/
if (!hasArbitrationDefence(state)) {
return evaluateArbitrationTerminal(
state,
rootPlayer,
rootBaseDiff
);
}
if (depth <= 0) {
return evaluateSearchState(state, rootPlayer, rootBaseDiff);
}
const legalMoves = generateLegalTurns(state);
if (legalMoves.length === 0) {
return evaluateArbitrationTerminal(
state,
rootPlayer,
rootBaseDiff
);
}
const tacticalChildren = [];
for (const move of legalMoves) {
const analysis = analyseSearchMove(state, move);
if (analysis.isTactical) {
tacticalChildren.push({
move,
analysis,
randomTie: Math.random()
});
}
}
/*
* 下一方沒有任何單接/雙接時,
* 這條戰術線就在目前分數結束。
*/
if (tacticalChildren.length === 0) {
return evaluateSearchState(state, rootPlayer, rootBaseDiff);
}
/*
* moverGain 永遠是當前行動方視角。
* 不論目前是根玩家還是對手,先搜尋當前方最有利的戰術棋,
* 可提高 alpha-beta 剪枝效率,但不改變完整結果。
*/
tacticalChildren.sort(
(a, b) =>
b.analysis.moverGain - a.analysis.moverGain ||
a.randomTie - b.randomTie
);
const maximizing = state.mover === rootPlayer;
if (maximizing) {
let best = -Infinity;
for (const child of tacticalChildren) {
const value = tacticalAlphaBeta(
child.analysis.childState,
depth - 1,
alpha,
beta,
rootPlayer,
rootBaseDiff
);
best = Math.max(best, value);
alpha = Math.max(alpha, best);
if (beta <= alpha) {
break;
}
}
return best;
}
let best = Infinity;
for (const child of tacticalChildren) {
const value = tacticalAlphaBeta(
child.analysis.childState,
depth - 1,
alpha,
beta,
rootPlayer,
rootBaseDiff
);
best = Math.min(best, value);
beta = Math.min(beta, best);
if (beta <= alpha) {
break;
}
}
return best;
}修改 16:替換 runTacticalPresearch()
整個函式替換為:
/*
* 對每一個根候選進行完整 2 ply 戰術搜索。
*
* 重要:
* 根候選本身不必是單接/雙接。
* 這樣才能檢查普通落子是否讓對手下一手立即單接/雙接得分。
*/
async function runTacticalPresearch(
rootState,
rootMoves,
targetDepth
) {
const rootPlayer = rootState.mover;
const opponent = rootPlayer === 1 ? 2 : 1;
const rootBaseDiff =
rootState.scores[rootPlayer] -
rootState.scores[opponent];
const completedScores = new Map();
completedScores.rootPlayer = rootPlayer;
for (let i = 0; i < rootMoves.length; i++) {
if (cancelAi) {
return null;
}
const move = rootMoves[i];
const analysis = analyseSearchMove(rootState, move);
/*
* targetDepth = 2:
* 第一個 ply 是根玩家的本手;
* 第二個 ply 是對手所有單接/雙接回應。
*/
const value = tacticalAlphaBeta(
analysis.childState,
targetDepth - 1,
-Infinity,
Infinity,
rootPlayer,
rootBaseDiff
);
completedScores.set(turnSignature(move), value);
await aiMaybeYield(
t('ai-thinking-candidates', {
phase:
`${targetDepth} ply 全單接/全雙接戰術搜索 ` +
`(${i + 1}/${rootMoves.length})`
})
);
}
/*
* 不存在超時 fallback。
* 未被使用者取消時,所有根候選都已完成搜索。
*/
return completedScores;
}此後可以刪除舊函式中的:
deadline
fallbackScores
tacticalRoots
performance.now() >= deadline
catch (error)
return fallbackScores九、MCTS 不再使用設定中的搜尋深度
搜尋深度設定移除後,MCTS 不需要再被人工指定 maxDepth。棋局本身是有限且無循環的,因此可以讓每條 MCTS 路徑自然向終局延伸;仍由時間限制控制搜尋次數。
修改 17:修改 runStateMCTS() 函式宣告
找到:
async function runStateMCTS(rootState, rootMoves, maxDepth, deadline, tacticalScores, cValue) {替換為:
async function runStateMCTS(
rootState,
rootMoves,
deadline,
tacticalScores,
cValue
) {修改 18:移除 MCTS 內的深度限制
在 MCTS 主迴圈內找到:
let node = root;
let depth = 0;
while (depth < maxDepth) {替換為:
let node = root;
while (true) {然後刪除該迴圈內兩處:
depth++;一處在建立新 child 後:
node = child;
path.push(node);
depth++;
break;改為:
node = child;
path.push(node);
break;另一處在選擇既有 child 後:
node = selected;
path.push(node);
depth++;改為:
node = selected;
path.push(node);因為每一層都會實際減少剩餘棋子,棋局不存在循環,所以 while (true) 最終會在以下任一情況退出:
- 正常完局;
- 仲裁終局;
- 沒有合法回合;
- 本次展開一個新 child。
不會形成無限迴圈。
十、所有 AI 統一在最後三回合使用 minimax
修改 19:修改 runStateMinimax() 宣告
原本最後一個參數沒有實際使用。
找到:
async function runStateMinimax(rootState, rootMoves, maxDepth, deadline, tacticalScores) {替換為:
async function runStateMinimax(
rootState,
rootMoves,
maxDepth,
deadline
) {其餘函式內容可保持不變。
修改 20:加入剩餘回合計算函式
在 getRequiredPiecesForState() 後面加入:
/*
* 計算從目前局面開始還剩多少個完整回合。
*
* 一般每回合使用兩子,整局最後一回合使用一子,
* 因此剩餘回合數為 ceil(剩餘總棋子數 / 2)。
*
* 剩餘 5 子時:
* 後手 2 子、先手 2 子、後手最後 1 子,共三回合。
*/
function getRemainingTurnCount(state) {
const totalPiecesRemaining = Object.values(state.counts).reduce(
(sum, count) => sum + Math.max(0, count),
0
);
return Math.ceil(totalPiecesRemaining / 2);
}這正好對應:
- 倒數第 3 回合:後手方倒數第 2 回合;
- 倒數第 2 回合:先手方最後 1 回合;
- 倒數第 1 回合:後手方最後 1 回合。
也就是使用者要求的「後手方最後兩回合或先手方最後一回合」。
十一、替換完整 startAI()
這是最重要的一項。它會確保:
- minimax 階段直接進 minimax,不執行戰術預搜尋;
- MCTS 階段固定先完整完成 2 ply 戰術搜尋;
- 戰術搜尋不受時間限制;
- 完成後 MCTS 才開始計算自己的完整限時;
- 所有 AI 都在最後三回合切換 minimax;
- 不再讀取任何搜尋深度設定。
將原本整個 startAI() 替換為:
async function startAI() {
aiThinking = true;
cancelAi = false;
updateUI();
showMessage(t('ai-thinking-ellipsis'), 0, true);
const aiPlayer = currentPlayer;
/*
* 開局中心棋仍保留原有的隨機種類、翻面及角度。
*/
if (
turnNumber === 1 &&
pieces.length === 0 &&
tempPieces.length === 0
) {
placeAIFirstMove(aiPlayer);
aiThinking = false;
document.getElementById('message-box').style.display = 'none';
return;
}
/*
* AI 設定只保留正式 MCTS/minimax 的時間限制。
* 戰術預搜尋不使用這個 deadline。
*/
const timeLimit =
Math.max(0.2, aiConfig.settings[aiPlayer].time || 3) * 1000;
/*
* 最後顯示的總用時,包含:
* 增量索引、候選生成、戰術預搜尋及正式搜尋。
*/
const totalStartTime = performance.now();
let searchCount = 0;
lastAIYieldTime = 0;
await aiYield(t('ai-thinking-step1'));
const rootState = createSearchStateFromGlobals(aiPlayer);
await ensureStateIncremental(rootState);
if (cancelAi) {
aiThinking = false;
return;
}
/*
* 仲裁判定只看共活。
*/
const arbitrationDefences =
generateArbitrationDefenceTurns(rootState);
if (
!isSearchGameComplete(rootState) &&
arbitrationDefences.length === 0
) {
aiThinking = false;
aiTriggerArbitrationSuccess();
return;
}
/*
* 正式搜索仍使用所有合法回合。
*/
let rootMoves = generateLegalTurns(rootState);
if (rootMoves.length === 0) {
aiThinking = false;
aiTriggerArbitrationSuccess();
return;
}
if (cancelAi) {
aiThinking = false;
return;
}
/*
* 先打亂原始候選,消除持久索引建立順序造成的棋種偏差。
*/
rootMoves = shuffledCopy(rootMoves);
const turnsLeft = getRemainingTurnCount(rootState);
/*
* 所有 AI 統一只在最後三個完整回合進入 minimax。
*/
const useMinimax =
turnsLeft <= ENDGAME_MINIMAX_TURNS;
let result;
if (useMinimax) {
/*
* 已進入最後三回合:
* 不再執行全單接/全雙接戰術預搜尋,
* 直接由完整 minimax 涵蓋所有合法分支。
*/
rootMoves = orderMovesForSearch(rootState, rootMoves);
const searchStartTime = performance.now();
const searchDeadline = searchStartTime + timeLimit;
result = await runStateMinimax(
rootState,
rootMoves,
turnsLeft,
searchDeadline
);
} else {
/*
* 尚未進入 minimax:
* easy、hard、expert、custom 全部統一先做完整 2 ply
* 全單接/全雙接戰術搜尋。
*
* 此階段不設時間上限。
*/
await aiYield(
t('ai-thinking-candidates', {
phase:
`${TACTICAL_PRESEARCH_PLY} ply ` +
'全單接/全雙接戰術搜索'
})
);
const tacticalScores = await runTacticalPresearch(
rootState,
rootMoves,
TACTICAL_PRESEARCH_PLY
);
if (cancelAi || !tacticalScores) {
aiThinking = false;
return;
}
/*
* 所有根候選均完成 2 ply 戰術評估後才排序。
* 不刪除候選。
*/
rootMoves = orderMovesForSearch(
rootState,
rootMoves,
tacticalScores
);
if (cancelAi) {
aiThinking = false;
return;
}
/*
* 到這裡才開始 MCTS 的完整限時。
* 戰術預搜尋耗時不會侵蝕 MCTS 時間。
*/
const searchStartTime = performance.now();
const searchDeadline = searchStartTime + timeLimit;
result = await runStateMCTS(
rootState,
rootMoves,
searchDeadline,
tacticalScores,
MCTS_C
);
}
searchCount = result.searchCount || 0;
if (cancelAi) {
aiThinking = false;
return;
}
const bestMove = result.bestMove || rootMoves[0];
if (!bestMove) {
aiThinking = false;
aiTriggerArbitrationSuccess();
return;
}
const totalTime = (
(performance.now() - totalStartTime) /
1000
).toFixed(1);
const valueText = useMinimax
? t('ai-score-label', {
score: Number.isFinite(result.value)
? result.value
: 0
})
: t('ai-winrate-label', {
rate: (
Math.max(
0,
Math.min(1, result.value || 0)
) * 100
).toFixed(1)
});
showMessage(
t('ai-done', {
time: totalTime,
count: searchCount,
val: valueText
}),
3000,
true
);
/*
* 搜尋棋轉成正式暫存棋。
* actionCheck() 仍會使用統一合法性和正式計分引擎再次驗證,
* 因此不會繞過現有遊戲流程。
*/
const actualMove = bestMove.map(source => {
const piece = JSON.parse(JSON.stringify(source));
piece.id = nextPieceId++;
piecesCount[piece.svgId]--;
return piece;
});
tempPieces = actualMove;
updateUI();
renderBoard();
setTimeout(() => {
actionCheck();
}, 600);
aiThinking = false;
}十二、修改後的實際搜尋流程
十三、建議驗證項目
修改後至少測試以下情況:
-
頂棋計分模式
- 新棋頂到舊棋時,確認分數加給新棋/頂棋所屬方。
- 舊棋頂到新棋時,確認分數加給舊棋/頂棋所屬方。
-
鑫棋計分模式
- 確認分數永遠加給被頂邊的
victim.owner。
- 確認分數永遠加給被頂邊的
-
送分單接
- 建立一個 AI 下單接只會讓對方得分的局面。
- 該候選的
moverGain應為負數,不再得到單接排序獎勵。
-
下一手立即得分
- 建立兩個根候選:
- 候選 A 本手不計分,但會讓對手下一手單接得分;
- 候選 B 本手不計分,對手下一手沒有得分戰術。
- 2 ply 戰術分數應判定 B 優於 A。
- 建立兩個根候選:
-
minimax 啟動時機
- 剩餘 7 子、4 回合時:使用 MCTS。
- 剩餘 5 子、3 回合時:切換 minimax。
- 這三回合依次為後手、先手、後手。
-
棋種順序
- 同評分局面多次重新開始 AI 搜尋。
- 三類棋不應再固定以
tile0/tile3、tile1/tile4、tile2/tile5的順序展開。 - 搜尋結果有明確策略優劣時,使用率不必硬性各占三分之一;只有同評分優先權才應均等,否則會損害 AI 正確性。
以上修改不改變正式 actionCheck()、validateTurnOnState()、applySearchTurnToState()、仲裁和棋譜流程;主要修正 AI 對計分結果的理解、候選排序、2 ply 覆蓋範圍、搜尋時機和棋種順序偏差。