jev-bot: CS:GO-бот, которым управляет языковая модель

· 3 мин чтения
llm game-ai agents sourcemod python open-source
📂 Исходный код на GitHub

Бот для CS:GO под управлением языковой модели. Плагин SourceMod отправляет состояние бота по UDP в Python-мост, модель выбирает тактику на следующие 0,35 секунды, мост возвращает действие. Бэкенды: Julia 1, Laya, локальная модель Hugging Face, HTTP-сервер или набор правил без модели. Только для локального сервера с флагом -insecure.

jev-bot: CS:GO-бот, которым управляет языковая модель

Это открытый проект бота для CS:GO. Бот бегает по карте, слышит шаги противника и в каждый момент решает, что делать дальше. Но решения принимает не обычный код с набором if-ов, а языковая модель. Точнее, модель выбирает тактику на следующие 0,35 секунды, а прицеливание и стрельба остаются в плагине.

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

Сам код не Julia, хотя название репозитория оставляет такое впечатление. Плагин написан на SourcePawn для SourceMod, а «мозг», который спрашивает модель, — это Python-скрипт. Julia появляется только как один из возможных моделей-решений.

Как устроено

Схема простая: две машины и один UDP-порт.

 ┌──────────── Desktop (Windows) ────────────┐        ┌──── Desktop or MacBook ────┐
 │ CS:GO client  ◄──►  srcds + SourceMod     │  UDP   │ bridge.py                  │
 │                       jev_bot.smx (body)  │ ─────► │ rules / julia / laya /      │
 │   aim, fire, reaction, reload, spots      │ ◄───── │ local / http (brain)        │
 └───────────────────────────────────────────┘ 27500  └────────────────────────────┘

На рабочем компьютере стоит выделенный сервер CS:GO с плагином SourceMod. Плагин отправляет по UDP строку с состоянием бота: номер тика, здоровье, броню, патроны, оружие, координаты, текущее действие и список врагов. Отдельно он передаёт услышанные звуки за последние три секунды.

На второй машине (это может быть ноутбук с macOS — так удобнее, если есть видеокарта) запускается bridge.py. Он разбирает строку, составляет список допустимых действий, превращает состояние в текст на английском и спрашивает у выбранной модели. Ответ модель даёт в виде выбора из вариантов с вероятностями. Мост отправляет обратно A <действие> <тик> <run|walk>.

Главное правило: прицел и стрельба модель не трогает. Она выбирает только движение и две дополнительные вещи — стоит ли бежать или идти крадучись, и куда повернуться, если она что-то услышала. Точность стрельбы, реакцию, компенсацию отдачи и точки, из которых бот выходит на позицию, плагин считает сам через cvar-параметры.

Девять действий

Модель выбирает из девяти тактик. Каждая живёт 0,35 секунды, потом приходит новая:

Действие Что делает бот
hold Стоит на месте и держит текущий угол
peek_left Сдвигается влево, чтобы выглянуть, и стреляет, когда враг в поле зрения
peek_right То же вправо
strafe_shoot Идёт влево-вправо и стреляет между шагами. Хорошо вблизи
crouch_spray Приседает и стреляет очередями. Ровнее на средней и дальней дистанции
fall_back Отходит назад, чтобы разорвать линию видимости или спастись
push Идёт вперёд на противника
reload Перезаряжается
face_sound Поворачивается туда, где последний раз слышали или видели врага, и держит этот угол

Вторым вопросом модель выбирает походку: run — бежать, шумно, противник услышит шаги; walk — идти крадучись с зажатым Shift, шагов не слышно. Если модель не принимает два вопроса в одном вызове, мост один раз предупреждает об этом и дальше решает по правилам: идти тихо, когда враг рядом, но не виден; бежать, когда бот идёт в атаку, отступает или перезаряжается.

Бот слышит, но не видит сквозь стены

Плагин подписан на игровые события player_footstep, weapon_fire, weapon_reload и player_jump и реагирует только на звуки противников в пределах заданной дистанции:

Звук Примерная дальность
Шаги ~28 м
Выстрелы ~76 м, с глушителем ~23 м
Перезарядка ~15 м
Прыжок ~20 м

Направление определяется относительно взгляда бота и разбито на восемь секторов: front, front_left, left, back_left, back, back_right, right, front_right. Модель получает это обычной фразой: «You heard footsteps 0.6 s ago, about 11 m behind you on the left.»

Две детали делают механизм честнее. Во-первых, игра не шлёт событие player_footstep, когда игрок идёт с Shift или крадётся, — так что преимущество тихого шага работает и для бота, и для человека. Во-вторых, координаты звука получают случайную ошибку до 8% от расстояния, чтобы бот не превращался в радар.

