QuanAI只读快照

共享会话

🧠 AI 棋力結構修復

分享于 2026年8月16日 09:26
提问者

目前 AI 棋力弱,有以下三個結構性原因:

搜索盤面與計分狀態沒有被完整封裝,假設盤面的對手得分經常算錯。 MCTS 深層每節點通常只探索一個隨機戰術候選,並沒有真的比較對手所有單接/雙接。 根節點和深層節點都以硬性優先序刪除大量候選,搜尋無法推翻錯誤啟發式。

修復:

建立完整 SearchState 按回合更新假設 scoredVictims 用假設盤面生成並評估對方完整合法回合 在 MCTS 前加入 2~3 ply 的全單接/全雙接戰術搜索 把硬篩選改成排序/progressive widening 統一人類、AI、仲裁與搜索的合法性判定

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

trigolden_game261.htmltext/plain · 249.8 KB预览下载
QuanAI

我會先分段讀取整份 HTML,定位目前的盤面狀態、計分、合法性判定、候選生成與 MCTS 實作,再逐處給出可直接替換的代碼。

下面的修改會把「真實對局」與「搜尋中的假設對局」統一到同一套狀態、計分及合法性引擎,並完成以下修正:

  1. SearchState 完整保存盤面、剩餘棋子、分數、scoredVictims、行動方及計分模式。
  2. 每次假設回合落子後,立即更新假設分數和假設 scoredVictims
  3. Minimax/MCTS 深層節點生成該盤面的全部合法單子/雙子回合。
  4. MCTS 前增加完整單接/雙接的 2~3 ply 戰術搜索。
  5. 不再用單接、雙接、共活等硬性優先序刪除候選,只用排序。
  6. MCTS 使用 progressive widening。
  7. 人類確認、AI、仲裁、導入棋譜及搜尋共用 validateTurnOnState()generateLegalTurns()

一、加入統一的 SearchState、計分及合法性引擎

修改位置

找到:

js
      // 取得單一棋子的所有合法落子方式(用於「本方只剩最後一手棋」的情況,只需落下一個棋子)

在這段註解之前插入以下完整代碼:

