ブラウザ/モバイルゲーム用のロードスクリプトの開発。 パート1

こんにちは、Habr。 前の記事で、私が働いているゲーム会社の負荷テストのプロセスを自動化することについて話しました。 ここで、ゲーム自体のテストプロセスの準備で直面しなければならない特定のタスクについて詳しく説明します。



異なる銀行/小売プロセスとゲームのテストには大きな違いがあります。 最初のケースでは、ユーザーはタスクをほぼ分離して実行し、現在ブラウザまたは他のクライアントのウィンドウに表示されているデータと要素のみを使用するため、ロードスクリプトの開発が容易になります。 ゲームでは、ユーザー(プレイヤー)は動的に変化する世界にあり、多くの場合互いに影響を受けます。 私の想像では、違いは次のようになります。









つまり、最初のケースでは、ユーザーは一連の同様のアクションを通じて最終結果に到達し、次のラウンドに進みます。 一方、ゲームはランダムなカオスであり、その中心はゲームの世界であり、プレイヤーは常にゲーム内のデータに影響を与え、ゲーム内のデータを変更し、自分自身と他のプレイヤーの両方に直接影響を及ぼします。 プレイヤーは通信し、ギルドで団結し、PvPをカットすることもできます。



したがって、ロードスクリプトを開発するときは、多くの条件、動的データなどを考慮する必要があります。 さまざまなオンラインゲームのボットを作成する人は、同じタスクの一部を自動化するために似たようなことをする必要があるように思えます。 しかし、テストでは、すべてのゲームアクティビティを実装しようとします。



関連データの問題



銀行業務プロセスをエミュレートするスクリプトを開発する場合、スクリプト(通常)

特定の段階(たとえば、Webページ)で「見る」データに依存します。 つまり、次のステップに進むには、事前に準備された(または同じページから取得された)データを適切な場所にまとめて送信するだけです。



ゲーム用のスクリプトを開発する際の主な問題の1つは、コマンドを実行する前に、この特定の瞬間の前に発生した変更を追跡(追跡)する複雑さです。 オブジェクト、リソース、ユニットなどの変更された状態に関する情報は、特定のデータに関連しないアクションの後でも、いつでも取得できます。 この更新をスキップすると、仮想ユーザー(VU、Jmeterのスレッド)はゲームと同期しなくなり、「リソースが足りない」または「マップにスペースがない」場合にエラーを生成し始め、負荷テストを役に立たないものに変えます。 もちろん、スクリプトが1秒前になると、「味方を攻撃できない」などのスクリプトが生成される可能性は常にありますが、実際のクライアントでも同じことが起こります。



また、ゲームにログインしたときにのみ、ゲーム世界のほぼすべてのソースデータと現在の状況がクライアントに届くという生活を複雑にします(通常、数メガバイトの巨大なJSON)。関連する状態、つまり現在の状況を知っている。 同じことをスクリプトに実装する必要があります。各VUは、ログイン段階でゲームが送信するものを「記憶」し、テスト中にこのデータを慎重に転送および変更する必要があります。 以下は、InnoGamesゲームの1つの問題をどのように解決したかの例です。



帝国のフォージ



(これが広告としてカウントされないことを願っています。問題と解決策の本質を説明する必要がありますが、ゲーム自体の簡単な説明なしではできません)



これは都市建設シミュレーターで、プレイヤーは石器時代から徐々に技術を開発し、州を征服し、他のプレイヤーと戦って、自分の都市を促進し、拡大します...非常に遠いエンドゲームです。



ログイン後に新しく登録されたプレーヤーには、次のようなものが表示されます。空のフィールドと1つの大きな建物(GZ)、その上にあるいくつかの木と道路:

画像



占有されていないフィールドとオブジェクトは、建物自体のサイズと地図上の空きスペースを考慮しなければならないサイズに応じて、正方形に分割されます。 建物は、住宅、産業、軍事、文化、道路などのタイプに分類されます。 建物が異なれば、住宅-人口とお金、工業-財と資源、文化-幸福など、さまざまな資源が生まれます。 各建物を建設するときは、同じリソースを考慮する必要があります。それらが十分でない場合は、待つか、たとえば人口を補うために新しい家を建てる必要があります。 私が運転している場所を感じますか? これはエミュレートするアカウンティングエントリではありません:)