Через cvar jev_fair_info 1 можно спрятать из состояния здоровье и расстояние до невидимых врагов. Так модель не получает информации, которой у человека не было бы, и сравнение с людьми становится честным. Значение 0 возвращает старую «информацию из стены» — её оставили для замеров.

Модель получает текст, а не числа

Мост переводит состояние в короткий текст на английском. Это не украшение: такой формат понимают и специализированные модели выбора, и обычные чат-модели. Фрагмент функции describe_state из bridge/bridge.py:

lines = [
    "Counter-Strike 1v1 duel. You control one bot; aiming and shooting are automatic, "
    "you only choose the movement tactic for the next 0.35 seconds.",
    f"You: {st.hp} HP, {st.armor} armor, weapon {st.weapon} with {max(st.clip, 0)} bullets "
    f"in the magazine and {max(st.reserve, 0)} in reserve.",
    f"Current action: {st.action}.",
]
if not st.enemies:
    lines.append("No enemies alive.")

Дальше мост отсекает недопустимые действия. Например, стрелять нельзя, если в магазине пусто или никто не виден, а face_sound имеет смысл только когда врага сейчас не видно, но звук или место, где его видели в последний раз, не старше трёх секунд. Так модель никогда не выдаёт бессмысленный ответ:

legal = []
for a in ACTIONS:
    if a in ("strafe_shoot", "crouch_spray"):
        if not (enemy_visible and has_ammo):
            continue
    elif a == "face_sound":
        if enemy_visible or not st.recent_enemy_info(3000):
            continue
    legal.append(a)
return legal

Пять бэкендов решений

Один и тот же протокол, пять разных «мозгов». Это удобно: можно сравнивать их между собой на одних и тех же позициях.

rules — базовая линия без модели. Обычные правила на Python. С ними удобно проверять, что канал связи вообще работает, и потом ставить точку отсчёта: насколько модель лучше или хуже.

julia — модель Julia 1 от Supersonic Labs. Ставится отдельным пакетом, и файлы модели скачиваются скриптом setup_julia.sh или setup_julia.bat, в репозитории их нет. Обращение выглядит так:

from julia import load_model

engine = load_model(
    "Julia-1", device="cpu", strict_encoding=True,
    max_length=1024, head_length=512,
)
result = engine.predict(
    state=state_text,
    questions={"action": {"type": "choice", "instructions": QUESTION, "criteria": criteria}},
)
answer = result["answers"]["action"]
action, probabilities = answer["choice"], answer["probabilities"]

Пакет называется julia — так же, как PyJulia. Ставить оба в одно окружение нельзя, для Julia нужно отдельное виртуальное окружение. Модель умеет работать на CPU и CUDA. На Mac с MPS она может не заработать — тогда мост сам переключается на CPU и пишет об этом в лог.

laya — модель Laya через пакет semantic-operators. Здесь используется интерфейс Choice из semantic-operators, а не текстовый запрос. Принимает cuda, mps и cpu.

local — любая причинная модель с Hugging Face. Вариант без дообучения: варианты помечаются буквами от A до H, делается один прямой проход, и выбирается та буква, у которой логит больше всего. Текст не генерируется вообще:

with torch.inference_mode():
    logits = self.model(**inputs).logits[0, -1].float()
scores = torch.stack([
    torch.logsumexp(logits[self.letter_ids[i]], dim=0) for i in range(len(legal))
])
p = torch.softmax(scores, dim=0).tolist()
probs = {a: float(p[i]) for i, a in enumerate(legal)}
action = max(probs, key=probs.get)

Походку в этом режиме считают по правилам: второй прямой проход удвоил бы задержку.

http — запрос к внешнему серверу. Есть бэкенд TypeSafe с официальным SDK (нужен ключ TYPESAFE_API_KEY, и это не локальный запуск) и сырой вариант с POST-запросом. Сырой вариант в репозитории помечен как заготовка: точный адрес и схема ответа не известны автору, и код нужно править под свой сервер.

Задержка — главный враг

Модель должна успевать отвечать быстрее, чем тик сервера. Мост печатает статистику каждые пять секунд:

[julia] 15.1 dec/s | arrival p50 0.4 p95 0.9 | model p50 27.5 p95 28.3 | bridge p50 28.1 p95 29.0 | network p50 1.0 p95 2.0 | total(rtt) p50 30.0 p95 33.0 max 41 ms | dropped 16 (max burst 1) | stale at plugin 0 | errors 0

