#04 回数制限のある交換 | Game Server Services(GS2)

https://www.youtube.com/watch?v=ghpUXKmlRHc

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/3ce6f87c-1163-cf27-4db6-4d5488781c2d.png)

第四回は毎日一回アイテムを受け取るための仕組みのお話をします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/651d625c-5469-a846-b25a-e0b23f845060.png)

GS2 で回数制限を実現するマイクロサービスが GS2-Limit。アイテムの交換処理を実現するマイクロサービスが GS2-Exchange です。
今回はこの2つのサービスについて説明をしつつ、毎日一回アイテムを受け取れる仕組みを作っていきたいと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/13948aaf-1b27-c511-621d-4c03ca8192ed.png)

まず最初に GS2-Limit と GS2-Exchange のデータ構造について説明します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/0f5d333b-9f2f-ca1f-3a09-bd6b70acb28a.png)

最初に GS2-Limit の構造について説明します。
大まかな構造は GS2-Inventory の時と変わりません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/ae789cac-3da9-3a77-a121-47b7a206f647.png)

ネームスペースの配下にはマスターデータとユーザーデータがあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/a27115ce-4f6c-e2fa-e458-f2a24c3e29cb.png)

マスターデータの配下には Limit Model が複数あります。
Limit Model は回数制限の種類を表すデータで、カウンターをリセットするタイミングを設定できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/fa5a86e1-03a1-7eef-c0e9-dffe9c881b42.png)

ユーザーデータの配下にはカウンターがあります。
カウンターは現在何回アクションを実行したかを保持しており、関連づけられた Limit Model に基づいてカウンターの値がリセットされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/2e6bddbd-f5ae-dbaf-d951-ddd5b5cf3f07.png)

今回はもう一つ、GS2-Exchange も利用しますので、GS2-Exchange のデータ構造についても解説します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/55403963-6b15-66b9-9429-34a588b253eb.png)

GS2-Exchange は Rate Model を持ちます。
Rate Model には交換レートが記録されています。

今回は「最大値1の回数制限のカウンターを1増やす」ことを対価として「ポーションを1個入手」できる交換レートを用意します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/858ce711-2707-c471-934f-d99d61058088.png)

GS2-Limit と GS2-Exchange の代表的なAPIを確認してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/29259e3a-92bb-f300-2667-c4a0778ad21f.png)

GS2-Limit の代表的なAPIです。

GS2-Inventoryの説明と同じように、左側に安全なAPIを、右側に不正行為につながるAPIを記載しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/eb24d4ce-ab4b-868f-9818-5e5e4990b125.png)

GS2-Exchange の代表的なAPIです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/d05b2d05-a5dc-4d26-faa1-c843ef2acd73.png)

GS2 のトランザクションシステムであるスタンプシートについて解説します。
今回のデモでは GS2-Exchange の Exchange 関数を呼び出すと、スタンプシートが発行されます。

スタンプシートには、対価と報酬の情報が記録されており、各システムはスタンプシートを受け取ってスタンプシート内に定められた処理を行う仕組みが備わっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/395b11a8-1ffd-a3f8-819a-7c2ec76e4f5f.png)

スタンプシートはこのような構造になっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/f1ee1ecb-29ed-9eea-6c47-72ee9d71ce4f.png)

スタンプシートのルート階層にはペイロードと署名が記録されており、ペイロードを偽装できないようになっています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/9ed5079d-53ff-3f01-201b-ebd849752447.png)

ペイロード内には、対価を表現する消費アクションのリストと、報酬を表現する入手アクションが記録されています。
今回の実装内容であれば、 Consume Action には GS2-Limit のカウンターを1上昇させることが記録されており、
Acquire Action には GS2-Inventory でポーションを1入手することが記録されたスタンプシートが作成されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/80c4d95e-65e9-63af-4cdd-2662f9c7ea15.png)

このスタンプシートを GS2-Limit に送信すると、回数制限のカウンターが操作されたり、ポーションを入手できるわけですが、
Consume Actions を先に実行して、すべてが完了してから Acquire Action を実行しなければならない、という条件があります。

