simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

CubeWalk:スロット番号をリクエストするとアイテムが増えたり減ったり

前置き

ゲームに登場する世界データは全プレイヤー間で一つ
配置場所を表すインデックスでアクセスし、データは 32 bit のキューブ情報の集合体です。
クライアントから直接この世界データにアクセスするにはインデックス指定は必須だと思いますが、配置するときにいずれかのキューブであることを表す情報を渡すのはいかがなものでしょうか?

そう、無限増殖とか、希少なキューブタイプを不正にリクエストすれば簡単に増やすことができてしまいます。
これは許せないですね。

f:id:simplestar_tech:20191229215957p:plain
ホットバーのアイテムを配置している様子(今回はこの仕組みを作ります)

リクエストにキューブのタイプを含めない

最近作られてきているインベントリ操作まわり
simplestar-tech.hatenablog.com


アイテムの CustomData に slotXX とカウントという情報が入るようになったので
クライアントからは、どのスロットから sideA, sideB を取り出すのか、そのスロット番号を与えれば良いことに気づけた

ということで、サーバーでスロット番号からアイテムを特定するロジックを組んでみることにしました。

スロットにあるアイテムを特定するには

とにかく配置したいキューブが収められているスロットを選択できないと話にならないので、先に UI としてホットバーを作りました。
simplestar-tech.hatenablog.com

現在のリクエスト内容は?

次の実装の通り

            // バイナリキャッシュサーバーへキューブアクションをリクエスト
            var microWorldCubeDataByteOffset = this.GetMicroWorldCubeDataByteOffset(microWorldKey, chunkKeyXYZ, cubeIndex);
            PlayFabCloudScript.RequestCubeAction(microWorldName, 1, microWorldCubeDataByteOffset, (byte)category, (byte)rotationType, (byte)sideTypeA, (byte)sideTypeB, requestResult =>
            {
                Debug.Log($"requestResult.status = {requestResult.status} message = {requestResult.message}");
            });

rotationType までは送る必要があります(まぁ、回転情報を知っているのはクライアントだけなので仕方なし)
sideTypeA,B を送るところをアクティブな Slot 番号にするのはどうか

現在のアクティブスロット番号

取れない。
ということで取れるように例のアクティブスロット更新イベントを購読させます。

CubeLocator にて次の実装を書いて、常に現在のアクティブスロットインデックスを同期して持たせます。

            // アクティブスロット変更イベントを購読
            this.itemInventory.onChangeActiveSlot += this.OnChangeActiveSlot;
        }

        /// <summary>
        /// インベントリのアクティブスロット変更イベント
        /// </summary>
        /// <param name="activeViewItem">新しいアイテム情報</param>
        private void OnChangeActiveSlot(ViewItem activeViewItem)
        {
             this.activeSlotIndex = activeViewItem.slotIndex;
        }

あとはこれをリクエストにつなげるだけ

    /// <summary>
    /// キューブアクションをリクエスト
    /// </summary>
    public static void RequestCubeAction(string microWorldIndex, int cubeAction, int byteOffset, byte category, byte rotation, byte sideA, byte sideB, int activeSlotIndex, UnityAction<RequestCubeDataResult> callback)
    {
        ExecuteCloudScript<RequestCubeDataResult>("requestCubeData", new
        {
            index = microWorldIndex,
            action = cubeAction,
            offset = byteOffset,
            category,
            rotation,
            sideA,
            sideB,
            slot = activeSlotIndex
        },
            successResult =>
        {
            callback?.Invoke(successResult);
        });
    }

空のキューブを配置するときは slot に -1 を詰めてもらうことにしました。

CloudScript 側で slot から Cube が取れるか?

最後にしてしまいましたが、最も重要な処理を書くことにします。
サーバー処理が重たくなりますが、これも無限増殖というチートを防ぐため、受け入れましょう。

取れることが確認できたコードがこちらです。

  // get slot item
  let item = null;
  let remainingUses = 0;
  let slotRemainingUses = 0;
  const playerInventory = server.GetUserInventory({
    PlayFabId: currentPlayerId
  });
  const length = playerInventory.Inventory.length;
  for (let i = 0; i < length; i++) {
    item = playerInventory.Inventory[i];
    if (null != item.CustomData) {
      const pattern = /slot([0-9]{2})$/;
      for (let key in item.CustomData) {
        const match = key.match(pattern);
        if (null != match && match[1] == args.slot) {
          slotRemainingUses = item.CustomData[key];
          break;
        }
      }
      if (0 < slotRemainingUses) {
        remainingUses = item.RemainingUses;
        break;
      }
    }
  }

