共享会话
重寫MCTS搜尋和minimax搜尋
分享于 2026年8月14日 04:48重寫MCTS搜尋和minimax搜尋 一個棋子的一條邊如果有至少一種方式可以放置一個對方棋子,則稱為「活邊」,否則為「死邊」。每一種放置方式為一種「活法」。
如果一條邊的某一個「活法」能跟另一條邊形成頂鑫結構,則標注為「單接」,並計其得分。
「死邊」有以下幾種「死法」:「貼死」、「頂死」、「夾死」以及「悶死」。「貼死」指的是有另一個棋子的邊與其貼合,「頂死」指被另一個棋子頂到,「夾死」指如果兩條互相接觸的邊夾角為36度則兩條邊都是夾死,或者如果夾角為72度則長度為1的邊會被夾死,「悶死」指的是不屬於以上情況但沒有棋子能合規放置。其中「貼死」的邊將不能再跟其它棋子形成頂鑫結構。
在當前盤面下,記錄每一條邊的「死活」情況以及所有的「活法」。在以後的每步棋或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子是否跟某些邊的某些活法在面積上重疊,或在邊上重合,或懸空的頂點重合」,更變已有棋子的邊的「死活」。而新加入的棋子的邊又會有新的「死活」。
在當前盤面下,統計所有同屬一方的棋子的活法兩兩之間是否形成頂鑫結構,如果存在則為這兩個活法都標注「雙接」。每一種「活法」都有一個特別的id,互相記下id以及得分情況。注意一個「活法」可以跟不同的「活法」之間產生「雙接」。在以後的每步棋或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子的活法是否能和舊的棋子的活法產生雙接」。
在當前盤面下,統計每個具有至少兩個「活邊」的棋子上的活法兩兩之間是否可以一起合規落子,即判斷這兩活法之間是否「在面積上重疊,在邊上重合,或懸空的頂點重合」,若能合規落子則兩個活法都相互記下id並標注「共活」。注意一個「活法」可以跟同一個棋子上的另一條邊上的多個「活法」產生「共活」。在以後的每回合或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子是否消滅了之前的共活或是否產生了新的共活」。
當進行MCTS搜尋時,使己方得分的「單接」和己方的「雙接」都將是優先選項。如果是「單接」,則在這一回合的第二手棋之前,先要更新全盤的「死活」、所有棋子的「活法」、「雙接」以及「共活」。如果選擇的是「雙接」,則在這一回合之後更新。
如果當前沒有使己方得分的「單接」和己方的「雙接」,則需在所有的「共活」之中,優先挑選能破壞使對方得分的「單接」和對方的「雙接」,並且不要產生新的使對方得分的「單接」和對方的「雙接」。
統計「共活」的算法跟「仲裁」算法應是一樣的,當在MCTS搜尋時,不能下出使對方下回合沒有「共活」的下法。
minimax將為「廣度優先」,將首先對所有能得分的下法進行搜尋,僅當沒有能得分的下法時才,對所有的「共活」進行搜尋。但在後手方最後一手棋時,如果沒有使他加分的「單接」,則只需隨機挑一個活法下即可。在消息框實時顯示當前計算的層數和用時。
當遊戲開始時,即使雙方都不是AI,程式也要保持對所有棋子的邊的「死活」、「活法」、「單接」、「雙接」、「共活」的增量統計。當人類玩家需要展示「預放棋子」時,則調出相應的「活法」。當一方是AI,而如果當前盤面所有棋子的邊的「死活」、「活法」、「單接」、「雙接」、「共活」都沒有統計完成,則把當前盤面的統計完成後再開始MCTS搜尋。限時只用於MCTS搜尋,但當MCTS搜尋限時結束,消息框要給出總用時。
以上的MCTS搜尋算法和minimax算法,將取代之前的「步驟一、步驟二、步驟三、步驟四」。
上面所說的「長度為1的邊」是指每個棋子中都有的兩條最短的邊。
「先手方」指的是第一局的玩家一和第二局的玩家二,「後手方」則是第一局的玩家二和第二局的玩家一。
回答要求:
明確給出所要修改的地方和所要替換的代碼。
QuanAI下面不要再把舊的「步驟一、二、三、四」候選生成、舊 getAICandidateMoves()、舊 findDisruptingMoves()、舊 MCTS 根節點隨機跑分,和新的增量圖同時保留。它們會造成候選規則互相衝突、仲裁誤觸發,以及 live/alive、單接欄位不同步。
以下按「尋找的函式/區塊」定位即可,不依賴固定行號。
0. 先刪除或停止呼叫的舊程式
請刪除先前加入、但會與本版衝突的下列內容:
- 舊的
computeAllLifeStatus、incrementalAddPiece、updateIncrementalGraph、updateIncrementalGraphLocal模組。 - 所有舊的:
getAICandidateMovesgetScoringSingleDingMethodsgetSingleJieMethodsgetMutualDingPairsfindDisruptingMovesplanFullTurnActionsMCTS- 舊
rolloutPolicy - 舊
minimaxEndgame
- 舊
startAI()的 MCTS / Minimax 搜尋區塊。 - 若程式內有兩個同名
updateIncrementalGraph(),兩個都不要保留。 本版只使用tgOnCommittedPieces()與tgEnsureBoardGraphSync()。
保留原有的精確幾何函式,例如:
attachByEdge
isValidGhost
shapesOverlap
segmentsOverlapAsEdges
pointOnOpenSegment
pointsEqual
ringSub
ringMul
conjugate
zetaPow
isImaginaryPartZero
realPartExact
phiFormToNumber
simulateScoreGain1. 新增:統一的「死活/活法/單接/雙接/共活」增量圖模組
插入位置: 放在 simulateScoreGain、getTreeDistance、formsTriGolden 等計分與幾何函式之後,getAllValidMoves() 之前。
同時刪除原先的 let boardGraph = ...、let methodIdCounter = ... 等舊宣告,再完整加入以下程式。
// ============================================================================
// 頂鑫棋:統一增量狀態圖
// 只保留本模組,不要與舊 edgeRecords / methodRegistry / boardGraph 混用。
// ============================================================================
const TG_DEATH = Object.freeze({
ADHESION: '貼死',
TOPPED: '頂死',
PINCHED: '夾死',
SMOTHERED: '悶死'
});
let boardGraph = tgEmptyGraph();
let isGraphCalculating = false;
/*
* 第一局:玩家一先手;第二局:玩家二先手。
* 請在開始第二局、交換先後手的位置設定為 2;
* 新賽局第一局開始時設定回 1。
*/
let openingPlayerThisGame = 1;
function tgOtherPlayer(player) {
return player === 1 ? 2 : 1;
}
function tgTilesOfPlayer(player) {
return player === 1
? ['tile0', 'tile1', 'tile2']
: ['tile3', 'tile4', 'tile5'];
}
function tgEdgeKey(pieceId, edgeIndex) {
return `${String(pieceId)}@${edgeIndex}`;
}
function tgPairKey(a, b) {
return String(a) < String(b)
? `${String(a)}|${String(b)}`
: `${String(b)}|${String(a)}`;
}
function tgZeroGain() {
return { 1: 0, 2: 0 };
}
function tgCopyRingPoint(point) {
return Array.isArray(point) ? point.slice() : point;
}
function tgClonePiece(piece) {
return {
...piece,
vertices: piece.vertices.map(tgCopyRingPoint)
};
}
function tgEmptyGraph() {
return {
ready: false,
version: 0,
// edgeKey -> {
// pieceId, edgeIndex,
// status: 'live' | 'dead',
// reason: null | 貼死 | 頂死 | 夾死 | 悶死,
// methodIds: Set<methodId>
// }
edges: new Map(),
// methodId -> method
methods: new Map()
};
}
function tgCloneGraph(graph) {
const copy = tgEmptyGraph();
copy.ready = graph.ready;
copy.version = graph.version;
for (const [key, edge] of graph.edges.entries()) {
copy.edges.set(key, {
...edge,
methodIds: new Set(edge.methodIds || [])
});
}
for (const [id, method] of graph.methods.entries()) {
const doubleWith = new Map();
for (const [otherId, info] of (method.doubleWith || new Map()).entries()) {
doubleWith.set(otherId, {
...info,
gain: { ...(info.gain || tgZeroGain()) },
contacts: (info.contacts || []).map(x => ({ ...x }))
});
}
copy.methods.set(id, {
...method,
vertices: method.vertices.map(tgCopyRingPoint),
singleWith: (method.singleWith || []).map(x => ({ ...x })),
jieGain: { ...(method.jieGain || tgZeroGain()) },
doubleWith,
coLiveWith: new Set(method.coLiveWith || [])
});
}
return copy;
}
function tgReadStock() {
return { ...piecesCount };
}
function tgBoardState() {
return {
pieces: pieces.map(tgClonePiece),
stock: tgReadStock(),
scores: { ...scores },
currentPlayer,
nextVirtualId: 1
};
}
/*
* 相容層:
* 若原本 simulateScoreGain(newPieces) 內部讀取全域 pieces,
* 這個包裝器可讓 MCTS / Minimax 在假設盤面上計分。
*
* simulateScoreGain 必須是同步、不得直接修改真實 scores 的純計算函式。
*/
function tgScoreGain(state, addedPieces) {
const oldPieces = pieces;
const oldTempPieces = tempPieces;
try {
pieces = state.pieces;
tempPieces = [];
const gain = simulateScoreGain(addedPieces);
return {
1: Number((gain && gain[1]) || 0),
2: Number((gain && gain[2]) || 0)
};
} finally {
pieces = oldPieces;
tempPieces = oldTempPieces;
}
}
function tgFindPiece(state, pieceId) {
return state.pieces.find(p => String(p.id) === String(pieceId)) || null;
}
function tgGhostFromMethod(method, virtualId = null) {
return {
id: virtualId || `method:${method.id}`,
mId: method.id,
owner: method.owner,
forPlayer: method.owner,
type: method.type,
svgId: method.svgId,
isFlipped: method.isFlipped,
vertices: method.vertices.map(tgCopyRingPoint),
parentId: method.targetId,
targetId: method.targetId,
targetEdge: method.targetEdge,
edgeOnOpp: method.targetEdge,
myEdge: method.myEdge
};
}
function tgMakeMethodId(targetPiece, targetEdge, svgId, isFlipped, myEdge) {
return [
'm',
String(targetPiece.id),
targetEdge,
svgId,
isFlipped ? 1 : 0,
myEdge
].join(':');
}
function tgStockOf(state, svgId) {
return Number(state.stock[svgId] || 0);
}
function tgEnoughForOne(state, method) {
return tgStockOf(state, method.svgId) >= 1;
}
function tgEnoughForTwo(state, methodA, methodB) {
if (methodA.svgId === methodB.svgId) {
return tgStockOf(state, methodA.svgId) >= 2;
}
return (
tgStockOf(state, methodA.svgId) >= 1 &&
tgStockOf(state, methodB.svgId) >= 1
);
}
// ---------------------------------------------------------------------------
// 精確幾何:夾死
// ---------------------------------------------------------------------------
function tgEdgeVector(piece, edgeIndex) {
return ringSub(
piece.vertices[(edgeIndex + 1) % 4],
piece.vertices[edgeIndex]
);
}
function tgIsLengthOneEdge(piece, edgeIndex) {
const v = tgEdgeVector(piece, edgeIndex);
const normSq = ringMul(v, conjugate(v));
return pointsEqual(normSq, ONE);
}
/*
* 回傳兩條「由接觸點向外」射線的實際夾角:
* 0 / 36 / 72 / 108 / 144 / 180,無法辨識則 null。
*/
function tgDiscreteAngle(v1, v2) {
const product = ringMul(v1, conjugate(v2));
for (let k = 0; k < 10; k++) {
const aligned = ringMul(product, conjugate(zetaPow(k)));
if (!isImaginaryPartZero(aligned)) continue;
const numeric = phiFormToNumber(realPartExact(aligned));
if (numeric > 0) {
return Math.min(k, 10 - k) * 36;
}
}
return null;
}
function tgRayFromContact(piece, edgeIndex, contactPoint) {
const a = piece.vertices[edgeIndex];
const b = piece.vertices[(edgeIndex + 1) % 4];
if (pointsEqual(contactPoint, a)) return ringSub(b, a);
if (pointsEqual(contactPoint, b)) return ringSub(a, b);
return null;
}
/*
* 兩條邊在端點相接時:
* - 36°:兩邊皆夾死
* - 72°:只有長度為 1 的邊夾死
*/
function tgPinchFlags(pieceA, edgeA, pieceB, edgeB) {
const endpointsA = [
pieceA.vertices[edgeA],
pieceA.vertices[(edgeA + 1) % 4]
];
const endpointsB = [
pieceB.vertices[edgeB],
pieceB.vertices[(edgeB + 1) % 4]
];
const result = { deadA: false, deadB: false };
for (const pointA of endpointsA) {
for (const pointB of endpointsB) {
if (!pointsEqual(pointA, pointB)) continue;
const rayA = tgRayFromContact(pieceA, edgeA, pointA);
const rayB = tgRayFromContact(pieceB, edgeB, pointA);
if (!rayA || !rayB) continue;
const angle = tgDiscreteAngle(rayA, rayB);
if (angle === 36) {
result.deadA = true;
result.deadB = true;
} else if (angle === 72) {
if (tgIsLengthOneEdge(pieceA, edgeA)) result.deadA = true;
if (tgIsLengthOneEdge(pieceB, edgeB)) result.deadB = true;
}
}
}
return result;
}
// ---------------------------------------------------------------------------
// 邊死活與活法
// ---------------------------------------------------------------------------
function tgEdgeHasAdhesion(state, piece, edgeIndex) {
const a = piece.vertices[edgeIndex];
const b = piece.vertices[(edgeIndex + 1) % 4];
for (const other of state.pieces) {
if (String(other.id) === String(piece.id)) continue;
for (let oe = 0; oe < 4; oe++) {
const c = other.vertices[oe];
const d = other.vertices[(oe + 1) % 4];
if (segmentsOverlapAsEdges(a, b, c, d)) {
return true;
}
}
}
return false;
}
function tgEdgeHasTop(state, piece, edgeIndex) {
const a = piece.vertices[edgeIndex];
const b = piece.vertices[(edgeIndex + 1) % 4];
for (const other of state.pieces) {
if (String(other.id) === String(piece.id)) continue;
for (const vertex of other.vertices) {
if (pointOnOpenSegment(vertex, a, b)) {
return true;
}
}
}
return false;
}
function tgEdgeHasPinch(state, piece, edgeIndex) {
for (const other of state.pieces) {
if (String(other.id) === String(piece.id)) continue;
for (let oe = 0; oe < 4; oe++) {
const flags = tgPinchFlags(piece, edgeIndex, other, oe);
if (flags.deadA) return true;
}
}
return false;
}
function tgEnumerateEdgeMethods(state, targetPiece, targetEdge) {
const result = [];
const seen = new Set();
const player = tgOtherPlayer(targetPiece.owner);
const p = targetPiece.vertices[targetEdge];
const q = targetPiece.vertices[(targetEdge + 1) % 4];
for (const svgId of tgTilesOfPlayer(player)) {
if (tgStockOf(state, svgId) <= 0) continue;
const type = SHAPE_MAP[svgId];
for (const isFlipped of [false, true]) {
for (let myEdge = 0; myEdge < 4; myEdge++) {
const attached = attachByEdge(type, isFlipped, myEdge, p, q);
if (!attached) continue;
const methodId = tgMakeMethodId(
targetPiece,
targetEdge,
svgId,
isFlipped,
myEdge
);
if (seen.has(methodId)) continue;
const ghost = {
id: `probe:${methodId}`,
mId: methodId,
owner: player,
forPlayer: player,
type,
svgId,
isFlipped,
vertices: attached.vertices.map(tgCopyRingPoint),
parentId: targetPiece.id,
targetId: targetPiece.id,
targetEdge,
edgeOnOpp: targetEdge,
myEdge
};
if (!isValidGhost(ghost, state.pieces)) continue;
seen.add(methodId);
result.push({
id: methodId,
owner: player,
forPlayer: player,
type,
svgId,
isFlipped,
vertices: ghost.vertices.map(tgCopyRingPoint),
targetId: targetPiece.id,
targetEdge,
myEdge,
edgeKey: tgEdgeKey(targetPiece.id, targetEdge),
singleWith: [],
isSingleJie: false,
jieGain: tgZeroGain(),
jieScore: 0,
jieScoreOpp: 0,
// methodId -> { compatible, gain, contacts }
doubleWith: new Map(),
// Set<methodId>
coLiveWith: new Set()
});
}
}
}
return result;
}
function tgClassifyEdge(state, piece, edgeIndex) {
if (tgEdgeHasAdhesion(state, piece, edgeIndex)) {
return {
status: 'dead',
reason: TG_DEATH.ADHESION,
methods: []
};
}
if (tgEdgeHasTop(state, piece, edgeIndex)) {
return {
status: 'dead',
reason: TG_DEATH.TOPPED,
methods: []
};
}
if (tgEdgeHasPinch(state, piece, edgeIndex)) {
return {
status: 'dead',
reason: TG_DEATH.PINCHED,
methods: []
};
}
const methods = tgEnumerateEdgeMethods(state, piece, edgeIndex);
if (methods.length === 0) {
return {
status: 'dead',
reason: TG_DEATH.SMOTHERED,
methods: []
};
}
return {
status: 'live',
reason: null,
methods
};
}
function tgRemoveMethod(graph, methodId) {
const method = graph.methods.get(methodId);
if (!method) return;
for (const otherId of Array.from(method.doubleWith.keys())) {
const other = graph.methods.get(otherId);
if (other) other.doubleWith.delete(methodId);
}
for (const otherId of Array.from(method.coLiveWith.values())) {
const other = graph.methods.get(otherId);
if (other) other.coLiveWith.delete(methodId);
}
const edge = graph.edges.get(method.edgeKey);
if (edge) edge.methodIds.delete(methodId);
graph.methods.delete(methodId);
}
function tgRebuildEdge(state, graph, piece, edgeIndex, changedMethodIds) {
const key = tgEdgeKey(piece.id, edgeIndex);
const old = graph.edges.get(key);
if (old) {
for (const methodId of Array.from(old.methodIds)) {
tgRemoveMethod(graph, methodId);
changedMethodIds.add(methodId);
}
}
const result = tgClassifyEdge(state, piece, edgeIndex);
const record = {
pieceId: piece.id,
edgeIndex,
status: result.status,
reason: result.reason,
methodIds: new Set()
};
graph.edges.set(key, record);
if (record.status !== 'live') return;
for (const method of result.methods) {
graph.methods.set(method.id, method);
record.methodIds.add(method.id);
changedMethodIds.add(method.id);
}
}
// ---------------------------------------------------------------------------
// 頂鑫接觸:貼死邊不得作為「鑫邊」
// ---------------------------------------------------------------------------
function tgIsBlockedXinEdge(piece, edgeIndex, graph) {
if (Number(piece.myEdge) === edgeIndex) return true;
const edge = graph.edges.get(tgEdgeKey(piece.id, edgeIndex));
return !!(edge && edge.reason === TG_DEATH.ADHESION);
}
function tgTriGoldenContacts(pieceA, pieceB, graph) {
const contacts = [];
function scan(topPiece, xinPiece) {
for (let vi = 0; vi < 4; vi++) {
const vertex = topPiece.vertices[vi];
for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
if (tgIsBlockedXinEdge(xinPiece, edgeIndex, graph)) continue;
const a = xinPiece.vertices[edgeIndex];
const b = xinPiece.vertices[(edgeIndex + 1) % 4];
if (pointOnOpenSegment(vertex, a, b)) {
contacts.push({
topPieceId: topPiece.id,
topVertex: vi,
xinPieceId: xinPiece.id,
xinEdge: edgeIndex
});
}
}
}
}
scan(pieceA, pieceB);
scan(pieceB, pieceA);
return contacts;
}
function tgRefreshSingleJie(state, graph, method) {
const ghost = tgGhostFromMethod(method, `single:${method.id}`);
const contacts = [];
for (const piece of state.pieces) {
contacts.push(...tgTriGoldenContacts(ghost, piece, graph));
}
method.singleWith = contacts;
method.isSingleJie = contacts.length > 0;
if (!method.isSingleJie) {
method.jieGain = tgZeroGain();
method.jieScore = 0;
method.jieScoreOpp = 0;
return;
}
method.jieGain = tgScoreGain(state, [ghost]);
method.jieScore = method.jieGain[method.owner] || 0;
method.jieScoreOpp = method.jieGain[tgOtherPlayer(method.owner)] || 0;
}
function tgTwoMethodsCompatible(state, methodA, methodB) {
if (!tgEnoughForTwo(state, methodA, methodB)) return false;
const ghostA = tgGhostFromMethod(methodA, `pair-a:${methodA.id}`);
const ghostB = tgGhostFromMethod(methodB, `pair-b:${methodB.id}`);
if (!isValidGhost(ghostA, state.pieces)) return false;
return isValidGhost(ghostB, state.pieces.concat([ghostA]));
}
function tgTryAddDoubleJie(state, graph, methodA, methodB) {
if (methodA.owner !== methodB.owner) return;
const ghostA = tgGhostFromMethod(methodA, `double-a:${methodA.id}`);
const ghostB = tgGhostFromMethod(methodB, `double-b:${methodB.id}`);
const contacts = tgTriGoldenContacts(ghostA, ghostB, graph);
if (contacts.length === 0) return;
/*
* 先標記為雙接;但只有 compatible === true 時,
* 才能作為實際完整回合候選。
*/
const compatible = tgTwoMethodsCompatible(state, methodA, methodB);
const gain = compatible
? tgScoreGain(state, [ghostA, ghostB])
: tgZeroGain();
const record = {
compatible,
gain,
contacts
};
methodA.doubleWith.set(methodB.id, record);
methodB.doubleWith.set(methodA.id, {
compatible,
gain: { ...gain },
contacts: contacts.map(x => ({ ...x }))
});
}
function tgTryAddCoLive(state, methodA, methodB) {
if (String(methodA.targetId) !== String(methodB.targetId)) return;
if (methodA.targetEdge === methodB.targetEdge) return;
if (!tgTwoMethodsCompatible(state, methodA, methodB)) return;
methodA.coLiveWith.add(methodB.id);
methodB.coLiveWith.add(methodA.id);
}
function tgClearDoubleLinks(graph, method) {
for (const otherId of Array.from(method.doubleWith.keys())) {
const other = graph.methods.get(otherId);
if (other) other.doubleWith.delete(method.id);
}
method.doubleWith.clear();
}
function tgClearCoLivesOfTarget(graph, targetId) {
const methods = Array.from(graph.methods.values()).filter(
m => String(m.targetId) === String(targetId)
);
for (const method of methods) {
for (const otherId of Array.from(method.coLiveWith.values())) {
const other = graph.methods.get(otherId);
if (
other &&
String(other.targetId) === String(targetId)
) {
other.coLiveWith.delete(method.id);
}
method.coLiveWith.delete(otherId);
}
}
}
/*
* 關係更新規則:
* - 單接得分:全部重算。因為新棋子可能改變全局最短路徑得分。
* - 雙接:只讓「新增/重建的活法」與舊活法比較。
* - 共活:只重算受影響目標棋子上的活法對。
*/
function tgRefreshRelations(
state,
graph,
changedMethodIds,
fullRebuild = false,
affectedTargetIds = new Set()
) {
const allMethods = Array.from(graph.methods.values());
// 單接與其分數必須反映當前全盤路徑,因此全部更新。
for (const method of allMethods) {
tgRefreshSingleJie(state, graph, method);
}
if (fullRebuild) {
for (const method of allMethods) {
method.doubleWith.clear();
method.coLiveWith.clear();
}
}
const participants = fullRebuild
? allMethods
: Array.from(changedMethodIds)
.map(id => graph.methods.get(id))
.filter(Boolean);
for (const method of participants) {
tgClearDoubleLinks(graph, method);
affectedTargetIds.add(String(method.targetId));
}
// 新活法只與既有活法做雙接增量比較。
const handledDoublePairs = new Set();
for (const methodA of participants) {
for (const methodB of allMethods) {
if (methodA.id === methodB.id) continue;
const key = tgPairKey(methodA.id, methodB.id);
if (handledDoublePairs.has(key)) continue;
handledDoublePairs.add(key);
tgTryAddDoubleJie(state, graph, methodA, methodB);
}
}
if (fullRebuild) {
for (const method of allMethods) {
affectedTargetIds.add(String(method.targetId));
}
}
// 共活只在同一個目標棋子內配對。
for (const targetId of affectedTargetIds) {
tgClearCoLivesOfTarget(graph, targetId);
const methods = Array.from(graph.methods.values()).filter(
m => String(m.targetId) === String(targetId)
);
for (let i = 0; i < methods.length; i++) {
for (let j = i + 1; j < methods.length; j++) {
tgTryAddCoLive(state, methods[i], methods[j]);
}
}
}
/*
* 新棋子可能改變最短路徑,因此已存在的雙接仍要更新得分;
* 但不需要把所有活法兩兩重新判斷是否形成雙接。
*/
const handledScores = new Set();
for (const methodA of graph.methods.values()) {
for (const [methodBId, info] of methodA.doubleWith.entries()) {
const key = tgPairKey(methodA.id, methodBId);
if (handledScores.has(key)) continue;
handledScores.add(key);
const methodB = graph.methods.get(methodBId);
if (!methodB || !info.compatible) continue;
const gain = tgScoreGain(state, [
tgGhostFromMethod(methodA, `score-a:${methodA.id}`),
tgGhostFromMethod(methodB, `score-b:${methodB.id}`)
]);
info.gain = gain;
const reverse = methodB.doubleWith.get(methodA.id);
if (reverse) reverse.gain = { ...gain };
}
}
}
// ---------------------------------------------------------------------------
// 增量更新:新棋子消滅舊活法的三種方式
// 面積重疊、邊重合、懸空頂點重合。
// ---------------------------------------------------------------------------
function tgMethodInvalidatedByNewPiece(method, newPiece) {
const ghost = tgGhostFromMethod(method, `invalidate:${method.id}`);
// 1. 面積重疊。
if (shapesOverlap(ghost, newPiece)) return true;
// 2. 邊重合。
for (let ge = 0; ge < 4; ge++) {
const ga = ghost.vertices[ge];
const gb = ghost.vertices[(ge + 1) % 4];
for (let ne = 0; ne < 4; ne++) {
const na = newPiece.vertices[ne];
const nb = newPiece.vertices[(ne + 1) % 4];
if (segmentsOverlapAsEdges(ga, gb, na, nb)) {
return true;
}
}
}
/*
* 3. 懸空頂點重合。
* 貼到目標邊上的兩個端點不是懸空頂點,因此跳過。
*/
const attachedA = Number(method.myEdge);
const attachedB = (attachedA + 1) % 4;
for (let gv = 0; gv < 4; gv++) {
if (gv === attachedA || gv === attachedB) continue;
for (let nv = 0; nv < 4; nv++) {
if (pointsEqual(ghost.vertices[gv], newPiece.vertices[nv])) {
return true;
}
}
}
return false;
}
function tgMethodStillLegal(state, method) {
if (!tgEnoughForOne(state, method)) return false;
return isValidGhost(
tgGhostFromMethod(method, `valid:${method.id}`),
state.pieces
);
}
function tgEdgeAffectedByNewPiece(piece, edgeIndex, newPiece) {
if (String(piece.id) === String(newPiece.id)) return false;
const a = piece.vertices[edgeIndex];
const b = piece.vertices[(edgeIndex + 1) % 4];
// 新棋子頂到此邊。
for (const vertex of newPiece.vertices) {
if (pointOnOpenSegment(vertex, a, b)) return true;
}
for (let ne = 0; ne < 4; ne++) {
const c = newPiece.vertices[ne];
const d = newPiece.vertices[(ne + 1) % 4];
// 新棋子貼到此邊。
if (segmentsOverlapAsEdges(a, b, c, d)) return true;
// 新棋子與此邊形成 36° / 72° 夾死。
const pinch = tgPinchFlags(piece, edgeIndex, newPiece, ne);
if (pinch.deadA) return true;
}
return false;
}
function tgBuildGraph(state) {
const graph = tgEmptyGraph();
const changedMethodIds = new Set();
const affectedTargetIds = new Set();
for (const piece of state.pieces) {
for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
tgRebuildEdge(
state,
graph,
piece,
edgeIndex,
changedMethodIds
);
}
}
tgRefreshRelations(
state,
graph,
changedMethodIds,
true,
affectedTargetIds
);
graph.ready = true;
graph.version++;
return graph;
}
/*
* 正常落子與 MCTS 節點都走這個函式。
* 不會全盤重新生成活法;只重建真正受影響的邊,
* 並讓新增活法與舊活法增量產生雙接/共活。
*/
function tgIncrementalUpdate(state, graph, addedPieces) {
if (!graph || !graph.ready || !addedPieces || addedPieces.length === 0) {
return tgBuildGraph(state);
}
const dirtyEdges = new Set();
const changedMethodIds = new Set();
const affectedTargetIds = new Set();
const changedTileIds = new Set(
addedPieces.map(piece => piece.svgId)
);
// 新棋子本身的四邊必須建立死活。
for (const piece of addedPieces) {
for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
dirtyEdges.add(tgEdgeKey(piece.id, edgeIndex));
}
// 父棋子被貼合的邊一定受影響。
if (
piece.targetId !== undefined &&
piece.targetId !== null &&
piece.targetEdge !== undefined &&
piece.targetEdge !== null
) {
dirtyEdges.add(tgEdgeKey(piece.targetId, piece.targetEdge));
affectedTargetIds.add(String(piece.targetId));
}
}
// 受新棋子直接頂、貼、夾影響的既有邊。
for (const piece of state.pieces) {
for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
if (
addedPieces.some(newPiece =>
tgEdgeAffectedByNewPiece(piece, edgeIndex, newPiece)
)
) {
dirtyEdges.add(tgEdgeKey(piece.id, edgeIndex));
affectedTargetIds.add(String(piece.id));
}
}
}
/*
* 舊活法是否被新棋子阻擋:
* 面積重疊、邊重合、懸空頂點重合任一成立即失效。
*/
for (const method of graph.methods.values()) {
const blocked = addedPieces.some(newPiece =>
tgMethodInvalidatedByNewPiece(method, newPiece)
);
if (blocked || !tgMethodStillLegal(state, method)) {
dirtyEdges.add(method.edgeKey);
affectedTargetIds.add(String(method.targetId));
}
}
/*
* 剩餘棋子數變動可能使同一棋種的「兩子共活」失效,
* 因此使用過的棋種所在目標棋子也要重算共活。
*/
for (const method of graph.methods.values()) {
if (changedTileIds.has(method.svgId)) {
affectedTargetIds.add(String(method.targetId));
}
}
for (const edgeKey of dirtyEdges) {
const [pieceId, edgeText] = edgeKey.split('@');
const piece = tgFindPiece(state, pieceId);
if (!piece) continue;
tgRebuildEdge(
state,
graph,
piece,
Number(edgeText),
changedMethodIds
);
affectedTargetIds.add(String(piece.id));
}
// 新生成的活法若使用剛被扣減的棋種,也要重算其共活。
for (const method of graph.methods.values()) {
if (changedTileIds.has(method.svgId)) {
affectedTargetIds.add(String(method.targetId));
}
}
tgRefreshRelations(
state,
graph,
changedMethodIds,
false,
affectedTargetIds
);
graph.ready = true;
graph.version++;
return graph;
}
function tgInvalidateBoardGraph() {
boardGraph = tgEmptyGraph();
}
function tgEnsureBoardGraphSync() {
if (boardGraph.ready) return;
isGraphCalculating = true;
try {
boardGraph = tgBuildGraph(tgBoardState());
} finally {
isGraphCalculating = false;
}
}
/*
* 真正落子後呼叫。
* 此時 pieces 與 piecesCount 必須已經是「落子後」狀態。
*/
function tgOnCommittedPieces(committedPieces) {
isGraphCalculating = true;
try {
const state = tgBoardState();
boardGraph = boardGraph.ready
? tgIncrementalUpdate(state, boardGraph, committedPieces)
: tgBuildGraph(state);
} finally {
isGraphCalculating = false;
}
}2. 替換人類玩家的 generateGhosts()
尋找:
function generateGhosts() {將整個函式替換為以下版本。
它不再即時暴力掃描全部幾何位置,而是直接讀取已統計的 boardGraph.methods。若本回合已先預放一子,則以「第一子後的暫存盤面」重新建立暫存圖,讓第二子讀取正確的活法。
function tgPreviewState() {
const state = {
pieces: pieces
.concat(tempPieces)
.map(tgClonePiece),
// tempPieces 已經扣除 piecesCount,因此直接使用目前庫存。
stock: tgReadStock(),
scores: { ...scores },
currentPlayer,
nextVirtualId: 1
};
state.graph = tgBuildGraph(state);
return state;
}
function tgSecondPreviewAllowed(firstGhost, candidateMethod, stateAfterFirst) {
// 同一個目標棋子:候選活法已經在第一子後狀態中合法,直接允許。
if (String(firstGhost.targetId) === String(candidateMethod.targetId)) {
return true;
}
// 第一子形成單接後,第二子可以去任意對方棋子的活邊。
if (firstGhost.wasSingleJie) {
return true;
}
/*
* 若第一子不是單接,第二子只能:
* 1. 仍貼在同一對方棋子;或
* 2. 與第一子形成雙接。
*/
const candidateGhost = tgGhostFromMethod(
candidateMethod,
`preview:${candidateMethod.id}`
);
return tgTriGoldenContacts(
firstGhost,
candidateGhost,
stateAfterFirst.graph
).length > 0;
}
function generateGhosts() {
ghosts = [];
if (!selectedTile || targetOpponentPieceId === null) return;
tgEnsureBoardGraphSync();
const state = tempPieces.length > 0
? tgPreviewState()
: (() => {
const live = tgBoardState();
live.graph = boardGraph;
return live;
})();
const wantedType = SHAPE_MAP[selectedTile];
const wantedFlip = !!isFlipped[selectedTile];
for (const method of state.graph.methods.values()) {
if (method.owner !== currentPlayer) continue;
if (String(method.targetId) !== String(targetOpponentPieceId)) continue;
if (method.type !== wantedType) continue;
if (method.svgId !== selectedTile) continue;
if (method.isFlipped !== wantedFlip) continue;
if (
tempPieces.length === 1 &&
!tgSecondPreviewAllowed(tempPieces[0], method, state)
) {
continue;
}
const ghost = tgGhostFromMethod(
method,
pieces.length + tempPieces.length + ghosts.length + 1
);
ghost.wasSingleJie = !!method.isSingleJie;
ghosts.push(ghost);
}
}3. 修改 commitGhost()
在 commitGhost(index) 中,找到:
let gp = ghosts[index];緊接著加入:
const sourceMethod = boardGraph.methods.get(gp.mId);
gp.wasSingleJie = !!(
sourceMethod &&
sourceMethod.isSingleJie
);並確保後面把 gp 放入 tempPieces 時,不要刪掉:
gp.mId
gp.targetId
gp.targetEdge
gp.myEdge
gp.wasSingleJie這些欄位是第二手預放、MCTS 單接更新、雙接合法性判斷所需資料。
4. 在真正確認落子後掛接增量更新
尋找 actionCheck() 內部真正把 tempPieces 提交進 pieces 的位置。
通常會接近下列形式:
lastTurnPieces = tempPieces.slice();
pieces.push(...tempPieces);
tempPieces = [];請改為:
const committedForGraph = tempPieces.map(tgClonePiece);
lastTurnPieces = tempPieces.slice();
pieces.push(...tempPieces);
tempPieces = [];
// 必須放在 pieces 與 piecesCount 都已更新之後。
tgOnCommittedPieces(committedForGraph);不要在
actionCheck()裡再呼叫舊的
updateIncrementalGraph()、updateIncrementalGraphLocal()、computeAllLifeStatus()或incrementalAddPiece()。
5. 重置、悔棋、重做、匯入棋譜後的修改
在下列函式的最後、盤面已恢復後加入:
resetGame()undo()redo()confirmImport()或你的匯入棋譜函式
加入:
tgInvalidateBoardGraph();
tgEnsureBoardGraphSync();這些操作不是「新增一子」,而是盤面回跳;應全盤重建,不應硬套增量更新。
6. 新增:MCTS 與廣度優先 Minimax 搜尋核心
插入位置: 放在 startAI() 前面。
// ============================================================================
// AI 搜尋狀態與完整回合候選
// ============================================================================
function tgCloneState(state) {
return {
pieces: state.pieces.map(tgClonePiece),
stock: { ...state.stock },
scores: { ...state.scores },
currentPlayer: state.currentPlayer,
nextVirtualId: state.nextVirtualId,
graph: tgCloneGraph(state.graph)
};
}
function tgSearchStateFromBoard() {
tgEnsureBoardGraphSync();
const state = tgBoardState();
state.graph = tgCloneGraph(boardGraph);
let maxNumericId = 0;
for (const piece of state.pieces) {
const numeric = Number(piece.id);
if (Number.isFinite(numeric)) {
maxNumericId = Math.max(maxNumericId, numeric);
}
}
state.nextVirtualId = maxNumericId + 1;
return state;
}
function tgRemainingPiecesOf(state, player) {
return tgTilesOfPlayer(player).reduce(
(sum, tileId) => sum + tgStockOf(state, tileId),
0
);
}
function tgPiecesRequiredThisTurn(state, player) {
const remaining = tgRemainingPiecesOf(state, player);
if (remaining <= 1) return 1;
// 先手方開局第一回合只下一子。
if (
state.pieces.length === 0 &&
player === openingPlayerThisGame
) {
return 1;
}
return 2;
}
function tgIsLaterPlayerLastSingle(state, player) {
const laterPlayer = tgOtherPlayer(openingPlayerThisGame);
return (
player === laterPlayer &&
tgRemainingPiecesOf(state, player) === 1
);
}
function tgMethodsForPlayer(state, player) {
return Array.from(state.graph.methods.values()).filter(method => {
if (method.owner !== player) return false;
if (!tgEnoughForOne(state, method)) return false;
return isValidGhost(
tgGhostFromMethod(method, `available:${method.id}`),
state.pieces
);
});
}
function tgMaterializePiece(ghost, id) {
return {
...ghost,
id,
vertices: ghost.vertices.map(tgCopyRingPoint),
parentId: ghost.targetId,
targetId: ghost.targetId,
targetEdge: ghost.targetEdge,
edgeOnOpp: ghost.targetEdge
};
}
function tgAppendOne(state, ghost) {
if (tgStockOf(state, ghost.svgId) <= 0) return null;
const next = tgCloneState(state);
const piece = tgMaterializePiece(
ghost,
`sim:${next.nextVirtualId++}`
);
if (!isValidGhost(piece, next.pieces)) return null;
const gain = tgScoreGain(next, [piece]);
next.pieces.push(piece);
next.stock[piece.svgId]--;
next.scores[1] += gain[1];
next.scores[2] += gain[2];
/*
* 單接的第一子:
* 這裡立刻更新整盤死活、活法、雙接、共活,
* 第二子必須從 next.graph 中取得。
*/
next.graph = tgIncrementalUpdate(next, next.graph, [piece]);
return {
state: next,
piece,
gain
};
}
function tgAppendPairAfterTurn(state, ghostA, ghostB) {
if (ghostA.svgId === ghostB.svgId) {
if (tgStockOf(state, ghostA.svgId) < 2) return null;
} else {
if (
tgStockOf(state, ghostA.svgId) < 1 ||
tgStockOf(state, ghostB.svgId) < 1
) {
return null;
}
}
const next = tgCloneState(state);
const pieceA = tgMaterializePiece(
ghostA,
`sim:${next.nextVirtualId++}`
);
const pieceB = tgMaterializePiece(
ghostB,
`sim:${next.nextVirtualId++}`
);
if (!isValidGhost(pieceA, next.pieces)) return null;
if (!isValidGhost(pieceB, next.pieces.concat([pieceA]))) {
return null;
}
const gain = tgScoreGain(next, [pieceA, pieceB]);
next.pieces.push(pieceA, pieceB);
next.stock[pieceA.svgId]--;
next.stock[pieceB.svgId]--;
next.scores[1] += gain[1];
next.scores[2] += gain[2];
/*
* 雙接、共活:
* 兩子均確認後才更新圖,不在兩子之間更新。
*/
next.graph = tgIncrementalUpdate(
next,
next.graph,
[pieceA, pieceB]
);
return {
state: next,
pieces: [pieceA, pieceB],
gain
};
}
/*
* plan.kind:
* - 'single':第一子單接;第一子後立即更新圖,再下第二子。
* - 'double':兩活法雙接;整回合完成後才更新圖。
* - 'colive':同一目標棋子的共活兩子;整回合完成後才更新圖。
* - 'last-single':最後一手單子。
*/
function tgApplyTurnPlan(state, plan) {
if (!plan || !plan.placements || plan.placements.length === 0) {
return null;
}
let result = null;
if (plan.placements.length === 1) {
result = tgAppendOne(state, plan.placements[0]);
if (!result) return null;
result.state.currentPlayer = tgOtherPlayer(state.currentPlayer);
return result;
}
if (plan.kind === 'single') {
const first = tgAppendOne(state, plan.placements[0]);
if (!first) return null;
const second = tgAppendOne(first.state, plan.placements[1]);
if (!second) return null;
second.state.currentPlayer = tgOtherPlayer(state.currentPlayer);
return second;
}
result = tgAppendPairAfterTurn(
state,
plan.placements[0],
plan.placements[1]
);
if (!result) return null;
result.state.currentPlayer = tgOtherPlayer(state.currentPlayer);
return result;
}
function tgMakeSinglePlans(state, player, methods, requiredPieces) {
const plans = [];
for (const method of methods) {
const firstGhost = tgGhostFromMethod(
method,
`plan-single-first:${method.id}`
);
if (requiredPieces === 1) {
plans.push({
kind: 'single',
placements: [firstGhost],
priority: method.jieScore
});
continue;
}
/*
* 必須先模擬第一個單接並更新圖,
* 然後才從更新後的活法選第二手。
*/
const afterFirst = tgAppendOne(state, firstGhost);
if (!afterFirst) continue;
const secondMethods = tgMethodsForPlayer(afterFirst.state, player);
for (const secondMethod of secondMethods) {
const secondGhost = tgGhostFromMethod(
secondMethod,
`plan-single-second:${secondMethod.id}`
);
if (!isValidGhost(secondGhost, afterFirst.state.pieces)) {
continue;
}
plans.push({
kind: 'single',
placements: [firstGhost, secondGhost],
priority: method.jieScore
});
}
}
return plans;
}
function tgDoubleRecords(state, player, scoringOnly) {
const records = [];
const seen = new Set();
for (const methodA of tgMethodsForPlayer(state, player)) {
for (const [methodBId, info] of methodA.doubleWith.entries()) {
const key = tgPairKey(methodA.id, methodBId);
if (seen.has(key)) continue;
seen.add(key);
const methodB = state.graph.methods.get(methodBId);
if (!methodB) continue;
if (methodB.owner !== player) continue;
if (!info.compatible) continue;
if (scoringOnly && (info.gain[player] || 0) <= 0) {
continue;
}
records.push({
methodA,
methodB,
info
});
}
}
return records;
}
function tgMakeDoublePlans(state, player, scoringOnly) {
const plans = [];
for (const record of tgDoubleRecords(state, player, scoringOnly)) {
const ghostA = tgGhostFromMethod(
record.methodA,
`plan-double-a:${record.methodA.id}`
);
const ghostB = tgGhostFromMethod(
record.methodB,
`plan-double-b:${record.methodB.id}`
);
plans.push({
kind: 'double',
placements: [ghostA, ghostB],
priority: record.info.gain[player] || 0
});
}
return plans;
}
function tgMakeCoLivePlans(state, player) {
const plans = [];
const seen = new Set();
for (const methodA of tgMethodsForPlayer(state, player)) {
for (const methodBId of methodA.coLiveWith.values()) {
const key = tgPairKey(methodA.id, methodBId);
if (seen.has(key)) continue;
seen.add(key);
const methodB = state.graph.methods.get(methodBId);
if (!methodB) continue;
if (methodB.owner !== player) continue;
plans.push({
kind: 'colive',
placements: [
tgGhostFromMethod(methodA, `plan-colive-a:${methodA.id}`),
tgGhostFromMethod(methodB, `plan-colive-b:${methodB.id}`)
],
priority: 0
});
}
}
return plans;
}
function tgHasCoLivePlan(state, player) {
return tgMakeCoLivePlans(state, player).length > 0;
}
/*
* 仲裁成功條件:
* 只有「恰好一方」沒有共活下法時,沒有共活的一方才可仲裁成功。
*/
function tgCanClaimArbitration(state, claimant) {
const opponent = tgOtherPlayer(claimant);
const claimantHasCoLive = tgHasCoLivePlan(state, claimant);
const opponentHasCoLive = tgHasCoLivePlan(state, opponent);
return !claimantHasCoLive && opponentHasCoLive;
}
/*
* MCTS 與 Minimax 均不得選擇:
* 讓對方下回合完全沒有共活下法的落子。
*/
function tgFilterPlansKeepingOpponentCoLive(state, player, plans) {
const opponent = tgOtherPlayer(player);
const safe = [];
for (const plan of plans) {
const applied = tgApplyTurnPlan(state, plan);
if