分享和讨论Javascript相关的话题
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復和改進:
一,檢查MCTS代碼
```
getBestUCTChild() {
const C = 3600; // 探索常數,配合你的遊戲分數(100, 150)量級調整
let bestValue = -Infinity;
let bestChild = null;

// 若當前節點是 AI 行動,選擇分數最大化;若是對手行動,對手會選擇讓 AI 分數最小化的路線
const isAI = this.state.player === currentPlayer;

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

const expectedScore = child.totalScore / child.visits;
const exploration = C * Math.sqrt(Math.log(this.visits) / child.visits);

let uctValue;
if (isAI) {
uctValue = expectedScore + exploration; // AI 期望最大化
} else {
uctValue = -expectedScore + exploration; // 對手期望最小化 AI 分數
}

if (uctValue > bestValue) {
bestValue = uctValue;
bestChild = child;
}
}
return bestChild;
}
```

以上是其它程式的代碼,它其中包含了:
```
if (isAI) {
uctValue = expectedScore + exploration; // AI 期望最大化
} else {
uctValue = -expectedScore + exploration; // 對手期望最小化 AI 分數
}
```

那為何本程式不作這個判定?會否發生錯誤?

為何其它程式的「探索常數」會達到3600?本程式的探索常數達到多少比較合適?

二,去掉所有跟「分差縮放分母」和「節點最低訪問次數」的代碼。

「節點最低訪問次數」等同於設為1(只要去掉,默認就是1)。

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

代码: 全选

修復和改進:
一,檢查MCTS代碼
```
        getBestUCTChild() {
          const C = 3600; // 探索常數,配合你的遊戲分數(100, 150)量級調整
          let bestValue = -Infinity;
          let bestChild = null;

          // 若當前節點是 AI 行動,選擇分數最大化;若是對手行動,對手會選擇讓 AI 分數最小化的路線
          const isAI = this.state.player === currentPlayer;

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

            const expectedScore = child.totalScore / child.visits;
            const exploration = C * Math.sqrt(Math.log(this.visits) / child.visits);

            let uctValue;
            if (isAI) {
              uctValue = expectedScore + exploration; // AI 期望最大化
            } else {
              uctValue = -expectedScore + exploration; // 對手期望最小化 AI 分數
            }

            if (uctValue > bestValue) {
              bestValue = uctValue;
              bestChild = child;
            }
          }
          return bestChild;
        }
```

以上是其它程式的代碼,它其中包含了:
```
            if (isAI) {
              uctValue = expectedScore + exploration; // AI 期望最大化
            } else {
              uctValue = -expectedScore + exploration; // 對手期望最小化 AI 分數
            }
```

那為何本程式不作這個判定?會否發生錯誤?

為何其它程式的「探索常數」會達到3600?本程式的探索常數達到多少比較合適?

二,去掉所有跟「分差縮放分母」和「節點最低訪問次數」的代碼。

「節點最低訪問次數」等同於設為1(只要去掉,默認就是1)。

需要明確給出所要修改的地方和所要替換的代碼。
附件
trirhombus2772.html.7z
(25.9 KiB) 已下载 3 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這是一個非常棒的觀察!你注意到了不同 MCTS(蒙地卡羅樹搜尋)實作在細節上的差異。我會先為你解答這兩個關於演算法邏輯的疑惑,接著提供明確的代碼修改指南,幫你把不需要的參數清理乾淨。

### 一、 關於 MCTS 演算法邏輯的解答

#### 1. 為何本程式沒有使用 `isAI` 的正負號判定?會發生錯誤嗎?

**不會發生錯誤。** 這是因為這份代碼採用了類似 **Negamax(負極大值)** 的概念,將正負號的翻轉實作在**反向傳播(Backpropagation)** 階段,而不是**選擇(Selection)** 階段。

在你的代碼第 1308 行附近(反向傳播階段),有這樣一段邏輯:

```javascript
let diff = p1SimScore - p2SimScore;
let curr = node;
while (curr !== null) {
  curr.visits++;
  if (curr.player === 1) {
    curr.wins += -diff;  // 注意這裡的反轉
  } else {
    curr.wins += diff;
  }
  curr = curr.parent;
}

```

因為在反向傳播時,已經根據該節點是哪位玩家的行動(`curr.player`),把對他不利的分差取了負數(`-diff`),所以存儲在節點裡的 `wins`(實際上是累計分差)已經是從**該節點玩家的視角**來看的期望值了。

因此,在選擇階段計算 UCT 時,直接取 `c.wins / c.visits` 就能保證雙方都在追求「自己的利益最大化」,自然就不需要在計算 UCT 時再用 `isAI` 去判斷一次了。這是一種非常優雅且常見的寫法。

#### 2. 探索常數 $C$ 為什麼在別人的程式是 3600?本程式該設多少?

UCT 的公式為:


$$UCT = \text{期望分數} + C \times \sqrt{\frac{\ln(N)}{n_i}}$$

公式分為兩部分:「開發(期望分數)」與「探索(後面的平方根項)」。**探索常數 $C$ 的作用,是將「探索項」放大到與「開發項」相同的量級**,讓兩者能互相抗衡。

* **為何別人的 $C=3600$?** 因為在那個遊戲中,最終的分數計算可能高達數百或數千(例如 $100$、$150$ 甚至更高)。如果 $C$ 太小(例如 $1.414$),探索項計算出來可能只有 $2$ 或 $3$,這在幾百倍的期望分數面前微不足道,AI 就會變成純貪婪算法,失去探索能力。
* **三菱棋(Trirhombus)的 $C$ 該設多少?** 三菱棋的每次模擬分差通常都在個位數或十位數(大約在 $-20$ 到 $20$ 之間)。因此,探索常數不需要設到幾千。預設的 $C = 3.6$ 到 $C = 5.0$ 左右是非常合理且合適的區間,足以平衡這款遊戲的分差量級。

---

### 二、 代碼修改指南(移除不必要的參數)

我們將把「分差縮放分母」與「節點最低訪問次數」相關的 UI 與邏輯徹底移除。請按照以下步驟尋找並替換代碼。

#### 1. 移除 HTML UI 介面

請在 HTML 中找到以下區塊(約在 `<div id="ai-modal">` 內部),並**將這兩段完整的 `<div class="setting-row">...</div>` 直接刪除**:

**請刪除這段(節點最低訪問次數):**

```html
        <div class="setting-row">
          <label id="label-mcts-v">節點最低訪問次數</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('mcts-v', -1, 1, 50, 0)">-</button>
            <input type="number" id="mcts-v" class="num-input" value="2" step="1" onchange="updateGlobalParams()" />
            <button class="num-btn" onclick="adjustGlobalParam('mcts-v', 1, 1, 50, 0)">+</button>
          </div>
        </div>

```

**並刪除這段(分差縮放分母):**

```html
        <div class="setting-row">
          <label id="label-mcts-scale">分差縮放分母:</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('mcts-scale', -1, 1, 50, 0)">-</button>
            <input
              type="number"
              id="mcts-scale"
              class="num-input"
              value="20"
              step="1"
              onchange="updateGlobalParams()"
            />
            <button class="num-btn" onclick="adjustGlobalParam('mcts-scale', 1, 1, 50, 0)">+</button>
          </div>
        </div>

```

#### 2. 移除多國語言字典 (I18N) 裡的定義

找到 `const I18N = {` 的設定,將 `zh` 與 `en` 裡面對應的鍵值刪除。

**在 `zh` 區塊中,刪除這兩行:**

```javascript
          mctsV: '節點最低訪問次數',
          mctsScale: '分差縮放分母',

```

**在 `en` 區塊中,刪除這兩行:**

```javascript
          mctsV: 'Minimum Node Visits',
          mctsScale: 'Score Scaling Denominator',

```

#### 3. 移除 `toggleLanguage()` 函數中的更新邏輯

找到 `toggleLanguage()` 函數,**刪除這兩行**:

```javascript
        document.getElementById('label-mcts-v').innerText = getText('mctsV');
        document.getElementById('label-mcts-scale').innerText = getText('mctsScale');

```

#### 4. 移除全域變數

找到宣告全域變數的地方(約在最上方或 AI 變數區),將 `mctsMinVisits` 移除。

**原代碼:**

```javascript
      let mctsExplorationConstant = 1.414;
      let mctsMinVisits = 1;

```

**替換為:**

```javascript
      let mctsExplorationConstant = 1.414;

```

#### 5. 修改 MCTS 核心:Selection 邏輯

找到 `executeMCTSSearch` 函數內部的 Selection 階段。將原本使用 `mctsMinVisits` 的判斷,改為最標準的 `c.visits === 0`。

**原代碼:**