建設業



都市計画シミュレータでは、主なビジネスプロセス(それを呼び出しましょう)は実際の建設そのものです。 これは、この種のゲームのスクリプトを作成する際の最初の主要な問題です。 建物の建設の問題は、同時に対処する必要があるいくつかのサブタスクに分かれています。



  1. 建物の大きさを理解し、その下の地図で自由時間を見つける
  2. (利用可能なリソースの事前比較)
  3. 新しい建物は道路を介して本館に接続する必要があります
  4. 負荷を分散する必要があります。つまり、毎回同じ建物を突き出す必要はありませんが、ユニットを含む異なるリソースを生成するために異なる建物を構築する必要があります。


ポイント3は特に怖かったです。いくつかの複雑なアルゴリズムを使用する必要性が頭に浮かびました。これは、Jmeterと数千のVUを使用したテスト条件では特に非現実的です。 できるだけ単純なアルゴリズムと構造を使用する必要があります。そうしないと、負荷ジェネレーター自体のハードウェアの問題が発生します。



数時間の思考の後、単純なアルゴリズムのアイデアが浮上しました。私はそれを「レイヤーによる構築」と呼びました。 その本質は次のとおりです。 上記のスクリーンショットでわかるように、GZはマップの端に押し付けられており、その背後では何も作成することができず、手でプレイされています。 ログイン後の各VUは、まず地図と本館の輪郭に沿って道路を建設し、場所がある限り、この道路の近くに必要な建物を建設します。 したがって、道路によって建設されたすべての建物は公共事業に接続されます。 次に、構築済みの建物の輪郭に沿って、道路の次の「レイヤー」を構築します。 したがって、条件に基づいて最初の道路を構築します:たとえば、マップの境界線が左側にあり、右側に空があり、チェック対象の四角形の上下が空であるか、何かがある場合、おそらく道路を構築できます。



このようなもの(緑の正方形-GZ、黄色-道路、黒-任意の建物):







行こう



このゲームは、JSONを使用してhttpのみでクライアントと通信するため、Jmeterの追加のorg.jsonライブラリを使用して、 ポストプロセッサーおよびプリプロセッサーで要求/応答を処理および解析します。



まず、前述したように、ログイン中に、ユーザーセッションを初期化するアクションを実行するときに、必要なすべての初期データを正しく解析して保存する必要があります。 このゲームに関しては、これは私たちの都市が現在どのようなものであるか、リソース、そして後で必要な建物、ユニット、商品のコストに関するすべての必要なメタ情報を見つけて覚えることができる唯一の瞬間です。



後でコードを単純化し、各Javaスレッドのメモリ消費を削減するために、データセット全体から使用するものだけを保存するため、最初に2つの単純な補助クラスEntityとExistEntityを作成して接続する必要があります-最初のものは、原則としてゲームで利用可能なすべての建物を担当します(コスト、サイズ、機能など)、および都市に既に構築されている(座標付き)の2番目。



public class Entity { protected String id; protected String type; protected Integer width; protected Integer length; protected Integer money; protected Integer supplies; protected Integer population; protected String tech_id; protected String demand_for_happiness; protected String provided_happiness; protected String era; ... } public class ExistEntity { protected String id; protected String cityentity_id; //   Entity protected String type; protected Integer x; protected Integer y; … }
      
      





最初のPOSTリクエストStaticData_getDataは、1-2メガバイトの巨大なJSONを返します。 解凍して構造体(HashMapなど)を作成し、IDキーを持つEntityオブジェクトを入力して、このハッシュマップにアクセスして特定の各建物に関する情報を取得します。



