Два года я в одиночку пилю текстовую MMORPG в Telegram. И знаете, что в этом самое странное? Мир там живёт без меня.
Раз в минуту просыпается крон — и это сердцебиение целой планеты. Пока я сплю, у кого-то достраивается база, списывается налог, бродят NPC, идут бои. К утру 485 выживших разбрелись по карте в 1 000 000 клеток, между ними отгремело часть из 45 296 боёв, а где-то новичок впервые поставил палатку и не понял, что делать дальше. Я просыпаюсь и читаю логи, как сводку с фронта войны, которую сам же и развязал.
Под капотом — CodeIgniter 4 и ~152 000 строк кода, написанных одним человеком по ночам. CRUD-туториалы про CI4 («сделай блог за вечер») такое не показывают. А живой мир показывает: грабли PHP и фреймворка вылезают не на хелло-ворлде, а когда по ту сторону экрана сидит реальный человек и делает то, чего ты не предусмотрел.
Вот пять, на которые я наступил по-настоящему. И за каждой — не абстрактный баг, а чей-то испорченный вечер. Иногда — мой.
Грабля 1. Рубильник, который не выключался: ((bool)»false») === true
У соло-разработчика нет QA, нет стейджинга с реальными игроками и нет ночного дежурного. Поэтому первое, что я построил, — рубильники. 486 настроек в админке, и у каждой опасной фичи свой killswitch: если в проде что-то пойдёт не так на глазах у живых выживших, я щёлкну false, и фича уснёт, не успев испортить людям вечер. Это мой спасательный круг. Сплю спокойнее.
И вот включаю новую механику, всё работает. Решаю на ночь выключить — щёлкаю false, сохраняю. А она не выключается. В базе по-прежнему 1.
Щёлкаю ещё раз. 1. Ещё. 1. В этот момент понимаешь две вещи: спасательный круг, который ты так гордо себе сшил, дырявый — и винить, кроме себя, некого. git blame показывает меня.
Код (было)
// value приходит из POST-формы админки строкой: "true" / "false"case 'bool': $update['value_bool'] = ((bool) $value) ? 1 : 0; break;
Виноват тут не CI4, а сам PHP и моя самоуверенность. К false в PHP приводятся только пустая строка "" и строка "0" (см. мануал, «Converting to boolean»). Всё остальное — true. А "false" — непустая строка, не равная "0". Значит true. Значит 1.
То есть рубильник исправно включал свет ("true" → true → 1), а вот выключить им было нельзя в принципе: "false" тоже давал 1. Полгода. Полгода у меня в проде висел выключатель, как в плохой съёмной квартире, — щёлкает, а свет горит. Спасало только то, что выключал я редко и обычно через соседнюю кнопку «Сброс к умолчанию», которая шла другим путём.
Код (стало)
case 'bool': // "true"/"1"/"on"/"yes" → 1, "false"/"0"/"off"/"" → 0 $update['value_bool'] = filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 1 : 0; break;
Урок: (bool)$строка для значений из форм — почти всегда баг. Любой ввод, где "false", "0" или "no" значат «нет», гони через filter_var($v, FILTER_VALIDATE_BOOLEAN). А проверку я, каюсь, сделал прямо в проде: щёлкнул в false, заглянул в базу — и впервые за полгода увидел там честный 0. Маленькая, глупая, но настоящая радость в три часа ночи.
Грабля 2. CI4 проглотил награду за верность: DataException: no data to update
Я делал стрики — награду за то, что человек возвращается. Мир суровый: голод, рейдеры, ночные вылазки. И мне хотелось, чтобы за каждый день, что выживший заходит, ему капало хоть немного добра. Маленький жест «спасибо, что не бросил».
Добавляю в таблицу колонку login_streak, пишу первое в жизни сохранение этой серии:
$characterModel->update($id, ['login_streak' => 5]);
И ловлю в ответ:
DataException: There is no data to update.
«Нет данных»? Я же передал массив с пятёркой! Есть в этом что-то обидное: ты пишешь код, который буквально благодарит игрока за верность, а фреймворк отвечает «никаких данных нет». Как будто не заметил, что человек вообще приходил.
А дело в $allowedFields — белом списке полей, которые CI4-модель разрешает массово писать. Новой колонки в списке нет → модель молча выкидывает её из набора. Передал одно поле, после фильтрации не осталось ни одного — «нет данных».
Фикс
protected $allowedFields = [ 'name', 'level', 'gold', /* ... */ 'login_streak', // ← забудешь добавить = поле молча исчезнет 'login_streak_last_day',];
И самое противное — оно молчит. Не «поле не разрешено», не warning. Просто фильтрует в пустоту. У меня этот путь был покрыт юнит-тестом на сервис-слое, где модель замокана, поэтому фильтрация $allowedFields там и не отрабатывала. Ловит такое только интеграционный тест с живой БД либо первый же апдейт в проде. Наступил я на это, кстати, дважды — второй раз со стыдом, потому что уже знал.
Урок: добавил колонку — сразу в $allowedFields. Я завёл железную привычку: миграция и правка $allowedFields идут одним коммитом. Иначе через неделю забудешь, что они вообще связаны.
Грабля 3. Один типос — и первый шаг новичка взрывается: ENUM + STRICT-режим MySQL
Эта история — про человека, которого я чуть не потерял на первой минуте.
В игру заходит совсем зелёный новичок. Ему всё непонятно: где база, как добывать, куда идти. И вот он делает свой самый первый осмысленный шаг — открывает экран базы. Игра в этот момент должна тихо записать в лог: «новичок открыл базу впервые» — это веха, по ней потом строится обучение. А вместо записи — красная ошибка прямо ему в лицо. На первой минуте. В мире, где он и так ничего не понимает.
Я этот сценарий поймал на аудите до того, как он встретил живого человека. И похолодел. Потому что причина была идиотской: в лог-флаг я записал значение done. Синоним Completed, мелочь, рука сама набрала.
Локально тесты были зелёные. А прод бы упал:
Data truncated for column 'action_status' at row 1
Колонки со статусами (Pending/Completed/Skipped, у задач in_work/completed) я сделал не VARCHAR, а ENUM — чтобы база сама стерегла инварианты. Но на проде включён STRICT_TRANS_TABLES, и для ENUM это значит буквально: любое внесписочное значение не «обрезается до пустого», а роняет весь запрос с исключением. То есть не просто кривой флаг записался — упало бы всё действие игрока, частью которого был этот INSERT. Его первый шаг в мире.
В чём была ловушка
action_status ENUM('Pending','Completed','Skipped') NOT NULL DEFAULT 'Pending'-- прод (STRICT_TRANS_TABLES): валит весь INSERTINSERT ... SET action_status = 'done'; -- Data truncated → Exception-- надо строго из спискаINSERT ... SET action_status = 'Completed';
А локально всё молчало просто потому, что у dev-сервера STRICT мог быть выключен — там done тихо превратился бы в ''. Классический «у меня работает», где разница не в коде, а в sql_mode между машинами. С тех пор я отношусь к ENUM как к вахтёру, который не пускает «почти подходящих»: записываю в такие колонки только строго из списка, а сами значения держу в коде константами — чтобы опечатка вылезла на этапе моих мыслей, а не в лицо новичку.
Урок: ENUM под STRICT_TRANS_TABLES — не «мягкая подсказка», а жёсткий гейт: внесписочное значение валит запрос целиком. И всегда сверяйте sql_mode между dev и прод. Половина «а у меня же работало» родом отсюда — и иногда ценой этому чей-то самый первый шаг в вашем мире.
Грабля 4. Капитальный ремонт мотора на ходу: CI4 Entity, который притворяется массивом
А это не баг, а решение, которым я тихо горжусь. Но далось оно нервами.
Сердце игры — персонаж. Выживший. Всё вращается вокруг него. А в коде он начинался как тупой массив: $character['name'], $character['health'] — тысячи таких обращений по всему проекту. Хотелось дать ему настоящую сущность: типизированные геттеры, методы прямо на объекте, нормальную инкапсуляцию. CI4 это умеет через $returnType = CharacterEntity::class.
Одна беда: переписать тысячи $char['...'] на $char->... за раз — это поставить мир на паузу. А его нельзя ставить на паузу. Прямо сейчас, пока я думаю об этом рефакторинге, по карте идут живые люди. Налог капает. Бои считаются. Останови двигатель — и кто-то посреди похода упрётся в ошибку.
Поэтому мотор пришлось чинить на ходу. Сущность, которая одновременно ведёт себя как массив:
Entity + ArrayAccess
class CharacterModel extends Model{ protected $returnType = CharacterEntity::class;}// CharacterEntity подключает трейт с ArrayAccess →// старый $char['name'] и новый $char->name работают оба$char = $characterModel->find($id);$char['health']; // легаси-код жив$char->health; // новый код
Старый код не сломался, новый поехал на сущностях, а миграция шла файл за файлом, без большого взрыва. Пассажиры даже не заметили, что под капотом поменяли половину мотора. Вот за такие моменты я и люблю это дело.
Цена — статический анализ, и тут буду честен. «PHPStan L9 без ошибок» у меня означает L9 с управляемым baseline на легаси, который я планомерно сокращаю. Новый код пишется уже под чистый L9: сигнатуры стали array|CharacterEntity, и анализатор справедливо требует на каждом шаге доказать, с чем имеешь дело. Зануда? Да. Но когда сьют из 1508 тестов и PHPStan в один голос говорят «No errors», я точно знаю, что переезд не протащил ни одной тихой регрессии в мир, где люди прямо сейчас живут. За это не жалко повозиться с union-типами.
Урок: ArrayAccess на сущности — отличный мост, чтобы мигрировать легаси, не останавливая прод. Только заранее прими: union array|Entity ты будешь доказывать статанализатору ещё долго. Это честная плата за то, что никто из игроков не упёрся в ошибку, пока я перебирал движок.
Грабля 5. Эмодзи, у которого вынули душу: utf8 в MySQL — это не то, что вы думаете
Мир у меня текстовый. Никакой графики — только буквы и эмодзи. Карта нарисована эмодзи. Огонь — 🔥, бой — ⚔️, заброшенный бункер — 🏚. Эти крошечные пиктограммы и есть лицо игры, её «найденная фотоплёнка». И вот накатываю порцию контента, открываю в Telegram — а половина иконок превратилась в ?.
Причём не все. —, кавычки-ёлочки, вся кириллица — на месте. А эмодзи — в знак вопроса. Как будто из мира выскоблили ровно ту часть, в которую я вкладывал характер.
Разгадка в том, что utf8 в MySQL — это не настоящий UTF-8. Исторический utf8 (он же utf8mb3) хранит максимум 3 байта на символ. Кириллица и типографика в три байта влезают. А эмодзи четырёхбайтные — не влезают, и молча режутся в ?. Душу — за борт, тихо, без единого warning.
Коварство именно в «частично»: глазами по тексту всё правильно (тире на месте, буквы на месте), ломаются только картинки. SQL-проверкой «совпадает ли строка» баг не ловится — ловит только реальный рендер в чате у игрока.
Фикс и подводные камни
-- было: колонка в utf8mb3 (он же исторический "utf8")ALTER TABLE tips CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;-- utf8mb4 = настоящий 4-байтный UTF-8, эмодзи влезают
Две оговорки, чтобы не наступить рядом:
-
На MySQL 8.0
utf8пока всё ещё алиасutf8mb3(с депрекейшеном), так что «utf8 = utf8mb3» — это про старые версии и MariaDB; на свежих дефолт ужеutf8mb4. -
При
CONVERT TO ... utf8mb4длинныеVARCHARпод индексом могут упереться в лимит длины ключа (767/3072 байта) — проверьте схему до миграции.
Урок: есть эмодзи в данных (а в Telegram-боте они везде) — колонка обязана быть utf8mb4, не utf8. И проверять надо не SQL-сравнением, а тем, как символ реально доезжает до человека на том конце. Мир чуть не остался без половины своего лица.
Что в сухом остатке
Ни одна из этих граблей не лечится чтением документации вперёд. Они вылезают, только когда система живая: когда по ту сторону экрана не тест-кейс, а человек, который злится, радуется, бросает и возвращается. 55 task-хендлеров, 238 экранов, 323 миграции, строгий PHPStan — это всё не ради красивых цифр в резюме. Это ради того, чтобы у новичка не взорвался первый шаг, чтобы стрик запомнил верного, чтобы эмодзи доехало живым.
CodeIgniter 4 в этой истории — честный рабочий конь: не мешает, даёт двигаться быстро, а грабли в основном там, где я сам срезал угол по ночам. Знать про них заранее полезно — поэтому и пишу.
А держусь я за всё это вот почему. Иногда читаешь логи, а там кто-то в общем чате спрашивает: «а как зайти на базу?» И ты вдруг понимаешь, что по ту сторону твоих WHERE и ENUM — живой человек, который сейчас стоит посреди твоего выдуманного постапокалипсиса и пытается выжить. Ради этого ощущения и пилишь два года в одиночку.
Если интересно посмотреть, как это живёт: бот — @wildworldrpg_bot. Максимальный уровень у выжившего сейчас 201 — так что место в рейтинге пока есть.
В следующей статье разберу боевую часть: как текстовый бой считается из «оружие + броня + RNG + события» и почему я в итоге залогировал каждую переменную боя — чтобы не отвечать игрокам «ну там рандом». Если тема зайдёт — накидайте в комментариях, во что из кишок игры копнуть интереснее.
ссылка на оригинал статьи https://habr.com/ru/articles/1046251/