simplestarの技術ブログ

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

CubeWalk:インベントリシステムの構想

概要

長いことクライアントで閉じていた処理がサーバーで行われるようになりました。
simplestar-tech.hatenablog.com

ふわふわしているアイテム操作のイメージを少しずつ固めていく工程を記録してみます。
構想というやつです。

最後に具体的なアクションリストにして、次の記事からはアクションについて書いていきます。
リスト最後にたどり着いたとき、今回の構想を通して完成した絵が私の手元で見えるようになるでしょう。

現状

キューブを破壊すると
空気プリズム x 2 が、もともとあったキューブの位置に配置され
もとあったキューブを構成していた空気じゃないプリズム x 2 がインベントリに格納されます。

ここで疑問1
プリズムだけを置くことはできますか?

はい

f:id:simplestar_tech:20191120084123p:plain
証拠画像

どういう操作でプリズムだけを置けるように指示するか?

横暴なやり方かもしれませんが、アイテムはすべてプリズムとして
配置するとディフォルトでプリズムになるのはどうでしょう?

キューブを作って置けるようにするか?
はい、そうですね そうしたい

よし、では右クリック長押し中はアローキーで回転を選ぶことができますが
アローキーのすぐそばにある R Ctrl キーを押したら
キューブとプリズムA, Bが交互に入れ変わるというのはどうでしょう?
これでいこう

これならインベントリは一つのslotにプリズムだけを配置することができるようになります。
どちらかというと、プリズム x 2 でスロットに配置でき、ユーザーには Cube 単位で数えさせるのがいいかもしれません
アイコンもキューブとしてボックスに配置できて楽

R Ctrl でプリズムだけ置いたら、キューブの数が 0.5 とか
0.5の状態ではプリズムだけしか置けない形

インベントリ?

PlayFab を使うとアイテムIDと残数の情報がリストで返ってくる
UIでのインベントリはどのアイテムをどこに置いたかが重要になるので
サーバ側のインベントリのアイテムには、カスタムデータとして配置場所についての情報が付与されるべき

例えば9つのホットバーのスロットに 1~9 のインデックスが振られているとして
カスタムデータにslot-1をキーに 2 が設定されていれば、インベントリの情報を引いてきたら、そのアイテムはホットバーの 1 スロットに 2個配置されるとする。

ユーザーが編集したら?

先ほどのアイテムをスロット 1 から 2 に移動させたら?

UIでまず、どうやって移動させる?

それはアイテムビューを開いたとき、マウスカーソルが出てくるモードがあるので、そのときにクリックして
アイテムがあればアイテムをホールド、なければ何も起きない
ホールドしていた状態でクリックしたとき、スロットだったらスロットに配置する
スロットでなければ、外界に高密度キューブとして投げる

高密度キューブはベーシックなキューブではなくスタッカブルタイプとして、タイプとカウントを持つ
最大 255 個まで
slotに 99 個以上は置けないことにしてしまおう

一連の操作を行い、アイテムビューを閉じたときに
クライアントで累積したアイテムのカスタムデータの更新処理の内容をすべて一括でリクエス

リクエストの形式はアイテムインスタンスIDのカスタムデータの Slot-1 の内容をホールド Slot-2 にドロップ
Slot-2 を半ホールド、外の世界へホールドしていた分をドロップなど 数字に関する操作はリクエストしない

でもそうするとアイテムを外界へドロップするときの数の計算ができない

仕方ない、一つの操作ごとにリクエストをなげてみよう
そうした結果を反映させていけば、ドロップ後のキューブ情報をバイナリキャッシュサーバーへサーバースクリプトから正しくリクエストできる

具体的な操作はサーバーの CloudScript 側で実行して、インベントリのアイテム数の調整はサーバースクリプトが行う
ユーザー側のハックで様々な不正なリクエストは投げられるが、数だけは合うという形

外界へ投げるというのも、できるのでしょうか?
ここはできそうであると言っておく

アイテムがすべて一つのスロットにあるとは限らない

確かに 100個あるプリズム x 2 のキューブを 1, 2 のスロットに 50 個ずつ分けるかもしれない

どうやって分けるの?

左クリックだと全部ホールドするので
右クリックで半数をホールドする

左クリックでリリース
空のslotの場合は置く、空じゃなかったら交換してホールド

インベントリのカスタムデータには、どのslotに何個配分するか決める

クライアントから利用時はカスタムデータの、ホットバーのslotにあるときの個数だけを消費でき
同様にサーバーでもカスタムデータのホットバーのslotの残数を見ながら
実際のインベントリのアイテムのプリズムの数を消費して、配置する(という CloudScript)

slotには 2桁までしか入らないので 99個詰めたら、別のslotに格納されることになる
限界までアイテムslotとホットバーを使い切ったら
ブロックの破壊を試みても、空きがないことをサーバーで計算され、リクエストは失敗する

リクエストに成功したらクライアントでキューブが破壊されたことにしてしまうので、失敗したら何も起きないことになる

インベントリのカスタムデータを頻繁に更新

ゲーム開始時に全部拾ってきて、インベントリ操作で頻繁に行うが、そんなことできるのか

一応できる
UpdateUserInventoryItemCustomData - Microsoft Azure PlayFab Server API
キーとバリューのペアとなると
slotの一意な名前と、スロットに格納した数をつめると良さそうかな

ですね、slot- のプレフィックスが付いていたらアイテムとするとか
操作することによって対象のアイテムに対するリクエストが作られ
操作完了時にリクエス

サーバー側はリクエスト内容によってアイテム数に増減が起きていないことを確認する

アイテムボックス

プレイヤーのインベントリについては固まってきたが
ゲーム内では取得したキューブデータをアイテムボックスに格納して置いておくなどができるようにする予定…だったが
高密度キューブがその役割であり、基本的な操作に変わりはない

調査したがタイトルデータは使えなかった

タイトルのデータはキーバリューで自由に置くことができ、後からそのキーの値だけ取り出すことができるのか
確かに、しかしドキュメントによると
api.playfab.com
グローバルスタティックなデータの置き場であり、更新には 15分は要し
要するに、アイテムの置き場所として利用するなとのこと

仕方ない、ここは
断念

この世界にアイテムボックスなど存在しない。
インベントリという四次元ポケットはプレイヤーという存在にしか存在しないことにする
そうすればカスタムデータに Slot- * を詰めるだけですべてのアイテム所持の操作が可能になる

ログアウトすると、どうなる?

ログアウトは不慮の事故や悪意ある強制終了で発生したりしますので、終了処理を入れることは期待できない
最後にいた地点に特別なキューブが設置され…なんて考えていたけど、最後にセーブした位置に戻るというのが正しい。

