QuanAI只读快照

共享会话

重寫MCTS搜尋和minimax搜尋

分享于 2026年8月14日 04:48
提问者

重寫MCTS搜尋和minimax搜尋 一個棋子的一條邊如果有至少一種方式可以放置一個對方棋子,則稱為「活邊」,否則為「死邊」。每一種放置方式為一種「活法」。

如果一條邊的某一個「活法」能跟另一條邊形成頂鑫結構,則標注為「單接」,並計其得分。

「死邊」有以下幾種「死法」:「貼死」、「頂死」、「夾死」以及「悶死」。「貼死」指的是有另一個棋子的邊與其貼合,「頂死」指被另一個棋子頂到,「夾死」指如果兩條互相接觸的邊夾角為36度則兩條邊都是夾死,或者如果夾角為72度則長度為1的邊會被夾死,「悶死」指的是不屬於以上情況但沒有棋子能合規放置。其中「貼死」的邊將不能再跟其它棋子形成頂鑫結構。

在當前盤面下,記錄每一條邊的「死活」情況以及所有的「活法」。在以後的每步棋或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子是否跟某些邊的某些活法在面積上重疊,或在邊上重合,或懸空的頂點重合」,更變已有棋子的邊的「死活」。而新加入的棋子的邊又會有新的「死活」。

在當前盤面下,統計所有同屬一方的棋子的活法兩兩之間是否形成頂鑫結構,如果存在則為這兩個活法都標注「雙接」。每一種「活法」都有一個特別的id,互相記下id以及得分情況。注意一個「活法」可以跟不同的「活法」之間產生「雙接」。在以後的每步棋或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子的活法是否能和舊的棋子的活法產生雙接」。

在當前盤面下,統計每個具有至少兩個「活邊」的棋子上的活法兩兩之間是否可以一起合規落子,即判斷這兩活法之間是否「在面積上重疊,在邊上重合,或懸空的頂點重合」,若能合規落子則兩個活法都相互記下id並標注「共活」。注意一個「活法」可以跟同一個棋子上的另一條邊上的多個「活法」產生「共活」。在以後的每回合或每一個MCTS搜尋節點採用增量算法,即「新加入的棋子是否消滅了之前的共活或是否產生了新的共活」。

當進行MCTS搜尋時,使己方得分的「單接」和己方的「雙接」都將是優先選項。如果是「單接」,則在這一回合的第二手棋之前,先要更新全盤的「死活」、所有棋子的「活法」、「雙接」以及「共活」。如果選擇的是「雙接」,則在這一回合之後更新。

如果當前沒有使己方得分的「單接」和己方的「雙接」,則需在所有的「共活」之中,優先挑選能破壞使對方得分的「單接」和對方的「雙接」,並且不要產生新的使對方得分的「單接」和對方的「雙接」。

統計「共活」的算法跟「仲裁」算法應是一樣的,當在MCTS搜尋時,不能下出使對方下回合沒有「共活」的下法。

minimax將為「廣度優先」,將首先對所有能得分的下法進行搜尋,僅當沒有能得分的下法時才,對所有的「共活」進行搜尋。但在後手方最後一手棋時,如果沒有使他加分的「單接」,則只需隨機挑一個活法下即可。在消息框實時顯示當前計算的層數和用時。

當遊戲開始時,即使雙方都不是AI,程式也要保持對所有棋子的邊的「死活」、「活法」、「單接」、「雙接」、「共活」的增量統計。當人類玩家需要展示「預放棋子」時,則調出相應的「活法」。當一方是AI,而如果當前盤面所有棋子的邊的「死活」、「活法」、「單接」、「雙接」、「共活」都沒有統計完成,則把當前盤面的統計完成後再開始MCTS搜尋。限時只用於MCTS搜尋,但當MCTS搜尋限時結束,消息框要給出總用時。

以上的MCTS搜尋算法和minimax算法,將取代之前的「步驟一、步驟二、步驟三、步驟四」。

上面所說的「長度為1的邊」是指每個棋子中都有的兩條最短的邊。

「先手方」指的是第一局的玩家一和第二局的玩家二,「後手方」則是第一局的玩家二和第二局的玩家一。

回答要求:

明確給出所要修改的地方和所要替換的代碼。

QuanAI

下面不要再把舊的「步驟一、二、三、四」候選生成、舊 getAICandidateMoves()、舊 findDisruptingMoves()、舊 MCTS 根節點隨機跑分,和新的增量圖同時保留。它們會造成候選規則互相衝突、仲裁誤觸發,以及 live/alive、單接欄位不同步。

以下按「尋找的函式/區塊」定位即可,不依賴固定行號。


0. 先刪除或停止呼叫的舊程式

請刪除先前加入、但會與本版衝突的下列內容:

  1. 舊的 computeAllLifeStatusincrementalAddPieceupdateIncrementalGraphupdateIncrementalGraphLocal 模組。
  2. 所有舊的:
    • getAICandidateMoves
    • getScoringSingleDingMethods
    • getSingleJieMethods
    • getMutualDingPairs
    • findDisruptingMoves
    • planFullTurnActionsMCTS
    • rolloutPolicy
    • minimaxEndgame
  3. startAI() 的 MCTS / Minimax 搜尋區塊。
  4. 若程式內有兩個同名 updateIncrementalGraph(),兩個都不要保留。 本版只使用 tgOnCommittedPieces()tgEnsureBoardGraphSync()

保留原有的精確幾何函式,例如:

js
attachByEdge
isValidGhost
shapesOverlap
segmentsOverlapAsEdges
pointOnOpenSegment
pointsEqual
ringSub
ringMul
conjugate
zetaPow
isImaginaryPartZero
realPartExact
phiFormToNumber
simulateScoreGain

1. 新增:統一的「死活/活法/單接/雙接/共活」增量圖模組

插入位置: 放在 simulateScoreGaingetTreeDistanceformsTriGolden 等計分與幾何函式之後,getAllValidMoves() 之前。

同時刪除原先的 let boardGraph = ...let methodIdCounter = ... 等舊宣告,再完整加入以下程式。

javascript
// ============================================================================
// 頂鑫棋:統一增量狀態圖
// 只保留本模組,不要與舊 edgeRecords / methodRegistry / boardGraph 混用。
// ============================================================================

