このページの目次

AFK決済を呼び出し回数に依存させない

同じ1時間の離脱でも、1回で決済するか1分ごとに決済するかで、「ほぼ同じ」量ではなく、完全に同一の状態、余剰分、インスタンス列を得るべきである。

AFKシステムは一見すると「時間 × レート」に過ぎない。しかし、セーブ、ドロップ、オンラインティックの処理に入ると、それはすぐに決定論的な問題へと変貌する。ブラウザは1秒ごとに呼び出されるかもしれないし、バックグラウンドのタブは数十秒ごとにしか再起動しないかもしれない。また、プレイヤーがページを閉じた後に数時間分の決済を1回で行うこともある。結果が呼び出し回数に依存する場合、デバイスの性能、ページの表示状態、セーブのタイミングがこっそりと報酬を変化させてしまう。

こうしたバグは当初、1〜2単位の誤差しか生じず、UIからは見分けがつかない。しかし、生成物がランダムな属性を持つ実際のインスタントである場合、数量の差1つが、その後のすべてのインスタンスID、プロパティ、レポート順序、ランダムカーソルを変えてしまう。その後、総報酬がたまたま一致したとしても、2つのセーブファイルは同じ履歴として再生できなくなる。

本稿では、「分割不変性」を持つバックグラウンド進化的アプローチについて解説する。目標は統計的な期待値を一致させることではなく、より強力な不変条件を満たすことである。

settle(state, a + b)
== settle(settle(state, a), b)

等号は、単なるリソースの合計ではなく、完全な権威ある状態を比較するものである。

最も一般的な3つの破壊要因

呼び出しごとに個別に床関数(切り捨て)で処理する

最も直感的な実装は以下の通りである。

earned = floor(elapsed × rate)

レートが「1時間につき1個」で、オンラインシステムが20分ごとに決済する場合、毎回0が得られる。一方、オフラインで1時間分の決済を行うと1個が得られる。呼び出しの粒度が、隠れた報酬パラメータとなってしまう。

浮動小数点の余剰分を保存すれば修正できるように思えるが、浮動小数点はプラットフォームやシリアライズ往復、長時間の累積において境界差を生じる可能性があり、権威ある要約に使用するのには適さない。

全てのドロップがグローバルな乱数ストリームを共有する

バックグラウンドのドロップが単に rng.next() を順に呼び出しているだけの場合、新しい種類の報酬を追加したり、走査順序を調整したり、他の場所で無関係な判定を1回行ったりするだけで、その後のインスタンス全体がずれてしまう。同じセーブファイルと経過時間では、同じアイテムを再生成できなくなる。

決済時に現在の設定を読み取る

プレイヤーがAFK中に装備を変更したり、新しいドロップをアンロックしたり、コンテンツテーブルのレートが更新されたりした場合。もし古いタスクが次のティックで現在の状態を直接読み取ると、すでに経過した時間が新しい設定によって遡及的に書き換えられてしまう。オンラインでの頻繁な決済とオフラインでの1回限りの決済は、異なる履歴入力を読み取ることになる。

解決策は、時間の積分、ランダムなアイデンティティ、タスク入力のすべてを同時に固定するものでなければならない。1つだけを修正しただけでは不十分である。

凍結されたバックグラウンドタスクの定義

バックグラウンドタスク開始時、「AFK中」というブール値だけでなく、結果に影響を与える入力をすべて保存すべきである。

BackgroundJob {
  job_id,
  target_id,
  frozen_build,
  elapsed_ms,
  reward_rows: {
    definition_id -> {
      rate_per_hour_fixed,
      remainder,
      next_ordinal
    }
  }
}

ゲームの玩法に応じて、凍結された入力には通常以下が含まれる。

  • 一意のタスクまたはデプロイメントID;
  • 対象、難易度、および検証済みの完全な戦闘入力;
  • 当時有効だった生産レート;
  • 当時アンロックされており、その対象によって許可されたドロップ定義;
  • 各定義ごとの独立した固定小数点余剰分と次の ordinal;
  • 必要に応じて、コンテンツバージョンまたはコンテンツ要約。