セーブ時に特別なキューブを置けるというのはアリ
そしてそのキューブを破壊した時、プレイヤーのインベントリの内容が周囲に飛び散るというものを考えたりしている

ここで、不正のことを考えていた

例えば根気よく世界のキューブすべてを破壊してインベントリに収めるプレイヤーがいたら
そのプレイヤーによって世界が空っぽになるわけで

やはりサーバー側でアイテムの上限をチェックして
一定数以上 たとえば 99 個以上の元素キューブは手に入らないとします。

ただし、キューブ素材の合成によって消失と新しいキューブ または アイテムの生成は可能とすると
キューブの質量は保存されない

いいんじゃないでしょうか?
不便で、チートはできないけど、いやがらせの行動は行える

その嫌がらせ行動をする人間をログや、他のプレイヤーが観測できるというもの
プレイヤーを分類して色々と小世界に隔離したりして実験できるわけで

思い出してきたことに、それがこのゲーム制作の目的でした。
ユーザーには好きに楽しんでもらい、このゲームをもう一つの世界として、プレイヤーの心理を描かせ
人間がどれだけ社会性を持つ生き物だったのかを証明する

そういう実験を行い、世の中を変えていきたい。

結論、破壊的な行動を行い、ゲームをつまらなくさせてしまうプレイヤーを許可する
例えば、キューブを片っ端から掃除して、世界をすっからかんにしてログアウトして戻ってこないプレイヤーを認める
そういうことするのが人間であると認め、運よくそのプレイヤーが最後に記録したキューブを破壊した人がすべてのアイテムを再取得する

以上

実装ステップ

これから、次の項目について記事を作っていきます。

アイテム対応
1.R Ctrl でキューブ、SideA, SideB キューブと配置形状が切り替わる
2.インベントリ情報を受け取って表示する機能
3.マウス操作でインベントリのアイテムを移動できる機能
4.カスタムデータを更新するリクエスト CloudScript の実装
5.実際にリクエストすることによって、次回のアイテムビューで更新結果が反映されている様子
6.投げて物理挙動したキューブ、速度が落ち着いたら位置としてそこに固定される
7.セーブ用の特別なキューブを破壊すると、対応するプレイヤーのインベントリの内容が周辺に配置される

PlayFab:キューブ破壊後にキューブデータをインベントリへ格納

キューブ保存の法則

世界のデータは減りも増えもしない(神様の創造は例外)
キューブを破壊しようとしたら、その破壊をバイナリキャッシュサーバーが判定し
成功したならインベントリにそのキューブの情報が格納される仕組みを作ります。

インベントリの操作

基本的に C# Unity からは API で CloudScript をたたくだけを想定しています。
その先の CloudScript 側でインベントリを操作する具体的な例を見て覚えていくところから

Qiita記事を参考に高速学習
qiita.com
こちらは Unity から直接インベントリのすべてのアイテムを確認する手段ですね。
執筆者のよつのさんは PlayFab 勉強会で帰り道の電車が一緒でいろいろゲームの話をした方です。

アイテムの受け渡し

qiita.com
アイテムを作るにはそのアイテムを管轄する「カタログ」が必要
ItemID がないとやり取りできないので作るほかない

ブロックの 1 つ目を何にするか考えましたが、ここは乾いた土にしました。

f:id:simplestar_tech:20191117113310p:plain
PlayFabのアイテム定義

続いてオンラインのキューブの pick 機能を考えます。
対象のキューブの位置を特定し、これの値を取り出す操作です。

pick対象のキューブのインデックス

ローカルで正しくキューブの破壊が行えているということは、正しいインデックスを取得できているのだと思いますが
何番でしょう?

現在のこの動いているコードがヒント

        /// <summary>
        /// Cube の情報を取得
        /// </summary>
        internal byte* GetCubeData(Vector3 cubePosition, Vector3Int chunkInt3)
        {
            this.ChunkInt3ToChunkKey(chunkInt3, out Vector3Int chunkKeyXYZ);
            var cubeInt3 = this.CubePositionToCubeInt3(cubePosition);
            var cubeIndex = this.CubeInt3ToCubeIndex(cubeInt3);
            var chunkIndex = this.ChunkKeyToChunkIndex(chunkKeyXYZ);
            var pChunkData = this.ppChunkData[chunkIndex];
            return (byte*)(pChunkData + cubeIndex);
        }

小世界ごとのキューブの位置を求めなければならないので
前回の記事のコンテキストを読み込んで
simplestar-tech.hatenablog.com

ひらめくコードは次の通り

        /// <summary>
        /// 小世界データ内でのチャンク情報オフセットを取得
        /// </summary>
        internal int ChunkKeyToMicroWorldChunkOffset(Vector3Int microWorldKey, Vector3Int chunkKeyXYZ)
        {
            const int halfChunk = ChunkConst.ChunkSizeX / 2;
            var offsetX = chunkKeyXYZ.x - microWorldKey.x * ChunkConst.ChunkSizeX - halfChunk;
            offsetX = AdjustLocalOffsetForChunkKey(offsetX);
            var offsetZ = chunkKeyXYZ.z - microWorldKey.z * ChunkConst.ChunkSizeZ - halfChunk;
            offsetZ = AdjustLocalOffsetForChunkKey(offsetZ);
            var offsetY = chunkKeyXYZ.y - microWorldKey.y * ChunkConst.ChunkSizeY - halfChunk;
            offsetY = AdjustLocalOffsetForChunkKey(offsetY);
            var chunkDataOffset = offsetX * ChunkConst.ChunkSizeZ * ChunkConst.ChunkSizeY + offsetZ * ChunkConst.ChunkSizeY + offsetY;
            return chunkDataOffset;
        }

        /// <summary>
        /// 小世界のキーから名前を取得
        /// </summary>
        internal string MicroWorldNameFromKey(Vector3Int microWorldKey)
        {
            return microWorldKey.x.ToString("X") + microWorldKey.y.ToString("X") + microWorldKey.z.ToString("X");
        }

        /// <summary>
        /// チャンクとキューブのキーから小世界のキューブデータのデータオフセットを取得
        /// </summary>
        internal int GetMicroWorldCubeDataByteOffset(Vector3Int chunkKeyXYZ, Vector3Int cubeInt3)
        {
            // キューブが所属する小世界名を判定
            var microWorldKey = this.ChunkKeyToMicroWorldKey(chunkKeyXYZ);
            // キューブのインデックス
            var cubeIndex = this.CubeInt3ToCubeIndex(cubeInt3);
            // 小世界データ内でのチャンク情報オフセット
            var chunkDataOffset = this.ChunkKeyToMicroWorldChunkOffset(chunkKeyXYZ, chunkKeyXYZ);
            var bufferByteChunkDataOffset = chunkDataOffset * ChunkConst.ChunkSizeX * ChunkConst.ChunkSizeY * ChunkConst.ChunkSizeZ * sizeof(int);
            // 小世界データ内でのキューブ情報オフセット
            var bufferByteCubeDataOffset = bufferByteChunkDataOffset + cubeIndex * sizeof(int);
            return bufferByteCubeDataOffset;
        }

