📜 ⬆️ ⬇️

Development of load scripts for browser / mobile games. Part 1

Hi, Habr. In the last article I talked about automating the process of load testing in the game company where I work. Now it's time to stop at some specific tasks that we had to face in preparation for the testing process of the games themselves.

There is a big difference between testing different banking / retail processes and games. In the first case, users perform their tasks almost in isolation from each other and use only those data and elements that they see in the window of their browsers or other clients at the moment, which facilitates the development of load scripts. In games, users (players) are in a dynamically changing world and are often influenced by each other. In my imagination, the difference looks like this:



')
That is, in the first case, users, through a series of similar actions, arrive at the final result and go to the next round. The game is a random chaos in the center of which is the game world, on which players constantly influence, change the in-game data, exerting a direct influence both on themselves and on other players. Also, players can chat, unite in guilds and hack PvP.

Thus, when developing load scripts, one has to reckon with a variety of conditions, dynamic data, and so on. It seems to me that people who create bots for different online games should be engaged in something similar in order to automate certain tasks of the same type. But in our tests we try to implement all the game activities.

Relevant data problem


When developing scripts for emulating banking business processes, scripts (usually)
rely on data that they “see” at a specific stage (on a web page for example). That is, to proceed to the next step, it is only necessary to pack the previously prepared (or taken from the same page) data into the right places and send them.

One of the main problems in the development of scripts for the game is the complexity of tracking (tracking) changes that occurred before this particular moment, before executing the command. Information about the changed state of objects, resources, units, etc. can come at any time, even after performing non-specific actions. If you skip this update, the virtual user (VU, thread in Jmeter) will be out of sync with the game and start generating errors ala “not enough resources” or “no place on the map” and turn the load test into something useless. Of course, there is always the likelihood that the script will still produce something like “you can’t attack an ally” if it became such a second ago, but the same will happen in a real client.

Also complicating life is the fact that almost always all the source data and the current state of affairs in the game world come to the client only when logged into the game (usually a huge JSON of several megabytes) and then the client proceeds from these initial data and passing changes in the relevant state, that is, he knows about the current state of affairs. The same should be implemented in the script, it is necessary that each VU “remembers” that the game sends at the login stage and then accurately transfer and modify this data during the test. The following is an example of how I solved the problem with one of the games of the company InnoGames.

Forge of Empires


(I hope this will not be reckoned for advertising, I need to describe the essence of the problem and the solution, but I cannot without a brief description of the game itself)

This is a town-planning simulator in which a player starts from the Stone Age and gradually, developing technologies, conquering provinces, fighting with other players, promotes and expands his city to ... endgame, which is very far away.

A newly registered player after login sees something like the following: an empty field and one main large building (GZ), a couple of trees and roads on it:
image

The unoccupied field itself and the objects are divided into squares, depending on the size you have to reckon with the size of the building itself and the free space on the map. Buildings are divided into types: residential, industrial, military, cultural, roads, and more. Different buildings produce different resources: residential - people and money, production - goods and resources, cultural - happiness and so on. When building each building, it is necessary to take into account the same resources, and if they are not enough, you must either wait, or, for example, build a new house to fill the population. Feel where I am getting at? These are not accounting entries to emulate :)

Building