Acquire Action を受け入れる API はスタンプシートに含まれるすべての Consume Actions が実行されていなければエラー応答されます。
これには不正行為を防ぐ目的があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/1efd4b3f-a9a4-2889-cc2e-d9b20a81b0d0.png)

スタンプシートの実行の流れを見てみましょう。

まず、ゲームプレイヤーは GS2-Exchange で Exchange API を呼び出します。
すると、Stamp Sheet が発行されます。この段階ではまだスタンプシートの実行は行われていないため、カウンターの値も、アイテムの所持数量も変化していません。

続けて、受け取った Stamp Sheet を GS2-Distributor に渡します。
すると、GS2-Distributor はスタンプシートの中身を確認し、適切なマイクロサービスに処理を転送し、スタンプシートに記載された処理を実行します。

この時、GS2-Limit や GS2-Inventory のマイクロサービスに障害が発生していた場合、処理は中断されてしまいます。
GS2-Distributor からエラー応答を受け取った場合は、 GS2-Distributor に対してリトライ処理を行うことが必要となります。

スタンプシートに記録された処理は重複実行されないように実装されており、繰り返しスタンプシートを実行したからといって、回数制限のカウンターが上がりすぎたり、アイテムを入手しすぎたりしないように設計されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/3098ac7c-b6d5-27e5-0c6c-712425b02b99.png)

このとき問題となるのは、リトライをするためには色々と考えなければならないことがある、ということです。

たとえば、GS2-Distributor にスタンプシートを送信した直後にアプリケーションがシャットダウンしてしまった場合に問題が発生します。
もし、エラーになっていたらリトライしなければならないものの、アプリケーションはシャットダウンしてしまったため、スタンプシートの情報はどこにも残っていません。
このような事故を防ぐためには、実行前にスタンプシートをセーブデータとして保存し、処理が終わったらセーブデータから削除するような実装を行っておく必要がありました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/8fbbe733-eca5-6dc5-e158-800c6f9645e3.png)

そこで追加された機能が、スタンプシートの自動実行機能です。

GS2-Exchange のネームスペースの設定で、スタンプシートの自動実行機能を有効にすると、GS2-Exchange の戻り値は Stamp Sheet ではなく トランザクションID　に変化します。
GS2-Distributor に対して Transaction ID を送信して状態を取得すると、スタンプシートの実行が完了したかがわかります。

このままでは、いつスタンプシートの実行がおわったのかがわかりづらく、使いづらいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/6633e5ec-d37b-55f1-8c31-bb7bf4488c75.png)

そこで、サーバーからのプッシュ通知を受け取れる仕組みを提供する GS2-Gateway の出番です。
GS2-Distributor は処理が終わったら GS2-Gateway にプッシュ通知を出すよう依頼をします。

GS2-Gateway はプレイヤーに対して、Stamp Sheet の実行が終わったことを知らせます。
プレイヤーはこの通知を受け取ったら GS2-Distributor に結果をとりにいけばいい、ということになります。

さて、Stamp Sheet の実行の流れについて同期処理と非同期処理それぞれの説明をしたわけですが、どちらもメリット・デメリットがあります。
処理の結果を待つ実装をしやすいのが同期処理で、しづらいのが非同期処理といえます。
非同期処理では Stamp Sheet の実行に失敗した場合も自動的にリトライ処理が行われますが、
ゲーム側からするとエラーが出ているから完了通知がこないのか、時間がかかっているのかなどの理由が不明なまま完了通知が来ないことになります。

GS2 としてはどちらが好ましいかは開発者に委ねており、どちらでも実装が可能です。

今回は最初に紹介した同期処理で実装します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/fb04e7f3-3303-071a-4062-ab098a649668.png)

これで GS2 における回数制限と交換に関する学習は終了です。

それでは、ここからは実際にコードを記述して GS2-Limit と GS2-Exchange の機能を利用してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/92e5aedc-9048-1277-63c2-83e6ea719ac8.png)

これまで、マネージメントコンソールでリソースを作って、動作確認をして、最後にテンプレートを用意していました。
今回からは最初からテンプレートを記述しながら実装を進めてみましょう。

まずは、GS2-Distributor のネームスペースを “Distributor” という名前で作成します。
スタンプシートの発行には GS2-Key の暗号鍵も必要となりますので、暗号鍵を作成します。

