[Tefuda] 一份引擎,三個執行環境:網頁、iOS、Android 共用同一套規則

Tefuda 是我做的一款小卡牌遊戲,同時以網站、iOS App、Android App 三種形式上架,背後接同一個由伺服器驗證的排行榜。

麻煩的不是這個組合,而是同一套規則必須同時在四個地方成立:瀏覽器要照它跑、iOS App 要照它跑、Android 要照它跑,而伺服器要在不採信任何用戶端說法的前提下,自己把分數重算一次。

最直覺的做法是把規則寫三次:瀏覽器的 TypeScript、手機的 Swift、伺服器再一份。代價是之後每個 bug 都要修三次,而且兩份實作只要對不起來,分數就取決於是誰算的。

所以規則只有一份。

這篇不從架構圖開始,而是從一個玩家的動線開始,按順序回答三個問題:你在螢幕上做的事是誰算的?什麼時候會打到 API?留下來的資料躺在哪裡? 這三題答完,整套系統就講完了一半;後面幾節處理的都是同一個麻煩的不同面向——規則會改版,而三個用戶端不會同時知道


1|三個 App,其實只有兩種畫法

先把「三個用戶端」拆開,因為它們的差別比看起來小很多:

用戶端誰畫牌桌誰跑規則規則怎麼更新
網頁Next.js 靜態輸出,跑在瀏覽器裡瀏覽器重新整理就是最新的
iOS App原生 SwiftUI,一行 HTML 都沒有JavaScriptCore(iOS 內建的 JS 引擎)背景下載,下次冷啟動換上
Android App它不畫,它就是全螢幕的 Chrome瀏覽器網站部署完,Android 就等於出了一版

Android 那一列不是偷懶,是刻意的:它是一個 Trusted Web Activity,也就是「Chrome 開在 https://playtefuda.com 上、網址列拿掉」,因為 App 和網站互相證明過彼此是同一個產品。整個 repo 裡沒有任何一個 .kt 檔案。

所以三個 App,實際上只有兩個地方在跑規則:瀏覽器,和 iOS App 裡的 JavaScriptCore。伺服器自己也要跑一次,加起來三個——標題的「三個執行環境」就是這個意思。

flowchart TD
  SRC(["lib/game + lib/wire<br/>規則和 API,各只寫一次"])
  SRC --> BUNDLE[/"engine.js<br/>建置出來的單一檔案,約 12 KB"/]

  BUNDLE --> WEB{{"瀏覽器<br/>網頁玩家在這裡跑它"}}
  BUNDLE --> JSC{{"JavaScriptCore<br/>iOS App 在這裡跑它"}}
  BUNDLE --> WK{{"Cloudflare Worker<br/>伺服器在這裡把它重跑一次"}}

  WEB -.-> TWA["Android App<br/>就是全螢幕的瀏覽器,自己不跑引擎"]

有三個詞這篇會用得很嚴格,後面每一節都靠這個區分:

  • 引擎(engine)= 建置出來的規則產物。一個檔案,沒有 import、沒有設定、沒有網路呼叫。
  • 執行環境(runtime)= 能跑這個檔案的宿主:瀏覽器、JavaScriptCore、Cloudflare Workers。
  • 一局(run)= 一場遊戲,紀錄成「一個 seed + 一串步驟」,從來不是一個分數

2|玩一局會打幾條 API?零條

最反直覺的一句話先講完:從發牌到結算,整局遊戲不需要伺服器。

洗牌是 seed 決定的;一步棋做了什麼、一手牌值多少分、什麼時候升級,全都是純函式,在你自己的裝置上算完。網頁玩家連 Worker 都碰不到一次——遊戲本體是靜態檔案,由 Cloudflare 的 asset router 直接送。

伺服器只在四件事情上出現:送成績、看排行榜、看別人的重播,以及(只有 iOS)問一句「引擎有沒有新版」。

整套 API 就這五條,沒有第六條:

呼叫什麼時候送什麼回什麼
GET /api/leaderboard打開排行榜哪個模式(一般模式/禪模式/每日挑戰)、我的玩家 id、分頁一頁的列
GET /api/replay點開某一列想看哪個板、第幾名seed、步驟紀錄、引擎版本
POST /api/score一局結束按送出seed 或日期、步驟紀錄、名字、玩家 id、引擎版本——沒有分數伺服器自己算出來的分數和排名
DELETE /api/score刪掉自己的紀錄列的 id + 玩家 id
GET /api/engine每次啟動,牌桌畫好後什麼都不送現在線上是哪個引擎、去哪抓

前四條三個用戶端都會打;只有最後一條 GET /api/engine只有 iOS 會打。

