Заваривай чай. Сегодня без пафоса, зато с цифрами и с моими косяками.

188 тысяч строк кода и 41 живой игрок за неделю. Вот два числа, между которыми я живу третий год. Первое я нарастил сам, второе почти не растёт. Именно поэтому статья называется так, как называется.

В прошлый раз я рассказывал про кнопку вайпа, которую собрал и до сих пор боюсь нажать. А сегодня про то, без чего этой кнопки, да и половины проекта, просто не было бы.

Коротко, если ты тут впервые. Wild World это текстовая MMORPG в Telegram. Остров после катастрофы, по нему бродят выжившие, добывают воду и хлам, крафтят, ставят базы, дерутся с тварями и иногда друг с другом. Пилю в одиночку по вечерам третий год. Не стартап, хобби. Бюджета на маркетинг нет, есть я и гаечный ключ.

Масштаб такой: 188 887 строк в app/, 1367 PHP-файлов, 464 миграции, 2401 тест, PHPStan на девятом уровне. И всё это обслуживает те самые сорок с небольшим человек. Да, я знаю, как это выглядит со стороны.

Проект и правда умирал

Первую версию я выкатил в 2024-м. Потом работа, потом усталость, потом полгода я вообще не открывал репозиторий. Классическая смерть пет-проекта. Игроки досидели, кто хотел, и разошлись.

Когда я вернулся, меня встретил мой же код годичной давности. Знакомое чувство: ты вроде автор, а читаешь как чужое. Полсотни моделей, куча обработчиков, магические числа в самых неожиданных местах и ноль тестов. Чтобы просто поменять баланс добычи, надо было вспомнить, в каком из трёх мест это считается.

По-хорошему, такой проект надо переписывать. По-честному, у одного человека по вечерам на это нет сил. Именно на этом месте пет-проекты и умирают: не от скуки, а от того, что цена изменения становится больше, чем твой вечерний запас сил.

Вот тут я и позвал ИИ. Не в режиме «напиши мне игру». Скорее как способ переварить объём.

Сразу про вопрос, который прилетит первым. Это Claude Code, терминальный CLI, подписка Max, старший тариф, 200 долларов в месяц. Никаких плагинов к редактору, всё в консоли рядом с репозиторием.

Двести долларов в месяц за хобби звучит дико, понимаю. Но писать дальше про «некий ассистент» было бы игрой в загадки, по описанию всё равно догадаются. Так что называю один раз и закрываю тему. Приёмы ниже работают с любым из больших ассистентов, а половина статьи вообще про то, где эта штука меня подвела.

Что он вытащил на себе

Я не буду говорить про «ускорение в десять раз». Скажу конкретными задачами, которые я бы один просто не сделал.

Класс багов вместо одного бага. У меня был мерзкий баг: игрок пьёт успокоительное, потом стимулятор, и второй препарат откатывает эффект первого.

Классический lost update. Выглядело это так:

$character = $this->characterModel->find($id);
$health = min(100, $character['health'] + $healAmount);
$this->characterModel->update($id, ['health' => $health]);

Читаем состояние, считаем в PHP, пишем абсолютное значение обратно. Откуда тут гонка у одного игрока в чате? Оттуда, что игрок жмёт кнопки быстрее, чем отвечает сервер, а поверх его действий идёт фоновый тик игрового цикла, который тоже правит здоровье и выносливость. Два запроса, каждый со своим снимком состояния, и тот, кто записал вторым, затёр первого.

Стало так:

$this->stats->adjust($id, ['health' => $healAmount]);

Метод принимает не новое значение, а дельту. Внутри одна транзакция, блокировка строки и запись только тех полей, которые меняются.

Починить конкретно аптечку это полчаса. Но такой код у меня был размазан по всему проекту, и я даже не знал, где именно. Вот тут ассистент и пригодился: прошёл по всем местам, где пишутся семь характеристик персонажа, и разложил их по типам. Где-то абсолютная запись осознанная (респавн), где-то дельта (добыча), где-то множитель (смерть).

Получилось около тридцати мест. Все дельты и множители переехали на один сервис с блокировкой строки. Отдельно вылезло, что регенерация здоровья гоняла по одному UPDATE на каждого персонажа в тике. Стало одним запросом на всех, кому регенерация положена.

Ручками я бы это не осилил. Не потому что сложно. Просто скучно и долго, а вечер у меня один.

Арт, который был, но которого никто не видел. В игре 24 ствола, и по-хорошему у каждого должна быть своя картинка. Рисую я как курица лапой, поэтому картинки генерю по единому стилевому промпту, чтобы всё выглядело как один мир.

