Как я писал разбор демок CS2 на вахте: Go, строгий ИИ и грабли в Laravel

Прокомментировать Просмотры: 12

Обо мне и предыстория проекта

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

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

Counter-Strike увлекает меня ещё со времен версии 1.6: 10-й уровень на FACEIT, участие в локальных турнирах и часы за детальным разбором чужих демок ради повышения скилла. Отсюда и родилась боль, подтолкнувшая к созданию платформы. Качественная аналитика для игроков представлена преимущественно зарубежными сервисами: их проблематично оплачивать из РФ, они полностью англоязычные и далеки от реалий нашего гейминга. Видеогайды непозволительно длинные, а специализированные сайты с раскидками требуют совершать по три клика ради демонстрации одного смока.

Именно так родился ELOUP.GG — тренировочный хаб для игроков в CS2, включающий интерактивные карты с гранатами, интерактивную тактическую доску для командной работы в реальном времени, подробные гайды и разбор матчей с помощью искусственного интеллекта в тандеме с профессиональными аналитиками.

Возможности платформы лучше изучить самостоятельно на самом сайте. Здесь же я поделюсь тем, за что так ценится SE7EN: технической кухней, подводными камнями и архитектурными решениями.

Технологический стек

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

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

  • Inertia SSR. Проект критически зависит от поискового трафика: гайды и раскидки обязаны индексироваться, поэтому разметка параллельно рендерится в Node.

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

  • Redis + Horizon — надежный инструмент для управления очередями задач: рендеринг видео раскидок, обработка демок, запросы к ИИ.

  • Laravel Reverb — легковесный WebSocket-сервер, обеспечивающий работу тактической доски.

  • Go — специализированный парсер матчей, о котором пойдет речь далее.

Почему выбор пал на Go при создании парсера

Демо-файл в CS2 представляет собой увесистый бинарник размером в сотни мегабайт. Его парсинг требует значительных объемов оперативной памяти и процессорного времени. Готовых библиотек для работы с форматом Source 2 на PHP не существует, а написание собственного аналога — утопия. Зато в экосистеме 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 или распакованные файлы;

  • жесткое ограничение на максимальный размер распакованного архива во избежание zip-бомб, способных забить дисковое пространство.

Реальные показатели на стандартном матче 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 МиБ.

Создавать отдельную таблицу в базе под трекинг загрузки я не стал — текущим статусом выступает фактический размер временного файла на диске. Клиент отправляет очередной фрагмент вместе с указанием смещения (offset), а сервер сверяет его с реальным объемом. При несовпадении сервер возвращает код 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. Доступ к нему предоставляется строго по индивидуальным заявкам, а рассмотрение запроса занимает около месяца.

Поэтому сейчас модуль интеграции с FACEIT честно предупреждает пользователя о временных ограничениях и не пытается скачать заведомо недоступный файл. Основным сценарием по-прежнему остается прямая загрузка архива. Архитектура парсера позволяет интегрировать новый источник силами одной ветки условий, не затрагивая базовую логику, так что подключение матчей с FACEIT и режима Premier по sharecode пройдет бесшовно.

ИИ-аналитика: код определяет ЧТО произошло, модель — КАК это преподнести

Это наиболее захватывающий компонент системы. Первая итерация была наивной: скормить языковой модели сырую статистику матча с формулировкой «проанализируй как профессиональный тренер». Обратная связь от первых бета-тестеров указала на фатальные промахи в фактологии:

  • модель заявляла, что игрок «погиб первым в раунде», хотя фактически он уходил из жизни четвёртым;

  • утверждала, что «у оппонентов эко-раунд», когда на деле у противников был полный закуп;

  • упрекала игрока за то, что он «не донес бомбу», хотя раунд отыгрывался за сторону защиты, где бомба в принципе отсутствует.

Прямые запреты в системном промпте не помогали — модель продолжала галлюцинировать. Это привело к смене базовой парадигмы: всю фактологию рассчитывает детерминированный код, а задача ИИ сводится исключительно к текстовой интерпретации.

