QuanAI只读快照

共享会话

🔧 增量運算優化分析

分享于 2026年8月17日 03:25
提问者

改進與修復

以下是本程式「是否有在用增量運算」的分析結果:

沒有使用的關鍵增量運算

對於使用者所說的「只對本回合新增兩顆棋子做局部更新」,在最耗時的部分,程式沒有完成這種增量化。

  1. 每個搜尋節點重新生成所有單子

每次 generateLegalTurns(state) 都重新呼叫:

generateLegalSinglePlacements(state, state.mover)

而它會:

掃過盤上所有對方棋子; 掃過尚可用的棋種; 正反面各一次; 目標邊 4 種; 自己貼合邊 4 種; 對每個候選呼叫 validatePlacementGeometry(); 再用 isValidGhost() 對整個盤面逐一做幾何衝突檢查。

沒有「父節點候選集 + 新增兩子影響範圍」的更新機制,也沒有按照盤面狀態保存生成結果的 transposition cache。

  1. 兩子組合是重新兩兩枚舉

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)。

這不是局部增量。

  1. 計分是全盤重新掃描

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增量運算。

回答要求:

不要使當前正確的遊戲流程發生改變。明確給出所要修改的地方和所要替換的代碼。

trigolden_game263.htmltext/plain · 258.2 KB预览下载
QuanAI

我會先定位附件中搜尋、走法生成、共活判定、計分與狀態複製的實作,再依現有資料結構設計可直接替換、且不改變規則語義的增量版本。

以下修改只替換搜尋層,不改變 UI、正式落子流程、仲裁顯示方式、棋譜格式或規則語義。

核心做法是:

  1. SearchState 保存持久化增量索引。
  2. 父節點落下一/兩子後:
    • 移除被新棋破壞的舊活法;
    • 只枚舉新棋四條邊產生的新活法;
    • 只重算受影響的單接;
    • 保留未受影響的共活/合法配對;
    • 只配對新增或計分狀態改變的活法。
  3. generateLegalTurns()generateArbitrationDefenceTurns() 統一使用同一份增量回合集合。
  4. 計分只掃描「新棋-舊棋」及「新棋-新棋」,不再掃描舊棋-舊棋。
  5. 樹距離使用增量 parent/depth/idMap 索引。
  6. 每個局面內快取走法分析結果,避免排序、MCTS 展開、minimax 展開重複套用和計分。

一、擴充 SearchState

1. 修改 cloneSearchState()

cloneSearchState() 回傳物件最後,把:

js
nextHypId: state.nextHypId

替換成:

js
nextHypId: state.nextHypId,

// 搜尋用持久化增量資料。
// _topology 可以安全共享;子狀態加入棋子時會建立新版本。
// _searchIndex 屬於當前盤面,不得直接當成子盤面的索引使用。
_topology: state._topology || null,
_searchIndex: state._searchIndex || null,
_searchDelta: null

2. 修改兩個 SearchState 建立函式

在以下兩個函式回傳物件的末尾:

  • createSearchStateFromGlobals()
  • createSearchStateForBoard()

把:

js
nextHypId

替換成:

js
nextHypId,
_topology: null,
_searchIndex: null,
_searchDelta: null

二、把計分和樹距離改成真正增量

找到原本整個:

js
function adjudicateSearchTurn(state, placedPieces) {
  ...
}

完整替換成以下代碼;並把前面的拓撲輔助函式一起放在它之前。

js
// =============================================================================
// 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 計分將不再使用它。


三、建立統一增量活法/共活/單接/雙接/合法回合引擎

找到從:

js
/*
 * 在指定 SearchState 上生成全部合法單子落點。
 */
function generateLegalSinglePlacements(...)

開始,到:

js
function hasArbitrationDefence(state) {
  ...
}

結束的整段,完整替換成以下代碼。

js
// =============================================================================
// 統一增量活法/共活/單接/雙接/合法回合引擎
// =============================================================================

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() 中找到:

js
const adjudication = adjudicateSearchTurn(state, placedPieces);

保留不變。

接著在:

js
next.boardPieces.push(...placedPieces);

之後加入:

js
/*
 * adjudicateSearchTurn 已建立「父拓撲 + 本回合新棋」的增量拓撲。
 */
next._topology = adjudication.topology;

然後在:

js
next.mover = next.mover === 1 ? 2 : 1;

