在debug時發現,某些可行的路線只走過一次,這樣得到的期望值是不準確的。為每個AI增加一個「最少測試局數」,不同AI等級的默認數量分別為「簡單60、困難120、專家180、自訂240」,用這個最少測試局數來得出當前節點的期望值。
代码: 全选
這是一個非常精準的觀察!在標準的 MCTS 中,如果一個節點只被隨機模擬(Playout)了一次,它得到的「期望值」充滿了隨機噪音。如果那一次隨機模擬剛好讓 AI 走進死胡同,這個原本極佳的路線就會被嚴重低估而遭到拋棄。
為剛擴展的節點強制進行「最少測試局數」(Multiple Rollouts per Node),可以有效抹平單次模擬的極端誤差,讓初始期望值更具參考價值。
以下是針對你的需求進行的程式碼修改,以及對期望值算法的解析。
---
### 一、 替換與新增程式碼
#### 1. 新增全域變數
請在程式碼最上方尋找 `let MCTS_DEPTH = 14;`,在其下方加入 `MIN_SIMULATIONS`:
**替換這段:**
```javascript
let aiStrength = 4000; // 預設困難
let SURE_KILL_DEPTH = 6; // 預設必殺深度
let MCTS_DEPTH = 14; // 預設 MCTS 模擬深度
let currentTurnColor = ''; // 追蹤目前是誰的回合
```
**改為:**
```javascript
let aiStrength = 4000; // 預設困難
let SURE_KILL_DEPTH = 6; // 預設必殺深度
let MCTS_DEPTH = 14; // 預設 MCTS 模擬深度
let MIN_SIMULATIONS = 120; // 預設最少測試局數
let currentTurnColor = ''; // 追蹤目前是誰的回合
```
#### 2. 更新主畫面下拉選單 (HTML)
請找到 `<div id="ai-strength-options" class="custom-select-options">` 區塊,替換為包含 `data-sim` 屬性與新文字的版本:
**替換為這段:**
```html
<div id="ai-strength-options" class="custom-select-options">
<div class="csinger-option strength-option" id="opt-ai-easy" data-value="1000" data-depth="4" data-mcts="12" data-sim="60">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<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" />
</svg>
簡單 (1s, 必殺 4, 模擬 12, 測試 60)
</div>
<div class="csinger-option strength-option" id="opt-ai-hard" data-value="4000" data-depth="6" data-mcts="14" data-sim="120">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<rect x="2" y="9" width="2" height="6" rx="1" />
<rect x="20" y="9" width="2" height="6" rx="1" />
<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" />
<circle cx="15" cy="11" r="1.2" />
<rect x="9" y="14" width="6" height="2" />
</svg>
困難 (4s, 必殺 6, 模擬 14, 測試 120)
</div>
<div class="csinger-option strength-option" id="opt-ai-expert" data-value="7000" data-depth="8" data-mcts="16" data-sim="180">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<rect x="7" y="7" width="10" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M3 9h4M3 15h4M17 9h4M17 15h4M9 3v4M15 3v4M9 17v4M15 17v4" stroke="currentColor" stroke-width="2" />
</svg>
專家 (7s, 必殺 8, 模擬 16, 測試 180)
</div>
<div class="csinger-option strength-option" id="opt-custom-ai" data-value="10000" data-depth="10" data-mcts="18" data-sim="240">
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor">
<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" />
</svg>
自定義 (10s, 必殺 10, 模擬 18, 測試 240)
</div>
</div>
```
#### 3. 更新設置面板 (HTML)
請找到 `<div id="modal-ai-presets"` 區塊內的四個 `<button>`,為它們加上 `data-sim` 屬性:
* `modal-preset-easy` 加上 `data-sim="60"`
* `modal-preset-hard` 加上 `data-sim="120"`
* `modal-preset-expert` 加上 `data-sim="180"`
* `modal-preset-custom` 加上 `data-sim="240"`
接著,找到 `lbl-mcts-depth` 所在的 `<div class="setting-row">`,在它**下方**插入新的輸入框:
```html
<div class="setting-row">
<label id="lbl-min-sim">最少測試局數:</label>
<div style="display: flex; align-items: center; gap: 5px">
<button id="btn-sim-minus" style="padding: 4px 10px; cursor: pointer; background: #34495e; color: white; border: 1px solid #7f8c8d; border-radius: 4px;">-</button>
<input type="number" id="inp-min-sim" step="10" min="10" max="1000" style="text-align: center" />
<button id="btn-sim-plus" style="padding: 4px 10px; cursor: pointer; background: #34495e; color: white; border: 1px solid #7f8c8d; border-radius: 4px;">+</button>
</div>
</div>
```
#### 4. 套用至 MCTS 核心邏輯 (JS)
請找到 `planFullTurnActionsMCTS` 函式中展開節點與模擬的地方:
**替換這段:**
```javascript
if (!node.isFullyExpanded() && !node.isTerminal()) {
node = node.expand();
}
// 接入玩家設定的 MCTS_DEPTH
const simScore = simulateRandomPlayout(node.state, aiColor, MCTS_DEPTH);
node.backpropagate(simScore);
```
**改為:**
```javascript
if (!node.isFullyExpanded() && !node.isTerminal()) {
node = node.expand();
// 對新擴展的節點,強制執行 N 次模擬以穩定初始期望值
for (let i = 0; i < MIN_SIMULATIONS; i++) {
const simScore = simulateRandomPlayout(node.state, aiColor, MCTS_DEPTH);
node.backpropagate(simScore);
}
iterations += MIN_SIMULATIONS; // 校正迭代次數的顯示
} else {
// 如果是已完全擴展的終端節點,執行單次模擬即可
const simScore = simulateRandomPlayout(node.state, aiColor, MCTS_DEPTH);
node.backpropagate(simScore);
iterations++;
}
```
*(注意:你也需要將原本下方的 `iterations++;` 刪除,因為我已經將其整合進 `if/else` 區塊中)*
#### 5. 同步 UI 操作邏輯 (JS)
在程式碼尾端,更新對應的事件與語言切換:
**A. 下拉選單點擊:**
```javascript
document.querySelectorAll('.strength-option').forEach(opt => {
opt.addEventListener('click', () => {
aiStrength = parseInt(opt.getAttribute('data-value')) || 4000;
SURE_KILL_DEPTH = parseInt(opt.getAttribute('data-depth')) || 6;
MCTS_DEPTH = parseInt(opt.getAttribute('data-mcts')) || 14;
MIN_SIMULATIONS = parseInt(opt.getAttribute('data-sim')) || 120; // 新增這行
aiStrengthTrigger.innerHTML = opt.querySelector('svg').outerHTML;
aiStrengthTrigger.setAttribute('data-value', aiStrength);
aiStrengthTrigger.setAttribute('data-depth', SURE_KILL_DEPTH);
aiStrengthTrigger.setAttribute('data-mcts', MCTS_DEPTH);
aiStrengthTrigger.setAttribute('data-sim', MIN_SIMULATIONS); // 新增這行
aiStrengthOptions.classList.remove('open');
});
});
```
**B. 語言切換 (`updateUIAfterLangChange`):**
加入 `lbl-min-sim` 的翻譯:
```javascript
const lblSim = document.getElementById('lbl-min-sim'); // 新增
if (lblSim) lblSim.innerText = window.currentLang === 'zh' ? '最少測試局數:' : 'Min Simulations:'; // 新增
```
更新選項文字:
```javascript
const easyOpt = document.getElementById('opt-ai-easy');
if (easyOpt) easyOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 簡單 (1s, 必殺 4, 模擬 12, 測試 60)' : ' Easy (1s, Kill 4, Sim 12, Test 60)';
const hardOpt = document.getElementById('opt-ai-hard');
if (hardOpt) hardOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 困難 (4s, 必殺 6, 模擬 14, 測試 120)' : ' Hard (4s, Kill 6, Sim 14, Test 120)';
const expertOpt = document.getElementById('opt-ai-expert');
if (expertOpt) expertOpt.lastChild.nodeValue = window.currentLang === 'zh' ? ' 專家 (7s, 必殺 8, 模擬 16, 測試 180)' : ' Expert (7s, Kill 8, Sim 16, Test 180)';
```
**C. 同步 Custom 選項 (`syncCustomOption`):**
```javascript
const currentSim = parseInt(document.getElementById('inp-min-sim').value); // 新增
// ...
if (customOpt) {
customOpt.setAttribute('data-value', msTime);
customOpt.setAttribute('data-depth', currentDepth);
customOpt.setAttribute('data-mcts', currentMcts);
customOpt.setAttribute('data-sim', currentSim); // 新增
const langDepth = window.currentLang === 'zh' ? '必殺' : 'Kill';
const langMcts = window.currentLang === 'zh' ? '模擬' : 'Sim';
const langSim = window.currentLang === 'zh' ? '測試' : 'Test';
const langCustom = window.currentLang === 'zh' ? '自定義' : 'Custom';
customOpt.innerHTML = `<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><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"/></svg> ${langCustom} (${msTime / 1000}s, ${langDepth} ${currentDepth}, ${langMcts} ${currentMcts}, ${langSim} ${currentSim})`;
}
```
**D. 預設按鈕高亮 (`updateAIPresetsHighlight`):**
```javascript
const s = parseInt(document.getElementById('inp-min-sim').value); // 新增
// ...
const sim = parseInt(btn.getAttribute('data-sim')); // 新增
if (val === t && dep === d && m === mcts && s === sim && btn.id !== 'modal-preset-custom') { // 更新判斷
```
記得在 `btn-settings` 開啟 Modal 的事件中加入:
```javascript
document.getElementById('inp-min-sim').value = MIN_SIMULATIONS;
```
並在 `presetBtns.forEach` 點擊事件中套用數值:
```javascript
document.getElementById('inp-min-sim').value = isCustom ? 240 : parseInt(btn.getAttribute('data-sim'));
```
在 `btn-modal-search` 點擊事件中抓取:
```javascript
MIN_SIMULATIONS = parseInt(document.getElementById('inp-min-sim').value) || 120;
```
最後綁定輸入框按鈕:
```javascript
setupNumberInput('inp-min-sim', 'btn-sim-minus', 'btn-sim-plus', 10, 1000); // 步長可根據 setupNumberInput 自行微調
```
---
### 二、 當前期望值的算法解析
目前程式碼中的期望值(Expected Value)計算邏輯可以拆分為兩個階段:**模擬計分**與**反向傳播平均**。
#### 1. 模擬計分 (Simulation Score)
在 `simulateRandomPlayout` 走到終點(或達到深度極限)時,會為當前局面打一個分數:
$$Score = ScoreDiff + (aiScore \times 500) - (oppScore \times 500)$$
* **權重極大化**:這是一種啟發式評估(Heuristic Evaluation)。AI 的分數乘上了 `500` 這個巨大權重,意味著「實際得分」的價值遠高於普通的「話筒數量優勢 (`scoreDiff`)」。
* **必勝/必敗**:如果觸發了 `aiScore >= 6`,直接回傳 $+10000$ 加上優勢;反之則是 $-10000$。
#### 2. 期望值與勝率轉換
這個 `Score` 透過 `node.backpropagate()` 一路向上累加到根節點,期望值即為:
$$Expected Score = \frac{Total Score}{Visits}$$
然後為了在 UI 上顯示為 $0\% \sim 100\%$ 的勝率,我們使用了一個 Sigmoid 函數來平滑化:
$$Win Rate = \frac{1}{1 + e^{-\frac{Expected Score}{200}}} \times 100\%$$
*(分母的 200 是溫度參數,用來將大幾百的分數縮放至合理的曲線範圍)*
---
### 三、 未來的改進方向
除了強制多次測試外,還有幾個能讓 MCTS 大幅進化的經典做法:
1. **模擬策略 (Playout Policy) 替換純隨機**:
目前的 `Math.floor(Math.random() * validMoves.length)` 是「純隨機推演」。純隨機會讓 AI 在模擬時做出極度愚蠢的行為(例如放著對手的話筒不吃,跑去撞牆)。
* **改進**:實作一個 **$\epsilon$-greedy (貪婪隨機) 策略**。在模擬時,有 70% 的機率隨機走,30% 的機率「強制選擇能得分或阻擋對手」的路線。這樣推演出來的期望值會更貼近「兩個高手對弈」的真實局勢。
2. **分數歸一化 (Score Normalization)**:
目前你的 UCT 公式是 `expectedScore + C * sqrt(ln(N)/n)`,其中 $C=150$。因為你的 `expectedScore` 是在正負幾千之間浮動的未收斂數值,這導致 $C=150$ 在某些局面下無法有效平衡「探索 (Exploration)」與「利用 (Exploitation)」。
* **改進**:在 `getBestUCTChild` 中,將所有子節點的期望分數動態對齊到 $[0, 1]$ 或 $[-1, 1]$ 之間,再使用標準的 $C = \sqrt{2}$。
3. **漸進式擴展 (Progressive Widening)**:
現在你的 `generateAllValidTurns` 會一次性把所有合法步(有時高達幾十種)全部生成並加入 `unexpandedMoves`。如果有 50 種走法,每種走法擴展時還要測試 240 次,很容易浪費算力在明顯是死路的選項上。
* **改進**:利用一個簡單的靜態評估函數,先為所有合法步打分,只取前 $K$ 個(例如前 8 名)最有可能的走法加入 MCTS 樹中進行深度模擬。