```javascript
              for (let c of node.children) {
                let uct;
                if (c.visits < mctsMinVisits) {
                  uct = 10000 + Math.random();
                } else {
                  // 取代原有的勝率百分比,直接使用真實分差期望值 + 探索項
                  let expectedScore = c.wins / c.visits;
                  uct = expectedScore + mctsExplorationConstant * Math.sqrt(Math.log(node.visits) / c.visits);
                }
                if (uct > bestUCT) {

```

**替換為:**

```javascript
              for (let c of node.children) {
                let uct;
                if (c.visits === 0) {
                  uct = 10000 + Math.random();
                } else {
                  // 取代原有的勝率百分比,直接使用真實分差期望值 + 探索項
                  let expectedScore = c.wins / c.visits;
                  uct = expectedScore + mctsExplorationConstant * Math.sqrt(Math.log(node.visits) / c.visits);
                }
                if (uct > bestUCT) {

```

#### 6. 修改 `updateGlobalParams` 函數

最後,清理更新參數的函數,移除抓取 `mcts-v` 的邏輯。

**原代碼:**

```javascript
      function updateGlobalParams() {
        mctsExplorationConstant = parseFloat(document.getElementById('mcts-c').value);
        mctsMinVisits = parseInt(document.getElementById('mcts-v').value, 10);
        markCustom(); // 切換到自訂標籤
      }

```

**替換為:**

```javascript
      function updateGlobalParams() {
        mctsExplorationConstant = parseFloat(document.getElementById('mcts-c').value);
        markCustom(); // 切換到自訂標籤
      }

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復和改進:
改進rolloutPolicy

不應計算每一步的即時分差,每次都要往下探索至局終。

不要每次都絕對選擇Top-K,設定一個機率(如0.3)隨機落子。

二,在Backpropagation中引進度獎勵(Progressive Bonus)

給予後期得分的權重獎勵,使一條路線在後期即深層網路得分價值略高於淺層網路。

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

代码: 全选

修復和改進:
改進rolloutPolicy

不應計算每一步的即時分差,每次都要往下探索至局終。

不要每次都絕對選擇Top-K,設定一個機率(如0.3)隨機落子。

二,在Backpropagation中引進度獎勵(Progressive Bonus)

給予後期得分的權重獎勵,使一條路線在後期即深層網路得分價值略高於淺層網路。

需要明確給出所要修改的地方和所要替換的代碼。
附件
trirhombus2773.html.7z
(25.72 KiB) 已下载 3 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro要睡到8:52

為什麼gemini3.1 pro至今沒有換下去,因為它確實能打,確實好用。

代码: 全选

這兩個修改能大幅改善 MCTS 在後期路線的判斷能力,也能跳出局部最佳解。

以下是針對你提供的 `trirhombus2773.html` 需要修改的地方與替換的程式碼:

### 第一部分:改進 `rolloutPolicy` 函數

這裡我們加入了 $0.3$ 的隨機探索機率(Epsilon-Greedy),並且在評估候選步時,不再只看「即時分差」,而是複製當前狀態並**隨機模擬至局終**來評估該步的最終潛力。考慮到模擬至局終較耗費效能,稍微調降了候選池的大小。

**請找到以下程式碼(約在檔案中後段):**

```javascript
      // === Top-K Random Rollout 策略 ===
      function rolloutPolicy(simState, simPlayer, workPieces, moves) {
        // 為了效能,我們不評估所有合法步,而是隨機抽取最多 8 步作為候選池
        const M = Math.min(8, moves.length);
        const K = Math.min(3, M); // 從候選池中挑選期望值最高的前 3 名

        let candidates = [];
        let movesCopy = [...moves];
        for (let i = 0; i < M; i++) {
          let rIdx = Math.floor(Math.random() * movesCopy.length);
          candidates.push(movesCopy.splice(rIdx, 1)[0]);
        }

        let evaluated = [];
        for (let m of candidates) {
          let simPid = (m >> 24) & 0xff;
          let simP1Idx = (m >> 12) & 0xfff;
          let simP2Idx = m & 0xfff;

          // 模擬落子
          simState.tryPlacePiece(simPid + 1, simState._idxToCoord(simP1Idx), simState._idxToCoord(simP2Idx), false);
          workPieces[simPlayer][simPid]--;

          // 評估即時分差 (作為期望值的簡易替代)
          let scores = simState.calculateScores();
          let scoreDiff = simPlayer === 1 ? scores.p1Score - scores.p2Score : scores.p2Score - scores.p1Score;

          // 復原盤面與可用棋子數
          simState.undo();
          workPieces[simPlayer][simPid]++;

          evaluated.push({ move: m, score: scoreDiff });
        }

        // 依分差降序排序,取前 K 名
        evaluated.sort((a, b) => b.score - a.score);
        let topK = evaluated.slice(0, K);

        // 再從前 K 名中隨機挑選 1 步 (加上隨機性,避免陷入局部最佳)
        return topK[Math.floor(Math.random() * topK.length)].move;
      }

```

**將其替換為以下程式碼:**

```javascript
      // === 改進版 Top-K Random Rollout 策略 ===
      function rolloutPolicy(simState, simPlayer, workPieces, moves) {
        // 1. 設定 30% (0.3) 的機率直接完全隨機落子,跳出局部最佳 (Epsilon-Greedy)
        if (Math.random() < 0.3) {
          return moves[Math.floor(Math.random() * moves.length)];
        }

        // 2. 為了效能平衡,由於需探索至局終,將候選池縮小至最多 4 步,取前 2 名
        const M = Math.min(4, moves.length);
        const K = Math.min(2, M); 

        let candidates = [];
        let movesCopy = [...moves];
        for (let i = 0; i < M; i++) {
          let rIdx = Math.floor(Math.random() * movesCopy.length);
          candidates.push(movesCopy.splice(rIdx, 1)[0]);
        }

        let evaluated = [];
        for (let m of candidates) {
          let simPid = (m >> 24) & 0xff;
          let simP1Idx = (m >> 12) & 0xfff;
          let simP2Idx = m & 0xfff;

          // 深度拷貝當前狀態,用於模擬至局終 (避免頻繁 undo 的複雜度)
          let tempState = simState.clone();
          let tempPieces = {
            1: new Int8Array(workPieces[1]),
            2: new Int8Array(workPieces[2])
          };

          tempState.tryPlacePiece(simPid + 1, tempState._idxToCoord(simP1Idx), tempState._idxToCoord(simP2Idx), false);
          tempPieces[simPlayer][simPid]--;

          let currentSimPlayer = simPlayer === 1 ? 2 : 1;

          // 不看即時分數,持續隨機落子直到局終
          while (true) {
            let tempMoves = tempState.getValidMoves(currentSimPlayer, tempPieces);
            if (tempMoves.length === 0) break;

            let randomMove = tempMoves[Math.floor(Math.random() * tempMoves.length)];
            let rPid = (randomMove >> 24) & 0xff;
            let rP1Idx = (randomMove >> 12) & 0xfff;
            let rP2Idx = randomMove & 0xfff;

            tempState.tryPlacePiece(rPid + 1, tempState._idxToCoord(rP1Idx), tempState._idxToCoord(rP2Idx), false);
            tempPieces[currentSimPlayer][rPid]--;
            currentSimPlayer = currentSimPlayer === 1 ? 2 : 1;
          }

          // 評估最終局終分差
          let scores = tempState.calculateScores();
          let scoreDiff = simPlayer === 1 ? scores.p1Score - scores.p2Score : scores.p2Score - scores.p1Score;

          evaluated.push({ move: m, score: scoreDiff });
        }

        // 依分差降序排序,取前 K 名
        evaluated.sort((a, b) => b.score - a.score);
        let topK = evaluated.slice(0, K);

        // 再從前 K 名中隨機挑選 1 步
        return topK[Math.floor(Math.random() * topK.length)].move;
      }

```

---

### 第二部分:在 Simulation 與 Backpropagation 引進進度獎勵

這裡我們在 Simulation 階段計算推演的深度 (`rolloutDepth`),並在 Backpropagation 階段結合 MCTS 樹的深度 (`currDepth`) 產生一個總深度。總深度越深,我們給予的得分權重加成越高(每深一步增加 1% 的價值),藉此鼓勵 AI 選擇能延伸到更深層網路或能存活更久的路線。

**請在 `executeMCTSSearch` 函數中,找到以下程式碼:**

```javascript
            // 3. Simulation (引入 Top-K Random)
            let p1SimScore, p2SimScore;
            if (node.isTerminal) {
              let scores = simState.calculateScores();
              p1SimScore = scores.p1Score;
              p2SimScore = scores.p2Score;
            } else {
              while (true) {
                let moves = simState.getValidMoves(simPlayer, workPieces);
                if (moves.length === 0) break;

                // 使用 Top-K Random 取代原本的完全隨機
                let m = rolloutPolicy(simState, simPlayer, workPieces, moves);

                let simPid = (m >> 24) & 0xff;
                let simP1Idx = (m >> 12) & 0xfff;
                let simP2Idx = m & 0xfff;
                simState.tryPlacePiece(
                  simPid + 1,
                  simState._idxToCoord(simP1Idx),
                  simState._idxToCoord(simP2Idx),
                  false
                );
                workPieces[simPlayer][simPid]--;
                simPlayer = simPlayer === 1 ? 2 : 1;
              }
              let scores = simState.calculateScores();
              p1SimScore = scores.p1Score;
              p2SimScore = scores.p2Score;
            }

            // 4. Backpropagation (去除歸一化,直接傳遞真實分差)
            let diff = p1SimScore - p2SimScore;
            let curr = node;
            while (curr !== null) {
              curr.visits++;
              if (curr.player === 1) {
                curr.wins += -diff;
              } else {
                curr.wins += diff;
              }
              curr = curr.parent;
            }
            iterations++;