之後、return 之前加入:

js
/*
 * 不在 apply 時立即生成子節點活法,避免 MCTS/minimax
 * 僅為排序而套用所有候選時,替每個尚未真正展開的子節點做多餘工作。
 *
 * 子節點第一次呼叫 generateIncrementalTurnSet 時,
 * 才從父節點索引按這兩顆新棋做增量派生。
 */
next._searchIndex = null;

next._searchDelta = state._searchIndex
  ? {
      parentState: state,
      placedPieces: placedPieces.slice(),
      newScoredKeys: adjudication.newScoredKeys.slice()
    }
  : null;

修改後這一段應為:

js
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
};

五、把單接/雙接分析也接到同一份增量資料

找到原本整個:

js
function analyseSearchMove(state, move) {
  ...
}

完整替換成:

js
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;
}

這會避免原本每次排序都重新:

js
adjudicateSearchTurn(state, [piece])

一遍。


六、避免排序後又重複套用相同回合

1. 修改 minimax 的 maximizing 分支

runStateMinimax() 內部 alphaBetaState() 找到:

js
const child = applySearchTurnToState(state, move, { validate: false }).state;

best = Math.max(best, alphaBetaState(child, depth - 1, alpha, beta));

替換成:

js
const child = analyseSearchMove(state, move).childState;

best = Math.max(
  best,
  alphaBetaState(child, depth - 1, alpha, beta)
);

2. 修改 minimax 的 minimizing 分支

找到:

js
const child = applySearchTurnToState(state, move, { validate: false }).state;

best = Math.min(best, alphaBetaState(child, depth - 1, alpha, beta));

替換成:

js
const child = analyseSearchMove(state, move).childState;

best = Math.min(
  best,
  alphaBetaState(child, depth - 1, alpha, beta)
);

3. 修改 minimax 根候選迭代

找到:

js
const child = applySearchTurnToState(rootState, move, { validate: false }).state;

const value = alphaBetaState(child, depth - 1, -Infinity, Infinity);

替換成:

js
const child = analyseSearchMove(
  rootState,
  move
).childState;

const value = alphaBetaState(
  child,
  depth - 1,
  -Infinity,
  Infinity
);

4. 修改 MCTS 展開

runStateMCTS() 找到:

js
const childState = applySearchTurnToState(node.state, move, { validate: false }).state;

替換成:

js
const childState = analyseSearchMove(
  node.state,
  move
).childState;

這些替換很重要,否則 orderMovesForSearch() 已經分析過一次候選後,minimax/MCTS 展開時又會重複計分和建立子狀態。


七、修改 startAI(),只取得一次統一回合集合

找到:

js
const arbitrationDefences = generateArbitrationDefenceTurns(rootState);

if (!isSearchGameComplete(rootState) && arbitrationDefences.length === 0) {
  aiThinking = false;
  aiTriggerArbitrationSuccess();
  return;
}

let rootMoves = generateLegalTurns(rootState);

替換成:

js
/*
 * 一次增量生成同時取得:
 * - 全部活法
 * - 全部共活
 * - 全部單接
 * - 全部雙接
 * - 全部正常合法回合
 *
 * 不再分別呼叫兩套生成器。
 */
const rootTurnSet = generateIncrementalTurnSet(rootState);

const arbitrationDefences =
  rootTurnSet.arbitrationTurns;

if (
  !isSearchGameComplete(rootState) &&
  arbitrationDefences.length === 0
) {
  aiThinking = false;
  aiTriggerArbitrationSuccess();
  return;
}

let rootMoves = rootTurnSet.legalTurns.slice();

功能沒有改變:

  • 仲裁仍然只承認共活;
  • 正常搜尋仍包含共活、單接、雙接和跨目標頂鑫例外;
  • 所有共活仍完整保留,不是找到第一組就停止。

八、getAllValidMoves() 保持相容,但改為直接取統一集合

找到:

js
function getAllValidMoves(player) {
  const state = createSearchStateFromGlobals(player);
  return generateArbitrationDefenceTurns(state);
}

替換成:

js
function getAllValidMoves(player) {
  const state = createSearchStateFromGlobals(player);

  return generateIncrementalTurnSet(state).arbitrationTurns;
}

這樣正式仲裁預覽仍能列出全部共活方式,arbValidMoves.length 的意義不變。