 import org.json.JSONArray; import org.json.JSONObject; import com.innogames.jmeter.foe.Entity; JSONArray responseData = new JSONArray(prev.getResponseDataAsString()); Map allBuildings = new HashMap(); //      Map availableBuildings = new HashMap(); // ,    () JSONArray buildings = responseData.getJSONObject("responseData").getJSONArray("buildings"); for (int i = 0; i < buildings.length(); i++) { JSONObject building = buildings.getJSONObject(i); String id = building.getString("id"); String type = building.getString("type"); String name = building.getString("name"); //  : Integer width = (building.has("width")) ? building.getInt("width") : 0; Integer length = (building.has("length")) ? building.getInt("length") : 0; //    : JSONObject requirements = building.getJSONObject("requirements"); Integer money = (requirements.getJSONObject("resources").has("money")) ? requirements.getJSONObject("resources").get("money") : 0; .... //         : String min_era = requirements.getString("min_era"); String tech_id = (requirements.has("tech_id") && (!requirements.isNull("tech_id"))) ? requirements.getString("tech_id") : null; Integer provided_happiness = (building.has("provided_happiness") && (!building.isNull("provided_happiness"))) ? building.getInt("provided_happiness") : 0; //        Entity e = new Entity(id, type, min_era, width, length, money, supplies, population, tech_id, provided_happiness ); allBuildings.put(e.getId(), e); // ,      .... if (e.getEraRank() <= userEraRank && tech_researched == true) { availableBuildings.put(e.getId(), e); } } } //   -   ,     vars.putObject("availableBuildings", availableBuildings); vars.putObject("allBuildings", allBuildings);
      
      





これで、各仮想ユーザーは、建物に関するすべての必要な情報を知っています。 次に、テリトリー、そのサイズ、および都市自体の建物の現在の位置を「記憶」する必要があります。 また、X、Y座標を持つjava.awt.Pointクラスのオブジェクトがキーとして使用され、この座標の建物タイプの名前を持つ文字列値がキーとして使用されるHashMapも使用しました。



都市自体の領域は正方形ではなく、4x4のサイズのオープンエリアのセットで構成されているため、最初に、ユーザーがアクセス可能なすべての座標でこのハッシュマップをゼロで埋めます。 さらに、前のステップのデータを使用する必要があります。なぜなら、 このリクエストから建物の座標のみを取得する場合、建物の幅と高さに基づいて他の座標も「埋める」必要があります。



 import org.json.JSONArray; import org.json.JSONObject; import com.innogames.jmeter.foe.Entity; import com.innogames.jmeter.foe.ExistEntity; import java.awt.Point; Integer maxBuildingId = 0; JSONArray responseData = new JSONArray(prev.getResponseDataAsString()); Map allBuildings = vars.getObject("allBuildings"); Map cityTerritory = new HashMap(); //      //            JSONArray entities = unlocked_areas.getJSONObject("responseData").getJSONArray("unlocked_areas"); for (int i = 0; i < unlocked_areas.length(); i++) { Integer x = (unlocked_areas.getJSONObject(i).has("x")) ? unlocked_areas.getJSONObject(i).getInt("x") : 0; Integer y = (unlocked_areas.getJSONObject(i).has("y")) ? unlocked_areas.getJSONObject(i).getInt("y") : 0; Integer width_ = (unlocked_areas.getJSONObject(i).has("width")) ? unlocked_areas.getJSONObject(i).getInt("width") : 0; Integer length_ = (unlocked_areas.getJSONObject(i).has("length")) ? unlocked_areas.getJSONObject(i).getInt("length") : 0; for (Integer xx = x; xx <= x + width_ - 1; xx++) { for (Integer yy = y; yy <= y + length_ - 1; yy++) { p = new Point(xx, yy); cityTerritory.put(p, "0"); } } } //  ""       JSONArray entities = responseData.getJSONObject("responseData").getJSONArray("buildings"); for (int i = 0; i < entities.length(); i++) { Integer id = entities.getJSONObject(i).getInt("id"); String cityentity_id = entities.getJSONObject(i).getString("cityentity_id"); String type = entities.getJSONObject(i).getString("type"); int x = (entities.getJSONObject(i).has("x")) ? entities.getJSONObject(i).getInt("x") : 0; int y = (entities.getJSONObject(i).has("y")) ? entities.getJSONObject(i).getInt("y") : 0; ExistEntity ee = new ExistEntity(String.valueOf(id), cityentity_id, type, x, y); if (id >= maxBuildingId) maxBuildingId = id; Entity e = allBuildings.get(cityentity_id); for (int xx = x; xx <= x + e.getWidth() - 1; xx++) { for (int yy = y; yy <= y + e.getLength() - 1; yy++) { cityTerritory.put(new Point(xx, yy), e.getType()); } } } //      ,      vars.putObject("cityTerritory", cityTerritory);
      
      





vars.putObject()を使用すると、すべてのスレッド(VU)が必要な情報をすべて把握できるようになり、ゲームが対応するデータを送信する場合、スクリプト実行の各段階でこれらのオブジェクトを時間内に更新するだけです。



建物



これで、仮想都市内のオブジェクトの現在の場所だけでなく、コスト、建物の大きさもわかったので、新しい建物の構築を開始できます。 前に書いたように、最初のステップは、マップアウトラインに沿った道路の最初の「レイヤー」です。これにより、後続のすべての建物がメインの建物と接続されます。



jsr223プリプロセッサをHTTPサンプラーに追加し、リクエストを作成します。 各正方形を並べ替えて、空の正方形と、別のオブジェクト(境界を含む)が占める少なくとも1つの(8つのうちの)正方形を囲む正方形を探します。 したがって、テリトリーの境界を含む高価なオブジェクトを「丸で囲みます」(最適化の余地が十分にあります。より良いアルゴリズムを誰かが教えてくれることを望みます):