破壊のリクエストと戻り値

バイナリキャッシュサーバーにリクエストをなげる準備が整いつつあります
キャッシュサーバー側の実装を byte オフセットに更新しました。

		offset := requestJson.Offset
		responseJson := ResponseJsonCubedata{http.StatusOK, cubedata[offset], cubedata[offset+1], cubedata[offset+2], cubedata[offset+3]}
		res, err := json.Marshal(responseJson)

		if 1 == requestJson.Action {
			cubedata[offset] = requestJson.Category
			cubedata[offset+1] = requestJson.Rotation
			cubedata[offset+2] = requestJson.SideA
			cubedata[offset+3] = requestJson.SideB
		}

クラウドスクリプトからキューブ情報の変更リクエストを行う機能を追加してみます。

// cubedata request
handlers.requestCubeData = function (args, context) {
    var headers = {
    };
    
    var body = {
        action: args.action,
        offset: args.offset,
        category: args.category,
        rotation: args.rotation,
        sideA: args.sideA,
        sideB: args.sideB
    };

    var url = "https://cubedata" + args.index + ".youredomain/cubedata";
    var content = JSON.stringify(body);
    var httpMethod = "post";
    var contentType = "application/json";

    var response = http.request(url, httpMethod, content, contentType, headers);
    return { responseContent: response };
};

実装からわかることは、リクエスト対象キューブのアクション前の値を返し
ステータスでアクションが成功したかどうかを知ることができる

完全な空気キューブが得られた場合は、何か物を空気キューブに置いたということ
空気キューブを空気キューブに置くことも考えられるが、これは意味のないこと

クライアントから試験的に呼び出せるので、キューブの破壊に合わせて遊んでみることにします。

バイナリキャッシュサーバーとCloudScriptとUnityクライアントコードを修正したので
AWS Elastic Beanstalk のデプロイと CloudScript のデプロイを先に行います。

デバッグの末、やっと C# Unity とバイナリキャッシュサーバー間で CloudScript の中継により通信が行えるようになり
CDN 経由で落としてきたデータから世界に最初から落とし穴が作られている様子を確認できました!

f:id:simplestar_tech:20191117170413p:plain
ゲーム開始直後、穴が開いている様子

所感としては、いったんはローカルで素早くキューブの設置、破壊が行えるようにして、後から本当の情報で上書きするのが良さそう
レスポンスをわざわざ待つことない→リクエストは基本的にすべて承認されるとして、待たずにローカルの状態を反映するようにした

キューブの破壊や設置でも同じロジック

バイナリキャッシュサーバーの実装を考えて、結果的にリクエストをすべてさばく仕組みを構築
クライアント改造後の不正なリクエスト連発について、攻撃には弱いですがロジックは単純です。

空気キューブを空気じゃないキューブに詰める → 空気じゃないキューブの取得
空気キューブを空気キューブに詰める → 空気キューブの取得
空気じゃないキューブを空気キューブに詰める → 空気キューブの取得
空気じゃないキューブを空気じゃないキューブに詰める → 空気じゃないキューブの取得

ひとつだけイレギュラーなものとして、ローカルでは空気キューブに空気じゃないキューブを置いたのに
なぜかインベントリに期待と異なる空気じゃないキューブが手に入るというもの

空気じゃないキューブを取得したとき、これをインベントリに詰めたい
ところで、空気じゃないキューブには sideA, sideB が入っているわけです。
それぞれ独立して CloudScript 実行後に、プレイヤーのインベントリに詰めることにします。

プレイヤーのインベントリの更新

CloudScript の実行結果を見て、成功の status 200 を受け取った時を考えてみます。

できました!

f:id:simplestar_tech:20191117192128p:plain
インベントリにキューブアイテムが追加されている様子

具体的な実装は次の通り

    var jsonString = http.request(url, httpMethod, content, contentType, headers);
    response = JSON.parse(jsonString);
    if (200 == response.status) {
        var request = {
            "PlayFabId": currentPlayerId,
            "CatalogVersion": "cube",
            "ItemIds": [
                response.sideA,
                response.sideB
            ]
        };
        server.GrantItemsToUser(request);   
    }

これでキャラクターごとにピックしたキューブ構成要素を正しく取り出すことができるようになりました。

まとめ

キューブ破壊後にキューブデータをインベントリへ格納する仕様を決めて
動作確認できました。
詳細な作業ログとともに具体的な実装を記録することができました。

CubeWalk:世界の境界の向こう側は無

世界データは最大 67MB

まずは事実から
バイナリキャッシュサーバーに 16 x 16 x 16 x 16 x 16 x 16 x 4 byte のデータを格納しています。
一時間おきに PlayFab からパスワード付きのリクエストを https で送信して PlayFab の CDNgzip 圧縮データをアップロードしています。
クライアントゲームは PlayFab にログイン成功後、最新のデータを CDN からダウンロードして gzip 解凍して

はい、ここまで
ここから zero を 1 にする作業です。

キャッシュサーバーに保存される世界データを小世界と呼ぶことにします。小世界は世界に 16 x 16 x 16 個存在し、現在関心を置いているのは中央が原点の小世界。
将来的に他の小世界から始まることを考えると、プレイヤーの開始位置は世界で様々としたときに
自分がいる小世界のデータをダウンロードして開始することになります。

小世界の果てに着いたらどうなるの?

どうやって小世界を特定するかは後回しにして、もし自身の小世界の終わりにたどり着いたとしたら、そこはどうなっているのでしょうか?
そこは無であり、ブロックを置こうとしても置けない空間です。

それでは小世界の移動ができないのか?

いいえ、小世界をまたぐゲートをまたぐことで小世界と小世界をつなぐことができます。
ゲートは世界を管理できる simplestar だけが設置できるものとし、このゲームの人気が出ない限り出現することはありません。

ゲートはどこに存在するのか?

小世界は上下左右に 6つの小世界と隣接しています。
世界の中央から上下左右に 6軸を延長して境界と交差したブロックを門とし、特別な破壊不能ブロックによって囲われて作られています。

想像しているゲートはこんな感じ

f:id:simplestar_tech:20191116193641p:plain
ゲートの案

プレイヤーが物理的にこのゲートに入った後、そのゲートの先にある小世界のデータのダウンロードに成功した時、その門の向こう側が構築され
プレイヤーは二つの世界を自由に行き来できるようになります。

