簡而言之

  • Gobblet Gobblers 是一種大棋子可蓋住小棋子的井字遊戲變體,現已被解決:先手方 13 著取勝。
  • 此次求解是對全部 531,557,711 個可達局面做一次逆向分析;其中只有 208,563 個非終局局面是和棋。
  • 所有小型與大型的首著皆勝,所有中型首著皆負,因為中型是對手仍能蓋住的最大棋子。
  • 深度來自疊放。移除覆蓋機制後,遊戲縮減到 140 萬個局面,最深的勝利只有 10 著。

Gobblet Gobblers 是帶有疊放機制的井字遊戲變體,以兒童遊戲販售(5 歲以上)。我對它做了完整求解,並建立了一個可查詢最佳著法的網頁局面瀏覽器。先手方 13 著取勝。

此次求解是對從起始局面可達的每一個局面做逆向分析,共 531,557,711 個。輸出是一個殘局庫,能給出任何你能合法到達的局面的確切結果與到結果的距離。

發現 結果
結果 先手方勝
自起始局面的取勝距離 13 著
標準化後的可達局面數 531,557,711
和棋局面數 208,563 (0.04%)
最深的必勝 23 著

原始碼: github.com/brianhliou/gobblet-gobblers

另開獨立頁面 →

每個合法著法依其真實結果上色:綠色為勝,紅色為負。

遊戲規則

3×3 棋盤,兩位玩家。每位玩家有六枚棋子(兩小、兩中、兩大),開局時六枚全在手中。只要在任一橫列、直行或斜線上露出自己顏色的三子連線即獲勝。

你的回合

每回合你做以下兩件事之一:

  • 放置一枚手中的棋子到棋盤上,或
  • 移動盤上一枚自己的棋子到另一格。

無論哪一種,棋子都必須落在空格,或者吞掉(gobble)一枚嚴格更小的棋子(自己或對手的)並蓋住它。只有疊層最上面那枚可見:只有最上面的棋子計入連線,也只有最上面的棋子能被拿起移動。

大棋子蓋住較小的棋子
紅方大棋子吞掉藍方小棋子

揭露規則

把一枚棋子移離它所在的格子,會露出壓在它底下的東西,而這發生在你的棋子落到別處之前。你不能露出對手已完成的連線又放任不管:如果拿起某枚棋子會露出對手的三子連線,那枚棋子就必須落回那三格之一並蓋住它。若它無法到達其中任何一格,這次拿起就是不合法的。

揭露前的局面
後手方的大棋子壓著先手方的小棋子
唯一合法脫身著法後的局面
後手方必須吞回第 0 行才能存活

最尖銳的戰術正是由此而生。一枚棋子可能被牽制:它是唯一蓋住對手連線的棋子,因此動不了。一個疊層承載著歷史,會在許多著之後仍限制著法。當輪到走的一方完全無合法著法時,它輸;當雙方都無法取得進展時,因重複而和棋。

你看不到的部分

實體遊戲會隱藏被蓋住的棋子:你不可以偷看被吞掉的棋子,所以必須記住底下是什麼,實際對局部分是記憶遊戲。本次求解假設完全資訊:雙方都知道每一枚被蓋住的棋子。局面瀏覽器同樣顯示完整疊層,讓你能看到殘局庫評估的確切局面。

逆向分析

重複規則排除了最直覺的做法。帶有 position → outcome 快取的前向 minimax 假設每個局面只有一個值,但在三次重複的規則下,一個局面的值取決於抵達它的路徑:同一個盤面在第三次出現時是和棋,在第一次出現時卻仍有勝負。每個局面只快取一個值是不可靠的。(這就是圖歷史交互作用問題。)

逆向分析則反向求解整張圖,完全不涉及路徑。分兩個階段:

  1. 以廣度優先掃描列舉從起始局面可達的每一個標準化局面,以局面為索引鍵。換位會收斂為同一個節點。
  2. 以反向歸納填值直到不動點。先給終局局面設定初值,然後反覆迭代直到沒有任何變化。
// one round over the still-unknown positions
for &pos in &unknown {
    let mut win = false;                  // a move reaches a loss for the opponent
    let mut all_children_decided_win = true;
    for child in moves(pos) {
        match value[child] {
            Loss    => { win = true; break; }
            Unknown => all_children_decided_win = false,
            Win     => {}
        }
    }
    if win                           { set(pos, Win) }
    else if all_children_decided_win { set(pos, Loss) }
    // else leave Unknown: it resolves in a later round, or it's a draw
}

和棋不需要特別處理。它們就是不動點永遠無法解出的局面,卡在雙方都無法逼出結果的循環裡。整個求解過程中沒有任何地方出現重複計數器。一個局面是和棋,恰好就是在最佳著法下只能靠無限重複來避免落敗的情況,而那正是未解出集合的內容。這與西洋棋和西洋跳棋殘局庫背後的方法相同。

結果