 ... Map cityTerritory = vars.getObject("cityTerritory"); Map availableBuildings = vars.getObject("availableBuildings"); Integer maxBuildingId = Integer.valueOf(vars.get("maxBuildingId")); Iterator cityTerritory = map.entrySet().iterator(); //    while (it.hasNext()) Map.Entry entry = (Map.Entry) it.next(); Point key = (Point) entry.getKey(); String value = (String) entry.getValue(); key_x = (int) key.x; key_y = (int) key.y; if (value.equals("0")) { //       ( )   if (map.containsKey(new Point(key_x, key_y - 1))) a = map.get(new Point(key_x, key_y - 1)); else a = "-1"; if (map.containsKey(new Point(key_x - 1, key_y - 1))) b = map.get(new Point(key_x - 1, key_y - 1)); else b = "-1"; if (map.containsKey(new Point(key_x + 1, key_y))) c = map.get(new Point(key_x + 1, key_y)); else c = "-1"; if (map.containsKey(new Point(key_x - 1, key_y))) d = map.get(new Point(key_x - 1, key_y)); else d = "-1"; if (map.containsKey(new Point(key_x, key_y + 1))) e = map.get(new Point(key_x, key_y + 1)); else e = "-1"; if (map.containsKey(new Point(key_x - 1, key_y + 1))) f = map.get(new Point(key_x - 1, key_y + 1)); else f = "-1"; if (map.containsKey(new Point(key_x + 1, key_y - 1))) g = map.get(new Point(key_x + 1, key_y - 1)); else g = "-1"; if (map.containsKey(new Point(key_x + 1, key_y - 1))) h = map.get(new Point(key_x + 1, key_y - 1)); else h = "-1"; //        ( ) if ((!a.equals("0") && !a.equals("street")) || (!b.equals("0") && !b.equals("street")) || (!d.equals("0") && !d.equals("street")) || (!c.equals("0") && !c.equals("street")) || (!e.equals("0") && !e.equals("street")) || (!f.equals("0") && !f.equals("street")) || (!g.equals("0") && !g.equals("street"))) { //      maxBuildingId = maxBuildingId + 1; vars.put("maxBuildingId", String.valueOf(maxBuildingId)); x = String.valueOf(key_x); y = String.valueOf(key_y); ...... } } }
      
      





次に、建物自体を構築する必要があります。 今、私たちはどちらを気にせず、そのサイズだけが私たちにとって重要だと仮定します。 それに対応して、X軸に沿った建物の幅とY軸に沿った建物の高さの距離に自由な正方形があり、建物の隅にある8つの正方形の1つに道路がある架空のマップ上の座標を探します上から下に行く):