const TG_DEATH = Object.freeze({
  ADHESION: '貼死',
  TOPPED: '頂死',
  PINCHED: '夾死',
  SMOTHERED: '悶死'
});

let boardGraph = tgEmptyGraph();
let isGraphCalculating = false;

/*
 * 第一局:玩家一先手;第二局:玩家二先手。
 * 請在開始第二局、交換先後手的位置設定為 2;
 * 新賽局第一局開始時設定回 1。
 */
let openingPlayerThisGame = 1;

function tgOtherPlayer(player) {
  return player === 1 ? 2 : 1;
}

function tgTilesOfPlayer(player) {
  return player === 1
    ? ['tile0', 'tile1', 'tile2']
    : ['tile3', 'tile4', 'tile5'];
}

function tgEdgeKey(pieceId, edgeIndex) {
  return `${String(pieceId)}@${edgeIndex}`;
}

function tgPairKey(a, b) {
  return String(a) < String(b)
    ? `${String(a)}|${String(b)}`
    : `${String(b)}|${String(a)}`;
}

function tgZeroGain() {
  return { 1: 0, 2: 0 };
}

function tgCopyRingPoint(point) {
  return Array.isArray(point) ? point.slice() : point;
}

function tgClonePiece(piece) {
  return {
    ...piece,
    vertices: piece.vertices.map(tgCopyRingPoint)
  };
}

function tgEmptyGraph() {
  return {
    ready: false,
    version: 0,

    // edgeKey -> {
    //   pieceId, edgeIndex,
    //   status: 'live' | 'dead',
    //   reason: null | 貼死 | 頂死 | 夾死 | 悶死,
    //   methodIds: Set<methodId>
    // }
    edges: new Map(),

    // methodId -> method
    methods: new Map()
  };
}

function tgCloneGraph(graph) {
  const copy = tgEmptyGraph();
  copy.ready = graph.ready;
  copy.version = graph.version;

  for (const [key, edge] of graph.edges.entries()) {
    copy.edges.set(key, {
      ...edge,
      methodIds: new Set(edge.methodIds || [])
    });
  }

  for (const [id, method] of graph.methods.entries()) {
    const doubleWith = new Map();

    for (const [otherId, info] of (method.doubleWith || new Map()).entries()) {
      doubleWith.set(otherId, {
        ...info,
        gain: { ...(info.gain || tgZeroGain()) },
        contacts: (info.contacts || []).map(x => ({ ...x }))
      });
    }

    copy.methods.set(id, {
      ...method,
      vertices: method.vertices.map(tgCopyRingPoint),
      singleWith: (method.singleWith || []).map(x => ({ ...x })),
      jieGain: { ...(method.jieGain || tgZeroGain()) },
      doubleWith,
      coLiveWith: new Set(method.coLiveWith || [])
    });
  }

  return copy;
}

function tgReadStock() {
  return { ...piecesCount };
}

function tgBoardState() {
  return {
    pieces: pieces.map(tgClonePiece),
    stock: tgReadStock(),
    scores: { ...scores },
    currentPlayer,
    nextVirtualId: 1
  };
}

/*
 * 相容層:
 * 若原本 simulateScoreGain(newPieces) 內部讀取全域 pieces,
 * 這個包裝器可讓 MCTS / Minimax 在假設盤面上計分。
 *
 * simulateScoreGain 必須是同步、不得直接修改真實 scores 的純計算函式。
 */
function tgScoreGain(state, addedPieces) {
  const oldPieces = pieces;
  const oldTempPieces = tempPieces;

  try {
    pieces = state.pieces;
    tempPieces = [];

    const gain = simulateScoreGain(addedPieces);

    return {
      1: Number((gain && gain[1]) || 0),
      2: Number((gain && gain[2]) || 0)
    };
  } finally {
    pieces = oldPieces;
    tempPieces = oldTempPieces;
  }
}

function tgFindPiece(state, pieceId) {
  return state.pieces.find(p => String(p.id) === String(pieceId)) || null;
}

function tgGhostFromMethod(method, virtualId = null) {
  return {
    id: virtualId || `method:${method.id}`,
    mId: method.id,

    owner: method.owner,
    forPlayer: method.owner,

    type: method.type,
    svgId: method.svgId,
    isFlipped: method.isFlipped,

    vertices: method.vertices.map(tgCopyRingPoint),

    parentId: method.targetId,
    targetId: method.targetId,
    targetEdge: method.targetEdge,
    edgeOnOpp: method.targetEdge,
    myEdge: method.myEdge
  };
}

function tgMakeMethodId(targetPiece, targetEdge, svgId, isFlipped, myEdge) {
  return [
    'm',
    String(targetPiece.id),
    targetEdge,
    svgId,
    isFlipped ? 1 : 0,
    myEdge
  ].join(':');
}

function tgStockOf(state, svgId) {
  return Number(state.stock[svgId] || 0);
}

function tgEnoughForOne(state, method) {
  return tgStockOf(state, method.svgId) >= 1;
}

function tgEnoughForTwo(state, methodA, methodB) {
  if (methodA.svgId === methodB.svgId) {
    return tgStockOf(state, methodA.svgId) >= 2;
  }

  return (
    tgStockOf(state, methodA.svgId) >= 1 &&
    tgStockOf(state, methodB.svgId) >= 1
  );
}

// ---------------------------------------------------------------------------
// 精確幾何:夾死
// ---------------------------------------------------------------------------

function tgEdgeVector(piece, edgeIndex) {
  return ringSub(
    piece.vertices[(edgeIndex + 1) % 4],
    piece.vertices[edgeIndex]
  );
}

function tgIsLengthOneEdge(piece, edgeIndex) {
  const v = tgEdgeVector(piece, edgeIndex);
  const normSq = ringMul(v, conjugate(v));
  return pointsEqual(normSq, ONE);
}

/*
 * 回傳兩條「由接觸點向外」射線的實際夾角:
 * 0 / 36 / 72 / 108 / 144 / 180,無法辨識則 null。
 */
