Ребаланс живой игры: как я дважды сломал старых игроков и почему тест был зелёным
В субботу мне написал игрок моей idle-игры про башню. Коротко: «Спасибо за обновление, но теперь у меня миллионы HP и нет урона, не могу убить ни одного врага».
Самое неприятное в этом письме — слово «обновление». За два дня до этого тот же игрок жаловался на противоположное: после большого ребаланса его прокачанная башня умирала на первой волне. Я выпустил исправление, написал тест, тест прошёл. И исправление сломало игру ещё раз, только в другую сторону.
Это разбор того, как так получилось. Не про формулы баланса — про то, как тестировать изменение цифр в игре, где у людей уже есть прогресс.
Ребаланс — это миграция данных, а не правка конфига
В версии 0.10.0 я переделал прогрессию по образцу референсной игры. Враги на высоких уровнях сложности (тирах) стали намного сильнее. На тире 10 здоровье врагов выросло с множителя ×2800 до ×40320 — в 14 раз. Атака — примерно в 5 раз.
Для нового игрока это нормально: он пройдёт путь с нуля и прокачается под новые цифры. Но у старого игрока уже открыт тир 10. Его башня прокачана под старые цифры. Сейв никто не трогал, а мир вокруг него стал в 14 раз толще.
Вот главный вывод, который я тогда пропустил: ребаланс меняет смысл уже сохранённых данных. Уровень прокачки «урон 75» в сейве раньше значил «хватает на тир 10», а теперь значит «не хватает даже на тир 5». Это такая же миграция, как переименование колонки в базе. Просто её не видно в коде: код сейва не менялся ни на строчку.
Поэтому вопрос для QA при любом ребалансе не «правильные ли новые цифры», а «что станет с людьми, которые пришли со старыми».
Первая поломка: башня умирает на первой волне
Первый симптом был очевидным. Игрок с тиром 10 запускает забег и умирает за секунды. Враги бьют в 5 раз сильнее, а здоровья у башни столько же.
Я решил вернуть таким сейвам «броню тира» — множитель, который в старом балансе увеличивал здоровье, реген и блок башни на высоких тирах. Сейв помечается как «старый», если тиры были открыты до 0.10.0, и для него броня включается обратно.
Написал тест: берём настоящий сейв с тиром 10 из старой версии, запускаем симуляцию боя, проверяем, что башня дожила до 20-й волны. Раньше умирала на первой — теперь доживает. Зелёный. Релиз.
Вторая поломка: миллионы HP и ни одного убийства
Броня работает на тире 10 примерно как ×2200 к здоровью. Башня действительно перестала умирать. Но броня не трогает урон. А здоровье врагов выросло в 14 раз.
В итоге получилась башня, которая стоит с огромным запасом здоровья и почти никого не может убить. Враги не умирают, приходят новые, их становится всё больше. Именно это игрок и описал: «миллионы HP, но нет урона».
Почему тест это не поймал? Потому что номер волны в моей игре растёт по таймеру. Волна идёт фиксированное время, потом начинается следующая — независимо от того, убил ты кого-то или нет. Тест «дожить до волны 20» проверял, что башня не умерла за 20 отрезков времени. Он не проверял, что она играет.
Когда я переписал проверку на убийства, картина стала очевидной:
- Версия 0.10.1 (только броня): к 10-й волне на тире 10 на поле 106 живых врагов, к 30-й — 200, это лимит игры. За 62 волны башня убила 311 врагов.
- Тот же сейв на тире 1: к 10-й волне живых врагов 0, за 115 волн — 4166 убийств.
Тест измерял прокси — «волна выросла» — и считал его равным результату — «игрок нормально играет». В нормальной игре эти вещи совпадают. В сломанной — расходятся, и именно там тест и нужен.
Как я тестировал второй раз
Во второй раз я начал не с фикса, а с таблицы. Взял тот же старый сейв и прогнал симуляцию на каждом тире с 1 по 10, в двух режимах: с покупкой улучшений во время забега и без. Для каждого прогона записывал не номер волны, а:
- сколько врагов убито к 3-й, 10-й, 30-й волне;
- сколько врагов живо на поле в эти же моменты — это и есть затор;
- на какой волне и почему забег закончился.
И сравнил четыре варианта фикса:
- Только броня (как в 0.10.1). Башня не умирает, но враги копятся. Затор — с 10-й волны.
- Старые статы врагов на тирах, без брони. Враги снова пробиваемые, но башня умирает на тирах 8–10 к 10–15-й волне: в новом балансе изменились ещё и кривые волн.
- Старые статы + слабая броня. Чуть лучше, но тиры 7–10 всё равно умирают рано.
- Старые статы + прежняя броня. К 10-й волне на всех тирах 0 живых врагов. Затор появляется только к 30–50-й волне на верхних тирах — это естественный потолок прокачки, а не баг.
Выбор стал очевиден, только когда я видел все четыре варианта рядом и по метрике, которая отражает то, что чувствует игрок.
Ещё два момента, которые нашлись по дороге:
- Броня применялась и в турнирах. Турнир — соревнование, там старый игрок не должен получать бонус, которого нет у новых. Фикс включается только в обычных забегах.
- Конфиг может приехать с сервера. Баланс у меня частично управляется через Remote Config. Если старые значения тиров хранить в том же конфиге, сервер может их перезаписать. Исторические значения я зашил в код, а включение режима оставил флагом, который можно выключить удалённо.
Что я теперь делаю при любом ребалансе
Собираю когорты сейвов. Не «один старый сейв», а набор: новый игрок, средний на старом балансе, топовый на старом балансе, игрок, который открыл тиры уже на новом балансе. Сейвы берутся из реальных версий, а не собираются руками. Руками легко собрать сейв, который удобен тесту.
Проверяю исход, а не прокси. Для боевой игры исход — это «убивает ли башня то, что на неё идёт» и «когда и почему заканчивается забег». Номер волны, время забега, «не упало» — прокси. Хороший вопрос к любому тесту: может ли он пройти, если игроку плохо?
Проверяю обе стороны. Тест «башня не умирает» ловит только «слишком сложно». Нужен и тест на «слишком легко»: на верхнем тире забег не должен быть бесконечным. Иначе следующий фикс переборщит и тест снова будет зелёным.
Пишу тест на симптом из жалобы. У меня теперь есть тест, который включает конфигурацию 0.10.1 и проверяет, что на 10-й волне больше 50 живых врагов. То есть он воспроизводит баг. Если кто-то вернёт фикс назад, этот тест скажет, что баг вернулся.
Ограничиваю зону фикса. Режим совместимости для старых игроков не должен утекать туда, где важна честность: турниры, рейтинги, события с наградами.
Даю игроку способ прислать ID. Во второй раз я смог проверить конкретную ситуацию быстро, потому что в предыдущем фиксе добавил ID игрока в настройки и кнопку поддержки с готовым письмом. Без этого «не могу убить ни одного врага» пришлось бы расшифровывать перепиской.
Чек-лист: ребаланс в живой игре
- Перечислить, какие сохранённые значения меняют смысл после ребаланса: уровни прокачки, открытые уровни сложности, валюта, рекорды.
- Собрать сейвы из реальных старых версий: новичок, середина, топ, игрок, начавший на новом балансе.
- Решить для каждой когорты: мигрировать, компенсировать или оставить как есть — и записать почему.
- Для каждой когорты прогнать все затронутые уровни сложности, с покупками во время забега и без.
- Мерить исход: убийства, затор врагов, время и причину конца забега — не номер волны и не «не упало».
- Добавить тест на «слишком легко»: забег не бесконечный, награды не ломают экономику.
- Добавить тест, воспроизводящий жалобу игрока на старой конфигурации.
- Проверить, что режим совместимости не включается в турнирах, рейтингах и ивентах.
- Проверить, что Remote Config не перезапишет исторические значения.
- Флаг миграции хранить в сейве и ставить один раз — чтобы следующий запуск не пересчитал игрока заново.
- Перед выкладкой сыграть руками хотя бы один старый сейв на верхнем уровне сложности.
Про то, как вообще строить симулятор для проверки баланса, я писал в статье про баланс и симулятор. Про то, почему обновление — это новый код со старыми данными, — в статье про тестирование обновлений. Эта история — место, где обе темы встретились.
Тест, который проходит, когда игроку плохо, хуже, чем отсутствие теста. Без теста ты хотя бы знаешь, что не проверял.