js
      // =============================================================================
      // 統一 SearchState/合法回合/假設計分引擎
      // =============================================================================

      const SEARCH_TERMINAL_VALUE = 1000000;
      const MCTS_PW_K = 1.8;
      const MCTS_PW_ALPHA = 0.55;

      class SearchTimeout extends Error {
        constructor() {
          super('SEARCH_TIMEOUT');
        }
      }

      function getPlayerTileIds(player) {
        return [0, 1, 2].map(i => 'tile' + (player === 1 ? i : i + 3));
      }

      function cloneSearchState(state) {
        return {
          boardPieces: state.boardPieces.slice(),
          counts: { ...state.counts },
          scores: { ...state.scores },
          scoredVictims: new Set(state.scoredVictims),
          mover: state.mover,
          turnNumber: state.turnNumber,
          scoringMode: state.scoringMode,
          startingPlayer: state.startingPlayer,
          gameNumber: state.gameNumber,
          lastTurnPieces: state.lastTurnPieces.slice(),
          nextHypId: state.nextHypId
        };
      }

      /*
       * piecesCount 在人類暫存落子時已經扣掉 tempPieces。
       * SearchState 的 boardPieces 不包含 tempPieces,因此建立「落子前狀態」時
       * 必須把 tempPieces 的數量加回去。
       */
      function createSearchStateFromGlobals(mover = currentPlayer) {
        const counts = { ...piecesCount };

        tempPieces.forEach(piece => {
          counts[piece.svgId] = (counts[piece.svgId] || 0) + 1;
        });

        let nextHypId = -1;
        for (const piece of pieces) {
          if (typeof piece.id === 'number' && piece.id < 0) {
            nextHypId = Math.min(nextHypId, piece.id - 1);
          }
        }

        return {
          boardPieces: pieces.slice(),
          counts,
          scores: { ...scores },
          scoredVictims: new Set(scoredVictims),
          mover,
          turnNumber,
          scoringMode: SCORING_MODE,
          startingPlayer,
          gameNumber,
          lastTurnPieces: lastTurnPieces.slice(),
          nextHypId
        };
      }

      function createSearchStateForBoard(mover, boardPieces, extraHypPieces = []) {
        const counts = { ...piecesCount };

        tempPieces.forEach(piece => {
          counts[piece.svgId] = (counts[piece.svgId] || 0) + 1;
        });

        /*
         * boardPieces 通常已經包含 extraHypPieces;這裡只用 extraHypPieces
         * 扣除搜尋路徑已經消耗的棋子數。
         */
        extraHypPieces.forEach(piece => {
          if (counts[piece.svgId] !== undefined) {
            counts[piece.svgId]--;
          }
        });

        let nextHypId = -1;
        for (const piece of boardPieces) {
          if (typeof piece.id === 'number' && piece.id < 0) {
            nextHypId = Math.min(nextHypId, piece.id - 1);
          }
        }

        return {
          boardPieces: boardPieces.slice(),
          counts,
          scores: { ...scores },
          scoredVictims: new Set(scoredVictims),
          mover,
          turnNumber,
          scoringMode: SCORING_MODE,
          startingPlayer,
          gameNumber,
          lastTurnPieces: lastTurnPieces.slice(),
          nextHypId
        };
      }

      function getStateRemainingTotal(state, player) {
        return getPlayerTileIds(player).reduce((sum, tileId) => {
          return sum + Math.max(0, state.counts[tileId] || 0);
        }, 0);
      }

      function isSearchGameComplete(state) {
        return Object.values(state.counts).every(count => count <= 0);
      }

      function getRequiredPiecesForState(state) {
        if (state.boardPieces.length === 0) return 1;
        return getStateRemainingTotal(state, state.mover) === 1 ? 1 : 2;
      }

      function exactVerticesEqual(a, b) {
        return (
          a &&
          b &&
          a.length === b.length &&
          a.every((vertex, index) => pointsEqual(vertex, b[index]))
        );
      }

      function piecePlacementSignature(piece) {
        return [
          piece.svgId,
          piece.isFlipped ? 1 : 0,
          piece.targetId ?? 'root',
          piece.myEdge ?? 'root',
          piece.targetEdge ?? piece.edgeOnOpp ?? 'root',
          piece.vertices.map(vertex => vertex.join(',')).join('/')
        ].join('|');
      }

      function turnSignature(move) {
        return move
          .map(piecePlacementSignature)
          .sort()
          .join('||');
      }

      /*
       * 純計分函式。
       *
       * 重要:
       * 1. 使用 state.scoredVictims,而不是全域 scoredVictims。
       * 2. 不改寫 state。
       * 3. 回傳本回合應新增的 scoredVictims key。
       * 4. 同一鑫棋端點有多個頂棋時,仍取樹距離最近者。
       */
      function adjudicateSearchTurn(state, placedPieces) {
        const allPieces = state.boardPieces.concat(placedPieces);
        const idMap = new Map();
        const newIds = new Set(placedPieces.map(piece => piece.id));
        const dingMap = new Map();

        allPieces.forEach(piece => {
          preparePieceSAT(piece);
          idMap.set(piece.id, piece);
        });

        for (const dinger of allPieces) {
          for (const victim of allPieces) {
            if (dinger.id === victim.id) continue;
            if (checkSATCollision(getSAT(dinger), getSAT(victim)) === 'separated') continue;

            const dingVertices = [];

            for (const vertex of dinger.vertices) {
              let touchesEdge = false;

              for (let edgeIndex = 0; edgeIndex < 4; edgeIndex++) {
                if (
                  pointOnOpenSegment(
                    vertex,
                    victim.vertices[edgeIndex],
                    victim.vertices[(edgeIndex + 1) % 4]
                  )
                ) {
                  touchesEdge = true;
                  break;
                }
              }

              if (touchesEdge) dingVertices.push(vertex);
            }

            if (dingVertices.length === 0) continue;

            const treeResult = getTreeDistance(dinger, victim, allPieces, idMap);
            if (!treeResult || treeResult.dist < 0) continue;

            for (const vertex of dingVertices) {
              const key = victim.id + '_' + vertex.join('_');
              if (state.scoredVictims.has(key)) continue;

              if (!dingMap.has(key)) dingMap.set(key, []);

              dingMap.get(key).push({
                dinger,
                victim,
                dist: treeResult.dist,
                path: treeResult.path
              });
            }
          }
        }

        const gain = { 1: 0, 2: 0 };
        const newScoredKeys = [];
        const records = [];
        const rings = [];

        for (const [key, candidates] of dingMap) {
          let best = null;

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

          if (!best) continue;

          let scorer = 0;

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

          const involvesNew =
            newIds.has(best.dinger.id) || newIds.has(best.victim.id);

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

            rings.push({
              path: best.path,
              scorer
            });
          }

          /*
           * 與正式 actionCheck 原規則一致:
           * 即使距離為 0,只要已選出該端點的頂棋,也要封存該 key。
           */
          newScoredKeys.push(key);

          records.push({
            key,
            dinger: best.dinger,
            victim: best.victim,
            dist: best.dist,
            score: best.dist,
            scorer,
            path: best.path,
            involvesNew
          });
        }

        return {
          gain,
          newScoredKeys,
          records,
          rings,
          hasScoringStructure: records.some(
            record => record.involvesNew && record.score > 0
          )
        };
      }

      function validateOpeningPiece(state, piece) {
        if (piece.owner !== state.mover) {
          return { ok: false, reason: 'wrong-owner' };
        }

        if (!piece.svgId || !SHAPE_MAP[piece.svgId]) {
          return { ok: false, reason: 'invalid-piece-type' };
        }

        if ((state.counts[piece.svgId] || 0) <= 0) {
          return { ok: false, reason: 'no-piece-left' };
        }

        const expected = buildShapeVertices(
          ZERO,
          0,
          SHAPES[SHAPE_MAP[piece.svgId]],
          !!piece.isFlipped
        ).vertices;

        if (!exactVerticesEqual(piece.vertices, expected)) {
          return { ok: false, reason: 'opening-not-centered' };
        }

        return { ok: true };
      }

      /*
       * 所有人類 ghost、AI ghost、仲裁 ghost 及搜尋 ghost 共用的單子幾何判定。
       */
      function validatePlacementGeometry(piece, boardPieces, player) {
        if (!piece || piece.owner !== player) {
          return { ok: false, reason: 'wrong-owner' };
        }

        if (!piece.svgId || !SHAPE_MAP[piece.svgId]) {
          return { ok: false, reason: 'invalid-piece-type' };
        }

        const target = boardPieces.find(
          boardPiece => boardPiece.id === piece.targetId
        );

        if (!target || target.owner === player) {
          return { ok: false, reason: 'invalid-target' };
        }

        const targetEdge =
          piece.targetEdge !== undefined
            ? piece.targetEdge
            : piece.edgeOnOpp;

        if (
          !Number.isInteger(piece.myEdge) ||
          piece.myEdge < 0 ||
          piece.myEdge > 3 ||
          !Number.isInteger(targetEdge) ||
          targetEdge < 0 ||
          targetEdge > 3
        ) {
          return { ok: false, reason: 'invalid-edge' };
        }

        if (
          !segmentsPerfectlyMatch(
            piece.vertices[piece.myEdge],
            piece.vertices[(piece.myEdge + 1) % 4],
            target.vertices[targetEdge],
            target.vertices[(targetEdge + 1) % 4]
          )
        ) {
          return { ok: false, reason: 'declared-edge-mismatch' };
        }

        preparePieceSAT(piece);

        if (!isValidGhost(piece, boardPieces)) {
          return { ok: false, reason: 'geometry-conflict' };
        }

        return { ok: true };
      }

      /*
       * 正式回合合法性:
       * - 開局一子;
       * - 最後剩一子時一子;
       * - 一般回合兩子;
       * - 每子都要貼既有對方棋子;
       * - 同目標時必須是不同邊;
       * - 跨目標時,完整回合必須形成尚未計分且距離 > 0 的頂鑫結構。
       *
       * 「是否讓對方下一回合無棋可下」不在此硬刪除。
       * 搜尋會把它評估為對方仲裁獲勝,而不是用啟發式直接刪除候選。
       */
      function validateTurnOnState(state, move) {
        if (!Array.isArray(move)) {
          return { ok: false, reason: 'invalid-move' };
        }

        const required = getRequiredPiecesForState(state);

        if (move.length !== required) {
          return {
            ok: false,
            reason: 'wrong-piece-count',
            required
          };
        }

        if (state.boardPieces.length === 0) {
          return validateOpeningPiece(state, move[0]);
        }

        const usage = {};

        for (const piece of move) {
          usage[piece.svgId] = (usage[piece.svgId] || 0) + 1;

          if (usage[piece.svgId] > (state.counts[piece.svgId] || 0)) {
            return { ok: false, reason: 'no-piece-left' };
          }

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

          if (!result.ok) return result;
        }

        if (move.length === 1) {
          return { ok: true };
        }

        const first = move[0];
        const second = move[1];

        if (!twoGhostsCompatible(first, second)) {
          return { ok: false, reason: 'pair-conflict' };
        }

        const firstTargetEdge =
          first.targetEdge !== undefined
            ? first.targetEdge
            : first.edgeOnOpp;

        const secondTargetEdge =
          second.targetEdge !== undefined
            ? second.targetEdge
            : second.edgeOnOpp;

        if (first.targetId === second.targetId) {
          if (firstTargetEdge === secondTargetEdge) {
            return { ok: false, reason: 'same-target-edge' };
          }

          return { ok: true };
        }

        const adjudication = adjudicateSearchTurn(state, move);

        if (!adjudication.hasScoringStructure) {
          return {
            ok: false,
            reason: 'cross-target-without-score'
          };
        }

        return {
          ok: true,
          adjudication
        };
      }

      /*
       * 在一個 SearchState 上完成整個回合:
       * - 扣棋子;
       * - 加入盤面;
       * - 更新分數;
       * - 更新 scoredVictims;
       * - 換手。
       */
      function applySearchTurnToState(
        state,
        move,
        options = {}
      ) {
        const shouldValidate = options.validate !== false;

        if (shouldValidate) {
          const validation = validateTurnOnState(state, move);
          if (!validation.ok) {
            throw new Error('Illegal search turn: ' + validation.reason);
          }
        }

        const next = cloneSearchState(state);
        const usedIds = new Set(
          next.boardPieces.map(piece => piece.id)
        );

        const placedPieces = move.map(source => {
          const piece = {
            ...source,
            vertices: source.vertices,
            satBounds: source.satBounds
          };

          if (
            piece.id === undefined ||
            piece.id === null ||
            usedIds.has(piece.id)
          ) {
            piece.id = next.nextHypId--;
          }

          usedIds.add(piece.id);
          preparePieceSAT(piece);
          return piece;
        });

        const negativeIds = placedPieces
          .map(piece => piece.id)
          .filter(id => typeof id === 'number' && id < 0);

        if (negativeIds.length > 0) {
          next.nextHypId =
            Math.min(next.nextHypId, ...negativeIds) - 1;
        }

        const adjudication = adjudicateSearchTurn(
          state,
          placedPieces
        );

        for (const piece of placedPieces) {
          next.counts[piece.svgId]--;
        }

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

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

        adjudication.newScoredKeys.forEach(key => {
          next.scoredVictims.add(key);
        });

        next.lastTurnPieces = placedPieces.slice();
        next.turnNumber++;
        next.mover = next.mover === 1 ? 2 : 1;

        return {
          state: next,
          placedPieces,
          adjudication
        };
      }

      /*
       * 在指定 SearchState 上生成全部合法單子落點。
       */
      function generateLegalSinglePlacements(
        state,
        player = state.mover
      ) {
        const result = [];
        const seen = new Set();
        const tileIds = getPlayerTileIds(player).filter(
          tileId => (state.counts[tileId] || 0) > 0
        );
        const opponentPieces = state.boardPieces.filter(
          piece => piece.owner !== player
        );

        let idCursor = state.nextHypId;

        for (const target of opponentPieces) {
          for (const tileId of tileIds) {
            for (const flip of [false, true]) {
              for (
                let targetEdge = 0;
                targetEdge < 4;
                targetEdge++
              ) {
                for (let myEdge = 0; myEdge < 4; myEdge++) {
                  const attached = attachByEdge(
                    SHAPE_MAP[tileId],
                    flip,
                    myEdge,
                    target.vertices[targetEdge],
                    target.vertices[(targetEdge + 1) % 4],
                    !!target.isFlipped
                  );

                  if (!attached) continue;

                  const ghost = {
                    id: idCursor--,
                    vertices: attached.vertices,
                    type: SHAPE_MAP[tileId],
                    owner: player,
                    svgId: tileId,
                    isFlipped: flip,
                    edgeOnOpp: targetEdge,
                    targetId: target.id,
                    myEdge,
                    targetEdge,
                    parentId: target.id,
                    level:
                      (target.level !== undefined
                        ? target.level
                        : 0) + 1
                  };

                  preparePieceSAT(ghost);

                  const validation = validatePlacementGeometry(
                    ghost,
                    state.boardPieces,
                    player
                  );

                  if (!validation.ok) continue;

                  const signature =
                    piecePlacementSignature(ghost);

                  if (seen.has(signature)) continue;
                  seen.add(signature);
                  result.push(ghost);
                }
              }
            }
          }
        }

        return result;
      }

      function pairFitsInventory(state, first, second) {
        if (first.svgId !== second.svgId) {
          return (
            (state.counts[first.svgId] || 0) >= 1 &&
            (state.counts[second.svgId] || 0) >= 1
          );
        }

        return (state.counts[first.svgId] || 0) >= 2;
      }

      /*
       * 生成該 SearchState 下的完整合法回合。
       *
       * 同目標:列出所有不同邊的合規組合。
       * 跨目標:列出所有符合頂鑫例外的合規組合。
       *
       * 不使用單接/雙接/共活優先序刪除任何候選。
       */
      function generateLegalTurns(state) {
        const required = getRequiredPiecesForState(state);

        if (required === 1) {
          if (state.boardPieces.length === 0) return [];

          return generateLegalSinglePlacements(
            state,
            state.mover
          ).map(ghost => [ghost]);
        }

        const singles = generateLegalSinglePlacements(
          state,
          state.mover
        );

        if (singles.length < 2) return [];

        /*
         * 跨目標例外的預篩:
         * 若兩子跨目標,至少要:
         * - 其中一子單獨已形成可計分頂鑫;或
         * - 兩子互相形成頂鑫。
         *
         * 通過預篩後仍會呼叫 validateTurnOnState 做完整驗證。
         */
        const singleScoring = singles.map(ghost => {
          return adjudicateSearchTurn(state, [ghost])
            .hasScoringStructure;
        });

        const moves = [];
        const seen = new Set();

        for (let i = 0; i < singles.length; i++) {
          for (let j = i + 1; j < singles.length; j++) {
            const first = singles[i];
            const second = singles[j];

            if (!pairFitsInventory(state, first, second)) {
              continue;
            }

            if (!twoGhostsCompatible(first, second)) {
              continue;
            }

            if (first.targetId === second.targetId) {
              if (
                (first.targetEdge ?? first.edgeOnOpp) ===
                (second.targetEdge ?? second.edgeOnOpp)
              ) {
                continue;
              }
            } else {
              const possiblyScores =
                singleScoring[i] ||
                singleScoring[j] ||
                formsTriGolden(first, second);

              if (!possiblyScores) continue;
            }

            const move = [first, second];
            const validation = validateTurnOnState(
              state,
              move
            );

            if (!validation.ok) continue;

            const signature = turnSignature(move);
            if (seen.has(signature)) continue;

            seen.add(signature);
            moves.push(move);
          }
        }

        return moves;
      }

      function evaluateSearchState(
        state,
        rootPlayer,
        rootBaseDiff
      ) {
        const opponent = rootPlayer === 1 ? 2 : 1;
        const currentDiff =
          state.scores[rootPlayer] - state.scores[opponent];

        return currentDiff - rootBaseDiff;
      }

      function evaluateNoMoveTerminal(
        state,
        rootPlayer,
        rootBaseDiff
      ) {
        /*
         * 棋子全部下完是正常終局,以分數判定。
         * 尚未完局但行動方沒有合法回合,行動方可仲裁獲勝。
         */
        if (isSearchGameComplete(state)) {
          return evaluateSearchState(
            state,
            rootPlayer,
            rootBaseDiff
          );
        }

        return state.mover === rootPlayer
          ? SEARCH_TERMINAL_VALUE
          : -SEARCH_TERMINAL_VALUE;
      }

      function analyseSearchMove(state, move) {
        const mover = state.mover;
        const opponent = mover === 1 ? 2 : 1;

        const applied = applySearchTurnToState(state, move, {
          validate: false
        });

        const moverGain =
          applied.adjudication.gain[mover] -
          applied.adjudication.gain[opponent];

        let singleJieCount = 0;

        for (const piece of move) {
          if (
            adjudicateSearchTurn(state, [piece])
              .hasScoringStructure
          ) {
            singleJieCount++;
          }
        }

        const isDoubleJie =
          move.length === 2 &&
          formsTriGolden(move[0], move[1]) &&
          applied.adjudication.hasScoringStructure;

        return {
          childState: applied.state,
          adjudication: applied.adjudication,
          moverGain,
          singleJieCount,
          isDoubleJie,
          isTactical:
            singleJieCount > 0 || isDoubleJie
        };
      }

      /*
       * 只排序、不刪除。
       * 戰術搜索分數最高,其次才看當前回合的直接淨得分。
       */
      function orderMovesForSearch(
        state,
        moves,
        tacticalScores = null
      ) {
        return moves
          .map((move, originalIndex) => {
            const signature = turnSignature(move);
            const analysis = analyseSearchMove(state, move);

            const tacticalScore =
              tacticalScores &&
              tacticalScores.has(signature)
                ? tacticalScores.get(signature)
                : null;

            let orderScore =
              analysis.moverGain * 100 +
              analysis.singleJieCount * 15 +
              (analysis.isDoubleJie ? 25 : 0);

            if (tacticalScore !== null) {
              const moverPerspective =
                state.mover ===
                tacticalScores.rootPlayer
                  ? tacticalScore
                  : -tacticalScore;

              orderScore += moverPerspective * 1000;
            }

            return {
              move,
              orderScore,
              originalIndex
            };
          })
          .sort(
            (a, b) =>
              b.orderScore - a.orderScore ||
              a.originalIndex - b.originalIndex
          )
          .map(item => item.move);
      }

      /*
       * 戰術搜索只延伸「單接/雙接」,但每一個節點會先生成全部合法回合,
       * 再完整保留其中所有戰術回合,不再隨機只抽一個。
       */
      function tacticalAlphaBeta(
        state,
        depth,
        alpha,
        beta,
        rootPlayer,
        rootBaseDiff,
        deadline
      ) {
        if (performance.now() >= deadline) {
          throw new SearchTimeout();
        }

        if (isSearchGameComplete(state) || depth <= 0) {
          return evaluateSearchState(
            state,
            rootPlayer,
            rootBaseDiff
          );
        }

        const legalMoves = generateLegalTurns(state);

        if (legalMoves.length === 0) {
          return evaluateNoMoveTerminal(
            state,
            rootPlayer,
            rootBaseDiff
          );
        }

        const tacticalChildren = [];

        for (const move of legalMoves) {
          if (performance.now() >= deadline) {
            throw new SearchTimeout();
          }

          const analysis = analyseSearchMove(state, move);

          if (analysis.isTactical) {
            tacticalChildren.push({
              move,
              analysis
            });
          }
        }

        if (tacticalChildren.length === 0) {
          return evaluateSearchState(
            state,
            rootPlayer,
            rootBaseDiff
          );
        }

        tacticalChildren.sort(
          (a, b) => b.analysis.moverGain - a.analysis.moverGain
        );

        const maximizing = state.mover === rootPlayer;

        if (maximizing) {
          let best = -Infinity;

          for (const child of tacticalChildren) {
            const value = tacticalAlphaBeta(
              child.analysis.childState,
              depth - 1,
              alpha,
              beta,
              rootPlayer,
              rootBaseDiff,
              deadline
            );

            best = Math.max(best, value);
            alpha = Math.max(alpha, best);

            if (beta <= alpha) break;
          }

          return best;
        }

        let best = Infinity;

        for (const child of tacticalChildren) {
          const value = tacticalAlphaBeta(
            child.analysis.childState,
            depth - 1,
            alpha,
            beta,
            rootPlayer,
            rootBaseDiff,
            deadline
          );

          best = Math.min(best, value);
          beta = Math.min(beta, best);

          if (beta <= alpha) break;
        }

        return best;
      }

      async function runTacticalPresearch(
        rootState,
        rootMoves,
        targetDepth,
        deadline
      ) {
        const rootPlayer = rootState.mover;
        const opponent = rootPlayer === 1 ? 2 : 1;
        const rootBaseDiff =
          rootState.scores[rootPlayer] -
          rootState.scores[opponent];

        const fallbackScores = new Map();
        fallbackScores.rootPlayer = rootPlayer;

        const tacticalRoots = [];

        for (let i = 0; i < rootMoves.length; i++) {
          const move = rootMoves[i];
          const analysis = analyseSearchMove(
            rootState,
            move
          );

          if (analysis.isTactical) {
            tacticalRoots.push({
              move,
              analysis
            });

            fallbackScores.set(
              turnSignature(move),
              evaluateSearchState(
                analysis.childState,
                rootPlayer,
                rootBaseDiff
              )
            );
          }

          await aiMaybeYield(
            t('ai-thinking-candidates', {
              phase:
                `完整單接/雙接預掃描 ` +
                `(${i + 1}/${rootMoves.length})`
            })
          );
        }

        if (tacticalRoots.length === 0) {
          return fallbackScores;
        }

        const completedScores = new Map();
        completedScores.rootPlayer = rootPlayer;

        try {
          for (let i = 0; i < tacticalRoots.length; i++) {
            if (performance.now() >= deadline) {
              throw new SearchTimeout();
            }

            const item = tacticalRoots[i];

            const value = tacticalAlphaBeta(
              item.analysis.childState,
              targetDepth - 1,
              -Infinity,
              Infinity,
              rootPlayer,
              rootBaseDiff,
              deadline
            );

            completedScores.set(
              turnSignature(item.move),
              value
            );

            await aiMaybeYield(
              t('ai-thinking-candidates', {
                phase:
                  `${targetDepth} ply 全單接/全雙接戰術搜索 ` +
                  `(${i + 1}/${tacticalRoots.length})`
              })
            );
          }

          return completedScores;
        } catch (error) {
          if (!(error instanceof SearchTimeout)) {
            throw error;
          }

          /*
           * 不使用不完整的深層搜索結果,避免先被搜索到的候選得到不公平優勢。
           */
          return fallbackScores;
        }
      }

      async function runStateMinimax(
        rootState,
        rootMoves,
        maxDepth,
        deadline,
        tacticalScores
      ) {
        const rootPlayer = rootState.mover;
        const opponent = rootPlayer === 1 ? 2 : 1;
        const rootBaseDiff =
          rootState.scores[rootPlayer] -
          rootState.scores[opponent];

        let searchCount = 0;
        let committedBestMove = rootMoves[0];
        let committedBestValue = 0;

        function alphaBetaState(
          state,
          depth,
          alpha,
          beta
        ) {
          searchCount++;

          if (cancelAi || performance.now() >= deadline) {
            throw new SearchTimeout();
          }

          if (isSearchGameComplete(state) || depth <= 0) {
            return evaluateSearchState(
              state,
              rootPlayer,
              rootBaseDiff
            );
          }

          let legalMoves = generateLegalTurns(state);

          if (legalMoves.length === 0) {
            return evaluateNoMoveTerminal(
              state,
              rootPlayer,
              rootBaseDiff
            );
          }

          legalMoves = orderMovesForSearch(
            state,
            legalMoves
          );

          const maximizing = state.mover === rootPlayer;

          if (maximizing) {
            let best = -Infinity;

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

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

              alpha = Math.max(alpha, best);
              if (beta <= alpha) break;
            }

            return best;
          }

          let best = Infinity;

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

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

            beta = Math.min(beta, best);
            if (beta <= alpha) break;
          }

          return best;
        }

        for (let depth = 1; depth <= maxDepth; depth++) {
          let iterationBestMove = committedBestMove;
          let iterationBestValue = -Infinity;
          let iterationCompleted = true;

          try {
            for (const move of rootMoves) {
              if (
                cancelAi ||
                performance.now() >= deadline
              ) {
                throw new SearchTimeout();
              }

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

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

              if (value > iterationBestValue) {
                iterationBestValue = value;
                iterationBestMove = move;
              }
            }
          } catch (error) {
            if (!(error instanceof SearchTimeout)) {
              throw error;
            }

            iterationCompleted = false;
          }

          /*
           * 只提交完整搜索完全部根候選的深度。
           */
          if (!iterationCompleted) break;

          committedBestMove = iterationBestMove;
          committedBestValue = iterationBestValue;

          showMessage(
            t('ai-minimax-progress', {
              depth,
              time: (
                (performance.now() -
                  (deadline - 1)) /
                1000
              ).toFixed(1)
            }),
            0,
            true
          );

          await new Promise(resolve =>
            setTimeout(resolve, 0)
          );
        }

        return {
          bestMove: committedBestMove,
          value: committedBestValue,
          searchCount
        };
      }

      async function runStateMCTS(
        rootState,
        rootMoves,
        maxDepth,
        deadline,
        tacticalScores,
        cValue
      ) {
        const rootPlayer = rootState.mover;
        const opponent = rootPlayer === 1 ? 2 : 1;
        const rootBaseDiff =
          rootState.scores[rootPlayer] -
          rootState.scores[opponent];

        let searchCount = 0;

        function makeNode(
          state,
          parent = null,
          move = null,
          rootMove = null
        ) {
          return {
            state,
            parent,
            move,
            rootMove,
            children: [],
            visits: 0,
            valueSum: 0,
            orderedMoves: null,
            nextMoveIndex: 0,
            terminalValue: null
          };
        }

        function ensureNodeMoves(node, isRoot = false) {
          if (node.orderedMoves !== null) return;

          if (isSearchGameComplete(node.state)) {
            node.orderedMoves = [];
            node.terminalValue = evaluateSearchState(
              node.state,
              rootPlayer,
              rootBaseDiff
            );
            return;
          }

          let moves = isRoot
            ? rootMoves.slice()
            : generateLegalTurns(node.state);

          if (moves.length === 0) {
            node.orderedMoves = [];
            node.terminalValue = evaluateNoMoveTerminal(
              node.state,
              rootPlayer,
              rootBaseDiff
            );
            return;
          }

          node.orderedMoves = orderMovesForSearch(
            node.state,
            moves,
            isRoot ? tacticalScores : null
          );
        }

        function selectUcbChild(node) {
          const selectingForRoot =
            node.state.mover === rootPlayer;

          let bestChild = null;
          let bestValue = -Infinity;

          for (const child of node.children) {
            if (child.visits === 0) return child;

            let exploit =
              child.valueSum / child.visits;

            /*
             * 所有 value 都從 root 玩家角度保存。
             * 輪到對手選擇時,對手會偏好較低的 root value。
             */
            if (!selectingForRoot) {
              exploit = -exploit;
            }

            const explore =
              cValue *
              Math.sqrt(
                Math.log(Math.max(1, node.visits)) /
                  child.visits
              );

            const ucb = exploit + explore;

            if (ucb > bestValue) {
              bestValue = ucb;
              bestChild = child;
            }
          }

          return bestChild;
        }

        function normalizeLeafValue(rawValue) {
          if (rawValue >= SEARCH_TERMINAL_VALUE / 2) {
            return 1;
          }

          if (rawValue <= -SEARCH_TERMINAL_VALUE / 2) {
            return -1;
          }

          return Math.tanh(rawValue / 6);
        }

        const root = makeNode(rootState);
        root.orderedMoves = orderMovesForSearch(
          rootState,
          rootMoves,
          tacticalScores
        );

        while (
          performance.now() < deadline &&
          !cancelAi
        ) {
          const path = [root];
          let node = root;
          let depth = 0;

          while (depth < maxDepth) {
            ensureNodeMoves(node, node === root);

            if (node.terminalValue !== null) break;
            if (node.orderedMoves.length === 0) break;

            /*
             * Progressive widening:
             * visits 增加時才逐步開放更多排序後的候選。
             * 候選沒有被刪除,只是延後展開。
             */
            const allowedChildren = Math.min(
              node.orderedMoves.length,
              Math.max(
                1,
                Math.ceil(
                  MCTS_PW_K *
                    Math.pow(
                      node.visits + 1,
                      MCTS_PW_ALPHA
                    )
                )
              )
            );

            if (
              node.nextMoveIndex <
              allowedChildren
            ) {
              const move =
                node.orderedMoves[
                  node.nextMoveIndex++
                ];

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

              const child = makeNode(
                childState,
                node,
                move,
                node.rootMove || move
              );

              node.children.push(child);
              node = child;
              path.push(node);
              depth++;
              break;
            }

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

            const selected = selectUcbChild(node);
            if (!selected) break;

            node = selected;
            path.push(node);
            depth++;
          }

          let rawLeafValue;

          ensureNodeMoves(node, node === root);

          if (node.terminalValue !== null) {
            rawLeafValue = node.terminalValue;
          } else {
            rawLeafValue = evaluateSearchState(
              node.state,
              rootPlayer,
              rootBaseDiff
            );
          }

          const leafValue =
            normalizeLeafValue(rawLeafValue);

          for (const pathNode of path) {
            pathNode.visits++;
            pathNode.valueSum += leafValue;
          }

          searchCount++;

          if (searchCount % 10 === 0) {
            showMessage(
              t('ai-mcts-progress', {
                time: '—',
                count: searchCount
              }),
              0,
              true
            );

            await new Promise(resolve =>
              setTimeout(resolve, 0)
            );
          }
        }

        if (root.children.length === 0) {
          return {
            bestMove: root.orderedMoves[0],
            value: 0,
            searchCount
          };
        }

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

            if (
              child.visits === best.visits &&
              child.valueSum /
                Math.max(1, child.visits) >
                best.valueSum /
                  Math.max(1, best.visits)
            ) {
              return child;
            }

            return best;
          },
          root.children[0]
        );

        const meanValue =
          bestChild.valueSum /
          Math.max(1, bestChild.visits);

        return {
          bestMove: bestChild.rootMove,
          /*
           * 顯示成 0~1 勝率。
           */
          value: (meanValue + 1) / 2,
          searchCount
        };
      }