function tgDiscreteAngle(v1, v2) {
  const product = ringMul(v1, conjugate(v2));

  for (let k = 0; k < 10; k++) {
    const aligned = ringMul(product, conjugate(zetaPow(k)));

    if (!isImaginaryPartZero(aligned)) continue;

    const numeric = phiFormToNumber(realPartExact(aligned));

    if (numeric > 0) {
      return Math.min(k, 10 - k) * 36;
    }
  }

  return null;
}

function tgRayFromContact(piece, edgeIndex, contactPoint) {
  const a = piece.vertices[edgeIndex];
  const b = piece.vertices[(edgeIndex + 1) % 4];

  if (pointsEqual(contactPoint, a)) return ringSub(b, a);
  if (pointsEqual(contactPoint, b)) return ringSub(a, b);

  return null;
}

/*
 * 兩條邊在端點相接時:
 * - 36°:兩邊皆夾死
 * - 72°:只有長度為 1 的邊夾死
 */
function tgPinchFlags(pieceA, edgeA, pieceB, edgeB) {
  const endpointsA = [
    pieceA.vertices[edgeA],
    pieceA.vertices[(edgeA + 1) % 4]
  ];

  const endpointsB = [
    pieceB.vertices[edgeB],
    pieceB.vertices[(edgeB + 1) % 4]
  ];

  const result = { deadA: false, deadB: false };

  for (const pointA of endpointsA) {
    for (const pointB of endpointsB) {
      if (!pointsEqual(pointA, pointB)) continue;

      const rayA = tgRayFromContact(pieceA, edgeA, pointA);
      const rayB = tgRayFromContact(pieceB, edgeB, pointA);

      if (!rayA || !rayB) continue;

      const angle = tgDiscreteAngle(rayA, rayB);

      if (angle === 36) {
        result.deadA = true;
        result.deadB = true;
      } else if (angle === 72) {
        if (tgIsLengthOneEdge(pieceA, edgeA)) result.deadA = true;
        if (tgIsLengthOneEdge(pieceB, edgeB)) result.deadB = true;
      }
    }
  }

  return result;
}

// ---------------------------------------------------------------------------
// 邊死活與活法
// ---------------------------------------------------------------------------

function tgEdgeHasAdhesion(state, piece, edgeIndex) {
  const a = piece.vertices[edgeIndex];
  const b = piece.vertices[(edgeIndex + 1) % 4];

  for (const other of state.pieces) {
    if (String(other.id) === String(piece.id)) continue;

    for (let oe = 0; oe < 4; oe++) {
      const c = other.vertices[oe];
      const d = other.vertices[(oe + 1) % 4];

      if (segmentsOverlapAsEdges(a, b, c, d)) {
        return true;
      }
    }
  }

  return false;
}

function tgEdgeHasTop(state, piece, edgeIndex) {
  const a = piece.vertices[edgeIndex];
  const b = piece.vertices[(edgeIndex + 1) % 4];

  for (const other of state.pieces) {
    if (String(other.id) === String(piece.id)) continue;

    for (const vertex of other.vertices) {
      if (pointOnOpenSegment(vertex, a, b)) {
        return true;
      }
    }
  }

  return false;
}

function tgEdgeHasPinch(state, piece, edgeIndex) {
  for (const other of state.pieces) {
    if (String(other.id) === String(piece.id)) continue;

    for (let oe = 0; oe < 4; oe++) {
      const flags = tgPinchFlags(piece, edgeIndex, other, oe);

      if (flags.deadA) return true;
    }
  }

  return false;
}

function tgEnumerateEdgeMethods(state, targetPiece, targetEdge) {
  const result = [];
  const seen = new Set();

  const player = tgOtherPlayer(targetPiece.owner);
  const p = targetPiece.vertices[targetEdge];
  const q = targetPiece.vertices[(targetEdge + 1) % 4];

  for (const svgId of tgTilesOfPlayer(player)) {
    if (tgStockOf(state, svgId) <= 0) continue;

    const type = SHAPE_MAP[svgId];

    for (const isFlipped of [false, true]) {
      for (let myEdge = 0; myEdge < 4; myEdge++) {
        const attached = attachByEdge(type, isFlipped, myEdge, p, q);

        if (!attached) continue;

        const methodId = tgMakeMethodId(
          targetPiece,
          targetEdge,
          svgId,
          isFlipped,
          myEdge
        );

        if (seen.has(methodId)) continue;

        const ghost = {
          id: `probe:${methodId}`,
          mId: methodId,

          owner: player,
          forPlayer: player,

          type,
          svgId,
          isFlipped,

          vertices: attached.vertices.map(tgCopyRingPoint),

          parentId: targetPiece.id,
          targetId: targetPiece.id,
          targetEdge,
          edgeOnOpp: targetEdge,
          myEdge
        };

        if (!isValidGhost(ghost, state.pieces)) continue;

        seen.add(methodId);

        result.push({
          id: methodId,

          owner: player,
          forPlayer: player,

          type,
          svgId,
          isFlipped,

          vertices: ghost.vertices.map(tgCopyRingPoint),

          targetId: targetPiece.id,
          targetEdge,
          myEdge,

          edgeKey: tgEdgeKey(targetPiece.id, targetEdge),

          singleWith: [],
          isSingleJie: false,
          jieGain: tgZeroGain(),
          jieScore: 0,
          jieScoreOpp: 0,

          // methodId -> { compatible, gain, contacts }
          doubleWith: new Map(),

          // Set<methodId>
          coLiveWith: new Set()
        });
      }
    }
  }

  return result;
}

function tgClassifyEdge(state, piece, edgeIndex) {
  if (tgEdgeHasAdhesion(state, piece, edgeIndex)) {
    return {
      status: 'dead',
      reason: TG_DEATH.ADHESION,
      methods: []
    };
  }

  if (tgEdgeHasTop(state, piece, edgeIndex)) {
    return {
      status: 'dead',
      reason: TG_DEATH.TOPPED,
      methods: []
    };
  }

  if (tgEdgeHasPinch(state, piece, edgeIndex)) {
    return {
      status: 'dead',
      reason: TG_DEATH.PINCHED,
      methods: []
    };
  }

  const methods = tgEnumerateEdgeMethods(state, piece, edgeIndex);

  if (methods.length === 0) {
    return {
      status: 'dead',
      reason: TG_DEATH.SMOTHERED,
      methods: []
    };
  }

  return {
    status: 'live',
    reason: null,
    methods
  };
}

