QuanAI只读快照

共享会话

🔄 重寫MCTS與Minimax算法

分享于 2026年8月15日 02:16
提问者

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

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

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

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

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

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

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

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

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

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

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

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

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

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

回答要求:

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

trigolden_game2462claude.htmltext/plain · 201.7 KB预览下载
QuanAI

以下修改以你提供的 HTML 為準;舊的「步驟一~四」候選生成、舊 MCTS、舊 minimax,以及舊的死活/活法追蹤區塊都不要保留,否則會出現同名函式或舊邏輯仍被呼叫的問題。


1. 補充訊息字典

const i18n = { ... } 中,將原本的 ai-mcts-progress 替換,並加入以下項目:

js
        'ai-tracking': {
          zh: 'AI 正在完成全盤死活/活法/單接/雙接/共活統計…',
          en: 'AI is completing board life/method/single/double/co-live statistics…'
        },
        'ai-mcts-progress': {
          zh: '蒙地卡羅樹搜尋 | 層數: {depth} | 用時: {time}s | 節點: {count}',
          en: 'MCTS | Depth: {depth} | Time: {time}s | Nodes: {count}'
        },
        'ai-mcts-done': {
          zh: 'MCTS 完成 | 總用時: {time}s | 節點: {count} | 勝率: {rate}%',
          en: 'MCTS done | Total: {time}s | Nodes: {count} | Win rate: {rate}%'
        },
        'ai-no-safe-move': {
          zh: '所有合規下法都會令對方下回合失去共活;AI 不會違反限制而落子,已切換為人類操作。',
          en: 'Every legal move removes the opponent’s next co-live. AI will not violate the constraint; control is now human.'
        },

2. 取代 formsTriGoldensimulateScoringTriGolden

找到原本這兩個函式:

js
function formsTriGolden(A, B) { ... }
function simulateScoringTriGolden(allNewP, pIds) { ... }

完整替換為:

js
      function formsTriGolden(A, B, allPieces = pieces.concat(tempPieces)) {
        return (
          getUsableDingContacts(A, B, allPieces).length > 0 ||
          getUsableDingContacts(B, A, allPieces).length > 0
        );
      }

      function simulateScoringTriGolden(allNewP, pIds) {
        const idSet = new Set(pIds);
        const candidatePieces = allNewP.filter(p => idSet.has(p.id));
        const basePieces = allNewP.filter(p => !idSet.has(p.id));

        const result = scoreGainForCandidates(basePieces, candidatePieces, new Set(scoredVictims));
        return result.claimedKeys.length > 0;
      }

3. 修改真正計分時的「貼死邊不可形成頂鑫」

actionCheck() 裡,找到這一段:

js
            let dingVertices = [];
            for (let v of A.vertices) {
              let isDing = false;
              for (let e = 0; e < 4; e++) {
                if (pointOnOpenSegment(v, B.vertices[e], B.vertices[(e + 1) % 4])) {
                  isDing = true;
                  break;
                }
              }
              if (isDing) dingVertices.push(v);
            }

替換成:

js
            // 貼死邊不可再作為鑫邊形成頂鑫。
            let dingVertices = getUsableDingVertices(A, B, allNewP);

這會讓:

  • 貼死 的邊不再計算頂鑫;
  • AI 的單接/雙接得分判斷;
  • 人類實際落子後的計分;

三者使用一致規則。


4. 完整取代舊的「死活/活法/互頂追蹤系統」

找到從這個註解開始的整段:

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

一直刪除到、但不包含原本的:

js
      function getAllValidMoves(player) {

然後貼入以下完整區塊。

js
      // =============================================================================
      // 死活/活法/單接/雙接/共活:增量追蹤系統
      // =============================================================================

      const DEAD_EDGE_KIND = Object.freeze({
        glued: '貼死',
        poked: '頂死',
        clamped: '夾死',
        suffocated: '悶死'
      });

      const EDGE_LENGTH_KEYS = [1, 2, 'phi', 'phi2', 'twoPhi'];

      let lifeState = null;
      let edgeRecords = new Map();
      let methodRegistry = new Map();
      let nextMethodId = 1;
      let liveTrackingReady = false;
      let trackingGeneration = 0;
      let lifeTrackingUpdatePromise = Promise.resolve();

      function edgeKey(pieceId, edgeIdx) {
        return `${pieceId}_${edgeIdx}`;
      }

      function pointKey(p) {
        return p.join('_');
      }

      function cloneBoardPiece(piece) {
        return {
          ...piece,
          vertices: piece.vertices.map(v => [...v])
        };
      }

      function placementSignature(piece) {
        return [
          piece.svgId,
          piece.isFlipped ? 1 : 0,
          piece.targetId,
          piece.targetEdge,
          piece.myEdge,
          piece.vertices.map(pointKey).join('|')
        ].join(':');
      }

      function stockForBoard(board) {
        const stock = {
          tile0: N_PIECES,
          tile1: N_PIECES,
          tile2: N_PIECES,
          tile3: N_PIECES,
          tile4: N_PIECES,
          tile5: N_PIECES
        };

        for (const p of board) {
          if (Object.prototype.hasOwnProperty.call(stock, p.svgId)) {
            stock[p.svgId]--;
          }
        }
        return stock;
      }

      function isFloatingVertex(ghost, vertexIndex) {
        const attachedA = ghost.myEdge;
        const attachedB = (ghost.myEdge + 1) % 4;
        return vertexIndex !== attachedA && vertexIndex !== attachedB;
      }

      function getExactEdgeInfo(S, E) {
        const vector = ringSub(E, S);

        for (const length of EDGE_LENGTH_KEYS) {
          for (let angle = 0; angle < 10; angle++) {
            if (pointsEqual(vector, edgeVector(length, angle))) {
              return { length, angle };
            }
          }
        }
        return null;
      }

      function sharedEndpointData(S1, E1, S2, E2) {
        if (pointsEqual(S1, S2)) return { shared: S1, free1: E1, free2: E2 };
        if (pointsEqual(S1, E2)) return { shared: S1, free1: E1, free2: S2 };
        if (pointsEqual(E1, S2)) return { shared: E1, free1: S1, free2: E2 };
        if (pointsEqual(E1, E2)) return { shared: E1, free1: S1, free2: S2 };
        return null;
      }

      function edgeIsClamped(S1, E1, S2, E2) {
        const data = sharedEndpointData(S1, E1, S2, E2);
        if (!data) return false;

        const a = getExactEdgeInfo(data.shared, data.free1);
        const b = getExactEdgeInfo(data.shared, data.free2);
        if (!a || !b) return false;

        let delta = Math.abs(a.angle - b.angle) % 10;
        delta = Math.min(delta, 10 - delta);

        // 36°:兩邊都夾死。
        if (delta === 1) return true;

        // 72°:只有長度 1 的邊夾死。
        if (delta === 2 && a.length === 1) return true;

        return false;
      }

      function isGluedEdgeOnBoard(piece, edgeIdx, allPieces) {
        const S = piece.vertices[edgeIdx];
        const E = piece.vertices[(edgeIdx + 1) % 4];

        for (const other of allPieces) {
          if (other.id === piece.id) continue;

          for (let j = 0; j < 4; j++) {
            const S2 = other.vertices[j];
            const E2 = other.vertices[(j + 1) % 4];

            if (segmentsPerfectlyMatch(S, E, S2, E2)) return true;
          }
        }
        return false;
      }

      function getUsableDingContacts(A, B, allPieces) {
        const contacts = [];

        for (let vi = 0; vi < 4; vi++) {
          const vertex = A.vertices[vi];

          for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
            const S = B.vertices[edgeIdx];
            const E = B.vertices[(edgeIdx + 1) % 4];

            if (
              pointOnOpenSegment(vertex, S, E) &&
              !isGluedEdgeOnBoard(B, edgeIdx, allPieces)
            ) {
              contacts.push({
                dinger: A,
                victim: B,
                vertex,
                vertexIndex: vi,
                victimEdge: edgeIdx,
                key: `${B.id}_${pointKey(vertex)}`
              });
              break;
            }
          }
        }
        return contacts;
      }

      function getUsableDingVertices(A, B, allPieces) {
        return getUsableDingContacts(A, B, allPieces).map(c => c.vertex);
      }

      function scoreGainForCandidates(basePieces, candidatePieces, alreadyScored = new Set(), options = {}) {
        const allPieces = basePieces.concat(candidatePieces);
        const candidateIds = new Set(candidatePieces.map(p => p.id));
        const dingMap = new Map();
        const rawContacts = [];
        let formed = false;

        for (const A of allPieces) {
          for (const B of allPieces) {
            if (A.id === B.id) continue;

            const aIsNew = candidateIds.has(A.id);
            const bIsNew = candidateIds.has(B.id);

            if (options.onlyBetweenCandidates) {
              if (!aIsNew || !bIsNew) continue;
            } else if (!aIsNew && !bIsNew) {
              continue;
            }

            const contacts = getUsableDingContacts(A, B, allPieces);

            for (const contact of contacts) {
              formed = true;
              rawContacts.push(contact);

              if (alreadyScored.has(contact.key)) continue;

              const treeRes = getTreeDistance(A, B, allPieces);
              if (!treeRes || treeRes.dist < 0) continue;

              if (!dingMap.has(contact.key)) dingMap.set(contact.key, []);
              dingMap.get(contact.key).push({
                ...contact,
                dist: treeRes.dist,
                path: treeRes.path
              });
            }
          }
        }

        const gain = { 1: 0, 2: 0 };
        const claimedKeys = [];

        dingMap.forEach((dings, key) => {
          let best = null;

          for (const d of dings) {
            if (!best || d.dist < best.dist) best = d;
          }

          if (!best) return;

          // 與 actionCheck 的行為一致:即使距離為 0,
          // 這個鑫點也會被標記為已處理,之後不可重複計分。
          claimedKeys.push(key);

          if (best.dist <= 0) return;

          let scorer = 0;
          if (SCORING_MODE === 0) scorer = best.dinger.owner;
          else if (SCORING_MODE === 1) scorer = best.victim.owner;
          else if (SCORING_MODE === 2 && best.dinger.owner === best.victim.owner) {
            scorer = best.dinger.owner;
          }

          if (scorer > 0) gain[scorer] += best.dist;
        });

        return {
          formed,
          gain,
          claimedKeys,
          contacts: rawContacts
        };
      }

      // 相容於其他既有呼叫點。
      function simulateScoreGain(candidatePieces) {
        return scoreGainForCandidates(pieces, candidatePieces, new Set(scoredVictims)).gain;
      }

      function classifyDeadEdge(piece, edgeIdx, allPieces) {
        const S = piece.vertices[edgeIdx];
        const E = piece.vertices[(edgeIdx + 1) % 4];

        // 1. 貼死
        if (isGluedEdgeOnBoard(piece, edgeIdx, allPieces)) {
          return DEAD_EDGE_KIND.glued;
        }

        // 2. 頂死
        for (const other of allPieces) {
          if (other.id === piece.id) continue;

          for (const v of other.vertices) {
            if (pointOnOpenSegment(v, S, E)) {
              return DEAD_EDGE_KIND.poked;
            }
          }
        }

        // 3. 夾死
        for (const other of allPieces) {
          if (other.id === piece.id) continue;

          for (let j = 0; j < 4; j++) {
            const S2 = other.vertices[j];
            const E2 = other.vertices[(j + 1) % 4];

            if (edgeIsClamped(S, E, S2, E2)) {
              return DEAD_EDGE_KIND.clamped;
            }
          }
        }

        // 4. 悶死
        return DEAD_EDGE_KIND.suffocated;
      }

      function edgeTouchesNewPiece(piece, edgeIdx, newPiece) {
        const S = piece.vertices[edgeIdx];
        const E = piece.vertices[(edgeIdx + 1) % 4];

        for (const v of newPiece.vertices) {
          if (pointsEqual(v, S) || pointsEqual(v, E) || pointOnOpenSegment(v, S, E)) {
            return true;
          }
        }

        for (let j = 0; j < 4; j++) {
          const S2 = newPiece.vertices[j];
          const E2 = newPiece.vertices[(j + 1) % 4];

          if (
            segmentsOverlapAsEdges(S, E, S2, E2) ||
            edgeIsClamped(S, E, S2, E2)
          ) {
            return true;
          }
        }

        return false;
      }

      function ghostIsInvalidatedByPiece(ghost, newPiece) {
        if (shapesOverlap(ghost, newPiece)) return true;

        for (let i = 0; i < 4; i++) {
          const S1 = ghost.vertices[i];
          const E1 = ghost.vertices[(i + 1) % 4];

          for (let j = 0; j < 4; j++) {
            const S2 = newPiece.vertices[j];
            const E2 = newPiece.vertices[(j + 1) % 4];

            if (segmentsOverlapAsEdges(S1, E1, S2, E2)) {
              return true;
            }
          }
        }

        // 只檢查懸空頂點;貼合邊的兩個端點不屬於懸空頂點。
        for (let vi = 0; vi < 4; vi++) {
          if (!isFloatingVertex(ghost, vi)) continue;

          for (const v2 of newPiece.vertices) {
            if (pointsEqual(ghost.vertices[vi], v2)) return true;
          }
        }

        return false;
      }

      class LifeState {
        constructor(board = [], stock = null, scoredKeys = null) {
          this.board = [...board];
          this.stock = stock ? { ...stock } : stockForBoard(board);
          this.scoredKeys = scoredKeys ? new Set(scoredKeys) : new Set();

          this.edgeRecords = new Map();
          this.methods = new Map();

          this.nextMethodId = 1;
          this.nextSyntheticPieceId = -1000000000;
          this.ready = false;
        }

        clone() {
          const copy = new LifeState([], {}, new Set());
          copy.board = this.board.map(cloneBoardPiece);
          copy.stock = { ...this.stock };
          copy.scoredKeys = new Set(this.scoredKeys);
          copy.nextMethodId = this.nextMethodId;
          copy.nextSyntheticPieceId = this.nextSyntheticPieceId;
          copy.ready = this.ready;

          for (const [key, rec] of this.edgeRecords) {
            copy.edgeRecords.set(key, {
              ...rec,
              methodIds: [...rec.methodIds]
            });
          }

          for (const [id, method] of this.methods) {
            const cloned = {
              ...method,
              ghost: cloneBoardPiece(method.ghost),
              single: {
                ...method.single,
                scoreByPlayer: { ...method.single.scoreByPlayer },
                contacts: method.single.contacts.map(c => ({ ...c }))
              },
              doubleJie: new Map(),
              mutual: null,
              coLive: new Map()
            };

            copy.methods.set(id, cloned);
          }

          for (const [id, method] of this.methods) {
            const cloned = copy.methods.get(id);

            method.doubleJie.forEach((link, otherId) => {
              cloned.doubleJie.set(otherId, {
                ...link,
                scoreByPlayer: { ...link.scoreByPlayer },
                contacts: link.contacts.map(c => ({ ...c }))
              });
            });

            cloned.mutual = cloned.doubleJie;
            cloned.coLive = new Map(method.coLive);
          }

          return copy;
        }

        getPlayerTiles(player) {
          return [0, 1, 2].map(n => `tile${player === 1 ? n : n + 3}`);
        }

        playerRemaining(player) {
          return this.getPlayerTiles(player).reduce((sum, tile) => sum + Math.max(0, this.stock[tile] || 0), 0);
        }

        materializeGhost(ghost) {
          const p = cloneBoardPiece(ghost);
          p.id = this.nextSyntheticPieceId--;
          return p;
        }

        enumerateEdgeMethods(piece, edgeIdx) {
          const methods = [];
          const forPlayer = piece.owner === 1 ? 2 : 1;
          const tiles = this.getPlayerTiles(forPlayer).filter(tile => (this.stock[tile] || 0) > 0);
          const seen = new Set();

          for (const tile of tiles) {
            for (const flip of [false, true]) {
              for (let myEdge = 0; myEdge < 4; myEdge++) {
                const res = attachByEdge(
                  SHAPE_MAP[tile],
                  flip,
                  myEdge,
                  piece.vertices[edgeIdx],
                  piece.vertices[(edgeIdx + 1) % 4],
                  !!piece.isFlipped
                );

                if (!res) continue;

                const ghost = {
                  id: -1,
                  vertices: res.vertices,
                  type: SHAPE_MAP[tile],
                  owner: forPlayer,
                  svgId: tile,
                  isFlipped: flip,
                  edgeOnOpp: edgeIdx,
                  targetId: piece.id,
                  targetEdge: edgeIdx,
                  myEdge,
                  parentId: piece.id,
                  level: (piece.level || 0) + 1
                };

                const sig = placementSignature(ghost);
                if (seen.has(sig)) continue;
                seen.add(sig);

                if (isValidGhost(ghost, this.board)) {
                  methods.push(ghost);
                }
              }
            }
          }

          return methods;
        }

        removeMethod(methodId) {
          const method = this.methods.get(methodId);
          if (!method) return;

          method.doubleJie.forEach((_, otherId) => {
            const other = this.methods.get(otherId);
            if (other) other.doubleJie.delete(methodId);
          });

          method.coLive.forEach((_, otherId) => {
            const other = this.methods.get(otherId);
            if (other) other.coLive.delete(methodId);
          });

          const rec = this.edgeRecords.get(edgeKey(method.ownerEdgePieceId, method.edgeIdx));
          if (rec) {
            rec.methodIds = rec.methodIds.filter(id => id !== methodId);
          }

          this.methods.delete(methodId);
        }

        buildEdgeRecord(piece, edgeIdx) {
          const key = edgeKey(piece.id, edgeIdx);
          const old = this.edgeRecords.get(key);

          if (old) {
            old.methodIds.slice().forEach(id => this.removeMethod(id));
          }

          const ghosts = this.enumerateEdgeMethods(piece, edgeIdx);

          const record = {
            pieceId: piece.id,
            edgeIdx,
            owner: piece.owner,
            status: ghosts.length > 0 ? 'live' : 'dead',
            life: ghosts.length > 0 ? '活邊' : '死邊',
            deadKind: ghosts.length > 0 ? null : classifyDeadEdge(piece, edgeIdx, this.board),
            methodIds: []
          };

          for (const ghost of ghosts) {
            const id = this.nextMethodId++;
            ghost.id = -100000 - id;

            const method = {
              id,
              ownerEdgePieceId: piece.id,
              edgeIdx,
              forPlayer: ghost.owner,
              ghost,

              isSingleJie: false,
              jieScore: 0,
              jieScoreOpp: 0,

              single: {
                formed: false,
                scoreByPlayer: { 1: 0, 2: 0 },
                contacts: []
              },

              // partnerId -> { scoreByPlayer, contacts, ... }
              doubleJie: new Map(),
              mutual: null,

              // partnerId -> true
              coLive: new Map()
            };

            method.mutual = method.doubleJie;

            this.methods.set(id, method);
            record.methodIds.push(id);
          }

          this.edgeRecords.set(key, record);
          return record;
        }

        pairIsLegal(methodA, methodB, requireSameTarget = false) {
          if (!methodA || !methodB) return false;
          if (methodA.id === methodB.id) return false;
          if (methodA.forPlayer !== methodB.forPlayer) return false;

          if (
            methodA.ghost.svgId === methodB.ghost.svgId &&
            (this.stock[methodA.ghost.svgId] || 0) < 2
          ) {
            return false;
          }

          if (
            methodA.targetId === methodB.targetId &&
            methodA.targetEdge === methodB.targetEdge
          ) {
            return false;
          }

          if (requireSameTarget) {
            if (methodA.ownerEdgePieceId !== methodB.ownerEdgePieceId) return false;
            if (methodA.edgeIdx === methodB.edgeIdx) return false;
          }

          const a = cloneBoardPiece(methodA.ghost);
          const b = cloneBoardPiece(methodB.ghost);

          const orderAB =
            isValidGhost(a, this.board) &&
            isValidGhost(b, this.board.concat([a]));

          const orderBA =
            isValidGhost(b, this.board) &&
            isValidGhost(a, this.board.concat([b]));

          return orderAB || orderBA;
        }

        refreshSingle(method) {
          const result = scoreGainForCandidates(this.board, [method.ghost], this.scoredKeys);

          method.single = {
            formed: result.formed,
            scoreByPlayer: { ...result.gain },
            contacts: result.contacts.map(c => ({
              victimId: c.victim.id,
              victimEdge: c.victimEdge,
              key: c.key
            }))
          };

          method.isSingleJie = result.formed;
          method.jieScore = result.gain[method.forPlayer];
          method.jieScoreOpp = result.gain[method.forPlayer === 1 ? 2 : 1];
        }

        evaluateDouble(methodA, methodB) {
          if (!this.pairIsLegal(methodA, methodB, false)) return null;

          const result = scoreGainForCandidates(
            this.board,
            [methodA.ghost, methodB.ghost],
            this.scoredKeys,
            { onlyBetweenCandidates: true }
          );

          if (!result.formed) return null;

          return {
            scoreByPlayer: { ...result.gain },
            contacts: result.contacts.map(c => ({
              victimId: c.victim.id,
              victimEdge: c.victimEdge,
              key: c.key
            }))
          };
        }

        setDouble(methodA, methodB, result) {
          methodA.doubleJie.set(methodB.id, {
            partnerId: methodB.id,
            scoreByPlayer: { ...result.scoreByPlayer },
            contacts: result.contacts.map(c => ({ ...c }))
          });

          methodB.doubleJie.set(methodA.id, {
            partnerId: methodA.id,
            scoreByPlayer: { ...result.scoreByPlayer },
            contacts: result.contacts.map(c => ({ ...c }))
          });
        }

        addDoubleIfAny(methodA, methodB) {
          if (!methodA || !methodB) return;
          if (methodA.doubleJie.has(methodB.id)) return;

          const result = this.evaluateDouble(methodA, methodB);
          if (result) this.setDouble(methodA, methodB, result);
        }

        refreshExistingDoubles() {
          const pairs = [];

          this.methods.forEach(method => {
            method.doubleJie.forEach((_, otherId) => {
              if (method.id < otherId) pairs.push([method.id, otherId]);
            });
          });

          for (const [aId, bId] of pairs) {
            const a = this.methods.get(aId);
            const b = this.methods.get(bId);
            if (!a || !b) continue;

            const result = this.evaluateDouble(a, b);

            if (!result) {
              a.doubleJie.delete(bId);
              b.doubleJie.delete(aId);
            } else {
              this.setDouble(a, b, result);
            }
          }
        }

        updateCoLiveForPiece(pieceId) {
          const ids = [];

          for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
            const rec = this.edgeRecords.get(edgeKey(pieceId, edgeIdx));

            if (rec && rec.status === 'live') {
              ids.push(...rec.methodIds);
            }
          }

          for (const id of ids) {
            const method = this.methods.get(id);
            if (!method) continue;

            for (const otherId of [...method.coLive.keys()]) {
              const other = this.methods.get(otherId);
              if (other) other.coLive.delete(id);
            }

            method.coLive.clear();
          }

          for (let i = 0; i < ids.length; i++) {
            const a = this.methods.get(ids[i]);
            if (!a) continue;

            for (let j = i + 1; j < ids.length; j++) {
              const b = this.methods.get(ids[j]);
              if (!b) continue;

              // 共活必須在同一棋子、不同邊上,且完全沿用仲裁的兩子合法性判定。
              if (this.pairIsLegal(a, b, true)) {
                a.coLive.set(b.id, true);
                b.coLive.set(a.id, true);
              }
            }
          }
        }

        rebuild() {
          this.edgeRecords.clear();
          this.methods.clear();
          this.nextMethodId = 1;

          for (const piece of this.board) {
            for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
              this.buildEdgeRecord(piece, edgeIdx);
            }
          }

          this.methods.forEach(method => this.refreshSingle(method));

          const methods = [...this.methods.values()];
          for (let i = 0; i < methods.length; i++) {
            for (let j = i + 1; j < methods.length; j++) {
              this.addDoubleIfAny(methods[i], methods[j]);
            }
          }

          for (const piece of this.board) {
            this.updateCoLiveForPiece(piece.id);
          }

          this.ready = true;
        }

        pieceGluedStoredContact(newPiece, contact) {
          const victim = this.board.find(p => p.id === contact.victimId);
          if (!victim || victim.id === newPiece.id) return false;

          const S = victim.vertices[contact.victimEdge];
          const E = victim.vertices[(contact.victimEdge + 1) % 4];

          for (let i = 0; i < 4; i++) {
            if (
              segmentsPerfectlyMatch(
                S,
                E,
                newPiece.vertices[i],
                newPiece.vertices[(i + 1) % 4]
              )
            ) {
              return true;
            }
          }

          return false;
        }

        addPlacedPiece(piece) {
          const placed = cloneBoardPiece(piece);

          if (this.board.some(p => p.id === placed.id)) return;

          if (Object.prototype.hasOwnProperty.call(this.stock, placed.svgId)) {
            this.stock[placed.svgId] = Math.max(0, this.stock[placed.svgId] - 1);
          }

          const affectedPieceIds = new Set([placed.id]);

          // 棋子庫存耗盡後,對應類型的活法立即失效。
          for (const method of [...this.methods.values()]) {
            if (
              method.forPlayer === placed.owner &&
              (this.stock[method.ghost.svgId] || 0) <= 0
            ) {
              affectedPieceIds.add(method.ownerEdgePieceId);
              this.removeMethod(method.id);
            }
          }

          // 同一方少了一枚棋,所有該方的共活可能因庫存變化而失效。
          this.methods.forEach(method => {
            if (method.forPlayer === placed.owner) {
              affectedPieceIds.add(method.ownerEdgePieceId);
            }
          });

          this.board.push(placed);

          // 只移除被新棋子破壞的既有活法,不重新暴力枚舉全部舊邊。
          for (const record of this.edgeRecords.values()) {
            if (record.pieceId === placed.id) continue;

            if (record.status === 'live') {
              const invalidIds = [];

              for (const methodId of record.methodIds) {
                const method = this.methods.get(methodId);
                if (!method) continue;

                if (ghostIsInvalidatedByPiece(method.ghost, placed)) {
                  invalidIds.push(methodId);
                }
              }

              if (invalidIds.length > 0) {
                affectedPieceIds.add(record.pieceId);
                invalidIds.forEach(id => this.removeMethod(id));
              }

              if (record.methodIds.length === 0) {
                const ownerPiece = this.board.find(p => p.id === record.pieceId);
                record.status = 'dead';
                record.life = '死邊';
                record.deadKind = classifyDeadEdge(ownerPiece, record.edgeIdx, this.board);
              }
            } else if (edgeTouchesNewPiece(
              this.board.find(p => p.id === record.pieceId),
              record.edgeIdx,
              placed
            )) {
              const ownerPiece = this.board.find(p => p.id === record.pieceId);
              record.deadKind = classifyDeadEdge(ownerPiece, record.edgeIdx, this.board);
            }
          }

          // 新棋子的四邊建立新活法。
          const newMethodIds = [];

          for (let edgeIdx = 0; edgeIdx < 4; edgeIdx++) {
            const record = this.buildEdgeRecord(placed, edgeIdx);

            if (record.status === 'live') {
              newMethodIds.push(...record.methodIds);
            }
          }

          // 新棋子可能令舊活法出現/失去單接。
          const singleRefreshIds = new Set(newMethodIds);

          this.methods.forEach(method => {
            if (
              formsTriGolden(method.ghost, placed, this.board) ||
              method.single.contacts.some(contact =>
                this.pieceGluedStoredContact(placed, contact)
              )
            ) {
              singleRefreshIds.add(method.id);
            }
          });

          singleRefreshIds.forEach(id => {
            const method = this.methods.get(id);
            if (method) this.refreshSingle(method);
          });

          // 舊雙接只重新驗證既存連結;不重新做全盤 M² 枚舉。
          this.refreshExistingDoubles();

          // 新活法只與已存在活法做增量配對。
          for (const id of newMethodIds) {
            const method = this.methods.get(id);
            if (!method) continue;

            this.methods.forEach(other => {
              if (other.id === method.id) return;
              this.addDoubleIfAny(method, other);
            });
          }

          // 只重算受影響棋子與新棋子的共活。
          affectedPieceIds.forEach(pieceId => this.updateCoLiveForPiece(pieceId));

          this.ready = true;
        }

        refreshScoreLabels() {
          this.methods.forEach(method => this.refreshSingle(method));
          this.refreshExistingDoubles();
        }

        getLiveMethods(player) {
          return [...this.methods.values()].filter(
            method =>
              method.forPlayer === player &&
              (this.stock[method.ghost.svgId] || 0) > 0
          );
        }

        getScoringSingles(player) {
          return this.getLiveMethods(player).filter(
            method => method.single.scoreByPlayer[player] > 0
          );
        }

        getScoringDoubles(player) {
          const result = [];
          const seen = new Set();

          this.methods.forEach(method => {
            if (method.forPlayer !== player) return;

            method.doubleJie.forEach((link, otherId) => {
              const key = method.id < otherId
                ? `${method.id}_${otherId}`
                : `${otherId}_${method.id}`;

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

              const other = this.methods.get(otherId);
              if (!other) return;

              if ((link.scoreByPlayer[player] || 0) > 0) {
                result.push({ a: method, b: other, link });
              }
            });
          });

          return result;
        }

        getCoLiveMoves(player) {
          const result = [];
          const seen = new Set();

          this.methods.forEach(method => {
            if (method.forPlayer !== player) return;

            method.coLive.forEach((_, otherId) => {
              const other = this.methods.get(otherId);
              if (!other) return;

              const key = method.id < otherId
                ? `${method.id}_${otherId}`
                : `${otherId}_${method.id}`;

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

              result.push({
                kind: 'coLive',
                methodIds: [method.id, other.id],
                targetId: method.targetId,
                ghosts: [cloneBoardPiece(method.ghost), cloneBoardPiece(other.ghost)]
              });
            });
          });

          return result;
        }

        hasCoLive(player) {
          return this.getCoLiveMoves(player).length > 0;
        }
      }

      function syncLifeTrackingAliases() {
        edgeRecords = lifeState ? lifeState.edgeRecords : new Map();
        methodRegistry = lifeState ? lifeState.methods : new Map();
        nextMethodId = lifeState ? lifeState.nextMethodId : 1;
        liveTrackingReady = !!(lifeState && lifeState.ready);
      }

      function lifeStateMatchesBoard() {
        if (!lifeState) return false;
        if (lifeState.board.length !== pieces.length) return false;

        return lifeState.board.every(p =>
          pieces.some(realPiece => realPiece.id === p.id)
        );
      }

      function resetLifeTracking() {
        lifeState = null;
        edgeRecords = new Map();
        methodRegistry = new Map();
        nextMethodId = 1;
        liveTrackingReady = false;
        trackingGeneration++;
        lifeTrackingUpdatePromise = Promise.resolve();
      }

      function computeAllLifeStatus() {
        lifeState = new LifeState(
          pieces,
          stockForBoard(pieces),
          new Set(scoredVictims)
        );

        lifeState.rebuild();
        syncLifeTrackingAliases();
      }

      function incrementalAddPiece(newPiece) {
        if (!lifeState || !lifeState.ready) {
          computeAllLifeStatus();
          return;
        }

        lifeState.scoredKeys = new Set(scoredVictims);

        if (!lifeState.board.some(p => p.id === newPiece.id)) {
          lifeState.addPlacedPiece(newPiece);
        }

        lifeState.refreshScoreLabels();
        syncLifeTrackingAliases();
      }

      function ensureLifeStatsReady() {
        if (!liveTrackingReady || !lifeStateMatchesBoard()) {
          computeAllLifeStatus();
        }
      }

      function scheduleLifeTrackingUpdate(newPieces) {
        const generation = trackingGeneration;

        lifeTrackingUpdatePromise = lifeTrackingUpdatePromise.then(
          () =>
            new Promise(resolve => {
              setTimeout(() => {
                if (generation !== trackingGeneration) {
                  resolve();
                  return;
                }

                if (!lifeTrackingReady || !lifeState) {
                  computeAllLifeStatus();
                  resolve();
                  return;
                }

                lifeState.scoredKeys = new Set(scoredVictims);

                for (const piece of newPieces) {
                  if (!lifeState.board.some(p => p.id === piece.id)) {
                    lifeState.addPlacedPiece(piece);
                  }
                }

                if (!lifeStateMatchesBoard()) {
                  computeAllLifeStatus();
                } else {
                  lifeState.refreshScoreLabels();
                  syncLifeTrackingAliases();
                }

                resolve();
              }, 0);
            })
        );

        return lifeTrackingUpdatePromise;
      }

