- BrainTools - https://www.braintools.ru -
Всем привет! Сразу скажу: я не сеньор из продуктовой компании и не гуру 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 — парсер демок, о нём ниже.
Демка 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'] }), пока статус не станет «готово».
Целиком такой файл не пролезет: у 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']; — и тестом, который шлёт смещение строкой, как настоящий браузер.
План был красивый: игрок вставляет ссылку на матч 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‑парсере. Ошибки [4] находит детерминированный код, а не модель: контрстрейф (сколько очередей на бегу и чем они кончились), экономика (форс против команды, не сохранил оружие), повторная смерть в одном месте, сухой пик в одиночку без гранат и размена, ранние ротации и ротации на фейк. Каждая находка несёт номер раунда и тик.
Выжимка матча. Парсер отдаёт метрики и покадровые файлы, но модели это не скормить ни по цене, ни по смыслу. Из них собирается выжимка «матч глазами одного игрока»: закуп, входные дуэли, размены, смерти и что было вокруг них, гранаты. Ников в ней нет ни одного — ни заказчика, ни остальных девяти игроков. Для модели все «ты», «тиммейт» и «соперник», и этого ей хватает.
Цепочка маленьких запросов. Код выбирает топ-3 находки, модель параллельно пишет по блоку на каждую. Потом отдельным запросом — вердикт, сильные стороны и домашнее задание по готовым блокам.
Проверка каждого блока. Утверждение сверяется с выжимкой раунда. Не сошлось — одна перегенерация, потом выброс. Проверка грубая, по словам, и это осознанно: лучше выкинуть верное замечание, чем показать неверное.
Проверка мест на карте. Отдельная болезнь моделей — уверенно переносить названия позиций с карты на карту: 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
Нажмите здесь для печати.