バージョンと互換性
内部ドキュメント
このドキュメントは内部実装の詳細です。ゲーム開発者向けの情報は docs.code.uzu-app.com を参照してください。
このページは「理想形」
uzu_code のバージョン管理はこうあるべき、という到達目標を書いている。 実装が追いついていない箇所は最後の 現状とのギャップ にまとめてある。
1. 大前提 — 固まる側と、進み続ける側
uzu_code の互換性の話は、すべてこの 1 枚から始まる。
シナリオは uzu publish した瞬間、その時点の SDK を dist に焼き込んで R2 に固まる。 以後クリエイターが再 publish しない限り、中身は永久に変わらない。
一方でプラットフォーム側は毎日進む。しかもシナリオは uzu-scenarios の独立リポジトリにあり、外部クリエイターが所有しているので、 こちらから一斉に上げて揃えることは構造的にできない。
導かれる原則
「全部を同じバージョンに揃える」は選択肢に無い。 常に古い側と新しい側が同時に動いている前提で、すべての境界を設計する。
2. 登場人物と契約面
古い側と新しい側が出会う場所を契約面と呼ぶ。 条件は「凍結された成果物が、後から進んだプラットフォームと出会う」こと。これに当てはまるのは 5 つある。
| 契約面 | 古くなる側 | 相手 | 版の名前 | |
|---|---|---|---|---|
| C1 | Bridge | 焼き込み済み SDK と 端末の Flutter アプリ(両方向) | Flutter アプリ | BRIDGE_VERSION |
| C2 | Wire | 焼き込み済み SDK | play-server | WIRE_VERSION |
| C3 | Logic | 凍結された logic.js | エンジン | WIRE_VERSION に含む |
| C4 | Params | 焼き込み済み SDK の URL 解釈 と ホストの URL 組み立て(両方向) | Flutter アプリ | BRIDGE_VERSION に含む |
| C5 | Storage | 古いエンジンが書いた永続 state | エンジン | 持たない(下記) |
C1 と C4 は双方向に古くなりうる。新しいシナリオを古いアプリで開くことも、古いシナリオを新しいアプリで開くこともあるからだ。
C5 だけは相手がクリエイターではない。入れ替わった新しいエンジンが、一つ前のエンジンが残した書き置きを読む。 凍結物を作ったのが外部ではなく過去の自分、というだけで、古い側と新しい側が出会う構図は他と変わらない。
契約面ではないもの
似て見えるが、専用の版を持つ必要がないものを挙げておく。増やさない判断のほうが重要なので、理由まで残す。
| 候補 | 理由 |
|---|---|
| publish API(uzu-cli ↔ backend) | publish するその瞬間だけの通信で、凍結物が後から叩くわけではない。backend はその場でエラーを返せる。記録済みの cliVersion を semver で見れば足りる |
| manifest.json | jsonb で丸ごと保存し、読む側は既知フィールドだけを見る(未知は無視・欠損はゼロ値)。「足すだけ、既存フィールドの意味を変えない」で足りる |
| content-server の HTTP | 古いアプリが叩くのは署名付きサブドメインと /__zip__ だけ。エンドポイントを壊さない運用で足りる |
| backend の GraphQL | uzu_code 固有ではない。既存の後方互換ポリシー(API Schema は後方互換を維持する)でカバーされる |
| SDK の TypeScript API(クライアント側) | バンドルされるので、凍結物の中で完結する。npm の semver と型検査で足りる |
logic.js から SDK を参照する場合も凍結物の中で完結する。 logic のビルドは @uzuhq/code-sdk を engine-core への素通し shim へ差し替えて bundle するので、 実行時シンボルは成果物の中へ取り込まれる。publish した時点の実装が焼き付き、 あとからプラットフォーム側の実装が変わっても凍結物は影響を受けない。
ビルドされた logic.js に bare import を残さない
Worker Loader が facet へ渡すのは index.js / game-facet.js / logic.js の 3 つだけで、 npm パッケージの解決先は無い(cloudflare:* の組み込みだけは別)。 logic.js に未解決の import が 1 つでも残ると、publish は成功して部屋の起動時に落ちる。
そのため logic のビルドでは何も external にしない。解決できない import は esbuild がその場でエラーにするので、壊れた成果物は publish されない。
uzu dev と uzu publish で SDK の解決方法を変えない
両者は同じ esbuild plugin (sdk-server-stub) を通す。かつて dev だけ shim へ差し替え、 publish は external にしていたため、harness では動くのに publish だけが落ちる状態が生まれた。 型検査もユニットテストも CI も通り、uzu publish を実際に叩くまで気付けない。
shim は engine-core を export * で通すので、SDK が公開していない symbol (asGameTime / GameClock 等) も実行時には解決できてしまう。TypeScript のシナリオは .d.ts に無いので型で止まるが、JS のシナリオでは届く。列挙に戻すと engine-core への 追加が shim へ届かず uzu dev が壊れる(実際に起きた)ため、drift しない側を採っている。
3. 互換性を決める 3 つの版
版はこの 3 つだけ。それ以外のバージョン番号は互換性の判断に使わない。
| 定数 | カバーする契約 | 持ち主 | 記録先 | 判定するのは |
|---|---|---|---|---|
BRIDGE_VERSION | bridge のメッセージ + URL パラメータ | シナリオが申告 | publish 時に DB | backend(GraphQL の応答時) |
WIRE_VERSION | wire のメッセージ + logic の呼び出し規約 | シナリオが申告 | publish 時に DB(+ R2) | backend(同上) + play-server(最終防衛線) |
ENGINE_VERSION | エンジンの内部実装 | プラットフォームがビルド時に導出 | 部屋ごとに codeId として | play-server(接続時) |
WIRE_VERSION が logic の呼び出し規約まで含むのも、BRIDGE_VERSION が URL パラメータまで含むのも、理由は同じ。 相手も更新速度も一致していて、「その SDK 世代がその相手と交わす約束」として一体に動く。分ける理由がない。
C5 Storage だけは版を持たない。相手が過去の自分なので申告する主体がおらず、比べる 2 つの値が存在しないためだ。 ここは版ではなく、C5 の節で述べる後方互換の規律そのもので守る。
前の 2 つと ENGINE_VERSION は向きが逆である点に注意する。
BRIDGE_VERSION/WIRE_VERSION— シナリオが自分の要求を申告し、プラットフォームが門前で判定する。下限を上げるとシナリオが切られるENGINE_VERSION— プラットフォームが自分の現行値と部屋の記録を比べ、黙ってエンジンを入れ替える。変わるとシナリオは動かす土台ごと差し替えられる
同じ「版」でも、片方はシナリオを止める方向に、もう片方はシナリオの足元を入れ替える方向に働く。
値は人が決めない
ENGINE_VERSION は build-template-sources.mjs が生成物へ書き出す hash であって、 手で上げる番号ではない。材料は game-worker-template/ を esbuild した結果 (@uzuhq/code-room-core / @uzuhq/code-engine-core はここへバンドルされる)と、 Worker Loader へ渡す読み込み設定を組むコード(play-server/src/facet-load-config.ts)。 とくに room-core はゲームルーム本体(game-facet.ts は Durable Object の皮でしかない) なので、材料から落とすと修正がほぼ何も届かなくなる。
互換性に使わないバージョン
次のバージョン番号は、値としては存在するが互換性の判断軸としては機能していない。判定に使おうとしないこと。
| バージョン | 例 | 実際の役割 |
|---|---|---|
| SDK の semver | 0.7.3 | npm の依存解決、型検査(publish 前に落ちる)、障害調査の手がかり |
| uzu-cli の semver | 0.5.2 | publish が失敗したときのサポート情報 |
configSchemaVersion | "3.0.0" | v1 / v2 / UZU Code (メジャー 3) の世代分岐だけ。uzu_code では刻まず 3.0.0 に固定し続ける(下記) |
| モバイルアプリの semver | 6.32.0 | 強制アップデート(required_app_version)と、uzu_code 作品への入室下限(MinUzuCodeAppVersion)の判定用。bridge 互換の判定には使わない |
semver で互換性を判定しない
「SDK 0.6.2 以降なら bridge の X が使える」というマッピング表を人間が維持する形にしてはいけない。 SDK を変更した人がその表を更新し忘れた瞬間に判定が壊れ、しかも静かに壊れる。 契約版を SDK 自身に持たせれば、判定は整数の比較だけで済み、表そのものが要らなくなる。
SDK の semver が判定に使えないのは、運用上の理由でもある。 javascript_uzu_sdk_release.yml が毎営業日 cron で patch を自動 bump しているので、 版番号は変更内容ではなく時間の関数になっている。0.7.2 と 0.7.3 のあいだに何があったかは番号からは分からない。
configSchemaVersion には乗らない
configSchemaVersion は v1 / v2 で契約版として実際に機能していた。 backend/internal/ent/0_scenario_revision.go の IsPlayable() が 「その config 版を再生できる最小アプリ版」の表を持ち、2.0.0 から 2.0.40 まで刻んで運用されている。 仕組みだけ見れば、このページが求めているものとほぼ同じ形をしている。
それでも uzu_code はこれに乗らない。 uzu_code は v1 / v2 の正統進化ではなく別物であり、両者の版を同じ数直線に載せると、 互いに無関係な変更で相手側の判定表が伸びていく。契約面も判定材料も違うものを 1 本の版で表そうとすると、 どちらの意味も薄まる。
3.0.0 は固定し続ける
「乗らない」は放置ではなく、3.0.0 から動かさないという積極的な約束である。
IsPlayable() の requiredVersions は未知のキーに対して false を返す(fail-closed)。 uzu-cli は configSchemaVersion: '3.0.0' をハードコードして送っており(cli/src/rest-register.ts)、 ここを 3.0.1 に変えて publish すると、backend に行を足してデプロイするまで、その revision は誰も再生できない。 CLI 側の 1 行が backend の表と暗黙に結合している。
刻みたくなったら、それは BRIDGE_VERSION / WIRE_VERSION を上げたいという意味である。
4. 契約面ごとの中身
C1 — Bridge(SDK ↔ Flutter アプリ)
WebView の中のシナリオと、それを表示している Flutter アプリのあいだの postMessage プロトコル。
{ "channel": "sdk" | "game", "type": "playSound", "payload": {...}, "playerId": "..." }壊れ方: 片方が知らない type を送る。効果音が鳴らない、splash が消えない、マイクが切り替わらない、といった「一部の機能だけ黙って死ぬ」形になる。
理想の扱い:
- 判定は backend で行う。GraphQL の応答時に、シナリオの
BRIDGE_VERSIONとリクエスト元アプリの対応範囲を突き合わせ、範囲外なら再生用の URL を返さない - リクエスト元のアプリバージョンは、モバイルアプリが全 GraphQL リクエストに
UZU-App-VersionとUZU-Bridge-Versionを載せている(dart/apps/mobile/lib/graphql/links/base_link.dart)。 web / studio / admin はUZU-Client-Typeしか送らない。 UZU TOKYO は GraphQL を通らないので、席が申告した範囲を backend が gRPC の引数で受け取る(入口は 2 本ある) - 未知の
typeを握りつぶさない。受信側は必ず warn ログを出す(モバイルのログは Cloud Logging に送られるので、後から横断検索できる)
backend が判定するには、アプリが対応している bridge の範囲を知る必要がある。 単一の値では足りない。C1 は双方向に古くなるので、シナリオが要求する版がアプリの上限を超えていないか、 下限を下回っていないかの両方を見る。
UZU-Bridge-Version: 3-7ヘッダは 1 本にする。2 本に分けると「片方だけ届いている」状態が作れてしまい、 受け側でその扱いを決める必要が出る。1 本なら「あるか無いか」の 2 状態で済む。
値は uzu_code パッケージが定数で持ち、GraphQL の link が自動で載せる。 モバイルも emulator も同じパッケージを使うので、宣言は 1 箇所で足りる。
それを送らない旧アプリのためのフォールバックとして、アプリ semver のマッピングを backend に持つ。
このマッピングは持ってよい
semver で互換性を判定しない と矛盾するようだが、性質が違う。
| SDK の semver マッピング(不可) | アプリの semver マッピング(可) | |
|---|---|---|
| 誰が使うか | 外部クリエイターが任意の版を使う | 自社アプリ。全バージョンを把握している |
| 表の大きさ | 発散する | 境界だけ。単調増加 |
| 更新頻度 | SDK を変えるたび | bridge を変えたときだけ(稀) |
C1 だけは防御が 1 層しかない。 bridge は WebView とアプリの直接通信で、プラットフォーム側に関門が無いためだ。 backend が URL を返さないことで開かせない、というのが唯一の止め方になる。 握りつぶし禁止(未知 type の warn ログ)が C1 で特に重要なのは、この非対称性が理由。
C2 — Wire(SDK ↔ play-server)
/ws/games/{revisionId}/{roomId} の WebSocket でやりとりする __action / __state / __tick_delta などのメッセージ形式。
壊れ方: 2 年前に publish されたシナリオが、今日デプロイした play-server に接続してくる。メッセージ形式が変わっていれば、そのシナリオだけ動かない。
メッセージの種類だけでなく、JSON Patch の op の語彙も wire に含まれる。 差分を作るのはサーバー、適用するのは凍結された SDK なので、op を増やすと古い SDK はそれを解釈できない。 しかも sdk/src/json-patch.ts の applyPatch は知らない op を黙って読み飛ばして成功を返す。 20 メッセージに 1 回のフルスナップショットで自己修復はするが、それまでのあいだ、ズレた state が描画され続ける。 op を足すのは WIRE_VERSION を上げる変更である。
理想の扱い: C1 と同じく backend で判定する。WIRE_VERSION はサポート範囲と突き合わせるだけなので、 アプリの情報は要らず、シナリオ側の版だけで判定が完結する。
サポート範囲は backend だけが持つ
範囲を play-server にも置かない。判定するのは backend だけなので、 play-server に同じ数値を置いても使われず、乖離する余地を作るだけになる。
play-server が持つべきなのは数値ではなくアダプタそのものである。「WIRE 3 をサポートする」の実体は、 play-server に v3 規約のアダプタが在ることであって、定数ではない。
守る不変条件は「backend の下限より古い規約のアダプタが play-server に残っている」こと。 これは CI で 2 つの定数を突き合わせるのではなく、運用の順序で担保する。
下限を上げる = そのアダプタを消してよくなる。同じ変更でやる。
1 つの行為が両方を同時に触るので、ずれる隙が無い。
上限にも対になる規則が要る。
契約版を上げる = 受け入れる上限も上げる。同じ変更でやる。
上限を置き去りにすると、新しく publish しようとした作品が登録時点で 400 になる。 理由はレスポンス本文に出るが、上限が上がるまで作者は publish できない。
判定を接続時ではなくプレイ開始前に置くのが重要になる。WIRE_VERSION はシナリオの属性なので参加者全員が同じ結果になり、 接続時に弾く設計だと「5 人集まってから全員繋がらない」が起きる。シナリオを選ぶ時点で弾けば、そもそも集合が始まらない。
play-server 側の判定は最終防衛線
backend で止まれば通常フローでは play-server に到達しないので、優先度は下がる。 emulator や dev harness など backend を経由しない経路と、直接叩かれた場合の保険として位置づける。 最初の実装では省いてよい。
実装する場合、接続 URL のクエリに版を載せる方法は勧めない。ホストの実装を信頼することになり、 付け忘れた接続が判定をすり抜けて静かに通ってしまう。成果物の側(R2)に置いて play-server が自分で読むほうが権威が明確になる。 ENGINE_VERSION の照合で既に部屋ごとの記録を読んでいるので、追加の実行コストもほとんどない。
C3 — Logic(logic.js ↔ GameFacet エンジン)
run() パターン(サーバー権威)専用の契約面。ここだけ構造が違うので、まず「なぜこの仕組みがあるのか」から説明する。
なぜエンジンを後から差し替える必要があるのか
run() パターンでクリエイターが書くのは logic.js(setup / actions / update / deadlines)だけ。 tick ループ、action の dispatch、state の差分同期、締切の発火、再起動後の state 復元といった エンジン本体はプラットフォームが持っている。
料理で言えば、クリエイターがレシピを書き、プラットフォームがコンロを用意している状態。 レシピは publish した時点で固まるが、コンロはこちらの都合でいつでも作り替えられる。
問題になるのは、コンロにバグが見つかったときだ。 たとえば「復帰したとき state が復元されない」——クリエイターに責任のない、完全にプラットフォーム側のバグ。 コードを直してデプロイしても、すでに動いている部屋は古いエンジンを抱えたままで届かない。
届ける方法は 2 つしかない。
- 外部クリエイター全員に再 publish を依頼する → 独立リポジトリで連絡も取れないため非現実的
- プラットフォーム側で、動いているエンジンを勝手に入れ替える
2 を選んだ結果が ENGINE_VERSION であり、古いエンジンで動いている部屋を見つけて入れ替えるための目印、それ以上の意味はない。
一番近い比喩はブラウザのアップデート。Web サイト(logic.js)はそのままで、ブラウザ(エンジン)だけが新しくなる。 サイトを作り直す必要はないし、普通は作り直さなくても動く。
入れ替えの流れ
logic.js は R2 に置いたまま、エンジンは play-server が持ち、接続のたびに動的ロードで合成する。 revision ごとの成果物を事前に作る工程は無い。
合成したコードの同一性は codeId = ${revisionId}:${ENGINE_VERSION} が表す。 supervisor は部屋ごとに直前の codeId を覚えていて、接続のたびに現行値と突き合わせる。
state はエンジンの入れ替えをまたいで生き残る。 中断した部屋を 1 ヶ月後に再開しても盤面が残っている。
いつ入れ替わるか
ロードの内容は codeId 単位でキャッシュされる。 codeId が変わらない限り、デプロイしても 稼働中の facet は古いコードと古い設定を持ち続ける。取りこぼしは「届くのが遅れる」ではなく 「永久に届かない」になる。
だから codeId の材料である ENGINE_VERSION は、人が上げる番号ではなく エンジンの実体の sha256 にしてある。材料は次の 3 つで、いずれも codeId 単位で キャッシュされる側にある。
game-worker-template/配下@uzuhq/code-room-core/@uzuhq/code-engine-core(codegen がテンプレへバンドルする)- Worker Loader へ渡す読み込み設定を組むコード(
play-server/src/facet-load-config.ts)
game-host.ts は材料に入れない。heartbeat や alarm は codeId でキャッシュされず親 Worker の デプロイでそのまま反映されるので、混ぜると純粋な偽陽性の再起動になる。 そのかわり Worker Loader へ渡す項目(globalOutbound など)は必ず facet-load-config.ts で組む。 game-host.ts 側で足すと材料から漏れ、稼働中の部屋へ届かない。
型定義だけの変更は JS の出力が変わらないので hash も動かない。逆にコメントの手直しは esbuild の出力に残るので hash が動き、実挙動が同じでも再起動が走る。 代償は該当する部屋のエンジンの再起動だけで state は保たれるため、安全側に倒している。
新しく作られる部屋は記録された codeId を持たないので、常に現行エンジンで起動する。 入れ替えの対象になるのはすでに動いている部屋だけである。
デプロイは動いている部屋を再起動する
入れ替えに限らず、play-server のデプロイは毎回、稼働中の全部屋を再起動する。 Durable Object はコードの更新で落ちるので、WebSocket はエンジンの変更有無に関係なく切れ、 クライアントは再接続して __state から復帰する。エンジンの入れ替え(facet の abort)は その上に何も足さない(進行中の部屋の再起動)。
開始時刻の決まった公演の最中にリリースを出す運用は避ける。
規約を変えたらアダプタで吸収する
削除・rename の定石は追加先行の 2 リリース分割だが、logic 呼び出し規約には効かない。 分割は「利用側を新しい方へ切り替える」段を挟む手なのに、凍結された logic.js は切り替えられない。 再 publish を頼める相手ばかりとは限らず、外部クリエイターは独立リポジトリで作品を持っている。
そこでエンジン側に旧規約の分岐を残して吸収する。作品を触らずに、こちらだけで完結できるのが利点になる。
分岐に使うのは、成果物に並んだ契約版である。凍結された logic.js は自分では何も申告せず、 SDK は logic のビルドで external 扱いなので、版を logic.js 自身へ埋め込むこともできない。 エンジンから見えるのは R2 の中身だけなので、成果物の隣に置く。
{revisionId}/logic.js ゲーム固有のロジック
{revisionId}/meta.json ← 契約版を書く契約版より前の世代は対象外
吸収するのは契約版を持つ成果物、つまり WIRE_VERSION の導入以降に publish されたものだけ。
それより前の成果物は meta.json を持たず、どちらの規約で書かれているかを申告する手段がない。 残るのは宣言された引数の数などからの推定だが、推定は外れたときに無言で壊れる。 旧規約のハンドラを現行の形で呼ぶと、作者の第 1 引数に引数オブジェクトが丸ごと入り、 state を書き換えたつもりでラッパーを書き換えて終わる。例外も出ない。
確実に判定できない世代のために、無言で壊れうる推定を抱えるのは割に合わない。 これらは過去として割り切り、必要なら再 publish してもらう。
追加だけで済む互換は、版を見ずにエンジンへ置いてよい。旧 serverOnly() の brand を検出して 新方式と同じ扱いにしている箇所と、roster を players へ寄せたときに旧名でも同じ配列を渡している 箇所がそれで、どちらも分岐ではなく別名の受け入れに留まっている。後者には撤去条件 (全 revision の再 publish 後)も書かれている。
しきい値は、その変更が起きた版で凍結する
アダプタの分岐は「どの版から形が変わったか」を境目にする。この境目は過去に起きた変更の記録なので、 一度決めたら二度と動かさない。現行の契約版から導出してはいけない。
const OBJECT_ARGS_SINCE = 1; // ✅ 起きた版で固定する
const OBJECT_ARGS_SINCE = WIRE_VERSION; // ❌ 現行値に束縛されている下を選ぶと、規約と無関係な理由で契約版を上げた瞬間に境目も一緒に動く。 それまでに publish された全成果物が「境目より古い」と判定され、一斉に旧規約で呼ばれて壊れる。 引き金が「メッセージを 1 つ足しただけ」なので、変更した本人には結び付かない。
規約変更を重ねるときは、変更ごとに新しい定数を足す。既存の値は動かさない。
C4 — Params(ホスト → SDK の URL パラメータ)
ホストが WebView に渡す URL のクエリ文字列。SDK は起動時にこれを読んで動作モードを決める。
?server=wss://…&revisionId=…&roomId=…&seatId=…&players=[{"id","name","iconUrl","characterId"}]組み立てるのは Flutter 側の QueryParamsBuilder(常に最新)、解釈するのは焼き込み済み SDK(凍結)。 C1 と同じ 2 者のあいだの契約だが、壊れ方が正反対なので分けて扱う。
| C1 Bridge | C4 Params | |
|---|---|---|
| 知らないものが来たら | 黙って捨てる | その場で throw する |
| 壊れるタイミング | その機能を使った瞬間 | 起動直後。1 枚も描かれない |
| ユーザーに見えるもの | 音が鳴らない等の部分的な不調 | 真っ黒な画面 |
SDK が throw する条件は 3 つある(sdk/src/index.ts)。自席 ID、roster、そして ?server= 指定時の revisionId。
ホストがパラメータを 1 つ落としただけで、そのシナリオは起動しない。 しかも revisionId は経路の端から端まで nullable である。GraphQL の v3RevisionId が nullable、 モバイルはそれをそのまま PlayScreenController に渡し、QueryParamsBuilder は 値がある場合だけクエリに載せる。どこか 1 段で null になれば、警告もなく throw まで到達する。
理想の扱い: 新しいパラメータを必須にしない。SDK 側で既定値へ倒し、足りないことは warn ログに残す。 どうしても必須にする必要があるなら、それは BRIDGE_VERSION を上げる変更であり、旧アプリを門前で止める対象になる。
名前を変えるときは 3 箇所が同時に要る
パラメータの rename は実際に起きる。そして C4 の rename は、他の契約面より手当てする場所が多い。 roster のようにホストが載せ、SDK が読み、WebSocket でさらに play-server へ渡る値では、名前が 3 つの区間を通るためだ。
ホスト ──①──> SDK ──②──> play-server区間ごとに、直せる側と直せない側が違う。
| 区間 | 古くなりうる側 | 直せるか |
|---|---|---|
| ① ホスト → SDK | 端末に残った旧アプリ と publish 済みの旧 SDK | どちらも直せない |
| ② SDK → play-server | publish 済みの旧 SDK | 直せない(受け手の play-server 側で吸収する) |
したがって手順は §7 の追加先行の 2 リリース分割の、この契約面向けの形になる。
- 送る側は新旧を両方載せる(ホストも、WS へ中継する SDK も)
- 読む側は新旧を両方受ける(SDK も play-server も、新しい名前を優先して古い名前へフォールバック)
- 対象 revision がすべて入れ替わったことを確認してから、古い名前を落とす
移行期の互換には撤去条件を書く
旧名の受け入れは「いつ落とすか」を書かずに入れると、誰も消せないまま残り続ける。 コメントに条件(例: 全 revision の再 publish 後)を書き、 新しいシナリオが旧名を選べないよう型には新しい名前だけを宣言する。
「ホスト側で完結する」はホストが 1 種類のうちしか成り立たない
Flutter はかつて viewport-fit と背景色を、契約面を増やさずに bridge_service.dart から JS を注入して読んでいた。「SDK に依存しないので SDK 未使用・ 旧バージョンのゲームでも動く」という利点は本物だったが、JS を注入できるのは WebView ホストだけで、ゲームを iframe に載せる 3 ホストでは同じ手が使えなかった。
契約面を避けられるかどうかは、ホストが増えたときに初めて分かる。 「今のホストなら読める」を根拠に契約面から外すと、ホストが増えた時点で作り直しになる。
C5 — Storage(永続 state ↔ エンジン)
エンジンを入れ替えても、その部屋の state はそのまま残る。 これは副作用ではなく設計目標で、「中断して 1 ヶ月後に再開できる」を成立させるために選ばれている。
結果として、一つ前のエンジンが書いた state を、入れ替わった新しいエンジンが読む。 state が長生きするほど、読み手と書き手の世代差は開いていく。
壊れ方: キー名や値の形を変えると ensureStateLoaded() が state を見つけられず、stateInitialized が false のままになる。 その後 maybeStartGame() が走って、進行中のゲームが最初からやり直しになる。 例外は出ない。「復元しました」のログが出ないだけで、異常としては何も現れない。
理想の扱い: 版を増やさず、規律で守る。manifest.json に対して既に採っているのと同じ形になる。
- 既存キーは消さない・意味を変えない。増やすのは自由
- 欠損はゼロ値で受ける。
savedTick ?? 0のように、無ければ既定値へ倒す stateだけが例外で、この 1 つが「復元するか、作り直すか」を決めている。ここに触る変更は必ず移行コードを伴う
現行実装はこの規律をほぼ満たしている。tickCount / seed / randomState / seats / seq はすべて欠損を許容していて、 唯一 state が全体を握っている。
roster を players に改名したときも、保存キーだけは seats のまま据え置かれた。 稼働中の部屋が既にそのキーで書いているから、というのがコードに残された理由である。 規律は既に働いている。
世代差に上限が無い
終局した部屋の state はいつまでも残る(ライフサイクルポリシーは未定)。 「何日か経てば古い state は消える」という時間切れの安全弁は無い。
1 年前のエンジンが書いた state を今日のエンジンが読むことが起こりうる前提で、 キーの後方互換を守る必要がある。
再起動はプレイの最中にも起きる
prd の play-server は GitHub Release の publish で出る。 リリースを出した瞬間に稼働中の部屋はすべて再起動し、エンジンの実体が変わっていれば 記録との不一致から次の接続で入れ替えも走る。
再接続や復帰はプレイ中に普通に起きるので、 「進行中のゲームの下でエンジンが入れ替わる」は例外ではなく通常運転として設計する。 UZU TOKYO の公演のように開始時刻が決まっている用途では、この入れ替わりが本番の最中に起きうる。
進行中の部屋の再起動(実測)
dev で WebSocket を張ったまま play-server をデプロイし、エンジンを変えない場合と変えた場合を比べた(2026-09-22)。
- 全接続が 1006 で切れ、クライアントは約 1.5 秒で再接続して
__stateから復帰した - 復帰後の state は最後の書き込みまで戻る。切断が最後の tick の 337ms 後に当たった回は、tick 1 回分を失った
- エンジンを変えても変えなくても結果は同じだった。 入れ替えのログが出るのは変えた回だけで、 state を失うかどうかを決めているのは入れ替えではなくデプロイによる再起動である
つまり ENGINE_VERSION が実体の hash になって入れ替えの頻度が上がっても、中断のリスクは増えない。 リスクはもともとデプロイの頻度で決まっている。
失う量は state の書き込み間隔(最大 500ms。play-server)で決まり、 ack を返した直後の action も同じ窓で消えうる。
5. 版はどう使われるか
版は記録するだけでは意味がない。次の 3 箇所で必ず読まれる。
| いつ | 何をする | |
|---|---|---|
| 1 | publish 時 | ビルド成果物に埋め込まれた版を backend の DB に記録し、WIRE_VERSION がサポート範囲外なら登録を拒否する |
| 2 | プレイ前 | backend が記録された版を読み、サポート範囲外なら再生用 URL を返さない |
| 3 | 変更前 | 台帳に問い合わせ、下限を上げると何本切れるかを数える |
3 番目が、この設計の一番の見返りになる。 publish のたびに全 revision の版が backend に溜まっているので、 サポート範囲を狭める前に、影響を受けるシナリオを SQL で正確に数えられる。 「たぶん大丈夫」ではなく「対象 3 本、うち 1 本は直近 30 日プレイ実績あり」で意思決定できる。
版はどうやって記録するか
ビルド成果物に埋め込ませ、そこから読む。 node_modules を探して読んではいけない。
node_modules の中身は「実際にバンドルされた SDK」と一致する保証がない。
| 食い違う状況 | 起きること |
|---|---|
vite の alias で別パスを指す | node_modules は npm 版、バンドルはローカル checkout |
| npm workspaces の hoisting | 実体がリポジトリ root に巻き上がり、探索が外れる |
| 依存が二重に入る | どちらがバンドルされたか判別できない |
| yarn PnP | そもそも node_modules が存在しない |
1 番目は他人事ではない。SDK を直しながらシナリオを動かすための UZU_SDK_PATH (.claude/rules/uzu-code-scenario.md に手順がある)は、まさに vite の alias でローカルの src を指す運用で、 我々自身が食い違う状況を常用している。
正しい形は、SDK が版を export し、ビルド時に成果物へ埋め込むこと。 ビルドツールが解決した結果がそのまま記録されるので、alias も workspaces も PnP も透過する。 CLI がゲームのディレクトリ構造を知る必要もなくなる。
| 案 | 評価 |
|---|---|
dist をパースして探す | minify で壊れる |
SDK が vite プラグインを提供し、dist にメタファイルを書き出す | ビルドツールの解決結果がそのまま残る。推奨 |
| CLI がビルド後に Node で再解決 | node_modules 依存に逆戻り |
| manifest に開発者が書く | 自己申告。実体と乖離する |
同じ成果物でも、クライアント側と logic では版の運び方が違う。
| 成果物 | SDK の扱い | 版の運び方 |
|---|---|---|
クライアントの dist | バンドルされる | ビルド成果物に埋め込む |
logic.js | external。バンドルされない | meta.json を隣に並べる |
logic.js に SDK のコードが入らない以上、そこへ版を埋め込むことはできない。 エンジンが読めるのは R2 なので、成果物の隣に置く。
契約版と semver では、埋め込むまでの経路が違う点にも注意する。 契約版は SDK のソースに直接書く定数なのでそのまま export できるが、 semver はソースには存在しない。リポジトリの package.json はどのパッケージも 0.0.0 で、 実際の版は release ワークフローが publish 直前に pnpm version で書き込んでいる。 semver も埋め込みたいなら、同じタイミングで生成する一手間が要る。
semver が取れなくても publish は落とさない
semver は診断用途なので、取れなくても実害がない。読めないときは警告に留め、記録は空のままにする。
止めると、依存の置き方を変えただけでシナリオが publish できなくなる。 探索ロジックを足して個別の構成に対応しても、node_modules を読む限り同じことが繰り返される。
契約版はこれと扱いが違う。判定に使う値なので、取れないまま publish を通してはいけない。
版が記録されていない revision の扱い
記録は publish の必須条件にする。現在の登録 API はメタを付けない登録も受理するため、 機能が入る前の古い CLI から publish すると、版を持たない revision が黙って生まれる。 uzu_code 世代の登録ではメタを必須にして弾く。
そのうえで、仕組みが入る前の revision は版を持たない。これらは 最古(0)とみなして通す。
判定の倒し方はこの一点に集約する。
| 挙動 | |
|---|---|
| 版が無い | 通す(最古とみなす)。ただし warn ログを残す |
| 版があり、範囲内 | 通す |
| 版があり、範囲外 | 止める |
「材料が無いなら通す・材料があって範囲外なら止める」という単純な規則にする。
必須化と組み合わせることで、版を持たない revision は過去の有限集合に閉じる。 必須化をしないと、この経路がいつまでも増え続け、フォールバックが恒久的な抜け道になる。
範囲外の版は publish 時点で拒否する
WIRE_VERSION がサポート範囲外の成果物は、登録 API が 400 で拒否する。 プレイ前の判定だけに任せると「登録は成功するのにプレイだけできない」状態になり、作者から見て理由が分からない。
下限未満と上限超過では作者が取るべき行動が逆になるので、レスポンスの文面も分ける。 下限未満は CLI が古いだけなので更新を促し、上限超過はプラットフォームの未対応なので運営への連絡を促す。
BRIDGE_VERSION は突き合わせ相手(アプリが名乗る範囲)がリクエストごとに変わり、 サーバー側に固定の範囲が無いので publish 時には見ない。
判定は backend で、プレイが始まる前に行う
「版が記録されていない」経路が要るのは、すでに publish 済みのシナリオは版を持っていないから。 これらを最古とみなす経路が無いと、仕組みを入れた瞬間に既存シナリオが全部切れる。
契約版とは別に、アプリの semver にも下限(backend/internal/ent/0_uzu_code_version.go の MinUzuCodeAppVersion)を置いている。 契約は変えずにホスト側で直した不具合(入室・通話・表示)を抱えた旧アプリを、作品の手前で止めるためのもの。 下限未満のアプリでも、アプリ全体の強制アップデートとは違い、uzu_code の作品以外は遊べる。
下限を上げる前に、その版が全ストアで 100% 配信済みかを確かめる。 Google Play の staged rollout と Microsoft Store の段階公開では、配信対象外のユーザーは手動でも更新できない。 「アップデートが必要です」と表示されても抜け出せなくなる。
なぜクライアントではなく backend か
判定ロジックをクライアントに置くこともできるが、backend に置くほうが優れている。
| クライアント判定 | backend 判定 | |
|---|---|---|
| 古いアプリ | 効かない(判定コードを持たない) | 効く |
| 下限を変えるとき | アプリのリリース+普及待ちで数ヶ月 | backend のデプロイだけ |
| 実装箇所 | ホストの数だけ重複する | 入口ごとに 1 箇所 |
2 段目が特に大きい。「WIRE_VERSION の下限を上げたい」と思ったときに、 モバイルをリリースしてユーザーの更新を待つ必要がなく、backend を出せばその瞬間から効く。
入口は 2 本ある
再生用の情報を取り出す経路は 1 本ではない。両方に判定を置かないと、片方が素通しになる。
| 入口 | 使うホスト | 判定に使える材料 |
|---|---|---|
| GraphQL | モバイルアプリ | UZU-App-Version / UZU-Bridge-Version が届く |
gRPC GetReleasedRevision | UZU TOKYO / admin | 呼び出し元が BridgeRange を引数で名乗る |
そのうえで、契約面によって「相手の情報が要るか」が違う。ここが判定を置ける範囲を決める。
| 契約面 | 判定に必要なもの | 名乗らない入口でも判定できるか |
|---|---|---|
| C2 Wire / C3 Logic | シナリオ側の版だけ | できる。相手は play-server で常に最新なので、突き合わせる相手が固定されている |
| C1 Bridge / C4 Params | 相手のホストの対応範囲 | できない。名乗る仕組みが要る |
名乗るのはアプリではなくパッケージ
bridge の対応範囲を決めているのはアプリの semver ではなく、ホストが積んでいる uzu_code パッケージの版である。 モバイルも UZU TOKYO も emulator も同じパッケージを使うので、 パッケージ側が範囲を持てば、すべてのホストが 1 本の数直線に乗る。 運び方だけが入口ごとに違う(モバイルは UZU-Bridge-Version ヘッダ、UZU TOKYO は席の申告を集約した gRPC の引数)。
アプリ semver のマッピングは、ヘッダを送らない既存アプリのためのフォールバックに縮む。
止めるだけでは足りない用途がある
開始時刻の決まった公演では、プレイ直前に止めても打つ手が無い。 判定そのものは同じものが使えるので、必要なのは「いつ呼ぶか」の設計になる。
gRPC 入口では判定結果をレスポンスに載せる
GraphQL 側は再生用 URL を返す / 返さないで止められるが、gRPC 側は同じ手が使えない。 呼ぶのが端末ではなく運用システムなので、**止めるのではなく「可否を伝える」**のが正しい形になる。
判定結果は専用 RPC ではなく GetReleasedRevision のレスポンスに足す。
message ScenarioRevision {
// 既存フィールド…
PlayabilityCheck playability = N;
}
message PlayabilityCheck {
bool ok = 1;
repeated PlayabilityIssue issues = 2;
}
message PlayabilityIssue {
string code = 1; // 機械可読
string message = 2; // 画面に出す文言
bool blocking = 3; // true=不可 / false=警告
}この形を選ぶ理由は 3 つ。
- 追加のみで済む。フィールドを足すだけなので、依存を上げていない呼び出し元は無視するだけで壊れない
- 要求と対象が一致している。判定したいのは released revision で、この RPC が返すのがまさにそれ
- 呼び出し側が既に定期的に叩いている。運用 worker が tick ごとに呼ぶので、判定結果は放っておいても届く
押し忘れの穴が塞がる
可否の確認は人が操作するものなので、押し忘れれば意味を成さない。 だが同じ RPC を worker が tick ごとに叩いているので、 受け取った playability が ok=false のときに warn ログを出すだけで、人の操作に依存しない網になる。
判定を専用 RPC に切り出すとこの利点が消える。
判定の中身
判定は UZU backend の 1 箇所に置く。呼び出し側は結果をそのまま扱うだけで、 判定ロジックを持たせない(持たせた瞬間に二重実装になり、GraphQL 側と食い違う)。
契約版が無くても判定できるものと、版が入って初めて効くものがある。
| code | 内容 | 判定 |
|---|---|---|
NOT_UZU_CODE | 世代が uzu_code のものではない | 不可 |
LOGIC_NOT_FOUND | run() なのに成果物に logic.js が無い | 不可 |
NO_PUBLISH_META | publish メタが記録されていない | 警告 |
WIRE_OUT_OF_RANGE | WIRE_VERSION がサポート範囲外 | 不可 |
BRIDGE_OUT_OF_RANGE | BRIDGE_VERSION が呼び出し元の対応範囲外 | 不可 |
BRIDGE_UNKNOWN | 呼び出し元が bridge の対応範囲を名乗っていない | 警告 |
BRIDGE_UNKNOWN が要るのは、上の表のとおり bridge の判定には相手の対応範囲が要るためである。 名乗らない呼び出し元に対して、この入口が答えられるのは wire と logic の観点だけになる。 答えられなかったことを黙って ok に含めない。
契約版が入るまでの暫定判定
契約版が無いあいだは、判定に使わないはずの SDK の semver を見て、 エンジンが呼び方を知らない世代を弾く暫定コードを置いてよい。 版は毎営業日 cron で単調に上がるので、日付の代理としては機能する。
ただしこれは原則の例外なので、撤去条件を添えて入れる。 WIRE_VERSION が導入され WIRE_OUT_OF_RANGE が機能した時点で消す。
結果は押した瞬間のスナップショット
公演に revision は紐づいておらず、運用 worker は tick ごとに 「その時点の released revision」を引き直す(現状とのギャップ)。
したがって確認した revision と本番で走る revision が同じである保証は無い。 確認操作そのものは保証ではなく、上記の tick 側のログのほうが実質的な網になる。
「更新してください」を出す口は既にある
再生用 URL を返さないだけの止め方だと、判定コードを持たない旧アプリは既存のエラー経路で落ちる。 止まりはするが、ユーザーには意味不明なエラーが出る(現行の mobile なら Exception('v3ContentsEndpointUrl が null です'))。
だがモバイルには既に、版が範囲外であることを正しく伝える経路が実装済みである。
| 出す側 | 出る先 |
|---|---|
ScenarioRevision.isPlayable が false | mobile が showNeedUpdateDialog() を表示 |
Event.joinableStatus が UPDATE_REQUIRED | 参加ボタンが更新要求に切り替わる |
| 観戦不可理由 | i18n の UpdateRequired 文言 |
いずれも UZU-App-Version を見て backend 側で判定している。 つまり 3 段階目(親切に止まる)は、新しいアプリを待たずに今すぐ出荷済みの全アプリで実現できる。
優先順位は 黙って壊れる < 意味不明だが止まる < 親切に止まる。
契約版の判定は IsPlayable() を拡張して、この既存の口へ流し込む。 新しいフィールドを増やさないので、出荷済みのアプリがそのまま親切なエラーを出せる。
接続後のハンドシェイクは行わない
シナリオと接続してから版を交換する設計も考えられるが、採らない。
- ZIP をダウンロードして展開し、WebView を起動して bridge が確立するまで判定できない。 ユーザーは待たされた末にエラーを見ることになる
- 記録と申告の 2 系統を持つと、両者がズレたときにどちらを正とするか決める必要が出る。単一ソースにする
- 既存のエンジン入れ替え判定 (
ENGINE_VERSION) も「接続時ネゴシエーション」ではなく「記録との比較」で動いている。揃えたほうが仕組みが 1 種類で済む
ただし未知メッセージの warn ログは、版の判定とは別に必ず入れる。 「版は範囲内なのに、なぜか知らないメッセージが来た」という想定外を捕まえる唯一の網になる。
6. サポート範囲
各契約面は「今サポートしている版の範囲」を宣言する。
サポート幅は契約面ごとに決める。目安は次のとおり。
| 契約面 | 幅の目安 | 理由 |
|---|---|---|
| C1 Bridge | 広め(アプリ 3〜4 リリース分) | ユーザーがアプリを更新しないと縮まらない。こちらの都合で縮められない |
| C2 Wire | 広め | 古いシナリオが最も長く生き残る面 |
| C3 Logic | 狭くてよい | エンジンの入れ替えで全 revision が現行に揃うので、実質 1 世代しか存在しない |
| C4 Params | C1 と同じ | 同じ 2 者のあいだの契約なので、BRIDGE_VERSION の範囲にそのまま従う |
| C5 Storage | 範囲を持たない | 版が無いので狭める操作自体が存在しない。既存キーを維持し続けるだけ |
C1 と C4 は、こちらの意思で狭められない点に注意する。どうしても切る必要があるときは、 Firebase Remote Config の required_app_version による強制アップデートが唯一の手段になる。
7. 何を変えたら、何を上げるか
ENGINE_VERSION だけ人の判断が要らない。エンジンの実体の hash なので、触れば勝手に変わり、 変わった分だけ既存の部屋へ届く。
BRIDGE_VERSION / WIRE_VERSION は「安全」か「破壊的」かで、その後にやることが変わる。
| 判定 | やること |
|---|---|
| 安全(追加のみ) | そのままデプロイしてよい。受信側は未知のものを warn ログに落とすだけで動き続ける |
| 破壊的(削除・rename) | ① 新しい方を先に足す → ② 利用側を切り替える → ③ 影響 revision を数えて再 publish を依頼 → ④ 完了を確認してから古い方を消す |
ただし logic 呼び出し規約だけは 2 リリース分割ではなくアダプタで吸収する。 凍結された logic.js は「利用側を切り替える」ことができないので、切り替えを待つ手が使えない。
削除と rename は、追加先行の 2 リリースに必ず分割する。 これは DB のカラムを破壊的に変更する手順 と同じ考え方で、 「消す前に、消えても困らない状態を先に作る」という順序を崩さない。
8. 機械が守るもの
人間の規律で維持できる変更頻度ではない。次は CI とランタイムで機械的に守る。
| 守るもの | どこで | 落ちる条件 |
|---|---|---|
| logic 規約変更時のアダプタ | CI | 呼び出し規約を変えたのに旧規約の分岐が無い |
| 凍結 logic × 現行エンジン | CI | 凍結した logic.js を流した結果(送信メッセージ・state・永続化・エンジンの警告)が凍結時の記録と食い違った |
| 凍結物の作り直し | CI | 既存 fixture の logic.js / meta.json を変えた・消した |
| 契約型の単一ソース性 | CI | SDK / play-server / cli に複製された型定義が乖離している |
| — | @uzuhq/code-room-core を本番 facet と dev-server が共有するようになり、乖離しうる面が無くなった | |
| C5 storage キーの後方互換 | CI | ensureStateLoaded() が読むキーを消した・rename した |
| 契約メッセージの削除・rename | CI | 版を上げずにメッセージを消した |
| 未知メッセージの検知 | ランタイム | dev では throw、本番では warn ログ(握りつぶさない) |
| 判定結果の握りつぶし | ランタイム | 受け取った playability が ok=false なのに、ログにも画面にも出ない |
| サポート範囲外の起動 | ランタイム | ロード前に明示エラーを出す。無言で機能を落とさない |
| 生成物の同期 | CI | template-sources.generated.ts がソースと不一致 |
| logic の SDK 参照 | publish | ビルド結果に SDK の import が残っている(型以外を参照している) |
| publish のメタ欠落 | 登録 API | uzu_code 世代なのに契約版が付いていない登録を弾く |
握りつぶしを許さないのが要点
このページで挙げた失敗はほぼ全部、エラーではなく「何も起きない」形で出る。 catch {} で握りつぶした瞬間に、原因の追跡は不可能になる。 未知のものを受け取ったら、必ずどこかに記録を残す。
現状とのギャップ
上記のうち実装済みなのは次のとおり。
| 項目 | 状態 |
|---|---|
| エンジン入れ替えの仕組み | ✅ 実装済み。codeId 照合で入れ替え、state は保たれる |
| publish 時のメタ記録の仕組み | ✅ 動いている(scenario_revisions.uzu_code_publish_meta) |
| backend がホストの対応範囲を知る経路 | ✅ モバイルは UZU-App-Version / UZU-Bridge-Version を全リクエストに載せる。UZU TOKYO は席 (iPad) の申告を backend が集約し、gRPC の BridgeRange で名乗る。web / studio / admin は UZU-Client-Type のみ |
| 名乗りを受け取る入口 | ✅ UZU-App-Version / UZU-Bridge-Version とも http (query / mutation) と websocket (subscription) の両方で context へ載る。モバイルは isPlayable も joinableStatus も subscription で解決するので、片方だけだと実質すべての判定が効かない |
BRIDGE_VERSION / WIRE_VERSION | ✅ @uzuhq/code-engine-core が定数として持ち、publish 時に DB と meta.json の両方へ記録される |
| 契約版の現在値 | ✅ WIRE_VERSION 3 / BRIDGE_VERSION 2(接続と通話の状態 を入れた版)。backend の SupportedWireRange は 2〜3、アプリ(packages/uzu_code の bridge_version.dart)が名乗る bridge の範囲は 0〜2。wire 2 / bridge 1 の作品もそのまま動く。play-server は __platform を全員へ配るが旧 SDK は捨て、緊急停止は __pause / __resume でも受ける。アプリは旧いメッセージ(playersChanged / changeRoom / setMicEnabled / sdk の requestPause)も送り続け、受け続ける |
| 下限を上げる条件 | ⚠️ wire 2 / bridge 1 の作品をすべて再 publish し終えたら、SupportedWireRange の下限を 3 に、アプリの bridge の下限を 2 に上げ、同じ変更で play-server の __pause / __resume とアプリの旧いメッセージを消す。bridge 2 の作品を UZU TOKYO で動かすには、席 (iPad) のアプリが 0〜2 を名乗る版に上がっている必要がある |
| プレイ前の版チェック | ✅ IsPlayable() が uzu_code 世代だけ契約版で判定する。イベント作成 (showNeedUpdateDialog()) と参加・観戦 (UPDATE_REQUIRED / nonWatchableReason) に加えて、プレイ画面へ入る直前 (PlayScreenMain / SoloEventPlayScreen) でも見る |
| 参加後・入室経路の判定 | ✅ 入室 / 即時プレイ招待の受諾 / プレイ中イベントへの自動復帰 / リプレイ / 一人用の再開は、いずれもゲームをロードする直前の 1 箇所で止まる。経路ごとに判定を置くと、経路が増えたときに素通りする面ができるため置かない |
| publish 経路 | ✅ 登録 API が契約版を必須にし、WIRE_VERSION がサポート範囲外なら 400 で拒否する。版なしの revision はこれ以上増えない(2026-08-28 以降ゼロ。既存の 17 本は再 publish で解消する) |
| 配信経路 | ✅ GraphQL 側は isPlayable、gRPC 側は GetReleasedRevision の playability で判定結果が届く |
| gRPC 入口の可否判定 | ✅ proto のフィールドが release され、uzutokyo の tick が受け取った playability を読んでいる(jobs/runOperationTick.ts) |
| 判定 NG に人が気付く経路 | ⚠️ tick は ok=false を console.error に落とすだけで、Slack 通知も運営画面表示も無い。ログを見に行かないと気付けない |
| 記録した値を判定に使う | ❌ sdkVersion を backend で読んでいる箇所はゼロ。スキーマのコメントには「ホスト側のサポート範囲判定に使う」とあるが、semver なので判定には使えない(互換性に使わないバージョン) |
| 版の記録方法 | ✅ 契約版は engine-core の定数から記録する。semver は引き続き node_modules 由来だが、診断用途なので読めなくても publish は通す |
| logic 呼び出し規約のアダプタ | ⚠️ まだ規約を変えていないので分岐は無い。次に変えたとき meta.json の版で分岐する。契約版より前の世代は対象外 |
| 未知メッセージの検知 | ⚠️ Flutter 側は未知の type と解釈不能なメッセージを warn ログに落とす(Cloud Logging に送られる)。SDK 側は未対応 |
| facet のログ | ❌ Worker Loader で読んだ facet の console 出力は Workers Logs に届かない。GameRoomCore の復元・ゲーム開始・Storage put failed・tick エラーは、本番で起きていても見えない |
| 契約型の単一ソース性 | ✅ @uzuhq/code-engine-core に統合済み。型・json-patch・seeded random・roster・serverOnly を SDK / play-server / cli が同じ実体から取る |
| ゲームルーム実装の単一ソース性 | ✅ @uzuhq/code-room-core に統合済み。本番 facet と uzu dev の dev-server が同じ GameRoomCore を動かし、各自は transport と永続化だけを足す |
| 旧版シナリオに対する回帰テスト | ⚠️ 凍結した logic.js を現行エンジンで決定論的に回し、凍結時の記録と突き合わせるテストがある(cli/src/frozen-logic、CI は javascript_uzu_code_test.yml)。fixture は tick で進む shogi と、本番作品と同じ tickRate: 0 + deadlines で進む quick_draw の 2 本。ただしどちらも凍結時点のエンジンと同世代なので、世代差のある検証はまだ無い。WS 再接続 / hibernation / Worker Loader は GameRoomCore の外なので対象外 |
| C5 永続 state の後方互換 | ⚠️ 規律のみ。state キーを失うと進行中のゲームが無言で作り直される。CI ガードは未実装 |
| dev harness のエンジン | ⚠️ ルーム実装は @uzuhq/code-room-core で本番と同一になった(game-room.ts に残るのは transport と admin だけ)。ただし uzu dev は常に最新エンジン × 最新 logic なので、「古い logic × 新エンジン」は依然として再現できない |
ENGINE_VERSION の導出 | ✅ エンジンの実体の hash をビルド時に生成する。ロード内容は codeId 単位でキャッシュされるので、手で上げる運用だと取りこぼしが永久に届かないまま残った |
| 進行中の再起動 | ⚠️ デプロイのたびに全部屋が切断・再起動する。state は最後の書き込みまで戻る(最大 500ms、実測)。エンジンの入れ替えの有無で差は無い |
| 終局した部屋の state | ⚠️ 掃除の仕組みが無く、いつまでも残る。世代差の上限が無いまま C5 の後方互換に依存する |
| 公演と revision の紐づけ | ❌ 無い。UZU TOKYO は公演に revision を保存せず、worker が tick ごとに「その時点の released revision」を引き直して端末へ配る。事前に判定した revision と本番で走る revision が一致する保証が無い |
| publish による released revision の自動差し替え | ✅ 差し替わるのは studio_status が Approved 系でなく playable_via_link でもないシナリオだけ(usecase/create_scenario_revision.go)。uzu_code の 4 本はいずれも APPROVED なので該当しない |
configSchemaVersion | ✅ 3.0.0 固定。uzu_code では刻まない方針(configSchemaVersion には乗らない) |
本番の実態
uzu_code の prd 作品は 4 本で、プレイ経路は UZU TOKYO の公演と app のテストプレイに分かれている(2026-09-22 時点)。
| 作品 | app の events | UZU TOKYO の公演 | released revision | wire / bridge | SDK |
|---|---|---|---|---|---|
| kikaidochu | 0 件 | 30 公演 | 94715 | 2 / 1 | 0.8.8 |
| superego | 0 件 | 8 公演(直近 2026-08-06) | 90360 | 無し | — |
| NOSTALGIA | 12 件(すべてテストプレイ) | 0 公演 | 95340 | 2 / 1 | 0.8.9 |
| 両極磔殺人事件 | 9 件(すべてテストプレイ) | 0 公演 | 95876 | 2 / 1 | 0.8.9 |
かつて実プレイは UZU TOKYO 側だけで起きていたが、app 側でもテストプレイが走り始めている。 どちらか一方の経路を前提に判定を設計すると、もう一方に一切効かない。
UZU TOKYO の席 (iPad) は 5 台とも bridge 0-1 を申告していて、部屋の共通範囲も 0-1 になる。 kikaidochu の BRIDGE_VERSION は 1 なので範囲に収まっている。
superego は契約版を持たない。publish したのが契約版の導入(2026-08-24)より前なので、 記録が無いのは当然で、実際に wire 1 世代の成果物でもある。 SupportedWireRange の下限は既に 2 なので、この revision は現時点で WIRE_OUT_OF_RANGE(不可)と判定される。 誤判定ではない。公演予定が無いため実害は出ていないが、公演を組んだ時点で表に出る。
こうした revision は再 publish すれば解消する。 新しく作らせないことのほうが重要で、登録時にメタを必須にするのがその手当てになる。