二、替換任意盤面的單子生成函式

修改位置

找到並完整刪除原本這兩個函式:

js
function getAllValidSingleMovesOnBoard(...)

以及:

js
function getAllValidSingleMoves(...)

替換成:

js
      function getAllValidSingleMovesOnBoard(
        player,
        boardPieces,
        extraHypPieces = []
      ) {
        const state = createSearchStateForBoard(
          player,
          boardPieces,
          extraHypPieces
        );

        return generateLegalSinglePlacements(
          state,
          player
        );
      }

      function getAllValidSingleMoves(player) {
        const state = createSearchStateFromGlobals(player);

        return generateLegalSinglePlacements(
          state,
          player
        );
      }

三、替換 simulateScoreGain

修改位置

找到原本:

js
function simulateScoreGain(candidatePieces) {
    ...
}

完整替換成:

js
      function simulateScoreGain(
        candidatePieces,
        explicitState = null
      ) {
        const state =
          explicitState ||
          createSearchStateFromGlobals(currentPlayer);

        return adjudicateSearchTurn(
          state,
          candidatePieces
        ).gain;
      }

這個相容包裝保留給既有的死活統計函式使用,但新的 Minimax/MCTS 不再把多回合棋子壓成一個陣列一次計分,而是每回合呼叫 applySearchTurnToState(),因此 scoredVictims 會逐回合正確更新。


