共享会话
當前的MCTS棋力很弱,改進使其變強。明確給出所要修改的地方和所要替換的代碼
分享于 2026年9月14日 00:30
QuanAI我會先完整檢視附件中的棋局表示、合法著生成、勝負判定,以及 MCTS 的選擇/擴展/模擬/回傳程式,再據此標出可直接替換的區塊。
我已檢視附件。棋力弱的主因集中在內嵌的 mcts-worker-source:
- 節點會先展開所有著法才深入搜尋,分支很多時搜尋樹非常淺。
- 模擬策略過於隨機:專家 AI 仍有約 32% 機率直接亂走,而且只抽樣少量著法。
- 沒有必勝著、立即送勝、對手下一手必勝的檢查。
- 沒有著法排序或先驗機率,好壞著法獲得近似相同的搜尋資源。
- Worker 啟動失敗時,
runFallbackAI()會直接隨機走,表面上看起來就像 MCTS 極弱。
下面改成:
- PUCT 選擇。
- 漸進式展開(Progressive Widening)。
- 根節點必勝/防一手必敗檢查。
- 搜尋樹內立即勝利著優先。
- 戰術導向 rollout。
- 島嶼控制、元法諾線、島內成線、機動力評估。
- 無合法著時正確作為和局。
- 保持原本 Worker 訊息格式,
startAI()不必重寫。
一、替換整個 MCTS Worker
在檔案中找到:
html
<script id="mcts-worker-source" type="javascript/worker">一直到它對應的:
html
</script>將整段完整替換成以下程式:
html
<script id="mcts-worker-source" type="javascript/worker">
'use strict';
const LINES = [
[0, 1, 4],
[0, 2, 6],
[4, 5, 6],
[0, 3, 5],
[1, 3, 6],
[2, 3, 4],
[1, 2, 5]
];
const CELL_COUNT = 49;
const SIDE_SIZE = 7;
const MOVE_BASE = 49;
/*
* 島內與島群線形的評估權重。
* 島群中已有兩個己方控制島且第三格未被敵方控制,
* 是最重要的非終局特徵。
*/
const LOCAL_WEIGHTS = [0, 0.35, 3.4, 14];
const META_WEIGHTS = [0.1, 2.8, 20, 200];
const PROFILES = {
easy: {
rolloutDepth: 16,
policySamples: 5,
rolloutEpsilon: 0.24,
rolloutNoise: 5,
rolloutTop: 4,
rolloutRankPower: 1.5,
rolloutSafetyDepth: 1,
rolloutSafetyChecks: 1,
priorTemperature: 58,
replySamples: 3,
replyWeight: 0.12,
maxThreatCount: 3,
lateTreeSafetyPly: 99,
lateTreeSafetyDepth: 0,
rootWidening: 4.8,
widening: 1.9,
wideningExponent: 0.5,
cpuctMultiplier: 1.15,
batchSize: 12
},
hard: {
rolloutDepth: 24,
policySamples: 10,
rolloutEpsilon: 0.09,
rolloutNoise: 1.6,
rolloutTop: 4,
rolloutRankPower: 2.1,
rolloutSafetyDepth: 4,
rolloutSafetyChecks: 2,
priorTemperature: 46,
replySamples: 5,
replyWeight: 0.22,
maxThreatCount: 4,
lateTreeSafetyPly: 38,
lateTreeSafetyDepth: 1,
rootWidening: 4.2,
widening: 1.65,
wideningExponent: 0.5,
cpuctMultiplier: 1.05,
batchSize: 8
},
expert: {
rolloutDepth: 32,
policySamples: 16,
rolloutEpsilon: 0.02,
rolloutNoise: 0.45,
rolloutTop: 3,
rolloutRankPower: 3,
rolloutSafetyDepth: 8,
rolloutSafetyChecks: 3,
priorTemperature: 36,
replySamples: 7,
replyWeight: 0.34,
maxThreatCount: 6,
lateTreeSafetyPly: 32,
lateTreeSafetyDepth: 2,
rootWidening: 3.8,
widening: 1.5,
wideningExponent: 0.5,
cpuctMultiplier: 1,
batchSize: 6
},
custom: {
rolloutDepth: 38,
policySamples: 24,
rolloutEpsilon: 0.006,
rolloutNoise: 0.16,
rolloutTop: 3,
rolloutRankPower: 3.6,
rolloutSafetyDepth: 12,
rolloutSafetyChecks: 4,
priorTemperature: 31,
replySamples: 9,
replyWeight: 0.42,
maxThreatCount: 8,
lateTreeSafetyPly: 28,
lateTreeSafetyDepth: 3,
rootWidening: 3.6,
widening: 1.45,
wideningExponent: 0.5,
cpuctMultiplier: 0.95,
batchSize: 4
}
};
let rngState = 0x9e3779b9;
function seedRandom(seed) {
rngState = seed >>> 0;
if (!rngState) rngState = 0x9e3779b9;
}
function random() {
let value = rngState | 0;
value ^= value << 13;
value ^= value >>> 17;
value ^= value << 5;
rngState = value >>> 0;
return rngState / 4294967296;
}
function randomInt(limit) {
return (random() * limit) | 0;
}
function clamp(value, minimum, maximum) {
return Math.max(minimum, Math.min(maximum, value));
}
function hashState(state) {
let hash = 2166136261 >>> 0;
for (let i = 0; i < CELL_COUNT; i++) {
hash ^= (state.board[i] + 2 + i * 3) & 255;
hash = Math.imul(hash, 16777619);
}
for (let i = 0; i < SIDE_SIZE; i++) {
hash ^= (state.claims[i] + 2 + i * 11) & 255;
hash = Math.imul(hash, 16777619);
}
hash ^= state.turn === 1 ? 0x51ed270b : 0x7f4a7c15;
hash = Math.imul(hash, 16777619);
hash ^= state.ply >>> 0;
return hash >>> 0;
}
function encodeMove(from, to) {
return from * MOVE_BASE + to;
}
function moveFrom(move) {
return (move / MOVE_BASE) | 0;
}
function moveTo(move) {
const from = moveFrom(move);
return move - from * MOVE_BASE;
}
function publicMove(move) {
if (!Number.isInteger(move) || move < 0) return null;
return {
from: moveFrom(move),
to: moveTo(move)
};
}
function hasIslandLine(board, island, player) {
const base = island * SIDE_SIZE;
for (const line of LINES) {
if (
board[base + line[0]] === player &&
board[base + line[1]] === player &&
board[base + line[2]] === player
) {
return true;
}
}
return false;
}
function islandOwner(board, island) {
const fire = hasIslandLine(board, island, 1);
const ice = hasIslandLine(board, island, -1);
if (fire && !ice) return 1;
if (ice && !fire) return -1;
return 0;
}
function hasMetaLine(claims, player) {
for (const line of LINES) {
if (
claims[line[0]] === player &&
claims[line[1]] === player &&
claims[line[2]] === player
) {
return true;
}
}
return false;
}
function generateMoves(state) {
const board = state.board;
const player = state.turn;
const result = [];
for (let from = 0; from < CELL_COUNT; from++) {
if (board[from] !== player) continue;
const island = (from / SIDE_SIZE) | 0;
const point = from % SIDE_SIZE;
const base = island * SIDE_SIZE;
for (let nextPoint = 0; nextPoint < SIDE_SIZE; nextPoint++) {
if (nextPoint === point) continue;
const to = base + nextPoint;
if (board[to] === 0) {
result.push(encodeMove(from, to));
}
}
for (let nextIsland = 0; nextIsland < SIDE_SIZE; nextIsland++) {
if (nextIsland === island) continue;
const to = nextIsland * SIDE_SIZE + point;
if (board[to] === 0) {
result.push(encodeMove(from, to));
}
}
}
return result;
}
function cloneState(state) {
return {
board: state.board.slice(),
claims: state.claims.slice(),
turn: state.turn,
winner: state.winner,
ply: state.ply
};
}
function applyMoveInPlace(state, move) {
const from = moveFrom(move);
const to = moveTo(move);
const player = state.turn;
state.board[to] = player;
state.board[from] = -player;
const originIsland = (from / SIDE_SIZE) | 0;
const destinationIsland = (to / SIDE_SIZE) | 0;
state.claims[originIsland] = islandOwner(state.board, originIsland);
if (destinationIsland !== originIsland) {
state.claims[destinationIsland] = islandOwner(state.board, destinationIsland);
}
let winner = 0;
/*
* 與主執行緒的規則一致:
* 先檢查行動方,再檢查對手。
*/
if (hasMetaLine(state.claims, player)) {
winner = player;
} else if (hasMetaLine(state.claims, -player)) {
winner = -player;
}
state.turn = -player;
state.winner = winner;
state.ply++;
return state;
}
function applyMove(state, move) {
const next = cloneState(state);
return applyMoveInPlace(next, move);
}
function terminalReward(state, rootPlayer) {
if (state.winner === rootPlayer) return 1;
if (state.winner === -rootPlayer) return 0;
if (state.winner === 2) return 0.5;
return -1;
}
function ownerBalance(owner, player) {
if (owner === player) return 1;
if (owner === -player) return -1;
return 0;
}
function claimBalance(claims, player) {
let score = 0;
for (let i = 0; i < SIDE_SIZE; i++) {
score += ownerBalance(claims[i], player);
}
return score;
}
function metaPotential(claims, player) {
let score = 0;
for (const line of LINES) {
let own = 0;
let enemy = 0;
for (const island of line) {
if (claims[island] === player) own++;
else if (claims[island] === -player) enemy++;
}
if (enemy === 0) {
score += META_WEIGHTS[own];
}
}
return score;
}
function metaBalance(claims, player) {
return metaPotential(claims, player) - metaPotential(claims, -player);
}
function localPotential(board, island, player) {
const base = island * SIDE_SIZE;
let score = 0;
for (const line of LINES) {
let own = 0;
let enemy = 0;
for (const point of line) {
const occupant = board[base + point];
if (occupant === player) own++;
else if (occupant === -player) enemy++;
}
if (enemy === 0) {
score += LOCAL_WEIGHTS[own];
}
}
return score;
}
function localBalance(board, island, player) {
return localPotential(board, island, player) - localPotential(board, island, -player);
}
function mobilityBalance(state, player) {
const emptyByIsland = new Int8Array(SIDE_SIZE);
const emptyByPoint = new Int8Array(SIDE_SIZE);
for (let index = 0; index < CELL_COUNT; index++) {
if (state.board[index] !== 0) continue;
const island = (index / SIDE_SIZE) | 0;
const point = index % SIDE_SIZE;
emptyByIsland[island]++;
emptyByPoint[point]++;
}
let score = 0;
for (let index = 0; index < CELL_COUNT; index++) {
const occupant = state.board[index];
if (!occupant) continue;
const island = (index / SIDE_SIZE) | 0;
const point = index % SIDE_SIZE;
const mobility = emptyByIsland[island] + emptyByPoint[point];
if (occupant === player) score += mobility;
else score -= mobility;
}
return score;
}
/*
* 暫時在原棋盤上走一步,再完整恢復。
* withScore=false 時只檢查勝負,供 rollout 的快速戰術掃描使用。
*/
function inspectMove(state, move, withScore = true) {
const from = moveFrom(move);
const to = moveTo(move);
const player = state.turn;
const originIsland = (from / SIDE_SIZE) | 0;
const destinationIsland = (to / SIDE_SIZE) | 0;
const oldFrom = state.board[from];
const oldTo = state.board[to];
const oldOriginClaim = state.claims[originIsland];
const oldDestinationClaim = state.claims[destinationIsland];
let beforeClaim = 0;
let beforeMeta = 0;
let beforeLocal = 0;
if (withScore) {
beforeClaim = ownerBalance(oldOriginClaim, player);
if (destinationIsland !== originIsland) {
beforeClaim += ownerBalance(oldDestinationClaim, player);
}
beforeMeta = metaBalance(state.claims, player);
beforeLocal = localBalance(state.board, originIsland, player);
if (destinationIsland !== originIsland) {
beforeLocal += localBalance(state.board, destinationIsland, player);
}
}
state.board[to] = player;
state.board[from] = -player;
state.claims[originIsland] = islandOwner(state.board, originIsland);
if (destinationIsland !== originIsland) {
state.claims[destinationIsland] = islandOwner(state.board, destinationIsland);
}
let winner = 0;
if (hasMetaLine(state.claims, player)) {
winner = player;
} else if (hasMetaLine(state.claims, -player)) {
winner = -player;
}
let score = 0;
if (withScore) {
let afterClaim = ownerBalance(state.claims[originIsland], player);
if (destinationIsland !== originIsland) {
afterClaim += ownerBalance(state.claims[destinationIsland], player);
}
const afterMeta = metaBalance(state.claims, player);
let afterLocal = localBalance(state.board, originIsland, player);
if (destinationIsland !== originIsland) {
afterLocal += localBalance(state.board, destinationIsland, player);
}
score =
(afterClaim - beforeClaim) * 90 +
(afterMeta - beforeMeta) * 13 +
(afterLocal - beforeLocal) * 4.5;
if (winner === player) {
score = 1000000000;
} else if (winner === -player) {
score = -1000000000;
}
}
state.board[from] = oldFrom;
state.board[to] = oldTo;
state.claims[originIsland] = oldOriginClaim;
if (destinationIsland !== originIsland) {
state.claims[destinationIsland] = oldDestinationClaim;
}
return {
winner,
score
};
}
function evaluateRaw(state, player) {
let local = 0;
for (let island = 0; island < SIDE_SIZE; island++) {
local += localBalance(state.board, island, player);
}
return (
claimBalance(state.claims, player) * 70 +
metaBalance(state.claims, player) * 14 +
local * 4 +
mobilityBalance(state, player) * 0.18 +
(state.turn === player ? 2 : -2)
);
}
function findImmediateWinningMove(state, moves = null) {
const candidates = moves || generateMoves(state);
const player = state.turn;
for (const move of candidates) {
if (inspectMove(state, move, false).winner === player) {
return move;
}
}
return -1;
}
function heuristicReward(state, rootPlayer) {
const terminal = terminalReward(state, rootPlayer);
if (terminal >= 0) return terminal;
const moves = generateMoves(state);
if (!moves.length) return 0.5;
/*
* 輪到的一方已有立即勝利著時,不應由一般靜態評估掩蓋。
*/
if (findImmediateWinningMove(state, moves) !== -1) {
return state.turn === rootPlayer ? 0.99 : 0.01;
}
const raw = evaluateRaw(state, rootPlayer);
const scaled = clamp(raw / 110, -8, 8);
const probability = 1 / (1 + Math.exp(-scaled));
/*
* 靜態評估不返回絕對 0/1,以便真正終局始終比估值更可信。
*/
return 0.02 + probability * 0.96;
}
function analyseReplies(state, config) {
if (state.winner) {
return {
replyCount: 0,
immediateWins: 0,
bestScore: 0
};
}
const moves = generateMoves(state);
if (!moves.length) {
state.winner = 2;
return {
replyCount: 0,
immediateWins: 0,
bestScore: 0
};
}
const player = state.turn;
let immediateWins = 0;
/*
* 完整掃描所有回應,檢查對手是否下一手即可獲勝。
* 數量只需計到上限,超過後已足以判定此著非常危險。
*/
for (const move of moves) {
if (inspectMove(state, move, false).winner === player) {
immediateWins++;
if (immediateWins >= config.maxThreatCount) {
break;
}
}
}
/*
* 額外抽樣少量對手回應,作為兩層戰術排序。
*/
let bestScore = 0;
const samples = Math.min(config.replySamples, moves.length);
for (let index = 0; index < samples; index++) {
const move = moves[randomInt(moves.length)];
const score = inspectMove(state, move, true).score;
if (score > bestScore) {
bestScore = score;
}
}
return {
replyCount: moves.length,
immediateWins,
bestScore
};
}
class Node {
constructor(state, parent = null, move = -1, prior = 1, depth = 0) {
this.state = state;
this.parent = parent;
this.move = move;
this.prior = prior;
this.depth = depth;
this.children = [];
this.actions = null;
this.nextAction = 0;
this.originalMoveCount = 0;
this.visits = 0;
this.valueSum = 0;
this.wins = 0;
}
}
function assignPriors(actions, config) {
if (!actions.length) return;
actions.forEach(action => {
action.tie = random();
});
actions.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return right.tie - left.tie;
});
const maximum = actions[0].score;
let total = 0;
for (const action of actions) {
const scaled = clamp(
(action.score - maximum) / config.priorTemperature,
-14,
0
);
action.weight = Math.exp(scaled) + 0.001;
total += action.weight;
}
for (const action of actions) {
action.prior = action.weight / total;
delete action.weight;
delete action.tie;
}
}
function buildActions(node, config) {
const state = node.state;
if (state.winner) {
node.actions = [];
node.originalMoveCount = 0;
return;
}
const moves = generateMoves(state);
node.originalMoveCount = moves.length;
if (!moves.length) {
state.winner = 2;
node.actions = [];
return;
}
const player = state.turn;
const all = [];
const winning = [];
const nonLosing = [];
for (const move of moves) {
const inspected = inspectMove(state, move, true);
const action = {
move,
score: inspected.score,
winner: inspected.winner,
danger: 0,
prior: 0,
cachedState: null
};
all.push(action);
if (inspected.winner === player) {
winning.push(action);
}
if (inspected.winner !== -player) {
nonLosing.push(action);
}
}
/*
* 有立即勝利著時,只保留勝利著。
* 沒有立即勝利時,只要存在不會當場送勝的著法,
* 就排除直接令對手獲勝的著法。
*/
let actions = winning.length
? winning
: nonLosing.length
? nonLosing
: all;
const useSafetyScreen =
node.depth === 0 ||
(
state.ply >= config.lateTreeSafetyPly &&
node.depth <= config.lateTreeSafetyDepth
);
/*
* 根節點一定做一手防敗檢查;
* 後盤分支下降後,也在較淺的樹節點進行同樣檢查。
*/
if (!winning.length && useSafetyScreen && actions.length > 1) {
for (const action of actions) {
const next = applyMove(state, action.move);
action.cachedState = next;
if (next.winner) {
action.danger =
next.winner === player
? 0
: config.maxThreatCount + 1;
continue;
}
const replyAnalysis = analyseReplies(next, config);
action.danger = replyAnalysis.immediateWins;
/*
* 對手下一手的局部戰術收益也會影響此著的先驗排序。
*/
action.score -=
clamp(replyAnalysis.bestScore, 0, 320) *
config.replyWeight;
}
const safeActions = actions.filter(action => action.danger === 0);
if (safeActions.length) {
/*
* 存在不會被下一手直接擊敗的著法時,
* 不再浪費搜尋量於立即送勝的著法。
*/
actions = safeActions;
} else {
/*
* 如果所有著法都會遭遇立即威脅,
* 優先選擇讓對手必勝回應較少的著法。
*/
for (const action of actions) {
action.score -= action.danger * 5000;
}
}
}
assignPriors(actions, config);
node.actions = actions;
node.nextAction = 0;
}
function ensureActions(node, config) {
if (node.actions === null) {
buildActions(node, config);
}
}
function allowedChildren(node, config) {
const factor = node.depth === 0
? config.rootWidening
: config.widening;
return Math.max(
1,
Math.floor(
factor *
Math.pow(node.visits + 1, config.wideningExponent)
)
);
}
function shouldExpand(node, config) {
if (!node.actions || node.nextAction >= node.actions.length) {
return false;
}
if (!node.children.length) {
return true;
}
return node.children.length < allowedChildren(node, config);
}
function selectChild(node, rootPlayer, config) {
const rootToMove = node.state.turn === rootPlayer;
const parentScale = Math.sqrt(node.visits + 1);
const cpuct = config.exploration * config.cpuctMultiplier;
let best = null;
let bestScore = -Infinity;
for (const child of node.children) {
const rootMean = child.visits
? child.valueSum / child.visits
: 0.5;
/*
* 所有 value 都以根玩家視角儲存。
* 對手節點則最大化 1-rootMean,相當於最小化根玩家收益。
*/
const exploitation = rootToMove
? rootMean
: 1 - rootMean;
const exploration =
cpuct *
child.prior *
parentScale /
(1 + child.visits);
const score =
exploitation +
exploration +
random() * 1e-10;
if (score > bestScore) {
bestScore = score;
best = child;
}
}
return best;
}
function chooseRolloutMove(state, moves, config, rolloutDepth) {
const player = state.turn;
const allMoves = [];
const nonLosingMoves = [];
/*
* 先用較便宜的勝負檢查完整掃描:
* 1. 有立即勝利著就直接走。
* 2. 只要有其他選擇,就不走立即送勝著。
*/
for (const move of moves) {
const winner = inspectMove(state, move, false).winner;
if (winner === player) {
return move;
}
allMoves.push(move);
if (winner !== -player) {
nonLosingMoves.push(move);
}
}
const pool = nonLosingMoves.length
? nonLosingMoves
: allMoves;
if (random() < config.rolloutEpsilon) {
return pool[randomInt(pool.length)];
}
/*
* 不放回抽樣,避免舊版同一著被重複抽到、
* 實際只評估兩三個不同著法的問題。
*/
const shuffled = pool.slice();
const sampleCount = Math.min(config.policySamples, shuffled.length);
const candidates = [];
for (let index = 0; index < sampleCount; index++) {
const swapIndex =
index +
randomInt(shuffled.length - index);
const temporary = shuffled[index];
shuffled[index] = shuffled[swapIndex];
shuffled[swapIndex] = temporary;
const move = shuffled[index];
const inspected = inspectMove(state, move, true);
candidates.push({
move,
score:
inspected.score +
(random() - 0.5) * config.rolloutNoise
});
}
candidates.sort((left, right) => right.score - left.score);
const shortlistLength = Math.min(
candidates.length,
Math.max(
config.rolloutTop,
config.rolloutSafetyChecks
)
);
let shortlist = candidates.slice(0, shortlistLength);
/*
* rollout 前幾層對候選著法做一手防敗檢查,
* 大幅減少隨機模擬中的低級送勝。
*/
if (
rolloutDepth < config.rolloutSafetyDepth &&
config.rolloutSafetyChecks > 0
) {
const safeCandidates = [];
const checks = Math.min(
config.rolloutSafetyChecks,
shortlist.length
);
for (let index = 0; index < checks; index++) {
const candidate = shortlist[index];
const next = applyMove(state, candidate.move);
if (next.winner === player) {
return candidate.move;
}
if (next.winner === -player) {
continue;
}
const replies = generateMoves(next);
if (!replies.length) {
next.winner = 2;
safeCandidates.push(candidate);
continue;
}
if (findImmediateWinningMove(next, replies) === -1) {
safeCandidates.push(candidate);
}
}
if (safeCandidates.length) {
shortlist = safeCandidates;
}
}
const topCount = Math.min(
config.rolloutTop,
shortlist.length
);
/*
* 從前幾名中抽取,強度越高越偏向第一名,
* 但仍保留少量變化,避免 rollout 完全同質化。
*/
const rank = Math.min(
topCount - 1,
Math.floor(
Math.pow(random(), config.rolloutRankPower) *
topCount
)
);
return shortlist[rank].move;
}
function rollout(startState, rootPlayer, config) {
const simulation = cloneState(startState);
const remainingMoves = Math.max(
1,
CELL_COUNT - 1 - simulation.ply
);
const depthLimit = Math.min(
config.rolloutDepth,
remainingMoves
);
for (let depth = 0; depth < depthLimit; depth++) {
const terminal = terminalReward(simulation, rootPlayer);
if (terminal >= 0) {
return terminal;
}
const moves = generateMoves(simulation);
if (!moves.length) {
return 0.5;
}
const move = chooseRolloutMove(
simulation,
moves,
config,
depth
);
applyMoveInPlace(simulation, move);
}
return heuristicReward(simulation, rootPlayer);
}
function runIteration(root, rootPlayer, config) {
let node = root;
let reward = -1;
while (true) {
const terminal = terminalReward(node.state, rootPlayer);
if (terminal >= 0) {
reward = terminal;
break;
}
ensureActions(node, config);
if (!node.actions.length) {
reward = terminalReward(node.state, rootPlayer);
if (reward < 0) {
reward = 0.5;
}
break;
}
if (shouldExpand(node, config)) {
const action = node.actions[node.nextAction++];
const nextState =
action.cachedState ||
applyMove(node.state, action.move);
action.cachedState = null;
const child = new Node(
nextState,
node,
action.move,
action.prior,
node.depth + 1
);
node.children.push(child);
node = child;
break;
}
const selected = selectChild(
node,
rootPlayer,
config
);
if (!selected) {
break;
}
node = selected;
}
if (reward < 0) {
reward = rollout(node.state, rootPlayer, config);
}
while (node) {
node.visits++;
node.valueSum += reward;
if (reward >= 0.999999) {
node.wins++;
}
node = node.parent;
}
}
function bestRootChild(root) {
let best = null;
for (const child of root.children) {
if (
!best ||
child.visits > best.visits ||
(
child.visits === best.visits &&
child.valueSum / child.visits >
best.valueSum / best.visits
)
) {
best = child;
}
}
return best;
}
self.onmessage = function (event) {
const data = event.data;
if (!data || data.type !== 'start') {
return;
}
const level = PROFILES[data.level]
? data.level
: 'hard';
const config = {
...PROFILES[level]
};
const suppliedExploration = Number(data.exploration);
config.exploration = Number.isFinite(suppliedExploration)
? clamp(suppliedExploration, 0.1, 4)
: 1.2;
const limit = Math.max(
50,
Number(data.limitMs) || 6000
);
const rootState = {
board: Int8Array.from(data.state.board),
claims: Int8Array.from(data.state.claims),
turn: data.state.turn,
winner: data.state.winner,
ply: data.state.ply
};
const token = data.token;
const rootPlayer = rootState.turn;
seedRandom(
hashState(rootState) ^
(Number(token) || 0) ^
(Date.now() >>> 0)
);
const root = new Node(rootState);
const start = performance.now();
const deadline = start + limit;
let iterations = 0;
let nextUpdate = 100;
const initialTerminal = terminalReward(
rootState,
rootPlayer
);
if (initialTerminal >= 0) {
self.postMessage({
type: 'done',
token,
elapsed: performance.now() - start,
iterations: 0,
move: null,
expected: initialTerminal,
winRate: initialTerminal === 1 ? 1 : 0
});
return;
}
/*
* 先完成根節點戰術篩選。
* 這一步能保證 AI 不會漏掉當前立即勝利著。
*/
ensureActions(root, config);
if (!root.actions.length) {
self.postMessage({
type: 'done',
token,
elapsed: performance.now() - start,
iterations: 0,
move: null,
expected: 0.5,
winRate: 0
});
return;
}
const immediateWin = root.actions.find(
action => action.winner === rootPlayer
);
if (immediateWin) {
self.postMessage({
type: 'done',
token,
elapsed: performance.now() - start,
iterations: 0,
move: publicMove(immediateWin.move),
expected: 1,
winRate: 1
});
return;
}
while (performance.now() < deadline) {
for (
let batch = 0;
batch < config.batchSize;
batch++
) {
runIteration(root, rootPlayer, config);
iterations++;
if (
(batch & 3) === 3 &&
performance.now() >= deadline
) {
break;
}
}
const elapsed = performance.now() - start;
if (elapsed >= nextUpdate) {
self.postMessage({
type: 'progress',
token,
elapsed,
iterations
});
nextUpdate += 100;
if (nextUpdate < elapsed) {
nextUpdate =
Math.floor(elapsed / 100) * 100 + 100;
}
}
}
const best = bestRootChild(root);
let selectedMove = -1;
let expected = 0.5;
let winRate = 0;
if (best && best.visits) {
selectedMove = best.move;
expected = best.valueSum / best.visits;
winRate = best.wins / best.visits;
} else {
/*
* 即使時間全花在根節點戰術分析,
* 也能使用排序第一的合法著,而不是隨機著。
*/
const fallbackAction = root.actions[0];
selectedMove = fallbackAction.move;
const next =
fallbackAction.cachedState ||
applyMove(rootState, selectedMove);
const terminal = terminalReward(next, rootPlayer);
expected = terminal >= 0
? terminal
: heuristicReward(next, rootPlayer);
winRate = terminal === 1 ? 1 : 0;
}
self.postMessage({
type: 'done',
token,
elapsed: performance.now() - start,
iterations,
move: publicMove(selectedMove),
expected,
winRate
});
};
</script>二、替換 AI 探索常數
找到原本的:
js
const AI_LEVELS = {
easy: { ms: DEFAULT_AI_SECONDS.easy * 1000, exploration: 1.65 },
hard: { ms: DEFAULT_AI_SECONDS.hard * 1000, exploration: 1.42 },
expert: { ms: DEFAULT_AI_SECONDS.expert * 1000, exploration: 1.22 },
custom: { ms: DEFAULT_AI_SECONDS.custom * 1000, exploration: 1.4 }
};替換成:
js
const AI_LEVELS = {
easy: {
ms: DEFAULT_AI_SECONDS.easy * 1000,
exploration: 1.55
},
hard: {
ms: DEFAULT_AI_SECONDS.hard * 1000,
exploration: 1.25
},
expert: {
ms: DEFAULT_AI_SECONDS.expert * 1000,
exploration: 1.05
},
custom: {
ms: DEFAULT_AI_SECONDS.custom * 1000,
exploration: 1.15
}
};新搜尋使用的是帶先驗機率的 PUCT,不宜再使用舊版偏高的 UCT 探索常數。
三、修改自訂 AI 的預設探索值
3.1 修改 HTML 滑桿
找到:
html
<input id="explorationRange" type="range" min=".60" max="2.20" step=".05" value="1.40" />
<output id="explorationOutput" class="range-output">1.40</output>替換成:
html
<input id="explorationRange" type="range" min=".60" max="2.20" step=".05" value="1.15" />
<output id="explorationOutput" class="range-output">1.15</output>3.2 修改 JavaScript 預設值
找到:
js
let preferences = {
exploration: 1.4,
customTimeSeconds: DEFAULT_AI_SECONDS.custom
};替換成:
js
let preferences = {
exploration: 1.15,
customTimeSeconds: DEFAULT_AI_SECONDS.custom
};建議值:
0.85~1.00:更偏重目前最佳著法,適合長時間搜尋。1.05~1.25:推薦範圍。1.40以上:分配較多時間到次要著法,通常不會更強。- 不建議低於
0.75,否則容易過早鎖定錯誤分支。
四、替換隨機後備 AI
這一項很重要。若瀏覽器、CSP 或 blob: Worker 權限造成 Worker 啟動失敗,原程式會執行:
js
const move = moves[(Math.random() * moves.length) | 0];也就是完全隨機走。
找到整個原本的:
js
function runFallbackAI(token, started) {
...
}替換成:
js
function runFallbackAI(token, started) {
if (token !== aiToken) return;
const rootPlayer = state.turn;
const level = controllers[rootPlayer];
const moves = getLegalMoves(state);
thinking = false;
if (!moves.length) {
state.winner = 2;
history[historyIndex].state = cloneState(state);
renderAll();
return;
}
const localWeights = [0, 0.4, 3, 12];
const metaWeights = [0, 3, 22, 1000];
function fallbackPositionScore(position) {
if (position.winner === rootPlayer) return 1000000;
if (position.winner === -rootPlayer) return -1000000;
if (position.winner === 2) return 0;
let score = 0;
/*
* 已控制島嶼的價值。
*/
for (let island = 0; island < 7; island++) {
if (position.claims[island] === rootPlayer) {
score += 80;
} else if (position.claims[island] === -rootPlayer) {
score -= 80;
}
}
/*
* 整個群島上的成線潛力。
*/
for (const line of LOCAL_LINES) {
let own = 0;
let enemy = 0;
for (const island of line) {
if (position.claims[island] === rootPlayer) {
own++;
} else if (position.claims[island] === -rootPlayer) {
enemy++;
}
}
if (enemy === 0) {
score += metaWeights[own];
}
if (own === 0) {
score -= metaWeights[enemy];
}
}
/*
* 各島內的局部成線潛力。
*/
for (let island = 0; island < 7; island++) {
const base = island * 7;
for (const line of LOCAL_LINES) {
let own = 0;
let enemy = 0;
for (const point of line) {
const occupant = position.board[base + point];
if (occupant === rootPlayer) {
own++;
} else if (occupant === -rootPlayer) {
enemy++;
}
}
if (enemy === 0) {
score += localWeights[own] * 4;
}
if (own === 0) {
score -= localWeights[enemy] * 4;
}
}
}
return score;
}
let bestMove = null;
let bestNext = null;
let bestScore = -Infinity;
for (const move of moves) {
const next = applyMove(state, move);
if (!next) continue;
let score = fallbackPositionScore(next);
if (next.winner === rootPlayer) {
score = 1000000;
} else if (next.winner === -rootPlayer) {
score = -1000000;
} else if (next.winner !== 2) {
/*
* 排除會讓對手下一手立即勝利的著法。
*/
const replies = getLegalMoves(next);
let opponentCanWinImmediately = false;
for (const reply of replies) {
const afterReply = applyMove(next, reply);
if (afterReply?.winner === -rootPlayer) {
opponentCanWinImmediately = true;
break;
}
}
if (opponentCanWinImmediately) {
score -= 500000;
}
}
/*
* 只用極小亂數打破完全同分,不影響戰術排序。
*/
score += Math.random() * 1e-7;
if (score > bestScore) {
bestScore = score;
bestMove = move;
bestNext = next;
}
}
if (!bestMove) {
bestMove = moves[0];
bestNext = applyMove(state, bestMove);
bestScore = bestNext
? fallbackPositionScore(bestNext)
: 0;
}
let expected;
if (bestNext?.winner === rootPlayer) {
expected = 1;
} else if (bestNext?.winner === -rootPlayer) {
expected = 0;
} else if (bestNext?.winner === 2) {
expected = 0.5;
} else {
const bounded = Math.max(-500, Math.min(500, bestScore));
expected = 1 / (1 + Math.exp(-bounded / 110));
}
commitMove(bestMove, 'ai', {
elapsed: performance.now() - started,
iterations: 0,
expected,
winRate: bestNext?.winner === rootPlayer ? 1 : 0,
level
});
}五、建議加入 Worker 失敗提示
找到:
js
} catch (error) {
runFallbackAI(token, started);
return;
}替換成:
js
} catch (error) {
console.error('MCTS Worker 建立失敗,改用戰術後備 AI:', error);
runFallbackAI(token, started);
return;
}再找到:
js
aiWorker.onerror = () => {
if (token !== aiToken) return;
aiWorker?.terminate();
aiWorker = null;
runFallbackAI(token, started);
};替換成:
js
aiWorker.onerror = event => {
if (token !== aiToken) return;
console.error(
'MCTS Worker 執行失敗,改用戰術後備 AI:',
event.message || event
);
aiWorker?.terminate();
aiWorker = null;
runFallbackAI(token, started);
};如果控制台出現這個錯誤,部署網站的 CSP 需允許:
text
worker-src 'self' blob:否則真正的 MCTS Worker 不會執行。
改動後的主要差異
| 項目 | 舊版 | 新版 |
|---|---|---|
| 樹展開 | 所有著法展開完才深入 | 漸進式展開,較早搜尋深層 |
| 選擇公式 | 無先驗 UCT | 帶策略先驗的 PUCT |
| 當前必勝著 | 可能因隨機而很晚才發現 | 搜尋開始前完整檢查 |
| 一手送勝 | 沒有排除 | 有安全著時直接排除 |
| Rollout 候選 | 最多約 2~7 次且可重複 | 最多 24 個不同候選 |
| 專家隨機率 | 約 32% 完全隨機 | 約 2% |
| 自訂隨機率 | 約 12% 完全隨機 | 約 0.6% |
| 著法排序 | 無 | 控制島、元成線、島內成線、對手威脅 |
| Worker 失敗 | 完全隨機 | 一層戰術後備 AI |
新版本每一次 iteration 做的工作比舊版更多,因此畫面顯示的「MCTS 搜尋次數」可能降低;不能單純用 iteration 數判斷強弱。新的單次模擬會檢查必勝、送勝和戰術候選,品質遠高於舊版隨機 rollout。