```

**將其替換為以下程式碼:**

```javascript
            // 3. Simulation (引入 Top-K Random 與進度追蹤)
            let p1SimScore, p2SimScore;
            let rolloutDepth = 0; // 新增:紀錄 Simulation 往下探索的深度
            if (node.isTerminal) {
              let scores = simState.calculateScores();
              p1SimScore = scores.p1Score;
              p2SimScore = scores.p2Score;
            } else {
              while (true) {
                let moves = simState.getValidMoves(simPlayer, workPieces);
                if (moves.length === 0) break;

                // 使用改良版的 rolloutPolicy
                let m = rolloutPolicy(simState, simPlayer, workPieces, moves);

                let simPid = (m >> 24) & 0xff;
                let simP1Idx = (m >> 12) & 0xfff;
                let simP2Idx = m & 0xfff;
                simState.tryPlacePiece(
                  simPid + 1,
                  simState._idxToCoord(simP1Idx),
                  simState._idxToCoord(simP2Idx),
                  false
                );
                workPieces[simPlayer][simPid]--;
                simPlayer = simPlayer === 1 ? 2 : 1;
                rolloutDepth++; // 累加探索深度
              }
              let scores = simState.calculateScores();
              p1SimScore = scores.p1Score;
              p2SimScore = scores.p2Score;
            }

            // 4. Backpropagation (引進進度獎勵 Progressive Bonus)
            let diff = p1SimScore - p2SimScore;
            
            // 計算總深度:MCTS 樹內深度 (currDepth) + Rollout 隨機延伸深度 (rolloutDepth)
            let totalDepth = currDepth + rolloutDepth;
            // 設定權重獎勵:每多推進一手,價值提升 1% (可依實際遊戲節奏調整 0.01 的常數)
            let progressiveBonus = 1 + (totalDepth * 0.01);
            let weightedDiff = diff * progressiveBonus;

            let curr = node;
            while (curr !== null) {
              curr.visits++;
              if (curr.player === 1) {
                curr.wins += -weightedDiff;
              } else {
                curr.wins += weightedDiff;
              }
              curr = curr.parent;
            }
            iterations++;

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復和改進:
一,展示每個分支能拿到的最高分差

在AI設置窗口的搜尋結果,給出每個分支能拿到的最高分差,它應該是一個整數,可能是正數或負數或0。

二,改進rolloutPolicy

去掉Top-K Random和Epsilon-Greedy,起初完全隨機,直到分差期望的差距大到一定的值,才開始偏向分差期望高的分支。

這段代碼「uct = 10000 + Math.random();」我不理解,如果是想表示無窮大,那直接用Infinity不是更好?

三,去掉Backpropagation中的進度獎勵(Progressive Bonus)

之前「給予後期得分的權重獎勵,使一條路線在後期即深層網路得分價值略高於淺層網路。」但是後來發現沒什麼用,現在要把它去掉。

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

代码: 全选

修復和改進:
一,展示每個分支能拿到的最高分差

在AI設置窗口的搜尋結果,給出每個分支能拿到的最高分差,它應該是一個整數,可能是正數或負數或0。

二,改進rolloutPolicy

去掉Top-K Random和Epsilon-Greedy,起初完全隨機,直到分差期望的差距大到一定的值,才開始偏向分差期望高的分支。

這段代碼「uct = 10000 + Math.random();」我不理解,如果是想表示無窮大,那直接用Infinity不是更好?

三,去掉Backpropagation中的進度獎勵(Progressive Bonus)

之前「給予後期得分的權重獎勵,使一條路線在後期即深層網路得分價值略高於淺層網路。」但是後來發現沒什麼用,現在要把它去掉。

需要明確給出所要修改的地方和所要替換的代碼。
附件
trirhombus2774.html.7z
(26.12 KiB) 已下载 3 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

claude用一下
附件
trirhombus2774claude2.html.7z
(26.82 KiB) 已下载 4 次
trirhombus2774claude.html.7z
(26.23 KiB) 已下载 3 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復和改進:
一,展示每個分支能拿到的最高分差

在AI設置窗口的搜尋結果,給出每個分支能拿到的最高分差,它應該是一個整數,可能是正數或負數或0。

正確理解我說的「最高分差」。舉個例子,如果我方的某一步棋最高可以高出對方3分,則對方接下來的一步棋的所有可能中最多只能使我方比他高出3分。

二,改進rolloutPolicy

去掉Top-K Random和Epsilon-Greedy,起初完全隨機,直到分差期望的差距大到一定的值,才開始偏向分差期望高的分支。

這段代碼「uct = 10000 + Math.random();」我不理解,如果是想表示無窮大,那直接用Infinity不是更好?

三,去掉Backpropagation中的進度獎勵(Progressive Bonus)

之前「給予後期得分的權重獎勵,使一條路線在後期即深層網路得分價值略高於淺層網路。」但是後來發現沒什麼用,現在要把它去掉。

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

代码: 全选

修復和改進:
一,展示每個分支能拿到的最高分差

在AI設置窗口的搜尋結果,給出每個分支能拿到的最高分差,它應該是一個整數,可能是正數或負數或0。

正確理解我說的「最高分差」。舉個例子,如果我方的某一步棋最高可以高出對方3分,則對方接下來的一步棋的所有可能中最多只能使我方比他高出3分。

二,改進rolloutPolicy

去掉Top-K Random和Epsilon-Greedy,起初完全隨機,直到分差期望的差距大到一定的值,才開始偏向分差期望高的分支。

這段代碼「uct = 10000 + Math.random();」我不理解,如果是想表示無窮大,那直接用Infinity不是更好?

三,去掉Backpropagation中的進度獎勵(Progressive Bonus)

之前「給予後期得分的權重獎勵,使一條路線在後期即深層網路得分價值略高於淺層網路。」但是後來發現沒什麼用,現在要把它去掉。

需要明確給出所要修改的地方和所要替換的代碼。
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

這三個要求都非常合理,能讓 MCTS 引擎的邏輯更純粹且有效率。

在開始修改之前,先解答你關於 `uct = 10000 + Math.random();` 的疑惑:
如果你直接給未探索節點設為 `Infinity`,當有多個未探索節點同時都是 `Infinity` 時,程式在比對大小(尋找最大值)時會把它們視為相等,通常會導致系統「永遠固定選擇陣列中的第一個節點」來展開,這會讓搜尋樹在初期嚴重向左傾斜,失去探索的多樣性。加上一個微小的 `Math.random()` 就能確保這些未探索節點雖然擁有極高優先級,但彼此之間會隨機打亂,這在 MCTS 實作中是個非常經典且實用的除錯小技巧。

以下是針對你的三個需求,明確需要替換的代碼片段:

### 1. 修改 `rolloutPolicy` (拔除 Epsilon-Greedy 與 Top-K)

將原本的 `rolloutPolicy` 函數整段替換為以下代碼。這個版本會在初期完全隨機,而在遊戲進入中後期且「隨機抽樣的走法中,即時分差大於等於 2」時,才會貪婪地選擇高分分支,大幅減少模擬運算量。