門をくぐる行為が成功しない限り、門の向こう側の世界は無としてありつづけます。

小世界の名前

始まりの小世界は原点を表す 000 の文字列がキーになり
x 方向に進んで突き当たるゲートをくぐると 100 のキーの世界へ行けます

  • x 方向だったなら F00 の世界へ行けます

y 方向に上昇してゲートを潜ると010の世界へ行くことになります。

XYZ の値を意味しているわけで 000 ~ FFF の全4096世界を表現します。

自分がどの小世界なのかを判定するのは、チャンクのキーが頼りです。
チャンクには 0~255, 0~255, 0~255 の三次元インデックスが振られています。

世界 000 はチャンク
0 - 8 ~ 7 つまり各軸 248~255, 0 ~ 7 のチャンクのキーインデックスに収まっているときに世界 000 にいることが確定します。

チャンクキーから小世界の名前はわかるのか?

そろそろエンジニア向けの話になってきました。
ロジック書けますか?

それぞれの世界には中心チャンクが存在します。並べてみるとわかってくるのではないでしょうか?
小世界の名前 → 中心チャンクキー
E00 → 224, 0, 0
F00 → 240, 0, 0
000 → 0, 0, 0
100 → 16, 0, 0
200 → 32, 0, 0
こんな感じで 16 ずつ中心チャンクキーが移動します。

つまり 1/16倍して round to int すれば 0 ~ 15 の値になるのでは?
試験してみたところ

残念

0~8 までが 0
9~23 までが 1 になってしまった

本当は 0~7 までが 0 で
9~24 までが 1 であってほしいのに

そこで小さい値を詰めて調整することにした
期待通りのインデックス文字列が得られるようになりました。

        for (int i = 0; i < 256; i++)
        {
            int index = Mathf.RoundToInt((i + 0.1f) / 16f);
            if (16 <= index) index = 0;
            Debug.Log($"i = {i}, index = {index.ToString("X")}");
        }

小世界の大きさ

16 x 16 x 16 キューブのチャンクが、前後左右上下で 8 チャンクの距離なので、一方向に 16 x 8 = 128 [cube] まで
実際に遠目で見てみるとこんな感じ

f:id:simplestar_tech:20191116233850p:plain
水平な世界の図

キューブの情報はダウンロード後にすべて作るとしてこのようにします。

        /// <summary>
        /// 未読み込みの場合のみデータをロード
        /// </summary>
        void LoadChunkData(ChunkLoadTask chunkLoadTask)
        {
            this.ChunkInt3ToChunkKey(chunkLoadTask.chunkInt3, out var chunkKeyXYZ);
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = chunkKeyXYZ.x * byteMax * byteMax + chunkKeyXYZ.z * byteMax + chunkKeyXYZ.y;
            if (null == this.ppChunkData[chunkIndex])
            {
                // チャンクが所属する小世界のデータをバッファに書き込み
                var microWorldKey = this.ChunkKeyToMicroWorldKey(chunkKeyXYZ);
                var microWorldName = this.MicroWorldNameFromKey(microWorldKey);
                if (this.cubedataLibrary.ContainsKey(microWorldName))
                {
                    // cubedataLibrary には小世界すべての情報が格納されているので、目的のチャンク情報にアクセスするためのオフセットを計算
                    const int eight = 8;
                    const int deight = 16;
                    var offsetX = chunkLoadTask.chunkInt3.x - microWorldKey.x * ChunkConst.ChunkSizeX - eight;
                    offsetX = AdjustLocalOffsetForChunkKey(byteMax, deight, offsetX);
                    var offsetZ = chunkLoadTask.chunkInt3.z - microWorldKey.z * ChunkConst.ChunkSizeZ - eight;
                    offsetZ = AdjustLocalOffsetForChunkKey(byteMax, deight, offsetZ);
                    var offsetY = chunkLoadTask.chunkInt3.y - microWorldKey.y * ChunkConst.ChunkSizeY - eight;
                    offsetY = AdjustLocalOffsetForChunkKey(byteMax, deight, offsetY);
                    var chunkDataOffset = offsetX * deight * deight + offsetZ * deight + offsetY;
                    var bufferByteOffset = chunkDataOffset * deight * deight * deight * 4;

                    // cubedataLibrary から目的のチャンクの情報部分をコピー
                    var chunkDataSize = sizeof(int) * ChunkConst.ChunkSizeX * ChunkConst.ChunkSizeY * ChunkConst.ChunkSizeZ;
                    var microWorldData = this.cubedataLibrary[microWorldName];
                    var chunkData = new byte[chunkDataSize];
                    Buffer.BlockCopy(microWorldData, bufferByteOffset, chunkData, 0, chunkDataSize);
                    var nativeArray = new NativeArray<byte>(chunkData, Allocator.TempJob);
                    var pNativeChunkData = (int*)nativeArray.GetUnsafePtr();
                    var pChunkData = (int*)(UnsafeUtility.Malloc(chunkDataSize, sizeof(int), Allocator.Persistent));
                    UnsafeUtility.MemCpy(pChunkData, pNativeChunkData, chunkDataSize);
                    this.ppChunkData[chunkIndex] = pChunkData;
                    nativeArray.Dispose();
                }
                else
                {
                    // 小世界データがライブラリにないので null のままにしておく
                }
            }
        }

        /// <summary>
        /// 0をまたぐインデックスのためのオフセット調整
        /// </summary>
        private static int AdjustLocalOffsetForChunkKey(int byteMax, int deight, int offset)
        {
            if (deight < offset)
            {
                offset = offset - byteMax + deight;
            }
            if (0 > offset)
            {
                offset += deight;
            }

            return offset;
        }

世界の果ての様子

期待通りこんな感じでした。

f:id:simplestar_tech:20191117005258p:plain
小世界の果て

PlayFab:ログインタイミングを気にせずプログラミングする術~ファイル編

コンテキスト

PlayFab から世界データ拾ってきたいなぁ
でもログインまだだった 失敗

なんてことが絶対に起こらないような

PlayFab から世界データ取得を成功させる仕組みを考えたい

もう作ってた!

simplestar-tech.hatenablog.com

しばらく Unity から離れてたから自分で記事にしていることすら忘れていた

このアイディアをファイルに対しても使ってみます。

ファイルのダウンロードについてはこちらの Qiita の記事が参考になりました。(これも自分が書いてる)
qiita.com

実装詳細

