Webシングルプレイヤーゲームのすべての操作を真に原子にする
このページの目次
「コマンド実行成功後に後付けでセーブする」のは原子操作ではない。状態、乱数、セーブデータ、UIがそれぞれ独立して成功する場合、それらはそれぞれ独立して失敗する可能性がある。
Webシングルプレイヤーゲームには、一見安全に見える構造がよくある:ゲームロジックがメモリ上で実行され、成功後にJSONをIndexedDBに書き込み、最後にUIを更新する。正常なパスは短く、テストも通りやすいが、真に厄介なのは、ディスク書き込みの失敗、複数タブ間の競合、途中でのページクラッシュ、そしてプレイヤーが同じ操作を再試行する場合である。
「リソースを消費してアイテムを生成する」操作を想像してみよう。
- メモリ上ではリソースが既に消費済み;
- 乱数によりアイテムの属性が既に決定済み;
- IndexedDBへの書き込みに失敗;
- UIは獲得結果を表示し続けている、あるいはメモリが次のコマンドを受け付け続けている。
この時、メモリを保持するか旧セーブに戻すかを選んでも、少なくとも一部で事実の不整合が生じる。さらに危険なのは、このようなエラーは通常、セーブデータを即座に破損させず、次のリフレッシュ、再試行、または決済時に、重複課金、重複ドロップ、乱数列のオフセット、または結果ポップアップの消失といった形で現れることである。
本稿では、「権威あるゲームロジックカーネルがブラウザプロセスで実行され、IndexedDBが唯一の永続化媒体である」という環境に適用可能なコミットプロトコルを記述する。これはデータベーストランザクションの考え方を借用しているが、分散型2フェーズコミットではない:永続化参加者は1つだけであり、メモリ、永続化、可視結果が、同一の完全なpayloadのみを承認することを目的とする。
まず、1つのコマンドが原子保護すべきものを定義する
原子単位は「プレイヤー状態」とだけ書いてはならない。1つの意味的コマンドは、少なくとも以下の要素を共通して持つ必要がある。
- ドメイン状態(リソース、キャラクター、バックパック、現在のアクティビティなど)
- 乱数台帳とそのすべてのカーソル
- 単調増加するリビジョン
- コマンドの因果的結果とレシート
- リクエスト重複排除記録
- プレイヤー確認待ちの保留中結果
- 今回の保存で用いられる観測時間
これらはすべて、一緒にコミットするか、一緒に放棄する必要がある。特に乱数について:失敗したコマンドが乱数ストリームを消費した場合、プレイヤーは「状態は変わっていない」と見えても、その後のドロップは既に異なっている。これも観測可能な半コミットである。
保存構造は、権威あるルート(root)を1つに抑えるようにすべきである。
Profile {
revision,
random_ledger,
permanent_state,
active_run?,
background_jobs,
active_battle?,
pending_receipt?,
processed_requests
}
UIルーティング、現在のタブ、再構築可能なプレビュー、アニメーションの進行状況は、このルートに含まれる必要はない。逆に、リフレッシュ後に失われると、ゲームプレイの事実が変わったり、重複実行が発生したりするものは、ルートpayloadと一緒に保存する必要がある。
ルートが1つである目的は、JSONを大きくすることではなく、正しさの問題を狭めることである:IndexedDBでは1つのレコードを原子置換するだけでよく、複数のオブジェクトストア、ログ、コミットフラグ、リカバリブランチが常に整合していることを証明する必要がなくなる。
コマンドエンベロープには4つの識別子が要る
完全なコマンドエンベロープは以下のように抽象できる。
CommandEnvelope {
request_id,
expected_revision,
command,
preview_fingerprint?
}
4つの部分は、4つの異なる問題を解決する。
| フィールド | 問いかける問題 |
|---|---|
request_id | これは同じユーザー意図の再試行か? |
expected_revision | ユーザー操作時に、表示されているのは現在の状態か? |
command | プレイヤーは具体的に何を実行することを要求しているか? |
preview_fingerprint | 送信するのは、先ほどプレビューした正確な条件と結果と一致するか? |
カーネルは、正規化された command に対して command_digest も計算する必要がある。すると、リクエスト処理ルールは以下のように正確に定義できる。
- 同一
request_id、同一 digest:最初の実行結果を返し、replay フラグを立てる - 同一
request_id、異なる digest:拒否。クライアントエラーでリクエストIDを再利用している - 新規リクエストだが
expected_revisionが期限切れ:拒否し、リフレッシュを要求 - revision は同じだが、プレビューフィンガープリントが一致しない:拒否し、プレビューを再投影
チェックの順序が重要である。リクエストリプレイは、revision 検証よりも前に実行しなければならない。 既にコミット済みのリクエストが再試行される場合、クライアントが持つのは通常古い revision である。revision を先にチェックすると、古い revision として誤って拒否され、元の結果のリプレイにはならない。
第1層の原子性:すべてのロジックはまずプライベートドラフト上で実行する
カーネルは現在のゲームを直接変更してはならない。最も単純で確実な方法は、完全な権威ルート(root)をクローンし、プライベートドラフト上で実行することである。
execute(current, envelope):
digest = digest_of(envelope.command)
if processed_requests contains envelope.request_id:
if saved_digest == digest:
return saved_result with replayed = true
reject REQUEST_ID_CONFLICT
if an unacknowledged receipt blocks this command:
reject RECEIPT_PENDING
if envelope.expected_revision != current.revision:
reject STALE_REVISION
draft = clone(current)
receipts = draft.apply(envelope.command) // 乱数も draft のみを変更
draft.revision += 1
feedback = diff_causally(current, draft)
result = make_result(receipts, feedback, draft.revision)
draft.processed_requests[request_id] = { digest, result }
draft.pending_receipt = maybe_make_pending(result)
return PreparedCandidate { draft, result }
検証やドメイン操作のいずれかが失敗した場合、ドラフトのみを破棄する。現在の状態、revision、乱数カーソル、リクエストテーブル、レシートは一切変更しない。
ここでは、「変更されると思われる」サブモジュールだけをクローンしてはならない。1つのコマンドは複数のドメインを跨ぐことがある:リソース消費、インスタンス生成、所有権変更、アンロック更新、タスク進行、フィードバック生成など。手動で補償操作やフィールドホワイトリストを維持していると、機能が増えるにつれて、乱数台帳、IDアロケータ、または新規状態を見逃しやすくなる。中規模以下のシングルプレイヤープロファイルでは、複雑なロールバックログよりも、完全なドラフトの方がコストが安く、証明も容易である。
第2層の原子性:準備、永続化、公開
ドラフトの実行が成功しても、すぐにメモリ内の正式なゲームを置き換えてはならない。IndexedDBの書き込みが失敗する可能性があるからである。ホスト側は後半部を、明確な3つのステップに分割する必要がある。
Prepare
アダプタ層は現在のゲームをクローンし、候補(candidate)上でコマンドを実行し、完全な候補セーブデータをエクスポートして、以下を計算する。
before_digest = 現在永続化されているpayloadのダイジェスト
after_digest = 候補の完全なpayloadのダイジェスト
アダプタ層は、準備中の候補を1つだけ保持することを許可する。ブラウザの同一ページ内では、意味的コマンドをPromiseキューで直列化し、2つの非同期書き込みが同時に同じベースを保持しないようにする。
Persist
ホストは、同じIndexedDB read-writeトランザクション内で、現在のレコードを読み取り、ダイジェストを比較して置換する。
replace_profile(expected_digest, next):
begin readwrite transaction
current = get("profile")
current_digest = current == null ? null : current.digest
if current_digest != expected_digest:
abort CONFLICT
put("profile", next)
commit
この比較をトランザクションの外で「先に読み取って後で書く」ことはできない。そうすると、2つのタブが同時に古いダイジェストを読み取り、互いに上書きし合う可能性がある。読み取り、比較、put を同じ read-write トランザクションに含めることで、IndexedDBのトランザクション直列化がCASを保護する。
同一ページのコマンドキューはローカル並行性を解決し、CASは他のタブ、古いページ、または外部インポートによる競合を解決する。これらは互いに代替できない。
Publish
データベースのコミット後、ホストはレコードを再読み取りし、カーネルに公開を依頼する。
- request ID は準備中の候補と一致していること;
- 現在のメモリの base ダイジェストが
before_digestと等しいこと; - 読み戻したJSONが候補保存内容と一致していること;
- JSON が現在の保存構造とドメイン不変条件を検証できること;
- 再計算されたダイジェストが
after_digestと一致していること。
これらを通過した後、カーネルは以前のメモリドラフトを直接使用するのではなく、実際に読み戻したpayload から正式なゲームを再構築するのが望ましい。こうすることで、メモリが承認するのは、永続化媒体中の正確なバイト列が表す状態のみとなり、シリアライズの省略、ホストの誤伝達、ダイジェストの不一致が境界をすり抜けることがなくなる。
publish が成功して初めて、UI は権威スナップショットを更新し、成功レシートを表示する。
失敗時は、永続化媒体内の勝者のみを認める
このプロトコルで最も価値があるのは、成功パスがきれいになることではなく、各失敗の窓口に唯一の回復ルールがあることである。
| 失敗箇所 | 永続化状態 | 回復アクション | プレイヤーに見える結果 |
|---|---|---|---|
| コマンド検証失敗 | 旧payload | ドラフトを破棄 | 状態は変更されていない |
| ドラフト実行中失敗 | 旧payload | ドラフトとその中の乱数抽選を破棄 | 状態は変更されていない |
| CAS で base 競合を検出 | 他の書き込みによるpayload | 候補を破棄し、セーブデータの勝者を再読込 | 状態が更新されたことを表示 |
| IndexedDB 書き込み失敗 | 旧payload | prepared を abort し、旧セーブを再読込 | 状態は変更されていない |
| 書き込み成功、publish 前異常 | 新payload | 新セーブを再読込 | コミット済み、旧状態にはロールバックしない |
| publish 検証失敗 | データベースを基準とする | 候補メモリを破棄し、データベース記録を再インポート | 疑わしいメモリを使い続けない |
| 成功結果表示前にリフレッシュ | 新payload | 保存されたレシートから結果を復元 | コマンドの重複実行をしない |
5行目に注意:データベースがすでに成功して置換した場合、その後のJavaScriptの例外によって、「操作は発生しなかった」としてはならない。正しい動作は、永続化記録を読み取り、それを使用することである。コミットポイントは IndexedDB トランザクションが完了した瞬間である;publish は単にメモリがこの事実を認めるようにするだけである。
リクエストリプレイ記録は、コマンド結果と一緒にアーカイブする必要がある
メモリ内に最近の request ID だけを保存していては、リフレッシュやクラッシュには対処できない。完全な profile には、以下を保存する必要がある。
processed_requests[request_id] = {
command_digest,
original_result
}
元の結果には、「成功」という文字だけでなく、revision、ドメインレシート、数値差分、因果的フィードバックが含まれるべきである。同じリクエストのリプレイでは、この結果を返し、重複課金、重複生成、乱数の消費を行わない。
これにより、クライアントは request ID を1つの関数呼び出しではなく、1つのユーザー意図にバインドする必要がある。通信のタイムアウトやホスト側の再試行では元の ID を再利用し、プレイヤーが新しい意味上の操作を始めるときだけ新しい ID を生成する。
重複排除記録は無限に成長してはならない。有界ウィンドウ、確認水位によるクリーンアップ、またはセーブ世代ごとの圧縮を採用できるが、クリーンアップルールは満たす必要がある:再試行される可能性のあるリクエストと、確認待ちのレシートは、決して先に削除してはならない。容量戦略はプロトコルの一部であり、セーブデータが膨張してから臨時対応するべきではない。
レシートも状態であり、一度限りのポップアップパラメータではない
いくつかのコマンドは、プレイヤーが必ず目にする重大な結果を生成する。コマンドがすでに永続化されていても、ページがポップアップ表示前にリフレッシュした場合、コンポーネントのローカル状態のみではフィードバックが永久に失われる。
profile 内に pending_receipt を保存し、request ID を参照し、表示が必要な因果的結果をコピーできる。保存検証では、以下も証明する必要がある。
- pending receipt は、processed request に対応していること;
- 両者の request ID、receipt ID、結果内容が一致していること;
- プレイヤーが明示的に確認するまで、文脈を上書きする可能性のある他の意味的コマンドがブロックされていること;
- 「レシートを確認する」操作自体が、型付きで永続化可能な冪等コマンドであること。
これはUI状態をすべてセーブに詰め込むためではなく、すでにコミット済みだがプレイヤーがまだ消費していない因果情報を保護するためである。通常のトースト、タブ、展開状態は、依然として瞬時のUI状態であってもよい。
なぜダイジェストにJSON文字列を直接ハッシュしてはならないのか
同じ論理オブジェクトでも、フィールドの順序、空白、エスケープ、またはシリアライザの違いにより、異なるJSONになることがある;異なる数値セマンティクスでも、一部のランタイムでは同じ浮動小数点表現に圧縮されることがある。生のJSON文字列を直接CASの識別子として使用すると、表現の詳細を状態の識別子と誤認してしまう。
より安全なプロトコルは以下の通りである。
- 値を明確なデータモデルに制限する;
- 決定論的エンコーディングを使用する(例:正規化CBOR);
- 整数と長さは最短表現を使用する;
- map キーはエンコーディング後の長さとバイトで安定ソートする;
- 権威ある数値は浮動小数点数を拒否し、整数または固定小数点を使用する;
- ダイジェスト入力には、schema、バージョン、domain tag の層を外部に追加する;
- 最後に、SHA-256 などの安定したダイジェストアルゴリズムを使用する。
digest = SHA256(canonical_encode([
"protocol-digest",
schema_version,
domain, // command / save / preview / content
value
]))
domain separation は、同一バイト列が異なる文脈で誤って同一の証明として扱われるのを防ぐ。例えば、コマンドのダイジェストをセーブのダイジェストとして偽造することはできない。正規化エンコーディングには、異なるコンテナでの挿入順序が同じ結果を生み、domain が異なれば異なる結果を生むことを保証するゴールデンテストが必要であり、浮動小数点の権威は明確に拒否される。
revision はプレビューフィンガープリントに代えられない
revision は、「profile全体が別のコマンドによって進行したかどうか」を証明するだけである。プレイヤーが先ほど見ていたプレビューを完全に表現することはできない。
例えば、操作ページには正確なコスト、ソースオブジェクト、ターゲット、結果が表示されている。送信時には、意思決定に影響を与えるこれらの入力を、revision、現在のアクティブ状態、コンテンツ定義と一緒に preview_fingerprint として計算する。カーネルは正式な実行前に再計算し、比較する。
fingerprint = digest(
domain = "resolution-plan",
value = {
revision,
active_state,
relevant_profile_state,
content,
submitted_intent
}
)
コンテンツのホットアップデート、ターゲットオブジェクトの変更、コストソースの変更、またはクライアントが送信した意図がプレビューと一致しない場合、表面的な revision に差異がなくても、古くなったプレビューとなる。回復アクションは、プレイヤーがまだ同意していると推測することではなく、ページを再投影して、プレイヤーにもう一度確認を求めることである。
すべてのボタンにプレビューフィンガープリントが必要というわけではない。「プレイヤーが見える一連の結果に基づいて確認を行う」コマンドに適している;単純なタブの切り替えや純粋なナビゲーションは意味的コミットには該当しない。
UI は即時フィードバックできるが、権威ある結果を先に公開してはならない
手触りのために、ボタン押下時に即座にローディング状態に入ったり、押下エフェクトを再生したり、取り消し可能な意図アニメーションを表示したりできる;しかし、新しい残高、アイテム、タスク結果、成功レシートは、publish 後の権威スナップショットからのみ提供されるべきである。
特に、以下の順序を実行してはならない。
setState(candidate)
showSuccess(candidate.result)
await save(candidate)
保存に失敗した場合、コンポーネントツリー、ルーティング、アニメーション、プロンプトを手動で逆転させる必要があり、いずれかの省略がゴースト状態を生む。より単純な順序は、準備時に現在の操作のみをロックし、永続化成功後に完全な投影を1回更新する;失敗時は永続化payloadを再読込し、それに基づいてUIを生成する。
どうテストすべきか
原子プロトコルは、「実行後にセーブ内容が正しい」というだけでは検証できない。少なくとも以下のような不変条件テストが必要である。
- 無効なコマンド前後の完全なセーブJSONが完全に一致していること;
- 失敗したコマンドが、revision、インスタンスID、または任意の乱数カーソルを進行させないこと;
- 同一 request ID、同一コマンドは元の結果のみを返し、副作用を重複して生成しないこと;
- 同一 request ID、異なるコマンドは拒否されること;
- リクエストリプレイは、古い revision を携带していても、先に replay にヒットすること;
- pending receipt が他の意味的コマンドをブロックし、確認後に解放されること;
- pending receipt と processed request は、保存検証で一致していること;
- IndexedDB 書き込み異常後、メモリが旧payloadを再読込すること;
- CAS 競合後、別のタブの永続化結果を採用し、上書きしないこと;
- データベースコミット後、publish 前に異常を注入しても、リフレッシュで新状態を復元すること;
- publish で異なるバイト列、誤ったダイジェスト、または不正なセーブを受け取ると拒否すること;
- プレビューに依存するいずれかの入力が変更されると、古い fingerprint が無効になること;
- 正規化ダイジェストにはプラットフォーム間のゴールデンがあり、浮動小数点の権威を拒否すること;
- 連続する高速クリックが、単一ページ内で厳密に直列化されること。
特に有効なのは障害注入である。prepare の後、CAS の前、transaction の完了後、publish の前でそれぞれ例外を発生させ、永続化された payload、メモリ上のスナップショット、乱数台帳、利用者に見える応答を確認する。ドメイン関数だけのテストでは、危険が集中するホストとの境界を検証できない。
このソリューションが解決しないもの
- 不正防止システムではない。ローカルプレイヤーがブラウザとファイルを完全に制御している場合、データを改竄できる;ダイジェストは主に整合性と競合検出に使用される。
- セーブバージョンやドメイン検証に代わるものではない。ハッシュが正しいpayloadでも、現在のルールに準拠していない可能性がある。
- サーバー間の分散トランザクションではない。クラウドセーブや決済サービスを追加する場合、新しい同期および競合プロトコルが必要である。
- すべてのUI状態をセーブに含めることを要求しない。ゲームプレイの事実を変更したり、結果の損失を引き起こしたりする状態のみを保護する。
- request 記録の保持期間、デバイス間マージ、またはクロックの信頼性を自動的に決定しない。これらは別途設計する必要がある。
最小実装リスト
- 完全で検証可能な権威profileルートは1つだけ;
- 状態、乱数台帳、revision、レシート、リクエスト記録は共同でコミット;
- 新規コマンドは、まず request replay をチェックし、次に保留中のレシートと revision をチェック;
- 同一 request ID で同一ダイジェストはリプレイ、異なるダイジェストは拒否;
- すべてのドメイン変更は、まず完全なプライベートドラフト上で発生;
- アダプタ層は準備中の候補を1つだけ保持;
- 同一ページのコマンドは直列化、複数ページでの書き込みはトランザクション内CASを使用;
- IndexedDB 成功前に、新しいメモリと新しいUIを公開しない;
- publish は、実際に読み戻したpayloadから権威状態を再構築;
- いずれかの失敗で候補を破棄し、永続化媒体内の勝者を再読込;
- 重要なレシートと重複排除記録は、同じ保存ルートに含める;
- コマンド、セーブ、プレビューには、domain付きの正規化ダイジェストを使用;
- 各コミットウィンドウで故障注入テストを実施。
結び:コミットポイントは1つだけであるべき
Webシングルプレイヤーゲームにはリモートサーバーがないが、メモリ、WebAssembly、IndexedDB、Reactなど、複数の状態キャリアを同時に持っている。これらすべてが「ほぼ権威ある」と見なされると、クラッシュの窓はゲームプレイのバグになる。
このプロトコルが最終的にやっていることは、コミットポイントを1回の成功した永続化原子置換に固定することである。カーネルドラフトは、ドメイン状態と乱数が共に成功または失敗することを担当し、CAS は並行性の勝者を選び出し、publish はメモリがディスクへの書き込み事実を認めることを担当し、リクエスト記録と永続化レシートは、リフレッシュと再試行が2回目の結果を生み出さないことを担当する。境界が明確になれば、エラー処理は「実行後に後付けで保存する」よりもむしろ単純になる。
Keywords: ゲーム開発, WebAssembly, IndexedDB, 原子コマンド, compare-and-swap, CAS, 冪等, request id, revision, 正規化ダイジェスト, セーブトランザクション