- BrainTools - https://www.braintools.ru -

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

Кто я и откуда этот проект

Всем привет! Сразу скажу: я не сеньор из продуктовой компании и не гуру SaaS. Я работаю в авиации, часто уезжаю на вахты, у меня ипотека и семья. Бросить стабильный доход ради разработки я не могу, поэтому проект пилю там, где получается: по вечерам после смены и в вахтовых перерывах.

За плечами — шесть лет изучения языков, паттернов и архитектуры, проекты на заказ и подработки в коммерческих командах. Так что как всё должно быть устроено, я представляю. А вот фултайма в коммерческой разработке у меня не было, и этот проект — моя попытка сделать что‑то своё от начала до прода.

В Counter‑Strike я играю с 1.6: 10 уровень на FACEIT, локальные турниры, бесконечный разбор чужих матчей, чтобы стать сильнее. Отсюда и боль [1], с которой всё началось. Хорошие инструменты аналитики для игроков в основном зарубежные: их неудобно оплачивать из России, они на английском и не говорят на языке игрока. Видеогайды долгие, а на сайтах с раскидками гранат нужно три клика ради одного смока.

Так появился ELOUP.GG [2] — платформа для тренировки в CS2: интерактивные карты с раскидками, тактическая доска для команды в реальном времени, гайды и разбор демок — силами ИИ и живых аналитиков.

Рассказывать про фичи я не буду, их можно просто открыть и посмотреть. Расскажу про то, ради чего читают Хабр: как это устроено, на что я наступил и какие решения принял.

Стек

Когда пишешь один и урывками, стек выбирается по одному принципу: чтобы на каждую фичу уходил вечер, а не неделя.

  • Laravel 13 + Inertia + Vue 3. Для соло‑разработчика это спасение: получается SPA, но без отдельного API для фронта. Роутинг, авторизация и валидация остаются в Laravel, страница просто получает пропсы.

  • Inertia SSR. Сайт живёт поиском: раскидки и гайды должны индексироваться, поэтому страницы рендерятся ещё и в Node.

  • PostgreSQL — jsonb для результатов разбора оказался очень кстати.

  • Redis + Horizon — очереди: видео раскидок, разбор демок, ИИ.

  • Laravel Reverb — WebSocket‑сервер для тактической доски.

  • Go — парсер демок, о нём ниже.

Почему парсер на Go

Демка CS2 — это бинарный файл на сотни мегабайт. Разбор занимает сотни мегабайт памяти [3] и секунды процессора. Готовых парсеров под 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 подключатся без переделки остального.

ИИ‑разбор: код решает, ЧТО сказать, модель — КАК

Самая интересная часть. Первая версия была наивной: скормить модели статистику матча и попросить «разбери как тренер». Отзывы первых бета‑тестеров оказались почти все не про советы, а про факты:

  • «погиб первым» — а на самом деле четвёртым;

  • «у соперника эко» — а там был полный закуп;

  • «не унёс бомбу» — игроку за защиту, у которого бомбы не бывает.

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

Как это устроено сейчас:

  1. Детекторы в Go‑парсере. Ошибки [4] находит детерминированный код, а не модель: контрстрейф (сколько очередей на бегу и чем они кончились), экономика (форс против команды, не сохранил оружие), повторная смерть в одном месте, сухой пик в одиночку без гранат и размена, ранние ротации и ротации на фейк. Каждая находка несёт номер раунда и тик.

  2. Выжимка матча. Парсер отдаёт метрики и покадровые файлы, но модели это не скормить ни по цене, ни по смыслу. Из них собирается выжимка «матч глазами одного игрока»: закуп, входные дуэли, размены, смерти и что было вокруг них, гранаты. Ников в ней нет ни одного — ни заказчика, ни остальных девяти игроков. Для модели все «ты», «тиммейт» и «соперник», и этого ей хватает.

  3. Цепочка маленьких запросов. Код выбирает топ-3 находки, модель параллельно пишет по блоку на каждую. Потом отдельным запросом — вердикт, сильные стороны и домашнее задание по готовым блокам.

  4. Проверка каждого блока. Утверждение сверяется с выжимкой раунда. Не сошлось — одна перегенерация, потом выброс. Проверка грубая, по словам, и это осознанно: лучше выкинуть верное замечание, чем показать неверное.

  5. Проверка мест на карте. Отдельная болезнь моделей — уверенно переносить названия позиций с карты на карту: A-лонг на Mirage, банан на Dust II. Для каждой карты есть словарь мест, и позиция, которая существует только на другой карте, отбраковывается.