Текущая схема функционирования выглядит следующим образом:

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

  2. Генерация выжимки матча. Сырые метрики и покадровые дампы невозможно скормить модели из-за ограничений по стоимости и контексту. Вместо этого формируется текстовая выжимка «матч сквозь призму одного игрока»: экономика закупок, стартовые дуэли, размены, гибели в контексте окружения, расход гранат. Упоминание никнеймов исключено полностью — ни самого подопечного, оперируемого обезличенно, ни остальных девяти участников. Для модели существуют только роли «ты», «тиммейт» и «оппонент», и этого контекста ей вполне достаточно.

  3. Каскад точечных запросов. Программный код отбирает топ-3 ключевые ошибки, после чего модель параллельно генерирует текстовый блок под каждую из них. На финальном этапе отдельным запросом формируется общий вердикт, перечень сильных сторон и персональные рекомендации на основе уже утвержденных блоков.

  4. Верификация каждого блока. Сгенерированные тезисы сверяются с исходной выжимкой раунда. При малейшем несоответствии запускается единственная попытка регенерации, после чего не прошедший проверку блок отбраковывается. Такой грубый текстовый фильтр внедрен сознательно: лучше упустить валидное замечание, чем выдать пользователю выдуманную дезинформацию.

  5. Валидация позиций на карте. Типичная болезнь языковых моделей — путать топонимику карт: переносить A-лонг с одной локации на другую или приплетать банан из Inferno на Dust II. Для каждой карты заведен строгий словарь легитимных позиций, и упоминание чужеродных зон безжалостно отсекается.

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

В качестве языковой модели используется DeepSeek через отечественного провайдера AITunnel. Качество генерируемого контента достигается не переходом на дорогие флагманские модели, а выверенным контекстом и каскадом компактных запросов. Месячные затраты на ИИ составляют символические пару сотен рублей. В реалиях соло-проекта финансово обременительным оказался не сам ИИ, а серверная инфраструктура для парсинга демок, поэтому лимиты на разборы продиктованы исключительно пропускной способностью парсера, а не токенными бюджетами.

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

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

Доска представляет собой интерактивную сессионную комнату, где участники команды могут совместно чертить тактики поверх внутриигровых карт: указывать направления атак, траектории гранат и оборонительные позиции. Ее устройство:

  • эталонное состояние доски персистентно хранится в базе данных и обновляется пользователями с правами редакторов;

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

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

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

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

Еще три архитектурные ловушки, отнявшие массу времени

SSR и белый экран смерти. Страница первично рендерится в среде Node, а браузер в дальнейшем производит гидратацию разметки. Использование стандартного тега для модальных окон приводило к полному удалению корневого контейнера div#app во время гидратации: пользователи наблюдали пустой белый экран, хотя в dev-режиме без SSR всё функционировало безупречно. Проблема решена заворачиванием телепорта в кастомную обертку, активирующую монтирование строго после завершения гидратации. Отсюда сформировалось правило: обращения к объекту window, вычисление ширины экрана и инициализация new Date() допускаются исключительно внутри хука onMounted, в противном случае неизбежны расхождения между серверным и клиентским рендерингом.

PostgreSQL и нюансы кириллицы. Реализуя поисковый функционал по раскидкам, я приводил текст к нижнему регистру с помощью SQL-функции LOWER(). При дефолтной локали базы данных C эта функция игнорирует символы кириллицы: русскоязычный поиск молча возвращал пустые результаты без единой ошибки в логах. На продакшене база развернута с локалью C.UTF-8, тогда как локальное окружение работало на C, из-за чего тесты стабильно падали «на ровном месте». Теперь конфигурация базы проверяется отдельным ассертом в тестах во избежание рецидивов.

Тихий деплой с последствиями. Версия PHP на моей локальной машине оказалась новее серверной. Менеджер зависимостей Composer честно разрешил пакеты под локальное окружение, подтянув библиотеку symfony/cache, несовместимую с установленным на сервере модулем расширения Redis. Процесс деплоя завершался ошибкой, однако веб-сервер продолжал обслуживать запросы на старой кодовой базе. Проблема решилась жесткой фиксацией версий в секции config.platform файла composer.json с учетом особенностей продакшн-окружения и введением негласного правила «никогда не удалять пины версий».

Текущие результаты и планы на будущее

Что уже реализовано и функционирует: интерактивные карты с раскидками, тактическая доска с поддержкой реплеев, база гайдов, загрузка демок с автоматическим расчетом метрик, ИИ-анализ с привязкой к таймлайну ошибок, а также система заявок для профессиональных аналитиков. Платформа официально запущена: монетизация строится на подписной модели и разовых отчетах — детальные условия представлены на самом сайте.

Находятся в разработке или ожидают реализации:

  • автоматизированный импорт демок с платформы FACEIT — ожидаем одобрения доступа к Downloads API;

  • загрузка матчей соревновательного режима Premier по sharecode — требуется развертывание Steam-бота для взаимодействия с Game Coordinator;

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

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

Разрабатывать столь масштабный продукт в одиночку, совмещая его с основными рабочими сменами и бытовой рутиной — задача изнурительная. Но когда подсистема загрузки, парсер, ИИ-модуль и графический реплей впервые синхронизировались в единый конвейер, а разбор матча открылся на экране смартфона с возможностью перемотки к таймкоду ошибки, это дало мощнейший прилив мотивации, ради которого всё и затевалось.

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

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

 

Источник

Похожие статьи

Поиск по играм, новостям и статьям…

Введите не менее двух символов

Введите не менее двух символов