In the town-planning simulator, the main business process (let's call it that) is the construction itself. This is the first and main problem when creating scripts for games of this kind. The problem of building a building is divided into several subtasks that need to be addressed simultaneously:

  1. Understand the size of the building and find free time on the map under it
  2. (Pre-verify available resources)
  3. A new building must be connected to the main one through the road, otherwise it will be useless.
  4. It is necessary to diversify the load, that is, we do not have to shove the same building every time, but build diverse ones in order to produce different resources, including units.

Point number 3 especially scared me, the need to use some complex algorithms came to my head, which is particularly unrealistic in terms of testing with Jmeter and several thousand VU. It is necessary to use as simple as possible algorithms and structures, otherwise the question about the hardware of the load generators themselves will be a stake.

After a few hours of thinking, the idea of ​​a simple algorithm came up; I called it “construction by layers”. Its essence is as follows. As you can see in the screenshot above, the GZ is pressed to the edge of the map beyond which you cannot build anything and it played into the hands. Each VU after login first of all builds a road along the contour of the map and the main building, and only then builds the necessary buildings, along this road, while there is space. Thus, all buildings built along the road will be connected to the GZ. Next, we build the next “layer” of the road along the contour of the constructed buildings. Thus, we build the original road based on the condition: for example, if the border of the map is to the left, empty to the right, and something is above or below the checked square, then we can probably build a road.

Something like this (green square - GZ, yellow - road, black - any building):



Go


Since this game communicates with the client exclusively via http with JSON, I use the additional org.json library in Jmeter to work and parse requests / responses in the post and pre processors .

First of all, as I mentioned above, you need to correctly parse and save all the necessary initial data during login, when performing actions that initialize the user session. Regarding this game - this is the only time when we can find out and remember how our city looks at the moment, our resources, as well as all the necessary meta-information about the cost of buildings, units, goods that we need afterwards.

To simplify the code afterwards and reduce memory consumption by each java-thread, we save only the ones we use from the entire data set, so we first need to create and connect two simple auxiliary classes Entity and ExistEntity - the first is responsible for any building available in the game in principle ( with cost, size, functions and other), and the second for the already built in the city (with coordinates).

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; … } 

The first POST request, StaticData_getData, returns a huge JSON weighing 1-2 megabytes. Let's parse it, create a structure, for example, HashMap and fill it with Entity objects with id keys, to subsequently refer to this hash map for information about each specific building:

 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); 

Now every virtual user knows all the necessary information about the buildings. Next, you need to "remember" the territory, its size and current location of buildings in the city itself. I also used HashMap, which uses java.awt.Point class objects with X, Y coordinates as keys and String values ​​with the name of the building type in this coordinate.

The territory of the city itself is not a square, but consists of a set of open areas, 4x4 in size, so initially we fill this hash map with zeros in all coordinates that are open and accessible to the user. In addition, we need to use the data from the previous step, since we only get the building coordinates from this query, must also “fill in” other coordinates, based on the width and height of the building.

 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); 

With the help of vars.putObject () now each thread (VU) will know all the necessary information, it remains only to update these objects in time at each stage of the script if the game sends the corresponding data.

Build


Now, knowing the cost, size of buildings, as well as the current location of objects in the virtual city, you can begin to build new buildings. The first step, as I wrote earlier, is the first “layer” of the road along the contour of the map so that all subsequent buildings have a connection with the main one.

Add the Sampler jsr223 pre-processor to the HTTP and form the request. We go through each square, look for an empty one and the one surrounded by at least one (out of 8) occupied by another object (including the border) is a square. Thus, we will “circle” any expensive object, including the border of the territory (there is a lot of room for optimizations, I hope someone will suggest a better algorithm):

 ... 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); ...... } } } 

Next we need to build the building itself. Suppose now it does not matter what we are, only its size is important to us. Correspondingly, we search for an coordinate on an imaginary map from which there are empty squares at a distance of the width of the building along the X axis and the height of the building along the Y axis, and there is a road in one of the eight squares in the corners of the building (I really check the top 4, thus filling the city goes from top to bottom):



It is also necessary to make sure that throughout the desired territory of the future building there will not be any object (wood for example)

 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) { //   } } } } } 

At the very top level of the Jmeter test plan, we add a Post-processor that will respond to each incoming response from the game, parse it and update the objects, since we need to track resource changes and also update the virtual map with new buildings:

 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)); } } 

Total


After a total of one 12-hour load test, you can see a really built city with various buildings that are connected to the main building, which means that they are functioning quite well:


Thank you for your attention, I decided not to dump everything in a pile and break the topic into several parts. The next part will be devoted to solving the same problem, but in more severe conditions, when the game client uses the HTTP protocol with protobuf, and receives updates via a web socket with STOMP.

Leave a link to our githab , can find something interesting.

Good luck and relevant tests.

Source: https://habr.com/ru/post/354348/


All Articles