入力を厳しくチェック

クライアントからのリクエストがサーバーのインベントリの情報とすべてマッチしていることを確認します。
本当はクライアントのロジックで不正なリクエストは絶対発生しないように作ります。
それでも改竄してアイテム増殖を行おうという人がこのロジックで弾かれて、バイナリキャッシュサーバーまで処理が届けられなくなります。

  // validate slot
  if (0 != args.action && -1 != args.slot) {
    // get slot item
    let item = null;
    let slotRemainingUses = 0;
    const playerInventory = server.GetUserInventory({
      PlayFabId: currentPlayerId
    });
    const length = playerInventory.Inventory.length;
    for (let i = 0; i < length; i++) {
      item = playerInventory.Inventory[i];
      if (null != item.CustomData) {
        const pattern = /slot([0-9]{2})$/;
        for (let key in item.CustomData) {
          const match = key.match(pattern);
          if (null != match && match[1] == args.slot) {
            slotRemainingUses = item.CustomData[key];
            break;
          }
        }
        if (0 < slotRemainingUses) {
          break;
        }
      }
    }

    // check slot remaining
    if (0 <= args.slot && 0 == slotRemainingUses) {
      return JSON.stringify({
        status: 402,
        message: "empty slot"
      });
    }

    // check match input side types
    let uses = 0;
    if (0 != args.sideA) {
      if (args.sideA != item.ItemId) {
        return JSON.stringify({
          status: 403,
          message:
            "required itemId: " + args.sideA + " but ItemId: " + item.ItemId
        });
      }
      uses++;
    }
    if (0 != args.sideB) {
      if (args.sideB != item.ItemId) {
        return JSON.stringify({
          status: 404,
          message:
            "required itemId: " + args.sideB + " but ItemId: " + item.ItemId
        });
      }
      uses++;
    }

    // check remaining
    if (uses > slotRemainingUses) {
      return JSON.stringify({
        status: 405,
        message:
          "required remainging uses: " +
          uses +
          " but slotRemainingUses: " +
          slotRemainingUses
      });
    }
  }

一応、クライアント改竄による不正にアイテムを増やそうとする対応をわざと入れてみましたが、弾かれました。
f:id:simplestar_tech:20191229104922p:plain
うまくチェック機構が機能しているみたい

サーバー側でスロットのアイテムが減る

まずは配置に成功したら、uses の数だけ CustomData からカウントを減らします。
チェック機構で必ず残数があることを確かめているので、ここは単純に減らすだけ

加えてアイテム本体の数を減らす必要があります。

PlayFab の API
api.playfab.com

調べると次の通り

  // if cube updated, grant cube items
  if (1 == args.action && 201 == response.status) {
    // grant replaced item
    let grants = [];
    if (0 != response.sideA) {
      grants.push(response.sideA);
    }
    if (0 != response.sideB) {
      grants.push(response.sideB);
    }
    if (0 < grants.length) {
      server.GrantItemsToUser({
        PlayFabId: currentPlayerId,
        CatalogVersion: "cube",
        ItemIds: grants
      });
    }
    // reduce item of use
    if (null != item && 0 < uses) {
      const reduceCount = -uses;
      server.ModifyItemUses({
        PlayFabId: currentPlayerId,
        ItemInstanceId: item.ItemInstanceId,
        UsesToAdd: reduceCount
      });
    }
  }

サーバー側でスロット内のアイテム数が減る

先ほどの例はアイテム自体のカウントの消費ですが、これから行うのは CustomData 内のカウントの消費です。
スロットのアイテムを消費するとなると、こうですかね(期待通り、スロット番号が減っていきました)