function tgRemoveMethod(graph, methodId) {
  const method = graph.methods.get(methodId);
  if (!method) return;

  for (const otherId of Array.from(method.doubleWith.keys())) {
    const other = graph.methods.get(otherId);
    if (other) other.doubleWith.delete(methodId);
  }

  for (const otherId of Array.from(method.coLiveWith.values())) {
    const other = graph.methods.get(otherId);
    if (other) other.coLiveWith.delete(methodId);
  }

  const edge = graph.edges.get(method.edgeKey);
  if (edge) edge.methodIds.delete(methodId);

  graph.methods.delete(methodId);
}

function tgRebuildEdge(state, graph, piece, edgeIndex, changedMethodIds) {
  const key = tgEdgeKey(piece.id, edgeIndex);
  const old = graph.edges.get(key);

  if (old) {
    for (const methodId of Array.from(old.methodIds)) {
      tgRemoveMethod(graph, methodId);
      changedMethodIds.add(methodId);
    }
  }

  const result = tgClassifyEdge(state, piece, edgeIndex);

  const record = {
    pieceId: piece.id,
    edgeIndex,
    status: result.status,
    reason: result.reason,
    methodIds: new Set()
  };

  graph.edges.set(key, record);

  if (record.status !== 'live') return;

  for (const method of result.methods) {
    graph.methods.set(method.id, method);
    record.methodIds.add(method.id);
    changedMethodIds.add(method.id);
  }
}

// ---------------------------------------------------------------------------
// 頂鑫接觸:貼死邊不得作為「鑫邊」
// ---------------------------------------------------------------------------

function tgIsBlockedXinEdge(piece, edgeIndex, graph) {
  if (Number(piece.myEdge) === edgeIndex) return true;

  const edge = graph.edges.get(tgEdgeKey(piece.id, edgeIndex));

  return !!(edge && edge.reason === TG_DEATH.ADHESION);
}

function tgTriGoldenContacts(pieceA, pieceB, graph) {
  const contacts = [];

  function scan(topPiece, xinPiece) {
    for (let vi = 0; vi < 4; vi++) {
      const vertex = topPiece.vertices[vi];

      for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
        if (tgIsBlockedXinEdge(xinPiece, edgeIndex, graph)) continue;

        const a = xinPiece.vertices[edgeIndex];
        const b = xinPiece.vertices[(edgeIndex + 1) % 4];

        if (pointOnOpenSegment(vertex, a, b)) {
          contacts.push({
            topPieceId: topPiece.id,
            topVertex: vi,
            xinPieceId: xinPiece.id,
            xinEdge: edgeIndex
          });
        }
      }
    }
  }

  scan(pieceA, pieceB);
  scan(pieceB, pieceA);

  return contacts;
}

function tgRefreshSingleJie(state, graph, method) {
  const ghost = tgGhostFromMethod(method, `single:${method.id}`);
  const contacts = [];

  for (const piece of state.pieces) {
    contacts.push(...tgTriGoldenContacts(ghost, piece, graph));
  }

  method.singleWith = contacts;
  method.isSingleJie = contacts.length > 0;

  if (!method.isSingleJie) {
    method.jieGain = tgZeroGain();
    method.jieScore = 0;
    method.jieScoreOpp = 0;
    return;
  }

  method.jieGain = tgScoreGain(state, [ghost]);
  method.jieScore = method.jieGain[method.owner] || 0;
  method.jieScoreOpp = method.jieGain[tgOtherPlayer(method.owner)] || 0;
}

function tgTwoMethodsCompatible(state, methodA, methodB) {
  if (!tgEnoughForTwo(state, methodA, methodB)) return false;

  const ghostA = tgGhostFromMethod(methodA, `pair-a:${methodA.id}`);
  const ghostB = tgGhostFromMethod(methodB, `pair-b:${methodB.id}`);

  if (!isValidGhost(ghostA, state.pieces)) return false;

  return isValidGhost(ghostB, state.pieces.concat([ghostA]));
}

function tgTryAddDoubleJie(state, graph, methodA, methodB) {
  if (methodA.owner !== methodB.owner) return;

  const ghostA = tgGhostFromMethod(methodA, `double-a:${methodA.id}`);
  const ghostB = tgGhostFromMethod(methodB, `double-b:${methodB.id}`);

  const contacts = tgTriGoldenContacts(ghostA, ghostB, graph);

  if (contacts.length === 0) return;

  /*
   * 先標記為雙接;但只有 compatible === true 時,
   * 才能作為實際完整回合候選。
   */
  const compatible = tgTwoMethodsCompatible(state, methodA, methodB);
  const gain = compatible
    ? tgScoreGain(state, [ghostA, ghostB])
    : tgZeroGain();

  const record = {
    compatible,
    gain,
    contacts
  };

  methodA.doubleWith.set(methodB.id, record);
  methodB.doubleWith.set(methodA.id, {
    compatible,
    gain: { ...gain },
    contacts: contacts.map(x => ({ ...x }))
  });
}

function tgTryAddCoLive(state, methodA, methodB) {
  if (String(methodA.targetId) !== String(methodB.targetId)) return;
  if (methodA.targetEdge === methodB.targetEdge) return;

  if (!tgTwoMethodsCompatible(state, methodA, methodB)) return;

  methodA.coLiveWith.add(methodB.id);
  methodB.coLiveWith.add(methodA.id);
}

function tgClearDoubleLinks(graph, method) {
  for (const otherId of Array.from(method.doubleWith.keys())) {
    const other = graph.methods.get(otherId);
    if (other) other.doubleWith.delete(method.id);
  }

  method.doubleWith.clear();
}

function tgClearCoLivesOfTarget(graph, targetId) {
  const methods = Array.from(graph.methods.values()).filter(
    m => String(m.targetId) === String(targetId)
  );

  for (const method of methods) {
    for (const otherId of Array.from(method.coLiveWith.values())) {
      const other = graph.methods.get(otherId);

      if (
        other &&
        String(other.targetId) === String(targetId)
      ) {
        other.coLiveWith.delete(method.id);
      }

      method.coLiveWith.delete(otherId);
    }
  }
}

