共享会话
🔄 重寫MCTS與Minimax算法
分享于 2026年8月15日 02:16重寫MCTS搜尋和minimax搜尋一個棋子的一條邊如果有至少一種方式可以放置一個對方棋子,則稱為「活邊」,否則為「死邊」。每一種放置方式為一種「活法」。
如果一條邊的某一個「活法」能跟另一條邊形成頂鑫結構,則標注為「單接」,並計其得分。
「死邊」有以下幾種「死法」:「貼死」、「頂死」、「夾死」以及「悶死」。「貼死」指的是有另一個棋子的邊與其貼合,「頂死」指被另一個棋子頂到,「夾死」指如果兩條互相接觸的邊夾角為36度則兩條邊都是夾死,或者如果夾角為72度則長度為1的邊會被夾死,「悶死」指的是不屬於以上情況但沒有棋子能合規放置。其中「貼死」的邊將不能再跟其它棋子形成頂鑫結構。
在當前盤面下,記錄每一條邊的「死活」情況以及所有的「活法」。在以後的每步棋或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子是否跟某些邊的某些活法在面積上重疊,或在邊上重合,或懸空的頂點重合」,更變已有棋子的邊的「死活」。而新加入的棋子的邊又會有新的「死活」。
在當前盤面下,統計所有同屬一方的棋子的活法兩兩之間是否形成頂鑫結構,如果存在則為這兩個活法都標注「雙接」。每一種「活法」都有一個特別的id,互相記下id以及得分情況。注意一個「活法」可以跟不同的「活法」之間產生「雙接」。在以後的每步棋或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子的活法是否能和舊的棋子的活法產生雙接」。
在當前盤面下,統計每個具有至少兩個「活邊」的棋子上的活法兩兩之間是否可以一起合規落子,即判斷這兩活法之間是否「在面積上重疊,在邊上重合,或懸空的頂點重合」,若能合規落子則兩個活法都相互記下id並標注「共活」。注意一個「活法」可以跟同一個棋子上的另一條邊上的多個「活法」產生「共活」。在以後的每回合或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子是否消滅了之前的共活或是否產生了新的共活」。
當進行MCTS搜尋時,使己方得分的「單接」和己方的「雙接」都將是優先選項。如果是「單接」,則在這一回合的第二手棋之前,先要更新全盤的「死活」、所有棋子的「活法」、「雙接」以及「共活」。如果選擇的是「雙接」,則在這一回合之後更新。
如果當前沒有使己方得分的「單接」和己方的「雙接」,則需在所有的「共活」之中,優先挑選能破壞使對方得分的「單接」和對方的「雙接」,並且不要產生新的使對方得分的「單接」和對方的「雙接」。
統計「共活」的算法跟「仲裁」算法應是一樣的,當在MCTS搜尋時,不能下出使對方下回合沒有「共活」的下法。
minimax將為「廣度優先」,將首先對所有能得分的下法進行搜尋,僅當沒有能得分的下法時才,對所有的「共活」進行搜尋。但在後手方最後一手棋時,如果沒有使他加分的「單接」,則只需隨機挑一個活法下即可。在消息框實時顯示當前計算的層數和用時。
當遊戲開始時,即使雙方都不是AI,程式也要保持對所有棋子的邊的「死活」、「活法」、「單接」、「雙接」、「共活」的增量統計。當人類玩家需要展示「預放棋子」時,則調出相應的「活法」。當一方是AI,而如果當前盤面所有棋子的邊的「死活」、「活法」、「單接」、「雙接」、「共活」都沒有統計完成,則把當前盤面的統計完成後再開始MCTS搜尋。限時只用於MCTS搜尋,但當MCTS搜尋限時結束,消息框要給出總用時。
以上的MCTS搜尋算法和minimax算法,將取代之前的「步驟一、步驟二、步驟三、步驟四」。
上面所說的「長度為1的邊」是指每個棋子中都有的兩條最短的邊。
「先手方」指的是第一局的玩家一和第二局的玩家二,「後手方」則是第一局的玩家二和第二局的玩家一。
回答要求:
明確給出所要修改的地方和所要替換的代碼。
QuanAI以下修改以你提供的 HTML 為準;舊的「步驟一~四」候選生成、舊 MCTS、舊 minimax,以及舊的死活/活法追蹤區塊都不要保留,否則會出現同名函式或舊邏輯仍被呼叫的問題。
1. 補充訊息字典
在 const i18n = { ... } 中,將原本的 ai-mcts-progress 替換,並加入以下項目:
'ai-tracking': {
zh: 'AI 正在完成全盤死活/活法/單接/雙接/共活統計…',
en: 'AI is completing board life/method/single/double/co-live statistics…'
},
'ai-mcts-progress': {
zh: '蒙地卡羅樹搜尋 | 層數: {depth} | 用時: {time}s | 節點: {count}',
en: 'MCTS | Depth: {depth} | Time: {time}s | Nodes: {count}'
},
'ai-mcts-done': {
zh: 'MCTS 完成 | 總用時: {time}s | 節點: {count} | 勝率: {rate}%',
en: 'MCTS done | Total: {time}s | Nodes: {count} | Win rate: {rate}%'
},
'ai-no-safe-move': {
zh: '所有合規下法都會令對方下回合失去共活;AI 不會違反限制而落子,已切換為人類操作。',
en: 'Every legal move removes the opponent’s next co-live. AI will not violate the constraint; control is now human.'
},2. 取代 formsTriGolden 與 simulateScoringTriGolden
找到原本這兩個函式:
function formsTriGolden(A, B) { ... }
function simulateScoringTriGolden(allNewP, pIds) { ... }完整替換為:
function formsTriGolden(A, B, allPieces = pieces.concat(tempPieces)) {
return (
getUsableDingContacts(A, B, allPieces).length > 0 ||
getUsableDingContacts(B, A, allPieces).length > 0
);
}
function simulateScoringTriGolden(allNewP, pIds) {
const idSet = new Set(pIds);
const candidatePieces = allNewP.filter(p => idSet.has(p.id));
const basePieces = allNewP.filter(p => !idSet.has(p.id));
const result = scoreGainForCandidates(basePieces, candidatePieces, new Set(scoredVictims));
return result.claimedKeys.length > 0;
}3. 修改真正計分時的「貼死邊不可形成頂鑫」
在 actionCheck() 裡,找到這一段:
let dingVertices = [];
for (let v of A.vertices) {
let isDing = false;
for (let e = 0; e < 4; e++) {
if (pointOnOpenSegment(v, B.vertices[e], B.vertices[(e + 1) % 4])) {
isDing = true;
break;
}
}
if (isDing) dingVertices.push(v);
}替換成:
// 貼死邊不可再作為鑫邊形成頂鑫。
let dingVertices = getUsableDingVertices(A, B, allNewP);這會讓:
貼死的邊不再計算頂鑫;- AI 的單接/雙接得分判斷;
- 人類實際落子後的計分;
三者使用一致規則。
4. 完整取代舊的「死活/活法/互頂追蹤系統」
找到從這個註解開始的整段:
// =============================================================================
// 死活/活法/互頂 追蹤系統(增量式)
// =============================================================================一直刪除到、但不包含原本的:
function getAllValidMoves(player) {然後貼入以下完整區塊。
// =============================================================================
// 死活/活法/單接/雙接/共活:增量追蹤系統
// =============================================================================
const DEAD_EDGE_KIND = Object.freeze({
glued: '貼死',
poked: '頂死',
clamped: '夾死',
suffocated: '悶死'
});
const EDGE_LENGTH_KEYS = [1, 2, 'phi', 'phi2', 'twoPhi'];
let lifeState = null;
let edgeRecords = new Map();
let methodRegistry = new Map();
let nextMethodId = 1;
let liveTrackingReady = false;
let trackingGeneration = 0;
let lifeTrackingUpdatePromise = Promise.resolve();
function edgeKey(pieceId, edgeIdx) {
return `${pieceId}_${edgeIdx}`;
}
function pointKey(p) {
return p.join('_');
}
function cloneBoardPiece(piece) {
return {
...piece,
vertices: piece.vertices.map(v => [...v])
};
}
function placementSignature(piece) {
return [
piece.svgId,
piece.isFlipped ? 1 : 0,
piece.targetId,
piece.targetEdge,
piece.myEdge,
piece.vertices.map(pointKey).join('|')
].join(':');
}
function stockForBoard(board) {
const stock = {
tile0: N_PIECES,
tile1: N_PIECES,
tile2: N_PIECES,
tile3: N_PIECES,
tile4: N_PIECES,
tile5: N_PIECES
};
for (const p of board) {
if (Object.prototype.hasOwnProperty.call(stock, p.svgId)) {
stock[p.svgId]--;
}
}
return stock;
}
function isFloatingVertex(ghost, vertexIndex) {
const attachedA = ghost.myEdge;
const attachedB = (ghost.myEdge + 1) % 4;
return vertexIndex !== attachedA && vertexIndex !== attachedB;
}
function getExactEdgeInfo(S, E) {
const vector = ringSub(E, S);
for (const length of EDGE_LENGTH_KEYS) {
for (let angle = 0; angle < 10; angle++) {
if (pointsEqual(vector, edgeVector(length, angle))) {
return { length, angle };
}
}
}
return null;
}
function sharedEndpointData(S1, E1, S2, E2) {
if (pointsEqual(S1, S2)) return { shared: S1, free1: E1, free2: E2 };
if (pointsEqual(S1, E2)) return { shared: S1, free1: E1, free2: S2 };
if (pointsEqual(E1, S2)) return { shared: E1, free1: S1, free2: E2 };
if (pointsEqual(E1, E2)) return { shared: E1, free1: S1, free2: S2 };
return null;
}
function edgeIsClamped(S1, E1, S2, E2) {
const data = sharedEndpointData(S1, E1, S2, E2);
if (!data) return false;
const a = getExactEdgeInfo(data.shared, data.free1);
const b = getExactEdgeInfo(data.shared, data.free2);
if (!a || !b) return false;
let delta = Math.abs(a.angle - b.angle) % 10;
delta = Math.min(delta, 10 - delta);
// 36°:兩邊都夾死。
if (delta === 1) return true;
// 72°:只有長度 1 的邊夾死。
if (delta === 2 && a.length === 1) return true;
return false;
}
function isGluedEdgeOnBoard(piece, edgeIdx, allPieces) {
const S = piece.vertices[edgeIdx];
const E = piece.vertices[(edgeIdx + 1) % 4];
for (const other of allPieces) {
if (other.id === piece.id) continue;
for (let j = 0; j < 4; j++) {
const S2 = other.vertices[j];
const E2 = other.vertices[(j + 1) % 4];
if (segmentsPerfectlyMatch(S, E, S2, E2)) return true;
}
}
return false;
}
function getUsableDingContacts(A, B, allPieces) {
const contacts = [];
for (let vi = 0; vi < 4; vi++) {
const vertex = A.vertices[vi];
for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
const S = B.vertices[edgeIdx];
const E = B.vertices[(edgeIdx + 1) % 4];
if (
pointOnOpenSegment(vertex, S, E) &&
!isGluedEdgeOnBoard(B, edgeIdx, allPieces)
) {
contacts.push({
dinger: A,
victim: B,
vertex,
vertexIndex: vi,
victimEdge: edgeIdx,
key: `${B.id}_${pointKey(vertex)}`
});
break;
}
}
}
return contacts;
}
function getUsableDingVertices(A, B, allPieces) {
return getUsableDingContacts(A, B, allPieces).map(c => c.vertex);
}
function scoreGainForCandidates(basePieces, candidatePieces, alreadyScored = new Set(), options = {}) {
const allPieces = basePieces.concat(candidatePieces);
const candidateIds = new Set(candidatePieces.map(p => p.id));
const dingMap = new Map();
const rawContacts = [];
let formed = false;
for (const A of allPieces) {
for (const B of allPieces) {
if (A.id === B.id) continue;
const aIsNew = candidateIds.has(A.id);
const bIsNew = candidateIds.has(B.id);
if (options.onlyBetweenCandidates) {
if (!aIsNew || !bIsNew) continue;
} else if (!aIsNew && !bIsNew) {
continue;
}
const contacts = getUsableDingContacts(A, B, allPieces);
for (const contact of contacts) {
formed = true;
rawContacts.push(contact);
if (alreadyScored.has(contact.key)) continue;
const treeRes = getTreeDistance(A, B, allPieces);
if (!treeRes || treeRes.dist < 0) continue;
if (!dingMap.has(contact.key)) dingMap.set(contact.key, []);
dingMap.get(contact.key).push({
...contact,
dist: treeRes.dist,
path: treeRes.path
});
}
}
}
const gain = { 1: 0, 2: 0 };
const claimedKeys = [];
dingMap.forEach((dings, key) => {
let best = null;
for (const d of dings) {
if (!best || d.dist < best.dist) best = d;
}
if (!best) return;
// 與 actionCheck 的行為一致:即使距離為 0,
// 這個鑫點也會被標記為已處理,之後不可重複計分。
claimedKeys.push(key);
if (best.dist <= 0) return;
let scorer = 0;
if (SCORING_MODE === 0) scorer = best.dinger.owner;
else if (SCORING_MODE === 1) scorer = best.victim.owner;
else if (SCORING_MODE === 2 && best.dinger.owner === best.victim.owner) {
scorer = best.dinger.owner;
}
if (scorer > 0) gain[scorer] += best.dist;
});
return {
formed,
gain,
claimedKeys,
contacts: rawContacts
};
}
// 相容於其他既有呼叫點。
function simulateScoreGain(candidatePieces) {
return scoreGainForCandidates(pieces, candidatePieces, new Set(scoredVictims)).gain;
}
function classifyDeadEdge(piece, edgeIdx, allPieces) {
const S = piece.vertices[edgeIdx];
const E = piece.vertices[(edgeIdx + 1) % 4];
// 1. 貼死
if (isGluedEdgeOnBoard(piece, edgeIdx, allPieces)) {
return DEAD_EDGE_KIND.glued;
}
// 2. 頂死
for (const other of allPieces) {
if (other.id === piece.id) continue;
for (const v of other.vertices) {
if (pointOnOpenSegment(v, S, E)) {
return DEAD_EDGE_KIND.poked;
}
}
}
// 3. 夾死
for (const other of allPieces) {
if (other.id === piece.id) continue;
for (let j = 0; j < 4; j++) {
const S2 = other.vertices[j];
const E2 = other.vertices[(j + 1) % 4];
if (edgeIsClamped(S, E, S2, E2)) {
return DEAD_EDGE_KIND.clamped;
}
}
}
// 4. 悶死
return DEAD_EDGE_KIND.suffocated;
}
function edgeTouchesNewPiece(piece, edgeIdx, newPiece) {
const S = piece.vertices[edgeIdx];
const E = piece.vertices[(edgeIdx + 1) % 4];
for (const v of newPiece.vertices) {
if (pointsEqual(v, S) || pointsEqual(v, E) || pointOnOpenSegment(v, S, E)) {
return true;
}
}
for (let j = 0; j < 4; j++) {
const S2 = newPiece.vertices[j];
const E2 = newPiece.vertices[(j + 1) % 4];
if (
segmentsOverlapAsEdges(S, E, S2, E2) ||
edgeIsClamped(S, E, S2, E2)
) {
return true;
}
}
return false;
}
function ghostIsInvalidatedByPiece(ghost, newPiece) {
if (shapesOverlap(ghost, newPiece)) return true;
for (let i = 0; i < 4; i++) {
const S1 = ghost.vertices[i];
const E1 = ghost.vertices[(i + 1) % 4];
for (let j = 0; j < 4; j++) {
const S2 = newPiece.vertices[j];
const E2 = newPiece.vertices[(j + 1) % 4];
if (segmentsOverlapAsEdges(S1, E1, S2, E2)) {
return true;
}
}
}
// 只檢查懸空頂點;貼合邊的兩個端點不屬於懸空頂點。
for (let vi = 0; vi < 4; vi++) {
if (!isFloatingVertex(ghost, vi)) continue;
for (const v2 of newPiece.vertices) {
if (pointsEqual(ghost.vertices[vi], v2)) return true;
}
}
return false;
}
class LifeState {
constructor(board = [], stock = null, scoredKeys = null) {
this.board = [...board];
this.stock = stock ? { ...stock } : stockForBoard(board);
this.scoredKeys = scoredKeys ? new Set(scoredKeys) : new Set();
this.edgeRecords = new Map();
this.methods = new Map();
this.nextMethodId = 1;
this.nextSyntheticPieceId = -1000000000;
this.ready = false;
}
clone() {
const copy = new LifeState([], {}, new Set());
copy.board = this.board.map(cloneBoardPiece);
copy.stock = { ...this.stock };
copy.scoredKeys = new Set(this.scoredKeys);
copy.nextMethodId = this.nextMethodId;
copy.nextSyntheticPieceId = this.nextSyntheticPieceId;
copy.ready = this.ready;
for (const [key, rec] of this.edgeRecords) {
copy.edgeRecords.set(key, {
...rec,
methodIds: [...rec.methodIds]
});
}
for (const [id, method] of this.methods) {
const cloned = {
...method,
ghost: cloneBoardPiece(method.ghost),
single: {
...method.single,
scoreByPlayer: { ...method.single.scoreByPlayer },
contacts: method.single.contacts.map(c => ({ ...c }))
},
doubleJie: new Map(),
mutual: null,
coLive: new Map()
};
copy.methods.set(id, cloned);
}
for (const [id, method] of this.methods) {
const cloned = copy.methods.get(id);
method.doubleJie.forEach((link, otherId) => {
cloned.doubleJie.set(otherId, {
...link,
scoreByPlayer: { ...link.scoreByPlayer },
contacts: link.contacts.map(c => ({ ...c }))
});
});
cloned.mutual = cloned.doubleJie;
cloned.coLive = new Map(method.coLive);
}
return copy;
}
getPlayerTiles(player) {
return [0, 1, 2].map(n => `tile${player === 1 ? n : n + 3}`);
}
playerRemaining(player) {
return this.getPlayerTiles(player).reduce((sum, tile) => sum + Math.max(0, this.stock[tile] || 0), 0);
}
materializeGhost(ghost) {
const p = cloneBoardPiece(ghost);
p.id = this.nextSyntheticPieceId--;
return p;
}
enumerateEdgeMethods(piece, edgeIdx) {
const methods = [];
const forPlayer = piece.owner === 1 ? 2 : 1;
const tiles = this.getPlayerTiles(forPlayer).filter(tile => (this.stock[tile] || 0) > 0);
const seen = new Set();
for (const tile of tiles) {
for (const flip of [false, true]) {
for (let myEdge = 0; myEdge < 4; myEdge++) {
const res = attachByEdge(
SHAPE_MAP[tile],
flip,
myEdge,
piece.vertices[edgeIdx],
piece.vertices[(edgeIdx + 1) % 4],
!!piece.isFlipped
);
if (!res) continue;
const ghost = {
id: -1,
vertices: res.vertices,
type: SHAPE_MAP[tile],
owner: forPlayer,
svgId: tile,
isFlipped: flip,
edgeOnOpp: edgeIdx,
targetId: piece.id,
targetEdge: edgeIdx,
myEdge,
parentId: piece.id,
level: (piece.level || 0) + 1
};
const sig = placementSignature(ghost);
if (seen.has(sig)) continue;
seen.add(sig);
if (isValidGhost(ghost, this.board)) {
methods.push(ghost);
}
}
}
}
return methods;
}
removeMethod(methodId) {
const method = this.methods.get(methodId);
if (!method) return;
method.doubleJie.forEach((_, otherId) => {
const other = this.methods.get(otherId);
if (other) other.doubleJie.delete(methodId);
});
method.coLive.forEach((_, otherId) => {
const other = this.methods.get(otherId);
if (other) other.coLive.delete(methodId);
});
const rec = this.edgeRecords.get(edgeKey(method.ownerEdgePieceId, method.edgeIdx));
if (rec) {
rec.methodIds = rec.methodIds.filter(id => id !== methodId);
}
this.methods.delete(methodId);
}
buildEdgeRecord(piece, edgeIdx) {
const key = edgeKey(piece.id, edgeIdx);
const old = this.edgeRecords.get(key);
if (old) {
old.methodIds.slice().forEach(id => this.removeMethod(id));
}
const ghosts = this.enumerateEdgeMethods(piece, edgeIdx);
const record = {
pieceId: piece.id,
edgeIdx,
owner: piece.owner,
status: ghosts.length > 0 ? 'live' : 'dead',
life: ghosts.length > 0 ? '活邊' : '死邊',
deadKind: ghosts.length > 0 ? null : classifyDeadEdge(piece, edgeIdx, this.board),
methodIds: []
};
for (const ghost of ghosts) {
const id = this.nextMethodId++;
ghost.id = -100000 - id;
const method = {
id,
ownerEdgePieceId: piece.id,
edgeIdx,
forPlayer: ghost.owner,
ghost,
isSingleJie: false,
jieScore: 0,
jieScoreOpp: 0,
single: {
formed: false,
scoreByPlayer: { 1: 0, 2: 0 },
contacts: []
},
// partnerId -> { scoreByPlayer, contacts, ... }
doubleJie: new Map(),
mutual: null,
// partnerId -> true
coLive: new Map()
};
method.mutual = method.doubleJie;
this.methods.set(id, method);
record.methodIds.push(id);
}
this.edgeRecords.set(key, record);
return record;
}
pairIsLegal(methodA, methodB, requireSameTarget = false) {
if (!methodA || !methodB) return false;
if (methodA.id === methodB.id) return false;
if (methodA.forPlayer !== methodB.forPlayer) return false;
if (
methodA.ghost.svgId === methodB.ghost.svgId &&
(this.stock[methodA.ghost.svgId] || 0) < 2
) {
return false;
}
if (
methodA.targetId === methodB.targetId &&
methodA.targetEdge === methodB.targetEdge
) {
return false;
}
if (requireSameTarget) {
if (methodA.ownerEdgePieceId !== methodB.ownerEdgePieceId) return false;
if (methodA.edgeIdx === methodB.edgeIdx) return false;
}
const a = cloneBoardPiece(methodA.ghost);
const b = cloneBoardPiece(methodB.ghost);
const orderAB =
isValidGhost(a, this.board) &&
isValidGhost(b, this.board.concat([a]));
const orderBA =
isValidGhost(b, this.board) &&
isValidGhost(a, this.board.concat([b]));
return orderAB || orderBA;
}
refreshSingle(method) {
const result = scoreGainForCandidates(this.board, [method.ghost], this.scoredKeys);
method.single = {
formed: result.formed,
scoreByPlayer: { ...result.gain },
contacts: result.contacts.map(c => ({
victimId: c.victim.id,
victimEdge: c.victimEdge,
key: c.key
}))
};
method.isSingleJie = result.formed;
method.jieScore = result.gain[method.forPlayer];
method.jieScoreOpp = result.gain[method.forPlayer === 1 ? 2 : 1];
}
evaluateDouble(methodA, methodB) {
if (!this.pairIsLegal(methodA, methodB, false)) return null;
const result = scoreGainForCandidates(
this.board,
[methodA.ghost, methodB.ghost],
this.scoredKeys,
{ onlyBetweenCandidates: true }
);
if (!result.formed) return null;
return {
scoreByPlayer: { ...result.gain },
contacts: result.contacts.map(c => ({
victimId: c.victim.id,
victimEdge: c.victimEdge,
key: c.key
}))
};
}
setDouble(methodA, methodB, result) {
methodA.doubleJie.set(methodB.id, {
partnerId: methodB.id,
scoreByPlayer: { ...result.scoreByPlayer },
contacts: result.contacts.map(c => ({ ...c }))
});
methodB.doubleJie.set(methodA.id, {
partnerId: methodA.id,
scoreByPlayer: { ...result.scoreByPlayer },
contacts: result.contacts.map(c => ({ ...c }))
});
}
addDoubleIfAny(methodA, methodB) {
if (!methodA || !methodB) return;
if (methodA.doubleJie.has(methodB.id)) return;
const result = this.evaluateDouble(methodA, methodB);
if (result) this.setDouble(methodA, methodB, result);
}
refreshExistingDoubles() {
const pairs = [];
this.methods.forEach(method => {
method.doubleJie.forEach((_, otherId) => {
if (method.id < otherId) pairs.push([method.id, otherId]);
});
});
for (const [aId, bId] of pairs) {
const a = this.methods.get(aId);
const b = this.methods.get(bId);
if (!a || !b) continue;
const result = this.evaluateDouble(a, b);
if (!result) {
a.doubleJie.delete(bId);
b.doubleJie.delete(aId);
} else {
this.setDouble(a, b, result);
}
}
}
updateCoLiveForPiece(pieceId) {
const ids = [];
for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
const rec = this.edgeRecords.get(edgeKey(pieceId, edgeIdx));
if (rec && rec.status === 'live') {
ids.push(...rec.methodIds);
}
}
for (const id of ids) {
const method = this.methods.get(id);
if (!method) continue;
for (const otherId of [...method.coLive.keys()]) {
const other = this.methods.get(otherId);
if (other) other.coLive.delete(id);
}
method.coLive.clear();
}
for (let i = 0; i < ids.length; i++) {
const a = this.methods.get(ids[i]);
if (!a) continue;
for (let j = i + 1; j < ids.length; j++) {
const b = this.methods.get(ids[j]);
if (!b) continue;
// 共活必須在同一棋子、不同邊上,且完全沿用仲裁的兩子合法性判定。
if (this.pairIsLegal(a, b, true)) {
a.coLive.set(b.id, true);
b.coLive.set(a.id, true);
}
}
}
}
rebuild() {
this.edgeRecords.clear();
this.methods.clear();
this.nextMethodId = 1;
for (const piece of this.board) {
for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
this.buildEdgeRecord(piece, edgeIdx);
}
}
this.methods.forEach(method => this.refreshSingle(method));
const methods = [...this.methods.values()];
for (let i = 0; i < methods.length; i++) {
for (let j = i + 1; j < methods.length; j++) {
this.addDoubleIfAny(methods[i], methods[j]);
}
}
for (const piece of this.board) {
this.updateCoLiveForPiece(piece.id);
}
this.ready = true;
}
pieceGluedStoredContact(newPiece, contact) {
const victim = this.board.find(p => p.id === contact.victimId);
if (!victim || victim.id === newPiece.id) return false;
const S = victim.vertices[contact.victimEdge];
const E = victim.vertices[(contact.victimEdge + 1) % 4];
for (let i = 0; i < 4; i++) {
if (
segmentsPerfectlyMatch(
S,
E,
newPiece.vertices[i],
newPiece.vertices[(i + 1) % 4]
)
) {
return true;
}
}
return false;
}
addPlacedPiece(piece) {
const placed = cloneBoardPiece(piece);
if (this.board.some(p => p.id === placed.id)) return;
if (Object.prototype.hasOwnProperty.call(this.stock, placed.svgId)) {
this.stock[placed.svgId] = Math.max(0, this.stock[placed.svgId] - 1);
}
const affectedPieceIds = new Set([placed.id]);
// 棋子庫存耗盡後,對應類型的活法立即失效。
for (const method of [...this.methods.values()]) {
if (
method.forPlayer === placed.owner &&
(this.stock[method.ghost.svgId] || 0) <= 0
) {
affectedPieceIds.add(method.ownerEdgePieceId);
this.removeMethod(method.id);
}
}
// 同一方少了一枚棋,所有該方的共活可能因庫存變化而失效。
this.methods.forEach(method => {
if (method.forPlayer === placed.owner) {
affectedPieceIds.add(method.ownerEdgePieceId);
}
});
this.board.push(placed);
// 只移除被新棋子破壞的既有活法,不重新暴力枚舉全部舊邊。
for (const record of this.edgeRecords.values()) {
if (record.pieceId === placed.id) continue;
if (record.status === 'live') {
const invalidIds = [];
for (const methodId of record.methodIds) {
const method = this.methods.get(methodId);
if (!method) continue;
if (ghostIsInvalidatedByPiece(method.ghost, placed)) {
invalidIds.push(methodId);
}
}
if (invalidIds.length > 0) {
affectedPieceIds.add(record.pieceId);
invalidIds.forEach(id => this.removeMethod(id));
}
if (record.methodIds.length === 0) {
const ownerPiece = this.board.find(p => p.id === record.pieceId);
record.status = 'dead';
record.life = '死邊';
record.deadKind = classifyDeadEdge(ownerPiece, record.edgeIdx, this.board);
}
} else if (edgeTouchesNewPiece(
this.board.find(p => p.id === record.pieceId),
record.edgeIdx,
placed
)) {
const ownerPiece = this.board.find(p => p.id === record.pieceId);
record.deadKind = classifyDeadEdge(ownerPiece, record.edgeIdx, this.board);
}
}
// 新棋子的四邊建立新活法。
const newMethodIds = [];
for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
const record = this.buildEdgeRecord(placed, edgeIdx);
if (record.status === 'live') {
newMethodIds.push(...record.methodIds);
}
}
// 新棋子可能令舊活法出現/失去單接。
const singleRefreshIds = new Set(newMethodIds);
this.methods.forEach(method => {
if (
formsTriGolden(method.ghost, placed, this.board) ||
method.single.contacts.some(contact =>
this.pieceGluedStoredContact(placed, contact)
)
) {
singleRefreshIds.add(method.id);
}
});
singleRefreshIds.forEach(id => {
const method = this.methods.get(id);
if (method) this.refreshSingle(method);
});
// 舊雙接只重新驗證既存連結;不重新做全盤 M² 枚舉。
this.refreshExistingDoubles();
// 新活法只與已存在活法做增量配對。
for (const id of newMethodIds) {
const method = this.methods.get(id);
if (!method) continue;
this.methods.forEach(other => {
if (other.id === method.id) return;
this.addDoubleIfAny(method, other);
});
}
// 只重算受影響棋子與新棋子的共活。
affectedPieceIds.forEach(pieceId => this.updateCoLiveForPiece(pieceId));
this.ready = true;
}
refreshScoreLabels() {
this.methods.forEach(method => this.refreshSingle(method));
this.refreshExistingDoubles();
}
getLiveMethods(player) {
return [...this.methods.values()].filter(
method =>
method.forPlayer === player &&
(this.stock[method.ghost.svgId] || 0) > 0
);
}
getScoringSingles(player) {
return this.getLiveMethods(player).filter(
method => method.single.scoreByPlayer[player] > 0
);
}
getScoringDoubles(player) {
const result = [];
const seen = new Set();
this.methods.forEach(method => {
if (method.forPlayer !== player) return;
method.doubleJie.forEach((link, otherId) => {
const key = method.id < otherId
? `${method.id}_${otherId}`
: `${otherId}_${method.id}`;
if (seen.has(key)) return;
seen.add(key);
const other = this.methods.get(otherId);
if (!other) return;
if ((link.scoreByPlayer[player] || 0) > 0) {
result.push({ a: method, b: other, link });
}
});
});
return result;
}
getCoLiveMoves(player) {
const result = [];
const seen = new Set();
this.methods.forEach(method => {
if (method.forPlayer !== player) return;
method.coLive.forEach((_, otherId) => {
const other = this.methods.get(otherId);
if (!other) return;
const key = method.id < otherId
? `${method.id}_${otherId}`
: `${otherId}_${method.id}`;
if (seen.has(key)) return;
seen.add(key);
result.push({
kind: 'coLive',
methodIds: [method.id, other.id],
targetId: method.targetId,
ghosts: [cloneBoardPiece(method.ghost), cloneBoardPiece(other.ghost)]
});
});
});
return result;
}
hasCoLive(player) {
return this.getCoLiveMoves(player).length > 0;
}
}
function syncLifeTrackingAliases() {
edgeRecords = lifeState ? lifeState.edgeRecords : new Map();
methodRegistry = lifeState ? lifeState.methods : new Map();
nextMethodId = lifeState ? lifeState.nextMethodId : 1;
liveTrackingReady = !!(lifeState && lifeState.ready);
}
function lifeStateMatchesBoard() {
if (!lifeState) return false;
if (lifeState.board.length !== pieces.length) return false;
return lifeState.board.every(p =>
pieces.some(realPiece => realPiece.id === p.id)
);
}
function resetLifeTracking() {
lifeState = null;
edgeRecords = new Map();
methodRegistry = new Map();
nextMethodId = 1;
liveTrackingReady = false;
trackingGeneration++;
lifeTrackingUpdatePromise = Promise.resolve();
}
function computeAllLifeStatus() {
lifeState = new LifeState(
pieces,
stockForBoard(pieces),
new Set(scoredVictims)
);
lifeState.rebuild();
syncLifeTrackingAliases();
}
function incrementalAddPiece(newPiece) {
if (!lifeState || !lifeState.ready) {
computeAllLifeStatus();
return;
}
lifeState.scoredKeys = new Set(scoredVictims);
if (!lifeState.board.some(p => p.id === newPiece.id)) {
lifeState.addPlacedPiece(newPiece);
}
lifeState.refreshScoreLabels();
syncLifeTrackingAliases();
}
function ensureLifeStatsReady() {
if (!liveTrackingReady || !lifeStateMatchesBoard()) {
computeAllLifeStatus();
}
}
function scheduleLifeTrackingUpdate(newPieces) {
const generation = trackingGeneration;
lifeTrackingUpdatePromise = lifeTrackingUpdatePromise.then(
() =>
new Promise(resolve => {
setTimeout(() => {
if (generation !== trackingGeneration) {
resolve();
return;
}
if (!lifeTrackingReady || !lifeState) {
computeAllLifeStatus();
resolve();
return;
}
lifeState.scoredKeys = new Set(scoredVictims);
for (const piece of newPieces) {
if (!lifeState.board.some(p => p.id === piece.id)) {
lifeState.addPlacedPiece(piece);
}
}
if (!lifeStateMatchesBoard()) {
computeAllLifeStatus();
} else {
lifeState.refreshScoreLabels();
syncLifeTrackingAliases();
}
resolve();
}, 0);
})
);
return lifeTrackingUpdatePromise;
}5. 取代 getAllValidMoves(player)
將原本暴力枚舉版本的:
function getAllValidMoves(player) {
...
}完整替換為以下版本:
function getAllValidMoves(player) {
ensureLifeStatsReady();
// 仲裁與共活使用完全相同的「兩活法能否同時合規放置」判定:
// 面積不重疊、邊不重合、懸空頂點不重合、兩子貼同一對方棋子的不同邊。
return lifeState.getCoLiveMoves(player).map(move => [
cloneBoardPiece(move.ghosts[0]),
cloneBoardPiece(move.ghosts[1]),
move.targetId
]);
}這樣:
executeArbitration();- 共活統計;
- MCTS 的「不可令對方失去共活」限制;
會使用同一個來源。
6. 取代 generateGhosts(),讓人類預放棋子直接使用「活法」
將原本 generateGhosts() 完整替換為:
function generateGhosts() {
ghosts = [];
if (!selectedTile || !targetOpponentPieceId) return;
// 若上一手的非同步增量統計尚未完成,這裡立即補齊。
ensureLifeStatsReady();
const seen = new Set();
methodRegistry.forEach(method => {
if (method.forPlayer !== currentPlayer) return;
if (method.targetId !== targetOpponentPieceId) return;
if (method.ghost.svgId !== selectedTile) return;
if (method.ghost.isFlipped !== isFlipped[selectedTile]) return;
const ghost = cloneBoardPiece(method.ghost);
// 若本回合已暫放第一子,第二子仍須相對於 tempPieces 合規。
if (!isValidGhost(ghost, pieces.concat(tempPieces))) return;
const sig = placementSignature(ghost);
if (seen.has(sig)) return;
seen.add(sig);
ghosts.push(ghost);
});
}這同時修正原程式中 generateGhosts() 內同一個 ghost 被 push 兩次的問題。
7. 完整取代舊的 MCTS/Minimax 區塊
找到:
// --- MCTS 與 Minimax 核心引擎 ---從這裡開始,刪除到、但不包含:
function renderBoard() {然後貼入以下程式。
// =============================================================================
// 新 MCTS/Minimax:以死活、單接、雙接、共活為唯一候選來源
// =============================================================================
function otherPlayer(player) {
return player === 1 ? 2 : 1;
}
function isSecondSide(player) {
// 第一局:P2 是後手;第二局:P1 是後手。
return player !== startingPlayer;
}
function lifeRemaining(state, player) {
return state.life.playerRemaining(player);
}
function isSinglePieceTurn(state, player) {
return lifeRemaining(state, player) === 1;
}
function planSignature(plan) {
const placements = plan.ghosts.map(placementSignature);
// 單接的第一手有語義,不能排序。
if (plan.kind !== 'single') placements.sort();
return `${plan.kind}:${placements.join('||')}`;
}
function uniquePlans(plans) {
const seen = new Set();
return plans.filter(plan => {
const key = planSignature(plan);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
class SearchPosition {
constructor(life, player, score) {
this.life = life;
this.player = player;
this.score = { ...score };
}
clone() {
return new SearchPosition(
this.life.clone(),
this.player,
this.score
);
}
applyPlan(plan) {
const beforeBoard = this.life.board.map(cloneBoardPiece);
const beforeScored = new Set(this.life.scoredKeys);
let staged = plan.ghosts.map(g => this.life.materializeGhost(g));
if (staged.length === 0) return null;
if (staged.length === 1) {
if (!isValidGhost(staged[0], this.life.board)) return null;
this.life.addPlacedPiece(staged[0]);
} else if (plan.kind === 'single') {
// 單接:第一手先落下並完成增量更新,才選擇/驗證第二手。
if (!isValidGhost(staged[0], this.life.board)) return null;
this.life.addPlacedPiece(staged[0]);
if (!isValidGhost(staged[1], this.life.board)) return null;
this.life.addPlacedPiece(staged[1]);
} else {
// 雙接/共活:兩手先確定,之後才更新追蹤。
const ab =
isValidGhost(staged[0], this.life.board) &&
isValidGhost(staged[1], this.life.board.concat([staged[0]]));
const ba =
isValidGhost(staged[1], this.life.board) &&
isValidGhost(staged[0], this.life.board.concat([staged[1]]));
if (!ab && !ba) return null;
if (!ab) staged = [staged[1], staged[0]];
this.life.addPlacedPiece(staged[0]);
this.life.addPlacedPiece(staged[1]);
}
// 同一回合的實際得分,以兩手棋完成後的最終盤面為準。
const scoreResult = scoreGainForCandidates(
beforeBoard,
staged,
beforeScored
);
scoreResult.claimedKeys.forEach(key => this.life.scoredKeys.add(key));
this.life.refreshScoreLabels();
this.score[1] += scoreResult.gain[1];
this.score[2] += scoreResult.gain[2];
this.player = otherPlayer(this.player);
return {
pieces: staged,
gain: scoreResult.gain
};
}
}
function makeSinglePiecePlans(position, player) {
return position.life.getScoringSingles(player).map(method => ({
kind: 'single-piece',
methodIds: [method.id],
ghosts: [cloneBoardPiece(method.ghost)]
}));
}
function makeScoringTurnPlans(position) {
const player = position.player;
// 最後一子只能下一枚。
if (isSinglePieceTurn(position, player)) {
return makeSinglePiecePlans(position, player);
}
const plans = [];
// 單接:
// 第一子落下後先做增量更新,再從更新後的盤面找第二子。
for (const singleMethod of position.life.getScoringSingles(player)) {
const afterFirst = position.clone();
const first = afterFirst.life.materializeGhost(singleMethod.ghost);
if (!isValidGhost(first, afterFirst.life.board)) continue;
afterFirst.life.addPlacedPiece(first);
for (const secondMethod of afterFirst.life.getLiveMethods(player)) {
const plan = {
kind: 'single',
methodIds: [singleMethod.id, secondMethod.id],
ghosts: [
cloneBoardPiece(singleMethod.ghost),
cloneBoardPiece(secondMethod.ghost)
]
};
// 確保兩手完成後仍然真的令本方得分。
const trial = position.clone();
const applied = trial.applyPlan(plan);
if (applied && applied.gain[player] > 0) {
plans.push(plan);
}
}
}
// 雙接:
// 兩個活法本身構成頂鑫;選定兩手後才做狀態更新。
for (const pair of position.life.getScoringDoubles(player)) {
const plan = {
kind: 'double',
methodIds: [pair.a.id, pair.b.id],
ghosts: [
cloneBoardPiece(pair.a.ghost),
cloneBoardPiece(pair.b.ghost)
]
};
const trial = position.clone();
const applied = trial.applyPlan(plan);
if (applied && applied.gain[player] > 0) {
plans.push(plan);
}
}
return uniquePlans(plans);
}
function makeCoLivePlans(position) {
return uniquePlans(
position.life.getCoLiveMoves(position.player).map(move => ({
kind: 'coLive',
methodIds: [...move.methodIds],
ghosts: move.ghosts.map(cloneBoardPiece)
}))
);
}
function rawTurnChoices(position) {
const player = position.player;
const scoringPlans = makeScoringTurnPlans(position);
if (scoringPlans.length > 0) {
return {
kind: 'score',
hasScoringSingle: position.life.getScoringSingles(player).length > 0,
plans: scoringPlans
};
}
if (isSinglePieceTurn(position, player)) {
return {
kind: 'live',
hasScoringSingle: false,
plans: position.life.getLiveMethods(player).map(method => ({
kind: 'single-piece',
methodIds: [method.id],
ghosts: [cloneBoardPiece(method.ghost)]
}))
};
}
return {
kind: 'coLive',
hasScoringSingle: false,
plans: makeCoLivePlans(position)
};
}
function opponentCanContinue(position, opponent) {
const remaining = lifeRemaining(position, opponent);
// 對方已無棋,無須再檢查下一回合。
if (remaining <= 0) return true;
// 後手最後一子只需要一個活法。
if (remaining === 1) {
return position.life.getLiveMethods(opponent).length > 0;
}
return position.life.hasCoLive(opponent);
}
function scoringThreatSet(life, player) {
const threats = new Set();
for (const method of life.getScoringSingles(player)) {
threats.add(`S:${method.id}`);
}
for (const pair of life.getScoringDoubles(player)) {
const a = Math.min(pair.a.id, pair.b.id);
const b = Math.max(pair.a.id, pair.b.id);
threats.add(`D:${a}:${b}`);
}
return threats;
}
function mctsPlans(position) {
const player = position.player;
const opponent = otherPlayer(player);
const raw = rawTurnChoices(position);
if (raw.plans.length === 0) return [];
const safe = [];
// 強制規則:
// MCTS 不得落下令對方下回合沒有共活的棋。
for (const plan of raw.plans) {
const child = position.clone();
const applied = child.applyPlan(plan);
if (!applied) continue;
if (!opponentCanContinue(child, opponent)) continue;
safe.push(plan);
}
// 不可因為「沒有安全步」而退回危險步。
if (safe.length === 0) return [];
// 己方有得分單接/雙接時,直接優先。
if (raw.kind === 'score') return safe;
// 沒有己方得分單接/雙接時:
// 從共活中優先破壞對方得分單接/雙接,
// 且不產生新的對方得分單接/雙接。
const beforeThreats = scoringThreatSet(position.life, opponent);
const noNewThreat = [];
const disrupting = [];
for (const plan of safe) {
const child = position.clone();
const applied = child.applyPlan(plan);
if (!applied) continue;
const afterThreats = scoringThreatSet(child.life, opponent);
const createsNew = [...afterThreats].some(key => !beforeThreats.has(key));
if (createsNew) continue;
noNewThreat.push(plan);
const destroysOld = [...beforeThreats].some(key => !afterThreats.has(key));
if (destroysOld) disrupting.push(plan);
}
if (disrupting.length > 0) return disrupting;
return noNewThreat;
}
function minimaxPlans(position) {
const raw = rawTurnChoices(position);
const player = position.player;
// 後手方的最後一手:
// 沒有能為自己得分的單接時,只隨機下一個活法。
if (
isSecondSide(player) &&
isSinglePieceTurn(position, player) &&
!raw.hasScoringSingle &&
raw.kind === 'live' &&
raw.plans.length > 0
) {
return [
raw.plans[Math.floor(Math.random() * raw.plans.length)]
];
}
// Minimax:
// 有得分下法就只展開全部得分下法;
// 沒有得分下法才展開全部共活。
return raw.plans;
}
function evaluatePosition(position, rootPlayer) {
const opponent = otherPlayer(rootPlayer);
return position.score[rootPlayer] - position.score[opponent];
}
class MctsNode {
constructor(position, parent = null, move = null, depth = 0) {
this.position = position;
this.parent = parent;
this.move = move;
this.depth = depth;
this.visits = 0;
this.value = 0;
this.children = [];
this.untried = null;
}
}
function selectMctsChild(node, rootPlayer) {
const maximizing = node.position.player === rootPlayer;
let best = null;
let bestScore = -Infinity;
for (const child of node.children) {
const mean = child.value / Math.max(1, child.visits);
const exploit = maximizing ? mean : 1 - mean;
const explore =
MCTS_C *
Math.sqrt(Math.log(Math.max(1, node.visits)) / Math.max(1, child.visits));
const score = exploit + explore;
if (!best || score > bestScore) {
best = child;
bestScore = score;
}
}
return best;
}
function mctsValue(position, rootPlayer) {
const diff = evaluatePosition(position, rootPlayer);
return 1 / (1 + Math.exp(-diff / 3));
}
async function runMcts(rootPosition, rootPlayer, timeLimitMs, searchStart) {
const root = new MctsNode(rootPosition.clone());
let count = 0;
let maxDepth = 0;
while (
!cancelAi &&
performance.now() - searchStart < timeLimitMs
) {
let node = root;
// Selection + Expansion
while (!cancelAi) {
if (node.untried === null) {
node.untried = mctsPlans(node.position);
}
if (node.untried.length > 0) {
const index = Math.floor(Math.random() * node.untried.length);
const move = node.untried.splice(index, 1)[0];
const childPosition = node.position.clone();
if (!childPosition.applyPlan(move)) continue;
const child = new MctsNode(
childPosition,
node,
move,
node.depth + 1
);
node.children.push(child);
node = child;
maxDepth = Math.max(maxDepth, node.depth);
break;
}
if (node.children.length === 0) break;
node = selectMctsChild(node, rootPlayer);
}
// Rollout
let rollout = node.position.clone();
let rolloutDepth = node.depth;
while (
!cancelAi &&
performance.now() - searchStart < timeLimitMs &&
lifeRemaining(rollout, 1) + lifeRemaining(rollout, 2) > 0
) {
const plans = mctsPlans(rollout);
if (plans.length === 0) break;
const plan = plans[Math.floor(Math.random() * plans.length)];
const applied = rollout.applyPlan(plan);
if (!applied) break;
rolloutDepth++;
maxDepth = Math.max(maxDepth, rolloutDepth);
if (rolloutDepth % 4 === 0) {
await aiMaybeYield(
t('ai-mcts-progress', {
depth: maxDepth,
time: ((performance.now() - searchStart) / 1000).toFixed(1),
count
}),
80
);
}
}
const value = mctsValue(rollout, rootPlayer);
// Backpropagation
while (node) {
node.visits++;
node.value += value;
node = node.parent;
}
count++;
if (count % 8 === 0) {
await aiMaybeYield(
t('ai-mcts-progress', {
depth: maxDepth,
time: ((performance.now() - searchStart) / 1000).toFixed(1),
count
}),
80
);
}
}
if (root.children.length === 0) {
return {
plan: null,
value: 0,
count,
depth: maxDepth
};
}
const best = root.children.reduce((bestChild, child) => {
if (!bestChild) return child;
if (child.visits > bestChild.visits) return child;
if (child.visits === bestChild.visits && child.value > bestChild.value) return child;
return bestChild;
}, null);
return {
plan: best.move,
value: best.value / Math.max(1, best.visits),
count,
depth: maxDepth
};
}
async function runBreadthFirstMinimax(rootPosition, rootPlayer, searchStart) {
const root = {
position: rootPosition.clone(),
parent: null,
move: null,
children: [],
depth: 0,
noMove: false
};
let frontier = [root];
let count = 0;
let depth = 0;
while (frontier.length > 0 && !cancelAi) {
depth++;
await aiYield(
t('ai-minimax-progress', {
depth,
time: ((performance.now() - searchStart) / 1000).toFixed(1)
})
);
const nextFrontier = [];
for (const node of frontier) {
if (cancelAi) break;
const plans = minimaxPlans(node.position);
if (plans.length === 0) {
node.noMove = true;
continue;
}
for (const plan of plans) {
const childPosition = node.position.clone();
const applied = childPosition.applyPlan(plan);
if (!applied) continue;
const child = {
position: childPosition,
parent: node,
move: plan,
children: [],
depth,
noMove: false
};
node.children.push(child);
nextFrontier.push(child);
count++;
if (count % 24 === 0) {
await aiMaybeYield(
t('ai-minimax-progress', {
depth,
time: ((performance.now() - searchStart) / 1000).toFixed(1)
}),
50
);
}
}
}
frontier = nextFrontier;
}
function leafValue(node) {
const remaining =
lifeRemaining(node.position, 1) +
lifeRemaining(node.position, 2);
// 無任何合法回合選項時,輪到的一方可仲裁獲勝。
if (node.noMove && remaining > 0) {
return node.position.player === rootPlayer ? 100000 : -100000;
}
return evaluatePosition(node.position, rootPlayer);
}
function solve(node) {
if (node.children.length === 0) return leafValue(node);
const values = node.children.map(solve);
return node.position.player === rootPlayer
? Math.max(...values)
: Math.min(...values);
}
const value = solve(root);
let bestChild = null;
let bestValue = -Infinity;
for (const child of root.children) {
const childValue = solve(child);
if (!bestChild || childValue > bestValue) {
bestChild = child;
bestValue = childValue;
}
}
return {
plan: bestChild ? bestChild.move : null,
value,
count,
depth
};
}
function commitAIPlan(plan) {
const selected = plan.ghosts.map(cloneBoardPiece);
const myTiles = [0, 1, 2].map(n => `tile${currentPlayer === 1 ? n : n + 3}`);
const remaining = myTiles.reduce((sum, tile) => sum + piecesCount[tile], 0);
const required = remaining === 1 ? 1 : 2;
if (selected.length !== required) return false;
for (const piece of selected) {
if ((piecesCount[piece.svgId] || 0) <= 0) return false;
}
selected.forEach(piece => {
piece.id = nextPieceId++;
piecesCount[piece.svgId]--;
});
tempPieces = selected;
selectedTile = null;
targetOpponentPieceId = null;
ghosts = [];
updateUI();
renderBoard();
setTimeout(() => actionCheck(), 600);
return true;
}
function aiTriggerArbitrationSuccess() {
document.getElementById('message-box').style.display = 'none';
originalActionCross(false);
arbValidMoves = getAllValidMoves(currentPlayer);
arbEnded = true;
currentDialogMode = 'arbitration';
saveState({
newPieces: [],
arbSuccess: arbValidMoves.length === 0
});
document.getElementById('ui-res-msg').innerText = i18n['arb-success'][currentLang];
document.getElementById('ui-res-ways').innerText = i18n['arb-ways'][currentLang].replace('{n}', 0);
updateUI();
renderBoard();
document.getElementById('arb-result-dialog').style.display = 'flex';
}
function placeAIFirstMove(player) {
const myTiles = [0, 1, 2].map(n => `tile${player === 1 ? n : n + 3}`);
const available = myTiles.filter(tile => piecesCount[tile] > 0);
if (available.length === 0) return false;
const svgId = available[Math.floor(Math.random() * available.length)];
const flip = Math.random() < 0.5;
const angle = Math.floor(Math.random() * 360) + 1;
boardTransform.angle = angle;
updateTransform();
const shape = SHAPES[SHAPE_MAP[svgId]];
const { vertices } = buildShapeVertices(ZERO, 0, shape, flip);
tempPieces = [{
id: nextPieceId++,
owner: player,
type: SHAPE_MAP[svgId],
svgId,
isFlipped: flip,
vertices,
parentId: null,
level: 0,
boardAngle: angle
}];
piecesCount[svgId]--;
updateUI();
renderBoard();
setTimeout(() => actionCheck(), 600);
return true;
}
function checkAndTriggerAI() {
if (
aiConfig[currentPlayer] &&
!aiThinking &&
currentDialogMode === 'playing' &&
tempPieces.length === 0
) {
startAI();
}
}
const originalActionCross = actionCross;
actionCross = function (that) {
if (aiThinking && aiConfig[currentPlayer]) {
cancelAi = true;
aiConfig[currentPlayer] = false;
document.getElementById(`ai-p${currentPlayer}-type`).value = 'human';
updateAIConfig();
showMessage(t('ai-stopped'), 2000, true);
return;
}
originalActionCross(that);
};
const originalActionCheck = actionCheck;
actionCheck = function (that) {
originalActionCheck(that);
setTimeout(checkAndTriggerAI, 1500);
};
async function startAI() {
aiThinking = true;
cancelAi = false;
lastAIYieldTime = 0;
updateUI();
showMessage(t('ai-thinking-ellipsis'), 0, true);
const aiPlayer = currentPlayer;
try {
// 開局第一子。
if (turnNumber === 1 && pieces.length === 0 && tempPieces.length === 0) {
placeAIFirstMove(aiPlayer);
aiThinking = false;
document.getElementById('message-box').style.display = 'none';
return;
}
// 統計未完成時,先完成;這段不計入 MCTS 限時。
await aiYield(t('ai-tracking'));
await lifeTrackingUpdatePromise;
ensureLifeStatsReady();
if (cancelAi) {
aiThinking = false;
updateUI();
return;
}
const rootPosition = new SearchPosition(
lifeState.clone(),
aiPlayer,
scores
);
const threshold = aiConfig.settings[aiPlayer].n || 3;
const turnsLeft = Math.ceil(
(lifeRemaining(rootPosition, 1) + lifeRemaining(rootPosition, 2)) / 2
);
const useMinimax = turnsLeft <= threshold;
const raw = rawTurnChoices(rootPosition);
if (raw.plans.length === 0) {
aiThinking = false;
aiTriggerArbitrationSuccess();
return;
}
let result;
const searchStart = performance.now();
if (useMinimax) {
result = await runBreadthFirstMinimax(
rootPosition,
aiPlayer,
searchStart
);
const elapsed = ((performance.now() - searchStart) / 1000).toFixed(1);
showMessage(
t('ai-done', {
time: elapsed,
count: result.count,
val: t('ai-score-label', { score: result.value })
}),
3000,
true
);
} else {
const timeLimitMs = Math.max(
50,
(aiConfig.settings[aiPlayer].time || 3) * 1000
);
result = await runMcts(
rootPosition,
aiPlayer,
timeLimitMs,
searchStart
);
const elapsed = ((performance.now() - searchStart) / 1000).toFixed(1);
showMessage(
t('ai-mcts-done', {
time: elapsed,
count: result.count,
rate: (result.value * 100).toFixed(1)
}),
3000,
true
);
}
if (cancelAi) {
aiThinking = false;
updateUI();
return;
}
if (!result || !result.plan) {
// MCTS 的強制限制不允許用危險步保底。
aiThinking = false;
aiConfig[aiPlayer] = false;
document.getElementById(`ai-p${aiPlayer}-type`).value = 'human';
updateUI();
showMessage(t('ai-no-safe-move'), 5000, true);
return;
}
aiThinking = false;
commitAIPlan(result.plan);
} catch (err) {
console.error(err);
aiThinking = false;
updateUI();
showMessage(
currentLang === 'zh'
? 'AI 搜尋發生錯誤,已切換為人類操作。'
: 'AI search failed; control is now human.',
4000,
true
);
}
}8. 修改 actionCheck() 最後的增量更新呼叫
原本最後有:
scheduleLifeTrackingUpdate(lastTurnPieces, pieces);改成:
scheduleLifeTrackingUpdate(lastTurnPieces);因為新的追蹤器會自行保存自己的棋盤快照,不需要再傳入 pieces。
9. Undo / Redo 後立刻重建統計
在 undo() 裡,設定完:
scoredVictims = new Set(prevState.scoredVictims);以及所有 pieces、currentPlayer、turnNumber 還原完成後、updateUI() 前,加入:
computeAllLifeStatus();在 redo() 裡,設定完:
scoredVictims = new Set(nextState.scoredVictims);以及所有狀態還原完成後、updateUI() 前,同樣加入:
computeAllLifeStatus();10. 新局開始時初始化空盤統計
在 resetGame() 的最後:
updateTransform();
updateUI();
renderBoard();後面加入:
// 空盤也保持統計狀態已完成;
// 第一子落下後會直接走增量更新。
computeAllLifeStatus();修改後的資料結構
每條邊:
edgeRecords.get(edgeKey(pieceId, edgeIdx))會得到例如:
{
pieceId: 12,
edgeIdx: 2,
owner: 1,
status: 'live', // 或 'dead'
life: '活邊', // 或 '死邊'
deadKind: null, // 貼死/頂死/夾死/悶死
methodIds: [31, 32]
}每個活法:
methodRegistry.get(methodId)會包含:
{
id: 31,
forPlayer: 2,
ghost: { ... },
// 單接
isSingleJie: true,
single: {
formed: true,
scoreByPlayer: { 1: 0, 2: 3 },
contacts: [...]
},
// 雙接:partnerMethodId -> 得分資料
doubleJie: Map(...),
// 共活:partnerMethodId -> true
coLive: Map(...)
}這套替換後會符合:
- 活邊/死邊與四種死法;
- 長度為
1的邊在72°時才夾死; - 貼死邊不再形成頂鑫;
- 新棋只增量刪除被破壞的活法、建立新棋四邊活法;
- 活法的單接、雙接、共活均互記 ID;
- 單接第一子後先更新狀態才找第二子;
- 雙接兩子決定後才更新;
- MCTS 不會用「讓對方無共活」的危險步保底;
- minimax 以廣度優先展開,先完整展開得分步,沒有得分步才展開共活;
- 後手最後一子若無得分單接,隨機選一個活法;
- 訊息框會持續顯示 MCTS/minimax 的層數與用時。