プレイヤーデータと購入システムを実装する

*このコンテンツは、ベータ版のAI(人工知能)を使用して翻訳されており、エラーが含まれている可能性があります。このページを英語で表示するには、 こちら をクリックしてください。

バックグランド

Roblox は、データストアとのインターフェイスに使用する API セットを提供します DataStoreService 。これらの API の最も一般的な使用ケースは、プレイヤーデータの保存、ロード、およびレプリケーション です。すなわち、プレイヤーの進捗状況、購入、その他のセッション特性と関連するデータは、個々のプレイセッション間で持続します。

Roblox 上のほとんどのエクスペリエンスは、これらの API を使用して、プレイヤーデータシステムの形式を実装します。これらの実装はアプローチが異なりますが、一般的に同じ問題のセットを解決しようとします。

一般的な問題

以下は、プレイヤーデータシステムが解決しようとする最も一般的な問題のいくつかです:

  • メモリアクセス中: DataStoreService リクエストは非同期に動作し、レート制限の対象となるウェブリクエストを行います。これは、セッションの開始時の初期ロードに適していますが、ゲームプレイの通常のコースで高速読み込み/書き込み操作には適していません。ほとんどの開発者のプレイヤーデータシステムは、このデータを Roblox サーバーのメモリ内に保存し、次のシナリオに対する DataStoreService を制限します:

    • セッションの開始時の初期読み取り
    • セッションの終わりに最終書き込み
    • 期間的にインターバルで書き込みを行い、最終書き込みが失敗するシナリオを和らげる
    • 購入処理中にデータが保存されるように書き込みます
  • 効率的なストレージ: 1人のプレイヤーのセッションデータを単一のテーブルにすべて保存すると、複数の値をアトミックに更新し、少ないリクエストで同じ量のデータを処理できます。また、インターバル値の非同期化リスクを削除し、ロールバックを理由付けるのが簡単になります。

    一部の開発者は、大規模なデータ構造を圧縮するためにカスタムシリアライゼーションを実装し、通常はゲーム内のユーザー生成コンテンツを保存します。

  • レプリケーション: クライアントは、プレイヤーのデータに定期的にアクセスする必要があります (例えば、UI を更新するため)。プレイヤーデータをクライアントにレプリケートする一般的なアプローチでは、データの各コンポーネントにカスタムレプリケーションシステムを作成する必要がなく、この情報を送信できます。開発者はしばしば、クライアントに複製されるものとそうでないものを選択できるオプションを望んでいます。

  • エラー処理: データストアにアクセスできないとき、ほとんどのソリューションは再試行メカニズムと「デフォルト」データへのバックアップを実装します。後で「実際の」データに上書きされないようにするために、バックアップデータが適切にプレイヤーに伝達されるように、特別な注意が必要です。

  • 再試行: データストアがアクセスできないとき、ほとんどのソリューションは再試行メカニズムとデフォルトデータへのバックアップを実装します。フォールバックデータが「実際」のデータを後で上書きしないようにするために特別な注意を払い、プレイヤーに適切に状況を伝えます。

  • セッションロック: 単一プレイヤーのデータが複数のサーバーにロードされ、メモリ内にある場合、1つのサーバーが期限切れの情報を保存する問題が発生する可能性があります。これはデータ損失と一般的なアイテム複製の欠陥につながる可能性があります。

  • アトミック購入処理: アイテムが複数回失われたり授与されたりするのを防ぐため、購入をアトミックに検証、授与、記録する。

例のコード

Roblox には、プレイヤーデータシステムの設計と構築を助ける参照コードがあります。このページの残りは、背景、実装詳細、および一般的な注意事項を調べます。


モデルをStudio にインポートした後、次のフォルダ構造を見る必要があります:

Explorer window showing the purchasing system model.

アーキテクチャ

この高レベルの図は、サンプルの主要システムと、残りのエクスペリエンスでコードとのインターフェイス方法を示しています。

An architecture diagram for the code sample.

再試行

クラス: DataStoreWrapper >

バックグランド

As DataStoreService は、内部でウェブリクエストを行うため、リクエストが成功することは保証されません。これが起こると、DataStore メソッドがエラーをスローし、それらを処理できます。