/*
 * 關係更新規則:
 * - 單接得分:全部重算。因為新棋子可能改變全局最短路徑得分。
 * - 雙接:只讓「新增/重建的活法」與舊活法比較。
 * - 共活:只重算受影響目標棋子上的活法對。
 */
function tgRefreshRelations(
  state,
  graph,
  changedMethodIds,
  fullRebuild = false,
  affectedTargetIds = new Set()
) {
  const allMethods = Array.from(graph.methods.values());

  // 單接與其分數必須反映當前全盤路徑,因此全部更新。
  for (const method of allMethods) {
    tgRefreshSingleJie(state, graph, method);
  }

  if (fullRebuild) {
    for (const method of allMethods) {
      method.doubleWith.clear();
      method.coLiveWith.clear();
    }
  }

  const participants = fullRebuild
    ? allMethods
    : Array.from(changedMethodIds)
        .map(id => graph.methods.get(id))
        .filter(Boolean);

  for (const method of participants) {
    tgClearDoubleLinks(graph, method);
    affectedTargetIds.add(String(method.targetId));
  }

  // 新活法只與既有活法做雙接增量比較。
  const handledDoublePairs = new Set();

  for (const methodA of participants) {
    for (const methodB of allMethods) {
      if (methodA.id === methodB.id) continue;

      const key = tgPairKey(methodA.id, methodB.id);

      if (handledDoublePairs.has(key)) continue;
      handledDoublePairs.add(key);

      tgTryAddDoubleJie(state, graph, methodA, methodB);
    }
  }

  if (fullRebuild) {
    for (const method of allMethods) {
      affectedTargetIds.add(String(method.targetId));
    }
  }

  // 共活只在同一個目標棋子內配對。
  for (const targetId of affectedTargetIds) {
    tgClearCoLivesOfTarget(graph, targetId);

    const methods = Array.from(graph.methods.values()).filter(
      m => String(m.targetId) === String(targetId)
    );

    for (let i = 0; i < methods.length; i++) {
      for (let j = i + 1; j < methods.length; j++) {
        tgTryAddCoLive(state, methods[i], methods[j]);
      }
    }
  }

  /*
   * 新棋子可能改變最短路徑,因此已存在的雙接仍要更新得分;
   * 但不需要把所有活法兩兩重新判斷是否形成雙接。
   */
  const handledScores = new Set();

  for (const methodA of graph.methods.values()) {
    for (const [methodBId, info] of methodA.doubleWith.entries()) {
      const key = tgPairKey(methodA.id, methodBId);

      if (handledScores.has(key)) continue;
      handledScores.add(key);

      const methodB = graph.methods.get(methodBId);
      if (!methodB || !info.compatible) continue;

      const gain = tgScoreGain(state, [
        tgGhostFromMethod(methodA, `score-a:${methodA.id}`),
        tgGhostFromMethod(methodB, `score-b:${methodB.id}`)
      ]);

      info.gain = gain;

      const reverse = methodB.doubleWith.get(methodA.id);
      if (reverse) reverse.gain = { ...gain };
    }
  }
}

// ---------------------------------------------------------------------------
// 增量更新:新棋子消滅舊活法的三種方式
// 面積重疊、邊重合、懸空頂點重合。
// ---------------------------------------------------------------------------

function tgMethodInvalidatedByNewPiece(method, newPiece) {
  const ghost = tgGhostFromMethod(method, `invalidate:${method.id}`);

  // 1. 面積重疊。
  if (shapesOverlap(ghost, newPiece)) return true;

  // 2. 邊重合。
  for (let ge = 0; ge < 4; ge++) {
    const ga = ghost.vertices[ge];
    const gb = ghost.vertices[(ge + 1) % 4];

    for (let ne = 0; ne < 4; ne++) {
      const na = newPiece.vertices[ne];
      const nb = newPiece.vertices[(ne + 1) % 4];

      if (segmentsOverlapAsEdges(ga, gb, na, nb)) {
        return true;
      }
    }
  }

  /*
   * 3. 懸空頂點重合。
   * 貼到目標邊上的兩個端點不是懸空頂點,因此跳過。
   */
  const attachedA = Number(method.myEdge);
  const attachedB = (attachedA + 1) % 4;

  for (let gv = 0; gv < 4; gv++) {
    if (gv === attachedA || gv === attachedB) continue;

    for (let nv = 0; nv < 4; nv++) {
      if (pointsEqual(ghost.vertices[gv], newPiece.vertices[nv])) {
        return true;
      }
    }
  }

  return false;
}

function tgMethodStillLegal(state, method) {
  if (!tgEnoughForOne(state, method)) return false;

  return isValidGhost(
    tgGhostFromMethod(method, `valid:${method.id}`),
    state.pieces
  );
}

function tgEdgeAffectedByNewPiece(piece, edgeIndex, newPiece) {
  if (String(piece.id) === String(newPiece.id)) return false;

  const a = piece.vertices[edgeIndex];
  const b = piece.vertices[(edgeIndex + 1) % 4];

  // 新棋子頂到此邊。
  for (const vertex of newPiece.vertices) {
    if (pointOnOpenSegment(vertex, a, b)) return true;
  }

  for (let ne = 0; ne < 4; ne++) {
    const c = newPiece.vertices[ne];
    const d = newPiece.vertices[(ne + 1) % 4];

    // 新棋子貼到此邊。
    if (segmentsOverlapAsEdges(a, b, c, d)) return true;

    // 新棋子與此邊形成 36° / 72° 夾死。
    const pinch = tgPinchFlags(piece, edgeIndex, newPiece, ne);
    if (pinch.deadA) return true;
  }

  return false;
}

function tgBuildGraph(state) {
  const graph = tgEmptyGraph();
  const changedMethodIds = new Set();
  const affectedTargetIds = new Set();

  for (const piece of state.pieces) {
    for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
      tgRebuildEdge(
        state,
        graph,
        piece,
        edgeIndex,
        changedMethodIds
      );
    }
  }

  tgRefreshRelations(
    state,
    graph,
    changedMethodIds,
    true,
    affectedTargetIds
  );

  graph.ready = true;
  graph.version++;

  return graph;
}