using PlayFab;
using PlayFab.ClientModels;
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class PlayFabContentFile : MonoBehaviour
{
    /// <summary>
    /// Content Delivery Network からファイルコンテンツを取得
    /// </summary>
    /// <param name="key">PlayFabのTitleのRoot/以下のファイルオブジェクトキー</param>
    /// <param name="onComplete">完了時アクションでnullが返ってきたら失敗を意味する</param>
    public void GetContentFileData(string key, Action<byte[]> onComplete)
    {
        GetDownloadUrl(key, presignedUrl =>
        {
            GetFile(key, presignedUrl, onComplete);
        });
    }

    void GetDownloadUrl(string key, Action<string> onComplete)
    {
        PlayFabLogin.AfterLoginCall(() =>
        {
            PlayFabClientAPI.GetContentDownloadUrl(new GetContentDownloadUrlRequest()
            {
                Key = key,
                ThruCDN = true
            }, result => onComplete?.Invoke(result.URL),
            error => Debug.LogError(error.GenerateErrorReport()));
        });
    }

    void GetFile(string key, string preauthorizedUrl, Action<byte[]> onComplete)
    {
        StartCoroutine(this.GetData(key, preauthorizedUrl, onComplete));
    }

    IEnumerator GetData(string key, string preauthorizedUrl, Action<byte[]> onComplete)
    {
        UnityWebRequest www = UnityWebRequest.Get(preauthorizedUrl);
        yield return www.SendWebRequest();

        if (www.isNetworkError || www.isHttpError)
        {
            Debug.LogError(www.error);
            onComplete?.Invoke(null);
        }
        else
        {
            // 結果をバイナリデータとして取得する
            onComplete?.Invoke(www.downloadHandler.data);
        }
    }
}

テストコード

// オンラインから現在のプレイヤーチャンクが所属する世界データを読み込む
// this.playerChunkCenter→"000"
var worldIndex = "000";
var objectKey = $"world/cubedata{worldIndex}.gz";
playFabContentFile.GetContentFileData(objectKey, cubedata => {
    if (null != cubedata)
    {
        Debug.Log($"download length = {cubedata.Length}");
        cubedata = GZipCompressor.Decompress(cubedata);
        Debug.Log($"unzip length = {cubedata.Length}");
    }
    else
    {
        Debug.LogError($"キー{objectKey}でダウンロードできなかったんだけど…");
    }
});

実行結果

一度も失敗を経験することなく、期待通りの動作を確認できました。

f:id:simplestar_tech:20191113234155p:plain
大成功!

データの解凍についてはこちらの記事を参照
baba-s.hatenablog.com

自分はこんな実装が欲しかったのでちょっと変更

using ICSharpCode.SharpZipLib.GZip;
using System.IO;

// 使い方
//var compressedData = GZipCompressor.Compress(rawData);
//var rawData = GZipCompressor.Decompress(compressedData);

/// <summary>
/// gzip で byte[] の圧縮や解凍を行うクラス
/// </summary>
public static class GZipCompressor
{
    public static byte[] Compress(byte[] rawData)
    {
        using (var memoryStream = new MemoryStream())
        {
            Compress(memoryStream, rawData);
            return memoryStream.ToArray();
        }
    }

    public static byte[] Unzip(byte[] compressedData)
    {
        using (var memoryStream = new MemoryStream())
        {
            Decompress(memoryStream, compressedData);
            return memoryStream.ToArray();
        }
    }

    private static void Compress(Stream stream, byte[] rawData)
    {
        using (var gzipOutputStream = new GZipOutputStream(stream))
        {
            gzipOutputStream.Write(rawData, 0, rawData.Length);
        }
    }

    private static void Decompress(Stream stream, byte[] compressedData)
    {
        var buffer = new byte[4096];
        using (var memoryStream = new MemoryStream(compressedData))
        using (var gzipOutputStream = new GZipInputStream(memoryStream))
        {
            for (int r = -1; r != 0; r = gzipOutputStream.Read(buffer, 0, buffer.Length))
            {
                if (r > 0)
                {
                    stream.Write(buffer, 0, r);
                }
            }
        }
    }
}

ゲームではこのデータの解凍処理が終わったところで、それぞれの世界データを確保してデータを書き込み
これを使って世界メッシュの生成が進むとよいだろうと思う

CubeWalk:水平な世界データから始める

はじめに

PlayFab ログイン成功後、CDNから世界圧縮データを取得し
Unity で展開してから、それぞれのチャンク配列を確保し、データを詰めていく
詰め終わったら、ゲームシーンを開始する

世界圧縮データを最初は作らなければならない

世界データは16,777,216のint配列

ゲーム内のチャンクインデックスの振り方をおさらい

チャンクにはキーがあり、3次元であらわされたものを一次元に直すと次の通り

        /// <summary>
        /// チャンクキーから世界で一意のチャンクインデックスを取得
        /// </summary>
        internal int ChunkKeyToChunkIndex(Vector3Int chunkKeyXYZ)
        {
            var byteMax = (byte.MaxValue + 1);
            return chunkKeyXYZ.x * byteMax * byteMax + chunkKeyXYZ.z * byteMax + chunkKeyXYZ.y;
        }

つまり、y方向にインクリメントで、z方向に 256 単位オフセット、x方向は 256x256 オフセットが足されて特定されます。

同じようなやり方なら y 方向にインクリメントで、z方向に 16 単位オフセット、x方向は 16x16オフセットが足される形でチャンクのインデックスが得られるとする
一つのチャンクが 16 x 16 x 16 の 4096 キューブ情報を持つので、常にチャンクインデックス x 4096 がチャンクのデータポインタとなります。

ゲーム内のキューブインデックスの振り方をおさらい

        /// <summary>
        /// キューブの3次元位置表現から一次元の配列インデックスを取得
        /// </summary>
        int CubeInt3ToCubeIndex(Vector3Int cubeInt3)
        {
            return cubeInt3.x * ChunkConst.ChunkSizeZ * ChunkConst.ChunkSizeY + cubeInt3.z * ChunkConst.ChunkSizeY + cubeInt3.y;
        }

つまり、 y 方向にインクリメントで、z方向に 16 単位オフセット、x方向は 16x16オフセットが足される形でキューブのインデックスが得られる
チャンクの時と同じですね。
これでキューブのチャンク内の3次元位置から 0~4095 のインデックスになる

世界を y 方向に二分するインデックス範囲は?

y軸上半分を 0 とし
y 軸下半分を 1 とする値埋めをしたいとしたら

チャンクインデックスを各軸 0~15でループさせる 3重ループを作ったとして
その時にチャンクインデックスをy 方向にインクリメントで、z方向に 16 単位オフセット、x方向は 16x16オフセットが足される形でチャンクのインデックスを求め
その時にチャンクインデックス x 4096 がチャンクのデータポインタとなり
そこから連続して 4096 個を処理するとして
条件はチャンクインデックスが y < 8 とするときに 1 を入れれば良い

では golang で作ってみます。