Бонус от якорей «раунд + тик»: каждое замечание в разборе кликабельно и перематывает 2D-реплей ровно на тот момент, где ошибка случилась. Игрок не верит на слово — он видит.

Модель — DeepSeek через российского посредника AITunnel. Качество я добираю не сменой модели на более дорогую, а контекстом и цепочкой маленьких запросов. Расход на ИИ в месяц сейчас — пара сотен рублей. Для соло‑проекта это важно: дорогой оказалась не модель, а сервер, который разбирает демки, поэтому лимиты у разборов держат нагрузку на парсер, а не бюджет на токены.

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

Тактическая доска в реальном времени

Доска — это комната, где команда рисует стратегию поверх карты: стрелки, гранаты, позиции. Устроена она так:

  • эталон состояния доски хранится в базе, сохраняют его те, у кого есть право рисовать;

  • живые правки идут не через сервер приложения, а whisper‑событиями в presence‑канале Reverb room.{id}. Это клиентские события: Laravel в них не участвует, и рисование получается по‑настоящему мгновенным;

  • отправителя подставляет сам Reverb (accept_client_events_from = members), поэтому подделать, что стрелку нарисовал другой участник, нельзя;

  • всё входящее от других клиентов проходит очистку на фронте, а права (кто заблокирован, кто редактор) проверяются при входе в канал.

Реплей демки на доске и в разборе рисует один и тот же рендерер. Готовые библиотеки для карт безбожно тормозили на смартфонах, поэтому рисование я переписал сам, оставив только panzoom для жестов масштабирования. Помню вечер на вахте, когда после смены, с гулом двигателей в голове, засыпал над ноутбуком и всё равно переписывал эту логику [5]. Зато теперь на телефоне всё летает.

Ещё три грабли, которые стоили мне вечеров

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;

  • часть детекторов ещё на проверке: я сначала сам сверяю их срабатывания на реальных матчах, и только потом они попадают в разбор.

Вместо заключения

Делать такое в одиночку, между сменами и бытовыми обязательствами, тяжело. Но когда загрузка, парсер, ИИ и реплей впервые сложились в один конвейер и разбор открылся на телефоне с перемоткой к ошибке, это дало такой заряд мотивации [6], ради которого всё и затевалось.

Миллионером завтра я проснуться не жду. Но верю, что инструмент может пригодиться тысячам таких же фанатов CS, как я.

Мне важен взгляд со стороны: критика архитектуры, баг‑репорты, мысли о связке Laravel + Go и о том, как ещё сократить фантазии ИИ. Если вы любите CS2 и пишете на Laravel, Vue или Go — буду рад единомышленникам. Пишите в комментариях или в личку.

Автор: rkertini

Источник [7]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/37709

URLs in this post:

[1] боль: http://www.braintools.ru/article/9901

[2] ELOUP.GG: http://ELOUP.GG

[3] памяти: http://www.braintools.ru/article/4140

[4] Ошибки: http://www.braintools.ru/article/4192

[5] логику: http://www.braintools.ru/article/7640

[6] мотивации: http://www.braintools.ru/article/9537

[7] Источник: https://habr.com/ru/articles/1101074/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1101074

www.BrainTools.ru

Rambler's Top100