九、哪些舊代碼暫時不要刪除

以下舊的全域死活追蹤函式可以保留,因為目前仍被正式棋局落子後的非同步統計使用:

js
computeAllLifeStatus()
incrementalAddPiece()
scheduleLifeTrackingUpdate()
edgeRecords
methodRegistry

但新的 MCTS/minimax 不再依賴這個全域 registry。原因是全域 registry 只代表真實盤面,不能安全地被不同 MCTS 分支共同修改。

以下舊搜尋候選函式目前已不在新 runStateMCTS()runStateMinimax() 主路徑,可在確認測試通過後再移除:

js
buildCandidateMovesForNode()
buildCandidateMovesFromGhosts()
deriveIncrementalEdgeGhosts()
collectLiveGhostsForPlayer()
filterMovesKeepingOpponentAlive()
estimateOpponentBestReplyScore()
moveKeepsOpponentSafe()
findDisruptingMoves()

建議第一版先不要刪,避免其它舊 UI 或除錯入口仍有引用。


十、修改後的增量流程

流程图
正在绘制流程图…

十一、規則語義保持不變的項目

修改後仍維持:

  1. 首回合只在中心放一子。
  2. 某方只剩最後一子時只放一子。
  3. 一般回合必須放兩子。
  4. 同一目標必須貼不同邊。
  5. 跨目標仍必須形成未計分、距離大於零的頂鑫結構。
  6. 仲裁只承認:
    • 一般回合的同目標共活;
    • 最後一子回合的任一合法單子。
  7. 仲裁仍完整列出所有共活,供上一個/下一個瀏覽。
  8. 距離為零的頂鑫端點仍會加入 scoredVictims
  9. 同一鑫棋端點有多個頂棋時仍選樹距離最近者。
  10. MCTS progressive widening、minimax alpha-beta、戰術預搜索的決策流程不變,只把底層資料改成增量來源。

這個版本的首次根局面仍需要完整建立一次活法和配對;之後的 MCTS/minimax 子節點才真正只根據本回合新增的一/兩顆棋子派生更新。這是不能省略的初始化成本,但不再於每個搜尋節點全盤重建。

提问者

「這個版本的首次根局面仍需要完整建立一次活法和配對;之後的 MCTS/minimax 子節點才真正只根據本回合新增的一/兩顆棋子派生更新。這是不能省略的初始化成本,但不再於每個搜尋節點全盤重建。」

「首次根局面」指的是開局第一手棋(只有一個棋子)或導入的局面,還是每次輪到AI控制的一方,就是它的「首次根局面」?

QuanAI

這裡的「首次根局面」是指:每一次啟動 MCTS/minimax 搜尋時所建立的根 SearchState,不是特指開局第一手。

按照上述修改,目前的實際情況是:

1. 每次輪到 AI 都會有一個新的根局面

startAI() 通常會執行:

js
const rootState = createSearchStateFromGlobals(...);

createSearchStateFromGlobals() 建立的狀態是:

js
_searchIndex: null,
_searchDelta: null

所以本次 AI 搜尋第一次執行:

js
generateIncrementalTurnSet(rootState)

時,會進入:

js
buildRootSearchIndex(rootState)

完整建立當前盤面的:

  • 全部活法;
  • 單接資料;
  • 共活/合法配對;
  • 必要時雙方的配對目錄。

因此,每一次輪到 AI 思考,當前真實盤面都是該次搜尋的「首次根局面」,都需要完整初始化一次。


2. 只有該次搜尋中的子節點使用增量更新

例如目前真實盤面是:

text
R

AI 搜尋候選走法後形成:

text
R -> A
R -> B
R -> C

其中:

  • R:本次 AI 搜尋的根局面,完整建立一次索引;
  • A/B/C:AI 候選回合產生的子局面,從 R 增量派生;
  • A1/A2:對手在 A 之後的搜尋局面,從 A 增量派生。
流程图
正在绘制流程图…

當 AI 選定走法並正式落子後,本次搜尋結束。下一次再輪到 AI,如果又從全域棋盤重新呼叫:

js
createSearchStateFromGlobals()

就會建立一個新的根狀態,再完整初始化一次。


3. 開局第一手是特殊情況

如果盤面完全為空,程式通常不需要枚舉貼邊活法和兩子配對,而是直接生成中心首棋。因此空盤的「完整初始化」實際成本很低,甚至可能走專門的首回合邏輯。