golang で水平な世界埋め

まずはおさらい
前回のコードに
simplestar-tech.hatenablog.com

simplestar-tech.hatenablog.com

次の main を書けば下準備がそろう

func main() {

	// s3 init
	err := PersistentS3Store.Init(s3bucket, "ap-northeast-1")
	if err != nil {
		log.Fatal("s3 Init error.", err)
		return
	}
	// s3 download backup
	gzdata, err := PersistentS3Store.Get(cubedataObjectKey)
	if err != nil {
		log.Fatal("s3 Get error.", err)
		return
	}
	// unzip backup
	cubedata, err := gUnzipData(gzdata)
	if err != nil {
		log.Fatal("gUnzipData error.", err)
	}

	// zip data
	compressedData, err := gZipData(cubedata)
	if err != nil {
		log.Fatal("gZipData error.", err)
	}

	// local save
	writeFileData(compressedData, cubedataObjectKey)
	// s3 upload
	// err = PersistentS3Store.Set(cubedataObjectKey, compressedData)
	// if err != nil {
	// 	log.Fatal("s3 Set error.", err)
	// }
}

肝心の for 文を次のように書きます。
乱数生成あり

	random := rand.New(rand.NewSource(1))
	deight := 16
	for x := 0; x < deight; x++ {
		chunkX := x * deight * deight
		for z := 0; z < deight; z++ {
			chunkZ := z * deight
			// random blocks
			for y := 0; y < 8; y++ {
				chunkOffset := (chunkX + chunkZ + y) * deight * deight * deight
				for index := 0; index < deight*deight*deight; index++ {
					dataIndex := (chunkOffset + index) * 4
					cubedata[dataIndex+0] = 1
					cubedata[dataIndex+1] = (byte)(random.Intn(24))
					side := (byte)(random.Intn(67))
					cubedata[dataIndex+2] = side
					cubedata[dataIndex+3] = side
				}
			}
			// sky
			for y := 8; y < deight; y++ {
				chunkOffset := (chunkX + chunkZ + y) * deight * deight * deight
				for index := 0; index < deight*deight*deight; index++ {
					dataIndex := (chunkOffset + index) * 4
					cubedata[dataIndex+0] = 0
					cubedata[dataIndex+1] = 0
					cubedata[dataIndex+2] = 0
					cubedata[dataIndex+3] = 0
				}
			}
		}
	}

出力した gz ファイルのサイズは 16.7 MB
さすがに乱数で作ったファイルはそこまで小さくたためなかった

これを s3 に上げて、そのあとキャッシュサーバーを再起動すれば PlayFab の CDN が 16MB ほどのファイルになるはずです。
やってみましょう。

動作確認

PlayFab のスケジュールタスクはタイムアウトエラーが出ているけど
CDNには 17MB のデータがアップロードされていました。
go には何かそういうの回避するのあるのかな?

go側はタイムアウト設定した方がリークがなくなってよいのだとか
christina04.hatenablog.com


あと
>http.request呼び出しタイムアウトは2.5秒です。
解決方法は今のところないのだとさ まぁタイムアウトが起きても、呼び出されたキャッシュサーバーは仕事を完遂するので今は良しとします。
community.playfab.com

PlayFab:golangサーバーからCDNのFileContentにファイルをアップロード

概要

やりたいことは半年前から変わらず、キューブの世界のデータをバイナリキャッシュサーバーに置き
PlayFab ログインユーザーが Unity クライアントから CDN 経由で最新の世界データを引いてくることです。
できれば圧縮したファイルをユーザーに届けたい(いや必須で)

現在の進捗は次の記事の通り
golang 自作バイナリキャッシュサーバーが S3 に定期バックアップを保存している状況

simplestar-tech.hatenablog.com

調査結果とアイディア

公式ブログによれば?
blog.playfab.com

次の GetContentUploadUrl api をオブジェクトキーとシークレット情報で実行すると url が得られるのでこれにファイルアップロードリクエストを投げれば良いらしい
api.playfab.com

アイディアはここから
であるならば CloudScript 側でシークレットキーを使って(いらないかもだけど)アップロード url を作りこれを golang サーバーにリクエストボディで伝える
その url に golang から圧縮したファイルアップロードをしてもらう感じ いけそうですよね?
やってみます。

アップロード url を CloudScript から作ってバイナリキャッシュサーバーに渡す

CloudScript の説明を読むと、次のサーバーAPIフルアクセスになってて、クライアントから切り離されているからゲームコード書いていいよとある
api.playfab.com

残念ながら GetContentUploadUrl はこの Server リストに載っていなくて、次のように CloudScript からも http リクエストを作らなければならなかった

CloudScript の実装(動作確認済み)

handlers.updateContentFileCubedata = function (args, context) {
    // get upload url
    var headers = {
        "X-SecretKey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
    };
    var body = {
        Key: "hoge.zip",
        ContentType: "application/zip"
    };
    var url = "https://XXXXX.playfabapi.com/Admin/GetContentUploadUrl";
    var content = JSON.stringify(body);
    var httpMethod = "post";
    var contentType = "application/json";
    var response = http.request(url, httpMethod, content, contentType, headers);
    response = JSON.parse( response );

    // request zip upload
    headers = {
        "Authorization": "Basic dXNlcjpwYXNzd29yZA=="
    };
    body = {
        dataUrl: response.data.URL
    };
    url = "https://xxx.your.domain.net/upload";
    content = JSON.stringify(body);
    httpMethod = "post";
    contentType = "application/json";
    response = http.request(url, httpMethod, content, contentType, headers);
    return { responseContent: response };
};

※Authorization の値は user:password の値なので、本番では使われていません

golang で zip アーカイブ

これでいけました。(イケてません)

// data をアーカイブ
func compress(data []byte) (resData *bytes.Buffer, err error) {
	b := new(bytes.Buffer)
	zipWriter := zip.NewWriter(b)
	defer zipWriter.Close()

	writer, err := zipWriter.Create("filename")
	if err != nil {
		log.Fatal("zip input file create error.", err)
		return nil, err
	}
	writer.Write(data)

	return b, nil
}

golang で 圧縮と解凍

byte 配列を圧縮して byte 配列にしたいなら gZip ですよ。
zip はアーカイバなのでファイル名が中に必要 でこれは不要と

実装例はこちらを見つけました。
gist.github.com

テストのため byte 配列をファイル保存して 内容をチェックしてみます。

参考は昔の自分の記事
simplestar-tech.hatenablog.com

関数化しておくとこんな感じ

// data書き出し
func writeFileData(data []byte, filepath string) (err error) {
	file, err := os.Create(filepath)
	if err != nil {
		return
	}
	defer file.Close()
	file.Write(data)
	return
}