一般的な「見つけた」は、データストアの失敗をこのように処理しようとすると発生する可能性があります:


local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end

これは一般的な関数の再試行メカニズムで完全に有効ですが、リクエストの順序を保証しないため、DataStoreService リクエストには適していません。リクエストの順序を保持することは、DataStoreService 状態と相互作用するリクエストにとって重要です。次のシナリオを考えてください:

  1. リクエスト A は、キーの値 K を 1 に設定するために行われます。
  2. リクエストが失敗したので、2秒後に再試行が実行される予定です。
  3. 再試行が発生する前に、リクエスト B は K の値を 2 に設定しますが、リクエスト A の再試行はすぐにこの値を上書きし、 K を 1 に設定します。

最新バージョンのキーの値で動作しているにもかかわらず、 要求は無効な一時状態を避けるために処理されなければならず、例えば、コインの購入が処理される前にコインの減算が行われ、結果としてネガティブなコインが生じます。

プレイヤーデータシステムは、DataStoreWrapper という新しいクラスを使用し、キーごとに処理が保証される妥協再試行を提供します。

手法

An process diagram illustrating the retry system

DataStoreWrapper は、DataStore メソッドに対応するメソッドを提供します:DataStore:GetAsync()DataStore:SetAsync()DataStore:UpdateAsync()、およびDataStore:RemoveAsync()

これらのメソッドは、呼び出されると:

  1. リクエストをキューに追加します。各キーには独自のキューがあり、リクエストが順次および連続で処理されます。リクエストを行うスレッドは、リクエストが完了するまで待機します。

    この機能は、コルーチンベースのタスクスケジューラーとレート制限のクラス ThreadQueue に基づいています。約束を返すのではなく、ThreadQueue は、操作が完了するまで現在のスレッドを返し、失敗するとエラーをスローします。これは、単語的な非同期 Luau パターンにより一貫性があります。

  2. リクエストが失敗すると、設定可能な指数バックオフで再試行します。これらの再試行は、ThreadQueue に提出されたコールバックの一部であるため、このキーの次のリクエストが開始する前に完了することが保証されます。

  3. リクエストが完了すると、リクエストメソッドは success, result パターンで返されます

DataStoreWrapper は、指定のキーのキュー長を取得するメソッドも露出し、期限切れのリクエストをクリアします。後者のオプションは、サーバーがシャットダウンして最新のリクエストを処理する時間がないシナリオで特に役立ちます。

注意事項

DataStoreWrapper は、極端なシナリオの外で、すべてのデータストアリクエストが(成功的にまたはそうでなくても)完了することを許可する原則に従います。最新のリクエストがそれを冗長にする場合でも。新しいリクエストが発生すると、期限切れのリクエストはキューから削除されませんが、新しいリクエストが開始される前に完了することが許可されます。この理由は、このモジュールがプレイヤーデータ用の特定のツールではなく、一般的なデータストアユーティリティとして適用できることに起因し、以下のようになります:

  1. リクエストをキューから削除するのが安全かどうかを判断するための直感的なルールのセットを決めるのは難しいです。次のキューを考えてください:

    Value=0, SetAsync(1), GetAsync(), SetAsync(2)

    期待される動作は、GetAsync()1 を返すことですが、最新のものによって不要になったため、キューから SetAsync() リクエストを削除すると、0 が返されます。

    論理的な進行は、新しい書き込みリクエストが追加されると、最新の読み込みリクエストまでさかのぼってのみ古いリクエストを削除することです。UpdateAsync() , より一般的な操作(そしてこのシステムで使用される唯一の操作)は、読み込みと書き込みの両方が可能であるため、余分な複雑さを追加せずにこのデザイン内で調整することは困難でしょう。

    DataStoreWrapper は、UpdateAsync() リクエストが読み込みと/または書き込みが許可されたかどうかを指定する必要がありますが、セッションロックメカニズム (後で詳細に説明) により、これを事前に判断できないため、私たちのプレイヤーデータシステムには適用できません。

  2. キューから一度削除されると、これをどのように処理するかという直感的なルールを決めるのが難しくなります howDataStoreWrapper リクエストが行われると、現在のスレッドが完了するまで提供されます。キューから期限切れのリクエストを削除した場合、false, "Removed from queue" を返すか、アクティブなスレッドを決して返して排除しないかを決めなければなりません。両方のアプローチには、それぞれの欠点があり、消費者に追加の複雑さを課す。

