QuanAI只读快照

共享会话

下面是claude對於MCTS搜尋的改進意見,請你給出你的評價,是否同意它的觀點,以及你還有什麼更好的改進辦法。最後給…

分享于 2026年9月13日 00:58
提问者

下面是claude對於MCTS搜尋的改進意見,請你給出你的評價,是否同意它的觀點,以及你還有什麼更好的改進辦法。最後給出你的改進策略方向:我已經把 #mcts-worker-source 裡的完整 MCTS worker 程式碼讀過一遍,以下是分析結果。

1. 有沒有用 bitboard?

沒有。 棋盤是用 Int8Array(49) 存放三態值(1=火、-1=冰、0=空),島嶼歸屬用 Int8Array(7)

js
const rootState = {
  board: Int8Array.from(data.state.board),   // 49 格逐格存
  claims: Int8Array.from(data.state.claims), // 7 島逐島存
  ...
};

判斷連線(hasIslandLine)、產生合法步(legalMoves)都是逐格迴圈掃描,不是位元運算。以只有 49 個位置來說,這其實非常適合改成 bitboard(例如兩個 49-bit 佔位遮罩:fireBitsiceBits,用兩個 32-bit int 拼接或一個 BigInt 表示),好處是:

  • 複製盤面從「複製 49 個位元組的陣列」變成「複製 1~2 個整數」,成本趨近 O(1)。
  • 每條線(7 島 × 7 線=49 條)可預先算好 bitmask,判斷是否連線只要 (bits & lineMask) === lineMask,比逐格比對快很多。
  • 空格判斷、合法步產生也能用位元運算加速。

2. 有沒有用增量運算?

只有一部分。 applyMove 裡對「島嶼歸屬(claims)」是有做增量處理的——只重新計算被移動影響到的 1~2 個島(起點島、終點島),而不是全部 7 島都重算:

js
claims[originIsland] = islandOwner(board, originIsland);
if (destinationIsland !== originIsland) {
  claims[destinationIsland] = islandOwner(board, destinationIsland);
}

但除此之外,幾乎都是「整份重算」而非真正的增量/make-unmake:

  • 盤面本身:每次 applyMoveboard.slice() + claims.slice() 整份複製一份新陣列,不是原地修改+記錄復原資訊(make/unmake)。
  • hasIslandLineislandOwner:每次呼叫都重新掃描該島的 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 公式沒有標準的 探索常數(屬設計選擇,靠使用者調的 exploration 補償),也沒有 first-play urgency/progressive widening,可視需要加入讓早期展開更聰明。

這些改動裡,bitboard 化+增量連線計數表這兩項對效能提升最直接,而且會讓後面的 make/unmake、transposition table 都更容易做。

ifif2436.htmltext/plain · 136.2 KB预览下载
QuanAI

我會先核對附件中的 MCTS worker 實作、遊戲規則與熱路徑,確認上述判斷是否準確,再依「收益/風險/實作成本」提出更合適的優先順序。

總體評價