次のテストコードで圧縮前と圧縮後、解凍後で全部問題ないデータになっていることを確認できました。

		resData, err := gZipData(cubedata)
		if err != nil {
			log.Fatal("gZipData error.", err)
		}

		resdata2, err := gUnzipData(resData)
		if err != nil {
			log.Fatal("gUnzipData error.", err)
		}

		writeFileData(cubedata, "cubedata.bin")
		writeFileData(resdata2, "resData.gz")
		writeFileData(resdata2, "unzip.bin")
		

バイト配列を url にファイルとしてアップロード

ローカルにファイルを作らずにアップロードできるんじゃないかなと思っていますが、どうでしょう?

できました。
実装は次の通り(はじめてのことなのでいろいろ間違えて疎通確認取るまで大変でした!)

		// make gz data
		compressedData, err := gZipData(cubedata)
		if err != nil {
			log.Fatal("gZipData error.", err)
		}
		body := new(bytes.Buffer)
		body.Write(compressedData)

		// create request
		request, err := http.NewRequest("PUT", requestJson.DataUrl, body)
		if err != nil {
			log.Fatal(err)
		}
		request.Header.Set("Content-Type", "application/gzip")

		// send request
		client := &http.Client{}
		response, err := client.Do(request)
		if err != nil {
			log.Fatal(err)
		}
		defer response.Body.Close()

		// check result
		result, err := ioutil.ReadAll(response.Body)
		if err != nil {
			log.Fatal("ioutil.ReadAll(response.Body)", err)
		}
		resultMessage := string(result)
		if resultMessage != "" {
			log.Fatal("PlayFab upload error.", resultMessage)
		}

これで全世界何万人のユーザーが来ても、問題なくファイルを配信する機能が確認できました。

f:id:simplestar_tech:20191110182604p:plain
CDN にファイルアップロードされた

Unity からファイルをダウンロードする方法は Qiita に昔記事を書いていたので
こちら
qiita.com
クライアント実装時に参照しようと思います。

まとめ

半年間調査を続けた大目標が達成されました!
キューブの世界のデータを考え得る限り実装が楽で高速なバイナリキャッシュサーバーに置くことができ
PlayFab ログインユーザーだけが Unity クライアントから CDN 経由で定期的に最新のキャッシュサーバーのデータで更新される世界データを引いてこれること
圧縮したファイルをユーザーに届けられている

ここから Unity クライアントに作業を戻していきます。

AWS:golangバイナリキャッシュサーバーがS3にバックアップを保存&復帰

前提

http リクエストで int データを受け渡すバイナリキャッシュサーバーが完成しています。

前回の作業記録をなぞるだけ
simplestar-tech.hatenablog.com

大きな目的

PlayFab の CloudScripts から int or byte x 4 データを受け渡すバイナリキャッシュサーバーが定期的に s3 に全データを記録
そこからいつでも復帰できるというもの

今回は定期的に s3 に全データをバイナリで記録する機能を試します。

参考にする s3 操作はこちら
qiita.com

ローカルで s3 操作サンプルを利用できる環境づくり

EC2 インスタンスは Elastic Beanstalk によって作られたリソースです。
IAM ロールのポリシーをチェックした感じ elasticbeanstalk-* から始まるS3 バケットへの読み書きを許可しているので問題なく保存できます。
定期実行は毎分 schedule パスで動いているので保存されるかチェック

"github.com/aws/aws-sdk-go/aws"
はどうやってインストールするのか?

ローカルで試験したときはターミナルで以下のコマンドを実行

go get -u github.com/aws/aws-sdk-go

結果 users フォルダの go\src\github.com\aws\aws-sdk-go にインストールされることを確認しました。

EC2 インスタンスでも同じことするには…ssh で入って確認する必要があるのかな?→いいえ

Elastic Beanstalk で s3 操作サンプルを利用できる環境づくり

作業内容としては .zip 圧縮する一番上の階層に
Buildfile
Procfile
の二つのテキストファイルを作成し、それぞれに yaml 形式でビルドコマンドと、アプリケーションの実行ファイルを指定します。

具体的には

こちらのドキュメントの通り
Buildfile で Executable On-Server を構築する - AWS Elastic Beanstalk

awssdk: go get -u http://github.com/aws/aws-sdk-go
build: go build -o bin/application application.go

を Buildfile に書くことで解決できました。

Procfile には

web: bin/application

こちらを記述してます。(ファイル無くても、EC2 インスタンスではディフォルトでこれが作られます)

build と連携すれば起動するアプリケーションプロセスも変えられるとのこと
[Procfile] でアプリケーションプロセスを設定する - AWS Elastic Beanstalk

起動時に S3 から復元し、定期的にバックアップする golang サーバー

S3 操作ができる環境なので、次の実装を含めるだけです。

簡単に解説すると、起動時に byte 配列を S3 のダウンロード結果から作成し、
スケジュール実行用の scheduled パスのリクエストが来たら、S3 にバックアップを作成します。
(S3 にバックアップがないと起動しないという、卵か鶏かの話は生まれてますが)

実装内容は次の通り

package main

import (
	"bytes"
	"encoding/json"
	"io/ioutil"
	"log"
	"net/http"
	"os"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/awserr"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/aws/aws-sdk-go/service/s3/s3manager"
)

// ----- S3 -----

var PersistentS3Store = &S3Store{}

type S3Store struct {
	Bucket     string
	Uploader   *s3manager.Uploader
	Downloader *s3manager.Downloader
}

func (s *S3Store) Init(bucket, region string) (err error) {
	s.Bucket = bucket

	sess, err := session.NewSession(&aws.Config{
		Region: aws.String(region),
	})
	if err != nil {
		return
	}

	s.Uploader = s3manager.NewUploader(sess)
	s.Downloader = s3manager.NewDownloader(sess)

	return
}

func (s *S3Store) Set(key string, body []byte) (err error) {
	params := &s3manager.UploadInput{
		Bucket: aws.String(s.Bucket),
		Key:    aws.String(key),
		Body:   bytes.NewReader(body),
	}

	_, err = s.Uploader.Upload(params)
	return
}

func (s *S3Store) Get(key string) ([]byte, error) {
	buffer := aws.NewWriteAtBuffer([]byte{})

	_, err := s.Downloader.Download(buffer, &s3.GetObjectInput{
		Bucket: aws.String(s.Bucket),
		Key:    aws.String(key),
	})
	if err != nil {
		if aerr, ok := err.(awserr.Error); ok && aerr.Code() == "NoSuchKey" {
			return nil, nil
		}
		return nil, err
	}

	return buffer.Bytes(), nil
}

// ----- S3 -----