最終的には、単純なアプローチ(すべてのリクエストを処理する)がここで優先され、セッションロックなどの複雑な問題に対処するときに、より明確な環境を作成し、ナビゲートすることができます。これに対する唯一の例外は、DataModel:BindToClose() で、キューをクリアしてすべてのユーザーのデータを時間内に保存する必要が生じ、値個々の関数呼び出しの返却がもはや継続的な懸念ではないことです。これを考慮するために、skipAllQueuesToLastEnqueued メソッドを露出します。詳しくは、プレイヤーデータ を参照してください。

セッションロック

クラス: SessionLockedDataStoreWrapper >

バックグランド

プレイヤーデータは、サーバーのメモリに保存され、必要に応じてのみ読み込まれ、基本データストアに書き込まれます。Web リクエストを必要とせず、メモリ内のプレイヤーデータを即座に読み込み更新でき、DataStoreService 制限を超えることを避けることができます。

このモデルが意図したとおりに機能するには、1つ以上のサーバーが同時に DataStore からプレイヤーのデータをメモリにロードできないことが必須です。

たとえば、サーバー A がプレイヤーのデータをロードすると、サーバー B は最終保存中にサーバー A がそのロックを解除するまで、そのデータをロードできません。ロックメカニズムがないと、サーバー B は、サーバー A がメモリに保存できる最新バージョンを保存する機会がある前に、データストアから過期したプレイヤーデータを読み込むことができます。次に、サーバー A がサーバー B が期限切れのデータをロードした後、新しいデータを保存すると、サーバー B は次の保存中にその新しいデータを上書きします。

Roblox は、1 回に 1 サーバーにのみ接続できるクライアントを許可しているにもかかわらず、1 セッションのデータが次のセッション開始前に常に保存されるとは思えません。プレイヤーがサーバー A から退出すると発生する可能性のある次のシナリオを考えてください:

  1. サーバー A はデータを保存するための DataStore リクエストを送信しますが、リクエストに失敗して複数の再試行が必要になり、完了するのに時間がかかります。再試行期間中、プレイヤーはサーバー B に参加します。
  2. サーバー A は同じキーに多すぎる UpdateAsync() 呼び出しを行い、制限されます。最終保存リクエストはキューに配置されます。リクエストがキューにある間、プレイヤーはサーバー B に参加します。
  3. サーバー A では、PlayerRemoving イベントに接続されたコードの一部が、プレイヤーのデータが保存される前に生成されます。この操作が完了する前に、プレイヤーはサーバー B に参加します。
  4. サーバー A の性能が低下し、最終保存がプレイヤーがサーバー Bに参加するまで遅延するようになりました。

これらのシナリオはレアであるべきですが、特にプレイヤーが 1つのサーバーから切断し、すぐに別のサーバーに接続する状況で発生します(例えば、テレポート中)。悪意のあるユーザーは、この振る舞いを悪用して、持続しないでアクションを完了させようとするかもしれません。これは、プレイヤーが取引できるゲームで特に影響が大きく、アイテム複製の悪用の一般的なソースです。

セッションロックは、プレイヤーの DataStore キーがサーバーに最初に読み込まれると、サーバーが同じ UpdateAsync() 呼び出し内のキーのメタデータにアトミックにロックを書き込むようにして、この脆弱性を解決します。このロック値が存在すると、他のサーバーがキーを読んだり書いたりしようとすると、サーバーは進行しません。

手法

An process diagram illustrating the session locking system

SessionLockedDataStoreWrapper は、DataStoreWrapper クラスの周りのメタラッパーです。 は、セッションロックで補完されるキューイングと再試行機能を提供します。

SessionLockedDataStoreWrapper パスするすべての DataStore リクエスト—GetAsync であるか、SetAsync であるか、UpdateAsync であるかを問わず、UpdateAsync を通過します。これは、UpdateAsync がアトミックに読み込みと書き込みの両方が可能なキーを許可しているためです。変換コールバックで nil を返して読み込まれた値に基づいて書き込みを放棄することも可能です。

