回到作品列表
CGYBoardGame

CGYBoardGame

CGY 桌遊:11 款遊戲,大部分程式碼由 agent 寫,規則由伺服器說了算

CGY 桌遊 介紹影片

有時候和朋友聚在一起不知道要做什麼,但手邊又不會隨身帶著桌遊,這個專案因此而生。

能玩什麼

阿瓦隆、台灣麻將、大老二、牛頭王、矮人礦坑、吹牛骰、駱駝大賽(第二版規則)、三國殺(2009/10 標準版)、象棋、暗棋、五子棋。

畫面是酒館風格的 low-poly 3D 牌桌,每款遊戲在桌上用不同的道具呈現。

手機一個螢幕放得下整局是硬規定:計分、玩家列、紀錄都可以收起來,但不能擠壓牌桌和手牌。每款也都有三級提示(關閉/新手/教練),讓第一次玩的朋友不必先讀完規則書。

規則只存在伺服器上

這是整個架構最核心的決定,後面的設計幾乎都由它推導出來。

最直覺的做法,是讓某個玩家的手機當房主、跑遊戲邏輯,其他人連過去。我沒有這樣做,因為房主的手機上會有全部人的手牌。對阿瓦隆這種靠隱藏身分的遊戲來說,這等於直接把答案放在其中一位玩家的口袋裡。

現在的做法是:每個房間是伺服器上的一個 tokio task(room actor),完整的遊戲狀態只存在它的記憶體裡。玩家送出的只有「我想做什麼」,伺服器檢查合不合規則、更新狀態,再替每個座位各算一份畫面:

// server/crates/games/src/core.rs:引擎唯一的投影出口,給誰看就只算誰看得到的東西
fn view_for(state: &Self::State, viewer: PlayerId) -> Option<Self::View>;

view_for 是狀態離開引擎的唯一路徑。瀏覽器不做樂觀更新,也拿不到別人的資料,打開 DevTools 看 WebSocket 封包,裡面就只有你自己的手牌。

每次狀態變化要算 N 份 view,聽起來很浪費。但朋友局最多 10 人,這點成本換來的是不必擔心任何一條作弊路徑。

不存遊戲狀態,只存怎麼走到這一步

遊戲狀態 State 刻意不實作 Serialize。

資料庫(SQLite)只存兩樣東西:開局參數 (config, player_count, seed),以及每個成功的動作。伺服器重啟時,用同樣的 seed 把所有動作重播一次,就能還原出一模一樣的牌局。

這樣做有三個好處:

  • 資料庫裡不可能出現「規則上不合法」的狀態,因為每個狀態都是合法動作一步步走出來的。
  • State 根本沒有序列化的路徑,就不可能有人手滑把整個狀態(包含 seed 和別人的手牌)送給前端。
  • 引擎改內部欄位不需要寫 migration。

seed 被當成和手牌同等級的機密。拿到 seed 的人可以在自己的電腦上算出所有人的牌和之後的牌序,所以它不送前端、不寫 log,連 Debug 輸出都改成手寫,只印 SplitMix64(..)。另外有一支測試專門檢查 view 的 JSON 裡沒有 seed、rng、deck 這些字串。

寫入順序也有講究:先寫進資料庫,成功了才改記憶體、才廣播出去。玩家只要看到某一步發生了,伺服器重啟後那一步就一定還在。

不用 trait object,加一款遊戲漏改會編譯失敗

11 款遊戲要共用同一套房間、連線、計時流程。Rust 裡最直覺的寫法是 Box<dyn Game>,但我選了 enum 加巨集。

原因是型別。Game 有五個關聯型別(動作、狀態、畫面等),一旦變成 dyn,動作和畫面就只能當成 serde_json::Value 傳遞,型別在伺服器內部就消失了。前端的 TypeScript 型別是用 ts-rs 從 Rust 自動產生的,拿到的會是一片 unknown。

改用一張宣告表,交給巨集展開成所有需要的 enum 和 match:

// server/crates/server/src/game/registry.rs(節錄)
macro_rules! for_each_game {
    ($m:ident) => {
        $m! {
            Avalon {
                engine: games::avalon::Avalon,
                tag: "avalon",
                lobby: AvalonLobby,
                // ...
            },
            Mahjong { engine: games::mahjong::Mahjong, /* ... */ },
            // ……共 11 款
        }
    };
}