當開局第一顆棋已經存在,AI 開始搜尋下一回合時,這個一棋局面就是該次搜尋的根局面,會完整建立一次索引。


4. 導入局面也是同樣原則

導入棋譜或局面後:

  • 第一次讓 AI 搜尋該局面;
  • 從導入後的全域棋盤建立新的 rootState
  • 完整初始化該根局面的搜尋索引;
  • 後續模擬子節點再使用增量更新。

導入局面本身沒有特殊待遇,除非另外把可用的搜尋索引也序列化並導入;目前方案沒有這樣做。


準確表述

原句更準確地說應該是:

每次啟動一次新的 MCTS/minimax 搜尋時,當次搜尋的根 SearchState 仍需依照當前真實盤面完整建立一次活法和配對索引;在同一次搜尋內,由根節點產生的所有後代節點才根據每回合新增的一/兩顆棋子進行增量更新。

所以答案是:每次輪到 AI 並重新啟動搜尋,都是一次新的「首次根局面」。不只是開局第一手,也包括導入局面後第一次搜尋;目前這個版本沒有把搜尋索引跨 AI 回合持久保存。

提问者

不行,要改成一直能保存增量數據。即使雙方都是人類,這個增量數據也要在背景中計算。重新給出所要修改的地方和所要替換和代碼。

QuanAI

你說得對。上一版的問題是:edgeRecords / methodRegistry 只是額外的全域統計資料,沒有成為正式遊戲狀態的一部分;而且 undo/redo 會將它清掉,MCTS/minimax 也仍然建立自己的全盤候選。

正確做法應該是:

  1. 每個已確認局面都保存自己的 SearchState.incremental
  2. 每次正式落子,不論人類或 AI,都排入增量更新。
  3. 人類對人類時也會在 setTimeout(0) 背景佇列中持續更新。
  4. MCTS/minimax 子節點從父節點的增量索引推導,不重新掃描全盤。
  5. undo/redo 直接恢復當時保存的 SearchState,不再重算。
  6. 仲裁和正常合法回合共用同一批增量共活資料。
  7. 計分只檢查「至少一方是本回合新增棋子」的棋子對。

下面按函式名稱定位,不依賴行號。


一、增加正式持久狀態

在:

js
let nextHypothesisPieceId = -1000000000;

後面加入:

js
      /*
       * 正式盤面的 SearchState。
       *
       * incremental:
       *   已完成的持久增量索引。
       *
       * incrementalPromise:
       *   人類對人類時也會在背景依序完成的增量更新。
       *
       * 每個 SearchState 只代表一個不可變局面。
       * undo/redo 直接恢復相應 SearchState,不清空後重算。
       */
      let committedSearchState = null;
      let committedStateVersion = 0;

      function yieldIncrementalBackground() {
        return new Promise(resolve => setTimeout(resolve, 0));
      }

二、替換 SearchState 的建立和複製函式

刪除並替換以下三個舊函式:

js
cloneSearchState
createSearchStateFromGlobals
createSearchStateForBoard

替換為:

js
      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;
      }

三、刪除舊的死活追蹤區,換成持久增量索引

找到:

js
// =============================================================================
// 死活/活法/互頂 追蹤系統(增量式)
// =============================================================================

從這個標記開始,一直刪除到:

js
function getAllValidMoves(player) {

之前。

也就是刪除舊的:

  • edgeRecords
  • methodRegistry
  • resetLifeTracking
  • scheduleLifeTrackingUpdate
  • computeAllLifeStatus
  • incrementalAddPiece
  • collectLiveGhostsForPlayer
  • buildCandidateMovesFromGhosts
  • deriveIncrementalEdgeGhosts
  • 舊的共活、單接、雙接追蹤函式

然後在原位置放入下面的完整替換程式碼:

js
      // =============================================================================
      // 持久 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;
      }

四、把計分改為只檢查新增棋子

完整刪除舊的:

js
function adjudicateSearchTurn(state, placedPieces) {
  ...
}

替換為:

js
      /*
       * 增量計分。
       *
       * 舊局面中的舊棋子接觸,在前面的回合已經處理過。
       * 新回合只可能新增:
       *
       * 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

完整替換舊函式:

js
function applySearchTurnToState(state, move, options = {}) {

替換為:

js
      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
        };
      }

六、讓合法性判定使用已保存的單子活法

把舊的:

js
function validatePlacementGeometry(piece, boardPieces, player) {

改成:

js
function validatePlacementGeometry(piece, boardPieces, player, state = null) {

然後在函式內,完成:

js
segmentsPerfectlyMatch(...)

檢查之後、preparePieceSAT(piece) 之前加入:

js
        /*
         * 如果這個放法已存在於目前局面的持久活法索引,
         * 就不需要再次掃描整個盤面做幾何合法性檢查。
         */
        if (
          state &&
          state.incremental &&
          state.incremental.methods.has(
            piecePlacementSignature(piece)
          )
        ) {
          return { ok: true };
        }

然後將 validateTurnOnState 裡的:

js
const result = validatePlacementGeometry(
  piece,
  state.boardPieces,
  state.mover
);

替換為:

js
const result = validatePlacementGeometry(
  piece,
  state.boardPieces,
  state.mover,
  state
);

這不改變驗證規則,只是已經在增量索引中的放法不再重做全盤掃描。


七、替換單子、共活和全部合法回合生成器

7.1 替換 generateLegalSinglePlacements

完整替換為:

js
      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 後面:

js
      /*
       * 從持久 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

完整替換為:

js
      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 替換仲裁函式

完整刪除舊的:

js
function generateArbitrationDefenceTurns(state) {
  ...
}

替換為:

js
      function generateArbitrationDefenceTurns(state) {
        /*
         * 仲裁只承認共活。
         * 最後只剩一子時,generateAllCoLiveTurns 會自動改為單子規則。
         */
        return generateAllCoLiveTurns(state, state.mover);
      }

hasArbitrationDefence 可以保持原樣:

js
      function hasArbitrationDefence(state) {
        if (isSearchGameComplete(state)) {
          return true;
        }

        return generateArbitrationDefenceTurns(state).length > 0;
      }

這樣 generateLegalTurns 和仲裁不會再分別重新呼叫單子枚舉器;兩者共用同一份持久共活資料。


八、讓人類介面的 ghost 也優先使用持久活法

完整替換 generateGhosts()

js
      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 中找到:

js
const applied = applySearchTurnToState(beforeState, tempPieces, {
  validate: false
});

替換為:

js
        const applied = applySearchTurnToState(
          beforeState,
          tempPieces,
          {
            validate: false,

            /*
             * 不論人類或 AI,正式落子都在背景更新並保存增量索引。
             */
            backgroundIndex: true
          }
        );

然後在:

js
pieces = applied.state.boardPieces;

之前加入:

js
        /*
         * 這是新的正式持久局面。
         * SearchState 內已經保存本局面的 incrementalPromise。
         */
        committedSearchState = applied.state;

所以這一段應為:

js
        committedSearchState = applied.state;

        pieces = applied.state.boardPieces;
        piecesCount = { ...applied.state.counts };
        scores = { ...applied.state.scores };
        scoredVictims = new Set(
          applied.state.scoredVictims
        );

最後刪除 actionCheck 末尾的:

js
scheduleLifeTrackingUpdate(lastTurnPieces, pieces);

因為現在增量工作已經由:

js
applySearchTurnToState(..., { backgroundIndex: true })

排入背景佇列,而且結果直接保存在 applied.state 中。


十、MCTS/minimax 開始前等待根局面的背景索引

startAI() 中找到:

js
const rootState = createSearchStateFromGlobals(aiPlayer);

後面立即加入:

js
        /*
         * 人類上一手的增量索引可能仍在背景更新。
         * AI 只等待這一次;後續所有搜索子節點都同步從父索引增量推導。
         */
        await ensureStateIncremental(rootState);

        if (cancelAi) {
          aiThinking = false;
          return;
        }

變成:

js
        const rootState =
          createSearchStateFromGlobals(aiPlayer);

        await ensureStateIncremental(rootState);

        if (cancelAi) {
          aiThinking = false;
          return;
        }

MCTS/minimax 的:

js
applySearchTurnToState(state, move, { validate: false })

不用修改。

因為沒有傳入 backgroundIndex: true,所以搜索子節點會立即執行:

js
deriveIncrementalIndex(parentIndex, childState, placedPieces, ...)

而不會重新建立全盤索引。


十一、仲裁前等待同一份增量共活資料

把:

js
function executeArbitration() {

改成:

js
async function executeArbitration() {

然後找到:

js
arbValidMoves = getAllValidMoves(currentPlayer);

替換為:

js
        const arbitrationState =
          createSearchStateFromGlobals(currentPlayer);

        await ensureStateIncremental(arbitrationState);

        arbValidMoves =
          generateArbitrationDefenceTurns(
            arbitrationState
          );

完整的函式前半段應改成:

js
      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 改成:

js
      function getAllValidMoves(player) {
        const state = createSearchStateFromGlobals(player);

        /*
         * 呼叫者應先 await ensureStateIncremental(state)。
         * 此包裝只為舊介面相容。
         */
        return generateArbitrationDefenceTurns(state);
      }

十二、history 必須保存完整增量 SearchState

saveState()gameHistory.push({ ... }) 裡加入:

js
          /*
           * 保存當時完整的持久 SearchState。
           * incrementalPromise 即使尚未完成,也會繼續在背景完成;
           * history 持有的是同一個 state 物件。
           */
          searchStateSnapshot: committedSearchState,

建議放在:

js
piecesSnapshot: [...pieces],

後面:

js
          piecesSnapshot: [...pieces],
          searchStateSnapshot: committedSearchState,
          scores: { ...scores },

十三、undo/redo 不再清除增量資料

13.1 修改 undo

刪除:

js
resetLifeTracking();

在 undo 已經恢復完以下資料之後:

js
currentDialogMode = prevState.dialogMode;

加入:

js
          /*
           * 直接恢復當時保存的持久增量局面。
           * 不重新枚舉活法、共活、單接、雙接。
           */
          if (prevState.searchStateSnapshot) {
            committedSearchState =
              prevState.searchStateSnapshot;
          } else {
            /*
             * 只為相容修改前建立的舊 history。
             */
            committedSearchState =
              createRawSearchStateFromGlobals(
                currentPlayer
              );

            committedSearchState.incremental =
              buildIncrementalIndexFromBoard(
                committedSearchState
              );

            committedSearchState.incrementalPromise =
              Promise.resolve(
                committedSearchState.incremental
              );
          }

13.2 修改 redo

同樣刪除:

js
resetLifeTracking();

在:

js
currentDialogMode = nextState.dialogMode;

後加入:

js
          if (nextState.searchStateSnapshot) {
            committedSearchState =
              nextState.searchStateSnapshot;
          } else {
            committedSearchState =
              createRawSearchStateFromGlobals(
                currentPlayer
              );

            committedSearchState.incremental =
              buildIncrementalIndexFromBoard(
                committedSearchState
              );

            committedSearchState.incrementalPromise =
              Promise.resolve(
                committedSearchState.incremental
              );
          }

這樣 undo/redo 恢復的是當時已保存或仍在背景完成中的增量資料,不再:

js
edgeRecords = new Map();
methodRegistry = new Map();

然後下一次 AI 搜索再全盤重建。


十四、新局初始化正式 SearchState

resetGame() 中,完成這些設定之後:

js
currentPlayer = startingPlayer;
applyRotation();
boardTransform = { tx: 240, ty: 240, scale: 1, angle: 0 };

加入:

js
        /*
         * 建立這一局唯一的正式初始 SearchState。
         * 空盤索引為空,不需要做幾何掃描。
         */
        initializeCommittedSearchStateFromGlobals();

即:

js
        currentPlayer = startingPlayer;
        applyRotation();

        boardTransform = {
          tx: 240,
          ty: 240,
          scale: 1,
          angle: 0
        };

        initializeCommittedSearchStateFromGlobals();

confirmImport() 裡原本的:

js
resetLifeTracking();
resetGame(false);

可以改為:

js
resetGame(false);

因為 resetGame() 自己會建立新的正式增量狀態。


十五、計分模式同步到正式 SearchState

替換 setScoringMode

js
      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/redosearchStateSnapshot
棋譜導入每手經 actionCheck 逐步增量建立
任意舊盤面相容buildIncrementalIndexFromBoard僅備援時一次

最重要的差異是:增量資料現在屬於正式 SearchState,不是 AI 開始搜索時才臨時建立,也不是 undo/redo 後會消失的附屬全域資料。即使兩邊都是人類,每一次確認落子後都會沿同一條背景 Promise 佇列保存該局面的完整增量索引。