從起始局面可達的標準化局面共 531,557,711 個,而起始局面是先手方 13 著取勝。在 370,974,636 個非終局局面中,結果幾乎對半分:185,455,752 個先手方勝,185,310,321 個後手方勝,只有 208,563 個是和棋(0.04%)。每一個可達局面都是某一方的必勝或和棋;沒有任何局面懸而未決。

值得注意的發現

多數勝局立刻結束,只有一條細長的尾巴延伸到 23 著。 在全部局面的 83% 中,輪到走的一方已有一著能當場取勝。超過這一點後,必勝局面隨距離增加而急速變少,而且幾乎毫無例外地落在奇數著上。尾端收窄到單一個 23 著的必勝,是全局最深的一個,只有 9 個局面能達到。

以對數刻度呈現的必勝局面依取勝距離分布,從 1 著約 3 億衰減到 23 著的 9 個

那個最深的勝利尖銳到極致。從這個局面出發,先手方有 26 個合法著法,恰好只有一個保住勝勢(把中型棋子從中央疊層滑開),其餘 25 個都會輸,而勝利仍在 23 著之外。

開局依棋子大小呈現全有或全無,而且並非單調變化。 27 個首著中,所有小型與所有大型的放置都勝,但所有中型放置都負:它把必勝送給後手方。中型是對手仍能吞掉的最大棋子。用你的大棋子回應它,也就是從中央出發唯一的取勝應手,你便以一枚永遠不會被揭露的棋子佔住那一格,中型棋子被埋在底下。大型完全無法被吞掉;小型不值得用更大的棋子去蓋。只有中間那個尺寸誘發了被吞。

常常只有一著能贏。 在必勝局面中有 12.7% 只有單一著法能保住勝勢,其他每一著都會丟掉。上面那個最深的勝利就是極端案例,26 著中只有一著能救。

近四分之一的局面有被牽制的棋子。 在 22.8% 的局面中,拿起某枚棋子會露出對手的三子連線,因此揭露規則把它釘死在原處。

和棋是殘局現象。 208,563 個和棋集中在後期:91% 的盤上至少有 12 枚棋子中的 8 枚,最常見是 10 枚。這些局面中雙方都能來回挪動而無法逼出結果。

實作

完整狀態可裝進一個 u64:九格各六個位元(小、中、大各有擁有者,每個兩位元)加上一個行棋方位元。著法佔一個位元組,還原佔兩個,勝利判定則是八個預先算好的位元遮罩。

64 位元棋盤編碼

求解意味著要把全部 370,974,636 個非終局局面同時放進記憶體。局面到 id 的映射採用自製的開放定址表,以省去雜湊表每筆項目的指標開銷(在 0.74 負載率下約 6 GB)。不動點迭代在多核心上平行執行:每一輪讀取上一輪的值並寫入下一輪的值,因此每個局面都在其真實距離上解出,取勝距離也是最小的。整個求解在一台 14 核心筆電上約需 30 分鐘,峰值記憶體不到 12 GB。

提供查詢是第二個問題。這張表放不進無伺服器函式,所以用一個最小完美雜湊把 370,974,636 個標準化索引鍵映射到一段密集範圍,沒有碰撞也不儲存索引鍵(每個約 3.5 位元),加上每個局面一個位元組的到結果距離,合計 531 MB,由一個小型 Rust 服務常駐記憶體中。

Browser (game logic in WebAssembly)
   │  POST /lookup/batch  [canonical keys]
   ▼
Tablebase service (MPH in RAM)
   │  outcome + distance per position
   ▼
Browser colors each move

網頁瀏覽器以 WebAssembly 執行遊戲邏輯,只在需要評估值時呼叫該服務。終局局面由它自己判定,因此永遠不會送到殘局庫。

深度從何而來

揭露規則看起來像是深度的來源:一枚棋子因為是對手連線唯一的遮蓋而被牽制,一次拿起就當場落敗。但其實不是。移除該規則重新求解,允許任意拿起,結果幾乎沒有變化:同樣的 370,974,636 個局面,開局同樣是 13 著取勝,最深的勝利僅從 23 著微升到 25 著。這條規則影響了五分之一的著法清單,卻很少改變判定,因為好的著法本來就會避免露出連線。

疊放才是來源,去掉它就能證明這一點。停用覆蓋後重新求解,每枚棋子都只能放在空格,遊戲隨即崩縮:局面數從 370,974,636 降到 1,433,602,最深的勝利降到 10 著,且沒有一個和棋。原因是從來沒有任何棋子被吃掉。吞掉只是蓋住一枚棋子,並沒有把它移除。子力永遠不會像西洋棋中吃子把盤面簡化到殘局那樣變薄,所以一個 3×3 的格子永遠安定不下來。動物棋的深度出自同一個地方,只是路徑不同:被吃的棋子會以打入的方式回到盤上,所以它的子力同樣永不枯竭。

先手方 13 著取勝。位於 gobblet.brianhliou.com 的局面瀏覽器依殘局庫走棋,依真實結果為每個合法著法上色,並標註到結果的距離。求解程式在 GitHub 上。