```javascript
// === Top-K Random, Epsilon-Greedy Rollout 策略 (已替換為動態分差貪婪策略) ===
function rolloutPolicy(simState, simPlayer, workPieces, moves) {
  // 起初完全隨機
  let randomMove = moves[Math.floor(Math.random() * moves.length)];

  // 計算當前遊戲步數 (總共 36 步)
  let movesPlayed = 36 - (workPieces[1][0] + workPieces[1][1] + workPieces[1][2] + workPieces[2][0] + workPieces[2][1] + workPieces[2][2]);

  // 早期完全隨機,避免在此階段進行耗時評估 (例如前 20 步)
  if (movesPlayed < 20) {
    return randomMove;
  }

  // 後期:為效能考量,僅隨機抽樣最多 3 個著法來評估「即時分差」
  const M = Math.min(3, moves.length);
  let bestMove = randomMove;
  let bestDiff = -Infinity;
  let worstDiff = Infinity;

  for (let i = 0; i < M; i++) {
    let rIdx = Math.floor(Math.random() * moves.length);
    let m = moves[rIdx];

    let simPid = (m >> 24) & 0xff;
    let simP1Idx = (m >> 12) & 0xfff;
    let simP2Idx = m & 0xfff;

    // 建立暫存盤面測試即時分數
    let tempState = simState.clone();
    tempState.tryPlacePiece(simPid + 1, tempState._idxToCoord(simP1Idx), tempState._idxToCoord(simP2Idx), false);
    let scores = tempState.calculateScores();
    let diff = simPlayer === 1 ? scores.p1Score - scores.p2Score : scores.p2Score - scores.p1Score;

    if (diff > bestDiff) {
      bestDiff = diff;
      bestMove = m;
    }
    if (diff < worstDiff) {
      worstDiff = diff;
    }
  }

  // 直到分差期望的差距大到一定的值 (這裡設定為 2 分以上),才開始偏向高分分支
  if (bestDiff - worstDiff >= 2) {
    return bestMove;
  } else {
    return randomMove; // 差距不大則保持隨機
  }
}

```

---

### 2. 去除進度獎勵,並在反向傳播中記錄最高分差 (Minimax 基礎)

找到 `executeMCTSSearch` 函數中的 **第 4 步 Backpropagation**,將該段替換如下。我們移除了 `progressiveBonus`,並利用 MCTS 的特性順便記錄每個節點在模擬中所遇到的最大/最小分差(`mmDiff`),供稍後計算最高分差使用。

```javascript
            // 4. Backpropagation (移除進度獎勵,並紀錄 Minimax 模擬極值)
            let diff = p1SimScore - p2SimScore; // 絕對分差 (P1 - P2)

            let curr = node;
            while (curr !== null) {
              curr.visits++;
              if (curr.player === 1) {
                // 如果是 P1 落子,我們希望最大化 P1 - P2
                curr.wins += -diff;
                if (curr.mmDiff === undefined) curr.mmDiff = diff;
                else curr.mmDiff = Math.max(curr.mmDiff, diff);
              } else {
                // 如果是 P2 落子,P2 會試圖最小化 P1 - P2
                curr.wins += diff;
                if (curr.mmDiff === undefined) curr.mmDiff = diff;
                else curr.mmDiff = Math.min(curr.mmDiff, diff);
              }
              curr = curr.parent;
            }
            iterations++;

```

---

### 3. 計算並在 UI 中展示「最高分差」

為了完美實現「對方接下來的一步棋的所有可能中最多只能使我方高出 X 分」的 Minimax 邏輯,我們需要在 AI 搜尋結束後,由樹根往下遞迴計算一次極大極小值,並呈現在畫面上。

**第一處修改**:找到 `runAISearch()` 函數,在 `let { root, iterations, timeElapsed } = await executeMCTSSearch(timeLimit, true);` 這行之後,排序邏輯之前,插入以下這段遞迴計算代碼:

```javascript
        // --- 插入在 runAISearch 中,await executeMCTSSearch 之後 ---
        if (abortAiSearch) {
          statusDiv.innerHTML = `⚠️ 搜尋已中斷`;
          btn.disabled = false;
          btn.style.opacity = '1';
          return;
        }

        // 計算整數最高分差 (Minimax 演算法應用於 MCTS 樹)
        function computeTreeMinimax(n) {
          if (n.isTerminal) {
            n.treeMinimax = n.terminalScore;
            return n.treeMinimax;
          }
          if (n.children.length === 0) {
            // 葉節點回傳反向傳播記錄到的極端值
            n.treeMinimax = n.mmDiff !== undefined ? n.mmDiff : 0;
            return n.treeMinimax;
          }
          if (n.player === 1) { // P1 取 Max
            let maxVal = -Infinity;
            for (let c of n.children) maxVal = Math.max(maxVal, computeTreeMinimax(c));
            n.treeMinimax = maxVal;
            return maxVal;
          } else { // P2 取 Min
            let minVal = Infinity;
            for (let c of n.children) minVal = Math.min(minVal, computeTreeMinimax(c));
            n.treeMinimax = minVal;
            return minVal;
          }
        }
        computeTreeMinimax(root);
        // --- 插入結束 ---

        statusDiv.innerHTML = `✅ 搜尋完成!總耗時: ${(timeElapsed / 1000).toFixed(1)}s / 總次數: ${iterations}`;

```

**第二處修改**:找到 `buildTreeNodeDOM(node, isFirstLevel, evalPlayer)` 函數中的 UI 拼接部分(大約在 `let displayColor = expected > 0 ? '#007b00' : ...` 附近),替換成以下內容以顯示「最高」分差:

```javascript
        // 直接計算預期真實分差
        let expected = node.visits > 0 ? node.wins / node.visits : 0;
        if (node.parent && node.parent.player !== evalPlayer) {
          expected = -expected;
        }

        // 讀取剛才遞迴計算出的樹狀 Minimax 最高分差
        let maxDiffVal = node.treeMinimax !== undefined ? node.treeMinimax : 0;
        let displayMaxDiff = evalPlayer === 1 ? maxDiffVal : -maxDiffVal;
        
        let displayColor = expected > 0 ? '#007b00' : expected < 0 ? '#d00000' : '#666';
        let displayColorMax = displayMaxDiff > 0 ? '#007b00' : displayMaxDiff < 0 ? '#d00000' : '#666';

        let header = document.createElement('div');
        header.className = 'ai-tree-header';

        let hasChildren = node.children && node.children.length > 0;
        let expandIcon = hasChildren ? '▶' : ' ';

        header.innerHTML = `
          <span class="tree-expand" style="width: 20px; display: inline-block; text-align: center;">${expandIcon}</span>
          <span style="width: 130px; display: inline-block;">${pieceName} ${notation}</span>
          <span style="color: ${displayColor}; width: 90px; display: inline-block; font-weight: bold;">期望: ${expected.toFixed(1)}</span>
          <span style="color: ${displayColorMax}; width: 80px; display: inline-block; font-weight: bold;">最高: ${displayMaxDiff}</span>
          <span style="color: #666;">次數: ${node.visits}</span>
        `;

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復和改進:
一,將分差差距值放至AI思考設置窗口

分差期望差距值增加到AI思考設置的窗口中,使之可以調整,步進是0.1。

二,改變selection策略

用50%的比例「展開新子節點」或「往已存在的子節點繼續探索。

三,檢查代碼和說明

為何我發現分差差距小於2的分支,visit數量會相差很大?

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

代码: 全选

修復和改進:
一,將分差差距值放至AI思考設置窗口

分差期望差距值增加到AI思考設置的窗口中,使之可以調整,步進是0.1。

二,改變selection策略

用50%的比例「展開新子節點」或「往已存在的子節點繼續探索。

三,檢查代碼和說明

為何我發現分差差距小於2的分支,visit數量會相差很大?

需要明確給出所要修改的地方和所要替換的代碼。
附件
trirhombus2775.html.7z
(26.2 KiB) 已下载 3 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

跟2768下,又和一局。是否可以認為,現在想贏一局AI已經有點難了?
附件
Screenshot from 2026-07-07 12-31-45.png
Screenshot from 2026-07-07 12-31-45.png (199.54 KiB) 查看 40 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復和改進:
一,去掉所有「節點最低訪問次數」的代碼。

去掉所有跟「節點最低訪問次數」相關的代碼,節點最低訪問次數等同於設為1。

二,手機端時的消息框顯示位置調整

當js檢測到是在手機端時,js將查找board-wrapper的左上角在頁面上的絕對坐標,再把消息框絕對定位到那個位置上。

電腦端則不變,不要改動原來的css。

三,改進rolloutPolicy

不應計算每一步的即時分差,每次都要往下探索至局終。

增加三個判斷,在所有可能的下法中,當某步棋符合這三者之一,則優先選擇:

1.當這步棋使本方得分

2.當這步棋使對方不能立即得分(也就是先假定輪到對方,對方落下某種棋子在某個位置就能得分,而本方的這步棋是另一種棋子,正好佔了對方能得分的位置的兩個三角形的其中一個)

3.當這步棋能卡位得分(卡位的介紹如下)

每種棋子有四個卡位。紅色棋子(設為[(x,y-1),(x,y)])的卡位是[(x,y-3),(x,y-2)]、[(x,y+1),(x,y+2)]、[(x-1,y),(x-1,y-1)]、[(x+1,y),(x+1,y+1)];橙色棋子(設為[(x,y),(x,y+1)])的卡位是[(x,y-2),(x,y-1)]、[(x,y+2),(x,y+3)]、[(x-1,y-1),(x-1,y)]、[(x+1,y+1),(x+1,y+2)];藍色棋子(設為[(x,y),(x+1,y)])的卡位是[(x-1,y-1),(x,y-1)]、[(x+1,y-1),(x+2,y-1)]、[(x-1,y+1),(x,y+1)]、[(x+1,y+1),(x+2,y+1)]。

注意卡位要得分才優先選擇,同時因為卡位棋子也是同種類的棋子,因此要看本方是否還有足夠的棋子卡位。

這三種優先選擇並不完全替代隨機落子,可用50%的比例,餘下的則是隨機落子。

本棋每局一共有36手棋,這三種優先選擇只能用在倒數第n手棋上(簡單倒數第4手,困難倒數第8手,專家倒數第12手,自訂倒數第16手)。把這個輸入框加進AI設置窗口中。

四,把探索常數也加進AI設置的選項中

探索常數在不同的級別為:簡單2.4,困難3.6,專家4.8,自訂6.0。

以上修改都要更新相應的英文翻譯。

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

代码: 全选

修復和改進:
一,去掉所有「節點最低訪問次數」的代碼。

去掉所有跟「節點最低訪問次數」相關的代碼,節點最低訪問次數等同於設為1。

二,手機端時的消息框顯示位置調整

當js檢測到是在手機端時,js將查找board-wrapper的左上角在頁面上的絕對坐標,再把消息框絕對定位到那個位置上。

電腦端則不變,不要改動原來的css。

三,改進rolloutPolicy

不應計算每一步的即時分差,每次都要往下探索至局終。

增加三個判斷,在所有可能的下法中,當某步棋符合這三者之一,則優先選擇:

1.當這步棋使本方得分

2.當這步棋使對方不能立即得分(也就是先假定輪到對方,對方落下某種棋子在某個位置就能得分,而本方的這步棋是另一種棋子,正好佔了對方能得分的位置的兩個三角形的其中一個)

3.當這步棋能卡位得分(卡位的介紹如下)

每種棋子有四個卡位。紅色棋子(設為[(x,y-1),(x,y)])的卡位是[(x,y-3),(x,y-2)]、[(x,y+1),(x,y+2)]、[(x-1,y),(x-1,y-1)]、[(x+1,y),(x+1,y+1)];橙色棋子(設為[(x,y),(x,y+1)])的卡位是[(x,y-2),(x,y-1)]、[(x,y+2),(x,y+3)]、[(x-1,y-1),(x-1,y)]、[(x+1,y+1),(x+1,y+2)];藍色棋子(設為[(x,y),(x+1,y)])的卡位是[(x-1,y-1),(x,y-1)]、[(x+1,y-1),(x+2,y-1)]、[(x-1,y+1),(x,y+1)]、[(x+1,y+1),(x+2,y+1)]。

注意卡位要得分才優先選擇,同時因為卡位棋子也是同種類的棋子,因此要看本方是否還有足夠的棋子卡位。

這三種優先選擇並不完全替代隨機落子,可用50%的比例,餘下的則是隨機落子。

本棋每局一共有36手棋,這三種優先選擇只能用在倒數第n手棋上(簡單倒數第4手,困難倒數第8手,專家倒數第12手,自訂倒數第16手)。把這個輸入框加進AI設置窗口中。

四,把探索常數也加進AI設置的選項中

探索常數在不同的級別為:簡單2.4,困難3.6,專家4.8,自訂6.0。

以上修改都要更新相應的英文翻譯。

需要明確給出所要修改的地方和所要替換的代碼。
附件
trirhombus281.html.7z
(25.49 KiB) 已下载 2 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro

代码: 全选

以下是針對您的四項要求所進行的程式碼修復與改進指南。請在您的 `trirhombus281.html` 檔案中尋找對應的段落並進行替換。

### 一、移除「節點最低訪問次數」相關代碼與調整 UI 面板

1. **更新 AI 預設配置與翻譯字典 (包含第四項的探索常數 C 及第三項的預判步數 N)**
請找到 `const aiConfigs = { ... }` 區塊與 `const I18N = { ... }` 區塊,並將其替換為以下內容:

```javascript
      const aiConfigs = {
        simple: {
          time: 12000, c: 2.4, heuristicN: 4,
          icon: ' <rect x="5" y="7" width="14" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="2" /> <line x1="12" y1="2" x2="12" y2="6" stroke="currentColor" stroke-width="2" /> <circle cx="9" cy="12" r="1.2" fill="currentColor" /> <circle cx="15" cy="12" r="1.2" fill="currentColor" /> '
        },
        hard: {
          time: 24000, c: 3.6, heuristicN: 8,
          icon: ' <rect x="2" y="9" width="2" height="6" rx="1" fill="currentColor" /> <rect x="20" y="9" width="2" height="6" rx="1" fill="currentColor" /> <rect x="5" y="6" width="14" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="2" /> <circle cx="9" cy="11" r="1.2" fill="currentColor" /> <circle cx="15" cy="11" r="1.2" fill="currentColor" /> <rect x="9" y="14" width="6" height="2" fill="currentColor" /> '
        },
        expert: {
          time: 36000, c: 4.8, heuristicN: 12,
          icon: '<path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z" fill="currentColor"/>'
        },
        custom: {
          time: 48000, c: 6.0, heuristicN: 16,
          icon: '<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/>'
        },
        human: {
          icon: '<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>'
        }
      };
      let isAiThinking = false;
      let abortAiSearch = false; 
      let mctsExplorationConstant = 3.6; 
      let mctsHeuristicN = 8; // 新增:智能預判步數

```

請在 `I18N` 字典中,移除 `mctsV`,並加入 `heuristicN` 翻譯:

```javascript
          // 在 zh 區塊中:
          mctsC: '探索常數C(越大隨機性越強)',
          heuristicN: '智能預判步數 (局末介入)',
          runSearch: '執行盤面搜尋分析',

          // 在 en 區塊中:
          mctsC: 'Exploration Constant C (Larger = More Random)',
          heuristicN: 'Heuristic Steps (Endgame)',
          runSearch: 'Run Board Search Analysis',

```

2. **更新 HTML 面板**
找到 `<div id="ai-modal" style="display: none" class="fullscreen-modal">` 中的設定區塊,刪除 `mcts-v`,並加入預判步數設定:

```html
        <div class="setting-row">
          <label id="label-mcts-c">探索常數C(越大隨機性越強)</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('mcts-c', -0.1, 0.1, 10, 1)">-</button>
            <input type="number" id="mcts-c" class="num-input" value="3.6" step="0.1" onchange="updateGlobalParams()" style="width: 60px" />
            <button class="num-btn" onclick="adjustGlobalParam('mcts-c', 0.1, 0.1, 10, 1)">+</button>
          </div>
        </div>

        <div class="setting-row">
          <label id="label-heuristic-n">智能預判步數 (局末介入)</label>
          <div class="num-ctrl">
            <button class="num-btn" onclick="adjustGlobalParam('heuristic-n', -1, 0, 36, 0)">-</button>
            <input type="number" id="heuristic-n" class="num-input" value="8" step="1" onchange="updateGlobalParams()" />
            <button class="num-btn" onclick="adjustGlobalParam('heuristic-n', 1, 0, 36, 0)">+</button>
          </div>
        </div>

```

同時記得更新 `toggleLanguage()` 函數中對應的標籤:

```javascript
        document.getElementById('label-mcts-c').innerText = getText('mctsC');
        if (document.getElementById('label-heuristic-n')) document.getElementById('label-heuristic-n').innerText = getText('heuristicN');

```

3. **更新選項連動邏輯**
替換 `selectAiTab`、`markCustom` 和 `updateGlobalParams` 這三個函數:

```javascript
      function selectAiTab(preset) {
        document.querySelectorAll('.ai-tab').forEach(tab => {
          if (tab.dataset.preset === preset) tab.classList.add('active');
          else tab.classList.remove('active');
        });
        if (aiConfigs[preset]) {
          document.getElementById('ai-time').value = aiConfigs[preset].time / 1000;
          document.getElementById('mcts-c').value = aiConfigs[preset].c.toFixed(1);
          document.getElementById('heuristic-n').value = aiConfigs[preset].heuristicN;
          updateGlobalParams();
        }
      }

      function markCustom() {
        let t = parseFloat(document.getElementById('ai-time').value) * 1000;
        let c = parseFloat(document.getElementById('mcts-c').value);
        let n = parseInt(document.getElementById('heuristic-n').value, 10);

        let matchedPreset = 'custom';
        for (let k of ['simple', 'hard', 'expert']) {
          if (aiConfigs[k].time === t && Math.abs(aiConfigs[k].c - c) < 0.01 && aiConfigs[k].heuristicN === n) {
            matchedPreset = k;
            break;
          }
        }
        document.querySelectorAll('.ai-tab').forEach(tab => {
          if (tab.dataset.preset === matchedPreset) tab.classList.add('active');
          else tab.classList.remove('active');
        });
        aiConfigs.custom.time = t;
        aiConfigs.custom.c = c;
        aiConfigs.custom.heuristicN = n;
      }

      function updateGlobalParams() {
        mctsExplorationConstant = parseFloat(document.getElementById('mcts-c').value);
        mctsHeuristicN = parseInt(document.getElementById('heuristic-n').value, 10);
        markCustom(); 
      }