その後の装備変更、アイテムの譲渡、新しいアンロックは、このスナップショットを貫通してはならない。プレイヤーがタスクを停止して再開した際に、最新の構成を読み取るようにする。

これは動的システムを拒絶するためではなく、時間の境界を明確にするためである。もしデザイン上、途中でレートを変更できるのであれば、変更時には古い構成で切り替え点まで決済し、新しい構成を使用した新しい時間区間を作成するべきである。1回のオフライン復帰で、いつ変化が発生したかを勝手に推測させてはならない。

累積関数の差を用いて分割誤差を解消する

以下のように定義する。

t = タスク開始からの累積ミリ秒数
r = 1時間あたりの生成される固定小数点進捗
H = 1時間のミリ秒数

まず、タスクの起点から任意の時刻までの累積理論生産量を定義する。

C(t) = floor(t × r / H)

時刻 a から b への決済において、区間の長さを直接計算するのではなく、2つの累積値の差を取る。

delta(a, b) = C(b) - C(a)

これは自然に望遠鏡和(テレスコーピング和)を満たす。

delta(0, a) + delta(a, b)
= [C(a) - C(0)] + [C(b) - C(a)]
= C(b) - C(0)
= delta(0, b)

切り捨ては依然として存在するが、それは共通の起点における累積関数のみで発生し、各呼び出しの分割ごとに余剰分が繰り返し失われることはない。

例えば、ある生成物は 1000 ポイントの進捗を必要とし、レートも1時間あたり 1000 ポイントであるとする。20分ごとに決済する場合:

C(20分) = 333
C(40分) = 666
C(60分) = 1000

3つの区間の増加分 = 333 + 333 + 334 = 1000

3番目の区間で、前2つの区間の切り捨てによって生じた差分が自動的に補填され、最終的に1時間ごとの決済と完全に一致する。

実装時には、十分な幅の整数計算で乗算を行う。例えば、128ビットに拡張してから時間定数で除算する。外側の時間、レート、累積上限には明確な境界を設ける必要がある。飽和演算はクラッシュ防止のセーフティネットとして機能するが、入力の上限や境界テストに代わるものではない。なぜなら、実際に飽和値に達すると、通常の代数的不変条件が成立しなくなる可能性があるからである。

各生成物定義ごとに独立して余剰分を保存

異なる生成物には異なるレートがあるため、各定義に独自の進捗行が必要である。

earned     = cumulative_delta(previous_elapsed, next_elapsed, rate)
accumulated = remainder + earned
count       = accumulated / threshold
remainder   = accumulated % threshold

その後、0..count に対して個別に実際のインスタンスを作成する。

もし、有効な凍結入力下での mint(生成)が失敗しないように定義されている場合、この前提条件はコンテンツとセーブの検証によって保証されなければならない。もし mint が依然として失敗する可能性がある場合、今回の決済における進捗、ordinal、インスタンスID、ランダムカーソルは、トランザクションとして一緒にロールバックされなければならない。失敗したインスタンスをスキップしただけで、進捗を消費してはならない。

独立した進捗行には、いくつかの重要な性質がある。

  • 別の定義の追加や削除は、本定義の余剰分を変化させません;
  • ある定義が閾値を越えても、他の定義の累積をブロックしません;
  • レポートは、混合された進捗バーを表示するのではなく、各生成物を正確に説明できます;
  • セーブ往復後、総リソースから履歴を逆算する必要がありません。

余剰分はセーブに含めなければならない。すでに生成された整数個の数だけを保存すると、1個に満たない実際の進捗が再起動のたびに失われてしまう。

ランダム数は意味論的座標でアドレス指定する

固定小数点積分は「いくつ生成するか」を解決したが、「どのインスタンスを生成するか」までは解決していない。完全なインスタンス列を分割不変にするためには、ランダムな決定がグローバルストリームで何番目まで進んだかではなく、安定した意味論的座標によって位置指定されなければならない。

