jev-bot: CS:GO-бот, которым управляет языковая модель
📂 Исходный код на GitHubБот для CS:GO под управлением языковой модели. Плагин SourceMod отправляет состояние бота по UDP в Python-мост, модель выбирает тактику на следующие 0,35 секунды, мост возвращает действие. Бэкенды: Julia 1, Laya, локальная модель Hugging Face, HTTP-сервер или набор правил без модели. Только для локального сервера с флагом -insecure.
Это открытый проект бота для 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. Тот же приём переносится на робототехнику, торговые системы и любые задачи, где решения нужны на разных масштабах времени.
Ссылки на файлы
- Исходный код плагина
jev_bot.sp - Готовый скомпилированный
jev_bot.smx - Python-мост
bridge.py - Имитатор плагина
fake_plugin.py - Тесты
test_bridge.py - Конфиг дуэли
jev_duel.cfg - Расширение sm-ext-socket — без него плагин не запустится
- Карточка модели Julia 1
Установка в двух словах
Если коротко, порядок такой. Ставим выделенный сервер 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-расширения, тесты моста и работа адаптеров на подставных моделях правильного формата.