另外有兩種東西也會走網路,但它們不是 API,是靜態檔案/engine.js(iOS 抓新引擎)和 /engines/<版本>.js(要重播一局舊規則打的遊戲時,抓那套舊規則)。它們一樣由 asset router 送,不會叫醒任何一行我寫的程式碼。

伺服器端只有兩個路徑前綴會真的執行我的程式:

flowchart TD
  REQ["一個請求進來"] --> SPLIT{"看路徑"}

  SPLIT -->|"/api/*"| WORKER{{"Worker<br/>五條 API"}}
  SPLIT -->|"/w/*"| WORKER
  SPLIT -->|"其他全部<br/>頁面、圖片、engine.js、engines/*.js"| ASSETS("asset router → 靜態檔案<br/>0 次 Worker 呼叫")

  WORKER --> DB[("D1<br/>一張表")]

這張圖真正想說的是最下面那條線:玩這款遊戲要花的 Worker 請求數是零。 只有送成績、讀排行榜、看重播,和打開別人分享的連結(/w/*,留到後面分享卡片那節細講是什麼),才會碰到我寫的程式碼。


3|資料存在哪

三個地方,而且三邊存的東西幾乎不重疊。

(1) 玩家的裝置上

網頁存在 localStorage、iOS 存在 UserDefaults——存的是同一組東西,連 key 的名字都一樣,因為玩家身分那兩個 key 是 lib/wire.ts 給的,兩邊都從同一份程式碼讀:

存什麼網頁(localStorage)iOS(UserDefaults)
玩家身分(一串隨機 id)tefuda_player_id同名
顯示名稱tefuda_player_name同名
語言/主題/靜音tefuda_lang 等三個同名
每日挑戰打過哪幾天、各拿幾分tefuda_daily_v1同名
進行到一半的牌桌tefuda_save_v6沒有
送不出去、等重試的成績tefuda_pending_v1沒有
下次啟動要換上的新引擎沒有Application Support 底下一個 .js 檔 + 三個 key

最後三列的差異全都是平台差異,不是設計不一致:分頁隨時會被重新整理,所以網頁得把牌桌和送不出去的成績寫下來;手機 App 被切走時整個活在記憶體裡,回來就還在原地。反過來,只有 iOS 需要把「下次啟動要換上的引擎」存到磁碟——因為只有它的規則不是每次開啟都重抓(第 9 節)。

這裡沒有帳號。 沒有密碼、沒有 cookie、沒有 session。玩家 id 是用戶端自己隨機產生的一串字,它不是身分驗證,只是一個「這幾列是同一支裝置送的」的標記。這件事之所以無所謂,是因為排行榜的可信度完全不靠它——靠的是第 4 節的重播。

(2) 伺服器的資料庫

D1(Cloudflare 的 SQLite),六個 migration,一張表,每一筆被接受的紀錄就是一列:

欄位例子用來做什麼
movesp0p1c0dd…391 步,每步兩個字元
seed2880217059當初發的是哪一副牌
date2026-07-25哪一天的每日挑戰(或 free)
score608074打出來是多少
engine7766419a1169那是在哪一套規則下打的

分數雖然存著,但它不是事實來源,只是一次計算的快取。真正的事實是 (seed, moves, engine) 這三欄,因為它們可以把分數精準重算出來。這就是這裡「伺服器驗證」的意思,也是重播之所以可能的原因:觀看者不是在抓一段錄影,而是把那一局重跑一次。

這張表只增不改。刷新自己的紀錄不會覆蓋舊的那一列,所以別人分享出去的舊重播不會因為你變強而失效。

(3) 版控裡的一個目錄

engines/ 底下每個檔案是一整套「曾經上線過的規則」,各約 11 KB,全部納入 git。它為什麼必須存在,是第 8 節整節的內容。

換句話說,伺服器上關於一位玩家的全部,就是那張表裡的幾列;而伺服器上關於這款遊戲的全部,就是那張表加上那個目錄。


4|一局怎麼變成排行榜上的一列

前三節是靜態的地圖,這節是動線。跟著一局遊戲走一次,所有零件會照順序出場:

flowchart TD
  PLAY["玩家打完一局<br/>在瀏覽器或 iOS App 上"]
  PLAY -->|"POST /api/score<br/>seed + 步驟 + 引擎版本,沒有分數"| WORKER{{"Worker"}}
  WORKER --> LOOKUP{"engines/ 裡有這個版本嗎?"}
  LOOKUP -->|"沒有"| STALE["409 stale_engine"]
  LOOKUP -->|"有"| REPLAY["用那一份引擎重播<br/>同一個 seed、同樣 391 步"]
  REPLAY --> ROW[("寫進 D1<br/>seed · moves · engine · score")]
  ROW --> WATCH["幾週後有人點開想看<br/>抓 /engines/&lt;版本&gt;.js 再跑一次"]

裡面有兩件事就是整個設計。

用戶端送的是它實際打過的每一步,不是它宣稱的分數。 伺服器用同一個 seed 把這串步驟重跑一次,以自己重播的結果為準。被動過手腳的紀錄,不是套用失敗,就是跑出它真正的分數。整個防作弊模型就這樣,成本是零:不需要混淆、不需要簽章、不需要任何啟發式判斷。

前提比看起來嚴格。 伺服器的重播和玩家當下那一局,必須每一位數都一致。兩份規則只要有一張分數表差一個字元,每筆分數就是兩個數字之間的擲硬幣,排行榜會安靜地被沒人真的打出來的紀錄填滿。所以共用程式碼在這裡不是潔癖,是唯一可行的做法。


5|lib/game:只有規則,別的都不放

它是什麼。 一個 TypeScript 目錄,兩千五百行左右,知道一副牌怎麼從 seed 洗出來、一步棋對狀態做了什麼、一手牌怎麼比大小、一局值多少分。裡面全是對單純資料做事的單純函式:placeCard(state, index) 回傳一個新的狀態。沒有 React、沒有 DOM、沒有 fetch、沒有儲存、沒有時鐘,也沒有任何不是從 seed 來的隨機。

最後一項最重要。 這裡的每件事都必須是 (seed, 步驟) 的純函式,因為稍後伺服器手上就只有這兩樣東西。只要有一條計分規則去問了現在幾點,那一局就再也驗證不了。

它刻意不管的事:遊戲長什麼樣子。動畫、音效、排版、文案、顯示個人最佳紀錄的那個畫面,通通不在這裡,也通通不可能改變分數。

真正花最多力氣維持的紀律,是「規則」的邊界拉到多遠。Swift App 裡沒有一張「哪種牌型贏哪種」的對照表,它啟動時去問引擎;玩家名稱的字數上限也不在 Swift 裡,排行榜存名字用哪條規則裁切,App 就用同一條。這種常數正好是那種三十秒就能抄成 Swift 的東西,也正好是最會偏掉的東西。

Swift App 知道怎麼「畫」這場遊戲,不知道怎麼「玩」。

原生橋接通常是大家預期最醜的一段,這裡刻意做得無聊:一個 headless 入口,唯一的工作是把引擎重新輸出成 JSON 進、JSON 出的介面。

const TefudaEngine = {
  version: ENGINE_VERSION,
  newDaily: (date: string): string => JSON.stringify(createDailyState(date)),
  apply: (state: string, move: string): string =>
    JSON.stringify(apply(parse(state), move)),
  canDiscard: (state: string): boolean => canDiscard(parse(state)),
  // …
};

狀態以 JSON 字串跨過 JS↔Swift 邊界,Swift 用 Codable 解碼,數字和 enum 怎麼對應沒有模糊空間。檔案最上面寫著這條規矩:不要在這裡分叉邏輯,只能重新輸出。原生那側一旦自己算某件事,就等於擁有了一條規則,而同一條規則被兩邊各自持有,遲早會不一致。


6|lib/wire:連 API 也只描述一次

第 2 節那張五條 API 的表格,不是我整理出來的文件,而是一個檔案的內容。

共用了引擎卻手寫兩套 HTTP client,只是把重複往外推一層。iOS App 會自己持有一份路徑和參數名,而 endpoint 第一次搬家它就過期了——安靜地、在別人的裝置上,而且除了送審沒有別的辦法修。

所以 API 也只描述一次:一個純函式模組,收下每一條路徑、每一個 query 和 body key、每一種回應形狀、每一組錯誤碼。

兩邊的用戶端因此只剩傳輸。瀏覽器那份是外面包一層 fetch,iOS 那份是外面包一層 URLSession——而且它是從引擎 bundle 裡拿到這個模組的,自己不持有任何 endpoint。它跟模組要「這次呼叫該送什麼」,送出去,再把回應交回去解碼:

wire: {
  request: (op: string, ask: string): string => { /* → { method, path, body } */ },
  decode:  (op: string, status: number, body: string, ask: string): string => { /* … */ },
}

