共享会话
下面是claude對於MCTS搜尋的改進意見,請你給出你的評價,是否同意它的觀點,以及你還有什麼更好的改進辦法。最後給…
分享于 2026年9月13日 00:58下面是claude對於MCTS搜尋的改進意見,請你給出你的評價,是否同意它的觀點,以及你還有什麼更好的改進辦法。最後給出你的改進策略方向:我已經把 #mcts-worker-source 裡的完整 MCTS worker 程式碼讀過一遍,以下是分析結果。
1. 有沒有用 bitboard?
沒有。 棋盤是用 Int8Array(49) 存放三態值(1=火、-1=冰、0=空),島嶼歸屬用 Int8Array(7):
const rootState = {
board: Int8Array.from(data.state.board), // 49 格逐格存
claims: Int8Array.from(data.state.claims), // 7 島逐島存
...
};判斷連線(hasIslandLine)、產生合法步(legalMoves)都是逐格迴圈掃描,不是位元運算。以只有 49 個位置來說,這其實非常適合改成 bitboard(例如兩個 49-bit 佔位遮罩:fireBits、iceBits,用兩個 32-bit int 拼接或一個 BigInt 表示),好處是:
- 複製盤面從「複製 49 個位元組的陣列」變成「複製 1~2 個整數」,成本趨近 O(1)。
- 每條線(7 島 × 7 線=49 條)可預先算好 bitmask,判斷是否連線只要
(bits & lineMask) === lineMask,比逐格比對快很多。 - 空格判斷、合法步產生也能用位元運算加速。
2. 有沒有用增量運算?
只有一部分。 applyMove 裡對「島嶼歸屬(claims)」是有做增量處理的——只重新計算被移動影響到的 1~2 個島(起點島、終點島),而不是全部 7 島都重算:
claims[originIsland] = islandOwner(board, originIsland);
if (destinationIsland !== originIsland) {
claims[destinationIsland] = islandOwner(board, destinationIsland);
}但除此之外,幾乎都是「整份重算」而非真正的增量/make-unmake:
- 盤面本身:每次
applyMove都board.slice()+claims.slice()整份複製一份新陣列,不是原地修改+記錄復原資訊(make/unmake)。 hasIslandLine/islandOwner:每次呼叫都重新掃描該島的 7 條線,沒有維護「每條線上己方/對方各有幾子」的計數表。metaPotential:rollout 每一步都重新掃描全部 7 條 meta-line 算分,沒有增量更新。legalMoves:每個節點、每個 rollout 深度都重新掃描全部 49 格找己方棋子,沒有維護「己方棋子位置列表」。- MCTS 樹本身:AI 每次思考都是從一個全新的
root開始重建整棵樹(self.onmessage每次都new Node(rootState)),並沒有把上一次搜尋樹裡對應到實際走法的子樹保留下來繼續用(tree reuse)。
3. 可以改進的地方
表示法層面
- 改用 bitboard(雙位元遮罩)取代
Int8Array(49),盤面複製、連線判斷都能大幅加速,也讓「複製整份陣列」這個目前最頻繁的操作幾乎免費。 - 把
move從物件{from, to}編碼成單一整數(from * 49 + to,最大 2400,遠小於 16 位元),改用Int16Array存合法步清單,減少物件配置與 GC 壓力(目前legalMoves每次呼叫都 new 一堆小物件,MCTS 動輒百萬次迭代,這是很可觀的分配量)。
運算層面
- 把「整份複製」換成「原地修改 + undo 堆疊」的 make/unmake 模式,避免每個節點、每個 rollout 步驟都配置新的 typed array。
- 維護每條線(49 條)上雙方棋子數的計數表,走一步只更新該格所屬的線(每格最多屬於固定幾條線),把
islandOwner/連線判斷、metaPotential都改成增量更新而非整表重掃。 - 用棋子位置列表(每方最多幾顆棋)取代「掃描 49 格找己方棋子」來產生合法步。
搜尋演算法層面
- 樹重用(tree reuse):目前每次都從頭建樹,可以在真正落子後,把對應該步的子節點提升為新的 root,保留其 visits/value 統計繼續搜,省掉大量重複模擬。
- Transposition table:有 bitboard 之後可以直接拿盤面位元當 hash key,同一盤面經不同路徑到達時共用節點統計,避免重複展開/模擬。
- Rollout 輕量化:目前每次迭代都是完整 random/heuristic playout 跑到終局(最深 56 步),成本較高;可以考慮較短的截斷式 rollout 搭配簡單評估函式,或加入 RAVE/AMAF 讓早期統計收斂更快。
- 平行化:目前只有單一 Web Worker 跑序列 MCTS,若想在同樣時間預算內拿到更多 iterations,可以開多個 worker 做 root parallelization(各自跑一段時間後合併 visits 選最佳步)。
selectChild目前的 UCT 公式沒有標準的2×探索常數(屬設計選擇,靠使用者調的exploration補償),也沒有 first-play urgency/progressive widening,可視需要加入讓早期展開更聰明。
這些改動裡,bitboard 化+增量連線計數表這兩項對效能提升最直接,而且會讓後面的 make/unmake、transposition table 都更容易做。
QuanAI我會先核對附件中的 MCTS worker 實作、遊戲規則與熱路徑,確認上述判斷是否準確,再依「收益/風險/實作成本」提出更合適的優先順序。
總體評價
我大約 同意 Claude 的 80%。它對現有程式碼的事實判斷基本準確:
- 的確沒有 bitboard。
applyMove()的確頻繁複製board、claims。- 合法步使用大量
{from, to}物件。 - worker 每次搜尋都重新建立、完成後終止,因此沒有 tree reuse。
- rollouts、局部連線和 meta 評估都有不少重複計算。
但我不同意它的部分優先順序,也有幾個技術細節需要修正。目前最大的瓶頸很可能不是單純「掃描 49 格」,而是 rollout 中的大量狀態配置、走法物件配置,以及重複試走候選步。
一、逐項評價 Claude 的觀點
| Claude 的觀點 | 我的評價 | 補充 |
|---|---|---|
| 改用 bitboard | 同意 | 但 JavaScript BigInt 不一定比 Int8Array 快,應優先考慮多個 32-bit 整數 |
| move 編碼成整數 | 強烈同意 | 建議 (from << 6) | to,比 from * 49 + to 更容易解碼 |
| make/unmake | 強烈同意,且應是最高優先級之一 | 它比單純 bitboard 更能直接消除 GC 和 typed-array 配置 |
| 維護 49 條線計數 | 部分同意 | 可以做,但 bitboard 或小型查表可能更簡單、更快 |
| 維護棋子位置列表 | 部分同意 | bitboard set-bit iteration 可能已經足夠,不一定需要額外同步列表 |
| tree reuse | 同意 | 但需要改 worker 生命周期和統計值視角,不能只把 child 當 root |
| transposition table | 原則同意,但不能直接共用 Node | DAG MCTS 要使用搜尋路徑回傳,最好保留 edge-local 統計 |
| 截斷 rollout | 有條件同意 | 需要更好的非終局評估,否則速度提升可能換來更大偏差 |
| RAVE/AMAF | 低優先級 | 這個遊戲的走法效果非常依賴局面和順序,AMAF 假設未必適合 |
| 多 worker 平行化 | 同意,但放後面 | 先修單 worker 的配置與演算法浪費,收益通常更划算 |
UCT 缺少標準 2× | 不同意這是問題 | 2 或 sqrt(2) 本來就可以吸收到 exploration 常數中 |
| FPU / progressive widening | 可以考慮 | 但有領域先驗的走法排序,比單純 FPU 更重要 |
二、Claude 有兩個需要修正的地方
1. 「最多 rollout 56 步」不完全正確
程式碼的迴圈上限的確是 56:
for (let depth = 0; depth < 56; depth++)但本遊戲每走一步:
- 目的格由空變成己方棋子。
- 起點不會變空,而是變成對方棋子。
因此每步固定增加一個已佔用格。初始有一枚棋子,所以合法對局最多再走:
49−1=48步。
因此從初始局面開始,完整 rollout 最深實際上是 48 個落子,不會真的走到 56 步。56 只是安全上限。
這也帶來一個重要優勢:遊戲狀態是單調的、沒有循環,很適合:
- exact endgame solver;
- transposition table;
- solved-node propagation;
- alpha-beta 尾局求解。
這一點 Claude 沒有充分利用。
2. UCT 並沒有真正「少一個 2」
目前公式是:
mean + exploration * Math.sqrt(logarithm / child.visits)常見公式可能寫成:
Xˉj+CnjlnN也可能寫成:
Xˉj+nj2lnN兩者只是把 2 放進或移出探索常數。現在自訂值預設為 1.4,本身就接近 2。所以這不是錯誤,只是探索常數的定義不同。
真正需要做的是透過 self-play 調參,而不是機械地乘上一個 2。
三、Claude 漏掉的最大問題:rollout 配置量非常大
這段才很可能是目前的主要熱點:
const samples = Math.min(moves.length, 2 + Math.floor(strength * 6));
for (let n = 0; n < samples; n++) {
const move = moves[(Math.random() * moves.length) | 0];
const next = applyMove(state, move);
...
}以 custom AI 為例:
strength = 0.88- 88% 機率進行 heuristic 選步
- 一次最多抽樣 7 個候選
- 每一個候選都執行一次
applyMove - 每次
applyMove都配置:- 一份
board.slice() - 一份
claims.slice() - 一個新 state 物件
- 一份
一個完整 rollout 可能因此產生數百份 typed array 和 state 物件。此外,legalMoves() 還會配置大量 {from, to}。
更嚴重的是:目前候選是「有放回抽樣」
同一個走法可能在一次 rolloutChoice() 中被抽到兩次甚至更多次,導致完全重複的計算。
因此我認為優先級應是:
- 消除 rollout 中的狀態配置。
- 走法改整數。
- 候選無放回抽樣。
- 然後才是更細緻的逐格掃描優化。
四、Claude 漏掉的第二個重要問題:目前 heuristic 在前期幾乎是隨機的
rolloutChoice() 的評估只使用:
countClaims()metaPotential()- 終局勝負
也就是說,它主要只看「島是否已經被控制」以及「已控制島的 meta-line」。
但在前期,大多數走法都不會立即改變島嶼歸屬。這時候:
countClaims(next.claims, player) - beforeOwn
metaPotential(next.claims, player) - beforeOwnPotential通常全部是零,最後候選的差異主要只剩:
let score = Math.random();所以即使 custom AI 的 strength 很高,在島嶼尚未形成控制權之前,它的 rollout policy 仍接近隨機。
應增加的局部評估
至少加入以下特徵:
- 島內無阻擋的一子線、二子線數量。
- 對方島內二子一空威脅。
- 移動到目的格後形成的己方局部威脅。
- 起點生成對方棋子後,是否形成對方局部威脅。
- 某個島對 meta-line 的重要程度:
- 能否完成己方 meta-line;
- 能否阻止對方 meta-line;
- 是否同時參與多條活躍 meta-line。
- 當前玩家和對手的立即獲勝走法數量。
- 必要時加入 mobility,但權重不應過大。
這些通常比直接加入 RAVE 更能提升棋力。
五、我建議的狀態表示
1. 不建議優先使用 BigInt
49 bits 的確能放進 BigInt,但 JavaScript 的 BigInt:
- 運算不一定比小型 typed array 快;
- bit iteration 不如 32-bit 整數方便;
- 作為大量 MCTS 熱路徑運算,需要實際 benchmark。
我會把每方盤面拆成兩個整數,而且刻意在島嶼邊界切割:
- 前四島:28 bits
- 後三島:21 bits
fireLo // 島 0~3,共 28 bits
fireHi // 島 4~6,共 21 bits
iceLo
iceHi這樣每個島的 7 bits 不會跨過兩個 word,比一般的 32+17 切法更方便。
島嶼歸屬則使用:
fireClaims // 7 bits
iceClaims // 7 bits2. 用小型查表代替 49 條增量計數
每座島只有 7 個位置,每格有三種狀態,因此一座島只有:
37=2187種合法配置。
可以用:
key = fireMask7 | (iceMask7 << 7);預先建立查表:
islandOwnerTable[key]localPotentialFire[key]localPotentialIce[key]fireThreatCount[key]iceThreatCount[key]
claims 也只有七個位置,可以同樣建立:
- meta winner 查表;
- meta potential 查表;
- critical island weight 查表。
如此一來,每次走棋只要重新查受影響的 1~2 座島,通常比維護 98 個局部線計數及其 undo 邏輯更簡單。
所以我不會像 Claude 那樣,把「bitboard+49 條線計數」綁成第一方案。兩者功能有相當程度重疊,應先實測。
六、make/unmake 應如何重構
目前每個 Node 都保存完整 state:
this.state = state;更好的做法是:
State
├─ 4 個盤面 bitboard
├─ 2 個 claims bitmask
├─ turn
├─ emptyCount
└─ 增量 evaluation
Node
├─ move
├─ visits
├─ value
├─ prior
├─ children
├─ unexpanded index
└─ solved status每次迭代:
- 從 root 的單一 mutable state 開始。
- selection 每走過一條 edge,就
makeMove()。 - expansion 再 make 一步。
- rollout 或 exact solver 繼續原地走。
- backpropagation 使用本次 path stack。
- 最後反向
unmakeMove()回到 root。
這樣:
- Node 不再保存
board和claims。 - rollout 不再建立新 state。
- 不再產生大量 typed array。
- 更容易加入 transposition DAG。
- 更容易使用固定容量的 node arena/object pool。
這通常比「把 Int8Array 換成 BigInt,但仍然每步建立新 state」更有價值。
七、走法表示與生成
整數編碼
因為位置範圍是 0~48,6 bits 足夠:
const move = (from << 6) | to;
const from = move >>> 6;
const to = move & 63;最大值遠低於 Uint16 上限。
Claude 建議的 from * 49 + to 也可行,但 bit packing:
- 不需要除法和取模;
- 解碼更直接;
- 無效的 49~63 目的索引不會出現在生成器中即可。
不一定每個 Node 都使用 Int16Array
Uint16Array 適合:
- 可重用的走法生成 scratch buffer;
- node arena;
- 固定容量資料。
但是 Node 的未展開走法需要 swap-pop、排序和動態長度時,V8 最佳化後的 Array<number> 可能反而更方便甚至更快。
因此應測試:
- packed
Array<number> - 預配置
Uint16Array + count - arena 中的一段 move pool
而不是預設 typed array 一定最快。
預計算目的格
每個位置最多有 12 個候選目的地,可以預先建立:
DESTINATIONS[49][12]走法生成只需要:
- 遍歷己方 bitboard 的 set bits。
- 遍歷該起點的 12 個預計算目的地。
- 用 occupancy bit 測試是否為空。
額外維護棋子位置列表未必必要。
八、搜尋演算法方面更好的改進
1. 立即戰術檢查
在 MCTS expansion 或 root 搜尋前先檢查:
- 是否有立即獲勝走法。
- 哪些走法會立即讓對手獲勝。
- 對手下一手是否有直接勝利威脅。
- 是否存在唯一防守手。
這個遊戲一手同時改變兩個格子,而且起點會生成對手棋子,戰術性很高。純隨機展開可能浪費大量模擬。
2. 使用 heuristic prior,而不是隨機展開 untried
目前未展開走法完全隨機抽取:
const index = (Math.random() * node.untried.length) | 0;可改為:
- 先對合法步計算低成本 prior;
- 優先展開立即獲勝、取得島嶼、阻止 meta-line、製造局部二連線的走法;
- 使用 progressive bias,或沒有神經網路版本的 PUCT。
例如:
UCTj=Qj+CnjlnN+1+njβHj其中 Hj 是走法先驗。隨著 visits 增加,heuristic 影響自然衰減。
如果中盤 branching factor 確實很高,再加入 progressive widening:
m(N)=kNα只允許前 m(N) 個高 prior 走法進入 children。
3. Exact endgame solver
這是我認為 Claude 漏掉的最佳演算法改進之一。
因為每步增加一個已佔用格,所以:
- 不會循環;
- 剩餘空格數就是最大剩餘深度;
- 尾局天然適合 negamax/alpha-beta+TT。
可以從「剩餘 8~10 個空格」開始測試:
- 若進入閾值,改用精確 W/D/L 求解;
- 將精確結果回傳給 MCTS;
- 如果求解速度足夠,再逐步提高閾值。
比起所有 rollout 都在尾局繼續隨機走,這會顯著降低 variance,也能避免 AI 漏掉強制勝負。
4. MCTS-Solver
Node 增加:
UNKNOWN
PROVEN_WIN
PROVEN_DRAW
PROVEN_LOSS當發現:
- 某個 child 對當前玩家是必勝,parent 可立即標記必勝;
- 所有 child 都是必敗,parent 標記必敗;
- 沒有勝路但至少有和局,標記和局。
這個遊戲是確定性、零和、無循環遊戲,很適合 MCTS-Solver。它通常比單純增加 rollout 數更能解決戰術問題。
九、tree reuse 的正確實作方式
Claude 說 tree reuse 是對的,但現有架構需要做三項修改。
1. worker 必須保留
現在主執行緒在收到 done 後會:
aiWorker?.terminate();
aiWorker = null;要重用搜尋樹,就不能每次完成後終止 worker。應讓 worker 保留:
- root state hash;
- root tree;
- AI 選擇的 child;
- 後續實際落子路徑。
新局、undo、import 或狀態 hash 不符合時,再重建。
2. 搜尋值不能再綁定 rootPlayer
現在 value 是相對於當次搜尋 rootPlayer。若火、冰雙方都是 AI,下一次搜尋 rootPlayer 會切換,舊 value 的含義就反了。
最好固定為火方視角:
- 火勝:1
- 冰勝:0
- 和局:0.5
選擇時:
- 火方節點最大化 Q
- 冰方節點最大化 1−Q
如此樹在任何一方輪到時都能直接重用。
3. 用 state hash 驗證
主執行緒每次傳入:
- 實際走法;
- 新 state hash。
worker 只有在 child hash 符合時才提升為 root,否則清空重建。這可處理 undo、導入棋譜、人類走出未展開分支等情況。
十、Transposition table 不應直接「共用 Node」
目前 Node 有單一:
this.parent = parent;但 transposition 代表同一狀態可能有多個 parent。直接讓多個 parent 指向同一 Node,會導致:
- parent pointer 不唯一;
- backpropagation 路徑錯誤;
- parent-specific visits 不清楚;
- UCT 分母和探索統計被不適當混合。
正確做法應是:
- selection 保存本次搜尋 path;
- backpropagation 沿 path 回傳,不依賴單一 parent;
- 最好把 visits/Q 放在 edge;
- state node 可保存共享的狀態估值、終局結果及展開資訊。
而且應先測量 transposition hit rate。若命中率不高,JavaScript Map、hash 建立和記憶體成本可能抵消收益。
我的建議是:
- 先把 TT 用於 exact endgame solver,收益最明確。
- 再測試 MCTS DAG transposition。
- 只有命中率顯著時才全面啟用。
十一、低成本但很值得先修的問題
在大重構之前,可以先做以下快速修正:
- 走法改 packed integer。
- heuristic 候選改無放回抽樣。
- 降低或移除
score = Math.random()的大幅噪聲;目前噪聲 0~1,會蓋過部分小型 heuristic 差異。 - 對 sampled move 直接計算 delta,不建立完整 next state。
- 預計算
DESTINATIONS[49]、LINES_BY_POINT[7]。 - 使用可重現的快速 PRNG,例如 xorshift32,方便測試和比較。
- 在無合法步節點直接標記 draw,避免 tree 和 rollout 重複生成一次合法步。
- 改善時間控制;目前一次跑 8 個完整 iteration 才檢查 deadline,可能超時。可根據平均 iteration 時間自適應 batch。
- 加入 immediate-win root shortcut;找到直接勝利不必繼續浪費剩餘時間。
- 記錄平均 branching factor、rollout 深度、每秒 rollout、GC 和超時量。
十二、我建議的實作優先順序
階段 0:建立基準和正確性測試
先記錄:
- iterations/second;
- complete playouts/second;
- 平均 rollout 深度;
- 每回合產生的 state、move 數;
- heap 和 GC;
- deadline 超時量;
- 不同局面的 branching factor;
- transposition 命中率。
建立 differential test:
- 舊
Int8Array引擎作為參考; - 新引擎隨機走數十萬個合法局面;
- 每一步比較合法步、claims、winner、turn;
- 測試 make 後 unmake 是否完全還原。
階段 1:低風險快速收益
- packed integer move;
- 預計算目的格;
- 無放回候選抽樣;
- reusable move buffer;
- 局部 heuristic;
- immediate tactical checks;
- seeded PRNG;
- 更準確的 deadline 控制。
階段 2:核心效能重構
- 四個 32-bit 盤面遮罩;
- 兩個 claims bitmask;
- 島嶼與 meta 查表;
- make/unmake;
- Node 不保存 state;
- node/move arena,減少 GC。
這一階段應是最大的單執行緒吞吐量提升來源。
階段 3:提升每次模擬的品質
- heuristic move prior;
- progressive bias;
- 必要時 progressive widening;
- 改良 rollout policy;
- 對完整 rollout 與截斷 rollout 做 A/B 測試;
- exact endgame solver;
- MCTS-Solver。
階段 4:搜尋連續性
- persistent worker;
- 固定火方或 node-relative value;
- state hash;
- tree reuse;
- undo/new game/import 時安全失效。
階段 5:進階擴展
依實測再加入:
- MCTS transposition DAG;
- 2~4 個 worker 的 root parallelization;
- Fano plane 對稱性壓縮或 opening book;
- 如果 JavaScript 仍不足,再把核心 make/unmake、move generation、solver 移到 WebAssembly。
最終改進策略方向
我不會把最終方向定為單純的:
bitboard+增量線計數+更多 worker
而會定為:
緊湊位元狀態+零配置 make/unmake+領域化走法先驗+尾局精確求解+可重用搜尋樹。
優先級可以濃縮為:
- 先消除 rollout 中的配置與重複試走。
- 再把盤面改成 32-bit bitboard,配合局部查表,而不一定維護完整線計數。
- 改善目前前期近乎隨機的 rollout heuristic。
- 加入 immediate tactics、MCTS-Solver 和 exact endgame solver。
- 重構成 persistent worker,實作安全的 tree reuse。
- 最後才根據命中率與硬體條件加入 TT、平行化或 WASM。
Claude 的方向整體是正確的,但它較偏重「每秒跑更多 iterations」。真正應優化的目標是固定思考時間下的棋力,所以既要提高 nodes/sec,也要提高每個 rollout 和每次 expansion 的資訊價值。
按你說的更改,明確給出所要修改的地方和所要替換的代碼。
QuanAI下面給出一套可以直接套用的完整修改。這個版本先實作收益最高且彼此能協同工作的部分:
- worker 內部改用 4 個 32-bit bitboard;
- move 改成 16-bit packed integer;
- 預計算每格的 12 個候選目的地;
- 島嶼和 meta 狀態使用查表;
- rollout 改為原地
make/unmake,不再配置 state; - rollout 候選改成無放回抽樣;
- 加入島內局勢評估,不再只看已佔領島;
- 加入直接勝利檢查;
- 剩餘 7 個空格時啟動精確尾局求解;
- MCTS 的 value 固定為火方視角,允許跨回合重用;
- worker 完成搜尋後不終止,保留最佳子樹;
- 下一次搜尋自動匹配並提升對應子節點為 root。
暫時不加入多 worker、RAVE 和完整 MCTS transposition DAG。這些應該等單 worker 基準測試後再做。
一、替換整個 mcts-worker-source
在 HTML 中找到:
<script id="mcts-worker-source" type="javascript/worker">一直到對應的:
</script>把中間所有內容完整替換成以下程式碼:
<script id="mcts-worker-source" type="javascript/worker">
'use strict';
/*
* ============================================================
* 基本常數
* ============================================================
*/
const BOARD_SIZE = 49;
const MAX_MOVES = 588;
const MAX_DEPTH = 49;
/*
* 剩餘空格不超過此值時,嘗試使用精確 W/D/L 求解器。
*
* 如果較慢的手機出現明顯超時,可以改成 6。
* 如果桌面瀏覽器效能很好,可以測試 8。
*/
const EXACT_EMPTY_THRESHOLD = 7;
const EXACT_ABORT = 2;
const EXACT_TT_MAX_SIZE = 300000;
const LINES = [
[0, 1, 4],
[0, 2, 6],
[4, 5, 6],
[0, 3, 5],
[1, 3, 6],
[2, 3, 4],
[1, 2, 5]
];
const LINE_MASKS = LINES.map(line => (1 << line[0]) | (1 << line[1]) | (1 << line[2]));
/*
* 每個棋位可以移至:
* 1. 同一島的另外六個位置;
* 2. 其他六個島上的相同位置。
*
* 因此每格恰好有 12 個幾何候選目的地。
*/
const DESTINATIONS = Array.from({ length: BOARD_SIZE }, (_, from) => {
const result = [];
const island = (from / 7) | 0;
const point = from % 7;
const base = island * 7;
for (let p = 0; p < 7; p++) {
if (p !== point) result.push(base + p);
}
for (let i = 0; i < 7; i++) {
if (i !== island) result.push(i * 7 + point);
}
return Uint8Array.from(result);
});
/*
* ============================================================
* 小型查表
* ============================================================
*
* 一個島只有七個位置。
*
* key 的低七位表示火方棋子;
* key 的高七位表示冰方棋子。
*
* key = fireMask | (iceMask << 7)
*/
const POPCOUNT = new Uint8Array(128);
const HAS_LINE = new Uint8Array(128);
const TABLE_SIZE = 1 << 14;
const ISLAND_OWNER = new Int8Array(TABLE_SIZE);
const LOCAL_EVAL = new Float32Array(TABLE_SIZE);
const META_EVAL = new Float32Array(TABLE_SIZE);
for (let mask = 0; mask < 128; mask++) {
let count = 0;
let value = mask;
while (value) {
value &= value - 1;
count++;
}
POPCOUNT[mask] = count;
for (const lineMask of LINE_MASKS) {
if ((mask & lineMask) === lineMask) {
HAS_LINE[mask] = 1;
break;
}
}
}
/*
* 島內評估:
* - 一子活線:0.8
* - 二子活線:6
* - 三子線:20
*
* 這個分數是固定的火方視角:
* 正值對火有利,負值對冰有利。
*/
const LOCAL_LINE_SCORE = [0, 0.8, 6, 20];
/*
* Meta 評估比局部島嶼評估權重大。
*/
const META_LINE_SCORE = [0, 5, 35, 1000];
for (let fireMask = 0; fireMask < 128; fireMask++) {
for (let iceMask = 0; iceMask < 128; iceMask++) {
if (fireMask & iceMask) continue;
const key = fireMask | (iceMask << 7);
const fireHasLine = HAS_LINE[fireMask] !== 0;
const iceHasLine = HAS_LINE[iceMask] !== 0;
if (fireHasLine && !iceHasLine) {
ISLAND_OWNER[key] = 1;
} else if (iceHasLine && !fireHasLine) {
ISLAND_OWNER[key] = -1;
} else {
ISLAND_OWNER[key] = 0;
}
let localScore = 0;
let metaScore = (POPCOUNT[fireMask] - POPCOUNT[iceMask]) * 45;
for (const lineMask of LINE_MASKS) {
const fireCount = POPCOUNT[fireMask & lineMask];
const iceCount = POPCOUNT[iceMask & lineMask];
if (iceCount === 0) {
localScore += LOCAL_LINE_SCORE[fireCount];
metaScore += META_LINE_SCORE[fireCount];
}
if (fireCount === 0) {
localScore -= LOCAL_LINE_SCORE[iceCount];
metaScore -= META_LINE_SCORE[iceCount];
}
}
LOCAL_EVAL[key] = localScore;
META_EVAL[key] = metaScore;
}
}
/*
* ============================================================
* 快速偽隨機數
* ============================================================
*
* 使用 xorshift32,避免 Math.random() 成為 rollout 熱點,
* 同時讓同一 seed 較容易重現。
*/
let rngState = 0x9e3779b9;
function seedRandom(token, state) {
rngState =
(Number(token) ^
state.fireLo ^
Math.imul(state.fireHi, 0x9e3779b1) ^
Math.imul(state.iceLo, 0x85ebca6b) ^
Math.imul(state.iceHi, 0xc2b2ae35) ^
(state.turn === 1 ? 0x27d4eb2d : 0x165667b1)) >>>
0;
if (rngState === 0) rngState = 0x9e3779b9;
}
function random() {
let x = rngState;
x ^= x << 13;
x ^= x >>> 17;
x ^= x << 5;
rngState = x >>> 0;
return rngState / 4294967296;
}
function randomIndex(length) {
return (random() * length) | 0;
}
/*
* ============================================================
* Bitboard 狀態
* ============================================================
*
* 索引 0~27:Lo,對應島 0~3。
* 索引 28~48:Hi,對應島 4~6。
*
* 這樣每一個七格島都不會跨越兩個 32-bit word。
*/
function addPiece(state, index, player) {
if (index < 28) {
const bit = 1 << index;
if (player === 1) {
state.fireLo = (state.fireLo | bit) >>> 0;
} else {
state.iceLo = (state.iceLo | bit) >>> 0;
}
} else {
const bit = 1 << (index - 28);
if (player === 1) {
state.fireHi = (state.fireHi | bit) >>> 0;
} else {
state.iceHi = (state.iceHi | bit) >>> 0;
}
}
}
function removePiece(state, index, player) {
if (index < 28) {
const bit = 1 << index;
if (player === 1) {
state.fireLo = (state.fireLo & ~bit) >>> 0;
} else {
state.iceLo = (state.iceLo & ~bit) >>> 0;
}
} else {
const bit = 1 << (index - 28);
if (player === 1) {
state.fireHi = (state.fireHi & ~bit) >>> 0;
} else {
state.iceHi = (state.iceHi & ~bit) >>> 0;
}
}
}
function isEmpty(state, index) {
if (index < 28) {
const bit = 1 << index;
return (((state.fireLo | state.iceLo) & bit) >>> 0) === 0;
}
const bit = 1 << (index - 28);
return (((state.fireHi | state.iceHi) & bit) >>> 0) === 0;
}
function islandKey(state, island) {
let shift;
let fireMask;
let iceMask;
if (island < 4) {
shift = island * 7;
fireMask = (state.fireLo >>> shift) & 127;
iceMask = (state.iceLo >>> shift) & 127;
} else {
shift = (island - 4) * 7;
fireMask = (state.fireHi >>> shift) & 127;
iceMask = (state.iceHi >>> shift) & 127;
}
return fireMask | (iceMask << 7);
}
function islandEvaluation(state, island) {
return LOCAL_EVAL[islandKey(state, island)];
}
function refreshIslandClaim(state, island) {
const bit = 1 << island;
const owner = ISLAND_OWNER[islandKey(state, island)];
state.fireClaims &= ~bit;
state.iceClaims &= ~bit;
if (owner === 1) {
state.fireClaims |= bit;
} else if (owner === -1) {
state.iceClaims |= bit;
}
}
function recomputeDerivedState(state) {
state.fireClaims = 0;
state.iceClaims = 0;
state.localEval = 0;
for (let island = 0; island < 7; island++) {
refreshIslandClaim(state, island);
state.localEval += islandEvaluation(state, island);
}
state.metaEval = META_EVAL[state.fireClaims | (state.iceClaims << 7)];
}
function createCompactState(source) {
const state = {
fireLo: 0,
fireHi: 0,
iceLo: 0,
iceHi: 0,
fireClaims: 0,
iceClaims: 0,
turn: Number(source.turn) === -1 ? -1 : 1,
winner: Number(source.winner) || 0,
ply: Number(source.ply) || 0,
emptyCount: 49,
localEval: 0,
metaEval: 0
};
let occupied = 0;
for (let index = 0; index < BOARD_SIZE; index++) {
const player = Number(source.board[index]) || 0;
if (player === 1 || player === -1) {
addPiece(state, index, player);
occupied++;
}
}
state.emptyCount = BOARD_SIZE - occupied;
recomputeDerivedState(state);
return state;
}
/*
* key 用於:
* 1. worker 內的 tree reuse;
* 2. 尾局 transposition table。
*
* claims 可以由棋盤唯一推導,因此不必放入 key。
*/
function stateKey(state) {
return (
state.fireLo.toString(36) +
'.' +
state.fireHi.toString(36) +
'.' +
state.iceLo.toString(36) +
'.' +
state.iceHi.toString(36) +
'.' +
(state.turn === 1 ? 'f' : 'i')
);
}
function positionEvaluation(state) {
return state.localEval + state.metaEval;
}
/*
* ============================================================
* Packed move
* ============================================================
*
* from、to 都只需要六個 bits:
*
* move = (from << 6) | to
*/
function encodeMove(from, to) {
return (from << 6) | to;
}
function moveFrom(move) {
return move >>> 6;
}
function moveTo(move) {
return move & 63;
}
function exportMove(move) {
return {
from: moveFrom(move),
to: moveTo(move)
};
}
/*
* ============================================================
* Make / unmake
* ============================================================
*
* makeMove() 回傳父狀態的 localEval。
* unmakeMove() 使用它精確恢復,避免大量 make/unmake 後產生浮點漂移。
*/
function makeMove(state, move) {
const from = moveFrom(move);
const to = moveTo(move);
const player = state.turn;
const originIsland = (from / 7) | 0;
const destinationIsland = (to / 7) | 0;
const oldLocalEval = state.localEval;
let beforeLocal = islandEvaluation(state, originIsland);
if (destinationIsland !== originIsland) {
beforeLocal += islandEvaluation(state, destinationIsland);
}
/*
* 目的格:放入移動方棋子。
* 起點:原棋子消失,生成對方棋子。
*/
removePiece(state, from, player);
addPiece(state, from, -player);
addPiece(state, to, player);
let afterLocal = islandEvaluation(state, originIsland);
if (destinationIsland !== originIsland) {
afterLocal += islandEvaluation(state, destinationIsland);
}
state.localEval += afterLocal - beforeLocal;
refreshIslandClaim(state, originIsland);
if (destinationIsland !== originIsland) {
refreshIslandClaim(state, destinationIsland);
}
state.metaEval = META_EVAL[state.fireClaims | (state.iceClaims << 7)];
state.emptyCount--;
state.ply++;
state.turn = -player;
/*
* 保持原程式的勝負優先順序:
* 先檢查行動方,再檢查對方。
*/
const playerClaims = player === 1 ? state.fireClaims : state.iceClaims;
const opponentClaims = player === 1 ? state.iceClaims : state.fireClaims;
if (HAS_LINE[playerClaims]) {
state.winner = player;
} else if (HAS_LINE[opponentClaims]) {
state.winner = -player;
} else {
state.winner = 0;
}
return oldLocalEval;
}
function unmakeMove(state, move, oldLocalEval) {
const from = moveFrom(move);
const to = moveTo(move);
/*
* makeMove 後 turn 已經切換,
* 因此前一個行動者是 -state.turn。
*/
const player = -state.turn;
const originIsland = (from / 7) | 0;
const destinationIsland = (to / 7) | 0;
removePiece(state, to, player);
removePiece(state, from, -player);
addPiece(state, from, player);
refreshIslandClaim(state, originIsland);
if (destinationIsland !== originIsland) {
refreshIslandClaim(state, destinationIsland);
}
state.localEval = oldLocalEval;
state.metaEval = META_EVAL[state.fireClaims | (state.iceClaims << 7)];
state.emptyCount++;
state.ply--;
state.turn = player;
state.winner = 0;
}
/*
* ============================================================
* 合法步生成
* ============================================================
*/
const NODE_GENERATION_BUFFER = new Uint16Array(MAX_MOVES);
const ROLLOUT_MOVE_BUFFER = new Uint16Array(MAX_MOVES);
const ROLLOUT_PATH = new Uint16Array(MAX_DEPTH);
const ROLLOUT_UNDO = new Float64Array(MAX_DEPTH);
const SOLVER_MOVE_BUFFERS = Array.from(
{ length: MAX_DEPTH },
() => new Uint16Array(MAX_MOVES)
);
function appendMovesForPiece(state, from, output, count) {
const destinations = DESTINATIONS[from];
for (let i = 0; i < destinations.length; i++) {
const to = destinations[i];
if (isEmpty(state, to)) {
output[count++] = encodeMove(from, to);
}
}
return count;
}
function generateMovesToBuffer(state, output) {
let count = 0;
let lowBits = state.turn === 1 ? state.fireLo : state.iceLo;
while (lowBits) {
const leastBit = (lowBits & -lowBits) >>> 0;
const bitIndex = 31 - Math.clz32(leastBit);
count = appendMovesForPiece(state, bitIndex, output, count);
lowBits = (lowBits ^ leastBit) >>> 0;
}
let highBits = state.turn === 1 ? state.fireHi : state.iceHi;
while (highBits) {
const leastBit = (highBits & -highBits) >>> 0;
const bitIndex = 31 - Math.clz32(leastBit);
const from = 28 + bitIndex;
count = appendMovesForPiece(state, from, output, count);
highBits = (highBits ^ leastBit) >>> 0;
}
return count;
}
function generateMoveArray(state) {
const count = generateMovesToBuffer(state, NODE_GENERATION_BUFFER);
const result = new Array(count);
for (let i = 0; i < count; i++) {
result[i] = NODE_GENERATION_BUFFER[i];
}
return result;
}
/*
* ============================================================
* 走法啟發式
* ============================================================
*/
function quickMoveScore(state, move, player, beforeEvaluation) {
const oldLocalEval = makeMove(state, move);
let score;
if (state.winner === player) {
score = 1000000;
} else if (state.winner === -player) {
score = -1000000;
} else {
const delta = positionEvaluation(state) - beforeEvaluation;
/*
* positionEvaluation 是火方視角。
* 冰方行動時要反轉符號。
*/
score = player * delta;
}
unmakeMove(state, move, oldLocalEval);
return score;
}
function chooseRolloutMove(state, buffer, count, strength) {
if (count === 1) return buffer[0];
/*
* 弱 AI 保留更多隨機性。
*/
if (random() > strength) {
return buffer[randomIndex(count)];
}
/*
* 無放回抽樣。
*
* 原版可能在一次 rolloutChoice 中重複抽到同一走法。
* 這裡用局部 Fisher-Yates shuffle 保證不重複。
*/
const sampleCount = Math.min(
count,
2 + Math.floor(strength * 8)
);
const player = state.turn;
const beforeEvaluation = positionEvaluation(state);
let bestMove = buffer[0];
let bestScore = -Infinity;
for (let sample = 0; sample < sampleCount; sample++) {
const selectedIndex =
sample + randomIndex(count - sample);
const temporary = buffer[sample];
buffer[sample] = buffer[selectedIndex];
buffer[selectedIndex] = temporary;
const move = buffer[sample];
const score =
quickMoveScore(
state,
move,
player,
beforeEvaluation
) +
random() * 0.001;
if (score > bestScore) {
bestScore = score;
bestMove = move;
}
}
return bestMove;
}
/*
* ============================================================
* 精確尾局求解器
* ============================================================
*
* 回傳值是相對於 state.turn:
*
* 1 = 輪到的一方可以強制勝利
* 0 = 最佳結果是和局
* -1 = 輪到的一方必敗
* 2 = 因 deadline 中止
*/
const EXACT_TT = new Map();
let solverChecks = 0;
function exactOutcome(state, deadline, depth = 0) {
if (state.winner === 2) return 0;
if (state.winner === 1 || state.winner === -1) {
return state.winner === state.turn ? 1 : -1;
}
if (
(solverChecks++ & 63) === 0 &&
performance.now() >= deadline - 0.5
) {
return EXACT_ABORT;
}
const key = stateKey(state);
const cached = EXACT_TT.get(key);
if (cached !== undefined) {
return cached;
}
const buffer = SOLVER_MOVE_BUFFERS[depth];
const count = generateMovesToBuffer(state, buffer);
if (count === 0) {
EXACT_TT.set(key, 0);
return 0;
}
const player = state.turn;
let best = -1;
for (let i = 0; i < count; i++) {
const move = buffer[i];
const oldLocalEval = makeMove(state, move);
let result;
if (state.winner === 2) {
result = 0;
} else if (state.winner === player) {
result = 1;
} else if (state.winner === -player) {
result = -1;
} else {
const childResult = exactOutcome(
state,
deadline,
depth + 1
);
if (childResult === EXACT_ABORT) {
unmakeMove(state, move, oldLocalEval);
return EXACT_ABORT;
}
result = -childResult;
}
unmakeMove(state, move, oldLocalEval);
if (result === 1) {
EXACT_TT.set(key, 1);
return 1;
}
if (result === 0) {
best = 0;
}
}
EXACT_TT.set(key, best);
return best;
}
/*
* ============================================================
* Rollout
* ============================================================
*/
function terminalFireReward(state) {
if (state.winner === 1) return 1;
if (state.winner === -1) return 0;
if (state.winner === 2) return 0.5;
return -1;
}
function staticFireReward(state) {
const terminal = terminalFireReward(state);
if (terminal >= 0) return terminal;
/*
* 將啟發式分數映射到約 0.02~0.98。
* 只在搜尋 deadline 即將耗盡時使用。
*/
return (
0.5 +
0.48 * Math.tanh(positionEvaluation(state) / 120)
);
}
function exactOutcomeToFireReward(state, outcome) {
if (outcome === 0) return 0.5;
if (outcome === 1) {
return state.turn === 1 ? 1 : 0;
}
return state.turn === 1 ? 0 : 1;
}
function rolloutFire(state, strength, deadline) {
let madeCount = 0;
let reward = 0.5;
while (madeCount < MAX_DEPTH) {
const terminal = terminalFireReward(state);
if (terminal >= 0) {
reward = terminal;
break;
}
/*
* 在尾局嘗試精確求解。
*/
if (
state.emptyCount <= EXACT_EMPTY_THRESHOLD &&
performance.now() < deadline - 1
) {
const exact = exactOutcome(state, deadline, 0);
if (exact !== EXACT_ABORT) {
reward = exactOutcomeToFireReward(state, exact);
break;
}
}
/*
* 避免一個過長 rollout 讓搜尋大幅超過時間限制。
*/
if (
(madeCount & 3) === 0 &&
performance.now() >= deadline - 0.5
) {
reward = staticFireReward(state);
break;
}
const count = generateMovesToBuffer(
state,
ROLLOUT_MOVE_BUFFER
);
if (count === 0) {
reward = 0.5;
break;
}
const move = chooseRolloutMove(
state,
ROLLOUT_MOVE_BUFFER,
count,
strength
);
ROLLOUT_PATH[madeCount] = move;
ROLLOUT_UNDO[madeCount] = makeMove(state, move);
madeCount++;
}
/*
* 恢復到 rollout 開始前的狀態。
*/
for (let i = madeCount - 1; i >= 0; i--) {
unmakeMove(
state,
ROLLOUT_PATH[i],
ROLLOUT_UNDO[i]
);
}
return reward;
}
/*
* ============================================================
* MCTS Tree
* ============================================================
*
* valueFire 固定為火方視角:
*
* 火勝 = 1
* 和局 = 0.5
* 冰勝 = 0
*
* 因此換 root 或換行動方後,舊統計仍然有效。
*/
class Node {
constructor(move = 0, key = '', prior = 0) {
this.move = move;
this.key = key;
this.prior = prior;
this.children = [];
this.untried = null;
this.visits = 0;
this.valueFire = 0;
this.fireWins = 0;
this.iceWins = 0;
}
}
function selectChild(node, player, exploration) {
const logarithm = Math.log(node.visits + 1);
let best = null;
let bestScore = -Infinity;
for (const child of node.children) {
const fireMean =
child.visits > 0
? child.valueFire / child.visits
: 0.5;
const exploitation =
player === 1 ? fireMean : 1 - fireMean;
const explorationTerm =
exploration *
Math.sqrt(logarithm / Math.max(1, child.visits));
/*
* Progressive bias:
* 初期讓有領域先驗的走法稍微優先;
* visits 增加後影響逐漸消失。
*/
const normalizedPrior = Math.tanh(child.prior / 25);
const priorTerm =
(0.18 * normalizedPrior) /
(1 + child.visits * 0.15);
const score =
exploitation +
explorationTerm +
priorTerm +
random() * 1e-9;
if (score > bestScore) {
bestScore = score;
best = child;
}
}
return best;
}
let lastExpansionPrior = 0;
function takeExpansionMove(node, state, strength) {
const moves = node.untried;
const length = moves.length;
const sampleCount = Math.min(
length,
3 + Math.floor(strength * 7)
);
const player = state.turn;
const beforeEvaluation = positionEvaluation(state);
let bestSlot = 0;
let bestScore = -Infinity;
/*
* 從尚未展開的走法中無放回抽樣,
* 用輕量評估決定本次優先展開哪一步。
*/
for (let sample = 0; sample < sampleCount; sample++) {
const selectedIndex =
sample + randomIndex(length - sample);
const temporary = moves[sample];
moves[sample] = moves[selectedIndex];
moves[selectedIndex] = temporary;
const score =
quickMoveScore(
state,
moves[sample],
player,
beforeEvaluation
) +
random() * 0.001;
if (score > bestScore) {
bestScore = score;
bestSlot = sample;
}
}
const move = moves[bestSlot];
const lastIndex = moves.length - 1;
moves[bestSlot] = moves[lastIndex];
moves.pop();
lastExpansionPrior = bestScore;
return move;
}
const TREE_NODE_PATH = new Array(MAX_DEPTH + 1);
const TREE_MOVE_PATH = new Uint16Array(MAX_DEPTH);
const TREE_UNDO_PATH = new Float64Array(MAX_DEPTH);
function runIteration(
root,
state,
exploration,
strength,
deadline
) {
let node = root;
let nodeCount = 1;
let moveCount = 0;
TREE_NODE_PATH[0] = root;
/*
* Selection + Expansion
*/
while (!state.winner) {
if (node.untried === null) {
node.untried = generateMoveArray(state);
}
if (node.untried.length > 0) {
const move = takeExpansionMove(
node,
state,
strength
);
TREE_MOVE_PATH[moveCount] = move;
TREE_UNDO_PATH[moveCount] = makeMove(state, move);
moveCount++;
const child = new Node(
move,
stateKey(state),
lastExpansionPrior
);
node.children.push(child);
node = child;
TREE_NODE_PATH[nodeCount++] = child;
break;
}
if (node.children.length === 0) {
break;
}
const child = selectChild(
node,
state.turn,
exploration
);
const move = child.move;
TREE_MOVE_PATH[moveCount] = move;
TREE_UNDO_PATH[moveCount] = makeMove(state, move);
moveCount++;
node = child;
TREE_NODE_PATH[nodeCount++] = child;
}
/*
* Simulation
*/
const rewardFire = rolloutFire(
state,
strength,
deadline
);
/*
* Backpropagation
*/
for (let i = 0; i < nodeCount; i++) {
const current = TREE_NODE_PATH[i];
current.visits++;
current.valueFire += rewardFire;
if (rewardFire === 1) {
current.fireWins++;
} else if (rewardFire === 0) {
current.iceWins++;
}
}
/*
* 恢復 root state。
*/
for (let i = moveCount - 1; i >= 0; i--) {
unmakeMove(
state,
TREE_MOVE_PATH[i],
TREE_UNDO_PATH[i]
);
}
}
/*
* ============================================================
* 直接勝利檢查
* ============================================================
*/
function findImmediateWinningMove(state, moves) {
const player = state.turn;
for (let i = 0; i < moves.length; i++) {
const move = moves[i];
const oldLocalEval = makeMove(state, move);
const wins = state.winner === player;
unmakeMove(state, move, oldLocalEval);
if (wins) return move;
}
return 0;
}
/*
* ============================================================
* Tree reuse
* ============================================================
*/
let persistentRoot = null;
function findReusableRoot(key) {
if (!persistentRoot) return null;
if (persistentRoot.key === key) {
return persistentRoot;
}
/*
* 正常情況:
* - AI 對 AI:下一次 state 就是 persistentRoot;
* - 人類對 AI:人類走一步後,state 是它的一個 child。
*
* 額外搜尋到 grandchildren,處理少數排程或介面狀況。
*/
for (const child of persistentRoot.children) {
if (child.key === key) {
return child;
}
}
for (const child of persistentRoot.children) {
for (const grandchild of child.children) {
if (grandchild.key === key) {
return grandchild;
}
}
}
return null;
}
function rootExpectedValue(child, rootPlayer) {
if (!child || child.visits === 0) return 0.5;
const fireMean = child.valueFire / child.visits;
return rootPlayer === 1 ? fireMean : 1 - fireMean;
}
function rootWinRate(child, rootPlayer) {
if (!child || child.visits === 0) return 0;
return rootPlayer === 1
? child.fireWins / child.visits
: child.iceWins / child.visits;
}
function selectBestRootChild(root, rootPlayer) {
let best = null;
for (const child of root.children) {
if (!best) {
best = child;
continue;
}
if (child.visits > best.visits) {
best = child;
continue;
}
if (
child.visits === best.visits &&
rootExpectedValue(child, rootPlayer) >
rootExpectedValue(best, rootPlayer)
) {
best = child;
}
}
return best;
}
/*
* ============================================================
* Worker 入口
* ============================================================
*/
self.onmessage = function (event) {
const data = event.data;
if (!data || data.type !== 'start') return;
const state = createCompactState(data.state);
const token = data.token;
const limit = Math.max(
50,
Number(data.limitMs) || 6000
);
const exploration = Math.max(
0.1,
Number(data.exploration) || 1.4
);
const strength =
{
easy: 0.12,
hard: 0.42,
expert: 0.72,
custom: 0.9
}[data.level] || 0.3;
seedRandom(token, state);
if (EXACT_TT.size > EXACT_TT_MAX_SIZE) {
EXACT_TT.clear();
}
solverChecks = 0;
const rootPlayer = state.turn;
const incomingKey = stateKey(state);
let root = findReusableRoot(incomingKey);
if (!root) {
root = new Node(0, incomingKey, 0);
}
/*
* 先生成 root 合法步,用於:
* 1. 無合法步判斷;
* 2. 直接勝利檢查。
*/
const rootMoves = generateMoveArray(state);
const start = performance.now();
const deadline = start + limit;
if (rootMoves.length === 0) {
persistentRoot = root;
self.postMessage({
type: 'done',
token,
elapsed: performance.now() - start,
iterations: 0,
move: null,
expected: 0.5,
winRate: 0
});
return;
}
/*
* 如果有直接勝利步,立即落子,不再浪費剩餘時間。
*/
const immediateWin = findImmediateWinningMove(
state,
rootMoves
);
if (immediateWin) {
const oldLocalEval = makeMove(state, immediateWin);
const childKey = stateKey(state);
let winningChild = null;
for (const child of root.children) {
if (child.key === childKey) {
winningChild = child;
break;
}
}
if (!winningChild) {
winningChild = new Node(
immediateWin,
childKey,
1000000
);
root.children.push(winningChild);
}
if (winningChild.visits === 0) {
winningChild.visits = 1;
if (rootPlayer === 1) {
winningChild.valueFire = 1;
winningChild.fireWins = 1;
} else {
winningChild.valueFire = 0;
winningChild.iceWins = 1;
}
}
unmakeMove(state, immediateWin, oldLocalEval);
/*
* 預先把實際將落子的子節點保留下來。
*/
persistentRoot = winningChild;
self.postMessage({
type: 'done',
token,
elapsed: performance.now() - start,
iterations: 1,
move: exportMove(immediateWin),
expected: 1,
winRate: 1
});
return;
}
if (root.untried === null) {
root.untried = rootMoves.slice();
}
let iterations = 0;
let nextProgressTime = start + 100;
/*
* 至少執行一次 iteration。
*/
while (
iterations === 0 ||
performance.now() < deadline
) {
runIteration(
root,
state,
exploration,
strength,
deadline
);
iterations++;
/*
* 每四次 iteration 檢查時間,
* rollout 本身也會檢查 deadline。
*/
if ((iterations & 3) === 0) {
const now = performance.now();
if (now >= nextProgressTime) {
self.postMessage({
type: 'progress',
token,
elapsed: now - start,
iterations
});
nextProgressTime += 100;
if (nextProgressTime < now) {
nextProgressTime =
Math.floor(now / 100) * 100 + 100;
}
}
if (now >= deadline) break;
}
}
const best = selectBestRootChild(
root,
rootPlayer
);
const elapsed = performance.now() - start;
const expected = rootExpectedValue(
best,
rootPlayer
);
const winRate = rootWinRate(
best,
rootPlayer
);
/*
* 丟棄 root 的其他分支,只保留預計實際落子的子樹。
*
* 如果主執行緒最終沒有採用此走法,
* 下一次 state key 不匹配時會自動重建。
*/
persistentRoot = best || root;
self.postMessage({
type: 'done',
token,
elapsed,
iterations,
move: best ? exportMove(best.move) : null,
expected,
winRate
});
};
</script>二、替換主執行緒的 startAI()
找到原本完整的:
function startAI() {
...
}將整個函式替換成下面版本:
function startAI() {
if (
!gameStarted ||
state.winner ||
controllers[state.turn] === 'human'
) {
return;
}
const moves = getLegalMoves(state);
if (!moves.length) {
state.winner = 2;
history[historyIndex].state = cloneState(state);
renderAll();
return;
}
const level = controllers[state.turn];
const config = AI_LEVELS[level];
if (!config) return;
selectedCell = -1;
lastAIStats = null;
thinking = true;
aiProgress = {
elapsed: 0,
iterations: 0
};
renderAll();
const token = ++aiToken;
const started = performance.now();
/*
* 關鍵修改:
* worker 不再每次思考都重新建立。
*
* 搜尋正常完成後會保留 worker,
* 從而允許下一回合重用 MCTS 子樹。
*/
try {
if (!aiWorker) {
aiWorker = new Worker(getWorkerURL());
}
} catch (error) {
aiWorker = null;
runFallbackAI(token, started);
return;
}
aiWorker.onmessage = event => {
const message = event.data;
if (
!message ||
message.token !== aiToken ||
message.token !== token
) {
return;
}
if (message.type === 'progress') {
aiProgress = {
elapsed: message.elapsed,
iterations: message.iterations
};
renderStatus();
return;
}
if (message.type === 'done') {
/*
* 不要在這裡 terminate worker。
*
* 舊版:
*
* aiWorker?.terminate();
* aiWorker = null;
*
* 必須刪除,否則 tree reuse 無法工作。
*/
thinking = false;
const stats = {
elapsed: message.elapsed,
iterations: message.iterations,
expected: message.expected,
winRate: message.winRate,
level
};
if (message.move) {
commitMove(
message.move,
'ai',
stats
);
} else {
state.winner = 2;
lastAIStats = stats;
history[historyIndex].state =
cloneState(state);
renderAll();
}
}
};
aiWorker.onerror = () => {
if (token !== aiToken) return;
aiWorker?.terminate();
aiWorker = null;
runFallbackAI(token, started);
};
const exploration =
level === 'custom'
? preferences.exploration
: config.exploration;
const limitMs =
level === 'custom'
? preferences.customTimeSeconds * 1000
: config.ms;
try {
aiWorker.postMessage({
type: 'start',
token,
state: {
board: Array.from(state.board),
claims: Array.from(state.claims),
turn: state.turn,
winner: state.winner,
ply: state.ply
},
level,
limitMs,
exploration
});
} catch (error) {
aiWorker?.terminate();
aiWorker = null;
runFallbackAI(token, started);
}
}三、cancelAI() 保持終止 worker
cancelAI() 不要改成單純傳送取消訊息。Web Worker 在執行同步 MCTS 迴圈期間,不能即時處理新的 cancel message。
保留你原本的實作即可:
function cancelAI() {
clearTimeout(aiStartTimer);
aiStartTimer = null;
aiToken++;
if (aiWorker) {
aiWorker.terminate();
aiWorker = null;
}
thinking = false;
aiProgress = {
elapsed: 0,
iterations: 0
};
}這代表:
- AI 正常走完:worker 保留,能 tree reuse。
- 重新開局:worker 終止。
- undo/redo:worker 終止。
- 導入棋譜:worker 終止。
- 搜尋期間切換控制器:worker 終止。
- 離開頁面:worker 終止。
這正是需要的行為。
四、確認 done 分支沒有終止 worker
修改後,程式碼中仍然可能留有以下兩行:
aiWorker?.terminate();
aiWorker = null;它們可以存在於:
cancelAI();aiWorker.onerror;postMessage失敗的 catch;- fallback 前;
beforeunload間接呼叫的cancelAI()。
但不能存在於正常收到 message.type === 'done' 的分支裡。
否則 tree reuse 會完全失效。
五、這次不需要改主執行緒的棋盤格式
主 UI 仍然保留:
board: Int8Array(49)
claims: Int8Array(7)不要把 UI 的 state.board 一併改成 bitboard。
原因是:
- UI 繪圖每次只掃 49 格,並不是熱點;
- 棋譜、undo、redo、SVG 輸出都依賴目前格式;
- 只把 MCTS worker 改成 bitboard,已經可以消除搜尋中的主要成本;
- 避免一次改動整個應用程式,降低規則錯誤風險。
bitboard 只在 worker 收到訊息時轉換一次:
const state = createCompactState(data.state);六、建議調整 AI 探索常數
新的 rollout 和 expansion 已經比舊版更偏向好走法,因此可以稍微降低探索常數。
將原本:
const AI_LEVELS = {
easy: { ms: DEFAULT_AI_SECONDS.easy * 1000, exploration: 1.65 },
hard: { ms: DEFAULT_AI_SECONDS.hard * 1000, exploration: 1.42 },
expert: { ms: DEFAULT_AI_SECONDS.expert * 1000, exploration: 1.22 },
custom: { ms: DEFAULT_AI_SECONDS.custom * 1000, exploration: 1.4 }
};替換為:
const AI_LEVELS = {
easy: {
ms: DEFAULT_AI_SECONDS.easy * 1000,
exploration: 1.55
},
hard: {
ms: DEFAULT_AI_SECONDS.hard * 1000,
exploration: 1.28
},
expert: {
ms: DEFAULT_AI_SECONDS.expert * 1000,
exploration: 1.08
},
custom: {
ms: DEFAULT_AI_SECONDS.custom * 1000,
exploration: 1.25
}
};自訂滑桿的預設值也建議由:
<input
id="explorationRange"
type="range"
min=".60"
max="2.20"
step=".05"
value="1.40"
/>改成:
<input
id="explorationRange"
type="range"
min=".60"
max="2.20"
step=".05"
value="1.25"
/>以及:
<output id="explorationOutput" class="range-output">1.40</output>改成:
<output id="explorationOutput" class="range-output">1.25</output>再把:
let preferences = {
exploration: 1.4,
customTimeSeconds: DEFAULT_AI_SECONDS.custom
};改成:
let preferences = {
exploration: 1.25,
customTimeSeconds: DEFAULT_AI_SECONDS.custom
};這不是正確性要求,只是較適合新版搜尋行為的起始值。最終仍應透過 AI 自我對弈調參。
七、修改後必須做的正確性測試
至少測試以下局面。
1. 初始局面
確認:
- 火方合法走法數與舊版相同;
- AI 能正常走棋;
- 起點生成冰棋;
- 目的格變成火棋;
- 思考時間基本符合設定。
2. 同島移動
確認只更新一個島的歸屬,沒有錯誤更新其他島。
3. 跨島移動
確認:
- 起點島和目的島都會重新計算;
- 跨島時目的位置必須與原位置相同。
4. 起點生成對手勝利
這是非常重要的特殊情況。
確認某一步如果在起點產生對方棋子,並讓對方形成 meta-line,worker 和主程式判定一致。
5. 雙方同時存在 meta-line
原規則優先判定行動方:
if (hasMetaLine(claims, player)) {
winner = player;
} else if (hasMetaLine(claims, -player)) {
winner = -player;
}新版已經保持同樣順序,但應建立測試局面確認。
6. AI 對 AI
讓火、冰雙方都選 AI。確認:
- 第一個 AI 完成後 worker 沒有被終止;
- 下一方能正常搜尋;
- 沒有出現上一方 value 視角相反的問題;
- 搜尋 iterations 正常增長。
7. 人類對 AI 的 tree reuse
流程:
- AI 落子;
- 人類走一步;
- AI 再思考。
如果人類走法曾在保留子樹中展開,worker 會重用;如果沒有,會安全重建,不應報錯。
8. Undo/redo/重新開局
確認這些操作會終止舊 worker,之後能建立新 worker,不能重用不相符的舊樹。
9. 尾局測試
導入剩餘 7 個或更少空格的棋譜,確認:
- AI 不會卡死;
- 能找出強制勝利;
- 沒有明顯超出時間限制。
如果低階手機尾局超時,把:
const EXACT_EMPTY_THRESHOLD = 7;改成:
const EXACT_EMPTY_THRESHOLD = 6;八、預期效果
這個版本主要消除了舊版最昂貴的操作:
舊版 rollout 中每次試走都會執行:
const board = state.board.slice();
const claims = state.claims.slice();
return {
board,
claims,
turn,
winner,
ply
};新版改成:
const oldLocalEval = makeMove(state, move);
// 評估
unmakeMove(state, move, oldLocalEval);因此 rollout 熱路徑中不再持續建立:
Int8Array(49);Int8Array(7);- state 物件;
{from, to}走法物件。
除了提升 iterations/second,新版也修正了舊 heuristic 在未產生島嶼歸屬變化時幾乎完全隨機的問題。現在島內一子活線、二子活線和 meta 潛力都會影響 rollout 與 expansion。
這應當作第一個完整優化版本。下一階段應先量測:
- 每秒 iterations;
- 平均 rollout 深度;
- exact solver 呼叫和命中次數;
- AI 自我對弈勝率;
- tree reuse 成功率;
再決定是否加入 MCTS-Solver、完整 transposition DAG 或多 worker。