```

### 二、手機端消息框位置調整

替換 `showNotification` 函數,讓手機端自動抓取棋盤容器的絕對座標:

```javascript
      function showNotification(msg, persist = false) {
        const box = document.getElementById('test-box');
        box.innerText = msg;
        box.style.opacity = '1';

        const isMobile = window.innerWidth <= 768;
        let isOpponentTurn = currentPlayer === 2;
        let rotateStr = isAutoRotate && isOpponentTurn ? 'rotate(180deg)' : 'none';

        box.style.transform = rotateStr;
        box.style.top = 'auto';
        box.style.bottom = 'auto';
        box.style.left = 'auto';
        box.style.right = 'auto';
        box.style.margin = '0';

        if (isMobile) {
          const wrapper = document.querySelector('.board-wrapper');
          const rect = wrapper.getBoundingClientRect();
          // 手機端強制對齊 board-wrapper 的絕對左上角
          box.style.top = rect.top + 'px';
          box.style.left = rect.left + 'px';
        } else {
          // 電腦端保持原位
          if (isOpponentTurn) {
            if (isAutoRotate) {
              box.style.top = '15px';
              box.style.left = '15px';
            } else {
              box.style.bottom = '15px';
              box.style.left = '15px';
            }
          } else {
            box.style.bottom = '15px';
            box.style.right = '15px';
          }
        }

        clearTimeout(notifyTimeout);
        if (!persist) {
          notifyTimeout = setTimeout(() => {
            box.style.opacity = '0';
          }, 1200);
        }
      }

```

### 三、改進 MCTS Rollout Policy (模擬策略)

此部分請在 `triggerAITurn` 函數的 **上方**,直接插入以下兩個全新的智能判斷與輔助函式 `checkKawei` 和 `getRolloutMove`:

```javascript
      // 卡位形狀驗證 (依據規則)
      function checkKawei(state, m) {
        let pid = (m >> 24) & 0xff;
        let pA = state._idxToCoord((m >> 12) & 0xfff);
        let pB = state._idxToCoord(m & 0xfff);
        if (pA.x > pB.x || (pA.x === pB.x && pA.y > pB.y)) { let t = pA; pA = pB; pB = t; }

        let checkHas = (dx1, dy1, dx2, dy2) => {
          return state.get(pA.x + dx1, pA.y + dy1) === (pid + 1) && state.get(pA.x + dx2, pA.y + dy2) === (pid + 1);
        };

        if (pid === 1) { // 紅色
          if (checkHas(0, 2, 0, 2) || checkHas(0, -2, 0, -2) || checkHas(1, 0, 1, 0) || checkHas(-1, -1, -1, -1)) return true;
        } else if (pid === 2) { // 橙色
          if (checkHas(0, 2, 0, 2) || checkHas(0, -2, 0, -2) || checkHas(1, 1, 1, 1) || checkHas(-1, -1, -1, -1)) return true;
        } else if (pid === 0) { // 藍色
          if (checkHas(1, 1, 1, 1) || checkHas(-1, 1, -1, 1) || checkHas(1, -1, 1, -1) || checkHas(-1, -1, -1, -1)) return true;
        }
        return false;
      }

      // 新的 50% 智能 Rollout 策略
      function getRolloutMove(simState, simPlayer, workPieces, heuristicN) {
        let moves = simState.getValidMoves(simPlayer, workPieces);
        if (moves.length === 0) return null;

        let p1Rem = workPieces[1][0] + workPieces[1][1] + workPieces[1][2];
        let p2Rem = workPieces[2][0] + workPieces[2][1] + workPieces[2][2];
        let movesLeft = p1Rem + p2Rem;

        // 當局步數到達倒數 N 步,且有 50% 機率啟動智能過濾
        if (movesLeft <= heuristicN && Math.random() < 0.5) {
          let priorityMoves = [];
          let baseScores = simState.calculateScores();
          let baseDiff = simPlayer === 1 ? (baseScores.p1Score - baseScores.p2Score) : (baseScores.p2Score - baseScores.p1Score);

          let myScoringMoves = new Set();
          
          // 1. 尋找本方得分著法
          for (let m of moves) {
            let pid = (m >> 24) & 0xff;
            let c1 = simState._idxToCoord((m >> 12) & 0xfff);
            let c2 = simState._idxToCoord(m & 0xfff);
            let r = simState.tryPlacePiece(pid + 1, c1, c2, true);
            if (r.success) {
              let newDiff = simPlayer === 1 ? (r.scores.p1Score - r.scores.p2Score) : (r.scores.p2Score - r.scores.p1Score);
              if (newDiff > baseDiff) myScoringMoves.add(m);
              simState.undo();
            }
          }

          // 2. 評估敵方可能得分位置 (假定輪到對方)
          let oppPlayer = simPlayer === 1 ? 2 : 1;
          let oppPieces = { 1: new Int8Array([6,6,6]), 2: new Int8Array([6,6,6]) };
          let oppMoves = simState.getValidMoves(oppPlayer, oppPieces);
          let oppBaseDiff = simPlayer === 1 ? (baseScores.p2Score - baseScores.p1Score) : (baseScores.p1Score - baseScores.p2Score);
          let oppScoringPieces = [];

          for (let m of oppMoves) {
            let pid = (m >> 24) & 0xff;
            let c1 = simState._idxToCoord((m >> 12) & 0xfff);
            let c2 = simState._idxToCoord(m & 0xfff);
            let r = simState.tryPlacePiece(pid + 1, c1, c2, true);
            if (r.success) {
              let newDiff = simPlayer === 1 ? (r.scores.p2Score - r.scores.p1Score) : (r.scores.p1Score - r.scores.p2Score);
              if (newDiff > oppBaseDiff) {
                oppScoringPieces.push({ pid, p1Idx: (m >> 12) & 0xfff, p2Idx: m & 0xfff });
              }
              simState.undo();
            }
          }

          // 3. 整合三大優先條件
          for (let m of moves) {
            let pid = (m >> 24) & 0xff;
            let p1Idx = (m >> 12) & 0xfff;
            let p2Idx = m & 0xfff;

            // 條件 1 (得分) 或 條件 3 (卡位且得分)
            if (myScoringMoves.has(m) || (myScoringMoves.has(m) && checkKawei(simState, m))) {
              priorityMoves.push(m);
              continue;
            }

            // 條件 2 (卡住對方得分位置,且為不同種類棋子)
            let isBlock = false;
            for (let oppM of oppScoringPieces) {
              if (oppM.pid !== pid && (p1Idx === oppM.p1Idx || p1Idx === oppM.p2Idx || p2Idx === oppM.p1Idx || p2Idx === oppM.p2Idx)) {
                isBlock = true;
                break;
              }
            }
            if (isBlock) priorityMoves.push(m);
          }

          if (priorityMoves.length > 0) {
            return priorityMoves[Math.floor(Math.random() * priorityMoves.length)];
          }
        }

        // 若無優先條件或未觸發 50% 機率,則回退至隨機
        return moves[Math.floor(Math.random() * moves.length)];
      }

```

最後,在 `triggerAITurn` 與 `runAISearch` 的程式碼中,尋找 `// Selection` 及 `// Simulation` 區塊,修改如下:

**將原本判斷訪問次數 `mctsMinVisits` 的邏輯改為 0:**

```javascript
            // Selection 區塊 (請在 triggerAITurn 及 runAISearch 中替換這段)
            while (node.unexpanded.length === 0 && node.children.length > 0) {
              let bestUCT = -Infinity, bestChild = null;
              for (let c of node.children) {
                let uct;
                if (c.visits === 0) { // 修改這裡:等同於最低訪問次數設為 1
                  uct = 10000 + Math.random();
                } else {
                  uct = c.wins / c.visits + mctsExplorationConstant * Math.sqrt(Math.log(node.visits) / c.visits);
                }
                if (uct > bestUCT) {
                  bestUCT = uct;
                  bestChild = c;
                }
              }
              // ... 原代碼 ...
            }

```

**將 Simulation 中隨機跑完的 while 迴圈替換成使用新的 Rollout 策略:**

