惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
U
Unit 42
H
Help Net Security
博客园_首页
雷峰网
雷峰网
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
博客园 - 【当耐特】
Recent Announcements
Recent Announcements
Recorded Future
Recorded Future
aimingoo的专栏
aimingoo的专栏
爱范儿
爱范儿
C
CXSECURITY Database RSS Feed - CXSecurity.com
The Cloudflare Blog
S
Security @ Cisco Blogs
M
MIT News - Artificial intelligence
Cyberwarzone
Cyberwarzone
Cisco Talos Blog
Cisco Talos Blog
Spread Privacy
Spread Privacy
The GitHub Blog
The GitHub Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
K
Kaspersky official blog
MyScale Blog
MyScale Blog
阮一峰的网络日志
阮一峰的网络日志
C
CERT Recently Published Vulnerability Notes
V
Vulnerabilities – Threatpost
WordPress大学
WordPress大学
C
Cisco Blogs
G
Google Developers Blog
N
News and Events Feed by Topic
P
Palo Alto Networks Blog
Apple Machine Learning Research
Apple Machine Learning Research
Schneier on Security
Schneier on Security
博客园 - 聂微东
Security Latest
Security Latest
Security Archives - TechRepublic
Security Archives - TechRepublic
O
OpenAI News
云风的 BLOG
云风的 BLOG
IT之家
IT之家
PCI Perspectives
PCI Perspectives
Microsoft Security Blog
Microsoft Security Blog
NISL@THU
NISL@THU
小众软件
小众软件
Scott Helme
Scott Helme
大猫的无限游戏
大猫的无限游戏
L
LINUX DO - 最新话题
Microsoft Azure Blog
Microsoft Azure Blog
Simon Willison's Weblog
Simon Willison's Weblog
N
News and Events Feed by Topic
F
Fortinet All Blogs
The Last Watchdog
The Last Watchdog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Два года один пилю MMORPG на CI4: пять багов, за каждым из которых стоял живой игрок
Андрей · 2026-06-11 · via Все публикации подряд на Хабре

Простой

8 мин

3.2K

Выживший один у пыльного терминала в ночном бункере

Выживший один у пыльного терминала в ночном бункере

Два года я в одиночку пилю текстовую 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): валит весь INSERT
INSERT ... 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 + события» и почему я в итоге залогировал каждую переменную боя — чтобы не отвечать игрокам «ну там рандом». Если тема зайдёт — накидайте в комментариях, во что из кишок игры копнуть интереснее.