Swift 這一側唯一自己決定的事,是「一個根本沒送出去的請求叫做 offline」。連玩家身分存在哪個 key、名字最多幾個字、分享連結長什麼樣,都是跟這個模組要的答案。

這就是它跨執行環境的方式:它不是一個 App 去連結的函式庫,而是引擎交出來的資料。改一條 endpoint 會同時帶動兩邊,包含已經上架在 App Store 的那個版本。

兩條限制都是撞過才知道的。

它必須純粹:不能用 fetch、不能碰儲存、DOM 或 process.env,因為它跑在一個空的 JavaScriptCore 環境裡,URLbtoaTextEncoder 全都不存在。base64url 因此是手寫的。

它必須待在重播閉包之外,理由在下一節。


7|ENGINE_VERSION:沒有人手打的版本號

到這裡為止,「規則只有一份」已經成立了。但它只解決了「規則是同一套」,沒解決「規則此刻是同一套」,而後者才難:

  • 瀏覽器分頁會一直用它當初載到的 JavaScript,部署碰不到它。
  • 原生 App 只在下次冷啟動時採用下載回來的引擎,而手機通常是把 App 掛起好幾天,不是關掉。

所以每次建置都推導出一個 ENGINE_VERSION:對「一次重播真正會經過的模組」取雜湊,也就是 esbuild 從重播進入點解析出來的閉包。兩個執行環境持有同一個字串,就保證同一串步驟算出同一個分數;字串不同就不保證。排行榜靠的就是這條約定,而它也是第 3 節那張表裡 engine 欄位的內容。