每個需要「對所有遊戲做同一件事」的地方,都寫成 for_each_game!(自己的巨集),把整張表交給自己展開。

如果新增一款遊戲卻漏改了某處,match 不窮舉,直接編譯失敗,不會等到執行時才出錯。代價是巨集比一般程式碼難讀。

手機會一直斷線,所以斷線是常態

玩家看一下 LINE 再切回來,連線早就斷了。

所以斷線時座位會保留,不會把人踢掉。重連後伺服器直接送一份最新的完整畫面,不補發中間錯過的事件。前端也不等退避計時器:一偵測到頁面回到前景(visibilitychange)或網路恢復(online),就立刻重連。

前端用 Next.js 靜態匯出,由 Rust 的同一個執行檔一起提供,同源、不用處理 CORS,NAS 上也不必跑 Node。

Agent 寫的程式碼,要怎麼信任

這個專案大部分的程式碼是 AI agent 寫的,分工大概是這樣:

  • Opus 5.5 當主 agent:負責規劃、寫交辦單(.omc/handoffs/ 裡累積了 142 份)、最後驗收。資安和 3D 美術一律由它自己做。
  • MiniMax M3.1 Flash:機械性的接線和補 UI,是派最多工作的一個。
  • Sonnet 5.5:規則密集的引擎,例如三國殺的傷害與判定結算、吹牛骰。也負責 code review。
  • swift-1.5-iq3_s:跑在本機的模型,處理切得很細的單檔小修。

分工的原則是看風險,不是看便宜。

礦坑遊戲的伺服器接線交給 MiniMax,它回報「920 passed,clippy exit 0」。主 agent 重跑一次,結果有 1 個測試失敗,clippy --all-targets 也編不過。大老二和牛頭王也發生過類似的事:測試全綠,但核心規則是錯的。

從那之後,worker 回報「做完了」一律不算數。主 agent 要自己讀過程式碼、跑突變測試,確認測試真的抓得到錯,才算驗收。

整條產線都在家裡

程式碼合進去之前要過 19 個 CI job:Rust 的 clippy 和測試、前端的 lint 和單元測試、Playwright E2E,以及 gitleaks、trivy、semgrep、osv、guarddog 五種掃描。掃描結果送進 DefectDojo,High 以上等級的問題會擋住合併。

GitLab、CI runner(一台 Mac mini)、Container Registry 全部是自架的。合進 master 後,pipeline 會依 Conventional Commits 自動打版號、把 docs/ 同步成 wiki、用 kaniko 建出 amd64 image 推進自架的 Registry。最後一步部署由我自己在 NAS 上操作。

不靠雲端 CI,也不把 image 推上公開倉庫。對一個朋友間玩的專案來說這也許有點過頭,但它讓我能放心讓 agent 開 MR:反正沒過關的東西進不來。

這支影片本身也是 E2E 測試錄出來的

上面那支影片的實機畫面不是手動錄的。

專案原本就有一套 Playwright E2E,會用 docker 起真的 Rust server,開 2 到 5 個玩家把一局從建房打到結算。我在建立玩家的 helper 裡加了一個用環境變數控制的 CDP screencast,同一套劇本跑一次,就拿到所有遊戲的真實對局畫面,總共約 26,000 格。再交給 Remotion,依 120 BPM 的拍點剪成 16:9 和 9:16 兩個版本。

有一個坑值得記下來:screencast 想錄到裝置像素,要在 launch args 加 --force-device-scale-factor=3,只設 context 的 deviceScaleFactor 沒用。

專案資訊

最新版本
v2.8.1release+9
  • v2.8.12026.10.24
  • v2.8.02026.10.23
  • v2.7.02026.10.21
  • v2.6.02026.10.21
  • v2.5.12026.10.20
  • v2.5.02026.10.20
  • v2.4.02026.10.12
  • v2.3.02026.10.07
  • v2.2.02026.10.07
  • v2.1.32026.10.07
最後活躍
2026.10.24

語言比例

  • Rust 56.18%
  • TSX 23.47%
  • TypeScript 16.45%
  • CSS 2.7%
  • Python 0.73%
Dice6 · 骰到六
一個人寫程式、一個人寫字。喜歡的話可以追蹤 RSS。
訂閱 RSS