実用的なキー構造の一例は以下の通りである。

RandomKey {
  domain,      // 戦闘、ドロップなどの大域
  purpose,     // 装備プロパティ、バックグラウンドドロップなどの具体的な用途
  activity,    // 本次タスクまたはデプロイメントID
  source       // 対象、定義、ordinal の安定した組み合わせ
}

ある定義の n 番目の生成物に対して、以下の量をキーにエンコードできる。

(target, difficulty, definition, job_id, ordinal = n)

そして、グローバルシード、完全なキー、およびそのキーのローカルカーソルを混合してランダム値を生成する。各キーは独立したカーソルを持つため、以下が保証される。

  • 戦闘で20回追加で抽選しても、ドロップストリームは移動しません;
  • 同じドロップドメイン内の別の定義で1回追加抽選しても、本定義は移動しません;
  • 無関係なランダムな決定を追加しても、古いインスタンスは書き換えられません;
  • ランダムな帳簿がセーブおよび復元されると、各ストリームは元の位置で継続します。

ordinal はこの設計の鍵である。それは「このタスクがこの定義のために生成した n 番目のインスタンス」を意味し、単調に保存されなければならない。決済が何回に分かれても、0, 1, 2... のシーケンスは変わらない。同じ ordinal は常に同じランダム結果とソース証明に対応する。

配列のインデックス、現在のタイムスタンプ、または新しく割り当てられたインスタンスIDを直接使用してランダムなソースとしてはいけない。配列の順序はコンテンツ編集によって変化し、壁時計は不安定であり、インスタンスIDは通常 mint の後に割り当てられる。ランダムなアイデンティティは、mint より前に決定される領域座標から来るべきである。

決定論性には安定した走査と安定したID割り当ても必要

キー付きRNGを使用しても、自動的に完全な状態が同一になるわけではない。複数のタスクや定義が無順序のハッシュマップで走査される場合、それらのランダムなプロパティはそれぞれ同じであっても、グローバルなインスタンスIDや復帰レポートの順序は異なる可能性がある。

したがって、決済では以下を規定する必要がある。

  • バックグラウンドの位置は、安定したスロットまたはジョブIDでソート;
  • 各タスクの生成物定義は、安定した定義IDでソート;
  • 同一の定義は、ordinal の昇順で mint
  • インスタンスIDは、この安定した順序の中でのみ割り当て;
  • レポートは実際の mint 順序に従うか、明示的に安定したキーでソート;
  • 正規化された順序付きデータ構造を使用してセーブおよび要約に投入。

決済の並列化を行う場合、各パーティションが完全な安定キーを持つ候補結果を生成し、その後キーでマージしてグローバルIDを割り当てるべきである。複数のスレッドが単一のインクリメントIDを競合させると、数量やランダムなプロパティが一致していても、再現可能な順序を失ってしまう。

オンラインとオフラインは同じ積分器を呼び出すべき

「オンラインティック用アルゴリズム」と「オフライン用公式」の2セットを個別に維持してはならない。2つのパスは時間区間を取得する役割のみを持ち、最終的には同じ settle_elapsed に流入するべきである。

online tick:
    settle_elapsed(job, elapsed_since_last_tick)

offline restore:
    observed = max(0, now - saved_at)
    credited = min(observed, offline_cap)
    settle_elapsed(job, credited)

max(0, ...) または符号なし飽和減算は、システムクロックの巻き戻しを処理できる。1回のオフライン時間の上限は、経済のインフレとオーバーフローリスクを制御するために設けられる。この上限はプロダクトルールであり、不正防止の証明ではない。ローカルクロックとセーブを完全に制御できるプレイヤーは、ローカルゲームを改ざんし続けることができる。

オフライン復帰は、バックグラウンドでの進化が明示的に許可されているタスクのみを進める。インタラクティブな戦闘、未確認の選択、ストーリー対話、およびプレイヤーの入力を必要とする他の状態は、オフライン時間によって自動的に「シミュレーション完了」してはならない。これらの状態に対しては、正しい動作は凍結を維持し、プレイヤーの復帰を待つことである。