/*
 * 正常落子與 MCTS 節點都走這個函式。
 * 不會全盤重新生成活法;只重建真正受影響的邊,
 * 並讓新增活法與舊活法增量產生雙接/共活。
 */
function tgIncrementalUpdate(state, graph, addedPieces) {
  if (!graph || !graph.ready || !addedPieces || addedPieces.length === 0) {
    return tgBuildGraph(state);
  }

  const dirtyEdges = new Set();
  const changedMethodIds = new Set();
  const affectedTargetIds = new Set();
  const changedTileIds = new Set(
    addedPieces.map(piece => piece.svgId)
  );

  // 新棋子本身的四邊必須建立死活。
  for (const piece of addedPieces) {
    for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
      dirtyEdges.add(tgEdgeKey(piece.id, edgeIndex));
    }

    // 父棋子被貼合的邊一定受影響。
    if (
      piece.targetId !== undefined &&
      piece.targetId !== null &&
      piece.targetEdge !== undefined &&
      piece.targetEdge !== null
    ) {
      dirtyEdges.add(tgEdgeKey(piece.targetId, piece.targetEdge));
      affectedTargetIds.add(String(piece.targetId));
    }
  }

  // 受新棋子直接頂、貼、夾影響的既有邊。
  for (const piece of state.pieces) {
    for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
      if (
        addedPieces.some(newPiece =>
          tgEdgeAffectedByNewPiece(piece, edgeIndex, newPiece)
        )
      ) {
        dirtyEdges.add(tgEdgeKey(piece.id, edgeIndex));
        affectedTargetIds.add(String(piece.id));
      }
    }
  }

  /*
   * 舊活法是否被新棋子阻擋:
   * 面積重疊、邊重合、懸空頂點重合任一成立即失效。
   */
  for (const method of graph.methods.values()) {
    const blocked = addedPieces.some(newPiece =>
      tgMethodInvalidatedByNewPiece(method, newPiece)
    );

    if (blocked || !tgMethodStillLegal(state, method)) {
      dirtyEdges.add(method.edgeKey);
      affectedTargetIds.add(String(method.targetId));
    }
  }

  /*
   * 剩餘棋子數變動可能使同一棋種的「兩子共活」失效,
   * 因此使用過的棋種所在目標棋子也要重算共活。
   */
  for (const method of graph.methods.values()) {
    if (changedTileIds.has(method.svgId)) {
      affectedTargetIds.add(String(method.targetId));
    }
  }

  for (const edgeKey of dirtyEdges) {
    const [pieceId, edgeText] = edgeKey.split('@');
    const piece = tgFindPiece(state, pieceId);

    if (!piece) continue;

    tgRebuildEdge(
      state,
      graph,
      piece,
      Number(edgeText),
      changedMethodIds
    );

    affectedTargetIds.add(String(piece.id));
  }

  // 新生成的活法若使用剛被扣減的棋種,也要重算其共活。
  for (const method of graph.methods.values()) {
    if (changedTileIds.has(method.svgId)) {
      affectedTargetIds.add(String(method.targetId));
    }
  }

  tgRefreshRelations(
    state,
    graph,
    changedMethodIds,
    false,
    affectedTargetIds
  );

  graph.ready = true;
  graph.version++;

  return graph;
}

function tgInvalidateBoardGraph() {
  boardGraph = tgEmptyGraph();
}

function tgEnsureBoardGraphSync() {
  if (boardGraph.ready) return;

  isGraphCalculating = true;

  try {
    boardGraph = tgBuildGraph(tgBoardState());
  } finally {
    isGraphCalculating = false;
  }
}

/*
 * 真正落子後呼叫。
 * 此時 pieces 與 piecesCount 必須已經是「落子後」狀態。
 */
function tgOnCommittedPieces(committedPieces) {
  isGraphCalculating = true;

  try {
    const state = tgBoardState();

    boardGraph = boardGraph.ready
      ? tgIncrementalUpdate(state, boardGraph, committedPieces)
      : tgBuildGraph(state);
  } finally {
    isGraphCalculating = false;
  }
}

2. 替換人類玩家的 generateGhosts()

尋找:

js
function generateGhosts() {

將整個函式替換為以下版本。

它不再即時暴力掃描全部幾何位置,而是直接讀取已統計的 boardGraph.methods。若本回合已先預放一子,則以「第一子後的暫存盤面」重新建立暫存圖,讓第二子讀取正確的活法。

javascript
function tgPreviewState() {
  const state = {
    pieces: pieces
      .concat(tempPieces)
      .map(tgClonePiece),

    // tempPieces 已經扣除 piecesCount,因此直接使用目前庫存。
    stock: tgReadStock(),
    scores: { ...scores },
    currentPlayer,
    nextVirtualId: 1
  };

  state.graph = tgBuildGraph(state);
  return state;
}

function tgSecondPreviewAllowed(firstGhost, candidateMethod, stateAfterFirst) {
  // 同一個目標棋子:候選活法已經在第一子後狀態中合法,直接允許。
  if (String(firstGhost.targetId) === String(candidateMethod.targetId)) {
    return true;
  }

  // 第一子形成單接後,第二子可以去任意對方棋子的活邊。
  if (firstGhost.wasSingleJie) {
    return true;
  }

  /*
   * 若第一子不是單接,第二子只能:
   * 1. 仍貼在同一對方棋子;或
   * 2. 與第一子形成雙接。
   */
  const candidateGhost = tgGhostFromMethod(
    candidateMethod,
    `preview:${candidateMethod.id}`
  );

  return tgTriGoldenContacts(
    firstGhost,
    candidateGhost,
    stateAfterFirst.graph
  ).length > 0;
}

function generateGhosts() {
  ghosts = [];

  if (!selectedTile || targetOpponentPieceId === null) return;

  tgEnsureBoardGraphSync();

  const state = tempPieces.length > 0
    ? tgPreviewState()
    : (() => {
        const live = tgBoardState();
        live.graph = boardGraph;
        return live;
      })();

  const wantedType = SHAPE_MAP[selectedTile];
  const wantedFlip = !!isFlipped[selectedTile];

  for (const method of state.graph.methods.values()) {
    if (method.owner !== currentPlayer) continue;
    if (String(method.targetId) !== String(targetOpponentPieceId)) continue;
    if (method.type !== wantedType) continue;
    if (method.svgId !== selectedTile) continue;
    if (method.isFlipped !== wantedFlip) continue;

    if (
      tempPieces.length === 1 &&
      !tgSecondPreviewAllowed(tempPieces[0], method, state)
    ) {
      continue;
    }

    const ghost = tgGhostFromMethod(
      method,
      pieces.length + tempPieces.length + ghosts.length + 1
    );

    ghost.wasSingleJie = !!method.isSingleJie;

    ghosts.push(ghost);
  }
}

3. 修改 commitGhost()

commitGhost(index) 中,找到:

javascript
let gp = ghosts[index];

緊接著加入:

javascript
const sourceMethod = boardGraph.methods.get(gp.mId);

gp.wasSingleJie = !!(
  sourceMethod &&
  sourceMethod.isSingleJie
);

並確保後面把 gp 放入 tempPieces 時,不要刪掉

javascript
gp.mId
gp.targetId
gp.targetEdge
gp.myEdge
gp.wasSingleJie

這些欄位是第二手預放、MCTS 單接更新、雙接合法性判斷所需資料。


4. 在真正確認落子後掛接增量更新

尋找 actionCheck() 內部真正把 tempPieces 提交進 pieces 的位置。

通常會接近下列形式:

javascript
lastTurnPieces = tempPieces.slice();
pieces.push(...tempPieces);
tempPieces = [];

請改為:

javascript
const committedForGraph = tempPieces.map(tgClonePiece);

lastTurnPieces = tempPieces.slice();
pieces.push(...tempPieces);
tempPieces = [];

// 必須放在 pieces 與 piecesCount 都已更新之後。
tgOnCommittedPieces(committedForGraph);

不要在 actionCheck() 裡再呼叫舊的
updateIncrementalGraph()updateIncrementalGraphLocal()computeAllLifeStatus()incrementalAddPiece()


5. 重置、悔棋、重做、匯入棋譜後的修改

在下列函式的最後、盤面已恢復後加入:

  • resetGame()
  • undo()
  • redo()
  • confirmImport() 或你的匯入棋譜函式

加入:

javascript
tgInvalidateBoardGraph();
tgEnsureBoardGraphSync();

這些操作不是「新增一子」,而是盤面回跳;應全盤重建,不應硬套增量更新。


6. 新增:MCTS 與廣度優先 Minimax 搜尋核心

插入位置: 放在 startAI() 前面。

javascript
// ============================================================================
// AI 搜尋狀態與完整回合候選
// ============================================================================

function tgCloneState(state) {
  return {
    pieces: state.pieces.map(tgClonePiece),
    stock: { ...state.stock },
    scores: { ...state.scores },
    currentPlayer: state.currentPlayer,
    nextVirtualId: state.nextVirtualId,
    graph: tgCloneGraph(state.graph)
  };
}

function tgSearchStateFromBoard() {
  tgEnsureBoardGraphSync();

  const state = tgBoardState();
  state.graph = tgCloneGraph(boardGraph);

  let maxNumericId = 0;

  for (const piece of state.pieces) {
    const numeric = Number(piece.id);
    if (Number.isFinite(numeric)) {
      maxNumericId = Math.max(maxNumericId, numeric);
    }
  }

  state.nextVirtualId = maxNumericId + 1;

  return state;
}

function tgRemainingPiecesOf(state, player) {
  return tgTilesOfPlayer(player).reduce(
    (sum, tileId) => sum + tgStockOf(state, tileId),
    0
  );
}

function tgPiecesRequiredThisTurn(state, player) {
  const remaining = tgRemainingPiecesOf(state, player);

  if (remaining <= 1) return 1;

  // 先手方開局第一回合只下一子。
  if (
    state.pieces.length === 0 &&
    player === openingPlayerThisGame
  ) {
    return 1;
  }

  return 2;
}

function tgIsLaterPlayerLastSingle(state, player) {
  const laterPlayer = tgOtherPlayer(openingPlayerThisGame);

  return (
    player === laterPlayer &&
    tgRemainingPiecesOf(state, player) === 1
  );
}

function tgMethodsForPlayer(state, player) {
  return Array.from(state.graph.methods.values()).filter(method => {
    if (method.owner !== player) return false;
    if (!tgEnoughForOne(state, method)) return false;

    return isValidGhost(
      tgGhostFromMethod(method, `available:${method.id}`),
      state.pieces
    );
  });
}

function tgMaterializePiece(ghost, id) {
  return {
    ...ghost,
    id,
    vertices: ghost.vertices.map(tgCopyRingPoint),

    parentId: ghost.targetId,
    targetId: ghost.targetId,
    targetEdge: ghost.targetEdge,
    edgeOnOpp: ghost.targetEdge
  };
}

function tgAppendOne(state, ghost) {
  if (tgStockOf(state, ghost.svgId) <= 0) return null;

  const next = tgCloneState(state);

  const piece = tgMaterializePiece(
    ghost,
    `sim:${next.nextVirtualId++}`
  );

  if (!isValidGhost(piece, next.pieces)) return null;

  const gain = tgScoreGain(next, [piece]);

  next.pieces.push(piece);
  next.stock[piece.svgId]--;

  next.scores[1] += gain[1];
  next.scores[2] += gain[2];

  /*
   * 單接的第一子:
   * 這裡立刻更新整盤死活、活法、雙接、共活,
   * 第二子必須從 next.graph 中取得。
   */
  next.graph = tgIncrementalUpdate(next, next.graph, [piece]);

  return {
    state: next,
    piece,
    gain
  };
}

function tgAppendPairAfterTurn(state, ghostA, ghostB) {
  if (ghostA.svgId === ghostB.svgId) {
    if (tgStockOf(state, ghostA.svgId) < 2) return null;
  } else {
    if (
      tgStockOf(state, ghostA.svgId) < 1 ||
      tgStockOf(state, ghostB.svgId) < 1
    ) {
      return null;
    }
  }

  const next = tgCloneState(state);

  const pieceA = tgMaterializePiece(
    ghostA,
    `sim:${next.nextVirtualId++}`
  );

  const pieceB = tgMaterializePiece(
    ghostB,
    `sim:${next.nextVirtualId++}`
  );

  if (!isValidGhost(pieceA, next.pieces)) return null;

  if (!isValidGhost(pieceB, next.pieces.concat([pieceA]))) {
    return null;
  }

  const gain = tgScoreGain(next, [pieceA, pieceB]);

  next.pieces.push(pieceA, pieceB);
  next.stock[pieceA.svgId]--;
  next.stock[pieceB.svgId]--;

  next.scores[1] += gain[1];
  next.scores[2] += gain[2];

  /*
   * 雙接、共活:
   * 兩子均確認後才更新圖,不在兩子之間更新。
   */
  next.graph = tgIncrementalUpdate(
    next,
    next.graph,
    [pieceA, pieceB]
  );

  return {
    state: next,
    pieces: [pieceA, pieceB],
    gain
  };
}

/*
 * plan.kind:
 * - 'single':第一子單接;第一子後立即更新圖,再下第二子。
 * - 'double':兩活法雙接;整回合完成後才更新圖。
 * - 'colive':同一目標棋子的共活兩子;整回合完成後才更新圖。
 * - 'last-single':最後一手單子。
 */
function tgApplyTurnPlan(state, plan) {
  if (!plan || !plan.placements || plan.placements.length === 0) {
    return null;
  }

  let result = null;

  if (plan.placements.length === 1) {
    result = tgAppendOne(state, plan.placements[0]);

    if (!result) return null;

    result.state.currentPlayer = tgOtherPlayer(state.currentPlayer);
    return result;
  }

  if (plan.kind === 'single') {
    const first = tgAppendOne(state, plan.placements[0]);

    if (!first) return null;

    const second = tgAppendOne(first.state, plan.placements[1]);

    if (!second) return null;

    second.state.currentPlayer = tgOtherPlayer(state.currentPlayer);
    return second;
  }

  result = tgAppendPairAfterTurn(
    state,
    plan.placements[0],
    plan.placements[1]
  );

  if (!result) return null;

  result.state.currentPlayer = tgOtherPlayer(state.currentPlayer);
  return result;
}

function tgMakeSinglePlans(state, player, methods, requiredPieces) {
  const plans = [];

  for (const method of methods) {
    const firstGhost = tgGhostFromMethod(
      method,
      `plan-single-first:${method.id}`
    );

    if (requiredPieces === 1) {
      plans.push({
        kind: 'single',
        placements: [firstGhost],
        priority: method.jieScore
      });

      continue;
    }

    /*
     * 必須先模擬第一個單接並更新圖,
     * 然後才從更新後的活法選第二手。
     */
    const afterFirst = tgAppendOne(state, firstGhost);

    if (!afterFirst) continue;

    const secondMethods = tgMethodsForPlayer(afterFirst.state, player);

    for (const secondMethod of secondMethods) {
      const secondGhost = tgGhostFromMethod(
        secondMethod,
        `plan-single-second:${secondMethod.id}`
      );

      if (!isValidGhost(secondGhost, afterFirst.state.pieces)) {
        continue;
      }

      plans.push({
        kind: 'single',
        placements: [firstGhost, secondGhost],
        priority: method.jieScore
      });
    }
  }

  return plans;
}

function tgDoubleRecords(state, player, scoringOnly) {
  const records = [];
  const seen = new Set();

  for (const methodA of tgMethodsForPlayer(state, player)) {
    for (const [methodBId, info] of methodA.doubleWith.entries()) {
      const key = tgPairKey(methodA.id, methodBId);

      if (seen.has(key)) continue;
      seen.add(key);

      const methodB = state.graph.methods.get(methodBId);

      if (!methodB) continue;
      if (methodB.owner !== player) continue;
      if (!info.compatible) continue;

      if (scoringOnly && (info.gain[player] || 0) <= 0) {
        continue;
      }

      records.push({
        methodA,
        methodB,
        info
      });
    }
  }

  return records;
}

function tgMakeDoublePlans(state, player, scoringOnly) {
  const plans = [];

  for (const record of tgDoubleRecords(state, player, scoringOnly)) {
    const ghostA = tgGhostFromMethod(
      record.methodA,
      `plan-double-a:${record.methodA.id}`
    );

    const ghostB = tgGhostFromMethod(
      record.methodB,
      `plan-double-b:${record.methodB.id}`
    );

    plans.push({
      kind: 'double',
      placements: [ghostA, ghostB],
      priority: record.info.gain[player] || 0
    });
  }

  return plans;
}

function tgMakeCoLivePlans(state, player) {
  const plans = [];
  const seen = new Set();

  for (const methodA of tgMethodsForPlayer(state, player)) {
    for (const methodBId of methodA.coLiveWith.values()) {
      const key = tgPairKey(methodA.id, methodBId);

      if (seen.has(key)) continue;
      seen.add(key);

      const methodB = state.graph.methods.get(methodBId);

      if (!methodB) continue;
      if (methodB.owner !== player) continue;

      plans.push({
        kind: 'colive',
        placements: [
          tgGhostFromMethod(methodA, `plan-colive-a:${methodA.id}`),
          tgGhostFromMethod(methodB, `plan-colive-b:${methodB.id}`)
        ],
        priority: 0
      });
    }
  }

  return plans;
}

function tgHasCoLivePlan(state, player) {
  return tgMakeCoLivePlans(state, player).length > 0;
}

/*
 * 仲裁成功條件:
 * 只有「恰好一方」沒有共活下法時,沒有共活的一方才可仲裁成功。
 */
function tgCanClaimArbitration(state, claimant) {
  const opponent = tgOtherPlayer(claimant);

  const claimantHasCoLive = tgHasCoLivePlan(state, claimant);
  const opponentHasCoLive = tgHasCoLivePlan(state, opponent);

  return !claimantHasCoLive && opponentHasCoLive;
}

/*
 * MCTS 與 Minimax 均不得選擇:
 * 讓對方下回合完全沒有共活下法的落子。
 */
function tgFilterPlansKeepingOpponentCoLive(state, player, plans) {
  const opponent = tgOtherPlayer(player);
  const safe = [];

  for (const plan of plans) {
    const applied = tgApplyTurnPlan(state, plan);

    if