四、替換 getAllValidMoves

修改位置

在文件後段找到原本完整的:

js
function getAllValidMoves(player) {
    ...
}

整個替換成:

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

至此,仲裁和 AI 根節點會使用完全相同的合法回合生成器。


五、讓人類打勾按鈕也使用統一合法性判定

修改位置

在原始 updateUI() 中找到這一段:

js
          if (tempPieces.length === requiredPieces) {
            // 六、檢查是否落在同一棋子上,或有頂鑫結構
            let canCheck = true;
            if (requiredPieces === 2) {
              let p1 = tempPieces[0],
                p2 = tempPieces[1];
              if (p1.targetId !== p2.targetId) {
                let formed = simulateScoringTriGolden(pieces.concat(tempPieces), [p1.id, p2.id]);
                if (!formed) {
                  canCheck = false;
                  showMessage('msg-same-piece', 2400);
                }
              }
            }
            if (canCheck) check.classList.remove('disabled');
            else check.classList.add('disabled');
          } else {
            check.classList.add('disabled');
          }

完整替換成:

js
          if (tempPieces.length === requiredPieces) {
            const humanState =
              createSearchStateFromGlobals(currentPlayer);

            const validation = validateTurnOnState(
              humanState,
              tempPieces
            );

            if (validation.ok) {
              check.classList.remove('disabled');
            } else {
              check.classList.add('disabled');

              if (
                validation.reason ===
                  'cross-target-without-score' ||
                validation.reason ===
                  'same-target-edge'
              ) {
                showMessage('msg-same-piece', 2400);
              }
            }
          } else {
            check.classList.add('disabled');
          }