セーブ内の saved_at は観察の境界であり、ドロップの種類、インスタンスのプロパティ、またはランダムシードを決定するために使用すべきではない。壁時計の制限を「計算可能な elapsed を決定する」層に留めておけば、クロックの異常がコンテンツのアイデンティティを汚染することはない。

復帰レポートは決済結果の射影である

オフラインレポートが報酬付与を逆に駆動してはならない。正しい順序は以下の通りである。

  1. 権威ある状態が統一された積分器を使って決済を完了する;
  2. 実際のインスタンスは直ちに一意のインベントリに入る;
  3. 今回の実際の delta に基づいてレポートを構築する;
  4. 有効な変更がない場合、空のレポートでプレイヤーを中断しない。

レポートは瞬間的な射影で構わない。なぜなら、報酬、余剰分、ordinal はすでに権威ある状態に入っているからである。しかし、「この時間の区間をすでに決済したかどうか」は、これらの結果とともに永続化されなければならない。そうでないと、レポート表示後、次のセーブ前にページがクラッシュした場合、同じ時間区間が重複計算される可能性がある。

永続化の境界には、原子コマンドとセーブプロトコルの原則を採用できる。候補状態には、新しい観察境界、生成物、余剰分、ordinal、ランダム帳簿が同時に含まれ、永続化が成功してから復帰結果が公開される。分割不変性は数学とランダムなアイデンティティを解決し、原子保存はクラッシュ時の再生を解決する。これらは互いに代替できない。

コンテンツ更新には明確な戦略が必要

長時間のバックグラウンドタスクは、ゲームバージョンの更新をまたぐ可能性がある。セーブが definition_id のみを記録しており、新しいバージョンでレート、ドロップテーブル、またはインスタント生成ルールが変更された場合、古いタスクの復元時に曖昧さが生じる。

一般的な戦略は3つある。

  1. 完全凍結⁠:タスクは、停止するまで結果に影響を与える数値と生成バージョンをすべて保存する;
  2. コンテンツ要約ロック⁠:復元時に要約の一致を要求し、不一致の場合は明示的なマイグレーションまたはタスク停止を実行する;
  3. 分割マイグレーション⁠:古いルールでセーブ境界まで決済し、その境界から新しいルールでタスクを作成する。

最も危険なのは、新しいコンテンツテーブルを黙って読み取り、オフラインの全時間を新しいルールで計算することである。这样すると、更新のタイミングは「プレイヤーがいつゲームを開いたか」によって決定され、オンラインプレイヤーとオフラインプレイヤーが異なる履歴を得ることになる。

テストでは総数だけでなく完全な状態を比較する

最も重要な性質テストは、任意の分割である。

whole = settle(initial, total)

split = clone(initial)
for duration in random_partition(total):
    split = settle(split, duration)

assert whole == split

比較対象には少なくとも以下が含まれるべきである。

  • すべての通常リソースの総量;
  • 各定義の固定小数点余剰分;
  • 各定義の next_ordinal
  • インスタンスID、定義、品質、プロパティ、ソース;
  • インベントリの順序または正規表現;
  • ランダム帳簿のすべてのキーとカーソル;
  • バックグラウンドタスクの累積時間;
  • 今回のレポート内のインスタンス列。

また、以下の境界ケースもカバーする必要がある。

  • ちょうど閾値を下回る、等しい、越える;
  • 1回で複数の閾値を越える;
  • 0時間の区間と非常に短い時間の区間;
  • オンラインの小刻みなティック、オフラインの全体区間、およびそれらの混合;
  • 途中でのセーブ、復元後の継続決済;
  • 複数タスク、複数定義の安定した走査順序;
  • 同じランダムドメインに無関係な抽選を追加しても、既存のインスタンスは不変;
  • 新しくアンロックされた定義が、すでに凍結されたタスクを貫通しない;
  • クロックの巻き戻し、オフライン上限、整数の境界;
  • mint 失敗時、余剰分、ordinal、ランダムカーソルが一緒にロールバック;
  • コンテンツ要約の変化時には、黙って再計算するのではなく、事前に定められたマイグレーションパスを辿る。