Цифры читаются так. model — время, которое ушло на саму модель. arrival — насколько состояние задержалось по дороге от сервера к мосту; если эта величина растёт, дело в сетевом буфере или в очереди. network — то, что осталось после вычитания времени моста из измеренного плагином RTT: сама сеть плюс ожидание в колбэке сервера. stale at plugin — сколько ответов плагин выбросил, потому что состояние успело устареть (по умолчанию больше 16 тиков). Из-за этого бот скорее стоит на месте, чем действует по старым данным.

Практические советы из README:

  • не ставьте --max-hz ниже частоты отправки состояний. Мост начнёт ждать и решать по старому состоянию, и это только добавит задержку;
  • держите --rcvbuf 16384: небольшой буфер приёмника лучше, чем очередь из секунд старых состояний;
  • сначала берите маленькие модели;
  • на Windows включайте план «Высокая производительность», на Mac — не отключайте энергосбережение.

Без игры проверить весь путь можно отдельно: bridge/fake_plugin.py притворяется плагином и отправляет синтетические состояния, а bridge/test_bridge.py проверяет разбор протокола и сквозной путь. В репозитории заявлено 26 прошедших тестов.

Сложность настраивается числами

Уровень сложности задаётся cvar-параметрами, и это, пожалуй, самая полезная часть проекта для понимания. Чем больше задержка реакции и разброс прицела, тем больше времени у человека.

Параметр Что меняет Легко Честно Демон
jev_reaction_ms время от встречи врага до возможности стрелять, мс 420 260 120
jev_aim_jitter разброс прицела, градусы 3.0 1.8 0.3
jev_aim_speed максимальная скорость поворота, градусов за тик 3 5 20
jev_fire_cone угол, внутри которого бот вообще стреляет, градусы 3.5 2.5 1.5
jev_recoil_control компенсация отдачи, от 0 до 1 0.2 0.5 1.0
jev_state_every сколько тиков между отправкой состояний 4 4 2

Пресеты лежат в конфигах: exec jev_facil, exec jev_justo, exec jev_demonio. Режим «честно» подобран под человека с реакцией около 250 мс и не мгновенным прицеливанием. Режим «демон» ощущается как чит — и это, по словам автора, задумано.

Там же есть настройка слуха: jev_hearing 1 включает его, jev_hear_scale 1.0 меняет дальность (0.5 — «наполовину глухой», 2 — «слух летучей мыши»).

Что не работает

Ограничения перечислены честно, и часть из них серьёзная:

  • Видит сквоз дым. Трассировка видимости дым не учитывает. Светошумовые гранаты на бота тоже не действуют.
  • Нет навигации. Движения задаются только относительно взгляда бота. Он может упереться в стену или сойти с края. Позиции задаются вручную командами sm_jev_spot.
  • Слух упрощённый. Дальность фиксированная по типу звука, стены не глушат звук, тип пола не важен.
  • Целится в уровень глаз, не учитывая анимацию хитбокса.
  • Только один бот, и все игроки-люди делят одну и ту же точку human.
  • Одно решение сразу заменяет предыдущее. Выбранный один раз peek живёт примерно одно решение, около 60 мс. Режим rules держит выход из-за угла 0,45 секунды, для моделей это регулируется --commit-ms.

Куда это может пойти

В README есть раздел о планах, и там важная идея — разделить два разных масштаба времени. Быстрая модель (System One) работает 16–32 раза в секунду и отвечает за миллисекунды: что делать в ближайшие доли секунды. Медленная модель (System Two) запускается один раз в раунд, во время заморозки, и получает счёт, деньги, историю раундов и граф точек на карте. Она возвращает план — например, маршрут через long_doors в long_corner и агрессивность 0,7. Мост превращает план в контекст для быстрой модели и в ограничения на допустимые действия.

Планируется также граф именованных точек на Dust2 с командами sm_jev_point add и sm_jev_point link и простым исполнителем, который держит курс на следующую точку.

Именно эта схема «медленная модель планирует, быстрая действует» и делает проект интересным за пределами Counter-Strike. Тот же приём переносится на робототехнику, торговые системы и любые задачи, где решения нужны на разных масштабах времени.

Ссылки на файлы

Установка в двух словах

Если коротко, порядок такой. Ставим выделенный сервер CS:GO через SteamCMD (app 740), внутрь его папки распаковываем MetaMod:Source и SourceMod, а в extensions кладём socket.ext.dll из sm-ext-socket. Копируем csgo/addons/... и csgo/cfg/... из репозитория в сервер и компилируем плагин утилитой spcomp (или берём готовый .smx). Дальше поднимаем bridge.py с нужным бэкендом, задаём позиции бота и игрока командами sm_jev_spot, перезапускаем раунд — и играем.

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

Источник: https://github.com/jrlucas1/csgo-julia-plugin