- BrainTools - https://www.braintools.ru -
Два года по вечерам я в одиночку пилю текстовую MMORPG в Telegram. Мрачный остров, по нему ходят выжившие, добывают, строят базы, дерутся. В прошлый раз [1] я рассказывал, как чинил воронку первой сессии и понял, что лил воду в дырявое ведро. А сегодня про другое. Про кнопку, которая это ведро опрокидывает целиком.
Сейчас бота запустили 1111 человек, 586 завели выжившего, регулярно заходит примерно полторы сотни. Кто-то дорос до 214-го уровня, но средний по острову всего 4,4, потому что большинство отваливается в первые же дни. Как раз этот застой вайп и лечит. Между ними, кстати, отгремело 24 033 боя. И вот в админке лежит кнопка, которая сбросит весь этот прогресс до нуля. Сезонный вайп, как в Rust. Я собрал её месяц назад, прогнал на тестовом сервере и на проде так и не нажал.
Расскажу, почему «стереть всех» оказалось совсем не тем, чем я думал. И почему самой сложной частью был не сам вайп, а тест, который не даёт мне про него забыть.
Звучит дико. Ты два года собираешь людей, а потом хочешь обнулить им прогресс. Но для живого выживания это нормальная механика. Мир застаивается. Ранние игроки сидят на горах ресурсов, новичку в такой мир заходить тяжело, разрыв догонять больно. Сезонный вайп всех уравнивает. Новый старт, чистый остров, снова гонка за первую базу.
Плюс у меня накопился ребаланс, который на старом сервере честно не включить. Тронешь цены, и те, кто уже сидит на горах ресурсов, получат ещё и фору. Такое можно раскатывать только на чистом острове. Так что вопрос был не «стирать или нет», а «как нажать эту кнопку и не проснуться потом в холодном поту».
Важная оговорка. На проде я вайп ещё не запускал. Механика собрана, проверена на тесте, стоит на предохранителе и ждёт моего решения. Стереть прогресс живым людям это отдельный тяжёлый шаг, и к нему я пока не готов. Но инженерная часть готова полностью, и вот она интересная.
Именно так я и думал. Есть таблицы игроков, чистим их, готово. У меня даже был старый конфиг для сброса, остался с давних времён. Плоский список таблиц, пробегись и очисти.
Когда я открыл его свежим взглядом, мне стало нехорошо.
В списке было 15 таблиц. А в базе на тот момент их было 71. То есть около полусотни таблиц вообще не были расписаны. Большая часть из них это мир и контент, их и трогать не надо. Но примерно восемнадцать штук с прогрессом игроков остались бы висеть сиротами, привязанными к сброшенным персонажам. Инвентарь, постройки, туман войны, квесты, ставки, боевые логи.
Но добил меня не масштаб. Добила одна строчка.
В старом конфиге для таблицы characters стояло правило вида characters => id. По смыслу это читалось как «сбрось персонажа по ключу id». А работало как «удали строку персонажа».
То есть при сбросе я бы не обнулил прогресс выжившего, а физически стёр саму строку. Вместе с ней исчезли бы имя, id, дата регистрации, привязка к Telegram-аккаунту. Человек, заходя после вайпа, оказался бы не в начале пути, а в пустоте. Как будто его никогда не было.
А ведь канон у меня обратный. Имена и айдишки игроков переживают вайп. Как в Rust. Сервер стирается, но твой аккаунт и ник остаются твоими.
Старый код удалял строку персонажа вместо сброса. Игрок вернулся бы не в начало пути, а вот сюда. От него осталась бы только куртка на берегу.
Стало ясно, что задача вообще не про удаление. Она про решение, что делать с каждой таблицей по отдельности. А их у меня только прибавлялось.
Я сел и разложил все таблицы на группы по тому, что с ними должно случиться при вайпе. Получилось шесть стратегий.
KEEP. Не трогаем вообще. Это мир и карта (миллион клеток острова никуда не девается), определения контента (рецепты, оружие, постройки, квесты), настройки баланса и идентичность игроков.
PLAYER_DATA. Прогресс игрока. Удаляем полностью. Инвентарь, базы, исследованные клетки, логи боёв.
TRANSIENT. Временная населённость мира. Спавны NPC, караваны, активные события. Удаляем, крон наполнит заново за пару тиков.
CHARACTER_RESET. Только таблица characters. Не удаляем, а откатываем прогресс к стартовому. Уровень 1, стартовое золото, респавн на безопасный юг. Имя, id и настройки остаются.
IDENTITY_RESET. Только telegram_users. Идентичность целая, чистим лишь протухшие указатели на удалённые сообщения.
SEED_RESET. Экономика и эндгейм-агрегаты. Строки остаются, счётчики обнуляются.
Всё это я сложил в один файл-манифест, где у каждой таблицы ровно одна запись с её стратегией. Единственный источник правды.
// Мир остаётся как есть
'map' => ['strategy' => self::KEEP, 'note' => 'Карта мира (~997k клеток)'],
'weapons' => ['strategy' => self::KEEP, 'note' => 'Определения оружия'],
// Прогресс игрока чистим полностью
'character_resources' => ['strategy' => self::PLAYER_DATA, 'link' => 'id_characters', 'by' => 'character'],
'explored_cells' => ['strategy' => self::PLAYER_DATA, 'link' => 'character_id', 'by' => 'character'],
// Сам персонаж: сброс, а не удаление
'characters' => ['strategy' => self::CHARACTER_RESET, 'note' => 'Сброс к стартовым, id+имя сохранены'],
// Рынок: строки на месте, счётчики в ноль
'resources_bank' => [
'strategy' => self::SEED_RESET,
'reset' => ['current_quantity' => 0, 'resources_purchased' => 0, 'resources_sold' => 0],
],
Поле link нужно не для полного вайпа, а для сброса одного игрока. Тот же манифест обслуживает и «стереть всех», и «сбросить конкретного персонажа», по одной и той же карте таблиц. Никаких двух разных списков, которые разъедутся через месяц.
На сегодня в манифесте 99 таблиц. Сорок пять KEEP, сорок PLAYER_DATA, девять TRANSIENT, три SEED_RESET и по одной на сброс персонажа и аккаунта.
Я разложил 99 таблиц. Отлично. Но игра живёт. Каждую неделю я добавляю фичи, а с ними новые таблицы. Реферальная система, ставки в оракуле, боссы, коллекции. Каждая тянет за собой одну-две таблицы.
И вот я представил очевидное. Проходит два месяца. Я в полночь добавляю таблицу под какую-нибудь сотую механику, радуюсь, что фича работает, и иду спать. Про манифест вайпа я в этот момент не думаю вообще. Он где-то в другом конце проекта.
Ещё через полгода я жму кнопку вайпа. И новая таблица в сброс не попадает, потому что я про неё забыл. Тихо. Без ошибки [2]. Просто у части игроков останется висеть кусок старого прогресса, привязанный к уже чужому персонажу.
Держать 99 таблиц в голове невозможно. Значит, за меня это должна помнить машина.
Я написал тест, который делает ровно одну вещь. Он открывает все файлы миграций, вытаскивает оттуда каждый createTable('...') и проверяет, что эта таблица есть в манифесте. Если создаётся таблица, которую я не классифицировал, тест краснеет. А красный тест у меня блокирует деплой.
Тест на страже деплоя. Добавил таблицу, забыл её расписать в манифесте — дальше не пройдёшь, как ни проси.
public function testEveryCreatedTableIsClassified(): void
{
$created = $this->tablesCreatedByMigrations(); // разбор всех миграций
$manifest = (new WipeManifest())->tables;
$missing = [];
foreach ($created as $table) {
if (! array_key_exists($table, $manifest)) {
$missing[] = $table;
}
}
$this->assertSame([], $missing,
"Таблицы создаются миграцией, но не классифицированы в WipeManifest: "
. implode(', ', $missing)
. ". Добавь каждую в манифест, иначе при вайпе она будет упущена."
);
}
Сам разбор миграций тоже без изысков. Регуляркой по исходникам:
private function tablesCreatedByMigrations(): array
{
$tables = [];
foreach (glob(APPPATH . 'Database/Migrations/*.php') as $file) {
$src = file_get_contents($file);
if (preg_match_all('/createTable(s*['"]([A-Za-z0-9_]+)['"]/', $src, $m)) {
foreach ($m[1] as $name) {
$tables[$name] = true;
}
}
}
return array_keys($tables);
}
Специально без обращения к живой базе. Тест читает только исходники, поэтому зелёный или красный одинаково в любой среде, хоть в CI без базы вообще.
Дальше я довёл это до правила. Любое изменение схемы и любая новая таблица обязаны сопровождаться записью в манифесте. Не «надо не забыть», а «забыть физически не даст сборка». Я даже повесил хук, который при редактировании папки с миграциями напоминает мне об этом прямо в момент работы.
Получилась приятная штука. Мне больше не нужно быть внимательным. Внимательность вынесена в тест. Соло-разработчик и не может быть постоянно внимательным на дистанции в два года, у него просто нет второго человека на ревью. Значит, ревьюером работает CI.
Рядом с тестом живёт ещё один сторож, уже в рантайме. Перед самим вайпом сервис сверяет живую схему базы с манифестом. Если в базе вдруг есть таблица, которой в манифесте нет, вайп не начинается вообще. Лучше упасть с понятной ошибкой, чем снести половину и оставить половину.
Кнопка деструктивная, поэтому вокруг неё несколько слоёв защиты.
Сначала всегда сухой прогон. Он показывает, сколько строк в какой таблице удалится и сколько персонажей сбросится, но не трогает ничего. Только после этого можно вооружить кнопку, ввести пароль повторно и поставить галочку подтверждения. Сам вайп идёт одной транзакцией, так что либо всё, либо ничего. Если посреди процесса что-то падает, база откатывается в исходное.
А ещё был отличный момент, когда предохранитель едва не оказался слишком хорош.
Дёргаешь рубильник вайпа со всей силы, а он всё равно уходит в сухой отчёт. Полгода я так и не мог понять, почему.
Для аварийного запуска есть консольная команда. По задумке так:
php spark game:wipe # сухой отчёт
php spark game:wipe --confirm=WIPE-ALL-PLAYERS # реальный вайп
Я запускаю вторую форму на тестовом сервере, жду разрушений, а в ответ снова сухой отчёт. Ничего не удалено. Запускаю ещё раз, тот же результат.
Оказалось, парсер аргументов моего фреймворка форму с равно целиком кладёт в имя опции. То есть ключ у меня был не confirm со значением WIPE-ALL-PLAYERS, а ключ с именем confirm=WIPE-ALL-PLAYERS и пустым значением. Код проверял значение, видел пусто и честно уходил в безопасный сухой режим.
Смешно, что усвоил я это на команде, которая как раз и должна страховать от случайного запуска. Починил, теперь она понимает обе формы, и с равно, и через пробел.
private function resolveConfirm(): string
{
$confirm = CLI::getOption('confirm');
if (is_string($confirm) && $confirm !== '') {
return $confirm;
}
// форма --confirm=ФРАЗА, где парсер положил всё в имя ключа
foreach (array_keys(CLI::getOptions()) as $key) {
if (str_starts_with($key, 'confirm=')) {
return substr($key, strlen('confirm='));
}
}
return '';
}
Мне нравится, как в результате выглядит канон вайпа. Остров остаётся. Миллион клеток карты, биомы, мировые объекты, определения всех рецептов и оружия, настройки баланса. Всё это контент, который я делал руками, и стирать его при сезонном сбросе глупо.
Твоя личность тоже остаётся. Имя выжившего, твой id, дата, когда ты впервые ступил на берег. После вайпа ты не новый человек, ты тот же, просто с чистого листа. Уровень снова первый, инвентарь пуст, база снесена, туман войны затянул уже пройденные клетки. Мир тебя помнит, но прогресс придётся заслужить заново.
Технически весь этот смысл держится на разнице между «удалить строку» и «обновить строку». Ровно та разница, которую я чуть не потерял в самой первой наивной версии.
Пока собирал всё это, поймал себя на двух вещах, которые мне первым делом зададут сами игроки. И честного ответа у меня на них нет.
Первый. Как часто будет вайп? Не знаю. Это будет вообще первый сброс за всю историю игры, и ритм я подберу уже по тому, как проживётся первый сезон. Одно решил точно. Не молча. Перед вайпом будет предупреждение, а не проснувшаяся утром пустая база на месте твоей крепости.
Второй, и он злее. Если стирается всё, зачем гриндить до 214-го уровня? Сейчас честный ответ такой. От прошлого сезона у тебя остаётся только имя и то, что ты помнишь сам. Никаких сезонных лидербордов, ветеранских меток или титулов за прошлый заход пока нет. Нужны ли они, чтобы гринд не ощущался как гринд в пустоту, это ровно то, над чем я сейчас думаю. Пока склоняюсь, что какая-то память [3] о заслугах сезон должна переживать. Но это уже не про вайп, это следующая задача.
Можно было махнуть рукой. Один разработчик, полторы сотни живых игроков, кто там будет проверять, все ли таблицы попали в сброс. Но именно в соло-проекте цена забывчивости выше всего. У меня нет коллеги, который на ревью скажет «слушай, а новую таблицу в вайп добавил?». Некому. Поэтому всё, что можно вынести из головы в код, я выношу в код. Тест помнит за меня, сухой прогон показывает, что будет. А транзакция страхует, если я всё-таки где-то ошибся.
Кнопка стоит вооружённая. 1111 аккаунтов ждут. Тест зелёный, все 99 таблиц на месте. Я всё ещё не нажал. Но когда нажму, это будет не катастрофа, а первый сезон на старом острове.
Если хочется посмотреть на этот остров, пока прогресс ещё цел, заходи и заведи выжившего [4]. Первый сезон это гонка. Успеть добыть, скрафтить и вкопать первую базу раньше соседа, пережить первую стычку в поле, застолбить свой угол острова до того, как его застолбят за тебя. Начнёшь с небольшим пайком на холодном южном берегу. Как и все.
Автор: dron88
Источник [5]
Сайт-источник BrainTools: https://www.braintools.ru
Путь до страницы источника: https://www.braintools.ru/article/32823
URLs in this post:
[1] прошлый раз: https://habr.com/ru/articles/1056412/
[2] ошибки: http://www.braintools.ru/article/4192
[3] память: http://www.braintools.ru/article/4140
[4] заходи и заведи выжившего: https://t.me/wildworldrpg_bot?start=src_habr_article6
[5] Источник: https://habr.com/ru/articles/1056972/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1056972
Нажмите здесь для печати.