5. 取代 getAllValidMoves(player)

將原本暴力枚舉版本的:

js
function getAllValidMoves(player) {
   ...
}

完整替換為以下版本:

js
      function getAllValidMoves(player) {
        ensureLifeStatsReady();

        // 仲裁與共活使用完全相同的「兩活法能否同時合規放置」判定:
        // 面積不重疊、邊不重合、懸空頂點不重合、兩子貼同一對方棋子的不同邊。
        return lifeState.getCoLiveMoves(player).map(move => [
          cloneBoardPiece(move.ghosts[0]),
          cloneBoardPiece(move.ghosts[1]),
          move.targetId
        ]);
      }

這樣:

  • executeArbitration()
  • 共活統計;
  • MCTS 的「不可令對方失去共活」限制;

會使用同一個來源。


6. 取代 generateGhosts(),讓人類預放棋子直接使用「活法」

將原本 generateGhosts() 完整替換為:

js
      function generateGhosts() {
        ghosts = [];

        if (!selectedTile || !targetOpponentPieceId) return;

        // 若上一手的非同步增量統計尚未完成,這裡立即補齊。
        ensureLifeStatsReady();

        const seen = new Set();

        methodRegistry.forEach(method => {
          if (method.forPlayer !== currentPlayer) return;
          if (method.targetId !== targetOpponentPieceId) return;
          if (method.ghost.svgId !== selectedTile) return;
          if (method.ghost.isFlipped !== isFlipped[selectedTile]) return;

          const ghost = cloneBoardPiece(method.ghost);

          // 若本回合已暫放第一子,第二子仍須相對於 tempPieces 合規。
          if (!isValidGhost(ghost, pieces.concat(tempPieces))) return;

          const sig = placementSignature(ghost);
          if (seen.has(sig)) return;
          seen.add(sig);

          ghosts.push(ghost);
        });
      }