雜湊的是壓縮後的輸出,不是原始碼。 每次版本變動都會讓當下進行中的局作廢,所以改個註解、重排一個區塊、把區域變數改名,都不該害誰損失一局玩到一半的遊戲。壓縮剛好抹掉這些,而保留所有能影響分數的東西;真正的分數表只要改一個字元,雜湊一定會動。代價是 esbuild 自己的版本也成了輸入——這很少見,而且壞的方向是安全的:只會多跳一次版本,不會漏跳。

它是推導出來的,不是宣告的,因為「忘記手動 bump」正是這套機制要抓的失誤。腳本還防了雜湊變成自己的輸入:產生出來的版本檔如果跑進被雜湊的閉包裡,建置直接丟錯。

這也是 lib/wire 必須待在閉包外面的原因。如果在裡面,搬一條 endpoint 就會讓版本跳動、讓所有進行中的局作廢——為了一個網路層的改動,付一筆遊戲規則的帳。


8|engines/:舊規則不能丟

伺服器第一版是強制版本相符的:用其他字串打出來的紀錄一律回 stale_engine

答案正確,但問題問錯了。部署不會更新某個人正在玩的用戶端,只會把對方留在原地。網頁玩家會失去進行中的那一局;從不強制關閉 App 的 iOS 玩家可能被鎖上好幾天,什麼都送不出去。而這段期間,他們那一局依循的規則明明還存在,也定義得好好的。

所以伺服器把它們留著。每個部署過的引擎都封存成一份以自己版本命名、納入版控的 bundle;送上來的紀錄,用它自己宣告的那套規則重播,而不是今天的:

const rules = typeof engine === "string" ? ENGINES[engine] : undefined;
if (!rules) return json({ error: "stale_engine", engine: ENGINE_VERSION }, 409);

有兩件事它刻意不做。不用新規則把舊紀錄重算一次——那會是一個沒人為它玩過的數字。也不採信用戶端的任何說法——封存的引擎重播一串紀錄的方式,和線上那份完全一樣,所以用舊版本送上來的一局,和新版本一樣是被驗證過的。stale_engine 於是收斂成它本來該有的意思:舊到封存已經不再帶著它的引擎。

舊引擎實際上住在哪

「封存」兩個字承擔了太多意思,這裡講具體的——我自己隔幾個月回來看,唯一拼不回來的就是舊規則實體上放在哪。

一份封存的引擎就是一個檔案。 不是容器映像檔,不是資料庫裡的 blob,也不是還跑在某處的服務:

engines/7766419a1169.js       11 KB,納入版控

自給自足的壓縮 JavaScript,只把重播的進入點打包進去,匯出兩三個函式,沒有 import、沒有設定、沒有網路呼叫:

export { rr as replayDaily, tr as replayFree };

git log 裡有它,編輯器打得開,五年後照樣跑得動,因為它不依賴任何東西。

同一份位元組,三個讀者,所以建置把它複製到各自搆得著的位置:

flowchart TD
  SRC[/"engines/&lt;版本&gt;.js<br/>納入版控,約 11 KB"/]
  SRC -->|"由 registry.ts 匯入,編進 Worker"| W{{"伺服器<br/>替送上來的紀錄計分"}}
  SRC -->|"複製到 public/engines/"| B{{"瀏覽器<br/>用 import() 載入"}}
  SRC -->|"包裝成 .app.js"| A{{"iOS App<br/>JavaScriptCore 求值"}}

第三個需要那層包裝,是因為 JavaScriptCore 沒有模組載入器——script 裡出現 export 是語法錯誤,不是引擎。所以建置多產一種形式,改成掛到全域變數上。它的輸入是封存檔自己的位元組,不是重新編譯一次原始碼:從原始碼重建,可能產出跟伺服器計分用的那份有微妙差異的東西,接著畫面上的數字和榜上的數字就對不起來了。