// reduce item of use
    if (null != item && 0 < uses) {
      const reduceCount = -uses;
      server.ModifyItemUses({
        PlayFabId: currentPlayerId,
        ItemInstanceId: item.ItemInstanceId,
        UsesToAdd: reduceCount
      });

      // modify slot count
      if (null != matchecKey) {
        const nextSlotRemainUses = slotRemainingUses - uses;
        if (0 < nextSlotRemainUses) {
          const data = {
            [matchecKey]: nextSlotRemainUses
          };
          server.UpdateUserInventoryItemCustomData({
            PlayFabId: currentPlayerId,
            ItemInstanceId: item.ItemInstanceId,
            Data: data
          });
        } else {
          server.UpdateUserInventoryItemCustomData({
            PlayFabId: currentPlayerId,
            ItemInstanceId: item.ItemInstanceId,
            KeysToRemove: [matchecKey]
          });
        }
      }

最後 2個一緒に減らしたときにインベントリからアイテム自体が消えたので、成功ですね。
PlayFab の管理メニューから確認できました。

現在のクライアントの残り作業

いま rock アイテム固定になっているので、スロットのアイテムで上書きします。

           // 対象キューブ面にインベントリのアクティブスロットの値を設定
            var sideTypeA = SideType.Air;
            var sideTypeB = SideType.Air;
            var activeSlotType = 0;
            if (null != itemInventory.ActiveSlotItem)
            {
                var itemInstance = itemInventory.ActiveSlotItem.item;
                if (null != itemInstance)
                {
                    if (int.TryParse(itemInstance.ItemId, out int itemId))
                    {
                        activeSlotType = itemId;
                    }
                }
            }
            switch (this.locateMode)
            {
                case LocateMode.Cube:
                    sideTypeA = (SideType)activeSlotType;
                    sideTypeB = (SideType)activeSlotType;
                    break;
                case LocateMode.SideA:
                    sideTypeA = (SideType)activeSlotType;
                    break;
                case LocateMode.SideB:
                    sideTypeB = (SideType)activeSlotType;
                    break;
                default:
                    break;
            }

これでいけるはず

クライアントのロジックはそのほかにも削除に成功したなら、表示しているスロット内容も変更しないといけない

こんなかんじかな

        /// <summary>
        /// 世界のキューブデータ更新結果
        /// </summary>
        private void OnResponseCubeData(int lastSlotIndex, SideType sideTypeA, SideType sideTypeB, RequestCubeDataResult requestResult)
        {
            if (-1 != lastSlotIndex)
            {
                // 指定スロットにて A, B を消費した通知
                var lastItem = this.items[lastSlotIndex];
                if (null != lastItem)
                {
                    var lastCount = lastItem.count;
                    if (SideType.Air != sideTypeA)
                    {
                        lastCount--;
                    }
                    if (SideType.Air != sideTypeB)
                    {
                        lastCount--;
                    }
                    // カウントを減らして該当スロットの表示内容を更新
                    lastItem.count = lastCount;
                    this.UpdateSlotView(lastSlotIndex);
                }
            }
            else
            {
                // 単なるキューブの取得のケース

                // どこに置けるのか?探す

            }
        }

あと数字が残るバグもあったので直します。
ほか、スロット変更は mining モード時も有効になっていてほしい→対応

サーバー側で slot カウントも増やさなければならない

これが大変なケースがあって、例えばアイテムスロットに空きがあって、そこにアイテムが追加されてほしいじゃないですか
でもそういう計算をサーバーで行おうとすると、すべてのアイテムのすべての CustomData にある slotXX の XX の部分を総なめして
空きがある最小の値を見つけ出し、ここに置くと宣言する…という計算を走らせなければならない

それはやめようと思う
やめると困るのは、新しいキューブを手に入れたとき
サーバーで適切なスロットに格納されないから、ローカルで頑張らなければならない

そこで、サーバーで大変なケースになっていることはわかるので、その時だけクライアント側で計算してもらい
正しいスロット番号状態をリクエストしてもらうのはどうだろう

そうしよう、ということでサーバー側は簡易な以下のコードでスロット内の数を加算することにしました。
動作確認済みです。

    // grant replaced item
    let grants = [];
    if (0 != response.sideA) {
      grants.push(response.sideA);
    }
    if (0 != response.sideB) {
      grants.push(response.sideB);
    }
    if (0 < grants.length) {
      server.GrantItemsToUser({
        PlayFabId: currentPlayerId,
        CatalogVersion: "cube",
        ItemIds: grants
      });
      // grant item loop
      for (let itemId of grants) {
        let addSlotIndex = 1000;
        let addSlotKey = null;
        let addSlotCount = 99 * 2;
        let addSlotItem = null;
        // inventory item loop
        const playerInventory = server.GetUserInventory({
          PlayFabId: currentPlayerId
        });
        for (let slotItem of playerInventory.Inventory) {
          if (itemId != slotItem.ItemId) {
            // check item id is same
            continue;
          }
          if (null != slotItem.CustomData) {
            const pattern = /slot([0-9]{2})$/;
            for (let key in slotItem.CustomData) {
              const match = key.match(pattern);
              // has slot CustomData
              if (null != match && 99 * 2 > slotItem.CustomData[key]) {
                // has space of add
                if (addSlotIndex > match[1]) {
                  // lower index
                  addSlotIndex = match[1];
                  addSlotKey = key;
                  addSlotCount = Number(slotItem.CustomData[key]);
                  addSlotItem = slotItem;
                }
              }
            }
          }
        }
        if (
          null != addSlotKey &&
          1000 > addSlotIndex &&
          99 * 2 > addSlotCount &&
          null != addSlotItem
        ) {
          // add slot count
          const newCount = addSlotCount + 1;
          server.UpdateUserInventoryItemCustomData({
            PlayFabId: currentPlayerId,
            ItemInstanceId: addSlotItem.ItemInstanceId,
            Data: {
              [addSlotKey]: newCount
            }
          });
        }
      }
    }

最後にクライアント側でのアイテムの増加

サーバー側で行われたアイテム増減の結果は返ってきませんので
クライアント側で、サーバーの状態を類推して合わせてあげる必要があります。

       /// <summary>
        /// 世界のキューブデータ更新結果
        /// </summary>
        private void OnResponseCubeData(int lastSlotIndex, SideType sideTypeA, SideType sideTypeB, RequestCubeDataResult requestResult)
        {
            if (201 != requestResult.status)
            {
                // キャッシュサーバーに変更がなかったのでアイテム個数修正を無視
                return;
            }
            if (-1 != lastSlotIndex)
            {
                // 指定スロットを消費した通知と考えられる
                var lastItem = this.items[lastSlotIndex];
                if (null != lastItem)
                {
                    var lastCount = lastItem.count;
                    if (SideType.Air != sideTypeA)
                    {
                        lastCount--;
                    }
                    if (SideType.Air != sideTypeB)
                    {
                        lastCount--;
                    }
                    // カウントを減らして該当スロットの表示内容を更新
                    lastItem.count = lastCount;
                    if (0 >= lastItem.count)
                    {
                        // アイテムを使い切ったので情報を削除
                        this.items[lastSlotIndex] = null;
                    }
                    this.UpdateSlotView(lastSlotIndex);
                }
            }
            else
            {
                // 掘削によるキューブ取得
                List<int> grants = new List<int>();
                if (0 != requestResult.sideA)
                {
                    grants.Add(requestResult.sideA);
                }
                if (0 != requestResult.sideB)
                {
                    grants.Add(requestResult.sideB);
                }
                foreach (var grantItemId in grants)
                {
                    var reloadFlag = true;
                    for (int slotIndex = 0; slotIndex < MAX_SLOT_INDEX - 1; slotIndex++)
                    {
                        var item = this.items[slotIndex];
                        if (null == item)
                        {
                            continue;
                        }
                        if (null == item.item)
                        {
                            continue;
                        }
                        if (int.TryParse(item.item.ItemId, out int itemId))
                        {
                            if (itemId == grantItemId)
                            {
                                // 同じアイテムの最も若いスロット番号
                                if (99 * 2 > item.count)
                                {
                                    // 空きがあるならカウントを一つ増やす
                                    var newCount = item.count;
                                    newCount++;
                                    item.count = newCount;
                                    this.UpdateSlotView(slotIndex);
                                    reloadFlag = false;
                                    break;
                                }
                            }
                        }
                    }
                    if (reloadFlag)
                    {
                        // どこにも同じアイテムで空きスロットがないので、最新情報を取得してインベントリを初期化
                        this.OnOpenInventory();
                    }
                }
            }
        }

まとめ

上記の工程を経て、タイミングが良ければ…一見してアイテムを消費したり、掘削してキューブを取得したりができていそう
ここからデバッグしていきます。

ともかく、クライアントから送られてくるアイテム情報を利用せず、スロット番号だけを頼りにサーバーにあるインベントリ情報から世界データを更新
安全にアイテムの利用、キューブの破壊ができているようです。

現在のゲームプレイの様子