プロパティテストは、総時間と分割点をランダムに生成するのに適している。また、具体的なインスタンス列と正規化されたセーブをロックするためのゴールドンテスト(正解データ)をいくつか残しておく。前者は数学的な境界を見つけるのが得意であり、後者はランダムキー、走査順序、またはシリアライズ形式が意図せず変更されたことを発見するのに役立つ。

一般的だが不十分な修正

「ティックを1秒に固定する。」

ブラウザはバックグラウンドタイマーをスロットルし、プロセスが一時停止することもある。呼び出し間隔は信頼できる時計ではなく、オフライン復帰を制約するものでもない。

「浮動小数点の小数点以下をさらに多く保存する。」

これは誤差を縮小するだけであり、プラットフォームやセーブを跨ぐ完全な一致を保証するものではない。権威ある経済では、有界整数の固定小数点を使用すべきである。

「グローバルなRNG状態を保存すれば再現できる。」

それは完全に同一の呼び出し順序でのみ再現可能である。機能の追加や決済の分割によって呼び出し回数が変わると、その後の結果は全体としてずれてしまう。

「最終的な個数が一致すればよい。」

実際のインスタントには、ID、プロパティ、ソース、レポート順序が含まれる。個数が一致しても、状態が一致するわけではない。

「オフライン時は現在の戦力で再シミュレーションする。」

これにより、プレイヤーが復帰した際の装備構成が過去に影響を与え、結果がゲームを開くタイミングに依存してしまう。バックグラウンドタスクは、入力を凍結するか、変化点で明示的に分割する必要がある。

最小限の実装チェックリスト

  • 通算/分割された完全な状態の一致という不変条件を明確に定義;
  • タスク開始時に、生産に影響を与える入力をすべて凍結;
  • 権威あるレートと余剰分には整数固定小数点を使用;
  • 区間の収益には累積関数の差を使用し、各呼び出しで個別に切り捨てない;
  • 各生成物定義ごとに独立して余剰分と next_ordinal を保存;
  • ランダムキーには domain、purpose、activity、source、安定した ordinal を含める;
  • 無関係なランダムストリームは独立したカーソルを持つ;
  • タスク、定義、ordinal、インスタンスIDの割り当て順序を安定させる;
  • オンラインティックとオフライン復帰は同じ積分器を呼び出す;
  • 壁時計は elapsed のみを決定し、巻き戻しと単回上限を処理;
  • バックグラウンド進化が許可されている状態のみを進める;
  • 新しい観察境界、報酬、余剰分、ランダム帳簿を一緒に原子保存;
  • ランダムな分割の性質テストで完全な状態を比較;
  • コンテンツ更新には、凍結、要約拒否、または分割マイグレーションの戦略を用意。

結び:時間は入力であり、呼び出し回数ではない

信頼性の高いAFKシステムは、結果を「決済関数が何回呼び出されたか」ではなく、凍結された入力、安定したシード、累積時間の関数として定義すべきである。

累積差分により、切り捨て誤差は任意の分割下で自動的に相殺され、固定小数点の余剰分は未完成の実際の進捗を保存し、意味論的なランダムキーと ordinal は各インスタンスのアイデンティティを固定し、安定した走査はグローバルIDとレポートの再現を保証する。最後に原子保存によって観察境界を保護すれば、オンライン、オフライン、バックグラウンドのスロットル、リフレッシュは、単に異なる時間入力方式に過ぎず、4つの異なる経済ルールではない。

Keywords: ゲーム開発, オフライン決済, AFKシステム, 固定小数点, パーティション不変性, 決定論的RNG, キー付きランダム, ordinal, セーブ整合性, 属性テスト