六、替換正式落子 actionCheck

修改位置

找到原本完整的:

js
function actionCheck(that = false) {
    ...
}

整個替換成:

js
      function actionCheck(that = false) {
        if (
          that &&
          that.classList.contains('disabled')
        ) {
          return;
        }

        /*
         * 正式落子前固定連續正 ID。
         */
        tempPieces.forEach((piece, index) => {
          piece.id = pieces.length + index + 1;
          preparePieceSAT(piece);
        });

        const beforeState =
          createSearchStateFromGlobals(currentPlayer);

        const validation = validateTurnOnState(
          beforeState,
          tempPieces
        );

        /*
         * UI、棋譜導入或其它程式入口都不能繞過正式合法性判定。
         */
        if (!validation.ok) {
          if (
            validation.reason ===
              'cross-target-without-score' ||
            validation.reason ===
              'same-target-edge'
          ) {
            showMessage('msg-same-piece', 2400);
          } else {
            showMessage(
              currentLang === 'zh'
                ? '此落子不符合規則。'
                : 'This turn is illegal.',
              2400,
              true
            );
          }

          updateUI();
          renderBoard();
          return;
        }

        allRings = [];

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

        /*
         * 正式狀態直接採用統一引擎的結果。
         */
        pieces = applied.state.boardPieces;
        piecesCount = { ...applied.state.counts };
        scores = { ...applied.state.scores };
        scoredVictims = new Set(
          applied.state.scoredVictims
        );

        lastTurnPieces =
          applied.placedPieces.slice();

        allRings = applied.adjudication.rings.slice();

        turnNumber = applied.state.turnNumber;
        currentPlayer = applied.state.mover;
        nextPieceId = pieces.length + 1;

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

        if (allRings.length > 0) {
          setTimeout(() => {
            allRings = [];
            renderBoard();
          }, 1200);
        }

        applyRotation();
        updateUI();
        renderBoard();

        checkEndGame();
        saveState();

        /*
         * 舊的死活統計仍可保留供顯示或其它功能使用,
         * 但已不再充當搜尋的合法性引擎。
         */
        scheduleLifeTrackingUpdate(
          lastTurnPieces,
          pieces
        );
      }