各リクエストに渡された変換関数は、次の操作を実行します: UpdateAsync です:

  1. キーがアクセスできアクセス, 書き込み権限 (write access)かどうかを確認し、アクセスできない場合は操作を放棄します。「安全にアクセスできアクセス, 書き込み権限 (write access)」とは:

    • キーのメタデータオブジェクトには、ロック期限切れ時間以前に最後に更新された認識されない LockId 値が含まれていません。これは、別のサーバーによって設定されたロックを尊重し、期限切れした場合そのロックを無視することを説明します。

    • このサーバーが以前にキーのメタデータに自身の LockId 値を配置した場合、この値はまだキーのメタデータにあります。これは、別のサーバーがこのサーバーのロックを引き継いで (期限切れまたは強制的に) 後でリリースした状況を説明します。代わりに表現すると、LockIdnil であっても、別のサーバーは、キーをロックした時点からの時間でロックを交換し削除することができます。

  2. は、 要求された消費者の操作を実行します。たとえば、 は に翻訳されます。

  3. リクエストに渡されたパラメータによって、UpdateAsync キーをロックするか、アンロックする:

    1. キーをロックする場合、UpdateAsync は、キーのメタデータに LockId を GUID に設定します。この GUID は、サーバーのメモリに保存されており、次回にキーにアクセスするときに検証できます。サーバーがすでにこのキーにロックを持っている場合、変更はありません。また、ロックの期限時間内に再度キーにアクセスしないと警告するタスクをスケジュールして、ロック内のロックを維持します。

    2. キーをアンロックする場合、UpdateAsync はキーのメタデータにある LockId を削除します。

カスタムリトライハンドラーが基本の DataStoreWrapper にパスされ、セッションがロックされたためステップ 1 で中断された場合、操作が再試行されます。

カスタムエラーメッセージも消費者に返され、プレイヤーデータシステムがクライアントへのセッションロックの場合に代替エラーを報告できるようになります。

注意事項

セッションロックモードは、終了するときに常にキーのロックを解除するサーバーに依存します。これは常に PlayerRemoving または BindToClose() の最後の書き込みの一部として、キーをアンロックする指示を通じて起こるべきです。

しかし、特定の状況では、アンロックが失敗する可能性があります。たとえば:

  • サーバーがクラッシュしたか、DataStoreService はキーにアクセスするすべての試みに無効でした。
  • ロジックのエラーまたは同様のバグにより、キーのロック解除の指示は作成されませんでした。

キーのロックを維持するには、メモリにロードされている限り、定期的にアクセスする必要があります。これは通常、ほとんどのプレイヤーデータシステムで背景で実行されている自動保存ループの一部として行われますが、手動で行う必要がある場合は、refreshLockAsync メソッドも露出します。

ロックの期限切れ時間が更新されずに過ぎ去った場合、どのサーバーもロックを引き継ぐことができます。異なるサーバーがロックを取得すると、現在のサーバーがキーを読んだり書いたりする試みは、新しいロックを設定しない限り失敗します。

開発者製品処理

シングルトン: ReceiptHandler >

バックグランド

コールバックは、購入を終了する時期を決定するクリティカルな作業を実行します。ProcessReceipt は、非常に特定のシナリオで呼び出されます。保証のセットについては、MarketplaceService.ProcessReceipt を参照してください。

購入の「処理」の定義は、エクスペリエンス間で異なる可能性がありますが、以下の基準を使用します

  1. 購入は以前に処理されていません。

  2. 購入は現在のセッションに反映されます。

  3. 購入は DataStore に保存されました。

    すべての購入、一回性消耗品でさえ、DataStore に反映される必要があり、ユーザーの購入履歴がセッションデータと一緒に含まれます。

これには、PurchaseGranted を返す前に次の操作を実行する必要があります:

  1. PurchaseId が既に処理されたとして記録されていないことを確認する
  2. プレイヤーのメモリ内プレイヤーデータで購入を授与する。
  3. プレイヤーのメモリ内プレイヤーデータで処理された PurchaseId を記録する
  4. プレイヤーのメモリ内プレイヤーデータを DataStore に書き込みます。