十三列看不了的紀錄

會安靜壞掉的是最後一步。 第 3 節那張表的 engine 欄位,實質上是一個指向某個目錄裡某個檔案的外鍵,卻沒有任何東西在保護它:沒有 REFERENCES、沒有 migration、沒有型別。把檔案刪掉,欄位裡照樣寫著 7766419a1169,那一列照樣顯示 608074,因為那個數字在送出的當下就算好存下來了。只有重播會壞,而且壞的方式是走幾步就停住,因為今天的引擎發的是另一副牌,於是拒絕了一個對不上的步驟。

我知道它是這樣壞的,因為我幹過。封存留最新六個版本,卻有十三列還指著第七個。沒有東西報錯、沒有測試失敗、沒有任何一行 log,板子看起來完好。只是那十三局沒辦法被看,而唯一的症狀是播到一半凍住、計數器卻顯示著完整長度,跟功能壞掉長得一模一樣。

教訓是「保留最近 N 個」這個形狀本身就選錯了。 引擎服務的是兩件不同的事,期限也不同:

  • 送成績——還在跑那個版本的用戶端。這段期間會結束:分頁重新載入、手機下次冷啟動就更新。
  • 重播——每一列指名它的紀錄。這段期間永遠不會結束,因為一列會待在全時排行榜上,直到有人超越它。

用發版節奏決定封存深度,等於回答了第一件、忽略了第二件,而真正有約束力的一直是第二件。該保留的不是「六個版本」也不是「三個月」,而是任何一列還活著的紀錄所指名的每一個版本。所以這件事現在是一句查詢,不是一個猜測:

version        rows    verdict
4c8e230326bc      3    this build — never drop
7766419a1169     16    needed: 16 rows replay through it
f4cacd061554     13    needed: 13 rows replay through it
e99a0a3e2210      0    no row needs it — droppable

兩個住在不同地方的事實——一個檔案目錄,和一個 SQLite 欄位——被並排放在一起。系統裡沒有別的東西同時看得到兩邊,這也是它一開始會出錯的原因。

封存是這裡唯一一個「漏掉會安靜壞掉」的步驟,所以它不是一個習慣,是一個建置步驟:predev 寫入封存,prebuild--check 檢查、過期就讓建置失敗,pre-commit hook 負責重新產生。另外有一條救生索:--recover <commit> 改用 git archive <commit> 而不是工作目錄,把漏掉的 bundle 重建出來,因為一個版本的規則,就是產生它那個 commit 當下的原始碼。反過來也是警告:把產生某個版本的 commit squash 掉,那套規則就永久消失,連同任何一局照它玩過的紀錄。

那十三列我救回來了,但靠的是運氣:建出那份 bundle 的 commit 已經被 squash 掉,檔案以一個還沒被垃圾回收的 unreachable blob 形式留在 git 的物件庫裡。git cat-file -p f2cc99af67b6 吐回 11 KB,精準重現了全部十三筆已存的分數。那不是一套復原程序,那是差點出事。


9|OTA:把新規則送進已經在跑的 App

前一節講的都是「用舊規則打的紀錄怎麼辦」。這一節是反方向:用戶端怎麼拿到的。

網頁和 Android 沒有這個問題——重新整理就是最新版。有問題的只有 iOS,因為它是唯一一個把規則帶在身上的用戶端。

為什麼 App 裡還要編一份引擎進去

這是我自己最常被問、也最值得先講清楚的一題:既然引擎是 JavaScript、可以從網路抓,那 App 為什麼不乾脆每次啟動去雲端抓最新的,跑個更新動畫存到手機裡就好?

因為那樣做會在啟動路徑上放一個網路依賴,而遊戲本身根本不需要網路。具體有四個理由:

  1. 第一次打開就要能玩。 全新安裝、在飛機上、在地鐵裡。引擎如果只從網路來,沒網路時 App 就是一張空桌子。App Review 也會在很爛的網路下打開它。
  2. 啟動不該等網路。 那個「更新動畫」正是要避開的東西:它為了一個平均一個月才變一次的 12 KB 檔案,在每一次啟動時擋住牌桌。現在的順序是相反的——牌桌先畫好、可以玩了,.task 才在背景去問伺服器。整趟更新沒有任何一個畫面在等它,玩家也永遠看不到它。
  3. 就算抓下來了,也不能立刻換。 換引擎會改變發牌方式,局中抽換等於玩家眼前的牌桌和之後重播出來的分數對不上。所以新引擎一律留到下次冷啟動才採用——既然要跨啟動保存,本地就一定得有一份「開機用的引擎」。
  4. 它是退路。 存起來的 bundle 如果起不來,App 退回內建那一份。沒有內建那一份,這條退路就變成「App 開不起來」。

