AFK集計を呼び出し回数に依存させない

このページの目次

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

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

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

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

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

この等式の対象は、⁠加算を認めた同一の時間区間と、同じ凍結入力を使う積分器です。壁時計の観測、一回のオフライン上限、UI 通知回数は対象外です。min(a+b, cap) は通常 min(a, cap)+min(b, cap) と一致しません。再起動回数で上限が変わらないようにするには、同じオフライン期間で使用済みの枠を永続化します。

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

最も一般的な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 より前に決定される領域座標から来るべきである。

安定したソートだけでは不十分:集計の境界を越えて同じ識別子を使う

A、B の二つの定義がそれぞれ毎時一個を生み、呼び出すたびに A、B の順で走査して全体の連番 ID を割り当てるとします。二時間を一度に集計すると A0→1, A1→2, B0→3, B1→4、一時間ずつ二度集計すると A0→1, B0→2, A1→3, B1→4 です。両方ともソートされ、数量とキー付き乱数の属性も同じですが、A1 の ID が異なります。

ソートの対象は今回の呼び出しだけです。二時間分と一時間分ではイベント集合が違うため、局所的な順序では呼び出しをまたぐ割り当て順を固定できません。並列処理の結果をマージしてから連番を振っても、そのマージ内の実行順の違いしか解消できません。

直接的な解決法は、インスタンスの識別子を (job_id, definition_id, ordinal) と定めることです。ジョブ ID は全体で一意で再利用せず、定義 ID は凍結したジョブ内で安定させ、ordinal は永続化して単調に増やします。三つとも生成前に決まり、tick 数、スレッド順、全体の ID 発行器に依存しません。保存形式にはフィールド順を固定した JSON 配列などを定め、エスケープしない文字列連結は避けます。ハッシュへ圧縮するなら、符号化、版、衝突処理も決める必要があります。

在庫は識別子で索引し、保存や表示では明示した安定キーで並べます。これは正規化した表現であり、必ずしも発生時刻順ではありません。時系列のレポートが必要なら、凍結した速度から論理的な生成時刻を求め、同時刻を (time, job, definition, ordinal) で並べます。今回のループ順を履歴の時刻順と扱ってはいけません。

全体で増加する整数 ID が必須なら、関連する生成と外部イベントを共通の論理時間軸と全順序で処理し、イベントの挿入と追いつき処理の境界も制約します。これは今回のバッチをソートするより強い条件です。そのプロトコルなしに連番 ID の分割不変性を主張できません。

並列処理では意味に基づく識別子を持つ候補を作り、識別子で重複排除してマージできます。同じ識別子で内容が違えばエラーにし、任意に上書きしません。凍結した入力、余剰、ordinal、インスタンス、観測境界は依然として一つのトランザクションで確定させます。

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

「オンラインティック用アルゴリズム」と「オフライン用公式」の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、定義、品質、プロパティ、ソース;
  • インベントリの順序または正規化した表現;
  • ランダム帳簿のすべてのキーとカーソル;
  • バックグラウンドタスクの累積時間;
  • 同じ観測期間の各差分を意味に基づく識別子で統合したレポートを比較します。最後の短い tick のレポートと全期間のレポートを直接比較してはいけません。

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

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

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

複数定義の反例と実行できる確認

次の Python 3 標準ライブラリの例は、二つのジョブ、異なる速度、権威ある状態全体、107 通りの総時間のランダムな分割、各区間後の保存と復元を確認します。数量と余剰は独立した累積式でも照合します。固定速度だけのモデルなので、在庫上限、失敗するトランザクション、外部イベントには別途境界の規則とテストが必要です。SHA-256 の先頭二バイトは再現可能な属性の例であり、装備生成器全体ではありません。

"""Executable teaching model: identity must survive partitioning, not just row sorting."""
from copy import deepcopy
import hashlib
import json
import random

HOUR = 3_600_000


def canonical(value):
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))


def initial():
    return {
        "seed": "example-v1", "elapsed_ms": 0, "inventory": {},
        "rows": [
            {"job": job, "definition": definition, "rate": rate,
             "remainder": 0, "next_ordinal": 0}
            for job, definition, rate in [("J1", "A", 1), ("J1", "B", 1), ("J2", "A", 7)]
        ],
    }


def settle(state, duration):
    if not isinstance(duration, int) or duration < 0:
        raise ValueError("duration must be a nonnegative integer")
    state = deepcopy(state)
    state["elapsed_ms"] += duration
    for row in state["rows"]:
        count, row["remainder"] = divmod(row["remainder"] + duration * row["rate"], HOUR)
        for ordinal in range(row["next_ordinal"], row["next_ordinal"] + count):
            identity = [row["job"], row["definition"], ordinal]
            key = canonical(identity)
            payload = canonical([state["seed"], "affix-v1", identity]).encode("utf-8")
            affix = int.from_bytes(hashlib.sha256(payload).digest()[:2], "big")
            assert key not in state["inventory"]
            state["inventory"][key] = {"identity": identity, "affix": affix}
        row["next_ordinal"] += count
    return state


def old_ids(parts):
    """Even perfectly sorted rows give different global IDs across calls."""
    elapsed, inventory = 0, {}
    for duration in parts:
        for definition in ("A", "B"):
            for ordinal in range(elapsed // HOUR, (elapsed + duration) // HOUR):
                inventory[(definition, ordinal)] = len(inventory) + 1
        elapsed += duration
    return inventory


def check():
    assert old_ids([2 * HOUR]) != old_ids([HOUR, HOUR])
    assert old_ids([2 * HOUR])[("A", 1)] == 2
    assert old_ids([HOUR, HOUR])[("A", 1)] == 3
    rng = random.Random(20260912)
    totals = [0, 1, HOUR - 1, HOUR, HOUR + 1, 2 * HOUR, 17 * HOUR + 123]
    totals += [rng.randrange(24 * HOUR) for _ in range(100)]
    for total in totals:
        whole = settle(initial(), total)
        cuts = sorted([0, total] + [rng.randrange(total + 1) for _ in range(40)])
        split = initial()
        merged_report = {}
        for start, end in zip(cuts, cuts[1:]):
            before = set(split["inventory"])
            split = settle(split, end - start)
            merged_report.update({k: v for k, v in split["inventory"].items() if k not in before})
            split = json.loads(canonical(split))  # save/restore at every boundary
        assert canonical(split) == canonical(whole)
        assert merged_report == whole["inventory"]
        for row in whole["rows"]:
            # Independent closed-form oracle, including fractional progress.
            expected_count, expected_remainder = divmod(total * row["rate"], HOUR)
            assert (row["next_ordinal"], row["remainder"]) == (expected_count, expected_remainder)
    print(f"PASS: old-ID counterexample; {len(totals)} totals, random partitions, save/restore, full state and reports")


if __name__ == "__main__":
    check()

一般的だが不十分な修正

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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