這一修改也使棋譜導入的 actionCheck() 不能再繞過合法性判定。


七、替換整個 startAI

修改位置

找到原本完整的:

js
async function startAI() {
    ...
}

將整個函式替換為:

js
      async function startAI() {
        aiThinking = true;
        cancelAi = false;
        updateUI();

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

        const aiPlayer = currentPlayer;

        /*
         * 開局中心棋仍保留原有的隨機種類、翻面及角度。
         */
        if (
          turnNumber === 1 &&
          pieces.length === 0 &&
          tempPieces.length === 0
        ) {
          placeAIFirstMove(aiPlayer);
          aiThinking = false;

          document.getElementById(
            'message-box'
          ).style.display = 'none';

          return;
        }

        const timeLimit =
          Math.max(
            0.2,
            aiConfig.settings[aiPlayer].time || 3
          ) * 1000;

        const configuredDepth = Math.max(
          2,
          aiConfig.settings[aiPlayer].n || 3
        );

        const startTime = performance.now();
        const overallDeadline =
          startTime + timeLimit;

        let searchCount = 0;
        lastAIYieldTime = 0;

        await aiYield(t('ai-thinking-step1'));

        const rootState =
          createSearchStateFromGlobals(aiPlayer);

        /*
         * 根節點使用全部合法回合,不再先用單接、雙接、
         * 破壞對方或共活規則刪除候選。
         */
        let rootMoves = generateLegalTurns(rootState);

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

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

        /*
         * MCTS 前的全單接/全雙接戰術搜索。
         *
         * easy/hard:2 ply
         * expert 或深度 >= 5:3 ply
         */
        const tacticalDepth =
          aiConfig.type[aiPlayer] === 'expert' ||
          configuredDepth >= 5
            ? 3
            : 2;

        /*
         * 戰術搜索最多使用約 40% 總時間。
         * 若完整深度來不及完成,runTacticalPresearch
         * 會丟棄不完整深度,只保留完整的直接戰術評估。
         */
        const tacticalDeadline = Math.min(
          overallDeadline,
          startTime +
            Math.max(250, timeLimit * 0.4)
        );

        await aiYield(
          t('ai-thinking-candidates', {
            phase:
              `${tacticalDepth} ply ` +
              '全單接/全雙接戰術搜索'
          })
        );

        const tacticalScores =
          await runTacticalPresearch(
            rootState,
            rootMoves,
            tacticalDepth,
            tacticalDeadline
          );

        /*
         * 只按戰術結果排序,所有候選仍然保留。
         */
        rootMoves = orderMovesForSearch(
          rootState,
          rootMoves,
          tacticalScores
        );

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

        const totalPiecesRemaining =
          Object.values(rootState.counts).reduce(
            (sum, count) =>
              sum + Math.max(0, count),
            0
          );

        const turnsLeft = Math.ceil(
          totalPiecesRemaining / 2
        );

        const useMinimax =
          turnsLeft <= configuredDepth;

        let result;

        if (performance.now() >= overallDeadline) {
          result = {
            bestMove: rootMoves[0],
            value: 0,
            searchCount: 0
          };
        } else if (useMinimax) {
          result = await runStateMinimax(
            rootState,
            rootMoves,
            turnsLeft,
            overallDeadline,
            tacticalScores
          );
        } else {
          result = await runStateMCTS(
            rootState,
            rootMoves,
            configuredDepth,
            overallDeadline,
            tacticalScores,
            MCTS_C
          );
        }

        searchCount = result.searchCount || 0;

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

        const bestMove =
          result.bestMove || rootMoves[0];

        if (!bestMove) {
          aiThinking = false;
          aiTriggerArbitrationSuccess();
          return;
        }

        const totalTime = (
          (performance.now() - startTime) /
          1000
        ).toFixed(1);

        const valueText = useMinimax
          ? t('ai-score-label', {
              score:
                Number.isFinite(result.value)
                  ? result.value
                  : 0
            })
          : t('ai-winrate-label', {
              rate: (
                Math.max(
                  0,
                  Math.min(1, result.value || 0)
                ) * 100
              ).toFixed(1)
            });

        showMessage(
          t('ai-done', {
            time: totalTime,
            count: searchCount,
            val: valueText
          }),
          3000,
          true
        );

        /*
         * 把搜尋棋轉成正式暫存棋。
         * actionCheck 會再次使用統一合法性引擎確認並計分。
         */
        const actualMove = bestMove.map(source => {
          const piece = JSON.parse(
            JSON.stringify(source)
          );

          piece.id = nextPieceId++;
          piecesCount[piece.svgId]--;

          return piece;
        });

        tempPieces = actualMove;

        updateUI();
        renderBoard();

        setTimeout(() => {
          actionCheck();
        }, 600);

        aiThinking = false;
      }