```javascript
            // Simulation 區塊 (請在 triggerAITurn 及 runAISearch 中替換這段)
            let advantage;
            if (node.isTerminal) {
              advantage = node.terminalScore;
            } else {
              while (true) {
                // 修改這裡:使用新的 getRolloutMove 並傳入 mctsHeuristicN 參數
                let m = getRolloutMove(simState, simPlayer, workPieces, mctsHeuristicN);
                if (!m) break;
                
                let simPid = (m >> 24) & 0xff;
                let simP1Idx = (m >> 12) & 0xfff;
                let simP2Idx = m & 0xfff;
                simState.tryPlacePiece(
                  simPid + 1,
                  simState._idxToCoord(simP1Idx),
                  simState._idxToCoord(simP2Idx),
                  false
                );
                workPieces[simPlayer][simPid]--;
                simPlayer = simPlayer === 1 ? 2 : 1;
              }
              let scores = simState.calculateScores();
              advantage = scores.p1Score - scores.p2Score;
            }

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復和改進:
一,合並triggerAITurn和runAISearch相同的部分

將triggerAITurn和runAISearch這兩個函式中相同的部分合並,必要時可以將相同的代碼打包至一個函式中。

二,改進rolloutPolicy

檢查是否每次都往下探索至局終,而不是計算每一步的即時分差。

增加兩個判斷,在所有可能的下法中,當某步棋符合則優先選擇:

1.當這步棋使本方得分

2.當這步棋使對方不能立即得分(算法是先假定輪到對方,在對方所有可能的下法中能得分的棋位上的兩個三角形,我方所能下的棋中正好能佔到其中一個三角形)

這種優先選擇並不完全替代隨機落子,注意在實現時要特別高效,以防搜尋變慢。

本棋每局一共有36手棋,這三種優先選擇只能用在倒數第n手棋上,AI設置窗口以及代碼中已經存在這個變量,可以直接使用。

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

代码: 全选

修復和改進:
一,合並triggerAITurn和runAISearch相同的部分

將triggerAITurn和runAISearch這兩個函式中相同的部分合並,必要時可以將相同的代碼打包至一個函式中。

二,改進rolloutPolicy

檢查是否每次都往下探索至局終,而不是計算每一步的即時分差。

增加兩個判斷,在所有可能的下法中,當某步棋符合則優先選擇:

1.當這步棋使本方得分

2.當這步棋使對方不能立即得分(算法是先假定輪到對方,在對方所有可能的下法中能得分的棋位上的兩個三角形,我方所能下的棋中正好能佔到其中一個三角形)

這種優先選擇並不完全替代隨機落子,注意在實現時要特別高效,以防搜尋變慢。

本棋每局一共有36手棋,這三種優先選擇只能用在倒數第n手棋上,AI設置窗口以及代碼中已經存在這個變量,可以直接使用。

需要明確給出所要修改的地方和所要替換的代碼。
附件
trirhombus283.html.7z
(25.54 KiB) 已下载 1 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

gemini 3.1 pro 回答得非常乾淨

代码: 全选

我已經仔細分析了你的三菱棋程式碼。為了解決 `triggerAITurn` 與 `runAISearch` 的程式碼冗餘,並優化 MCTS 的 Rollout 策略(使 AI 在局末時能優先得分或阻擋對手得分),我為你準備了以下的修改方案。

請按照以下步驟替換程式碼:

### 第一步:新增共用的 `runMCTSBatch` 函式

在 `class MCTSNode` 之後(約第 1357 行附近),**新增**以下 `runMCTSBatch` 函式。這個函式打包了選擇、擴展、模擬(包含你的兩項局末啟發式優化)與反向傳播的過程,並確保每次探索都會下到局終(`moves.length === 0` 才跳出)。

```javascript
      // === 共用 MCTS 批次運算與智能預判引擎 ===
      function runMCTSBatch(root, rootState, workPieces, batchSize) {
        let iterations = 0;
        for (let i = 0; i < batchSize; i++) {
          if (root.unexpanded.length === 0 && root.children.length === 0) break;
          
          let node = root;
          let simState = rootState.clone();
          
          workPieces[1][0] = piecesLeft[1][0];
          workPieces[1][1] = piecesLeft[1][1];
          workPieces[1][2] = piecesLeft[1][2];
          workPieces[2][0] = piecesLeft[2][0];
          workPieces[2][1] = piecesLeft[2][1];
          workPieces[2][2] = piecesLeft[2][2];
          
          let simPlayer = currentPlayer;
          
          // 1. Selection
          while (node.unexpanded.length === 0 && node.children.length > 0) {
            let bestUCT = -Infinity, bestChild = null;
            for (let c of node.children) {
              let uct = c.visits < 1 
                ? 10000 + Math.random() 
                : (c.wins / c.visits) + mctsExplorationConstant * Math.sqrt(Math.log(node.visits) / c.visits);
              if (uct > bestUCT) { bestUCT = uct; bestChild = c; }
            }
            node = bestChild;
            let simPid = (node.move >> 24) & 0xff;
            simState.tryPlacePiece(simPid + 1, simState._idxToCoord((node.move >> 12) & 0xfff), simState._idxToCoord(node.move & 0xfff), false);
            workPieces[simPlayer][simPid]--;
            simPlayer = simPlayer === 1 ? 2 : 1;
          }
          
          // 2. Expansion
          if (node.unexpanded.length > 0) {
            let moveIdx = Math.floor(Math.random() * node.unexpanded.length);
            let move = node.unexpanded.splice(moveIdx, 1)[0];
            let simPid = (move >> 24) & 0xff;
            simState.tryPlacePiece(simPid + 1, simState._idxToCoord((move >> 12) & 0xfff), simState._idxToCoord(move & 0xfff), false);
            workPieces[simPlayer][simPid]--;
            simPlayer = simPlayer === 1 ? 2 : 1;
            
            let child = new MCTSNode(move, node, simState, simPlayer, workPieces);
            node.children.push(child);
            node = child;
          }
          
          // 3. Simulation (Rollout) - 保證一直探索至局終
          let advantage;
          if (node.isTerminal) {
            advantage = node.terminalScore;
          } else {
            while (true) {
              let moves = simState.getValidMoves(simPlayer, workPieces);
              if (moves.length === 0) break;
              
              let m;
              let movesPlayed = 36 - (workPieces[1][0]+workPieces[1][1]+workPieces[1][2] + workPieces[2][0]+workPieces[2][1]+workPieces[2][2]);
              
              // 啟發式探索:在局末 n 手內介入
              if ((36 - movesPlayed) <= mctsHeuristicN) {
                let baseScores = simState.calculateScores();
                let myBase = simPlayer === 1 ? baseScores.p1Score : baseScores.p2Score;
                let oppPlayer = simPlayer === 1 ? 2 : 1;
                let oppBase = oppPlayer === 1 ? baseScores.p1Score : baseScores.p2Score;
                
                let myScoringMoves = [];
                // 為了極致效能,每次最多只檢查 6 個隨機著法,避免嚴重拖慢搜尋
                let checkCount = Math.min(moves.length, 6); 
                
                // 條件一:找出能使我方得分的步
                for (let j = 0; j < checkCount; j++) {
                  let move = moves[j];
                  let simPid = (move >> 24) & 0xff;
                  let p1Idx = (move >> 12) & 0xfff;
                  let p2Idx = move & 0xfff;
                  let res = simState.tryPlacePiece(simPid + 1, simState._idxToCoord(p1Idx), simState._idxToCoord(p2Idx), false);
                  if (res.success) {
                    let newScore = simPlayer === 1 ? res.scores.p1Score : res.scores.p2Score;
                    simState.undo();
                    if (newScore > myBase) myScoringMoves.push(move);
                  }
                }
                
                if (myScoringMoves.length > 0) {
                  m = myScoringMoves[Math.floor(Math.random() * myScoringMoves.length)];
                } else {
                  // 條件二:找尋能阻擋對方得分的步
                  let oppMoves = simState.getValidMoves(oppPlayer, workPieces);
                  let blockingMoves = [];
                  let oppCheckCount = Math.min(oppMoves.length, 6);
                  for (let j = 0; j < oppCheckCount; j++) {
                    let move = oppMoves[j];
                    let simPid = (move >> 24) & 0xff;
                    let p1Idx = (move >> 12) & 0xfff;
                    let p2Idx = move & 0xfff;
                    let res = simState.tryPlacePiece(simPid + 1, simState._idxToCoord(p1Idx), simState._idxToCoord(p2Idx), false);
                    if (res.success) {
                      let newScore = oppPlayer === 1 ? res.scores.p1Score : res.scores.p2Score;
                      simState.undo();
                      if (newScore > oppBase) {
                        for (let k = 0; k < moves.length; k++) {
                          let myP1 = (moves[k] >> 12) & 0xfff;
                          let myP2 = moves[k] & 0xfff;
                          if (myP1 === p1Idx || myP1 === p2Idx || myP2 === p1Idx || myP2 === p2Idx) {
                            blockingMoves.push(moves[k]);
                          }
                        }
                      }
                    }
                  }
                  if (blockingMoves.length > 0) {
                    m = blockingMoves[Math.floor(Math.random() * blockingMoves.length)];
                  } else {
                    m = moves[Math.floor(Math.random() * moves.length)];
                  }
                }
              } else {
                // 一般隨機落子
                m = moves[Math.floor(Math.random() * moves.length)];
              }
              
              let simPid = (m >> 24) & 0xff;
              simState.tryPlacePiece(simPid + 1, simState._idxToCoord((m >> 12) & 0xfff), simState._idxToCoord(m & 0xfff), false);
              workPieces[simPlayer][simPid]--;
              simPlayer = simPlayer === 1 ? 2 : 1;
            }
            let scores = simState.calculateScores();
            advantage = scores.p1Score - scores.p2Score;
          }
          
          // 4. Backpropagation
          let scaledAdvantage = advantage / 20.0;
          let curr = node;
          while (curr !== null) {
            curr.visits++;
            if (curr.player === 1) {
              curr.wins += -scaledAdvantage;
            } else {
              curr.wins += scaledAdvantage;
            }
            curr = curr.parent;
          }
          iterations++;
        }
        return iterations;
      }