所以編進 App 的那份是地板——全新安裝、離線、出事時都是它——伺服器上的那份是天花板。地板一樣能玩、能上榜,因為伺服器是用那一局宣告的版本重播的(第 8 節)。

而且這其實就是常見做法:CodePush、Expo Updates 這類 OTA 方案,形狀都是「binary 裡有一份 baseline + 背景抓更新 + 下次啟動套用」。差別只在很多方案預設會在 splash 期間等下載完成,Tefuda 不等——12 KB 的規則差異不值得任何人多看一秒的載入畫面。

還要先把規模講清楚:這不是什麼 code push 框架。它就是一次雜湊比對加一次檔案下載,而它之所以能這麼小,靠的是前面每一節。

流程,包含它決定「不更新」的每個岔路

flowchart TD
  DEV["我改了 lib/game"] --> BUILD["建置:推導 ENGINE_VERSION、<br/>打包 engine.js、封存一份"]
  BUILD --> DEPLOY["部署,engine.js 成為靜態資源"]
  DEPLOY --> MANIFEST("GET /api/engine<br/>version · build · minUI · path")

  APP["App 用手上的引擎啟動<br/>牌桌已經畫好,可以玩了"] --> MANIFEST
  MANIFEST --> SAME{"build 雜湊和我手上的一樣?"}
  SAME -->|"一樣"| STOP["什麼都不做<br/>幾乎每次啟動都停在這"]
  SAME -->|"不一樣"| UI{"它需要比我畫得出來更新的牌桌嗎?"}
  UI -->|"需要"| HOLD["留著現有引擎<br/>照樣能玩、能上榜"]
  UI -->|"不需要"| DL["下載 engine.js"]
  DL --> PROBE{"丟進臨時 context 跑起來——<br/>起得來嗎?它說自己是哪個版本?"}
  PROBE -->|"起不來或對不上"| DROP["丟掉,這次當沒發生"]
  PROBE -->|"起得來且對得上"| STAGE["寫進 Application Support,<br/>標記為下次啟動採用"]
  STAGE --> COLD["下次冷啟動時換上"]

伺服器給的是一個指標,不是內容——幾乎每次啟動都只是一百多個位元組:

{
  version: ENGINE_VERSION,    // 哪一套規則
  build: sha256(servedBytes), // 哪一份位元組
  minUI: MIN_APP_UI,          // 最舊哪一版牌桌畫得出來
  path: '/engine.js',         // 一條路徑,不是絕對網址
  accepts: ENGINE_HISTORY,    // 封存還重播得了的每一個版本
}

為什麼需要 build,光有 version 為什麼不夠。 ENGINE_VERSION 只雜湊重播閉包,這是刻意的——正是它讓「搬一條 endpoint」不會害誰損失進行中的一局。但 bundle 裡裝的比那個閉包多:lib/wire 在裡面,新手教學和橋接介面也在裡面。所以改一次通訊協定,會產生不同的位元組、卻是同一個版本字串;只看 version 的 App 就會永遠停在舊 bundle 上,而且沒有任何症狀,因為它本來會的事情都還能做。這不是假設:新的 wire.enginePath 就是這樣上線的,在 build 加進來之前,一台裝好的 App 都沒收到。版本說的是哪套規則,雜湊說的是哪份位元組,兩個問題不一樣,兩個都要。

OTA 更新得了什麼:那份 bundle 裡是 JavaScript 的東西都行——分數表、牌組組成、一步棋做什麼、每日挑戰的變化、通訊協定、新手教學腳本。

更新不了什麼:Swift。一份 bundle 可以教會 App 新的數字,但教不會它畫出一個二進位檔裡根本不存在的控制項。minUI 就是講這件事的。具體一點:禪模式沒有時鐘、也不能棄牌,所以一張在 zen 出現之前做好的牌桌,會在一個再也不會超時的目標旁邊印出「還剩 −1 次擺放」,旁邊還擺一個按了完全沒反應的按鈕。什麼都不會當掉,畫面只是在描述另一款遊戲,而這正是這個數字要擋掉的事。低於 minUI 的 App 因此留著手上的引擎,照樣送得出成績——這是一個提醒,不是一道斷崖。

安全性,老實講。 這份 bundle 沒有簽章,而我認為這是想清楚後的選擇,不是偷懶。傳輸是 HTTPS,連的是 App 本來就在連的同一個 origin——而且伺服器回的是一條路徑,不是絕對網址,所以伺服器上的東西沒辦法把裝置指到另一台主機去。完整性用「跑跑看」來檢查:下載回來的 bundle 會先在一個用完即丟的 context 裡求值,然後問它自己是哪個版本,起得來、而且跟 manifest 說的一致,才會被存起來等下次啟動。截斷的下載會被丟掉。