我大約 同意 Claude 的 80%。它對現有程式碼的事實判斷基本準確:

  • 的確沒有 bitboard。
  • applyMove() 的確頻繁複製 boardclaims
  • 合法步使用大量 {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原則同意,但不能直接共用 NodeDAG MCTS 要使用搜尋路徑回傳,最好保留 edge-local 統計
截斷 rollout有條件同意需要更好的非終局評估,否則速度提升可能換來更大偏差
RAVE/AMAF低優先級這個遊戲的走法效果非常依賴局面和順序,AMAF 假設未必適合
多 worker 平行化同意,但放後面先修單 worker 的配置與演算法浪費,收益通常更划算
UCT 缺少標準 不同意這是問題2sqrt(2) 本來就可以吸收到 exploration 常數中
FPU / progressive widening可以考慮但有領域先驗的走法排序,比單純 FPU 更重要

二、Claude 有兩個需要修正的地方

1. 「最多 rollout 56 步」不完全正確

程式碼的迴圈上限的確是 56:

js
for (let depth = 0; depth < 56; depth++)

但本遊戲每走一步:

  • 目的格由空變成己方棋子。
  • 起點不會變空,而是變成對方棋子。

因此每步固定增加一個已佔用格。初始有一枚棋子,所以合法對局最多再走:

491=4849 - 1 = 48

步。

因此從初始局面開始,完整 rollout 最深實際上是 48 個落子,不會真的走到 56 步。56 只是安全上限。

這也帶來一個重要優勢:遊戲狀態是單調的、沒有循環,很適合:

  • exact endgame solver;
  • transposition table;
  • solved-node propagation;
  • alpha-beta 尾局求解。

這一點 Claude 沒有充分利用。


2. UCT 並沒有真正「少一個 2」

目前公式是:

js
mean + exploration * Math.sqrt(logarithm / child.visits)

常見公式可能寫成:

Xˉj+ClnNnj\bar X_j + C\sqrt{\frac{\ln N}{n_j}}

也可能寫成:

Xˉj+2lnNnj\bar X_j + \sqrt{\frac{2\ln N}{n_j}}

兩者只是把 2\sqrt{2} 放進或移出探索常數。現在自訂值預設為 1.4,本身就接近 2\sqrt{2}。所以這不是錯誤,只是探索常數的定義不同。

真正需要做的是透過 self-play 調參,而不是機械地乘上一個 2。


三、Claude 漏掉的最大問題:rollout 配置量非常大

這段才很可能是目前的主要熱點:

js
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() 中被抽到兩次甚至更多次,導致完全重複的計算。

因此我認為優先級應是:

  1. 消除 rollout 中的狀態配置。
  2. 走法改整數。
  3. 候選無放回抽樣。
  4. 然後才是更細緻的逐格掃描優化。

四、Claude 漏掉的第二個重要問題:目前 heuristic 在前期幾乎是隨機的

rolloutChoice() 的評估只使用:

  • countClaims()
  • metaPotential()
  • 終局勝負

也就是說,它主要只看「島是否已經被控制」以及「已控制島的 meta-line」。

但在前期,大多數走法都不會立即改變島嶼歸屬。這時候:

js
countClaims(next.claims, player) - beforeOwn
metaPotential(next.claims, player) - beforeOwnPotential

通常全部是零,最後候選的差異主要只剩:

js
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
text
fireLo   // 島 0~3,共 28 bits
fireHi   // 島 4~6,共 21 bits
iceLo
iceHi

這樣每個島的 7 bits 不會跨過兩個 word,比一般的 32+17 切法更方便。

島嶼歸屬則使用:

text
fireClaims // 7 bits
iceClaims  // 7 bits

2. 用小型查表代替 49 條增量計數

每座島只有 7 個位置,每格有三種狀態,因此一座島只有:

37=21873^7 = 2187

種合法配置。

可以用:

js
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:

js
this.state = state;

更好的做法是:

text
State
├─ 4 個盤面 bitboard
├─ 2 個 claims bitmask
├─ turn
├─ emptyCount
└─ 增量 evaluation

Node
├─ move
├─ visits
├─ value
├─ prior
├─ children
├─ unexpanded index
└─ solved status

每次迭代:

  1. 從 root 的單一 mutable state 開始。
  2. selection 每走過一條 edge,就 makeMove()
  3. expansion 再 make 一步。
  4. rollout 或 exact solver 繼續原地走。
  5. backpropagation 使用本次 path stack。
  6. 最後反向 unmakeMove() 回到 root。

這樣:

  • Node 不再保存 boardclaims
  • rollout 不再建立新 state。
  • 不再產生大量 typed array。
  • 更容易加入 transposition DAG。
  • 更容易使用固定容量的 node arena/object pool。

這通常比「把 Int8Array 換成 BigInt,但仍然每步建立新 state」更有價值。


七、走法表示與生成

整數編碼

因為位置範圍是 0~48,6 bits 足夠:

js
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 個候選目的地,可以預先建立:

text
DESTINATIONS[49][12]

走法生成只需要:

  1. 遍歷己方 bitboard 的 set bits。
  2. 遍歷該起點的 12 個預計算目的地。
  3. 用 occupancy bit 測試是否為空。

額外維護棋子位置列表未必必要。


八、搜尋演算法方面更好的改進

1. 立即戰術檢查

在 MCTS expansion 或 root 搜尋前先檢查:

  1. 是否有立即獲勝走法。
  2. 哪些走法會立即讓對手獲勝。
  3. 對手下一手是否有直接勝利威脅。
  4. 是否存在唯一防守手。

這個遊戲一手同時改變兩個格子,而且起點會生成對手棋子,戰術性很高。純隨機展開可能浪費大量模擬。


2. 使用 heuristic prior,而不是隨機展開 untried

目前未展開走法完全隨機抽取:

js
const index = (Math.random() * node.untried.length) | 0;

可改為:

  • 先對合法步計算低成本 prior;
  • 優先展開立即獲勝、取得島嶼、阻止 meta-line、製造局部二連線的走法;
  • 使用 progressive bias,或沒有神經網路版本的 PUCT。

例如:

UCTj=Qj+ClnNnj+βHj1+njUCT_j = Q_j+ C\sqrt{\frac{\ln N}{n_j}}+ \frac{\beta H_j}{1+n_j}

其中 HjH_j 是走法先驗。隨著 visits 增加,heuristic 影響自然衰減。

如果中盤 branching factor 確實很高,再加入 progressive widening:

m(N)=kNαm(N)=kN^\alpha

只允許前 m(N)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 增加:

text
UNKNOWN
PROVEN_WIN
PROVEN_DRAW
PROVEN_LOSS

當發現:

  • 某個 child 對當前玩家是必勝,parent 可立即標記必勝;
  • 所有 child 都是必敗,parent 標記必敗;
  • 沒有勝路但至少有和局,標記和局。

這個遊戲是確定性、零和、無循環遊戲,很適合 MCTS-Solver。它通常比單純增加 rollout 數更能解決戰術問題。


九、tree reuse 的正確實作方式

Claude 說 tree reuse 是對的,但現有架構需要做三項修改。

1. worker 必須保留

現在主執行緒在收到 done 後會:

js
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

選擇時:

  • 火方節點最大化 QQ
  • 冰方節點最大化 1Q1-Q

如此樹在任何一方輪到時都能直接重用。

3. 用 state hash 驗證

主執行緒每次傳入:

  • 實際走法;
  • 新 state hash。

worker 只有在 child hash 符合時才提升為 root,否則清空重建。這可處理 undo、導入棋譜、人類走出未展開分支等情況。


十、Transposition table 不應直接「共用 Node」

目前 Node 有單一:

js
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 建立和記憶體成本可能抵消收益。

我的建議是:

  1. 先把 TT 用於 exact endgame solver,收益最明確。
  2. 再測試 MCTS DAG transposition。
  3. 只有命中率顯著時才全面啟用。

十一、低成本但很值得先修的問題

在大重構之前,可以先做以下快速修正:

  1. 走法改 packed integer。
  2. heuristic 候選改無放回抽樣。
  3. 降低或移除 score = Math.random() 的大幅噪聲;目前噪聲 0~1,會蓋過部分小型 heuristic 差異。
  4. 對 sampled move 直接計算 delta,不建立完整 next state。
  5. 預計算 DESTINATIONS[49]LINES_BY_POINT[7]
  6. 使用可重現的快速 PRNG,例如 xorshift32,方便測試和比較。
  7. 在無合法步節點直接標記 draw,避免 tree 和 rollout 重複生成一次合法步。
  8. 改善時間控制;目前一次跑 8 個完整 iteration 才檢查 deadline,可能超時。可根據平均 iteration 時間自適應 batch。
  9. 加入 immediate-win root shortcut;找到直接勝利不必繼續浪費剩餘時間。
  10. 記錄平均 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+領域化走法先驗+尾局精確求解+可重用搜尋樹。

優先級可以濃縮為:

  1. 先消除 rollout 中的配置與重複試走。
  2. 再把盤面改成 32-bit bitboard,配合局部查表,而不一定維護完整線計數。
  3. 改善目前前期近乎隨機的 rollout heuristic。
  4. 加入 immediate tactics、MCTS-Solver 和 exact endgame solver。
  5. 重構成 persistent worker,實作安全的 tree reuse。
  6. 最後才根據命中率與硬體條件加入 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 中找到:

html
<script id="mcts-worker-source" type="javascript/worker">

一直到對應的:

html
</script>

把中間所有內容完整替換成以下程式碼:

html
<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()

找到原本完整的:

js
function startAI() {
  ...
}

將整個函式替換成下面版本:

js
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。

保留你原本的實作即可:

js
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

修改後,程式碼中仍然可能留有以下兩行:

js
aiWorker?.terminate();
aiWorker = null;

它們可以存在於:

  • cancelAI()
  • aiWorker.onerror
  • postMessage 失敗的 catch;
  • fallback 前;
  • beforeunload 間接呼叫的 cancelAI()

不能存在於正常收到 message.type === 'done' 的分支裡

否則 tree reuse 會完全失效。


五、這次不需要改主執行緒的棋盤格式

主 UI 仍然保留:

js
board: Int8Array(49)
claims: Int8Array(7)

不要把 UI 的 state.board 一併改成 bitboard。

原因是:

  • UI 繪圖每次只掃 49 格,並不是熱點;
  • 棋譜、undo、redo、SVG 輸出都依賴目前格式;
  • 只把 MCTS worker 改成 bitboard,已經可以消除搜尋中的主要成本;
  • 避免一次改動整個應用程式,降低規則錯誤風險。

bitboard 只在 worker 收到訊息時轉換一次:

js
const state = createCompactState(data.state);

六、建議調整 AI 探索常數

新的 rollout 和 expansion 已經比舊版更偏向好走法,因此可以稍微降低探索常數。

將原本:

js
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 }
};