```

---

### 第二步:改寫 `triggerAITurn` 函式

找到 `async function triggerAITurn()` 並將內部的 `while` 迴圈內容大幅替換。整個函式應改寫如下:

```javascript
      async function triggerAITurn() {
        if (gameState !== 'playing' || isAiThinking) return;
        isAiThinking = true;
        abortAiSearch = false; // 重置中斷狀態

        // 判斷是否為第一手,自動觸發中心落子
        let roundStart = currentRound === 2 ? 36 : 0;
        let movesInRound = historyIndex - roundStart + 1;
        if (movesInRound === 0) {
          autoPlayFirstBlue(currentPlayer);
          isAiThinking = false;
          return;
        }

        let pType = playerTypes[currentPlayer];
        let config = aiConfigs[pType] || aiConfigs['custom'];
        let timeLimit = config.time;

        const box = document.getElementById('test-box');
        box.style.opacity = '1';

        let rootState = gameLogic.clone();
        let root = new MCTSNode(null, null, rootState, currentPlayer, piecesLeft);

        let startTime = Date.now();
        let lastUiTime = startTime;
        let iterations = 0;

        // 在迴圈外預先建立工作區陣列,避免大量產生垃圾物件引發 GC 卡頓
        let workPieces = { 1: new Int8Array(3), 2: new Int8Array(3) };

        while (Date.now() - startTime < timeLimit) {
          if (abortAiSearch) {
            isAiThinking = false;
            box.style.opacity = '0';
            return; // 偵測到中斷指令,立刻退出搜尋
          }

          // 呼叫共用 MCTS 批次運算,一次執行 20 次
          let ran = runMCTSBatch(root, rootState, workPieces, 20);
          iterations += ran;
          if (ran === 0) break; // 若樹已走盡則跳出

          let now = Date.now();
          if (now - lastUiTime > 100) {
            box.innerText = `AI 思考中...\n時間: ${((now - startTime) / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s\n次數: ${iterations}`;
            await new Promise(r => setTimeout(r, 0));
            lastUiTime = now;
          }
        }

        let bestChild = null,
          mostVisits = -1;
        for (let c of root.children) {
          if (c.visits > mostVisits) {
            mostVisits = c.visits;
            bestChild = c;
          }
        }

        let finalTime = ((Date.now() - startTime) / 1000).toFixed(1);
        if (bestChild) {
          let winRate = (((bestChild.wins / bestChild.visits + 1) / 2) * 100).toFixed(1);
          box.innerText = `思考完成: ${finalTime}s\n當前勝率: ${winRate}%`;

          let pid = (bestChild.move >> 24) & 0xff;
          let p1Idx = (bestChild.move >> 12) & 0xfff;
          let p1Coord = gameLogic._idxToCoord(p1Idx);

          currentSelectedPiece = pid;
          isFirstMove = false;

          let halfWidth = 31.1769;
          let isRight = gameLogic.isRight(p1Coord.x, p1Coord.y);
          let cx = isRight ? (p1Coord.x - 1) * halfWidth + 10.392 : (p1Coord.x - 1) * halfWidth + 20.784;

          let tri = { idx: p1Coord.x, N: p1Coord.y, isRight: isRight, cx: cx, cy: p1Coord.y * 18 };
          attemptDrop(tri);
        } else {
          box.innerText = `無合法著法`;
          setTimeout(() => (box.style.opacity = '0'), 2000);
        }
        isAiThinking = false;
      }

```

---

### 第三步:改寫 `runAISearch` 函式

同樣地,找到 `async function runAISearch()` 並替換內部的 `while` 迴圈。改寫後的函式主體應如下:

```javascript
      // === AI 分析搜尋引擎與繪製樹狀圖 ===
      async function runAISearch() {
        if (gameState !== 'playing') {
          alert(getText('gameNotStarted'));
          return;
        }

        abortAiSearch = false; // 防護性重置

        const resDiv = document.getElementById('ai-results');
        const statusDiv = document.getElementById('ai-search-status');
        const btn = document.getElementById('ai-search-btn');

        resDiv.innerHTML = '';
        btn.disabled = true;
        btn.style.opacity = '0.5';

        let timeLimit = parseFloat(document.getElementById('ai-time').value) * 1000;

        let rootState = gameLogic.clone();
        let root = new MCTSNode(null, null, rootState, currentPlayer, piecesLeft);

        let startTime = Date.now();
        let lastUiTime = startTime;
        let iterations = 0;

        statusDiv.innerHTML = `搜尋中... 經過時間: 0.0s / ${(timeLimit / 1000).toFixed(0)}s`;

        // 建立唯一工作陣列區
        let workPieces = { 1: new Int8Array(3), 2: new Int8Array(3) };

        while (Date.now() - startTime < timeLimit) {
          if (abortAiSearch) break; // 若在分析中途被觸發中斷,直接退出

          // 呼叫共用 MCTS 批次運算,分析模式一次執行 30 次以提升效能
          let ran = runMCTSBatch(root, rootState, workPieces, 30);
          iterations += ran;
          if (ran === 0) break;

          let now = Date.now();
          if (now - lastUiTime > 100) {
            statusDiv.innerHTML = `搜尋中... 經過時間: ${((now - startTime) / 1000).toFixed(1)}s / ${(timeLimit / 1000).toFixed(0)}s | 模擬次數: ${iterations}`;
            await new Promise(r => setTimeout(r, 0));
            lastUiTime = now;
          }
        }

        statusDiv.innerHTML = `✅ 搜尋完成!總耗時: ${((Date.now() - startTime) / 1000).toFixed(1)}s / 總次數: ${iterations}`;
        btn.disabled = false;
        btn.style.opacity = '1';

        root.children.sort((a, b) => b.visits - a.visits);

        if (root.children.length === 0) {
          resDiv.innerHTML = '<div style="text-align:center; padding: 20px; color: #888;">無合法著法</div>';
        } else {
          for (let child of root.children) {
            resDiv.appendChild(buildTreeNodeDOM(child, true, currentPlayer));
          }
        }
      }

```
https://ejsoon.vip/
弈趣極光:享受思維樂趣
头像
ejsoon
一枝独秀
帖子: 6246
注册时间: 2022年 11月 18日 17:36
联系:

Re: 準備為三菱棋做遊戲和MCTS搜尋

帖子 ejsoon »

修復和改進:
一,新增AI探索完則中止思考功能

當棋局進入尾聲時,是有可能把所有的下法都完全搜尋的。現在的代碼並不會因為所有的下法都被搜尋過就停止搜尋。

增加功能,如果所有的下法都已經全部跑過,則AI將中止思考立即落子。

二,檢查和改進rolloutPolicy

現在的「使 AI優先得分或阻擋對手得分」策略是用在倒數第n手棋上,檢查代碼,是否在從倒數第n手棋開始往後的每一手rollout都用上這個策略,而不只是「第n手棋」。比如n是16,則從倒數第16手開始,到倒數第15手,倒數第14手……到倒數第1手,都要用上這個優先策略。

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

代码: 全选

修復和改進:
一,新增AI探索完則中止思考功能

當棋局進入尾聲時,是有可能把所有的下法都完全搜尋的。現在的代碼並不會因為所有的下法都被搜尋過就停止搜尋。

增加功能,如果所有的下法都已經全部跑過,則AI將中止思考立即落子。

二,檢查和改進rolloutPolicy

現在的「使 AI優先得分或阻擋對手得分」策略是用在倒數第n手棋上,檢查代碼,是否在從倒數第n手棋開始往後的每一手rollout都用上這個策略,而不只是「第n手棋」。比如n是16,則從倒數第16手開始,到倒數第15手,倒數第14手……到倒數第1手,都要用上這個優先策略。

需要明確給出所要修改的地方和所要替換的代碼。
附件
trirhombus284.html.7z
(26.11 KiB) 已下载 1 次
https://ejsoon.vip/
弈趣極光:享受思維樂趣
回复
  • 相似主题
    回复总数
    阅读次数
    最新帖子

在线用户

正浏览此版面之用户: 没有注册用户 和 17 访客