這段替換掉的舊邏輯

新的 startAI() 已不再使用以下硬篩選:

js
getSingleJieMethods(...)
getMutualDingPairs(...)
findDisruptingMoves(...)
filterMovesKeepingOpponentAlive(...)
moveKeepsOpponentSafe(...)
buildCandidateMovesForNode(...)
buildCandidateMovesFromGhosts(...)
estimateOpponentBestReplyScore(...)

這些函式暫時可以留在文件中,但新的 AI 不會再呼叫它們。確認新版運作正常後,可以把這些只服務舊搜尋的函式刪除。

尤其要注意:不要把下列舊候選建構代碼重新接回新版 startAI()

js
candidateMoves = mutualMoves;
candidateMoves = disrupting;
candidateMoves = filterMovesKeepingOpponentAlive(...);

因為這些都是硬性刪除候選,正是第三個結構性問題。


八、修正仲裁預覽,使最後一子也能使用同一合法性引擎

新版 getAllValidMoves() 可能在最後一手回傳 [ghost],原本的 applyArbMove() 寫死為兩子。

修改位置

找到:

js
function applyArbMove(index) {
    ...
}

完整替換成:

js
      function applyArbMove(index) {
        const move = arbValidMoves[index];
        if (!move) return;

        tempPieces.forEach(piece => {
          piecesCount[piece.svgId]++;
        });

        const previewPieces = move.map(
          (source, pieceIndex) => {
            const piece = JSON.parse(
              JSON.stringify(source)
            );

            piece.id =
              'p_arb_' + (pieceIndex + 1);

            piecesCount[piece.svgId]--;

            return piece;
          }
        );

        tempPieces = previewPieces;

        renderBoard();
        updateUI();
      }

