PunchSpain mourns after latest train tragedy kills 69ESPNEvery Premier League team ranked by bang-for-buck: What if they spent like Man City?Bollywood HungamaBhumi Pednekar reunites with Akshay Kumar for Priyadarshan’s next comedyInquirerMMDA suspends coding scheme on Monday, Nov. 2UN NewsAfghanistan opium production jumps fivefold as farmers find ways round Taliban banDaily MaverickEl Salvador to send over 2,000 soldiers to Haiti for rescue while elections postponedEl ComercioEstados Unidos revela su hoja de ruta para Venezuela: reformas, vacancia de Delcy Rodríguez y eleccionesZDF heuteEntdecken Sie das ZDF-NachrichtenstudioFotogramas"Perfecta para ver en familia en Halloween": La película con la que vuelve a los cines el personaje más entrañable de la animación 'stop motion'Business AMDuitse elitesoldaten van KSK bereiden zich voor op mogelijke oorlog met RuslandSoompiSon Suk Ku And Nagayama Eita’s Opposing Detective Styles Complicate Their Partnership In “The Blue Road”Egypt IndependentHeal the victim, heal the world – how Sri Sri Ravi Shankar reclaims lives in war zones, prisons, and rural communities
The Daily Newsstand · Free, Always
Thursday, October 29, 2026

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

Translate

Всем привет! Сразу скажу: я не сеньор из продуктовой компании и не гуру 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 подключатся без переделки остального.

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

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

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

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

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

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

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

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

  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 для жестов масштабирования. Помню вечер на вахте, когда после смены, с гулом двигателей в голове, засыпал над ноутбуком и всё равно переписывал эту логику. Зато теперь на телефоне всё летает.

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

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 — буду рад единомышленникам. Пишите в комментариях или в личку.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.