GS2-Limit のネームスペースを “Limit” という名前で作成します。
続けて、マスターデータを設定します。

今回は “Daily” という名前で毎日、日本時間0時にリセットする Limit Model を作成します。
時間の指定は UTC(協定世界時) で指定する必要があります。
日本時間は標準時より +9時間ですので、0時にリセットしようと思ったら 15時を指定することになります。

続けて、GS2-Exchange のネームスペースを “Exchange” という名前で設定します。
Stamp Sheet を発行するサービスのネームスペースには TransactionSetting という項目があります。
EnableAutoRun に false を設定し、KeyId に先ほど作成した暗号鍵のIDを指定します。

最後に GS2-Exchange のマスターデータを設定します。
交換レートを設定する必要があります。
“DailyRewards” という名前で交換レートを設定します。
consumeActions に対価を設定します。
今回は「最大値1の回数制限のカウンターを1増やす」ことを対価としたいです。
GS2 のドキュメントページで、スタンプシートに設定可能な対価の一覧ページを開きます。
ドキュメントに従って、GS2-Limit の回数制限カウンターを上昇させる対価を設定します。

続けて、報酬の設定です。報酬は acquireActions に設定します。
スタンプシートに設定可能な報酬の一覧ページを開きます。
GS2-Inventory の アイテムを入手する報酬に従って、「ポーションを1個入手」できる報酬を設定します。

```yaml
GS2TemplateFormatVersion: "2019-05-01"
Description: GS2 SDK identifier template Version 2019-07-10

Resources:
  DistributorNamespace:
    Type: GS2::Distributor::Namespace
    Properties:
      Name: Distributor
  
  KeyNamespace:
    Type: GS2::Key::Namespace
    Properties:
      Name: DailyRewardsKey
  
  Key:
    Type: GS2::Key::Key
    Properties:
      NamespaceName: DailyRewardsKey
      Name: StampSheetKey
    DependsOn:
      - KeyNamespace
  
  LimitNamespace:
    Type: GS2::Limit::Namespace
    Properties:
      Name: Limit
  
  LimitMasterData:
    Type: GS2::Limit::CurrentLimitMaster
    Properties:
      NamespaceName: Limit
      Settings:
        version: "2019-04-05"
        limitModels:
          - name: Daily
            resetType: daily
            resetHour: 15
    DependsOn: 
      - LimitNamespace
    
  ExchangeNamespace:
    Type: GS2::Exchange::Namespace
    Properties:
      Name: Exchange
      TransactionSetting:
        EnableAutoRun: false
        KeyId: !GetAttr Key.Item.KeyId
  
  ExchangeMasterData:
    Type: GS2::Exchange::CurrentRateMaster
    Properties:
      NamespaceName: Exchange
      Settings:
        version: "2019-08-19"
        rateModels:
          - name: DailyRewards
            consumeActions: 
              - action: Gs2Limit:CountUpByUserId
                request:
                  namespaceName: Limit
                  limitName: Daily
                  counterName: DailyRewards
                  userId: "#{userId}"
                  countUpValue: 1
                  maxValue: 1
            acquireActions:
              - action: Gs2Inventory:AcquireItemSetByUserId
                request:
                  namespaceName: Inventory
                  inventoryName: Bag
                  itemName: Potion
                  userId: "#{userId}"
                  acquireCount: 1
    DependsOn:
      - ExchangeNamespace
```

これで、GS2-Exchange で “DailyRewards” を交換することで、一日一回ポーションを1個手に入れることができるようになりました。

最後に作成したテンプレートをデプロイしましょう。

![Session04.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/100723/fdd96c52-2c5c-fd4e-3bd1-1f576381dfdc.jpeg)

それでは、実際にコードを記述してみましょう。

最初に GS2 クライアントの初期化処理に GS2-Distributor のネームスペースを設定します。
これで、APIを呼び出した結果、スタンプシートが発行された場合は、指定した GS2-Distributor を使用してスタンプシートを実行します。

