Пишу разбор демок CS2 на вахтах: Go‑парсер, ИИ без фантазий и грабли Laravel

Всем привет! Сразу скажу: я не сеньор из продуктовой компании и не гуру SaaS. Я работаю в авиации, часто уезжаю на вахты, у меня ипотека и семья. Бросить стабильный доход ради разработки я не могу, поэтому проект пилю там, где получается: по вечерам после смены и в вахтовых перерывах.
За плечами — шесть лет изучения языков, паттернов и архитектуры, проекты на заказ и подработки в коммерческих командах. Так что как всё должно быть устроено, я представляю. А вот фултайма в коммерческой разработке у меня не было, и этот проект — моя попытка сделать что‑то своё от начала до прода.
В Counter‑Strike я играю с 1.6: 10 уровень на FACEIT, локальные турниры, бесконечный разбор чужих матчей, чтобы стать сильнее. Отсюда и боль, с которой всё началось. Хорошие инструменты аналитики для игроков в основном зарубежные: их неудобно оплачивать из России, они на английском и не говорят на языке игрока. Видеогайды долгие, а на сайтах с раскидками гранат нужно три клика ради одного смока.
Так появился ELOUP.GG — платформа для тренировки в CS2: интерактивные карты с раскидками, тактическая доска для команды в реальном времени, гайды и разбор демок — силами ИИ и живых аналитиков.
Рассказывать про фичи я не буду, их можно просто открыть и посмотреть. Расскажу про то, ради чего читают Хабр: как это устроено, на что я наступил и какие решения принял.
Стек
Когда пишешь один и урывками, стек выбирается по одному принципу: чтобы на каждую фичу уходил вечер, а не неделя.
Laravel 13+Inertia+Vue 3. Для соло‑разработчика это спасение: получается SPA, но без отдельного API для фронта. Роутинг, авторизация и валидация остаются в Laravel, страница просто получает пропсы.Inertia SSR. Сайт живёт поиском: раскидки и гайды должны индексироваться, поэтому страницы рендерятся ещё и в Node.PostgreSQL—jsonbдля результатов разбора оказался очень кстати.Redis+Horizon— очереди: видео раскидок, разбор демок, ИИ.Laravel Reverb— WebSocket‑сервер для тактической доски.Go— парсер демок, о нём ниже.
Почему парсер на Go
Демка CS2 — это бинарный файл на сотни мегабайт. Разбор занимает сотни мегабайт памяти и секунды процессора. Готовых парсеров под PHP для Source 2 нет, а писать свой — утопия. А на Go есть живая библиотека demoinfocs-golang, которая уже умеет CS2.
Поэтому парсер — отдельный маленький сервис со строгими правилами:
слушает только
127.0.0.1:8081, наружу черезnginxне торчит;ничего не помнит между запросами. Очередь, приоритеты и статусы живут в
Laravel, сервис просто получает путь к файлу и отдаётJSON;занят — отвечает
503сRetry-After, иLaravelвозвращает задачу в очередь. Так платные заказы не ждут за бесплатными: приоритет решаетHorizon, а не парсер;запросы подписаны
HMACс общим секретом и меткой времени.
Тело запроса при этом не подписывается, и это осознанное решение. Хешировать поток на 350 МБ до начала разбора — значит читать демку дважды. Это допустимо ровно потому, что сервис слушает loopback; при переезде на отдельную машину проверку хеша тела придётся добавить.
Пара деталей, которые пришлось продумать для публичной загрузки:
демка проверяется по сигнатуре (
PBDEMS2для CS2) до того, как попадёт в библиотеку;упаковка определяется тоже по сигнатуре, а не по расширению:
zstd(в нём отдаёт демки FACEIT),gzipили голый файл;у размера после распаковки есть потолок. Без него любая архивная бомба выедает диск.
Цифры на реальном матче: de_mirage, 350 МБ, 23 раунда — разбор за 4,3 секунды. Позиции игроков снимаются с частотой 8 Гц: один раунд — около 534 кадров на 10 игроков, ~110 КБ JSON и ~17 КБ после gzip. Этого хватает и для метрик, и для 2D‑реплея в браузере.
Разбор идёт синхронно внутри задачи очереди. Я сначала планировал асинхронные задания с вебхуками, но матч разбирается за секунды, и сложность того не стоила. Страница разбора просто периодически перезапрашивает свои данные через Inertia с помощью вызова router.reload({ only: ['analysis'] }), пока статус не станет «готово».
Грабли № 1: загрузка демки на 350 МБ
Целиком такой файл не пролезет: у nginx на проде client_max_body_size — 100 МБ, да и мобильный интернет на вахте рвётся постоянно. Поэтому загрузка идёт кусками по 8 МиБ.
Отдельной таблицы под состояние загрузки нет — состояние это просто размер временного файла. Клиент присылает кусок и смещение, сервер сверяет смещение с текущим размером. Не совпало — отвечает 409 и сообщает фактический размер, клиент продолжает с него. Без такой сверки повторно отправленный кусок удвоил бы файл, а переставленный склеился бы в мусор, и вскрылось бы это только в парсере.
А дальше — классика. Проверка выглядела примерно так:
$data = $request->validate([
'offset' => ['required', 'integer', 'min:0'],
]);
if ($data['offset'] !== $current) { // '0' !== 0 → всегда true
return response()->json(['size' => $current], 409);
}Правило integer значение проверяет, но не преобразует: из multipart‑формы число приходит строкой. Строгое сравнение '0' !== 0 давало вечный 409, загрузка крутилась в цикле и не сдвигалась с нуля. А тесты передавали настоящий int и, конечно, всё проходили. Лечится одной строкой — $offset = (int) $data['offset']; — и тестом, который шлёт смещение строкой, как настоящий браузер.
Грабли № 2: FACEIT и хост, которого нет
План был красивый: игрок вставляет ссылку на матч FACEIT, мы сами скачиваем демку. В Data API у матча есть поле demo_url вида demos-europe-central.backblaze.faceit-cdn.net.
Проблема в том, что этот хост не резолвится ни из какой сети. Я проверял локально и с боевого сервера, через системный DNS и через 1.1.1.1, 8.8.8.8, 9.9.9.9. Сначала думал, что это блокировка, потом — что сбой. Оказалось, что demo_url указывает на приватный ресурс, и ссылку нужно обменять на подписанную через отдельный Downloads API. Доступ к нему выдают по заявке, а ответ идёт около 30 дней.
Поэтому сейчас источник FACEIT честно объясняет игроку, что пока так нельзя, и не пытается скачать заведомо недоступный файл. А основной путь — загрузка файла. Источник в парсере добавляется одной веткой, формат результата не меняется, так что FACEIT и Premier по sharecode подключатся без переделки остального.
ИИ‑разбор: код решает, ЧТО сказать, модель — КАК
Самая интересная часть. Первая версия была наивной: скормить модели статистику матча и попросить «разбери как тренер». Отзывы первых бета‑тестеров оказались почти все не про советы, а про факты:
«погиб первым» — а на самом деле четвёртым;
«у соперника эко» — а там был полный закуп;
«не унёс бомбу» — игроку за защиту, у которого бомбы не бывает.
Инструкция в промпте всё это прямо запрещала. Модель всё равно ошибалась. Отсюда главный принцип, на котором переписан разбор: факты считает код, модель только объясняет.
Как это устроено сейчас:
Детекторы в Go‑парсере. Ошибки находит детерминированный код, а не модель: контрстрейф (сколько очередей на бегу и чем они кончились), экономика (форс против команды, не сохранил оружие), повторная смерть в одном месте, сухой пик в одиночку без гранат и размена, ранние ротации и ротации на фейк. Каждая находка несёт номер раунда и тик.
Выжимка матча. Парсер отдаёт метрики и покадровые файлы, но модели это не скормить ни по цене, ни по смыслу. Из них собирается выжимка «матч глазами одного игрока»: закуп, входные дуэли, размены, смерти и что было вокруг них, гранаты. Ников в ней нет ни одного — ни заказчика, ни остальных девяти игроков. Для модели все «ты», «тиммейт» и «соперник», и этого ей хватает.
Цепочка маленьких запросов. Код выбирает топ-3 находки, модель параллельно пишет по блоку на каждую. Потом отдельным запросом — вердикт, сильные стороны и домашнее задание по готовым блокам.
Проверка каждого блока. Утверждение сверяется с выжимкой раунда. Не сошлось — одна перегенерация, потом выброс. Проверка грубая, по словам, и это осознанно: лучше выкинуть верное замечание, чем показать неверное.
Проверка мест на карте. Отдельная болезнь моделей — уверенно переносить названия позиций с карты на карту:
A-лонгнаMirage,бананнаDust II. Для каждой карты есть словарь мест, и позиция, которая существует только на другой карте, отбраковывается.
Бонус от якорей «раунд + тик»: каждое замечание в разборе кликабельно и перематывает 2D-реплей ровно на тот момент, где ошибка случилась. Игрок не верит на слово — он видит.
Модель — DeepSeek через российского посредника AITunnel. Качество я добираю не сменой модели на более дорогую, а контекстом и цепочкой маленьких запросов. Расход на ИИ в месяц сейчас — пара сотен рублей. Для соло‑проекта это важно: дорогой оказалась не модель, а сервер, который разбирает демки, поэтому лимиты у разборов держат нагрузку на парсер, а не бюджет на токены.
Чтобы правка промпта не ломала то, что уже работало, есть эталонный прогон: набор готовых разборов и вопросов прогоняется через новую версию с автопроверками и сравнением с прошлым прогоном. Это мой маленький регрессионный тест для ИИ.
Тактическая доска в реальном времени
Доска — это комната, где команда рисует стратегию поверх карты: стрелки, гранаты, позиции. Устроена она так:
эталон состояния доски хранится в базе, сохраняют его те, у кого есть право рисовать;
живые правки идут не через сервер приложения, а
whisper‑событиями в presence‑каналеReverbroom.{id}. Это клиентские события:Laravelв них не участвует, и рисование получается по‑настоящему мгновенным;отправителя подставляет сам
Reverb(accept_client_events_from = members), поэтому подделать, что стрелку нарисовал другой участник, нельзя;всё входящее от других клиентов проходит очистку на фронте, а права (кто заблокирован, кто редактор) проверяются при входе в канал.
Реплей демки на доске и в разборе рисует один и тот же рендерер. Готовые библиотеки для карт безбожно тормозили на смартфонах, поэтому рисование я переписал сам, оставив только panzoom для жестов масштабирования. Помню вечер на вахте, когда после смены, с гулом двигателей в голове, засыпал над ноутбуком и всё равно переписывал эту логику. Зато теперь на телефоне всё летает.
Ещё три грабли, которые стоили мне вечеров
SSR и белый экран. Страница рендерится в Node, а браузер эту разметку гидратирует. Обычный <Teleport to="body"> для модалки при гидратации удалял div#app целиком — пользователь видел белый экран, а в dev‑режиме без SSR всё работало. Теперь телепорт в body только через свою обёртку, которая монтирует его после гидратации. Туда же правило: window, ширина экрана и new Date() — только в onMounted, иначе серверный и клиентский рендер расходятся.
PostgreSQL и кириллица. Поиск по раскидкам приводит текст к нижнему регистру функцией LOWER(). С локалью базы C она кириллицу не трогает: поиск по русским словам молча пустой, без единой ошибки. На проде база с C.UTF-8, а локально была C, и тесты падали «без причины». Теперь отдельный тест проверяет локаль базы, чтобы это не повторилось.
Деплой, который падает молча. Локально у меня PHP новее, чем на проде. Composer честно резолвил зависимости под локальную версию и подтянул symfony/cache, несовместимый с расширением Redis на сервере. В деплое composer install падал, а сайт продолжал жить на старом коде. Лечится пином в composer.json — config.platform с версиями прода — и правилом «пины не снимать».
Что получилось и что пока не работает
Работает: карты с раскидками, тактическая доска с реплеем, гайды, загрузка демки и разбор с метриками, ИИ‑разбор с перемоткой реплея к ошибке, заявки живым аналитикам. Сегодня проект официально запускается: живёт он на подписке и разовых разборах — подробности на сайте, здесь не про это.
Не работает или в процессе:
автоматическое скачивание демок с
FACEIT— ждём доступа кDownloads API;демки
Premierпо sharecode — нуженSteam-боти запрос кGame Coordinator;часть детекторов ещё на проверке: я сначала сам сверяю их срабатывания на реальных матчах, и только потом они попадают в разбор.
Вместо заключения
Делать такое в одиночку, между сменами и бытовыми обязательствами, тяжело. Но когда загрузка, парсер, ИИ и реплей впервые сложились в один конвейер и разбор открылся на телефоне с перемоткой к ошибке, это дало такой заряд мотивации, ради которого всё и затевалось.
Миллионером завтра я проснуться не жду. Но верю, что инструмент может пригодиться тысячам таких же фанатов CS, как я.
Мне важен взгляд со стороны: критика архитектуры, баг‑репорты, мысли о связке Laravel + Go и о том, как ещё сократить фантазии ИИ. Если вы любите CS2 и пишете на Laravel, Vue или Go — буду рад единомышленникам. Пишите в комментариях или в личку.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.