func main() {
	f, _ := os.Create("/var/app/current/golang-server.log")
	defer f.Close()
	log.SetOutput(f)

	err := PersistentS3Store.Init(s3bucket, "ap-northeast-1")
	if err != nil {
		log.Fatal("s3 Init error.", err)
		return
	}
	cubedata, err := PersistentS3Store.Get(cubedataObjectKey)
	if err != nil {
		log.Fatal("s3 Get error.", err)
		return
	}
	port := os.Getenv("PORT")
	if port == "" {
		port = "5000"
	}

	http.HandleFunc("/request", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "POST" {
			http.Error(w, "Not Found.", http.StatusNotFound)
			return
		}
		user, pass, ok := r.BasicAuth()
		if ok == false || user != basicAuthUser || pass != basicAuthPassword {
			http.Error(w, "Not Found.", http.StatusNotFound)
			return
		}
		requestJson := new(RequestJson)
		if buf, err := ioutil.ReadAll(r.Body); err == nil {
			if err := json.Unmarshal(buf, requestJson); err != nil {
				http.Error(w, "Bad Request.", http.StatusBadRequest)
				return
			}
		}
		if 0 > requestJson.Index || len(cubedata) <= requestJson.Index*4 {
			http.Error(w, "Bad Request.", http.StatusBadRequest)
			return
		}
		offsetIndex := requestJson.Index * 4
		if 1 == requestJson.Action {
			cubedata[offsetIndex] = requestJson.Category
			cubedata[offsetIndex+1] = requestJson.Rotation
			cubedata[offsetIndex+2] = requestJson.SideA
			cubedata[offsetIndex+3] = requestJson.SideB
		}
		responseJson := ResponseJson{http.StatusOK, cubedata[offsetIndex], cubedata[offsetIndex+1], cubedata[offsetIndex+2], cubedata[offsetIndex+3]}

		res, err := json.Marshal(responseJson)

		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		w.Write(res)
	})

	http.HandleFunc("/s3dump", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "POST" {
			http.Error(w, "Not Found.", http.StatusNotFound)
			return
		}
		user, pass, ok := r.BasicAuth()
		if ok == false || user != basicAuthUser || pass != basicAuthPassword {
			http.Error(w, "Not Found.", http.StatusNotFound)
			return
		}
		err = PersistentS3Store.Set(cubedataObjectKey, cubedata)
		if err != nil {
			log.Fatal("s3 Set error.", err)
		}
	})

	http.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "GET" {
			http.Error(w, "Not Found.", http.StatusNotFound)
		}
	})

	log.Printf("Listening on port %s\n\n", port)
	http.ListenAndServe(":"+port, nil)
}

type RequestJson struct {
	Action   int  `json:"action"`
	Index    int  `json:"index"`
	Category byte `json:"category"`
	Rotation byte `json:"rotation"`
	SideA    byte `json:"sideA"`
	SideB    byte `json:"sideB"`
}

type ResponseJson struct {
	Status   int  `json:"status"`
	Category byte `json:"category"`
	Rotation byte `json:"rotation"`
	SideA    byte `json:"sideA"`
	SideB    byte `json:"sideB"`
}

const (
	basicAuthUser     = "user"
	basicAuthPassword = "password"
	s3bucket          = "elasticbeanstalk-ap-northeast-1-xxxxxxxxxxxxxxxxx"
	cubedataObjectKey = "data.bin"
)

毎時スケジュール実行(非推奨)

同フォルダに cron.yaml を設置

version: 1
cron: 
  - name: "backup"
    url: "/scheduled"
    schedule: "20 * * * *"

期待では毎時 20 分になるとバックアップを S3 にアップロードする予定なのですがどうでしょう

ログも流れないですね
これはどういうことか?

こちらの開発者ガイドに書かれている模様
https://docs.aws.amazon.com/ja_jp/elasticbeanstalk/latest/dg/awseb-dg.pdf

定期的なタスク
ソースバンドルで cron.yaml という名前のファイルに定期的なタスクを定義し、定期的な間隔でワー
カー環境のキューにジョブを自動的に追加できます。
たとえば、次の cron.yaml ファイルは 2 つの定期的なタスクを作成します。1 つは 12 時間ごとに実行さ
れ、もう 1 つは毎日午後 11 時 (UTC) に実行されます。
Example cron.yaml
version: 1
cron:
- name: "backup-job"
url: "/backup"
schedule: "0 */12 * * *"
- name: "audit"
url: "/audit"
schedule: "0 23 * * *"
name は、各タスクに対して一意である必要があります。URL は、ジョブをトリガーするために POST リ
エストを送信するパスです。スケジュールは、タスクをいつ実行するかを決定する CRON 式です。
タスクを実行すると、デーモンは実行する必要があるジョブを示すヘッダーとともに環境の SQS キュー
にメッセージをポストします。環境の任意のインスタンスはメッセージを取得し、ジョブを処理できま
す。

うーん、自分の行いはすべて正しい
だけど POST 来てないよ?(当時は Basic 認証つけてないから、本来なら通知が来るはずだった)

あ、もしかして環境の設定?

docs.aws.amazon.com

ちょっと項目を見てみます。
ないなぁ

SQS のキーワードを得たけど、なんだっけこれ
やめやめ 動かない

PlayFab のスケジュールタスクで定期実行

S3 への保存とか認証データつけないと怖くて公開できないじゃないですか
スケジュールを待ち受ける公開されたエンドポイントとか作っちゃダメ
ということで PlayFab の定期実行タスクから、認証情報付きでリクエストしてもらうことにします。

PlayFab には cron の形式でスケジュールタスクを設定できます。
具体的な手順は公式ドキュメントが詳しい
api.playfab.com

要約すると、CloudScript 実行します、この CloudScript 関数です を指定
今回は引数に json を指定して、対象サーバを切り分けるのに使いました。
毎時 25 分に実行するようにしたところ、ここ数日問題なくスケジュールが実行されることを確認

CloudScript は次のようにサンプルをベースに作りました。

// s3dump
handlers.s3dump = function (args, context) {
    var headers = {
        "Authorization": "Basic dXNlcjpwYXNzd29yZA=="
    };
    
    var body = {
    };

    var url = "https://xxx" + args.index + ".your.domainname.net/s3dump";
    var content = JSON.stringify(body);
    var httpMethod = "post";
    var contentType = "application/json";

    var response = http.request(url, httpMethod, content, contentType, headers);
    return { responseContent: response };
};

※ここに書かれている認証情報は user:password のBasic 認証文字列なので本番とは別物です

実際 S3 に 64MB のバイナリファイルが毎時 25 分 20 秒から 2秒ほどで更新され続けていることを確認できました。

証拠

f:id:simplestar_tech:20191109183706p:plain
S3 ファイルが保存されている様子