Продолжаю цикл статей из мира соло-разработки больших систем.
Первый месяц создания игры — медовый. Баги видны почти сразу, работа спорится. Но потом начинается настоящая работа. Баги становятся очень неприятными, когда ты находишь их после двух часов игры.
Например, когда на 1066-й день игры внезапно рушится экономика.
К этому моменту мне и самому надоела рутина — голыми руками проходить сценарии, и я начал искать способ как подрядить на это нейронку.
“Протестируй визуально” работает только с квадратно-гнездовыми интерфейсами и HTML. Чуть более сложный UI (knob-ы, слайдеры, пунктиры) считывается с трудом.
Самый навороченный CLIP Vision уже кое-как умеет распознавать даже сложные цепочки эффектов в аудиоредакторе. К сожалению, с непростительными ошибками, в играх он тем более разочаровывает. Как нельзя скормить нейросети изображение игрового интерфейса чтобы она сделала один в один, так она и не понимает, что на картинке происходит.
В проекте уже было четыре сотни Playwright-тестов, но этого было мало — нужно было находить экономический дисбаланс и прочие глюки сложной системы. Просто кликать, отправлять скриншот и данные на анализ нейросети, принимать решение, кликать куда-то ещё — процесс долгий и достаточно подробно описан в научных исследованиях. Вердикт — такое тестирование даже вроде как работает, но медленно и дорого.
Итак, что мы имеем после недели вечеров погружения в ML для тестирования.
Пациент
Подопытный — браузерная космическая стратегия с открытым миром в духе старых “Космических рейнджеров”: TypeScript, PixiJS 8, процедурная галактика, торговля, банк, квесты, боёвка и захватывающий галактику ИИ-противник (Овермайнд). То есть игра не набор экранов, а связная экономико-боевая система, где баги баланса видны только на длинной дистанции.
Классическая пирамида тестов у меня к этому времени уже была, причём 5115 тестов в три яруса:
-
Юнит-тесты на чистые функции (Vitest, всё замокано);
-
Playwright-тесты на реальном движке — ловят баги интеграций между компонентами (застаблен только PixiJS);
-
Playwright E2E — headless-браузер, реальные клики, реальный рендеринг, ловит интерактивные и визуальные баги.
И вся эта пирамида была бессильна против вопросов вида “а не выгоднее ли просто положить стартовый капитал на депозит и ждать?” или “почему в первые три дня выходит сообщение что галактика захвачена и вы проиграли?”
Диалог, с которого всё началось
Очередной раз сев за проект, я задался вопросом: а зачем я всё-таки подключил onnxruntime-web на 129Mb? Годной модели под игру (предполагались ИИ-текстовые квесты) я так и не нашел. Прежде чем его выпиливать, вступил в диалог с ассистентом:
-
Давай придумаем, как использовать onnx-runtime.
-
Можем обучить свою нейросеть для управления Овермайндом.
-
Для такой маленькой нейросети нужен модуль на 129 Мб?
-
Нет, можно запускать напрямую на GPU и хранить в json
-
Так. А давай с управлением овермайндом подождём и сделаем автопилота, который заменит нам Playwright-тесты и будет заглядывать в недостижимые для них уголки сценариев?
-
Давай.
–129 МБ, +50 строк
Warning: дальше много технических деталей из мира Machine Learning. Я сам многое узнавал по ходу дела.
И всё-таки почему не LLM? Выше я вскользь уже отвечал на этот вопрос, давайте разберем подробнее. Игровая сессия это тысячи решений, а тестовый прогон — сотни сессий. Даже по центу и по секунде на решение это выходит безумно дорого и долго. А главное — найденный баг обязан воспроизводиться на том же сиде шаг в шаг, иначе это не тест, а анекдот. Мозг агента должен стать бесплатным, мгновенным и детерминированным. Это описание нейросети размером с пылинку, не большой языковой модели.
Первым делом я выпилил ONNX-рантайм. Крошечного MLP (многослойного перцептрона) должно было хватить. Его forward pass это буквально “умножить матрицу на вектор, ReLU, повторить”:
function dense(layer: MlpLayer, input: readonly number[]): number[] { return layer.W.map((row, j) => { let sum = layer.b[j]; for (let i = 0; i < row.length; i++) sum += row[i] * input[i]; return sum; });}export function forward(weights: MlpWeights, input: readonly number[]): number[] { let acc = [...input]; weights.layers.forEach((layer, i) => { acc = dense(layer, acc); if (i < weights.layers.length - 1) acc = acc.map(relu); }); return acc; // логиты, argmax снаружи}
Веса модели это просто JSON в полтора мегабайта. Это массив слоёв, в каждом — двумерный массив чисел W и массив b. Обучается сеть в Python, поэтому числа в JSON лежат в том же порядке, в котором их хранит PyTorch — по строке на каждый выходной нейрон. Экспорт из PyTorch это сериализация 1-в-1, без разворота матриц, и коду выше JSON скармливается напрямую. Никакого wasm, воркеров, GPU-диспатча. 50000 умножений это микросекунды на CPU, можно дергать хоть на каждый кадр.
Почему так, а не через TensorFlow.js или brain.js? Потому что это добавляет мегабайты кода со своими сюрпризами при обновлениях к браузерной-only игре ради модели размером с favicon.ico.
Чтобы Python-сторона и TS-сторона не разъехались, есть parity-тест. Обученная в PyTorch модель на детерминированном входе обязана дать те же логиты, что TS-форвард, с точностью 1e-5. Этот тест дважды находил расхождения при рефакторингах.
Летсплей-движок для сбора датасета
Сначала был эвристический letsplay-тест на playwright, от одного потока которого мой ноутбук “взлетал” при каждом пуше. Дорого, медленно, вентилятор взвывает. Тогда я застабил PixiJS, отменил рендер картинок в тестах, от чего стало возможным запускать 7-10 потоков с относительно быстрым (в рамках минуты) результатом. Тысячи игровых дней в секунду вместо минут.
Работает это как самый обычный игровой цикл:
observe(engine) → Observation → policy.decide(obs) → Action → apply(action) → tick
Если простыми словами — эвристический агент это функция. На вход — объект с тем, что видит корабль прямо сейчас (сцена, топливо, корпус, кредиты, планеты, враги рядом, квесты). На выход — строка-действие из фиксированного словаря.
const ACTION_ORDER = [ "move", "warp", "land", "dock", "trade", "take-quest", "attack", "flee", "repair", "wait", "buy-gear", "upgrade-hull", "buy-module", "take-loan", "repay-loan", "sell-cargo", "arm-up", "liberate",] as const;
От кликов вместо словаря глаголов я отказался сразу потому что при любом редизайне пришлось бы перегенерировать весь датасет заново и обучать заново. А так — headless и live исполнители реализовывали одно и то же, просто разными методами.
В dev-режиме я реализовал с помощью этого агента режим авто-пилота. Удобно — нажимаешь backquote (`) и видишь насколько умён автопилот и чего он не замечает, где циклится. На странностях я сделал шорткат Shift+F, который делает скриншот+стейт с припиской “Тут что-то не так, потом разберемся”, чтобы потом оракулы (о них позже) находили по трейсам места с тупняками.
Одно решение, которое определило дальнейшие действия — должен ли агент знать всё о мире, или только то, что видел бы и игрок — пока не открыл Маркет, не слетал в соседнюю систему — не знает что там. И я решил сделать нейросеть с памятью — POMDP (partially observable — частично наблюдаемый мир).
У агента появилась память. Это обычный объект, который живёт между шагами. Обычно в ML память живёт внутри модели, вектором чисел, но так её сложнее логировать, тестировать и дебажить. Плюс обучать с внешней памятью гораздо проще — это простейшая классификация “вектор -> действие”, без возни с последовательностями.
Учитель
BC-нейросети (behavioral cloning) предполагают, что есть некий учитель. Который знает правильный ответ или как минимум ранжированный список приоритетов. Он создавался путем проб и ошибок в процессе наблюдений за автопилотом на эвристике. В порядке важности:
-
Выживание. Топлива меньше 20%, в системе есть обитаемая планета — садимся, посадка заправляет. Лечит зависание в захваченной системе без горючего.
-
Угроза. При слабом корпусе — сразу бегство в другую ближайшую систему.
-
Прибыль. Из памяти известно где можно купить-продать с большой разницей. Если нет — разведка.
-
Прогресс. Прохождение квестов. Если по карману апгрейд — покупаем.
-
Исследование. Иногда прыжок в неизвестную систему. Вдруг там есть в продаже корпус получше.
-
Ожидание на месте. На всякий случай, чтобы всегда было хотя бы это легальное действие.
Запустив летсплей-тест тысячи раз, проанализировав трейсы, я обнаружил множество странностей. Некоторые из них удалось обнаружить только глядя на автопилот.
-
Оружие и корпуса могут быть дороже, но гораздо слабее;
-
NPC были почти вдвое быстрее стартового корабля;
-
Доступность топового железа уже в начале игры и быстрый заработок (можно выиграть очень быстро и стать неуязвимым);
-
Возможность разворачиваться на месте приводит к дерганию камеры, а когда противник кружится вокруг тебя — это очень тупо выглядит;
-
Вклады под 44% годовых (сложный процент 0.1%/день). Выгоднее положить стартовый капитал на депозит и ждать, чем играть;
-
Везде одинаковое солнце, хотя задумано 30 вариантов.
Каждая находка превращалась не в разовый фикс, а в инвариант — правило, которое обязано выполняться всегда, оформленное обычным тестом. Например, “ни одна пушка не должна быть слабее более дешевой”.
Обучаем нейросеть. Сага о граблях.
А зачем тогда вообще обучать нейросеть если эвристика уже играет? Три причины.
Первая — потолок. Эвристика играет по строго заданным алгоритмам и правилам. Она не удивляет как настоящий игрок.
Вторая — JSON с весами плюс 50 строк весят как две картинки и спокойно идут в бандл как фича. Кода тестов гораздо больше.
Третья — задел для Овермайнда. Весь этот трубопровод (датасет-обучение-веса-инференс) один в один понадобится при обучении ИИ главного злодея.
Схема обучения называется behavioural cloning. Учитель просто играет и пишет что видел и что сделал. Дальше это обычная задача классификации, как определение спама. Числа на входе — номер действия на выходе.
Здесь у читателя может возникнуть закономерный вопрос — а где же reinforcement learning? Где агент, который умирает миллион раз и сам открывает стратегии? RL я отложил. Ему нужна функция награды, а в настолько забагованной игре нейросеть быстро научится эксплуатировать любую награду. Такой агент нашел бы стратегию победы за 5 секунд. Но не баги.
На входе моей MLP — что видит + память + топливо/сцена/трюм/корпус/оружие и тд — всего 54 ячейки. 30 — что вижу, 20 память, 4 — сюжет.
Сначала это была микроскопическая 18x32x10 нейросеть (18 входов (контекстное окно), 32 нейрона, 10 действий), 938 весов, датасет из 3600 строк. Обучилась на CPU ноутбука за секунды. Она умела садиться на планету, открывать рынок и взлетать. И тут началось.
Грабли №1. Потолок 67%, который решался математикой.
Точность классификации уперлась в 67% и не росла ни от эпох обучения (1000, 10000 прогонов), ни от дополнительных слоёв, ни от танцев с learning rate. Я уже был готов поверить, что нейросети — не моё.
Разбор датасета показал что в нём всего 8 уникальных векторов-признаков. На тысячи строк. Восемь. И четыре из этих восьми были с метками trade и move. А лучшее что может сделать в этом случае классификатор — писать “если было А, то Б, а если было Б, то А”, то есть зациклить АБАБАБАБ…
Решилось обогащением признаков (добавил тип сцены и признаки в память) а в эвристике заменил торговую заглушку реальным исполнением — точность выросла до 83%.
Грабли №2. Сеть-домосед.
Сеть стала отлично торговать и никогда не прыгала в другие системы. Учитель всегда предпочитал сесть на планету, нежели прыгнуть. Прыжков было мало в логе. В признаках не было “товар в трюме, дороже можно продать только в другой системе”. А сессии, где прыжки были, тонули в общей массе.
Добавил недостающий признак, поправил приоритеты учителя. Прыжки появились. А точность упала — с 83% до 79%. Смесь “прыгать или торговать” классифицируется тяжелее, чем “сел и торгуй”. Но сеть уже начала больше исследовать галактику и играть стала заметно дольше и лучше.
Тут тот же урок что и с большими LLM — бенчмарки на проверочных данных не есть показатель качества, только живой прогон.
Грабли №3. 99% точности = полный паралич.
К этому моменту словарь действий разросся, появилось понятие CJM — цепочки действий как единый юнит, “сел, купил, прыгнул, прыгнул, сел, продал дороже”. Датасет стал отборной солянкой из сессий с разными целями. Сеть выучилась на 99% точности.
На живом прогоне — встало. Выбирала одно и то же действие. Всегда. Стояла в космосе и ждала. Вечно.
Причина — самая коварная из всех. Цель игры в вектор признаков не входила. Сеть выучила среднее между “исследователь” и “торговец”, а поскольку при одних и тех же вводных, у них разные действия, сеть оставалась на месте.
Переобучил на данных с одной детерминированной политикой — точность выросла до 99.7%, и живой прогон стал разнообразным и долгим.
Грабли №4. Мертвые головы
Перед следующим большим обучением (на больших датасетах, длиной в 3 игровых года) я завёл валидатор датасета — маленький скрипт, который печатает отчет — сколько строк пришлось на каждое действие. Он показал что 8 из 16 действий (словарь тогда был из 16 действий, arm-up и liberate добавились позже) не встречаются ни разу.
Потому что как только учитель ввязывался в бой с гарнизоном Овермайнда, он не мог ни победить, ни отступить, сессия сжигала весь бюджет шагов на цикл атака-бегство.
Здесь пришлось переписывать и учителя, и игру. Переписал боёвку с большим радиусом поражения по уже покрытому тестами коду, тогда же учитель получил ярус “угроза” в списке приоритетов, чтобы не попадал в такие ситуации.
Грабли №5. Неполные предложения.
Выход сети — 18 чисел, по одному на действие. 18 глаголов. А исполнителю нужно ещё и существительное — id планеты. Реализовано это “телепатическим” методом — в TS обвязке появился метод, который дополняет выбранный глагол существующей целью и написан тест, который обязывает прогон с нейросетью пройти без ошибок и реализовать как минимум три разных действия (глагол+существительное должны быть уникальными).
Итоговая сеть сегодня — 54-256-128-18, это около 49к параметров. По меркам современного ML это даже не нано. Датасет генерируется на M1, а обучается сеть за минуты на RTX4090-боксе, хотя на ноуте было бы ненамного дольше, сеть крошечная.
Получившиеся веса стали “авто-пилотом”, этот режим теперь может включить игрок в dev-сборке.
Оракулы
Теперь — как это всё заменяет ручное прохождение. Агент играет и складывает каждую сессию в лог-транскрипт. Куда летел, что покупал, с кем дрался, сколько заработал. Над логами стоят четыре “оракула”: ошибок, тупиков, баланса, прогресса.
Все оракулы детерминированы и дёшевы. Кроме прочего, поверх них, после тысяч прогонов, я вручную заставляю пролететь LLM-судью в vision-варианте, то есть с анализом и логов, и скриншотов, и по моим наводкам (Shift+F) она создаёт правила-инварианты.
Почему судья не гейт при пуше? Потому что гейт должен быть детерминированным. Флакающий гейт хуже отсутствующего. LLM-гейт медленный, платный, каждый раз отвечает чуть по-разному, поэтому его место — облёт с отчетами, а право блокировать пуш остается у скучных быстрых проверок.
Теперь агент — это градусник. Лечить надо игру.
Самое важное правило, которое привнёс работающий автопилот, уместилось у меня в одну строчку:
когда агент выигрывает слишком быстро, проигрывает слишком быстро или ведёт себя как идиот, чини игру, а не агента.
В эвристическом учителе для headless и visual режимов была разная логика. Это привело к “слепоте” агента к некоторым элементам интерфейса. Сейчас удалось добиться того, что один и тот же нейросетевой автопилот умеет играть и там и там.
Что в итоге
-
Летсплей-движок для сборки датасета.
-
Учитель с ранжированным списком приоритетов (на самом деле правил там гораздо больше, я дал сильно упрощенный список)
-
Нейросеть-автопилот — MLP на 49к параметров
-
Петля обратной связи от человека (Shift+F)
Собранные данные помогли мне распланировать и реализовать адаптивную арку игры растянутую на 3 игровых года, то есть сделать так, что галактика развивается в зависимости от развития игрока.
Советы тем, кто пойдет этой дорогой:
-
Не парсите пиксели, читайте движок.
-
Каждая найденная странность должна умирать как класс багов. Инвариант вместо разового фикса.
-
Проверяйте датасет руками/скриптами перед обучением.
-
Тщательно планируйте вход сети.
-
Оценивайте живой прогон, а не точность на бенчмарках.
-
Важен и headless и live — расхождения между данными в них — самый дешевый детектор визуальных багов.
Всё это применимо к любой сложной stateful-системе: словарь действий вместо кликов, эвристика-учитель, оракулы над логами — так можно тестировать CRM, редактор, трейдинг-бота.
А главный злодей с ИИ вместо NPC логики всё ещё в бэклоге. Зато теперь для него готова вся инфраструктура.
Я поправил баланс и затупы. Но, как я уже говорил, визуальное тестирование сложных интерфейсов с помощью ИИ на данный момент не так эффективно. Поэтому, по традиции, приглашаю к бета-тестированию: https://alterfo.github.io/vacuum-rogues/. Самый активный репортер получит доступ к коду.
ссылка на оригинал статьи https://habr.com/ru/articles/1056722/