Началась история вообще не с красоты. Экран осмотра снаряжения начал сыпать ошибки в лог. Один игрок за четыре минуты собрал 31 ошибку. Пошли смотреть, и выяснилось интересное.

Своя картинка показывалась ровно у 4 стволов из 24. Ещё у 10 файл лежал на диске и запись в реестре была, но строки в таблице соответствия не оказалось, и резолвер молча подставлял заглушку. Молча, годами. Про заглушку в логах не было ни строчки, сыпал совсем другой экран, он-то меня туда и привёл. У оставшихся 10 арта не было вообще, их пришлось генерить.

Итог: 4 плюс 10 связанных плюс 10 дорисованных, ровно 24 из 24. Больше всего меня добила именно средняя десятка: работа была сделана и лежала в репозитории, а игрок её не видел.

Настройки вместо магических чисел. Сейчас в админке 707 параметров баланса. Значения и логику придумываю я, ассистент разносит их по категориям и оформляет описания: почему стоит именно это число, что будет, если выше, и что будет, если ниже. Обоснование есть у 704 из 707, до трёх я так и не добрался.

Читаю эти описания в первую очередь я сам, через полгода, когда всё забуду.

А теперь три раза, когда я поверил уверенному тону

Вот эта часть, по-моему, интереснее. Потому что «ИИ помог отрефакторить» звучит как реклама, а реальность выглядит иначе.

Сразу оговорюсь, чтобы не выглядело так, будто я сваливаю вину на машину. Только первый случай это враньё нейросети. Второй и третий это дыры в моём процессе, которые ассистент просто масштабировал: он уверенно оформляет вывод, и на неудобном месте ты, вместо того чтобы споткнуться, киваешь.

Случай первый: «фича не работает, она выключена». У меня почти всё сидит за переключателями. Новая механика приезжает выключенной, я гоняю её на тестовом сервере, потом включаю в админке.

И вот ассистент идёт смотреть, живая ли механика. Открывает код, видит значение по умолчанию false и рапортует: фича спит, надо включать. Звучит логично. Только значение по умолчанию показывает, что было при создании фичи. Настоящее лежит в базе на проде:

SELECT value_bool FROM game_settings
WHERE setting_key = 'onboarding.cold_open_v2.single_screen';

И там единица, стоит с июня.

За один рабочий день я поймал три таких доклада подряд. Три раза «эта штука у тебя мёртвая», и три раза один SELECT показывал обратное.

Лечится тупо. Работает оно или нет, спрашиваем у боевой базы.

Случай второй: ложная тревога от тестов. Делал я подсказку, которая говорит игроку, в какой стороне искать вулканы. Прогнал локально, а подсказка не выводится. Пустота. Вывод напрашивается: сломал.

На самом деле тесты ходят в отдельную тестовую базу, а в ней нет таблицы с картой мира. Данных нет, и код честно вернул пусто. Фича при этом рабочая, что подтвердилось на тестовом сервере с реальной картой.

Мораль скучная, но полезная. Пустой результат ещё не доказательство поломки. Это либо поломка, либо у тебя нет данных, и различать надо руками. Да, справедливо: будь у меня нормальные фикстуры, вопрос бы не возник.

Случай третий: зелёный тест, который ничего не проверяет. Самое опасное. Ассистент бодро пишет тест, тест зелёный, ты доволен. А тест не падает, даже если сломать то, что он якобы проверяет.

Конкретно: тест на новую подсказку проверял, что функция вернула строку. Она возвращала строку всегда. И пустую, и с текстом от старой механики. Формально проверка есть, фактически она пропустила бы любой мой косяк.

Теперь у меня правило: любой новый тест проверяется мутацией. Ломаешь код специально и смотришь, упал ли тест и упал ли именно с тем сообщением, которое ты ждал. Так я поймал два своих же теста.

Бонус: баг, который не ловит ни один тест

Этот случай не про враньё нейросети. Он про то, чего не видит вообще никакая проверка.

Девятого июля я перекроил нижнее меню бота, кнопки переименовались. А тексты подсказок продолжали звать старые названия. Четыре живых совета в базе отправляли игрока жать кнопки, которых больше нет: три звали «Перс», один посылал на экран «Карта». Обе кнопки уже две недели как называются иначе.

Сам баг мелкий. Но он показательный: формально всё работает. Текст выводится, кнопка существует, просто зовут её по-разному. Ни один тест такое не ловит, потому что ловить нечего.

Починил не заменой слов, а источником. Теперь название кнопки в тексте подставляется из того же места, откуда его берёт сама клавиатура. Сверху тест, который сканирует исходники и падает, если кто-то опять впишет название вручную:

foreach (glob(APPPATH . 'Services/Onboarding/*.php') as $file) {
    $source = (string) file_get_contents($file);
    // комментарии вырезаем: в них я как раз описываю сам инцидент
    $code = (string) preg_replace('~^\s*(//|\*|/\*).*$~mu', '', $source);

    foreach (['«Перс»', '«Карта»', '«Крафт»', '«База»'] as $stale) {
        $this->assertStringNotContainsString($stale, $code, basename($file));
    }
}

А советы лежат строками в базе, кодом их не вылечишь, там подстановка своя. В текстах обучения я названия кнопок вообще убрал: «открой крафт-хаб» не сломается никогда.

И да, тест я проверил мутацией. Вернул старую формулировку, тест упал. Значит, живой.

Три проверки, которые стоят между ИИ и игроками

Если совсем коротко, вот весь мой процесс.

Первое. Статический анализ и тесты на входе. PHPStan на девятом уровне и 2401 тест. Но тестом я считаю только тот, что прошёл мутацию. Зелёный тест, который не краснеет от поломки, хуже, чем никакого. Он выдаёт уверенность, которой нет.

Второе. Сверка с боевой базой. Любое утверждение «это включено» или «это не используется» проверяется запросом на проде. Код показывает намерение, а правду знает только база.

Третье. Прогон на живом боте. Именно клик по кнопке, руками, в реальном Telegram на тестовом сервере. Отдельно я научился ловить самую противную штуку: если разметка сообщения битая, Telegram молча отвечает отказом, и сообщение просто не доходит. В логах чисто, игрок ничего не получил. Поэтому перед выкаткой текстов я отправляю их живым запросом и смотрю на ответ.

Ничего умного, всё скучное. Но именно эта скука превращает «нейронка нагенерила» во что-то, что можно отдать людям.

Так спас или не спас?

Отвечу честно, без красивого финала.

Игру спас не ИИ. Игру спасло то, что я снова стал её открывать. Но открывать её снова стало возможно ровно потому, что цена изменения упала. Раньше «поправить баланс добычи» это был вечер археологии в собственном коде. Сейчас это вечер работы.

Тут есть неприятная правда, которую в подобных статьях обычно проглатывают. Никакой ИИ не сделал мою игру популярной. Он не привёл ни одного человека. Он вообще не про это, и 41 игрок в неделю это подтверждает. Игроков я, конечно, хочу. Просто пока все вечера уходят в код.

Что он сделал на самом деле, так это не дал проекту умереть второй раз. Разница между «заброшен» и «жив» для пет-проекта измеряется не количеством фич, а тем, открываешь ли ты редактор в среду вечером.

За последние 30 дней у меня 120 коммитов. Год назад за тот же месяц был ноль.

Если ты сам тащишь свой пет-проект и уже думал его бросить, попробуй сначала снизить себе цену изменения, а фичи потом. Мне помогло.

Как вообще выглядит вечер в такой игре

Раз уж я зову в бота, расскажу, во что там играют, чтобы ты решил сам, надо оно тебе или нет.

Игра пошаговая и неспешная, живёт в переписке с ботом. Тыкаешь кнопку «идти на север», проходишь клетку, видишь, что вокруг. Добыча и крафт идут таймерами: поставил собирать древесину, закрыл Telegram, вернулся через десять минут. Дальний переход через «Поход» тоже фоновый, отчёт придёт в чат. Сессия от двух минут (проверить, что доехало) до часа, если тебя затянуло в стройку.

Дальше начинается собственно MMO. Мир один на всех, карта в миллион клеток, ресурсы и объекты на ней общие. Базу ставишь на конкретной клетке, и она там физически стоит. На юге новичков не трогают, на арену попадаешь, только если сам открыл себя для дуэлей, а вот в глубине острова нарваться можно.

Про смерть скажу честными числами, потому что это первое, обо что спотыкаются. Цифры на момент написания, баланс я иногда двигаю. Пока ты новичок, до десятого уровня, смерть почти бесплатна. С базой не теряешь ничего, без базы десятую часть. После десятого дороже: с базой три процента, без базы половину. Склад при этом не грабится никогда. То есть игра прямым текстом говорит, что дом ставить выгодно, терять будешь в разы меньше.

Сорок с небольшим человек в неделю на карту в миллион клеток означает, что живого выжившего ты в первый вечер не встретишь. Зато следы других видно сразу: чужие базы стоят на карте, есть общий чат и рейтинг, а отдельный экран показывает пульс острова за сутки. Если ты ищешь толпу, это не сюда. Если тебе нравится тихое одиночное выживание, где иногда кто-то проходит рядом, тебе зайдёт.

Посмотреть, во что это вылилось, можно тут: Wild World. Игра бесплатная, покупок в ней нет вообще, продавать мне нечего.