セッションロックは、以下のシナリオについて心配する必要がなくなったため、このフローが簡素化されます:

  • 現在のサーバーの内メモリプレイヤーデータが期限切れの可能性があり、DataStore から最新の値を取得する必要があり、PurchaseId の履歴を確認する前に
  • 別のサーバーで実行されている同じ購入のコールバックは、PurchaseId の履歴を読み込み、書き込み、更新されたプレイヤーデータを保存する必要があり、レース条件を防ぐためにアトミックに反映された購入データを保存する

セッションロックは、プレイヤーの DataStore に書き込む試みが成功した場合、このサーバーでロードされて保存されたデータの間に、他のサーバーがプレイヤーの DataStore を読んだり書いたりすることを保証します。要するに、このサーバーのメモリ内プレーヤーデータは、最新のバージョンです。いくつかの注意事項はありますが、この動作には影響しません。

手法

ReceiptProcessor のコメントは、アプローチを説明します:

  1. プレイヤーのデータが現在、このサーバーにロードされており、エラーなしでロードされたことを確認します。

    このシステムはセッションロックを使用するため、このチェックではメモリ内のデータが最新バージョンであることも確認されます。

    プレイヤーのデータがまだロードされていない場合 (プレイヤーがゲームに参加すると期待される)、プレイヤーのデータがロードされるのを待ちます。システムは、データをロードする前にゲームを終了するプレイヤーを検出し、無期限に引き続き呼び出されず、このサーバーでこの購入のために再び呼び出されないようにする必要があるため、プレイヤーが再参加するときにこのコールバックが再び呼び出されないようにします。

  2. PurchaseId がプレイヤーデータにすでに記録されていないことを確認する

    セッションロックにより、システムのメモリにある PurchaseIds の配列が最新バージョンです。If the PurchaseId が処理されて記録され、DataStore にロードされたか保存された値に反映された場合、PurchaseGranted を返します。処理済みとして記録されているが、 反映されていない場合、 に戻ります。

  3. このサーバーでプレイヤーデータをローカルで更新して、購入を「授与」します。

    ReceiptProcessor は一般的なコールバックアプローチを採用し、それぞれの DeveloperProductId に異なるコールバックを割り当てます。

  4. このサーバーでプレイヤーデータをローカルで更新して、PurchaseIdを保存します。

  5. メモリ内のデータを保存するリクエストを DataStore に送信し、リクエストが成功した場合は PurchaseGranted を返します。そうでない場合は、NotProcessedYet を返します。

    この保存リクエストが成功しない場合、後でプレイヤーのインメモリセッションデータを保存するリクエストが成功する可能性があります。次の ProcessReceipt 呼び出しでは、ステップ 2 がこの状況を処理し、PurchaseGranted を返します。

プレイヤーデータ

シングルトン: PlayerData.Server , PlayerData.Client

バックグランド

ゲームコードからプレイヤーセッションデータを同期読み書きするインターフェイスを提供するモジュールは、Roblox の経験でよく見られます。このセクションでは PlayerData.ServerPlayerData.Client をカバーします。

手法

PlayerData.Server および PlayerData.Client はフォロー中の処理を行います:

  1. プレイヤーのデータをメモリにロードし、読み読み込むみに失敗した場合の処理を含む
  2. サーバーコードがプレイヤーデータをクエリーして変更するインターフェイスを提供する
  3. プレイヤーのデータの変更をクライアントにレプリケートして、クライアントコードがアクセスできるようにする
  4. エラーダイアログを表示できるように、クライアントにロードと/または保存のエラーをレプリケートする
  5. プレイヤーのデータを定期的に保存し、プレイヤーが退出するとき、およびサーバーがシャットダウンするとき

プレイヤーデータをロードする

An process diagram illustrating the loading system
  1. SessionLockedDataStoreWrapper はデータストアに getAsync リクエストを送信します。

    このリクエストが失敗すると、デフォルトのデータが使用され、プロフィールが「エラー」とマークされて、後でデータストアに書き込まれないようにします。

    代替オプションは、プレイヤーを追放することですが、プレイヤーがデフォルトのデータでプレイし、何が起こったかに関するメッセージをクリアすることを推奨しますが、経験から削除するのではありません。

  2. 最初のペイロードが PlayerDataClient に送信され、ロードされたデータとエラーステータス (ある場合) が含まれています。

  3. プレイヤーのために waitForDataLoadAsync を使用して生成されたすべてのスレッドが再開されます。