替換為:

js
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
  }
};

自訂滑桿的預設值也建議由:

html
<input
  id="explorationRange"
  type="range"
  min=".60"
  max="2.20"
  step=".05"
  value="1.40"
/>

改成:

html
<input
  id="explorationRange"
  type="range"
  min=".60"
  max="2.20"
  step=".05"
  value="1.25"
/>

以及:

html
<output id="explorationOutput" class="range-output">1.40</output>

改成:

html
<output id="explorationOutput" class="range-output">1.25</output>

再把:

js
let preferences = {
  exploration: 1.4,
  customTimeSeconds: DEFAULT_AI_SECONDS.custom
};

改成:

js
let preferences = {
  exploration: 1.25,
  customTimeSeconds: DEFAULT_AI_SECONDS.custom
};

這不是正確性要求,只是較適合新版搜尋行為的起始值。最終仍應透過 AI 自我對弈調參。


七、修改後必須做的正確性測試

至少測試以下局面。

1. 初始局面

確認:

  • 火方合法走法數與舊版相同;
  • AI 能正常走棋;
  • 起點生成冰棋;
  • 目的格變成火棋;
  • 思考時間基本符合設定。

2. 同島移動

確認只更新一個島的歸屬,沒有錯誤更新其他島。

3. 跨島移動