而這些之所以都不必扛太重的責任,是因為排行榜的模型:伺服器本來就不信任用戶端的引擎。 有人去改自己手機上的那份 bundle,改到的只有自己螢幕上顯示的東西——分數是伺服器拿那一局宣告的封存引擎重算的。被改過的引擎送不出假分數,最多只能產生一串套用失敗的紀錄。簽章在這裡要保護的,是玩家不要騙自己。

壞掉的時候會怎樣。 每一條失敗路徑,結果都是「App 繼續跑它現在跑的東西」:

  • 離線、500、404 → 這次更新安靜跳過,下次啟動再試。
  • Bundle 起不來 → 在存檔之前就被丟掉。
  • 存起來的 bundle 在啟動時居然起不來 → App 退回編進二進位檔的那份引擎,並把存起來的丟掉。這個退路就是「這次更新沒用」和「App 開不起來」之間的差別。
  • App 自己送了一份比存起來更新的引擎 → 把存起來的丟掉。這是 rollback 的情況,也是我做錯兩次的地方。兩個版本雜湊之間沒有先後可言,所以 App 比的是「目前二進位檔裡那份引擎的位元組」和「當初是蓋在哪份上面存的」。以前比的是 build number——本機重建永遠不會動到它,於是一份存起來的 bundle 蓋過了每一次重建,最後手機上跑的 JavaScript 比它外面那個 App 還舊了好幾個月。

網頁那邊的 OTA 就是重新載入,設計問題在什麼時候。不跳提示,也不在局中抽換:在唯一沒有東西可失去的那一刻重新載入——牌已經發好、但還沒動過的盤面。這剛好涵蓋了真正會發生的情況(分頁開著過了一夜、你停在結算畫面時剛好有一次部署),而且不會打斷任何人。原生 App 是同一個取捨,只是把那一刻挪到下次冷啟動。

一個值得抄走的細節:那次重新載入還必須把存檔丟掉,而不是接續它。螢幕上那副牌是被留在後面的引擎發的,新引擎用同一個 seed 可能發得不一樣——接續下去,那串步驟會重播成一個玩家從沒看過的分數。反正還沒動過任何一步,丟掉不花代價。另外它會記得每個版本只試一次,否則一個其實拿不到新版本的用戶端——靜態快取還在餵昨天的 HTML、中間卡了個代理、或某種殼——會無限重新載入下去。


10|Worker:伺服器真正的位置

伺服器是一個 Cloudflare Worker、五條 API 路徑,而它最重要的設計決策,是讓它盡量不要跑(第 2 節那張分流圖)。

信任邊界在重播,不在請求。 CORS 開 * 是刻意的:這些 endpoint 不帶 cookie,每筆分數都會被重播驗證,發出請求的來源本來就不是信任邊界。(用 * 而不是回填來源,也讓被快取的排行榜不必掛 Vary: Origin,一份快取就能同時服務網頁和原生。)流量限制刻意做得很輕——玩家 id 是用戶端自己挑的,所以它能做的只是壓住單一用戶端灌爆牌桌的速度。撐住排行榜的,是每一列都經過伺服器自己重播。

Cache-Control 就是基礎建設。 沒有 queue、沒有快取層、沒有 Redis,只有四個數字:排行榜 30 秒、一筆重播 300 秒、引擎 manifest 300 秒、分享卡片一小時。每個數字都是一個理由。manifest 很短,因為它是「規則變了」和「用戶端還沒發現」之間唯一的緩衝;分享卡片和它的圖用同一個小時,圖上的數字和文字裡的數字才不可能對不起來。

持久化是很無聊的 SQL。 D1、六個 migration、一張表,樸素的 prepare().bind()。刪除綁在 (id, player) 上,而列的 id 只會回傳給它自己的擁有者,就算 id 外洩也刪不掉別人的紀錄。

分享出去的一局是帶著走的,不是存起來的。 分享連結把整局——seed 和步驟紀錄——放在路徑裡。伺服器上沒有任何關於它的資料:連結在被建出來的那一刻就能離線使用,永遠不會 404,也沒有任何一列資料被誰刪掉會讓它失效。分數也不在裡面,打開的人自己重播那串紀錄才知道,標準和排行榜要求一筆送件的一樣。