また、将来の建物の希望する領域全体にオブジェクト(ツリーなど)がないことを確認する必要があります。



 Iterator it = cityTerritory.entrySet().iterator(); Integer checkSizeW = targetBuilding.getWidth() - 1; Integer checkSizeL = targetBuilding.getLength() - 1; //    while (it.hasNext()) { Map.Entry entry = (Map.Entry) entries.next(); Point key = (Point) entry.getKey(); String value = (String) entry.getValue(); if (value.equals("0")) { //    ,    //   ,       ,       4-   : if ((cityTerritory.containsKey(new Point(key.x - 1, key.y - 1)) && cityTerritory.containsKey(new Point(key.x - 1, key.y)) && cityTerritory.containsKey(new Point(key.x, key.y - 1)) && cityTerritory.containsKey(new Point(key.x - 1, key.y + checkSizeL)) && cityTerritory.containsKey(new Point(key.x + checkSizeW, key.y - 1)) && cityTerritory.containsKey(new Point(key.x + checkSizeW, key.y + checkSizeL))) && (cityTerritory.get(new Point(key.x - 1, key.y)).equals("street") || cityTerritory.get(new Point(key.x, key.y - 1)).equals("street") || cityTerritory.get(new Point(key.x - 1, key.y + checkSizeL)).equals("street") || cityTerritory.get(new Point(key.x + checkSizeW, key.y - 1)).equals("street")) ) { boolean isFree = true; //  ,            : for (int W = 0; W <= checkSizeW; W++) { for (int L = 0; L <= checkSizeL; L++) { if (!map.containsKey(new Point(key.x + W, key.y + L))) { sFree = false; } else { if (!map.get(new Point(key.x + W, key.y + L)).equals("0")) { isFree = false; } } } } if (isFree) { //   } } } } }
      
      





Jmeterテスト計画の最高レベルでは、リソースの変更を追跡し、仮想マップを新しい建物で更新する必要があるため、ゲームからのすべての着信応答に応答し、オブジェクトを更新するポストプロセッサーを追加します。



 JSONArray responseData = new JSONArray(response); for (int m = 0; m < responseData.length(); m++) { //       : if (responseData.getJSONObject(m).getString("requestClass").equals("CityMapService")) { JSONArray city_map_entities = responseData.getJSONObject(m).getJSONArray("responseData"); for (int i = 0; i < city_map_entities.length(); i++) { JSONObject city_map_entity = city_map_entitis.get(i); if (city_map_entity.toString().contains("CityMapEntity")) { Integer id = city_map_entity..getInt("id"); String cityentity_id = city_map_entity..getString("cityentity_id"); String type = city_map_entity..getString("type"); Integer x = (city_map_entity..has("x")) ? city_map_entity..getInt("x") : 0; Integer y = (city_map_entity..has("y")) ? city_map_entity..getInt("y") : 0; Entity e = availableBuildings.get(cityentity_id); if (id >= maxBuildingId) maxBuildingId = id; for (int xx = x; xx <= x + e.getWidth() - 1; xx++) { for (int yy = y; yy <= y + e.getLength() - 1; yy++) { cityTerritory.put(new Point(xx, yy), e.getType()); } } } } //        else if (responseData.getJSONObject(m).getString("requestClass").equals("ResourceService") && responseData.getJSONObject(m).getString("requestMethod").equals("getPlayerResources")) { JSONObject resources = responseData.getJSONObject(m).getJSONObject("responseData").getJSONObject("resources"); vars.putObject("resources", resources); Integer money = (resources.has("money")) ? resources.getInt("money") : 0; Integer supplies = (resources.has("supplies")) ? resources.getInt("supplies") : 0; Integer population = (resources.has("population")) ? resources.getInt("population") : 0; Integer strategy_points = (resources.has("strategy_points")) ? resources.getInt("strategy_points") : 0; vars.put("money", String.valueOf(money)); vars.put("supplies", String.valueOf(supplies)); vars.put("population", String.valueOf(population)); vars.put("strategy_points", String.valueOf(strategy_points)); } }
      
      





まとめ



その結果、12時間の負荷テストを1回行った後、主要な建物に接続されたさまざまな建物がある実際に建てられた都市を見ることができます。





ご清聴ありがとうございました。私はすべてを山積みにせず、トピックをいくつかの部分に分割することにしました。 次の部分は同じ問題を解決することに専念しますが、より厳しい条件では、ゲームクライアントがprotobufでHTTPプロトコルを使用し、STOMPでWebソケットを介して更新を受信します。



githubへのリンクを残します。何か面白いものが見つかるかもしれません。



皆さんと関連するテストに幸運を。



All Articles