九、建議順便修正 Minimax 進度時間顯示

上面的 runStateMinimax() 不知道搜尋開始時間,若要顯示正確用時,可再做一個小修改。

找到:

js
      async function runStateMinimax(
        rootState,
        rootMoves,
        maxDepth,
        deadline,
        tacticalScores
      ) {

改為:

js
      async function runStateMinimax(
        rootState,
        rootMoves,
        maxDepth,
        deadline,
        tacticalScores
      ) {
        const minimaxStartTime = performance.now();

然後在同一函式內找到:

js
              time: (
                (performance.now() -
                  (deadline - 1)) /
                1000
              ).toFixed(1)

替換成:

js
              time: (
                (performance.now() -
                  minimaxStartTime) /
                1000
              ).toFixed(1)

十、修正完成後的資料流

流程图
正在绘制流程图…

十一、修正後對應六項要求

要求修改後位置
建立完整 SearchStatecreateSearchStateFromGlobals()cloneSearchState()
按回合更新假設 scoredVictimsapplySearchTurnToState()
用假設盤面生成對方完整合法回合generateLegalTurns(state)
MCTS 前加入 2~3 ply 全單接/全雙接搜索runTacticalPresearch()tacticalAlphaBeta()
硬篩選改成排序/progressive wideningorderMovesForSearch()runStateMCTS()
統一人類、AI、仲裁與搜索合法性validateTurnOnState()、新版 actionCheck()getAllValidMoves()

最關鍵的行為差異是:現在某個假設回合得過分後,該端點會立即寫入該分支自己的 state.scoredVictims;對手下一層再搜索時,不會把同一端點重複計分,也不會錯用真實盤面的全域 scoredVictims