確認:

  • 起點島和目的島都會重新計算;
  • 跨島時目的位置必須與原位置相同。

4. 起點生成對手勝利

這是非常重要的特殊情況。

確認某一步如果在起點產生對方棋子,並讓對方形成 meta-line,worker 和主程式判定一致。

5. 雙方同時存在 meta-line

原規則優先判定行動方:

js
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

流程:

  1. AI 落子;
  2. 人類走一步;
  3. AI 再思考。

如果人類走法曾在保留子樹中展開,worker 會重用;如果沒有,會安全重建,不應報錯。

8. Undo/redo/重新開局

確認這些操作會終止舊 worker,之後能建立新 worker,不能重用不相符的舊樹。

9. 尾局測試

導入剩餘 7 個或更少空格的棋譜,確認:

  • AI 不會卡死;
  • 能找出強制勝利;
  • 沒有明顯超出時間限制。

如果低階手機尾局超時,把:

js
const EXACT_EMPTY_THRESHOLD = 7;

改成:

js
const EXACT_EMPTY_THRESHOLD = 6;

八、預期效果

這個版本主要消除了舊版最昂貴的操作:

舊版 rollout 中每次試走都會執行:

js
const board = state.board.slice();
const claims = state.claims.slice();

return {
  board,
  claims,
  turn,
  winner,
  ply
};

新版改成:

js
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。