共享会话
🔧 增量運算優化分析
分享于 2026年8月17日 03:25改進與修復
以下是本程式「是否有在用增量運算」的分析結果:
沒有使用的關鍵增量運算
對於使用者所說的「只對本回合新增兩顆棋子做局部更新」,在最耗時的部分,程式沒有完成這種增量化。
- 每個搜尋節點重新生成所有單子
每次 generateLegalTurns(state) 都重新呼叫:
generateLegalSinglePlacements(state, state.mover)
而它會:
掃過盤上所有對方棋子;
掃過尚可用的棋種;
正反面各一次;
目標邊 4 種;
自己貼合邊 4 種;
對每個候選呼叫 validatePlacementGeometry();
再用 isValidGhost() 對整個盤面逐一做幾何衝突檢查。
沒有「父節點候選集 + 新增兩子影響範圍」的更新機制,也沒有按照盤面狀態保存生成結果的 transposition cache。
- 兩子組合是重新兩兩枚舉
generateLegalTurns() 對所有單子做: js
for (let i = 0; i < singles.length; i++) { for (let j = i + 1; j < singles.length; j++) {
這是平方級配對。每一對還可能進行:
twoGhostsCompatible(first, second);
formsTriGolden(first, second);
validateTurnOnState(state, move);
adjudicateSearchTurn(state, move)。
這不是局部增量。
- 計分是全盤重新掃描
adjudicateSearchTurn(state, placedPieces) 裡有: const allPieces = state.boardPieces.concat(placedPieces);
for (const dinger of allPieces) { for (const victim of allPieces) {
即使本回合只新增一或兩顆棋,它仍對所有棋子對做接觸、頂點、邊與樹距離判斷,而不是只檢查「新棋與舊棋」及「新棋彼此」。
此外,每個可能的頂鑫關係還會呼叫:
getTreeDistance(dinger, victim, allPieces, idMap)
這也不是沿父狀態保存的可增量圖/距離結果。
死活/共活判斷同樣重算
hasArbitrationDefence(state) 會直接:
return generateArbitrationDefenceTurns(state).length > 0;
而 generateArbitrationDefenceTurns() 不是「找到第一組共活便停止」,而是收集全部共活回合後才回傳陣列。對 MCTS 而言,實際只需要知道是否存在至少一組,卻仍付出了生成所有組合、去重、完整驗證的成本。
結論
目前的情況是:
有 SAT 快取;
沒有候選走法的真正增量更新;
沒有計分/連接圖/樹距離的真正增量更新;
沒有局面轉置快取;
沒有把「是否有至少一組共活」做早停。
所以答案是:目前並非使用者設想的那種「只根據新增兩子局部更新活法」的增量運算。
分析結束。
根據以上分析,在MCTS和minimax搜尋中,把所有的「活法、共活、單連、雙接、計分」等都做成增量運算。
其中generateLegalTurns和generateArbitrationDefenceTurns在功能上是重覆的,應新建一個增量運算的函式,仍然通過增量的方式把所有的「共活」算完,不僅用於判定是否存在「共活」,還能把所有的「共活」用於接下來的MCTS或minimax增量運算。
回答要求:
不要使當前正確的遊戲流程發生改變。明確給出所要修改的地方和所要替換的代碼。
QuanAI我會先定位附件中搜尋、走法生成、共活判定、計分與狀態複製的實作,再依現有資料結構設計可直接替換、且不改變規則語義的增量版本。
以下修改只替換搜尋層,不改變 UI、正式落子流程、仲裁顯示方式、棋譜格式或規則語義。
核心做法是:
SearchState保存持久化增量索引。- 父節點落下一/兩子後:
- 移除被新棋破壞的舊活法;
- 只枚舉新棋四條邊產生的新活法;
- 只重算受影響的單接;
- 保留未受影響的共活/合法配對;
- 只配對新增或計分狀態改變的活法。
generateLegalTurns()和generateArbitrationDefenceTurns()統一使用同一份增量回合集合。- 計分只掃描「新棋-舊棋」及「新棋-新棋」,不再掃描舊棋-舊棋。
- 樹距離使用增量
parent/depth/idMap索引。 - 每個局面內快取走法分析結果,避免排序、MCTS 展開、minimax 展開重複套用和計分。
一、擴充 SearchState
1. 修改 cloneSearchState()
在 cloneSearchState() 回傳物件最後,把:
nextHypId: state.nextHypId替換成:
nextHypId: state.nextHypId,
// 搜尋用持久化增量資料。
// _topology 可以安全共享;子狀態加入棋子時會建立新版本。
// _searchIndex 屬於當前盤面,不得直接當成子盤面的索引使用。
_topology: state._topology || null,
_searchIndex: state._searchIndex || null,
_searchDelta: null2. 修改兩個 SearchState 建立函式
在以下兩個函式回傳物件的末尾:
createSearchStateFromGlobals()createSearchStateForBoard()
把:
nextHypId替換成:
nextHypId,
_topology: null,
_searchIndex: null,
_searchDelta: null二、把計分和樹距離改成真正增量
找到原本整個:
function adjudicateSearchTurn(state, placedPieces) {
...
}完整替換成以下代碼;並把前面的拓撲輔助函式一起放在它之前。
// =============================================================================
// SearchState 增量連接樹/樹距離
// =============================================================================
function buildSearchTopology(boardPieces) {
const pieceById = new Map();
const parentById = new Map();
const depthById = new Map();
for (const piece of boardPieces) {
pieceById.set(piece.id, piece);
parentById.set(
piece.id,
piece.parentId !== undefined && piece.parentId !== null
? piece.parentId
: null
);
}
function resolveDepth(id, visiting = new Set()) {
if (depthById.has(id)) return depthById.get(id);
if (visiting.has(id)) return 0;
visiting.add(id);
const parentId = parentById.get(id);
let depth = 0;
if (parentId !== null && parentId !== undefined && pieceById.has(parentId)) {
depth = resolveDepth(parentId, visiting) + 1;
}
visiting.delete(id);
depthById.set(id, depth);
return depth;
}
for (const piece of boardPieces) {
resolveDepth(piece.id);
}
return {
pieceById,
parentById,
depthById
};
}
function ensureSearchTopology(state) {
if (!state._topology) {
state._topology = buildSearchTopology(state.boardPieces);
}
return state._topology;
}
function extendSearchTopology(parentTopology, placedPieces) {
const topology = {
pieceById: new Map(parentTopology.pieceById),
parentById: new Map(parentTopology.parentById),
depthById: new Map(parentTopology.depthById)
};
for (const piece of placedPieces) {
const parentId =
piece.parentId !== undefined && piece.parentId !== null
? piece.parentId
: null;
topology.pieceById.set(piece.id, piece);
topology.parentById.set(piece.id, parentId);
const depth =
parentId !== null && topology.depthById.has(parentId)
? topology.depthById.get(parentId) + 1
: 0;
topology.depthById.set(piece.id, depth);
}
return topology;
}
/*
* 使用已保存的 parentById/pieceById 計算路徑。
*
* 回傳路徑方向與原 getTreeDistance 一致:
* 鑫棋 B -> 共同祖先 -> 頂棋 A。
*/
function getIndexedTreeDistance(A, B, topology) {
if (!A || !B) return null;
if (!topology.pieceById.has(A.id) || !topology.pieceById.has(B.id)) {
return null;
}
const ancestorsOfA = new Map();
const pathA = [];
let currentId = A.id;
let guard = 0;
while (
currentId !== null &&
currentId !== undefined &&
guard++ <= topology.pieceById.size + 1
) {
ancestorsOfA.set(currentId, pathA.length);
pathA.push(currentId);
currentId = topology.parentById.get(currentId);
}
const pathB = [];
currentId = B.id;
guard = 0;
let lcaId = null;
while (
currentId !== null &&
currentId !== undefined &&
guard++ <= topology.pieceById.size + 1
) {
pathB.push(currentId);
if (ancestorsOfA.has(currentId)) {
lcaId = currentId;
break;
}
currentId = topology.parentById.get(currentId);
}
if (lcaId === null) return null;
const aToLcaIndex = ancestorsOfA.get(lcaId);
// B -> ... -> LCA
const finalIds = pathB.slice();
// LCA 的下一顆 -> ... -> A
for (let i = aToLcaIndex - 1; i >= 0; i--) {
finalIds.push(pathA[i]);
}
const finalPath = finalIds
.map(id => topology.pieceById.get(id))
.filter(Boolean);
/*
* 路徑上排除頂棋、鑫棋自身後的中間棋子數。
* 父子直接相接時 finalPath.length === 2,因此得分為 0。
*/
const dist = Math.max(0, finalPath.length - 2);
return {
dist,
path: finalPath
};
}
/*
* 增量計分。
*
* 正確性依據:
* 每一次 applySearchTurnToState 都會把本回合所有新形成的頂鑫 key
* 加入 scoredVictims。因此下一回合開始前,所有舊棋-舊棋結構都已經
* 被封存,不需要再次掃描。
*
* 新的未封存頂鑫只能涉及本回合新增棋子:
* 1. 新頂棋 -> 舊鑫棋
* 2. 舊頂棋 -> 新鑫棋
* 3. 新頂棋 -> 新鑫棋
*/
function adjudicateSearchTurn(state, placedPieces) {
const normalizedPlacedPieces = placedPieces.map(piece => {
preparePieceSAT(piece);
return piece;
});
const allPieces = state.boardPieces.concat(normalizedPlacedPieces);
const newIds = new Set(normalizedPlacedPieces.map(piece => piece.id));
const dingMap = new Map();
const topology = extendSearchTopology(
ensureSearchTopology(state),
normalizedPlacedPieces
);
/*
* 只檢查至少一端是新棋子的有向棋子對。
* 由原本 O((n+k)^2) 降為 O(k*n+k^2),k 通常為 1 或 2。
*/
for (const dinger of allPieces) {
for (const victim of allPieces) {
if (dinger.id === victim.id) continue;
const involvesNew =
newIds.has(dinger.id) || newIds.has(victim.id);
if (!involvesNew) continue;
if (
checkSATCollision(getSAT(dinger), getSAT(victim)) === 'separated'
) {
continue;
}
const dingVertices = [];
for (const vertex of dinger.vertices) {
let touchesEdge = false;
for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
if (
pointOnOpenSegment(
vertex,
victim.vertices[edgeIndex],
victim.vertices[(edgeIndex + 1) % 4]
)
) {
touchesEdge = true;
break;
}
}
if (touchesEdge) {
dingVertices.push(vertex);
}
}
if (dingVertices.length === 0) continue;
const treeResult = getIndexedTreeDistance(
dinger,
victim,
topology
);
if (!treeResult || treeResult.dist < 0) continue;
for (const vertex of dingVertices) {
const key = victim.id + '_' + vertex.join('_');
if (state.scoredVictims.has(key)) continue;
if (!dingMap.has(key)) {
dingMap.set(key, []);
}
dingMap.get(key).push({
dinger,
victim,
dist: treeResult.dist,
path: treeResult.path
});
}
}
}
const gain = { 1: 0, 2: 0 };
const newScoredKeys = [];
const records = [];
const rings = [];
for (const [key, candidates] of dingMap) {
let best = null;
for (const candidate of candidates) {
if (!best || candidate.dist < best.dist) {
best = candidate;
}
}
if (!best) continue;
let scorer = 0;
if (state.scoringMode === 0) {
scorer = best.dinger.owner;
} else if (state.scoringMode === 1) {
scorer = best.victim.owner;
} else if (
state.scoringMode === 2 &&
best.dinger.owner === best.victim.owner
) {
scorer = best.dinger.owner;
}
if (best.dist > 0 && scorer > 0) {
gain[scorer] += best.dist;
rings.push({
path: best.path,
scorer
});
}
/*
* 保持原規則:
* 距離為 0 也要封存這個鑫棋端點。
*/
newScoredKeys.push(key);
records.push({
key,
dinger: best.dinger,
victim: best.victim,
dist: best.dist,
score: best.dist,
scorer,
path: best.path,
involvesNew: true
});
}
return {
gain,
newScoredKeys,
records,
rings,
topology,
hasScoringStructure: records.some(record => record.score > 0)
};
}原本的 getTreeDistance() 可以保留,因為 UI 或舊的顯示統計仍可能呼叫它;MCTS/minimax 計分將不再使用它。
三、建立統一增量活法/共活/單接/雙接/合法回合引擎
找到從:
/*
* 在指定 SearchState 上生成全部合法單子落點。
*/
function generateLegalSinglePlacements(...)開始,到:
function hasArbitrationDefence(state) {
...
}結束的整段,完整替換成以下代碼。
// =============================================================================
// 統一增量活法/共活/單接/雙接/合法回合引擎
// =============================================================================
function createPlayerSearchCatalog() {
return {
// placementSignature -> ghost
singles: new Map(),
// placementSignature -> 單子計分資訊
singleMeta: new Map(),
// turnSignature -> pair record
// null 表示尚未建立。
legalPairs: null,
// turnSignature -> analyseSearchMove 結果
analysisByMove: new Map(),
turnSet: null
};
}
function createStateViewForPlayer(state, player) {
if (state.mover === player) return state;
return {
...state,
mover: player
};
}
function setsEqual(a, b) {
if (a === b) return true;
if (!a || !b || a.size !== b.size) return false;
for (const value of a) {
if (!b.has(value)) return false;
}
return true;
}
function singleMetaEqual(a, b) {
if (!a || !b) return false;
return (
a.hasScoringStructure === b.hasScoringStructure &&
a.gain1 === b.gain1 &&
a.gain2 === b.gain2 &&
setsEqual(a.scoreKeys, b.scoreKeys)
);
}
function singleMetaTouchesScoredKeys(meta, scoredKeys) {
if (!meta || !meta.scoreKeys || scoredKeys.size === 0) {
return false;
}
for (const key of meta.scoreKeys) {
if (scoredKeys.has(key)) return true;
}
return false;
}
function calculateSinglePlacementMeta(state, ghost) {
const adjudication = adjudicateSearchTurn(state, [ghost]);
return {
hasScoringStructure: adjudication.hasScoringStructure,
gain1: adjudication.gain[1],
gain2: adjudication.gain[2],
scoreKeys: new Set(adjudication.newScoredKeys)
};
}
/*
* 只在指定 targetPieces 的邊上生成活法。
*
* 全盤初建時 targetPieces 是全部對方棋。
* 子節點增量更新時 targetPieces 只包含本回合新增的對方棋。
*/
function enumerateSinglePlacementsOnTargets(
state,
player,
targetPieces,
existingSignatures = null
) {
const result = [];
const seen = existingSignatures || new Set();
const tileIds = getPlayerTileIds(player).filter(
tileId => (state.counts[tileId] || 0) > 0
);
for (const target of targetPieces) {
if (target.owner === player) continue;
for (const tileId of tileIds) {
for (const flip of [false, true]) {
for (let targetEdge = 0; targetEdge < 4; targetEdge++) {
for (let myEdge = 0; myEdge < 4; myEdge++) {
const attached = attachByEdge(
SHAPE_MAP[tileId],
flip,
myEdge,
target.vertices[targetEdge],
target.vertices[(targetEdge + 1) % 4],
!!target.isFlipped
);
if (!attached) continue;
const ghost = {
id: allocateHypothesisPieceId(),
vertices: attached.vertices,
type: SHAPE_MAP[tileId],
owner: player,
svgId: tileId,
isFlipped: flip,
edgeOnOpp: targetEdge,
targetId: target.id,
myEdge,
targetEdge,
parentId: target.id,
level:
(target.level !== undefined ? target.level : 0) + 1
};
preparePieceSAT(ghost);
const validation = validatePlacementGeometry(
ghost,
state.boardPieces,
player
);
if (!validation.ok) continue;
const signature = piecePlacementSignature(ghost);
if (seen.has(signature)) continue;
seen.add(signature);
result.push(ghost);
}
}
}
}
}
return result;
}
function buildFullPlayerSingleCatalog(state, player) {
const view = createStateViewForPlayer(state, player);
const catalog = createPlayerSearchCatalog();
if (view.boardPieces.length === 0) {
return catalog;
}
const targets = view.boardPieces.filter(
piece => piece.owner !== player
);
const singles = enumerateSinglePlacementsOnTargets(
view,
player,
targets
);
for (const ghost of singles) {
const signature = piecePlacementSignature(ghost);
catalog.singles.set(signature, ghost);
catalog.singleMeta.set(
signature,
calculateSinglePlacementMeta(view, ghost)
);
}
return catalog;
}
function makeLegalPairRecord(
state,
catalog,
first,
second
) {
if (!pairFitsInventory(state, first, second)) {
return null;
}
if (!twoGhostsCompatible(first, second)) {
return null;
}
const firstTargetEdge =
first.targetEdge !== undefined
? first.targetEdge
: first.edgeOnOpp;
const secondTargetEdge =
second.targetEdge !== undefined
? second.targetEdge
: second.edgeOnOpp;
const sameTarget = first.targetId === second.targetId;
const mutual = formsTriGolden(first, second);
if (sameTarget) {
if (firstTargetEdge === secondTargetEdge) {
return null;
}
return {
move: [first, second],
aSig: piecePlacementSignature(first),
bSig: piecePlacementSignature(second),
sameTarget: true,
mutual
};
}
const firstSignature = piecePlacementSignature(first);
const secondSignature = piecePlacementSignature(second);
const firstMeta = catalog.singleMeta.get(firstSignature);
const secondMeta = catalog.singleMeta.get(secondSignature);
/*
* 跨目標例外至少要有:
* 1. 第一子單接;
* 2. 第二子單接;
* 3. 兩子互頂。
*/
if (
!(firstMeta && firstMeta.hasScoringStructure) &&
!(secondMeta && secondMeta.hasScoringStructure) &&
!mutual
) {
return null;
}
/*
* 最終仍以正式計分語義判定:
* 必須是尚未封存且距離大於 0 的頂鑫結構。
*/
const adjudication = adjudicateSearchTurn(state, [
first,
second
]);
if (!adjudication.hasScoringStructure) {
return null;
}
return {
move: [first, second],
aSig: firstSignature,
bSig: secondSignature,
sameTarget: false,
mutual
};
}
function buildFullLegalPairCatalog(state, catalog) {
const singles = Array.from(catalog.singles.values());
const pairs = new Map();
for (let i = 0; i < singles.length; i++) {
for (let j = i + 1; j < singles.length; j++) {
const record = makeLegalPairRecord(
state,
catalog,
singles[i],
singles[j]
);
if (!record) continue;
pairs.set(turnSignature(record.move), record);
}
}
return pairs;
}
/*
* 父節點活法在加入新棋後只可能:
* 1. 因幾何衝突而失效;
* 2. 因棋種耗盡而失效;
* 3. 因接觸新棋而新增/改變單接計分;
* 4. 因 scoredVictims 新增而失去原有單接。
*
* 不會有「舊目標上原本非法的幾何落點因加入棋子而變合法」的情況。
*/
function derivePlayerSingleCatalog(
parentCatalog,
childState,
player,
newPieces,
newScoredKeys
) {
const view = createStateViewForPlayer(childState, player);
const catalog = createPlayerSearchCatalog();
const changedSignatures = new Set();
const addedSignatures = new Set();
const scoredKeySet = new Set(newScoredKeys || []);
for (const [signature, ghost] of parentCatalog.singles) {
if ((view.counts[ghost.svgId] || 0) <= 0) {
continue;
}
let survives = true;
for (const newPiece of newPieces) {
if (ghostConflictsWithPiece(ghost, newPiece)) {
survives = false;
break;
}
}
if (!survives) continue;
catalog.singles.set(signature, ghost);
const oldMeta = parentCatalog.singleMeta.get(signature);
const geometricallyAffected = newPieces.some(
newPiece =>
checkSATCollision(getSAT(ghost), getSAT(newPiece)) !==
'separated'
);
const scoreSealAffected = singleMetaTouchesScoredKeys(
oldMeta,
scoredKeySet
);
if (
!oldMeta ||
geometricallyAffected ||
scoreSealAffected
) {
const newMeta = calculateSinglePlacementMeta(view, ghost);
catalog.singleMeta.set(signature, newMeta);
if (!singleMetaEqual(oldMeta, newMeta)) {
changedSignatures.add(signature);
}
} else {
catalog.singleMeta.set(signature, oldMeta);
}
}
/*
* 新落下的對方棋子才會為 player 新增可貼合目標。
*/
const newTargets = newPieces.filter(
piece => piece.owner !== player
);
if (newTargets.length > 0) {
const seen = new Set(catalog.singles.keys());
const addedGhosts = enumerateSinglePlacementsOnTargets(
view,
player,
newTargets,
seen
);
for (const ghost of addedGhosts) {
const signature = piecePlacementSignature(ghost);
catalog.singles.set(signature, ghost);
catalog.singleMeta.set(
signature,
calculateSinglePlacementMeta(view, ghost)
);
addedSignatures.add(signature);
changedSignatures.add(signature);
}
}
return {
catalog,
changedSignatures,
addedSignatures
};
}
function deriveLegalPairCatalog(
parentCatalog,
childCatalog,
childState,
changedSignatures
) {
/*
* 父節點還沒建立 pair catalog 時,不能增量繼承;
* 呼叫端之後會按需要完整建立一次。
*/
if (parentCatalog.legalPairs === null) {
return null;
}
const result = new Map();
/*
* 第一部分:保留未失效的舊合法配對。
*/
for (const [pairSignature, oldRecord] of parentCatalog.legalPairs) {
const first = childCatalog.singles.get(oldRecord.aSig);
const second = childCatalog.singles.get(oldRecord.bSig);
if (!first || !second) continue;
if (!pairFitsInventory(childState, first, second)) continue;
const affected =
changedSignatures.has(oldRecord.aSig) ||
changedSignatures.has(oldRecord.bSig);
if (affected) {
const newRecord = makeLegalPairRecord(
childState,
childCatalog,
first,
second
);
if (newRecord) {
result.set(turnSignature(newRecord.move), newRecord);
}
} else {
result.set(pairSignature, {
...oldRecord,
move: [first, second]
});
}
}
/*
* 第二部分:
* 只把「新增或單接狀態改變的活法」與目前全部活法配對。
*
* 這同時補上兩類新合法回合:
* 1. 新棋四條邊產生的新活法;
* 2. 舊活法因接觸新棋而首次取得跨目標計分例外。
*/
const allEntries = Array.from(childCatalog.singles.entries());
const checked = new Set();
for (const changedSignature of changedSignatures) {
const first = childCatalog.singles.get(changedSignature);
if (!first) continue;
for (const [otherSignature, second] of allEntries) {
if (changedSignature === otherSignature) continue;
const orderedKey =
changedSignature < otherSignature
? changedSignature + '||' + otherSignature
: otherSignature + '||' + changedSignature;
if (checked.has(orderedKey)) continue;
checked.add(orderedKey);
const record = makeLegalPairRecord(
childState,
childCatalog,
first,
second
);
const pairSignature = turnSignature([first, second]);
if (record) {
result.set(pairSignature, record);
} else {
result.delete(pairSignature);
}
}
}
return result;
}
function buildRootSearchIndex(state) {
return {
players: {
1: buildFullPlayerSingleCatalog(state, 1),
2: buildFullPlayerSingleCatalog(state, 2)
}
};
}
function deriveSearchIndex(parentState, childState, delta) {
const parentIndex = ensureSearchIndex(parentState);
const result = {
players: {}
};
for (const player of [1, 2]) {
const parentCatalog = parentIndex.players[player];
const derived = derivePlayerSingleCatalog(
parentCatalog,
childState,
player,
delta.placedPieces,
delta.newScoredKeys
);
derived.catalog.legalPairs = deriveLegalPairCatalog(
parentCatalog,
derived.catalog,
createStateViewForPlayer(childState, player),
derived.changedSignatures
);
result.players[player] = derived.catalog;
}
return result;
}
function ensureSearchIndex(state) {
if (state._searchIndex) {
return state._searchIndex;
}
if (state._searchDelta && state._searchDelta.parentState) {
state._searchIndex = deriveSearchIndex(
state._searchDelta.parentState,
state,
state._searchDelta
);
} else {
state._searchIndex = buildRootSearchIndex(state);
}
return state._searchIndex;
}
function ensurePlayerPairCatalog(state, player) {
const index = ensureSearchIndex(state);
const catalog = index.players[player];
if (catalog.legalPairs === null) {
const view = createStateViewForPlayer(state, player);
catalog.legalPairs = buildFullLegalPairCatalog(
view,
catalog
);
catalog.turnSet = null;
}
return catalog;
}
/*
* 統一回合集合。
*
* singlePlacements:全部活法
* coLiveTurns:全部共活
* singleJieTurns:至少有一子形成單接的合法回合
* doubleJieTurns:兩子互頂的合法回合
* legalTurns:包含共活及跨目標頂鑫例外的全部正常合法回合
* arbitrationTurns:仲裁防禦回合;目前等同 coLiveTurns
*/
function generateIncrementalTurnSet(state) {
const required = getRequiredPiecesForState(state);
if (state.boardPieces.length === 0) {
return {
singlePlacements: [],
legalTurns: [],
coLiveTurns: [],
arbitrationTurns: [],
singleJieTurns: [],
doubleJieTurns: []
};
}
const index = ensureSearchIndex(state);
const catalog = index.players[state.mover];
if (catalog.turnSet) {
return catalog.turnSet;
}
const singles = Array.from(catalog.singles.values());
if (required === 1) {
const singleTurns = singles.map(ghost => [ghost]);
catalog.turnSet = {
singlePlacements: singles,
legalTurns: singleTurns,
coLiveTurns: singleTurns,
arbitrationTurns: singleTurns,
singleJieTurns: singleTurns.filter(move => {
const meta = catalog.singleMeta.get(
piecePlacementSignature(move[0])
);
return !!(meta && meta.hasScoringStructure);
}),
doubleJieTurns: []
};
return catalog.turnSet;
}
/*
* 根節點同時建立雙方 pair catalog。
* 之後每個子節點即可從父節點的雙方 catalog 增量派生,
* 避免第一層每個不同子分支都為對手重新做平方級配對。
*/
if (!state._searchDelta) {
ensurePlayerPairCatalog(state, 1);
ensurePlayerPairCatalog(state, 2);
} else {
ensurePlayerPairCatalog(state, state.mover);
}
const activeCatalog =
ensureSearchIndex(state).players[state.mover];
const records = Array.from(activeCatalog.legalPairs.values());
const legalTurns = records.map(record => record.move);
const coLiveTurns = records
.filter(record => record.sameTarget)
.map(record => record.move);
const singleJieTurns = records
.filter(record => {
const firstMeta = activeCatalog.singleMeta.get(
record.aSig
);
const secondMeta = activeCatalog.singleMeta.get(
record.bSig
);
return (
(firstMeta && firstMeta.hasScoringStructure) ||
(secondMeta && secondMeta.hasScoringStructure)
);
})
.map(record => record.move);
const doubleJieTurns = records
.filter(record => record.mutual)
.map(record => record.move);
activeCatalog.turnSet = {
singlePlacements: singles,
legalTurns,
coLiveTurns,
arbitrationTurns: coLiveTurns,
singleJieTurns,
doubleJieTurns
};
return activeCatalog.turnSet;
}
/*
* 舊函式名稱保留成相容包裝。
* UI、仲裁、MCTS、minimax 不需要改變呼叫介面。
*/
function generateLegalSinglePlacements(
state,
player = state.mover
) {
const index = ensureSearchIndex(state);
return Array.from(index.players[player].singles.values());
}
function pairFitsInventory(state, first, second) {
if (first.svgId !== second.svgId) {
return (
(state.counts[first.svgId] || 0) >= 1 &&
(state.counts[second.svgId] || 0) >= 1
);
}
return (state.counts[first.svgId] || 0) >= 2;
}
function generateLegalTurns(state) {
return generateIncrementalTurnSet(state).legalTurns;
}
function generateArbitrationDefenceTurns(state) {
return generateIncrementalTurnSet(state).arbitrationTurns;
}
function hasArbitrationDefence(state) {
if (isSearchGameComplete(state)) {
return true;
}
/*
* 這裡不再另外生成共活。
* 它和正常合法回合來自同一份增量回合集合。
*/
return (
generateIncrementalTurnSet(state).arbitrationTurns.length > 0
);
}這一段會取代原本重複的兩套走法生成器,但保留原函式名稱作為相容入口,因此以下流程不需要改:
getAllValidMoves()executeArbitration()applyArbMove()- UI 仲裁上一個/下一個
- 棋譜匯入
- 正式
validateTurnOnState()
四、修改 applySearchTurnToState()
在 applySearchTurnToState() 中找到:
const adjudication = adjudicateSearchTurn(state, placedPieces);保留不變。
接著在:
next.boardPieces.push(...placedPieces);之後加入:
/*
* adjudicateSearchTurn 已建立「父拓撲 + 本回合新棋」的增量拓撲。
*/
next._topology = adjudication.topology;然後在:
next.mover = next.mover === 1 ? 2 : 1;之後、return 之前加入:
/*
* 不在 apply 時立即生成子節點活法,避免 MCTS/minimax
* 僅為排序而套用所有候選時,替每個尚未真正展開的子節點做多餘工作。
*
* 子節點第一次呼叫 generateIncrementalTurnSet 時,
* 才從父節點索引按這兩顆新棋做增量派生。
*/
next._searchIndex = null;
next._searchDelta = state._searchIndex
? {
parentState: state,
placedPieces: placedPieces.slice(),
newScoredKeys: adjudication.newScoredKeys.slice()
}
: null;修改後這一段應為:
next.lastTurnPieces = placedPieces.slice();
next.turnNumber++;
next.mover = next.mover === 1 ? 2 : 1;
next._searchIndex = null;
next._searchDelta = state._searchIndex
? {
parentState: state,
placedPieces: placedPieces.slice(),
newScoredKeys: adjudication.newScoredKeys.slice()
}
: null;
return {
state: next,
placedPieces,
adjudication
};五、把單接/雙接分析也接到同一份增量資料
找到原本整個:
function analyseSearchMove(state, move) {
...
}完整替換成:
function analyseSearchMove(state, move) {
/*
* 確保合法回合、單接、雙接及共活都來自同一份增量集合。
*/
generateIncrementalTurnSet(state);
const index = ensureSearchIndex(state);
const catalog = index.players[state.mover];
const signature = turnSignature(move);
const cached = catalog.analysisByMove.get(signature);
if (cached) return cached;
const mover = state.mover;
const opponent = mover === 1 ? 2 : 1;
const applied = applySearchTurnToState(state, move, {
validate: false
});
const moverGain =
applied.adjudication.gain[mover] -
applied.adjudication.gain[opponent];
let singleJieCount = 0;
for (const piece of move) {
const pieceSignature = piecePlacementSignature(piece);
const meta = catalog.singleMeta.get(pieceSignature);
if (meta && meta.hasScoringStructure) {
singleJieCount++;
}
}
const isDoubleJie =
move.length === 2 &&
formsTriGolden(move[0], move[1]) &&
applied.adjudication.hasScoringStructure;
const result = {
childState: applied.state,
adjudication: applied.adjudication,
moverGain,
singleJieCount,
isDoubleJie,
isTactical: singleJieCount > 0 || isDoubleJie
};
catalog.analysisByMove.set(signature, result);
return result;
}這會避免原本每次排序都重新:
adjudicateSearchTurn(state, [piece])一遍。
六、避免排序後又重複套用相同回合
1. 修改 minimax 的 maximizing 分支
在 runStateMinimax() 內部 alphaBetaState() 找到:
const child = applySearchTurnToState(state, move, { validate: false }).state;
best = Math.max(best, alphaBetaState(child, depth - 1, alpha, beta));替換成:
const child = analyseSearchMove(state, move).childState;
best = Math.max(
best,
alphaBetaState(child, depth - 1, alpha, beta)
);2. 修改 minimax 的 minimizing 分支
找到:
const child = applySearchTurnToState(state, move, { validate: false }).state;
best = Math.min(best, alphaBetaState(child, depth - 1, alpha, beta));替換成:
const child = analyseSearchMove(state, move).childState;
best = Math.min(
best,
alphaBetaState(child, depth - 1, alpha, beta)
);3. 修改 minimax 根候選迭代
找到:
const child = applySearchTurnToState(rootState, move, { validate: false }).state;
const value = alphaBetaState(child, depth - 1, -Infinity, Infinity);替換成:
const child = analyseSearchMove(
rootState,
move
).childState;
const value = alphaBetaState(
child,
depth - 1,
-Infinity,
Infinity
);4. 修改 MCTS 展開
在 runStateMCTS() 找到:
const childState = applySearchTurnToState(node.state, move, { validate: false }).state;替換成:
const childState = analyseSearchMove(
node.state,
move
).childState;這些替換很重要,否則 orderMovesForSearch() 已經分析過一次候選後,minimax/MCTS 展開時又會重複計分和建立子狀態。
七、修改 startAI(),只取得一次統一回合集合
找到:
const arbitrationDefences = generateArbitrationDefenceTurns(rootState);
if (!isSearchGameComplete(rootState) && arbitrationDefences.length === 0) {
aiThinking = false;
aiTriggerArbitrationSuccess();
return;
}
let rootMoves = generateLegalTurns(rootState);替換成:
/*
* 一次增量生成同時取得:
* - 全部活法
* - 全部共活
* - 全部單接
* - 全部雙接
* - 全部正常合法回合
*
* 不再分別呼叫兩套生成器。
*/
const rootTurnSet = generateIncrementalTurnSet(rootState);
const arbitrationDefences =
rootTurnSet.arbitrationTurns;
if (
!isSearchGameComplete(rootState) &&
arbitrationDefences.length === 0
) {
aiThinking = false;
aiTriggerArbitrationSuccess();
return;
}
let rootMoves = rootTurnSet.legalTurns.slice();功能沒有改變:
- 仲裁仍然只承認共活;
- 正常搜尋仍包含共活、單接、雙接和跨目標頂鑫例外;
- 所有共活仍完整保留,不是找到第一組就停止。
八、getAllValidMoves() 保持相容,但改為直接取統一集合
找到:
function getAllValidMoves(player) {
const state = createSearchStateFromGlobals(player);
return generateArbitrationDefenceTurns(state);
}替換成:
function getAllValidMoves(player) {
const state = createSearchStateFromGlobals(player);
return generateIncrementalTurnSet(state).arbitrationTurns;
}這樣正式仲裁預覽仍能列出全部共活方式,arbValidMoves.length 的意義不變。
九、哪些舊代碼暫時不要刪除
以下舊的全域死活追蹤函式可以保留,因為目前仍被正式棋局落子後的非同步統計使用:
computeAllLifeStatus()
incrementalAddPiece()
scheduleLifeTrackingUpdate()
edgeRecords
methodRegistry但新的 MCTS/minimax 不再依賴這個全域 registry。原因是全域 registry 只代表真實盤面,不能安全地被不同 MCTS 分支共同修改。
以下舊搜尋候選函式目前已不在新 runStateMCTS()/runStateMinimax() 主路徑,可在確認測試通過後再移除:
buildCandidateMovesForNode()
buildCandidateMovesFromGhosts()
deriveIncrementalEdgeGhosts()
collectLiveGhostsForPlayer()
filterMovesKeepingOpponentAlive()
estimateOpponentBestReplyScore()
moveKeepsOpponentSafe()
findDisruptingMoves()建議第一版先不要刪,避免其它舊 UI 或除錯入口仍有引用。
十、修改後的增量流程
十一、規則語義保持不變的項目
修改後仍維持:
- 首回合只在中心放一子。
- 某方只剩最後一子時只放一子。
- 一般回合必須放兩子。
- 同一目標必須貼不同邊。
- 跨目標仍必須形成未計分、距離大於零的頂鑫結構。
- 仲裁只承認:
- 一般回合的同目標共活;
- 最後一子回合的任一合法單子。
- 仲裁仍完整列出所有共活,供上一個/下一個瀏覽。
- 距離為零的頂鑫端點仍會加入
scoredVictims。 - 同一鑫棋端點有多個頂棋時仍選樹距離最近者。
- MCTS progressive widening、minimax alpha-beta、戰術預搜索的決策流程不變,只把底層資料改成增量來源。
這個版本的首次根局面仍需要完整建立一次活法和配對;之後的 MCTS/minimax 子節點才真正只根據本回合新增的一/兩顆棋子派生更新。這是不能省略的初始化成本,但不再於每個搜尋節點全盤重建。
「這個版本的首次根局面仍需要完整建立一次活法和配對;之後的 MCTS/minimax 子節點才真正只根據本回合新增的一/兩顆棋子派生更新。這是不能省略的初始化成本,但不再於每個搜尋節點全盤重建。」
「首次根局面」指的是開局第一手棋(只有一個棋子)或導入的局面,還是每次輪到AI控制的一方,就是它的「首次根局面」?
QuanAI這裡的「首次根局面」是指:每一次啟動 MCTS/minimax 搜尋時所建立的根 SearchState,不是特指開局第一手。
按照上述修改,目前的實際情況是:
1. 每次輪到 AI 都會有一個新的根局面
startAI() 通常會執行:
const rootState = createSearchStateFromGlobals(...);而 createSearchStateFromGlobals() 建立的狀態是:
_searchIndex: null,
_searchDelta: null所以本次 AI 搜尋第一次執行:
generateIncrementalTurnSet(rootState)時,會進入:
buildRootSearchIndex(rootState)完整建立當前盤面的:
- 全部活法;
- 單接資料;
- 共活/合法配對;
- 必要時雙方的配對目錄。
因此,每一次輪到 AI 思考,當前真實盤面都是該次搜尋的「首次根局面」,都需要完整初始化一次。
2. 只有該次搜尋中的子節點使用增量更新
例如目前真實盤面是:
RAI 搜尋候選走法後形成:
R -> A
R -> B
R -> C其中:
R:本次 AI 搜尋的根局面,完整建立一次索引;A/B/C:AI 候選回合產生的子局面,從R增量派生;A1/A2:對手在A之後的搜尋局面,從A增量派生。
當 AI 選定走法並正式落子後,本次搜尋結束。下一次再輪到 AI,如果又從全域棋盤重新呼叫:
createSearchStateFromGlobals()就會建立一個新的根狀態,再完整初始化一次。
3. 開局第一手是特殊情況
如果盤面完全為空,程式通常不需要枚舉貼邊活法和兩子配對,而是直接生成中心首棋。因此空盤的「完整初始化」實際成本很低,甚至可能走專門的首回合邏輯。
當開局第一顆棋已經存在,AI 開始搜尋下一回合時,這個一棋局面就是該次搜尋的根局面,會完整建立一次索引。
4. 導入局面也是同樣原則
導入棋譜或局面後:
- 第一次讓 AI 搜尋該局面;
- 從導入後的全域棋盤建立新的
rootState; - 完整初始化該根局面的搜尋索引;
- 後續模擬子節點再使用增量更新。
導入局面本身沒有特殊待遇,除非另外把可用的搜尋索引也序列化並導入;目前方案沒有這樣做。
準確表述
原句更準確地說應該是:
每次啟動一次新的 MCTS/minimax 搜尋時,當次搜尋的根
SearchState仍需依照當前真實盤面完整建立一次活法和配對索引;在同一次搜尋內,由根節點產生的所有後代節點才根據每回合新增的一/兩顆棋子進行增量更新。
所以答案是:每次輪到 AI 並重新啟動搜尋,都是一次新的「首次根局面」。不只是開局第一手,也包括導入局面後第一次搜尋;目前這個版本沒有把搜尋索引跨 AI 回合持久保存。
不行,要改成一直能保存增量數據。即使雙方都是人類,這個增量數據也要在背景中計算。重新給出所要修改的地方和所要替換和代碼。
QuanAI你說得對。上一版的問題是:edgeRecords / methodRegistry 只是額外的全域統計資料,沒有成為正式遊戲狀態的一部分;而且 undo/redo 會將它清掉,MCTS/minimax 也仍然建立自己的全盤候選。
正確做法應該是:
- 每個已確認局面都保存自己的
SearchState.incremental。 - 每次正式落子,不論人類或 AI,都排入增量更新。
- 人類對人類時也會在
setTimeout(0)背景佇列中持續更新。 - MCTS/minimax 子節點從父節點的增量索引推導,不重新掃描全盤。
- undo/redo 直接恢復當時保存的
SearchState,不再重算。 - 仲裁和正常合法回合共用同一批增量共活資料。
- 計分只檢查「至少一方是本回合新增棋子」的棋子對。
下面按函式名稱定位,不依賴行號。
一、增加正式持久狀態
在:
let nextHypothesisPieceId = -1000000000;後面加入:
/*
* 正式盤面的 SearchState。
*
* incremental:
* 已完成的持久增量索引。
*
* incrementalPromise:
* 人類對人類時也會在背景依序完成的增量更新。
*
* 每個 SearchState 只代表一個不可變局面。
* undo/redo 直接恢復相應 SearchState,不清空後重算。
*/
let committedSearchState = null;
let committedStateVersion = 0;
function yieldIncrementalBackground() {
return new Promise(resolve => setTimeout(resolve, 0));
}二、替換 SearchState 的建立和複製函式
刪除並替換以下三個舊函式:
cloneSearchState
createSearchStateFromGlobals
createSearchStateForBoard替換為:
function cloneSearchState(state) {
return {
boardPieces: state.boardPieces.slice(),
counts: { ...state.counts },
scores: { ...state.scores },
scoredVictims: new Set(state.scoredVictims),
mover: state.mover,
turnNumber: state.turnNumber,
scoringMode: state.scoringMode,
startingPlayer: state.startingPlayer,
gameNumber: state.gameNumber,
lastTurnPieces: state.lastTurnPieces.slice(),
nextHypId: state.nextHypId,
version: state.version,
incremental: state.incremental,
incrementalPromise: state.incrementalPromise
};
}
function createRawSearchStateFromGlobals(mover = currentPlayer) {
let nextHypId = -1;
for (const piece of pieces) {
if (typeof piece.id === 'number' && piece.id < 0) {
nextHypId = Math.min(nextHypId, piece.id - 1);
}
}
/*
* 正式狀態的 counts 必須是落子前庫存。
* tempPieces 已經從 piecesCount 扣除,所以加回去。
*/
const counts = { ...piecesCount };
for (const piece of tempPieces) {
counts[piece.svgId] = (counts[piece.svgId] || 0) + 1;
}
return {
boardPieces: pieces.slice(),
counts,
scores: { ...scores },
scoredVictims: new Set(scoredVictims),
mover,
turnNumber,
scoringMode: SCORING_MODE,
startingPlayer,
gameNumber,
lastTurnPieces: lastTurnPieces.slice(),
nextHypId,
version: ++committedStateVersion,
incremental: null,
incrementalPromise: null
};
}
function initializeCommittedSearchStateFromGlobals() {
const state = createRawSearchStateFromGlobals(currentPlayer);
/*
* 正常新局是空盤,所以這裡幾乎沒有成本。
* 對舊棋譜或沒有 SearchState 快照的歷史資料,才會完整建立一次。
*/
state.incremental = buildIncrementalIndexFromBoard(state);
state.incrementalPromise = Promise.resolve(state.incremental);
committedSearchState = state;
return state;
}
function createSearchStateFromGlobals(mover = currentPlayer) {
if (!committedSearchState) {
initializeCommittedSearchStateFromGlobals();
}
if (mover === committedSearchState.mover) {
return committedSearchState;
}
/*
* 只改行動方,盤面、庫存和增量索引可以共用。
*/
return {
...committedSearchState,
mover
};
}
function createSearchStateForBoard(mover, boardPieces, extraHypPieces = []) {
const counts = { ...piecesCount };
for (const piece of tempPieces) {
counts[piece.svgId] = (counts[piece.svgId] || 0) + 1;
}
for (const piece of extraHypPieces) {
if (counts[piece.svgId] !== undefined) {
counts[piece.svgId]--;
}
}
let nextHypId = -1;
for (const piece of boardPieces) {
if (typeof piece.id === 'number' && piece.id < 0) {
nextHypId = Math.min(nextHypId, piece.id - 1);
}
}
const state = {
boardPieces: boardPieces.slice(),
counts,
scores: { ...scores },
scoredVictims: new Set(scoredVictims),
mover,
turnNumber,
scoringMode: SCORING_MODE,
startingPlayer,
gameNumber,
lastTurnPieces: lastTurnPieces.slice(),
nextHypId,
version: ++committedStateVersion,
incremental: null,
incrementalPromise: null
};
/*
* 這是相容舊介面的備援入口。
* 正常 MCTS/minimax 不再走這條路。
*/
state.incremental = buildIncrementalIndexFromBoard(state);
state.incrementalPromise = Promise.resolve(state.incremental);
return state;
}
async function ensureStateIncremental(state) {
if (state.incremental) {
return state.incremental;
}
if (state.incrementalPromise) {
const index = await state.incrementalPromise;
if (!state.incremental) {
state.incremental = index;
}
return state.incremental;
}
state.incremental = buildIncrementalIndexFromBoard(state);
state.incrementalPromise = Promise.resolve(state.incremental);
return state.incremental;
}
function ensureStateIncrementalSync(state) {
if (!state.incremental) {
/*
* 只有同步呼叫者在背景工作尚未完成時才走此保險路徑。
* AI、仲裁入口都會先 await ensureStateIncremental()。
*/
state.incremental = buildIncrementalIndexFromBoard(state);
state.incrementalPromise = Promise.resolve(state.incremental);
}
return state.incremental;
}三、刪除舊的死活追蹤區,換成持久增量索引
找到:
// =============================================================================
// 死活/活法/互頂 追蹤系統(增量式)
// =============================================================================從這個標記開始,一直刪除到:
function getAllValidMoves(player) {之前。
也就是刪除舊的:
edgeRecordsmethodRegistryresetLifeTrackingscheduleLifeTrackingUpdatecomputeAllLifeStatusincrementalAddPiececollectLiveGhostsForPlayerbuildCandidateMovesFromGhostsderiveIncrementalEdgeGhosts- 舊的共活、單接、雙接追蹤函式
然後在原位置放入下面的完整替換程式碼:
// =============================================================================
// 持久 SearchState 增量索引
// =============================================================================
function pairMethodKey(a, b) {
return a < b ? a + '||' + b : b + '||' + a;
}
function methodTargetEdge(method) {
return method.ghost.targetEdge !== undefined
? method.ghost.targetEdge
: method.ghost.edgeOnOpp;
}
function createEmptyIncrementalIndex(state) {
return {
idMap: new Map(state.boardPieces.map(piece => [piece.id, piece])),
/*
* methods:
* 所有目前仍存活的單子活法。
* key 為 piecePlacementSignature。
*/
methods: new Map(),
byPlayer: {
1: new Set(),
2: new Set()
},
byTarget: new Map(),
byEdge: new Map(),
/*
* 所有共活,不只是「是否存在」。
* value = { a: methodKey, b: methodKey }
*/
coLivePairs: {
1: new Map(),
2: new Map()
},
/*
* 所有雙接/互頂。
*/
mutualPairs: {
1: new Map(),
2: new Map()
},
/*
* 單獨落下就會產生可計分頂鑫的活法。
*/
singleScoring: {
1: new Set(),
2: new Set()
}
};
}
function cloneSetMap(source) {
const result = new Map();
for (const [key, value] of source) {
result.set(key, new Set(value));
}
return result;
}
function cloneIncrementalIndex(source) {
return {
idMap: new Map(source.idMap),
methods: new Map(source.methods),
byPlayer: {
1: new Set(source.byPlayer[1]),
2: new Set(source.byPlayer[2])
},
byTarget: cloneSetMap(source.byTarget),
byEdge: cloneSetMap(source.byEdge),
coLivePairs: {
1: new Map(source.coLivePairs[1]),
2: new Map(source.coLivePairs[2])
},
mutualPairs: {
1: new Map(source.mutualPairs[1]),
2: new Map(source.mutualPairs[2])
},
singleScoring: {
1: new Set(source.singleScoring[1]),
2: new Set(source.singleScoring[2])
}
};
}
function addMethodToSetMap(map, key, methodKey) {
if (!map.has(key)) {
map.set(key, new Set());
}
map.get(key).add(methodKey);
}
function registerIndexedMethod(index, method) {
if (index.methods.has(method.key)) {
return false;
}
index.methods.set(method.key, method);
index.byPlayer[method.forPlayer].add(method.key);
addMethodToSetMap(index.byTarget, method.ghost.targetId, method.key);
addMethodToSetMap(
index.byEdge,
edgeKey(method.ghost.targetId, methodTargetEdge(method)),
method.key
);
if (method.hasScoringStructure) {
index.singleScoring[method.forPlayer].add(method.key);
}
return true;
}
function deleteIndexedMethod(index, methodKey) {
const method = index.methods.get(methodKey);
if (!method) return;
index.methods.delete(methodKey);
index.byPlayer[method.forPlayer].delete(methodKey);
index.singleScoring[method.forPlayer].delete(methodKey);
const targetSet = index.byTarget.get(method.ghost.targetId);
if (targetSet) {
targetSet.delete(methodKey);
if (targetSet.size === 0) index.byTarget.delete(method.ghost.targetId);
}
const edgeSet = index.byEdge.get(
edgeKey(method.ghost.targetId, methodTargetEdge(method))
);
if (edgeSet) {
edgeSet.delete(methodKey);
if (edgeSet.size === 0) {
index.byEdge.delete(
edgeKey(method.ghost.targetId, methodTargetEdge(method))
);
}
}
}
function removePairsContainingMethods(pairMap, removedKeys) {
for (const [key, pair] of pairMap) {
if (removedKeys.has(pair.a) || removedKeys.has(pair.b)) {
pairMap.delete(key);
}
}
}
function ghostConflictsWithPiece(ghost, piece) {
const collision = checkSATCollision(getSAT(ghost), getSAT(piece));
if (collision === 'separated') return false;
if (collision === 'overlap') return true;
for (let edgeA = 0; edgeA < 4; edgeA++) {
for (let edgeB = 0; edgeB < 4; edgeB++) {
if (
segmentsOverlapAsEdges(
ghost.vertices[edgeA],
ghost.vertices[(edgeA + 1) % 4],
piece.vertices[edgeB],
piece.vertices[(edgeB + 1) % 4]
)
) {
return true;
}
}
}
for (const vertexA of ghost.vertices) {
for (const vertexB of piece.vertices) {
if (pointsEqual(vertexA, vertexB)) {
return true;
}
}
}
return false;
}
/*
* 兩個活法作為同一回合的兩子時是否相容。
*/
function twoGhostsCompatible(g1, g2) {
const collision = checkSATCollision(getSAT(g1), getSAT(g2));
if (collision === 'separated') return true;
if (collision === 'overlap') return false;
for (let edgeA = 0; edgeA < 4; edgeA++) {
for (let edgeB = 0; edgeB < 4; edgeB++) {
if (
segmentsOverlapAsEdges(
g1.vertices[edgeA],
g1.vertices[(edgeA + 1) % 4],
g2.vertices[edgeB],
g2.vertices[(edgeB + 1) % 4]
)
) {
return false;
}
}
}
const attached1 = [g1.myEdge, (g1.myEdge + 1) % 4];
const attached2 = [g2.myEdge, (g2.myEdge + 1) % 4];
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
if (!pointsEqual(g1.vertices[i], g2.vertices[j])) continue;
/*
* 同一目標棋子的相鄰邊可以共用貼合端點。
* 只要其中一方是懸空端點,便不合法。
*/
if (!attached1.includes(i) || !attached2.includes(j)) {
return false;
}
}
}
return true;
}
/*
* 不按照庫存刪除活法。
* 庫存只在產生回合時過濾。
*
* 這樣某一種類用完不會破壞持久幾何索引;
* undo 後庫存恢復時也不必重新枚舉。
*/
function enumerateEdgeMethodsIncremental(state, piece, edgeIndex) {
const forPlayer = piece.owner === 1 ? 2 : 1;
const tileIds = getPlayerTileIds(forPlayer);
const result = [];
const seen = new Set();
for (const tileId of tileIds) {
for (const flipped of [false, true]) {
for (let myEdge = 0; myEdge < 4; myEdge++) {
const attached = attachByEdge(
SHAPE_MAP[tileId],
flipped,
myEdge,
piece.vertices[edgeIndex],
piece.vertices[(edgeIndex + 1) % 4],
!!piece.isFlipped
);
if (!attached) continue;
const ghost = {
id: allocateHypothesisPieceId(),
vertices: attached.vertices,
type: SHAPE_MAP[tileId],
owner: forPlayer,
svgId: tileId,
isFlipped: flipped,
edgeOnOpp: edgeIndex,
targetId: piece.id,
myEdge,
targetEdge: edgeIndex,
parentId: piece.id,
level: (piece.level !== undefined ? piece.level : 0) + 1
};
preparePieceSAT(ghost);
if (!isValidGhost(ghost, state.boardPieces)) {
continue;
}
const key = piecePlacementSignature(ghost);
if (seen.has(key)) continue;
seen.add(key);
result.push(ghost);
}
}
}
return result;
}
/*
* 相容舊函式名稱。
* 正式搜索不再依賴這個包裝。
*/
function enumerateEdgeMethods(piece, edgeIndex, allPieces) {
const state = {
boardPieces: allPieces,
counts: { ...piecesCount },
scores: { ...scores },
scoredVictims: new Set(scoredVictims),
mover: piece.owner === 1 ? 2 : 1,
turnNumber,
scoringMode: SCORING_MODE,
startingPlayer,
gameNumber,
lastTurnPieces: lastTurnPieces.slice(),
nextHypId: -1,
incremental: null,
incrementalPromise: null
};
return enumerateEdgeMethodsIncremental(state, piece, edgeIndex);
}
function evaluateIndexedMethod(state, oldMethod) {
const adjudication = adjudicateSearchTurn(state, [oldMethod.ghost]);
return {
...oldMethod,
hasScoringStructure: adjudication.hasScoringStructure,
gain: { ...adjudication.gain },
scoreKeys: new Set(adjudication.records.map(record => record.key))
};
}
function replaceIndexedMethodMetadata(index, state, methodKey) {
const oldMethod = index.methods.get(methodKey);
if (!oldMethod) return;
const updated = evaluateIndexedMethod(state, oldMethod);
index.methods.set(methodKey, updated);
if (updated.hasScoringStructure) {
index.singleScoring[updated.forPlayer].add(methodKey);
} else {
index.singleScoring[updated.forPlayer].delete(methodKey);
}
}
function registerMethodRelations(index, methodA, methodB) {
if (!methodA || !methodB) return;
if (methodA.key === methodB.key) return;
if (methodA.forPlayer !== methodB.forPlayer) return;
if (!twoGhostsCompatible(methodA.ghost, methodB.ghost)) return;
const player = methodA.forPlayer;
const pairKey = pairMethodKey(methodA.key, methodB.key);
/*
* 共活:同一目標、不同目標邊。
*/
if (
methodA.ghost.targetId === methodB.ghost.targetId &&
methodTargetEdge(methodA) !== methodTargetEdge(methodB)
) {
index.coLivePairs[player].set(pairKey, {
a: methodA.key,
b: methodB.key
});
}
/*
* 雙接/互頂。
*/
if (formsTriGolden(methodA.ghost, methodB.ghost)) {
index.mutualPairs[player].set(pairKey, {
a: methodA.key,
b: methodB.key
});
}
}
function addMethodsForNewPiece(index, state, piece) {
const addedKeys = [];
for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
const ghosts = enumerateEdgeMethodsIncremental(
state,
piece,
edgeIndex
);
for (const ghost of ghosts) {
const key = piecePlacementSignature(ghost);
const method = {
key,
forPlayer: ghost.owner,
ownerEdgePieceId: piece.id,
edgeIndex,
ghost,
hasScoringStructure: false,
gain: { 1: 0, 2: 0 },
scoreKeys: new Set()
};
if (registerIndexedMethod(index, method)) {
addedKeys.push(key);
}
}
}
return addedKeys;
}
/*
* 從父索引推導子索引:
*
* 1. 只刪除被新棋干涉的舊活法。
* 2. 只枚舉新棋四條邊帶來的新活法。
* 3. 只重算可能受新棋或新 scored key 影響的單接資料。
* 4. 舊共活/雙接直接保存。
* 5. 只建立「新方法與既有方法」的新關係。
*/
function deriveIncrementalIndex(
parentIndex,
childState,
placedPieces,
newScoredKeys
) {
const index = cloneIncrementalIndex(parentIndex);
for (const piece of placedPieces) {
preparePieceSAT(piece);
index.idMap.set(piece.id, piece);
}
const removedKeys = new Set();
const reevaluateKeys = new Set();
const scoredKeySet = new Set(newScoredKeys || []);
for (const [methodKey, method] of index.methods) {
let removed = false;
let touchesNewPiece = false;
for (const piece of placedPieces) {
const collision = checkSATCollision(
getSAT(method.ghost),
getSAT(piece)
);
if (collision !== 'separated') {
touchesNewPiece = true;
}
if (ghostConflictsWithPiece(method.ghost, piece)) {
removed = true;
break;
}
}
if (removed) {
removedKeys.add(methodKey);
continue;
}
if (touchesNewPiece) {
reevaluateKeys.add(methodKey);
continue;
}
for (const key of method.scoreKeys) {
if (scoredKeySet.has(key)) {
reevaluateKeys.add(methodKey);
break;
}
}
}
for (const methodKey of removedKeys) {
deleteIndexedMethod(index, methodKey);
}
for (const player of [1, 2]) {
removePairsContainingMethods(
index.coLivePairs[player],
removedKeys
);
removePairsContainingMethods(
index.mutualPairs[player],
removedKeys
);
}
const addedKeys = [];
for (const piece of placedPieces) {
addedKeys.push(
...addMethodsForNewPiece(index, childState, piece)
);
}
for (const methodKey of addedKeys) {
reevaluateKeys.add(methodKey);
}
for (const methodKey of reevaluateKeys) {
replaceIndexedMethodMetadata(index, childState, methodKey);
}
/*
* 舊方法彼此之間的幾何關係不會因為加棋而增加。
* 因此只需為新方法建立關係。
*/
const relatedPairsSeen = new Set();
for (const addedKey of addedKeys) {
const addedMethod = index.methods.get(addedKey);
if (!addedMethod) continue;
for (const otherKey of index.byPlayer[addedMethod.forPlayer]) {
if (otherKey === addedKey) continue;
const relationKey = pairMethodKey(addedKey, otherKey);
if (relatedPairsSeen.has(relationKey)) continue;
relatedPairsSeen.add(relationKey);
registerMethodRelations(
index,
addedMethod,
index.methods.get(otherKey)
);
}
}
return index;
}
/*
* 僅用於:
* - 新局初始化;
* - 舊歷史資料沒有 SearchState 快照;
* - 非正常入口建立任意 boardPieces。
*
* 正常每回合、MCTS、minimax 都不呼叫此函式。
*/
function buildIncrementalIndexFromBoard(state) {
const index = createEmptyIncrementalIndex(state);
const allAddedKeys = [];
for (const piece of state.boardPieces) {
allAddedKeys.push(...addMethodsForNewPiece(index, state, piece));
}
for (const methodKey of allAddedKeys) {
replaceIndexedMethodMetadata(index, state, methodKey);
}
for (const player of [1, 2]) {
/*
* 共活只在同一 targetId 內組合。
*/
for (const methodKeys of index.byTarget.values()) {
const list = Array.from(methodKeys)
.map(key => index.methods.get(key))
.filter(method => method && method.forPlayer === player);
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
registerMethodRelations(index, list[i], list[j]);
}
}
}
/*
* 雙接可能跨 target,所以完整初始化時需檢查同方方法。
* 這只發生在備援重建,不發生在正常搜索節點。
*/
const playerMethods = Array.from(index.byPlayer[player])
.map(key => index.methods.get(key))
.filter(Boolean);
for (let i = 0; i < playerMethods.length; i++) {
for (let j = i + 1; j < playerMethods.length; j++) {
registerMethodRelations(
index,
playerMethods[i],
playerMethods[j]
);
}
}
}
return index;
}
function scheduleIncrementalIndex(
parentState,
childState,
placedPieces,
newScoredKeys
) {
const parentPromise = parentState.incremental
? Promise.resolve(parentState.incremental)
: parentState.incrementalPromise ||
Promise.resolve(buildIncrementalIndexFromBoard(parentState));
childState.incremental = null;
/*
* 每一次正式落子都排入背景佇列。
* 即使雙方都是人類,也會執行。
*
* 如果玩家極快地連續操作,多個局面會沿 Promise 鏈依序更新,
* 不會跳過中間局面,也不會拿錯父索引。
*/
childState.incrementalPromise = parentPromise
.then(async parentIndex => {
await yieldIncrementalBackground();
return deriveIncrementalIndex(
parentIndex,
childState,
placedPieces,
newScoredKeys
);
})
.then(index => {
/*
* 如果某個同步入口已經先建立完成索引,保留已建立的版本。
*/
if (!childState.incremental) {
childState.incremental = index;
}
return childState.incremental;
});
return childState.incrementalPromise;
}
/*
* 兼容舊呼叫。
* 現在不再清除正式增量資料。
*/
function resetLifeTracking() {
// 不做任何事。
// resetGame() 會建立新的 committedSearchState。
}
function scheduleLifeTrackingUpdate() {
return committedSearchState
? committedSearchState.incrementalPromise
: Promise.resolve();
}
function ensureLifeStatsReady() {
if (!committedSearchState) {
initializeCommittedSearchStateFromGlobals();
}
return committedSearchState.incremental;
}四、把計分改為只檢查新增棋子
完整刪除舊的:
function adjudicateSearchTurn(state, placedPieces) {
...
}替換為:
/*
* 增量計分。
*
* 舊局面中的舊棋子接觸,在前面的回合已經處理過。
* 新回合只可能新增:
*
* 1. 新棋頂舊棋;
* 2. 舊棋頂新棋;
* 3. 兩顆新棋互頂。
*
* 所以由原本 O(全盤棋子²),改為 O(新增棋子 × 全盤棋子)。
*/
function adjudicateSearchTurn(state, placedPieces) {
const oldPieces = state.boardPieces;
const allPieces = oldPieces.concat(placedPieces);
const newIds = new Set(placedPieces.map(piece => piece.id));
const idMap = state.incremental
? new Map(state.incremental.idMap)
: new Map(oldPieces.map(piece => [piece.id, piece]));
for (const piece of placedPieces) {
preparePieceSAT(piece);
idMap.set(piece.id, piece);
}
const dingMap = new Map();
const checkedOrderedPairs = new Set();
function inspectOrderedPair(dinger, victim) {
if (!dinger || !victim || dinger.id === victim.id) return;
const orderedKey = String(dinger.id) + '>' + String(victim.id);
if (checkedOrderedPairs.has(orderedKey)) return;
checkedOrderedPairs.add(orderedKey);
if (
checkSATCollision(getSAT(dinger), getSAT(victim)) ===
'separated'
) {
return;
}
const touchingVertices = [];
for (const vertex of dinger.vertices) {
for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
if (
pointOnOpenSegment(
vertex,
victim.vertices[edgeIndex],
victim.vertices[(edgeIndex + 1) % 4]
)
) {
touchingVertices.push(vertex);
break;
}
}
}
if (touchingVertices.length === 0) return;
const treeResult = getTreeDistance(
dinger,
victim,
allPieces,
idMap
);
if (!treeResult || treeResult.dist < 0) return;
for (const vertex of touchingVertices) {
const key = victim.id + '_' + vertex.join('_');
if (state.scoredVictims.has(key)) {
continue;
}
if (!dingMap.has(key)) {
dingMap.set(key, []);
}
dingMap.get(key).push({
dinger,
victim,
dist: treeResult.dist,
path: treeResult.path
});
}
}
/*
* 新棋作為頂棋,對所有棋子檢查。
* 包括新棋與另一顆新棋。
*/
for (const dinger of placedPieces) {
for (const victim of allPieces) {
inspectOrderedPair(dinger, victim);
}
}
/*
* 舊棋作為頂棋,只需對新棋檢查。
*/
for (const dinger of oldPieces) {
for (const victim of placedPieces) {
inspectOrderedPair(dinger, victim);
}
}
const gain = { 1: 0, 2: 0 };
const newScoredKeys = [];
const records = [];
const rings = [];
for (const [key, candidates] of dingMap) {
let best = null;
for (const candidate of candidates) {
if (!best || candidate.dist < best.dist) {
best = candidate;
}
}
if (!best) continue;
let scorer = 0;
if (state.scoringMode === 0) {
scorer = best.dinger.owner;
} else if (state.scoringMode === 1) {
scorer = best.victim.owner;
} else if (
state.scoringMode === 2 &&
best.dinger.owner === best.victim.owner
) {
scorer = best.dinger.owner;
}
const involvesNew =
newIds.has(best.dinger.id) ||
newIds.has(best.victim.id);
if (best.dist > 0 && scorer > 0) {
gain[scorer] += best.dist;
rings.push({
path: best.path,
scorer
});
}
/*
* 維持原遊戲流程:
* 距離為 0 也封存該 victim vertex key。
*/
newScoredKeys.push(key);
records.push({
key,
dinger: best.dinger,
victim: best.victim,
dist: best.dist,
score: best.dist,
scorer,
path: best.path,
involvesNew
});
}
return {
gain,
newScoredKeys,
records,
rings,
/*
* 維持原本跨目標例外:
* 必須是本回合新增的可計分結構,而且距離 > 0。
*/
hasScoringStructure: records.some(
record => record.involvesNew && record.score > 0
)
};
}五、替換 applySearchTurnToState
完整替換舊函式:
function applySearchTurnToState(state, move, options = {}) {替換為:
function applySearchTurnToState(state, move, options = {}) {
const shouldValidate = options.validate !== false;
const backgroundIndex = options.backgroundIndex === true;
if (shouldValidate) {
const validation = validateTurnOnState(state, move);
if (!validation.ok) {
throw new Error(
'Illegal search turn: ' + validation.reason
);
}
}
const next = cloneSearchState(state);
const usedIds = new Set(
next.boardPieces.map(piece => piece.id)
);
const placedPieces = move.map(source => {
const piece = {
...source,
vertices: source.vertices,
satBounds: source.satBounds
};
if (
piece.id === undefined ||
piece.id === null ||
usedIds.has(piece.id)
) {
piece.id = next.nextHypId--;
}
usedIds.add(piece.id);
preparePieceSAT(piece);
return piece;
});
const negativeIds = placedPieces
.map(piece => piece.id)
.filter(id => typeof id === 'number' && id < 0);
if (negativeIds.length > 0) {
next.nextHypId =
Math.min(next.nextHypId, ...negativeIds) - 1;
}
/*
* 計分只計算本回合新增接觸。
*/
const adjudication = adjudicateSearchTurn(
state,
placedPieces
);
for (const piece of placedPieces) {
next.counts[piece.svgId]--;
}
next.boardPieces.push(...placedPieces);
next.scores[1] += adjudication.gain[1];
next.scores[2] += adjudication.gain[2];
for (const key of adjudication.newScoredKeys) {
next.scoredVictims.add(key);
}
next.lastTurnPieces = placedPieces.slice();
next.turnNumber++;
next.mover = next.mover === 1 ? 2 : 1;
next.version = ++committedStateVersion;
if (backgroundIndex) {
/*
* 正式人類/AI 落子:
* 先完成遊戲流程和畫面切換,再在背景更新持久索引。
*/
scheduleIncrementalIndex(
state,
next,
placedPieces,
adjudication.newScoredKeys
);
} else {
/*
* MCTS/minimax 子節點:
* 必須立即得到子節點索引,但只從父索引增量推導。
*/
const parentIndex = ensureStateIncrementalSync(state);
next.incremental = deriveIncrementalIndex(
parentIndex,
next,
placedPieces,
adjudication.newScoredKeys
);
next.incrementalPromise = Promise.resolve(
next.incremental
);
}
return {
state: next,
placedPieces,
adjudication
};
}六、讓合法性判定使用已保存的單子活法
把舊的:
function validatePlacementGeometry(piece, boardPieces, player) {改成:
function validatePlacementGeometry(piece, boardPieces, player, state = null) {然後在函式內,完成:
segmentsPerfectlyMatch(...)檢查之後、preparePieceSAT(piece) 之前加入:
/*
* 如果這個放法已存在於目前局面的持久活法索引,
* 就不需要再次掃描整個盤面做幾何合法性檢查。
*/
if (
state &&
state.incremental &&
state.incremental.methods.has(
piecePlacementSignature(piece)
)
) {
return { ok: true };
}然後將 validateTurnOnState 裡的:
const result = validatePlacementGeometry(
piece,
state.boardPieces,
state.mover
);替換為:
const result = validatePlacementGeometry(
piece,
state.boardPieces,
state.mover,
state
);這不改變驗證規則,只是已經在增量索引中的放法不再重做全盤掃描。
七、替換單子、共活和全部合法回合生成器
7.1 替換 generateLegalSinglePlacements
完整替換為:
function generateLegalSinglePlacements(
state,
player = state.mover
) {
if (state.boardPieces.length === 0) {
return [];
}
const index = ensureStateIncrementalSync(state);
const result = [];
for (const methodKey of index.byPlayer[player]) {
const method = index.methods.get(methodKey);
if (!method) continue;
/*
* 幾何活法永久保存,但目前沒有這種棋時不生成候選。
*/
if ((state.counts[method.ghost.svgId] || 0) <= 0) {
continue;
}
result.push(method.ghost);
}
return result;
}7.2 新增「所有共活」函式
放在 pairFitsInventory 後面:
/*
* 從持久 coLivePairs 中取出所有共活。
*
* generateLegalTurns、仲裁、MCTS、minimax 共用,
* 不再各自重新枚舉。
*/
function generateAllCoLiveTurns(
state,
player = state.mover
) {
const required = getRequiredPiecesForState(state);
if (required === 1) {
if (state.boardPieces.length === 0) return [];
return generateLegalSinglePlacements(
state,
player
).map(ghost => [ghost]);
}
const index = ensureStateIncrementalSync(state);
const moves = [];
const seen = new Set();
for (const pair of index.coLivePairs[player].values()) {
const firstMethod = index.methods.get(pair.a);
const secondMethod = index.methods.get(pair.b);
if (!firstMethod || !secondMethod) continue;
const first = firstMethod.ghost;
const second = secondMethod.ghost;
if (!pairFitsInventory(state, first, second)) {
continue;
}
const signature = turnSignature([first, second]);
if (seen.has(signature)) continue;
seen.add(signature);
moves.push([first, second]);
}
return moves;
}7.3 替換 generateLegalTurns
完整替換為:
function generateLegalTurns(state) {
const required = getRequiredPiecesForState(state);
if (required === 1) {
if (state.boardPieces.length === 0) return [];
return generateLegalSinglePlacements(
state,
state.mover
).map(ghost => [ghost]);
}
const index = ensureStateIncrementalSync(state);
const player = state.mover;
/*
* 普通同目標回合直接使用全部共活。
*/
const result = generateAllCoLiveTurns(state, player);
const seen = new Set(result.map(turnSignature));
const allMethods = Array.from(index.byPlayer[player])
.map(key => index.methods.get(key))
.filter(
method =>
method &&
(state.counts[method.ghost.svgId] || 0) > 0
);
function tryAddCrossTargetPair(methodA, methodB) {
if (!methodA || !methodB) return;
if (methodA.key === methodB.key) return;
const first = methodA.ghost;
const second = methodB.ghost;
if (first.targetId === second.targetId) return;
if (!pairFitsInventory(state, first, second)) {
return;
}
if (!twoGhostsCompatible(first, second)) {
return;
}
const move = [first, second];
const signature = turnSignature(move);
if (seen.has(signature)) return;
/*
* 最後仍使用正式規則確認:
* 跨目標必須形成尚未計分、距離 > 0 的頂鑫。
*
* 此處的 adjudicateSearchTurn 已經是增量計分,
* 不會全盤棋子兩兩重算。
*/
const validation = validateTurnOnState(state, move);
if (!validation.ok) return;
seen.add(signature);
result.push(move);
}
/*
* 跨目標例外來源一:
* 任一單子本身已是單接。
*/
for (const scoringKey of index.singleScoring[player]) {
const scoringMethod = index.methods.get(scoringKey);
if (!scoringMethod) continue;
if (
(state.counts[scoringMethod.ghost.svgId] || 0) <= 0
) {
continue;
}
for (const otherMethod of allMethods) {
tryAddCrossTargetPair(
scoringMethod,
otherMethod
);
}
}
/*
* 跨目標例外來源二:
* 兩個活法互相形成雙接。
*/
for (const pair of index.mutualPairs[player].values()) {
tryAddCrossTargetPair(
index.methods.get(pair.a),
index.methods.get(pair.b)
);
}
return result;
}7.4 替換仲裁函式
完整刪除舊的:
function generateArbitrationDefenceTurns(state) {
...
}替換為:
function generateArbitrationDefenceTurns(state) {
/*
* 仲裁只承認共活。
* 最後只剩一子時,generateAllCoLiveTurns 會自動改為單子規則。
*/
return generateAllCoLiveTurns(state, state.mover);
}hasArbitrationDefence 可以保持原樣:
function hasArbitrationDefence(state) {
if (isSearchGameComplete(state)) {
return true;
}
return generateArbitrationDefenceTurns(state).length > 0;
}這樣 generateLegalTurns 和仲裁不會再分別重新呼叫單子枚舉器;兩者共用同一份持久共活資料。
八、讓人類介面的 ghost 也優先使用持久活法
完整替換 generateGhosts():
function generateGhosts() {
ghosts = [];
if (!selectedTile || !targetOpponentPieceId) {
return;
}
const state = createSearchStateFromGlobals(currentPlayer);
/*
* 背景索引已完成時,直接取出保存的單子活法。
*/
if (state.incremental) {
const methodKeys =
state.incremental.byTarget.get(
targetOpponentPieceId
) || new Set();
const existingPieces = pieces.concat(tempPieces);
for (const methodKey of methodKeys) {
const method =
state.incremental.methods.get(methodKey);
if (!method) continue;
const source = method.ghost;
if (source.owner !== currentPlayer) continue;
if (source.svgId !== selectedTile) continue;
if (source.isFlipped !== isFlipped[selectedTile]) {
continue;
}
/*
* 第一顆暫存棋不在 committed index 中,
* 所以第二顆仍需檢查是否和 tempPieces 相容。
*/
if (!isValidGhost(source, existingPieces)) {
continue;
}
/*
* 必須複製,commitGhost 會改 id;
* 不可直接修改持久索引中的 ghost。
*/
ghosts.push({
...source,
vertices: source.vertices,
satBounds: source.satBounds
});
}
return;
}
/*
* 背景更新尚未完成時的 UI 備援。
* 不會寫入搜索狀態,也不會改變遊戲流程。
*/
const target = pieces.find(
piece => piece.id === targetOpponentPieceId
);
if (!target) return;
const selectedType = SHAPE_MAP[selectedTile];
const selectedFlipped = isFlipped[selectedTile];
const existingPieces = pieces.concat(tempPieces);
for (let targetEdge = 0; targetEdge < 4; targetEdge++) {
for (let myEdge = 0; myEdge < 4; myEdge++) {
const attached = attachByEdge(
selectedType,
selectedFlipped,
myEdge,
target.vertices[targetEdge],
target.vertices[(targetEdge + 1) % 4],
!!target.isFlipped
);
if (!attached) continue;
const ghost = {
id: allocateHypothesisPieceId(),
vertices: attached.vertices,
type: selectedType,
owner: currentPlayer,
svgId: selectedTile,
isFlipped: selectedFlipped,
edgeOnOpp: targetEdge,
targetId: target.id,
myEdge,
targetEdge,
parentId: target.id,
level:
(target.level !== undefined ? target.level : 0) +
1
};
preparePieceSAT(ghost);
if (isValidGhost(ghost, existingPieces)) {
ghosts.push(ghost);
}
}
}
}九、修改正式落子 actionCheck
在 actionCheck 中找到:
const applied = applySearchTurnToState(beforeState, tempPieces, {
validate: false
});替換為:
const applied = applySearchTurnToState(
beforeState,
tempPieces,
{
validate: false,
/*
* 不論人類或 AI,正式落子都在背景更新並保存增量索引。
*/
backgroundIndex: true
}
);然後在:
pieces = applied.state.boardPieces;之前加入:
/*
* 這是新的正式持久局面。
* SearchState 內已經保存本局面的 incrementalPromise。
*/
committedSearchState = applied.state;所以這一段應為:
committedSearchState = applied.state;
pieces = applied.state.boardPieces;
piecesCount = { ...applied.state.counts };
scores = { ...applied.state.scores };
scoredVictims = new Set(
applied.state.scoredVictims
);最後刪除 actionCheck 末尾的:
scheduleLifeTrackingUpdate(lastTurnPieces, pieces);因為現在增量工作已經由:
applySearchTurnToState(..., { backgroundIndex: true })排入背景佇列,而且結果直接保存在 applied.state 中。
十、MCTS/minimax 開始前等待根局面的背景索引
在 startAI() 中找到:
const rootState = createSearchStateFromGlobals(aiPlayer);後面立即加入:
/*
* 人類上一手的增量索引可能仍在背景更新。
* AI 只等待這一次;後續所有搜索子節點都同步從父索引增量推導。
*/
await ensureStateIncremental(rootState);
if (cancelAi) {
aiThinking = false;
return;
}變成:
const rootState =
createSearchStateFromGlobals(aiPlayer);
await ensureStateIncremental(rootState);
if (cancelAi) {
aiThinking = false;
return;
}MCTS/minimax 的:
applySearchTurnToState(state, move, { validate: false })不用修改。
因為沒有傳入 backgroundIndex: true,所以搜索子節點會立即執行:
deriveIncrementalIndex(parentIndex, childState, placedPieces, ...)而不會重新建立全盤索引。
十一、仲裁前等待同一份增量共活資料
把:
function executeArbitration() {改成:
async function executeArbitration() {然後找到:
arbValidMoves = getAllValidMoves(currentPlayer);替換為:
const arbitrationState =
createSearchStateFromGlobals(currentPlayer);
await ensureStateIncremental(arbitrationState);
arbValidMoves =
generateArbitrationDefenceTurns(
arbitrationState
);完整的函式前半段應改成:
async function executeArbitration() {
document.getElementById(
'arb-confirm-dialog'
).style.display = 'none';
actionCross(false);
const arbitrationState =
createSearchStateFromGlobals(currentPlayer);
await ensureStateIncremental(arbitrationState);
arbValidMoves =
generateArbitrationDefenceTurns(
arbitrationState
);
let ways = arbValidMoves.length;
arbEnded = true;
currentDialogMode = 'arbitration';
saveState({
newPieces: [],
arbSuccess: ways === 0
});
// 以下原程式保持不變getAllValidMoves 改成:
function getAllValidMoves(player) {
const state = createSearchStateFromGlobals(player);
/*
* 呼叫者應先 await ensureStateIncremental(state)。
* 此包裝只為舊介面相容。
*/
return generateArbitrationDefenceTurns(state);
}十二、history 必須保存完整增量 SearchState
在 saveState() 的 gameHistory.push({ ... }) 裡加入:
/*
* 保存當時完整的持久 SearchState。
* incrementalPromise 即使尚未完成,也會繼續在背景完成;
* history 持有的是同一個 state 物件。
*/
searchStateSnapshot: committedSearchState,建議放在:
piecesSnapshot: [...pieces],後面:
piecesSnapshot: [...pieces],
searchStateSnapshot: committedSearchState,
scores: { ...scores },十三、undo/redo 不再清除增量資料
13.1 修改 undo
刪除:
resetLifeTracking();在 undo 已經恢復完以下資料之後:
currentDialogMode = prevState.dialogMode;加入:
/*
* 直接恢復當時保存的持久增量局面。
* 不重新枚舉活法、共活、單接、雙接。
*/
if (prevState.searchStateSnapshot) {
committedSearchState =
prevState.searchStateSnapshot;
} else {
/*
* 只為相容修改前建立的舊 history。
*/
committedSearchState =
createRawSearchStateFromGlobals(
currentPlayer
);
committedSearchState.incremental =
buildIncrementalIndexFromBoard(
committedSearchState
);
committedSearchState.incrementalPromise =
Promise.resolve(
committedSearchState.incremental
);
}13.2 修改 redo
同樣刪除:
resetLifeTracking();在:
currentDialogMode = nextState.dialogMode;後加入:
if (nextState.searchStateSnapshot) {
committedSearchState =
nextState.searchStateSnapshot;
} else {
committedSearchState =
createRawSearchStateFromGlobals(
currentPlayer
);
committedSearchState.incremental =
buildIncrementalIndexFromBoard(
committedSearchState
);
committedSearchState.incrementalPromise =
Promise.resolve(
committedSearchState.incremental
);
}這樣 undo/redo 恢復的是當時已保存或仍在背景完成中的增量資料,不再:
edgeRecords = new Map();
methodRegistry = new Map();然後下一次 AI 搜索再全盤重建。
十四、新局初始化正式 SearchState
在 resetGame() 中,完成這些設定之後:
currentPlayer = startingPlayer;
applyRotation();
boardTransform = { tx: 240, ty: 240, scale: 1, angle: 0 };加入:
/*
* 建立這一局唯一的正式初始 SearchState。
* 空盤索引為空,不需要做幾何掃描。
*/
initializeCommittedSearchStateFromGlobals();即:
currentPlayer = startingPlayer;
applyRotation();
boardTransform = {
tx: 240,
ty: 240,
scale: 1,
angle: 0
};
initializeCommittedSearchStateFromGlobals();confirmImport() 裡原本的:
resetLifeTracking();
resetGame(false);可以改為:
resetGame(false);因為 resetGame() 自己會建立新的正式增量狀態。
十五、計分模式同步到正式 SearchState
替換 setScoringMode:
function setScoringMode(mode) {
SCORING_MODE = mode;
document
.getElementById('btn-score-dinger')
.classList.toggle('selected', mode === 0);
document
.getElementById('btn-score-xin')
.classList.toggle('selected', mode === 1);
/*
* 正常情況下只會在開局前修改。
* 同步到正式狀態,避免 SearchState 仍保存舊模式。
*/
if (
committedSearchState &&
committedSearchState.boardPieces.length === 0
) {
committedSearchState.scoringMode = mode;
}
}修改後的資料流
最終效果
修改後各項資料的來源如下:
| 功能 | 新資料來源 | 是否重新掃全盤 |
|---|---|---|
| 活法 | incremental.methods | 否 |
| 每條邊活法 | incremental.byEdge | 否 |
| 每個目標棋子的活法 | incremental.byTarget | 否 |
| 所有共活 | incremental.coLivePairs[player] | 否 |
| 單接 | incremental.singleScoring[player] | 否 |
| 雙接/互頂 | incremental.mutualPairs[player] | 否 |
| 仲裁防禦 | generateAllCoLiveTurns | 否 |
| 正常合法回合 | 共活+單接索引+雙接索引 | 否 |
| 正式計分 | 新棋與全盤棋子的接觸 | 不做全盤兩兩掃描 |
| MCTS 子節點 | deriveIncrementalIndex | 否 |
| minimax 子節點 | deriveIncrementalIndex | 否 |
| 人類對人類 | 每次確認落子都排入背景更新 | 是,持續維護 |
| undo/redo | searchStateSnapshot | 否 |
| 棋譜導入 | 每手經 actionCheck 逐步增量建立 | 否 |
| 任意舊盤面相容 | buildIncrementalIndexFromBoard | 僅備援時一次 |
最重要的差異是:增量資料現在屬於正式 SearchState,不是 AI 開始搜索時才臨時建立,也不是 undo/redo 後會消失的附屬全域資料。即使兩邊都是人類,每一次確認落子後都會沿同一條背景 Promise 佇列保存該局面的完整增量索引。