這點也正是這個 Worker 還要送 HTML 的原因(就是分流圖裡的 /w/*)。牌桌是從 URL 的 fragment 讀出那一局的,而 fragment 不會被送到伺服器——爬蟲只看得到光禿禿的站台,每一則分享都預覽成同一張空桌子的圖。所以 Worker 用一個指名這一局的 head 回答那條路徑,再把真正的瀏覽器送回 fragment 上:

<script>
  location.replace(target);
</script>

每一條被打開的連結、每一隻預覽它的爬蟲各算一次呼叫,而不是每個玩家一次。head 裡也沒有任何東西是從連結採信的:名字用排行榜存名字的同一條規則裁切,分數是重播算出來的,不是從 payload 讀來的。URL 裡的一個數字只是一句宣稱,而被畫進預覽卡片的宣稱,是一句附了圖的宣稱。

那張圖本身在 Worker 裡畫:SVG 經 resvg-wasm 點陣化,兩種字重的字型以位元組的形式 import 進來,因為那裡沒有檔案系統可讀,也沒有它應該為此連出去的網路。有兩個地方我一開始做錯了:initWasm 每個 isolate 只能呼叫一次、第二次會丟錯,所以守衛必須是那個 promise 本身(兩個請求同時打到冷 isolate 時,等的是同一次啟動);還有像素必須在回傳前從 wasm 記憶體複製出來,因為 response body 是在函式回傳之後才被讀的。


11|這套形狀讓什麼變便宜了

以下有些我會做,有些只是這個架構剛好讓它變簡單。今天全部都還沒做:

第四個執行環境幾乎免費。 任何能跑 12 KB、零依賴 JavaScript 的東西,都能玩或驗證一局——一支 Deno 腳本、一個 Discord bot、哪天 TWA 不夠用時的原生 Android 殼。要移植的是那個橋接檔,不是規則。

封存稽核應該進 CI,而不是一個我要記得跑的指令。 那句把 engines/engine 欄位對起來的查詢已經寫好了,只是還沒接到任何會讓建置失敗的地方。十三列那次事故,就發生在「有一個檢查」和「檢查會自己跑」之間的空隙裡。

替 bundle 簽章在哪一天會變重要:當引擎開始決定某件伺服器不會自己重算的事。今天它不會,這也是「跑跑看」這種檢查就夠用的唯一原因。

多留一個封存版本的成本是 12 KB。 現在的深度是被「板子還指名幾個版本」決定的,不是被 Worker 塞不塞得下決定的。


12|五件我下次還會這樣做的事

  1. 兩邊必須一致,就讓建置產生那份一致,不要寫在文件裡叫人記得。 一個共用常數被第二種語言重打一次,就是一次排好時間的故障。
  2. 版本號用「會改變答案的東西」算出來。 雜湊壓縮後的閉包,不要雜湊原始碼:排版免費,語意才收費。也不要手動 bump。
  3. 能相容就不要強制。 擋掉舊的用戶端不會讓它變新,只會把它留在原地;留著它的舊規則,代價是一個裝著 12 KB 檔案的目錄。
  4. 會安靜壞掉的事,讓它在建置時大聲壞掉。 封存漏掉,執行期什麼都不會講;prebuild --check 會直接讓建置失敗。
  5. 伺服器只放在真的需要伺服器的地方。 五條路徑,而遊戲本身一條都不會碰到。

附錄|五條 API 的型別

第 2 節那張表講的是給人看的版本;這裡是同一件事給編譯器看的版本——形狀取自 lib/wire(第 6 節),不是逐字貼原始碼,但每一個欄位都對得上表格裡的那一格。

type LeaderboardRequest = {
  board: "all" | "zen" | `daily:${string}`; // 一般模式/禪模式/某天的每日挑戰
  playerId: string;
  page: number;
};
type LeaderboardResponse = {
  rows: { rank: number; name: string; score: number; playerId: string }[];
  page: number;
  hasMore: boolean;
};

type ReplayRequest = { board: string; rank: number };
type ReplayResponse = { seed: string; moves: string; engine: string };

type ScoreRequest = {
  seed?: string; // 一般模式/禪模式
  date?: string; // 每日挑戰:用日期代替 seed
  moves: string;
  name: string;
  playerId: string;
  engine: string;
  // 沒有 score —— 伺服器用 (seed, moves, engine) 自己重播算出來
};
type ScoreResponse = { score: number; rank: number };

type DeleteScoreRequest = { id: string; playerId: string };
// 回應:204,沒有 body

type EngineManifest = {
  version: string; // 哪一套規則
  build: string; // 哪一份位元組
  minUI: number; // 最舊哪一版牌桌畫得出來
  path: string; // 一條路徑,不是絕對網址
  accepts: string[]; // 封存還重播得了的每一個版本
};

ScoreRequest 沒有 score 欄位,不是省略——那正是第 4 節整節在講的事:用戶端送的是它打過的步驟,分數是伺服器自己重播出來的。DeleteScoreRequest(id, player) 兩個欄位一起限定範圍,這也是第 10 節提到的那條刪除規則。EngineManifest 五個欄位裡 versionbuild 分開存在,是第 9 節花一整段解釋的那個區別:一個說規則,一個說位元組。