Привет. В прошлой статье я рассказал об автоматизации процесса нагрузочного тестирования в игровой компании, в которой я работаю. Теперь пришло время остановится на некоторых конкретных задачах, с которыми пришлось столкнуться в ходе подготовки к процессу тестирования самих игр.
Есть большая разница между тестированием разных банковских/retail-процессов и игр. В первом случае пользователи выполняют их задачи почти изолированно друг от друга и используют только те данные и элементы, которые видят в окне своих браузеров или других клиентов в данный момент, что облегчает разработку нагрузочных скриптов. В играх же пользователи (игроки) находятся в динамично меняющемся мире и часто подвержены влиянию друг друга. В моем воображении разница выглядит примерно так:
То есть в первом случае пользователи через череду однотипных действий приходят к конечному результату и уходят на следующий круг. Игра же — это рандомный хаос в центре которого находится игровой мир, на который игроки постоянно оказывают воздействие, меняют внутриигровые данные, оказывая непосредственное влияние как на себя, так и на других игроков. Также игроки могут общаться, объединяться в гильдии и рубиться PvP.
Таким образом, при разработке нагрузочных скриптов приходится считаться со множеством условий, динамическими данными и прочим. Мне кажется, чем-то подобным должны заниматься люди которые создают ботов для разных онлайн-игр, чтобы автоматизировать некоторые однотипные задачи. Но в своих тестах мы стараемся реализовать все игровые активности.
Проблема релевантных данных
При разработке скриптов для эмуляции банковских бизнес-процессов скрипты (обычно)
опираются на данные, которые “видят” на конкретном этапе (на веб-странице например). То есть, чтобы перейти на следующий шаг необходимо лишь расфасовать в нужные места заранее подготовленные (или взятые из этой же страницы) данные и отправить их.
Одна из главных проблем при разработке скриптов для игры — это сложность самого слежение (трекинга) изменений, которые произошли до данного конкретного момента, перед выполнением команды. Информация об изменённом состоянии объектов, ресурсов, юнитов и пр. может прийти в любой момент, даже после выполнения не связанных с конкретными данными действий. Если пропустить данное обновление, то виртуальный пользователь (VU, тред в Jmeter) будет рассинхронизован с игрой и начнет генерить ошибки аля “недостаточно ресурсов” или «нет места на карте» и превращать нагрузочный тест в нечто бесполезное. Конечно всегда есть вероятность того, что всё же скрипт выдаст что-то типа “нельзя атаковать союзника”, если он секунду назад стал таковым, но то же самое произойдет и в настоящем клиенте.
Также осложняет жизнь то, что почти всегда все исходные данные и текущее положение дел в игровом мире приходят в клиент только при логине в игру (обычно это огромный JSON на несколько мегабайт) и далее по ходу игры клиент исходя из этих первоначальных данных и преходящих изменений находится в релевантном состоянии, то есть знает о текущем положении дел. То же самое необходимо реализовать и в скрипте, надо чтобы каждый VU “запомнил” что игра присылает на стадии логина и далее аккуратно передавать и изменять эти данные в ходе выполнения теста. Далее приведу пример как я решал проблему с одной из игр компании InnoGames.
Forge of Empires
(надеюсь это не зачтется за рекламу, нужно описать суть проблемы и решения, но не могу без краткого описания самой игры)
Это градостроительный симулятор в котором игрок начинает с эпохи каменного века и постепенно, развивая технологии, завоевывая провинции, воюя с другими игроками, продвигает и расширяет свой город до… эндгейма, который очень далеко.
Вновь зарегистрированный игрок после логина видит примерно следующее: пустое поле и одно главное большое здание (ГЗ), пару деревьев и дорог на нем:
Само незанятое поле и объекты разбиты на квадраты, в зависимости от размера приходится считаться с размером самого здания и свободного места на карте. Здания делятся на типы: жилые, производственные, военные, культурные, дороги и прочее. Разные здания производят разные ресурсы: жилые — население и деньги, производственные — товары и ресурсы, культурные — счастье и так далее. При постройке каждого здания необходимо учитывать те же самые ресурсы и если их не хватает нужно либо ждать, либо, например, построить новый дом, чтобы восполнить население. Чувствуете куда я клоню? Это не бухгалтерские проводки эмулировать :)
Строительство
В градостроительном симуляторе главным бизнес-процессом (назовем это так) является собственно само строительство. Это первая и главная проблема при создании скриптов для игр такого рода. Проблема постройки здания делится на несколько подзадач, которые необходимо решать одновременно:
- Понять размер здания и найти свободное время на карте под него
- (Предварительно сверить доступные ресурсы)
- Новое здание должно быть соединено с главным посредством дороги, иначе оно будет бесполезным
- Необходимо разнообразить нагрузку, то есть мы не должны пихать одно и то же здание каждые раз, а строить разнообразные, чтобы производить разные ресурсы, включая юнитов.
Пункт номер 3 меня особенно пугал, в голову лезла необходимость использовать какие-нибудь сложные алгоритмы, что в условиях реализации тестирования при помощи Jmeter и нескольких тысяч VU особенно нереально. Необходимо использовать как можно более простые алгоритмы и структуры, иначе вопрос о железе самих генераторов нагрузки встанет колом.
После нескольких часов раздумий пришла идея простого алгоритма, я назвал его “строительство слоями”. Суть его в следующем. Как вы могли заметить на скриншоте сверху, ГЗ прижато к краю карты за которым уже ничего строить нельзя и это сыграло на руку. Каждый VU после логина первым делом строит дорогу по контуру карты и самого главного здания, а уже потом строит необходимые здания, возле этой дороги, пока есть место. Таким образом все здания построенные у дороги будут связаны с ГЗ. Далее мы строим следующий “слой” дороги уже по контуру построенный зданий. Таким образом, первоначальную дорогу мы строим, исходя из условия: например если слева граница карты, справа — пусто, а сверху и снизу от проверяемого квадрата пусто или что-то есть, то мы вероятно можем построить дорогу.
Примерно так (зеленый квадрат — ГЗ, жёлтый — дорога, черный — какое-либо здание):
Поехали
Так как данная игра общается с клиентом исключительно посредством http с JSON`ами, я использую в Jmeter дополнительную библиотеку org.json для работы и парсинга запросов/ответов в post- и pre- процессорах.
Первым делом, как я уже упоминал выше, необходимо в ходе логина, при выполнении инициализирующих пользовательскую сессию действий, правильно распарсить и сохранить все необходимые начальные данные. Касательно данной игры — это единственный момент, когда мы можем узнать и запомнить как выглядит наш город в данный момент, наши ресурсы, а также всю необходимую мета-информацию о стоимости зданий, юнитов, товаров, которая нам необходима впоследствии.
Для упрощения кода впоследствии и уменьшения потребляемой памяти каждым java-тредом мы сохраняем из всего набора данных только те, которые будем использовать, поэтому предварительно необходимо создать и подключить два простеньких вспомогательных класса Entity и ExistEntity — первый отвечает за любое доступное в игре здание в принципе (со стоимостью, размером, функциями и прочим), а второй за уже построенное в городе (с координатами).
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 возвращает огромный JSON весом в 1-2 мегабайта. Распарсим его, создадим структуру, например HashMap и заполним её объектами Entity с ключами id, чтобы впоследствии обращаться к этому хэш-мапу за информацией о каждом конкретном здании:
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);
Теперь каждый виртуальный пользователь знает всю необходимую информацию о зданиях. Далее необходимо “запомнить” территорию, её размеры и текущее расположение зданий в самом городе. Я использовал также HashMap, в котором в качестве ключей используются объекты класса java.awt.Point с координатами X, Y, а в качестве значений String с названием типа здания в данной координате.
Сама территория города не является квадратом а состоит из набора открытых областей, размером 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) будет знать всю необходимую информацию, остаётся только вовремя обновлять данные объекты на каждом этапе выполнения скрипта, если игра присылает соответствующие данные.
Строим
Теперь зная стоимость, размеры зданий, а также текущее расположение объектов на территории виртуального города, можно начинать строить новые здания. Первым шагом, как я писал раннее, является первый “слой” дороги по контуру карты, чтобы все последующие здания имели связь с главным.
Добавляем в HTTP Sampler jsr223 pre-processor и формируем запрос. Перебираем каждую квадратик, ищем пустой и тот, которого окружает хоть один (из 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 находятся свободные квадраты, а также в одной из восьми квадратов по углам здания есть дорога (я правда проверяю 4 верхних, таким образом заполнение города идет сверху вниз):
Также необходимо удостовериться, что на всей желаемой территории будущего здания не окажется какого либо объекта (дерева например):
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 добавляем Post-processor который, будет реагировать на каждый входящий респонс от игры, парсить его и обновлять объекты, так как нам необходимо трекать изменение ресурсов, а также обновлять виртуальную карту новыми зданиями:
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-часового нагрузочного теста можно увидеть реально построенный город с различными зданиями, которые соединены с главным зданием, а значит вполне себе неплохо функционируют:
Спасибо за внимание, я решил не сваливать всё в кучу и разбить тему на несколько частей. Следующая часть будет посвящена решению той же самой проблемы, но в более жёстких условиях, когда клиент игры использует HTTP-протокол с protobuf, а обновления получает через web-сокет с STOMP.
Оставлю ссылку на наш гитхаб, может найдете что-то интересное.
Всем удачи и релевантных тестов.
Автор: Герман Сёмин