GS2-Inventory の内容と、GS2-Limit のカウンターの内容を出力します。
続けて、GS2-Exchange で “DailyRewards” を交換します。
最後に、もう一度 GS2-Inventory の内容と、GS2-Limit のカウンターの内容を出力します。

```cs
using Cysharp.Threading.Tasks;
using Gs2.Core.Model;
using Gs2.Unity.Core;
using Gs2.Unity.Util;
using UnityEngine;

public class NewBehaviourScript : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        async UniTask Impl()
        {
            Debug.Log("Start");
            
            var profile = new Profile(
                "YourClientId",
                "YourClientSecret",
                new Gs2BasicReopener(),
                Region.ApNortheast1
            );
            await profile.InitializeAsync();
            
            Debug.Log("Initialized");

            var gs2 = new Gs2Domain(profile, "Distributor");
            
            var userId = PlayerPrefs.GetString("UserId");
            var password = PlayerPrefs.GetString("Password");

            if (userId == "" || password == "")
            {
                Debug.Log("Create Account");
                
                var namespaceObject = gs2.Account.Namespace(
                    namespaceName: "Account"
                );
                var createResult = await namespaceObject.CreateAsync(
                );
                var account = await createResult.ModelAsync();
                
                Debug.Log("Save Account");
                
                PlayerPrefs.SetString("UserId", account.UserId);
                PlayerPrefs.SetString("Password", account.Password);
                PlayerPrefs.Save();

                userId = account.UserId;
                password = account.Password;
            }
            
            Debug.Log("Authentication");
            
            var accountObject = gs2.Account.Namespace(
                namespaceName: "Account"
            ).Account(
                userId: userId
            );
            var authenticationResult = await accountObject.AuthenticationAsync(
                keyId: "grn:gs2:ap-northeast-1:xlSzJ6ni-tutorial:key:Key:key:AuthenticationKey",
                password: password
            );
            var body = authenticationResult.Body;
            var signature = authenticationResult.Signature;
            
            Debug.Log("Login");
            
            var accessTokenObject = gs2.Auth.AccessToken(
            );
            var result = await accessTokenObject.LoginAsync(
                keyId: "grn:gs2:ap-northeast-1:xlSzJ6ni-tutorial:key:Key:key:AuthenticationKey",
                body: body,
                signature: signature
            );

            var accessToken = await result.ModelAsync();
            
            Debug.Log(accessToken.AccessToken.UserId);

            var it = gs2.Inventory.Namespace(
                "Inventory"
            ).Me(
                accessToken
            ).Inventory(
                "Bag"
            ).ItemSetsAsync();

            await foreach (var item in it)
            {
                Debug.Log(item.ItemName + ": " + item.Count);
            }

            var counter = await gs2.Limit.Namespace(
                "Limit"
            ).Me(
                accessToken
            ).Counter(
                "Daily",
                "DailyRewards"
            ).ModelAsync();
            
            Debug.Log(counter.LimitName + ", " + counter.Name + ": " + counter.Count);

            await gs2.Exchange.Namespace(
                "Exchange"
            ).Me(
                accessToken
            ).Exchange().ExchangeAsync(
                "DailyRewards",
                1
            );
            
            it = gs2.Inventory.Namespace(
                "Inventory"
            ).Me(
                accessToken
            ).Inventory(
                "Bag"
            ).ItemSetsAsync();

            await foreach (var item in it)
            {
                Debug.Log(item.ItemName + ": " + item.Count);
            }
            
            counter = await gs2.Limit.Namespace(
                "Limit"
            ).Me(
                accessToken
            ).Counter(
                "Daily",
                "DailyRewards"
            ).ModelAsync();
            
            Debug.Log(counter.LimitName + ", " + counter.Name + ": " + counter.Count);
        }

        StartCoroutine(Impl().ToCoroutine());
    }

    // Update is called once per frame
    void Update()
    {
        
    }
}

```

これで実装は完了です。実行してみましょう。

カウンターの値が1になり、ポーションを1個手に入れています。

もう一度実行してみましょう。
カウンターの値が上限に達しているので、交換処理が成功せず例外が発生しました。正しく回数制限が機能しています。

次回予告！

GS2 SDK には UI Kit というコンポーネントがあり、UI の作成を楽に進めることができます。

次回もお楽しみに。