サーバーコードのインターフェイスを提供する

  • PlayerDataServer は、同じ環境で実行されているすべてのサーバーコードによって必要とアクセスできるシングルトンです。
  • プレイヤーデータは、キーと値の辞書に整理されます。サーバー上で setValue , getValue , updateValue および removeValue メソッドを使用して、これらの値を操作できます。これらのメソッドはすべて、交換なしで同期して動作します。
  • hasLoaded および waitForDataLoadAsync メソッドは、アクセスする前にデータがロードされたかどうかを確認するために利用可能です。我々は、他のシステムが開始される前にロード画面でこれを一度行うことを推奨し、クライアント上のデータとのすべての相互作用前にロードエラーをチェックする必要がないようにします。
  • A hasErrored メソッドは、プレイヤーの初期ロードに失敗した場合、デフォルトデータを使用するように質問できます。プレイヤーが何らかの購入を行う前に、このメソッドをチェックしてください。購入は成功したロードなしでデータに保存できないため、購入はできません。
  • A playerDataUpdated シグナルは、playerkey、および value プレイヤーのデータが変更されるたびに発動します。個々のシステムはこれにサブスクライブできます。

クライアントに変更をレプリケートする

  • プレイヤーデータの変更は、PlayerDataServer で再現され、キーが setValueAsPrivate を使用してプライベートとしてマークされていない限り、PlayerDataClient に再現されます
    • setValueAsPrivate は、クライアントに送信してはいけないキーを示すために使用されます
  • PlayerDataClient には、キーの値を取得するメソッド (get) と、更新されると発動するシグナル (updated) が含まれています。A hasLoaded メソッドと A loaded シグナルも含まれているため、クライアントはシステムを開始する前にデータがロードされて複製されるのを待つことができます
  • PlayerDataClient は、同じ環境で実行されているすべてのクライアントコードが必要としてアクセスできる単一のオブジェクト

クライアントにエラーをレプリケート

  • プレイヤーデータの保存または読み込み中に発生したエラー状態は、PlayerDataClient にレプリケートされます。
  • この情報には、getLoadError および getSaveError メソッド、および loaded および saved シグナルとともにアクセスします。
  • エラーには 2種類あります: DataStoreError (DataStoreService リクエストが失敗しました) と SessionLocked (セッションロック を参照) 。
  • これらのイベントを使用して、クライアントの購入プロンプトを無効にし、警告ダイアログを実装します。この画像は例のダイアログを示しています:
A screenshot of an example warning that could be shown when player data fails to load

プレイヤーデータを保存

A process diagram illustrating the saving system
  1. プレイヤーがゲームを終了すると、システムは次のステップを実行します:

    1. プレイヤーのデータをデータスト保管に書き込むことが安全かどうかをチェックします。安全でないシナリオには、プレイヤーのデータがロードできないか、まだロード中であることが含まれます。
    2. SessionLockedDataStoreWrapper を介してリクエストを作成して、現在のメモリ内データ値をデータストアに書き込み、完了するとセッションロックを削除します。
    3. プレイヤーのデータ (メタデータなどの他の変数も含む) をサーバーメモリからクリアします。
  2. 定期ループでは、サーバーは各プレイヤーのデータをデータストアに書き込みます (保存することが安全である場合)。この歓迎の重複は、サーバークラッシュの場合の損失を軽減し、セッションロックを維持するためにも必要です。

  3. サーバーをシャットダウンするリクエストが受信されると、次のことが BindToClose コールバックで発生します:

    1. リクエストがサーバーに保存され、通常プレイヤーがサーバーを離れるときに通過するプロセスに従って、各プレイヤーのデータを保存するように求められます。これらのリクエストは並行して行われ、BindToClose コールバックは完了するのに 30 秒しかありません。
    2. 保存をスピードアップするには、各キーのキューにある他のすべてのリクエストが、基本の DataStoreWrapper からクリアされます (再試行 を参照)。
    3. コールバックは、すべてのリクエストが完了するまで戻りません。