這同時修正原程式中 generateGhosts() 內同一個 ghost 被 push 兩次的問題。


7. 完整取代舊的 MCTS/Minimax 區塊

找到:

js
      // --- MCTS 與 Minimax 核心引擎 ---

從這裡開始,刪除到、但不包含

js
      function renderBoard() {

然後貼入以下程式。

js
      // =============================================================================
      // 新 MCTS/Minimax:以死活、單接、雙接、共活為唯一候選來源
      // =============================================================================

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

      function isSecondSide(player) {
        // 第一局:P2 是後手;第二局:P1 是後手。
        return player !== startingPlayer;
      }

      function lifeRemaining(state, player) {
        return state.life.playerRemaining(player);
      }

      function isSinglePieceTurn(state, player) {
        return lifeRemaining(state, player) === 1;
      }

      function planSignature(plan) {
        const placements = plan.ghosts.map(placementSignature);

        // 單接的第一手有語義,不能排序。
        if (plan.kind !== 'single') placements.sort();

        return `${plan.kind}:${placements.join('||')}`;
      }

      function uniquePlans(plans) {
        const seen = new Set();

        return plans.filter(plan => {
          const key = planSignature(plan);
          if (seen.has(key)) return false;
          seen.add(key);
          return true;
        });
      }

      class SearchPosition {
        constructor(life, player, score) {
          this.life = life;
          this.player = player;
          this.score = { ...score };
        }

        clone() {
          return new SearchPosition(
            this.life.clone(),
            this.player,
            this.score
          );
        }

        applyPlan(plan) {
          const beforeBoard = this.life.board.map(cloneBoardPiece);
          const beforeScored = new Set(this.life.scoredKeys);
          let staged = plan.ghosts.map(g => this.life.materializeGhost(g));

          if (staged.length === 0) return null;

          if (staged.length === 1) {
            if (!isValidGhost(staged[0], this.life.board)) return null;
            this.life.addPlacedPiece(staged[0]);
          } else if (plan.kind === 'single') {
            // 單接:第一手先落下並完成增量更新,才選擇/驗證第二手。
            if (!isValidGhost(staged[0], this.life.board)) return null;
            this.life.addPlacedPiece(staged[0]);

            if (!isValidGhost(staged[1], this.life.board)) return null;
            this.life.addPlacedPiece(staged[1]);
          } else {
            // 雙接/共活:兩手先確定,之後才更新追蹤。
            const ab =
              isValidGhost(staged[0], this.life.board) &&
              isValidGhost(staged[1], this.life.board.concat([staged[0]]));

            const ba =
              isValidGhost(staged[1], this.life.board) &&
              isValidGhost(staged[0], this.life.board.concat([staged[1]]));

            if (!ab && !ba) return null;
            if (!ab) staged = [staged[1], staged[0]];

            this.life.addPlacedPiece(staged[0]);
            this.life.addPlacedPiece(staged[1]);
          }

          // 同一回合的實際得分,以兩手棋完成後的最終盤面為準。
          const scoreResult = scoreGainForCandidates(
            beforeBoard,
            staged,
            beforeScored
          );

          scoreResult.claimedKeys.forEach(key => this.life.scoredKeys.add(key));
          this.life.refreshScoreLabels();

          this.score[1] += scoreResult.gain[1];
          this.score[2] += scoreResult.gain[2];

          this.player = otherPlayer(this.player);

          return {
            pieces: staged,
            gain: scoreResult.gain
          };
        }
      }

      function makeSinglePiecePlans(position, player) {
        return position.life.getScoringSingles(player).map(method => ({
          kind: 'single-piece',
          methodIds: [method.id],
          ghosts: [cloneBoardPiece(method.ghost)]
        }));
      }

      function makeScoringTurnPlans(position) {
        const player = position.player;

        // 最後一子只能下一枚。
        if (isSinglePieceTurn(position, player)) {
          return makeSinglePiecePlans(position, player);
        }

        const plans = [];

        // 單接:
        // 第一子落下後先做增量更新,再從更新後的盤面找第二子。
        for (const singleMethod of position.life.getScoringSingles(player)) {
          const afterFirst = position.clone();
          const first = afterFirst.life.materializeGhost(singleMethod.ghost);

          if (!isValidGhost(first, afterFirst.life.board)) continue;

          afterFirst.life.addPlacedPiece(first);

          for (const secondMethod of afterFirst.life.getLiveMethods(player)) {
            const plan = {
              kind: 'single',
              methodIds: [singleMethod.id, secondMethod.id],
              ghosts: [
                cloneBoardPiece(singleMethod.ghost),
                cloneBoardPiece(secondMethod.ghost)
              ]
            };

            // 確保兩手完成後仍然真的令本方得分。
            const trial = position.clone();
            const applied = trial.applyPlan(plan);

            if (applied && applied.gain[player] > 0) {
              plans.push(plan);
            }
          }
        }

        // 雙接:
        // 兩個活法本身構成頂鑫;選定兩手後才做狀態更新。
        for (const pair of position.life.getScoringDoubles(player)) {
          const plan = {
            kind: 'double',
            methodIds: [pair.a.id, pair.b.id],
            ghosts: [
              cloneBoardPiece(pair.a.ghost),
              cloneBoardPiece(pair.b.ghost)
            ]
          };

          const trial = position.clone();
          const applied = trial.applyPlan(plan);

          if (applied && applied.gain[player] > 0) {
            plans.push(plan);
          }
        }

        return uniquePlans(plans);
      }

      function makeCoLivePlans(position) {
        return uniquePlans(
          position.life.getCoLiveMoves(position.player).map(move => ({
            kind: 'coLive',
            methodIds: [...move.methodIds],
            ghosts: move.ghosts.map(cloneBoardPiece)
          }))
        );
      }

      function rawTurnChoices(position) {
        const player = position.player;
        const scoringPlans = makeScoringTurnPlans(position);

        if (scoringPlans.length > 0) {
          return {
            kind: 'score',
            hasScoringSingle: position.life.getScoringSingles(player).length > 0,
            plans: scoringPlans
          };
        }

        if (isSinglePieceTurn(position, player)) {
          return {
            kind: 'live',
            hasScoringSingle: false,
            plans: position.life.getLiveMethods(player).map(method => ({
              kind: 'single-piece',
              methodIds: [method.id],
              ghosts: [cloneBoardPiece(method.ghost)]
            }))
          };
        }

        return {
          kind: 'coLive',
          hasScoringSingle: false,
          plans: makeCoLivePlans(position)
        };
      }

      function opponentCanContinue(position, opponent) {
        const remaining = lifeRemaining(position, opponent);

        // 對方已無棋,無須再檢查下一回合。
        if (remaining <= 0) return true;

        // 後手最後一子只需要一個活法。
        if (remaining === 1) {
          return position.life.getLiveMethods(opponent).length > 0;
        }

        return position.life.hasCoLive(opponent);
      }

      function scoringThreatSet(life, player) {
        const threats = new Set();

        for (const method of life.getScoringSingles(player)) {
          threats.add(`S:${method.id}`);
        }

        for (const pair of life.getScoringDoubles(player)) {
          const a = Math.min(pair.a.id, pair.b.id);
          const b = Math.max(pair.a.id, pair.b.id);
          threats.add(`D:${a}:${b}`);
        }

        return threats;
      }

      function mctsPlans(position) {
        const player = position.player;
        const opponent = otherPlayer(player);
        const raw = rawTurnChoices(position);

        if (raw.plans.length === 0) return [];

        const safe = [];

        // 強制規則:
        // MCTS 不得落下令對方下回合沒有共活的棋。
        for (const plan of raw.plans) {
          const child = position.clone();
          const applied = child.applyPlan(plan);

          if (!applied) continue;
          if (!opponentCanContinue(child, opponent)) continue;

          safe.push(plan);
        }

        // 不可因為「沒有安全步」而退回危險步。
        if (safe.length === 0) return [];

        // 己方有得分單接/雙接時,直接優先。
        if (raw.kind === 'score') return safe;

        // 沒有己方得分單接/雙接時:
        // 從共活中優先破壞對方得分單接/雙接,
        // 且不產生新的對方得分單接/雙接。
        const beforeThreats = scoringThreatSet(position.life, opponent);
        const noNewThreat = [];
        const disrupting = [];

        for (const plan of safe) {
          const child = position.clone();
          const applied = child.applyPlan(plan);
          if (!applied) continue;

          const afterThreats = scoringThreatSet(child.life, opponent);

          const createsNew = [...afterThreats].some(key => !beforeThreats.has(key));
          if (createsNew) continue;

          noNewThreat.push(plan);

          const destroysOld = [...beforeThreats].some(key => !afterThreats.has(key));
          if (destroysOld) disrupting.push(plan);
        }

        if (disrupting.length > 0) return disrupting;
        return noNewThreat;
      }

      function minimaxPlans(position) {
        const raw = rawTurnChoices(position);
        const player = position.player;

        // 後手方的最後一手:
        // 沒有能為自己得分的單接時,只隨機下一個活法。
        if (
          isSecondSide(player) &&
          isSinglePieceTurn(position, player) &&
          !raw.hasScoringSingle &&
          raw.kind === 'live' &&
          raw.plans.length > 0
        ) {
          return [
            raw.plans[Math.floor(Math.random() * raw.plans.length)]
          ];
        }

        // Minimax:
        // 有得分下法就只展開全部得分下法;
        // 沒有得分下法才展開全部共活。
        return raw.plans;
      }

      function evaluatePosition(position, rootPlayer) {
        const opponent = otherPlayer(rootPlayer);
        return position.score[rootPlayer] - position.score[opponent];
      }

      class MctsNode {
        constructor(position, parent = null, move = null, depth = 0) {
          this.position = position;
          this.parent = parent;
          this.move = move;
          this.depth = depth;

          this.visits = 0;
          this.value = 0;
          this.children = [];
          this.untried = null;
        }
      }

      function selectMctsChild(node, rootPlayer) {
        const maximizing = node.position.player === rootPlayer;
        let best = null;
        let bestScore = -Infinity;

        for (const child of node.children) {
          const mean = child.value / Math.max(1, child.visits);
          const exploit = maximizing ? mean : 1 - mean;
          const explore =
            MCTS_C *
            Math.sqrt(Math.log(Math.max(1, node.visits)) / Math.max(1, child.visits));

          const score = exploit + explore;

          if (!best || score > bestScore) {
            best = child;
            bestScore = score;
          }
        }

        return best;
      }

      function mctsValue(position, rootPlayer) {
        const diff = evaluatePosition(position, rootPlayer);
        return 1 / (1 + Math.exp(-diff / 3));
      }

      async function runMcts(rootPosition, rootPlayer, timeLimitMs, searchStart) {
        const root = new MctsNode(rootPosition.clone());
        let count = 0;
        let maxDepth = 0;

        while (
          !cancelAi &&
          performance.now() - searchStart < timeLimitMs
        ) {
          let node = root;

          // Selection + Expansion
          while (!cancelAi) {
            if (node.untried === null) {
              node.untried = mctsPlans(node.position);
            }

            if (node.untried.length > 0) {
              const index = Math.floor(Math.random() * node.untried.length);
              const move = node.untried.splice(index, 1)[0];

              const childPosition = node.position.clone();
              if (!childPosition.applyPlan(move)) continue;

              const child = new MctsNode(
                childPosition,
                node,
                move,
                node.depth + 1
              );

              node.children.push(child);
              node = child;
              maxDepth = Math.max(maxDepth, node.depth);
              break;
            }

            if (node.children.length === 0) break;

            node = selectMctsChild(node, rootPlayer);
          }

          // Rollout
          let rollout = node.position.clone();
          let rolloutDepth = node.depth;

          while (
            !cancelAi &&
            performance.now() - searchStart < timeLimitMs &&
            lifeRemaining(rollout, 1) + lifeRemaining(rollout, 2) > 0
          ) {
            const plans = mctsPlans(rollout);
            if (plans.length === 0) break;

            const plan = plans[Math.floor(Math.random() * plans.length)];
            const applied = rollout.applyPlan(plan);
            if (!applied) break;

            rolloutDepth++;
            maxDepth = Math.max(maxDepth, rolloutDepth);

            if (rolloutDepth % 4 === 0) {
              await aiMaybeYield(
                t('ai-mcts-progress', {
                  depth: maxDepth,
                  time: ((performance.now() - searchStart) / 1000).toFixed(1),
                  count
                }),
                80
              );
            }
          }

          const value = mctsValue(rollout, rootPlayer);

          // Backpropagation
          while (node) {
            node.visits++;
            node.value += value;
            node = node.parent;
          }

          count++;

          if (count % 8 === 0) {
            await aiMaybeYield(
              t('ai-mcts-progress', {
                depth: maxDepth,
                time: ((performance.now() - searchStart) / 1000).toFixed(1),
                count
              }),
              80
            );
          }
        }

        if (root.children.length === 0) {
          return {
            plan: null,
            value: 0,
            count,
            depth: maxDepth
          };
        }

        const best = root.children.reduce((bestChild, child) => {
          if (!bestChild) return child;

          if (child.visits > bestChild.visits) return child;
          if (child.visits === bestChild.visits && child.value > bestChild.value) return child;

          return bestChild;
        }, null);

        return {
          plan: best.move,
          value: best.value / Math.max(1, best.visits),
          count,
          depth: maxDepth
        };
      }

      async function runBreadthFirstMinimax(rootPosition, rootPlayer, searchStart) {
        const root = {
          position: rootPosition.clone(),
          parent: null,
          move: null,
          children: [],
          depth: 0,
          noMove: false
        };

        let frontier = [root];
        let count = 0;
        let depth = 0;

        while (frontier.length > 0 && !cancelAi) {
          depth++;

          await aiYield(
            t('ai-minimax-progress', {
              depth,
              time: ((performance.now() - searchStart) / 1000).toFixed(1)
            })
          );

          const nextFrontier = [];

          for (const node of frontier) {
            if (cancelAi) break;

            const plans = minimaxPlans(node.position);

            if (plans.length === 0) {
              node.noMove = true;
              continue;
            }

            for (const plan of plans) {
              const childPosition = node.position.clone();
              const applied = childPosition.applyPlan(plan);

              if (!applied) continue;

              const child = {
                position: childPosition,
                parent: node,
                move: plan,
                children: [],
                depth,
                noMove: false
              };

              node.children.push(child);
              nextFrontier.push(child);
              count++;

              if (count % 24 === 0) {
                await aiMaybeYield(
                  t('ai-minimax-progress', {
                    depth,
                    time: ((performance.now() - searchStart) / 1000).toFixed(1)
                  }),
                  50
                );
              }
            }
          }

          frontier = nextFrontier;
        }

        function leafValue(node) {
          const remaining =
            lifeRemaining(node.position, 1) +
            lifeRemaining(node.position, 2);

          // 無任何合法回合選項時,輪到的一方可仲裁獲勝。
          if (node.noMove && remaining > 0) {
            return node.position.player === rootPlayer ? 100000 : -100000;
          }

          return evaluatePosition(node.position, rootPlayer);
        }

        function solve(node) {
          if (node.children.length === 0) return leafValue(node);

          const values = node.children.map(solve);

          return node.position.player === rootPlayer
            ? Math.max(...values)
            : Math.min(...values);
        }

        const value = solve(root);

        let bestChild = null;
        let bestValue = -Infinity;

        for (const child of root.children) {
          const childValue = solve(child);

          if (!bestChild || childValue > bestValue) {
            bestChild = child;
            bestValue = childValue;
          }
        }

        return {
          plan: bestChild ? bestChild.move : null,
          value,
          count,
          depth
        };
      }

      function commitAIPlan(plan) {
        const selected = plan.ghosts.map(cloneBoardPiece);
        const myTiles = [0, 1, 2].map(n => `tile${currentPlayer === 1 ? n : n + 3}`);
        const remaining = myTiles.reduce((sum, tile) => sum + piecesCount[tile], 0);
        const required = remaining === 1 ? 1 : 2;

        if (selected.length !== required) return false;

        for (const piece of selected) {
          if ((piecesCount[piece.svgId] || 0) <= 0) return false;
        }

        selected.forEach(piece => {
          piece.id = nextPieceId++;
          piecesCount[piece.svgId]--;
        });

        tempPieces = selected;
        selectedTile = null;
        targetOpponentPieceId = null;
        ghosts = [];

        updateUI();
        renderBoard();

        setTimeout(() => actionCheck(), 600);
        return true;
      }

      function aiTriggerArbitrationSuccess() {
        document.getElementById('message-box').style.display = 'none';
        originalActionCross(false);

        arbValidMoves = getAllValidMoves(currentPlayer);
        arbEnded = true;
        currentDialogMode = 'arbitration';

        saveState({
          newPieces: [],
          arbSuccess: arbValidMoves.length === 0
        });

        document.getElementById('ui-res-msg').innerText = i18n['arb-success'][currentLang];
        document.getElementById('ui-res-ways').innerText = i18n['arb-ways'][currentLang].replace('{n}', 0);

        updateUI();
        renderBoard();
        document.getElementById('arb-result-dialog').style.display = 'flex';
      }

      function placeAIFirstMove(player) {
        const myTiles = [0, 1, 2].map(n => `tile${player === 1 ? n : n + 3}`);
        const available = myTiles.filter(tile => piecesCount[tile] > 0);
        if (available.length === 0) return false;

        const svgId = available[Math.floor(Math.random() * available.length)];
        const flip = Math.random() < 0.5;
        const angle = Math.floor(Math.random() * 360) + 1;

        boardTransform.angle = angle;
        updateTransform();

        const shape = SHAPES[SHAPE_MAP[svgId]];
        const { vertices } = buildShapeVertices(ZERO, 0, shape, flip);

        tempPieces = [{
          id: nextPieceId++,
          owner: player,
          type: SHAPE_MAP[svgId],
          svgId,
          isFlipped: flip,
          vertices,
          parentId: null,
          level: 0,
          boardAngle: angle
        }];

        piecesCount[svgId]--;

        updateUI();
        renderBoard();

        setTimeout(() => actionCheck(), 600);
        return true;
      }

      function checkAndTriggerAI() {
        if (
          aiConfig[currentPlayer] &&
          !aiThinking &&
          currentDialogMode === 'playing' &&
          tempPieces.length === 0
        ) {
          startAI();
        }
      }

      const originalActionCross = actionCross;
      actionCross = function (that) {
        if (aiThinking && aiConfig[currentPlayer]) {
          cancelAi = true;
          aiConfig[currentPlayer] = false;
          document.getElementById(`ai-p${currentPlayer}-type`).value = 'human';
          updateAIConfig();
          showMessage(t('ai-stopped'), 2000, true);
          return;
        }

        originalActionCross(that);
      };

      const originalActionCheck = actionCheck;
      actionCheck = function (that) {
        originalActionCheck(that);
        setTimeout(checkAndTriggerAI, 1500);
      };

      async function startAI() {
        aiThinking = true;
        cancelAi = false;
        lastAIYieldTime = 0;

        updateUI();
        showMessage(t('ai-thinking-ellipsis'), 0, true);

        const aiPlayer = currentPlayer;

        try {
          // 開局第一子。
          if (turnNumber === 1 && pieces.length === 0 && tempPieces.length === 0) {
            placeAIFirstMove(aiPlayer);
            aiThinking = false;
            document.getElementById('message-box').style.display = 'none';
            return;
          }

          // 統計未完成時,先完成;這段不計入 MCTS 限時。
          await aiYield(t('ai-tracking'));
          await lifeTrackingUpdatePromise;
          ensureLifeStatsReady();

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

          const rootPosition = new SearchPosition(
            lifeState.clone(),
            aiPlayer,
            scores
          );

          const threshold = aiConfig.settings[aiPlayer].n || 3;
          const turnsLeft = Math.ceil(
            (lifeRemaining(rootPosition, 1) + lifeRemaining(rootPosition, 2)) / 2
          );

          const useMinimax = turnsLeft <= threshold;
          const raw = rawTurnChoices(rootPosition);

          if (raw.plans.length === 0) {
            aiThinking = false;
            aiTriggerArbitrationSuccess();
            return;
          }

          let result;
          const searchStart = performance.now();

          if (useMinimax) {
            result = await runBreadthFirstMinimax(
              rootPosition,
              aiPlayer,
              searchStart
            );

            const elapsed = ((performance.now() - searchStart) / 1000).toFixed(1);

            showMessage(
              t('ai-done', {
                time: elapsed,
                count: result.count,
                val: t('ai-score-label', { score: result.value })
              }),
              3000,
              true
            );
          } else {
            const timeLimitMs = Math.max(
              50,
              (aiConfig.settings[aiPlayer].time || 3) * 1000
            );

            result = await runMcts(
              rootPosition,
              aiPlayer,
              timeLimitMs,
              searchStart
            );

            const elapsed = ((performance.now() - searchStart) / 1000).toFixed(1);

            showMessage(
              t('ai-mcts-done', {
                time: elapsed,
                count: result.count,
                rate: (result.value * 100).toFixed(1)
              }),
              3000,
              true
            );
          }

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

          if (!result || !result.plan) {
            // MCTS 的強制限制不允許用危險步保底。
            aiThinking = false;
            aiConfig[aiPlayer] = false;
            document.getElementById(`ai-p${aiPlayer}-type`).value = 'human';
            updateUI();
            showMessage(t('ai-no-safe-move'), 5000, true);
            return;
          }

          aiThinking = false;
          commitAIPlan(result.plan);
        } catch (err) {
          console.error(err);
          aiThinking = false;
          updateUI();

          showMessage(
            currentLang === 'zh'
              ? 'AI 搜尋發生錯誤,已切換為人類操作。'
              : 'AI search failed; control is now human.',
            4000,
            true
          );
        }
      }

8. 修改 actionCheck() 最後的增量更新呼叫

原本最後有:

js
scheduleLifeTrackingUpdate(lastTurnPieces, pieces);

改成:

js
scheduleLifeTrackingUpdate(lastTurnPieces);

因為新的追蹤器會自行保存自己的棋盤快照,不需要再傳入 pieces


9. Undo / Redo 後立刻重建統計

undo() 裡,設定完:

js
scoredVictims = new Set(prevState.scoredVictims);

以及所有 piecescurrentPlayerturnNumber 還原完成後、updateUI() 前,加入:

js
          computeAllLifeStatus();

redo() 裡,設定完:

js
scoredVictims = new Set(nextState.scoredVictims);

以及所有狀態還原完成後、updateUI() 前,同樣加入:

js
          computeAllLifeStatus();

10. 新局開始時初始化空盤統計

resetGame() 的最後:

js
        updateTransform();
        updateUI();
        renderBoard();

後面加入:

js
        // 空盤也保持統計狀態已完成;
        // 第一子落下後會直接走增量更新。
        computeAllLifeStatus();

修改後的資料結構

每條邊:

js
edgeRecords.get(edgeKey(pieceId, edgeIdx))

會得到例如:

js
{
  pieceId: 12,
  edgeIdx: 2,
  owner: 1,
  status: 'live',       // 或 'dead'
  life: '活邊',         // 或 '死邊'
  deadKind: null,       // 貼死/頂死/夾死/悶死
  methodIds: [31, 32]
}

每個活法:

js
methodRegistry.get(methodId)

會包含:

js
{
  id: 31,
  forPlayer: 2,
  ghost: { ... },

  // 單接
  isSingleJie: true,
  single: {
    formed: true,
    scoreByPlayer: { 1: 0, 2: 3 },
    contacts: [...]
  },

  // 雙接:partnerMethodId -> 得分資料
  doubleJie: Map(...),

  // 共活:partnerMethodId -> true
  coLive: Map(...)
}

這套替換後會符合:

  • 活邊/死邊與四種死法;
  • 長度為 1 的邊在 72° 時才夾死;
  • 貼死邊不再形成頂鑫;
  • 新棋只增量刪除被破壞的活法、建立新棋四邊活法;
  • 活法的單接、雙接、共活均互記 ID;
  • 單接第一子後先更新狀態才找第二子;
  • 雙接兩子決定後才更新;
  • MCTS 不會用「讓對方無共活」的危險步保底;
  • minimax 以廣度優先展開,先完整展開得分步,沒有得分步才展開共活;
  • 後手最後一子若無得分單接,隨機選一個活法;
  • 訊息框會持續顯示 MCTS/minimax 的層數與用時。