Агент сам запускает игру, играет в неё и выносит вердикт
The Sorting Bureau это уютная головоломка про сортировку мелочей на столе. Unity 6, вышли в Steam, живём патчами, делаем вдвоём: я на коде и геймдизайне, художница на арте. Статья про машинерию проверок: как Claude Code запускает игру, водит её как игрок, выносит вердикт, собирает билд и прогоняет регресс на том самом бинарнике, который поедет игрокам.
Дальше будут слова из нашей игры, поэтому сразу словарь на два абзаца и две картинки, иначе половина примеров окажется непонятной.

Игрок работает в бюро находок. Есть комната, она же хаб: место, откуда всё начинается. В ней стоит полка, на полке лежат заказы от клиентов, каждый в виде коробки с запиской. Игрок берёт заказ, смотрит, чего от него хотят, и жмёт «Сортировать». Экран уезжает на стол.

Ещё три вещи, которые будут всплывать. Между днями показывается экран перехода дня, где игрок выбирает напиток: он меняет число предметов в заказах этого дня, примерно от минус тридцати до плюс сорока процентов. На столе иногда лежит письмо от персонажа, и пока его не прочитали, заказы на полке не раскрываются. И есть фотоальбом со снимками законченной работы, он открывается не сразу, а на определённом дне, и дальше это пригодится.
Когда в проекте появился агент, код стал писаться в разы быстрее, а проверка осталась прежней. Агент правил драг-энд-дроп, писал «код собран, компилируется», и на этом выдыхался. Дальше я запускал Unity, заходил в игру, водил мышкой и смотрел.
Асимметрия выходила смешная: правка две минуты, проверка пятнадцать, и делает её человек, пока агент простаивает. Хуже того, агент начинает отчитываться формулировками вроде «фича реализована», хотя реализован код, а работает ли он в рантайме, не знает никто.
Значит проверку тоже надо отдавать агенту. И вот тут выяснилась неприятная штука: отдать мало, потому что агент отлично умеет проверять не то. Он напишет тест, зелёный на любом коде. Посмотрит на скриншот и увидит там то, что ожидал увидеть. Прогонит команду и не заметит, что она молча ничего не сделала. Поэтому половина построенного отвечает не на вопрос «как автоматизировать», а на вопрос «как сделать, чтобы автоматическая проверка не врала».
Проверки живут на трёх уровнях, и они честно разные по природе.
Первый уровень это обычные юнит-тесты чистой логики: формулы сложности заказа, генераторы, рандомизация, валидация ScriptableObject-настроек. Всё, что обходится без движка. Мы вынесли их в отдельный маленький проект на голом dotnet test, который линкует нужные исходники прямо из Assets/ и прогоняется примерно за секунду. Секунда тут принципиальна: тест в секунду гоняют после каждой правки, тест в минуту гоняют раз в день, а потом перестают.
Второй уровень это живая игра. Запустить, довести до нужного состояния, выполнить действия игрока, посмотреть, что получилось. Здесь работает связка из драйвера, автоигрока и сценариев, и про неё дальше большая часть статьи.
Третий уровень это человек. Не потому что мы не доделали, а потому что часть вопросов структурно вне досягаемости автоматики. Об этом отдельный раздел в конце, и он, возможно, самый важный.
Отдельно скажу про то, чего у нас нет и почему. Unity Test Framework внутри редактора не видит игровой код: тесты живут в своей asmdef-сборке, а Assembly-CSharp в неё не пускают, ссылку на неё Unity молча игнорирует. Мы перепробовали три обходных пути, все три провалились, и записали это в документацию прямым текстом. Не пробовать заново, единственный настоящий путь это рефакторинг всех сборок на день-два, и он пока не окупается. Такая запись экономит больше времени, чем кажется: через полгода никто не полезет решать заново уже решённое.
Идея простая. Игре нужен канал, по которому снаружи приходят команды, а изнутри уходит состояние. Мы сделали его на файлах.
Питоновский скрипт пишет Temp/agent-cmd.json с порядковым номером и телом команды. Внутри игры висит компонент, который в Update следит за mtime этого файла, видит новую команду, выполняет её и кладёт ответ в Temp/agent-result.json с тем же номером. Скрипт поллит результат, дожидается своего номера и читает ответ. Корреляция по номеру нужна, чтобы не подобрать чужой протухший результат от прошлого вызова, а заодно она страхует от гонки: недописанный или битый файл просто не даёт совпадения по номеру, и поллинг спокойно ждёт следующий кадр.
Звучит примитивно, и это ровно то, что нужно. Файловый канал не зависит от того, в фокусе ли окно редактора, не требует сетевого порта, работает одинаково в редакторе и в собранной игре, переживает domain reload. Мы пробовали умнее, через MCP, и подводил он ровно тогда, когда агент работал автономно, а окно Unity уходило на второй план: команда входа в Play Mode просто не доезжала.
Каналов на самом деле два, потому что жизненный цикл Unity разрезает задачу пополам. Один живёт в редакторе под [InitializeOnLoad] и умеет то, чего рантайм не умеет в принципе: войти в Play Mode и выйти, запросить компиляцию. Второй это обычный MonoBehaviour внутри запущенной игры. Для агента это один инструмент, командная строка сама решает, в какой канал уходит подкоманда:
agent_drive.py status # жив ли редактор, в каком режиме
agent_drive.py play # войти в Play Mode и дождаться готовности
agent_drive.py snapshot # структурный снимок состояния игры
agent_drive.py shot # скриншот Game View, отдаёт путь к PNG
agent_drive.py cmd "day.set 3" # любая команда дев-консоли
agent_drive.py errors # буфер ошибок за сессию
Снимок состояния это то, на чём вообще держится вся конструкция. Игра отдаёт плоский JSON на полсотни полей: какой сейчас экран, идёт ли переход, есть ли активный заказ и какой у него прогресс, сколько предметов на столе, что лежит в каждой коробке, какие заказы на полке, открыт ли фотоальбом, какой выбран напиток, сколько накопилось ошибок. Собирается он вручную, полем за полем, и это осознанно: автогенерация вывалила бы полтысячи полей внутренних состояний, среди которых невозможно выбрать нужное. Дальше любое утверждение о состоянии игры это просто сравнение с полем снимка.
Отдельно про ошибки. Компонент подписан на Application.logMessageReceived и держит кольцо последних уникальных записей с текстом и стеком. Повторы схлопываются в одну запись со счётчиком, иначе первый же NRE в Update выдаёт тысячу одинаковых строк и вытесняет из буфера всё остальное. Агент после прогона спрашивает errors и видит, что игра ругалась, даже если визуально всё прошло гладко.
Самая дорогая ошибка в таких прогонах выглядит безобидно. Агент пишет: запусти игру, подожди четырнадцать секунд, выполни команду, подожди четыре секунды, сними скриншот. Работает через раз, а когда не работает, выглядит как баг игры.
Слепые паузы мы вычистили почти полностью. Вместо них шаг ожидания говорит про состояние: жди, пока экран не станет столом, пока не закончится переход хаба, пока не остановится бот. Условия читаются из того же снимка состояния и умеют сравнивать числа, строки и подстроки.
Разница не в красоте. Слепая пауза врёт в обе стороны. Слишком короткая, и замер попадает на едущий DOTween-твин, после чего агент уверенно докладывает о несуществующем дефекте. Слишком длинная, и прогон вместо минуты занимает пять. В базе провалов у нас на это отдельный урок с шестнадцатью зафиксированными случаями, и почти все они об одном: значение сняли, пока по нему ещё ехала анимация.
Есть и обратная ловушка, её мы поймали позже. Некоторые вещи в игре показываются недолго, всплывающая подсказка живёт пару секунд. Ждёшь «пока не появится», уходишь анализировать предыдущий кадр, а возвращаешься уже к пустому экрану. Лечится это не паузой, а замедлением Time.timeScale на нужном участке и плотной серией кадров без анализа между ними. Разобрать снятое можно и потом.
Отдельные вызовы драйвера склеиваются в цепочку. Одна команда, внутри последовательность шагов обоих каналов. Было так:
agent_drive.py play && sleep 14 && \
agent_drive.py cmd "menu.slot 3" && sleep 15 && \
agent_drive.py cmd "drink.set green_tea" && sleep 2 && \
agent_drive.py shot
Шесть запусков скрипта и тридцать три секунды слепых пауз. Стало так:
agent_drive.py run @slot 3 "drink.set green_tea" shot
Один вызов, паузы ровно по факту переходов. @slot тут это готовый сценарий-макрос из папки: войти в игру, зайти в слот с указанным номером, дождаться хаба. Внутри цепочки шаги разных типов различаются префиксом: wait: ждёт состояния, sleep: слепая пауза для случаев, где ждать реально нечего, timeout: поднимает потолок ожидания для длинных шагов, read: читает поле объекта, а любой другой токен уходит в дев-консоль как команда.
Цепочка рвётся на первой же неудаче и говорит, на каком шаге из скольких. Продолжать после осечки бессмысленно: дальше замеряется уже не то состояние, которое имелось в виду, а результат при этом выглядит настоящим.
Ещё у цепочки есть сухой прогон: она разбирает и проверяет весь план, печатает, что именно выполнится, и не трогает Unity вообще. Оказалось неожиданно полезно. Редактор часто занят другой сессией, а опечатку в шестом шаге хочется поймать до того, как первые пять изменили состояние игры.
Драйвер умеет исполнять команды, но не умеет играть. Играет отдельная штука, которую мы называем дев-ботом. Это конечный автомат, живущий внутри игры под флагом читов: выбрать заказ с полки, посмотреть коробку, нажать «Сортировать», разложить вещи, вернуться в хаб, взять следующий.
Задача у бота двойная, и это стоит сказать сразу, иначе половина его поведения выглядит блажью. Он не просто закрывает заказы, он проживает сессию целиком, со всеми побочными вещами, которые делает живой игрок. Именно из этого растут все странности ниже.
Режима у бота два, и разница между ними принципиальная.
Быстрый режим перетаскивание не имитирует, он раскладывает вещи по контейнерам напрямую служебным вызовом. Так проходятся длинные прогоны на баланс и контент: сотня заказов подряд, чтобы поймать тупик прогрессии или заказ, который невозможно закрыть.
Режим настоящего перетаскивания честно двигает курсор. Здесь мы сделали единственную нетривиальную вещь во всей конструкции. Контроллер драг-энд-дропа читает ввод не напрямую из Input, а через маленький интерфейс источника указателя. По умолчанию это обёртка над обычным вводом один в один. Бот на время прогона подменяет источник на скриптовый и водит курсор сам, а контроллер о боте не знает вовсе, он просто работает с тем вводом, который ему дают.
Дало это неожиданно много. Через настоящий жест проходит вся механика: захват предмета, следование за курсором, попадание в зону, притяжение к месту, каскад физических реакций соседей. Быстрый режим всё это минует, и регресс на нём был бы вечно зелёным именно на самом дефектном классе кода.


Возвращаясь к проживанию сессии. Заходя в комнату, бот с вероятностью примерно один к пяти идёт полистать фотоальбом или сфотографировать вид из окна, но не чаще раза в три заказа, чтобы «иногда» не превратилось в ритуал. На экране перехода дня выбирает случайный напиток, а не просто жмёт «продолжить».
Последнее выяснилось неприятным образом. Пока бот жал «продолжить», все длинные прогоны шли вообще без напитка, то есть без той самой ручки, которая крутит объём заказов на десятки процентов. Балансные выводы из тех прогонов оказались занижены, и никто этого не видел, потому что прогоны честно проходили от начала до конца. Ошибка была не в коде и не в тесте, а в том, что бот вёл себя не как игрок в одном-единственном месте.
Отдельно бот считает, во сколько часов живой игры превращается прогон. На каждом пропущенном событии прибавляет константу, на каждом заказе считает по формуле от числа предметов, коробок и типа фильтра. Цифра приблизительная и наверняка врёт процентов на двадцать, но отвечает она на вопрос продюсера, а не программиста: сколько игрок будет это проходить.
Водить игру мало. Нужен кто-то, кто скажет, правильно ли то, что получилось.
Мы развели два разных глагола, и это оказалось важнее, чем выглядит.
Один глагол ждёт. Он поллит состояние, пока условие не сойдётся, и это часть вождения: игра живая, переходы едут анимацией, ждать нормально.
Второй утверждает. Он смотрит на состояние здесь и сейчас, и если условие не выполнено, прогон падает с отдельным кодом и печатает расхождение по каждому непрошедшему условию. Повторных попыток тут нет намеренно. Повтор превратил бы утверждение обратно в ожидание и замаскировал бы ровно тот дефект, ради которого сценарий написан.
Между ними стоит защита, которую поставил накопленный опыт. Сценарий это текстовый файл, шаг на строку, и валидатор разбирает его целиком до первого обращения к игре. Если утверждение стоит сразу после шага, меняющего состояние, файл отвергается ещё на разборе, с объяснением. Такой замер попал бы на едущий твин и соврал бы, поэтому дешевле не пустить его в прогон вообще, чем потом разбираться в странном красном.
Коды возврата разведены осмысленно. Ноль это всё сошлось. Два это прогон не доехал до вердикта, таймаут или игра не ответила. Три это кривой сценарий, ошибка в самом тесте. Четыре это вердикт: игра сломана.
Различие между двойкой и четвёркой стоило нам отдельного правила приёмки. Новый сценарий засчитывается, только если на сломанном коде он даёт четвёрку, а не двойку. Звучит педантично, пока не увидишь это на практике. Сценарий ждёт, что заказ завершится. На сломанном коде заказ не завершается никогда. Ожидание истекает по таймауту, и регресс на реальном баге выглядит как подвисшая Unity, то есть теряется ровно то различие, ради которого коды и разведены. Две первых редакции сценариев вели себя именно так, и обе пришлось переписать.
Вот настоящий регресс из набора, слегка почищенный от комментариев. Проверяет он путь «игрок взял заказ, передумал и вернулся в бюро»: заказ обязан вернуться на полку, а не раствориться. Саёко в нём это наша сотрудница бюро, а не клиент, и взята она не случайно, об этом чуть ниже.
# @level: red
# @needs: fresh-save
timeout:90
wait:screen~Room,hubTransitioning=False
# постановка сцены: детерминированная полка вместо случайной
mail.close
wait:mailBlocking=False
shelf.clear
shelf.add Sayoko
order.replace "Sayoko - First Lesson"
wait:shelfOrderTemplates~Sayoko - First Lesson
# вход в заказ путём игрока: клик по коробке, потом кнопка
hub.clickbox 0
wait:orderInspectOpen=True
ui.click Sort
wait:screen~Desk,hubTransitioning=False
expect:hasOrder=True
expect:thingsOnTable>0
expect:orderTitle~Sayoko - First Lesson
# выход через меню паузы, как это делает живой игрок
game.pause
wait:pauseOpen=True
ui.click ReturnToHubButton
wait:screen~Room,hubTransitioning=False
# суть регресса: наш заказ снова на полке
expect:shelfOrderTemplates~Sayoko - First Lesson
Тут видно почти всю идеологию сразу. Директивы в шапке говорят раннеру, что сценарию нужно: @level это критичность, @needs: fresh-save требует чистого слота, потому что на пожившей партии состав полки другой и числа утверждать нельзя. Есть ещё @dirties: session для сценариев, которые оставляют игру в нестандартном месте: увидев такую пометку, раннер перезапустит игру перед следующим сценарием.
Первый блок ставит сцену служебными командами, и это законно. Второй и третий идут строго путём игрока: клик по коробке, кнопка «Сортировать», меню паузы, кнопка возврата. Утверждения стоят в двух местах, а не одно в конце, чтобы красный сразу говорил, какая половина сломалась.
Отдельная мелочь, за которую мы заплатили несколькими часами. В конце проверяется присутствие конкретного шаблона на полке, а не количество заказов. Счётчик сошёлся бы и в случае, когда взятый заказ пропал, а полка догенерировала другой, то есть ровно в том сценарии потери, ради которого регресс и написан.
Полсотни полей покрывают не всё, а расширять снимок ради одной проверки дорого: это правка C# и пересборка билда. Поэтому ключом условия может быть любой член живого объекта, до которого дотягивается рефлексия: тип она ищет по имени, инстанс берёт из DI-контейнера или со сцены, читает поля и свойства, в том числе приватные.
expect:@HubFlowController.CurrentDay=1
expect:@SaveController.HasActiveOrder=true
А ещё есть то, что видит игрок глазами. Регрессии локализации проявляются не в полях, а в тексте на экране, поэтому отдельный вид ключа читает видимую подпись по имени объекта:
expect:$NewGameButton~Открыть бюро
У такого чтения три особенности, все выстраданные. Читается только видимое: выключенный объект и панель с нулевой прозрачностью не считаются, потому что утверждать про невидимый текст так же нечестно, как жать на невидимую кнопку. Неоднозначность это ошибка, а не «возьму первый попавшийся»: если подходящих объектов несколько, сценарий падает и печатает их имена, потому что молчаливый выбор дал бы тест, который читает не тот текст и об этом не говорит. И наконец, опечатка в имени типа или поля не превращается в ожидание несбыточного: сценарий сразу падает кодом «кривой сценарий» и называет то, что не нашлось. Разница принципиальная, потому что молчаливое ожидание невозможного условия выглядит как «игра тормозит», и на этом уже терялись часы.
У каждого сценария в первой строке стоит уровень, и это обычный текстовый комментарий, который читает раннер.
Красный ломает игру совсем или ломает важное: сохранение, невозможность закрыть заказ, потерянный прогресс. Жёлтый это неприятные баги и вещи, которые могут не работать: настройки не сохранились, интерфейс поехал на сверхшироком мониторе, письмо не перенеслось на следующий день. Зелёный важен, но на фоне остального не страшен. Отдельно стоят служебные, которые проверяют не игру, а сам механизм суждения, и вспомогательные, которые раннер зовёт сам и никогда не ставит в очередь.
Метка живёт именно в шапке файла, а не в имени и не в папке, и это осознанно. Имя файла врёт при переименовании, а папки заставляют физически двигать файл каждый раз, когда меняется оценка критичности. Оценка меняется чаще, чем кажется: то, что вчера было жёлтым, после жалобы игрока становится красным.
Смысл градации чисто практический:
qa/run.py --level red # перед срочным хотфиксом, около минуты
qa/run.py --level red,yellow # обычная проверка перед патчем
qa/run.py # всё, перед релизом
qa/run.py --only order-retake # один сценарий, когда чинишь его же
Без уровней выбор стоял бы между «прогнать всё» и «не прогонять ничего», и на практике выигрывало бы всегда второе. Сейчас в наборе 36 сценариев: тринадцать красных, восемнадцать жёлтых, три служебных и пара вспомогательных. Красные это самое дорогое: новая игра с пустого слота, письмо открывается кликом и снимает блокировку с полки, взятый заказ возвращается на полку, если игрок передумал, заказ проходится настоящим перетаскиванием, сыгранное переживает закрытие игры, битый файл сохранения не исчезает молча.
Сценарий рождается не от желания «покрыть фичу тестами», а от конкретного трупа. Что-то сломалось, мы это починили, и теперь надо, чтобы оно не сломалось снова. Отсюда и вся форма файла.
Первое: половину объёма файла занимают комментарии, и это не многословие. В сценарии записано не только что делаем, но и почему именно так, потому что через три месяца это единственный способ не переоткрыть уже закрытую развилку. В примере выше за кадром осталось пояснение, почему взята именно Саёко. Она сотрудница бюро и открыта с первого дня, а доступность обычного клиента зависит от дня и генерации. Сценарий уровня red зависеть от везения не имеет права. Там же записано, почему вход и выход это один сценарий, а не два: это половины одного пути игрока, и разводить их значило бы дважды писать одни и те же шаги входа.
Отдельно в комментариях живут ловушки, найденные болью. Например, что ждать надо не просто переключения экрана, а ещё и окончания перехода: экран меняется раньше, чем переход завершился, и клик по кнопке возврата в этот момент молча проглатывается. Такие строки выглядят как паранойя ровно до того дня, когда сценарий начинает падать через раз.
Второе: сценарии плоские, один не включает другой. Собираем их на уровне вызова, а не вложенностью файлов, иначе шаги расползаются по десятку мест и понять, что реально выполнится, становится нельзя.
Третье: постановка сцены всегда детерминированная. Полку чистим и набиваем явно, потому что на живом сохранении её состав зависит от дня, напитка и генератора. Иначе сценарий проходит или падает по погоде, а разбираться в этом будет тот, кто через месяц увидит красный.
Четвёртое: скорость закладываем сразу. Медианный сценарий у нас идёт около сорока секунд, и главный рычаг тут размер заказа, потому что настоящее перетаскивание тащит каждый предмет отдельным жестом, примерно секунда на предмет. Регресс драг-энд-дропа намеренно взял тридцатипредметный шаблон, а не медианный девяностопредметный: механику это проверяет ровно так же, а идёт втрое быстрее.
Ускорять время в игре при этом можно, но только на транспортных участках: дойти до нужного дня, прокрутить переходы, добраться до состояния. Замер механики на ускоренном времени даёт ложные вердикты, потому что ускоритель крутит и общий масштаб времени, и скорость анимаций, а в самом боте мы уже трижды чинили гонки, вылезающие именно на ускорении. Проверять механику надо на той скорости, на которой в неё играют.
Три служебных сценария гоняются всегда и первыми, независимо от выбранного уровня. Один заведомо верный и должен проходить. Второй заведомо ложный и должен падать. Третий проверяет, что рефлексивное чтение полей работает в собранном билде, потому что IL2CPP при сборке вырезает метаданные, до которых дотягиваются только рефлексией.
Логика такая. Прошёл зелёным заведомо ложный сценарий, значит сломался сам механизм суждения, и вердикты всех остальных ничего не стоят. Раннер в этом случае останавливается сразу и не создаёт красивого зелёного отчёта, который врёт.
Это частный случай правила, выведенного дорогой ценой: молчание инструмента не факт. Пустой результат неотличим от «проверка не выполнилась», пока не было прогона на заведомо положительном примере. Урок на эту тему у нас второй по толщине из всех, и чаще всего врёт не чужая программа, а собственный скрипт, написанный тут же под конкретный вопрос: он кажется доверенным по авторству.
Это, наверное, единственная часть статьи, которая переносится куда угодно, потому что она не про технику.
Правило звучит так: действие идёт путём игрока. Заказ берётся кликом по коробке и кнопкой «Сортировать», а не служебной командой входа в заказ. Из заказа выходят через меню паузы, а не командой возврата. Новая игра начинается нажатием на кнопку, а не прямым вызовом.
Служебные команды при этом законны, но ровно в одном месте: пока они ставят сцену. Очистить полку, положить на неё нужный заказ, удалить сохранение, перепрыгнуть на нужный день. То есть привести игру в исходное положение, которое живой игрок добывал бы часами. Начался проверяемый путь, и служебных команд в нём быть не должно, иначе это подмена теста.
Что это не педантизм, видно на конкретном примере. Быстрый режим бота проходит заказ, не касаясь контроллера драг-энд-дропа вообще. Регресс на нём был бы зелёным всегда, включая случаи, когда перетаскивание сломано полностью. Шорткат приходит в нужное конечное состояние, но не накапливает предпосылку, на которой дефект и держится.
С наблюдением правило мягче. Смотреть внутрь игры не запрещено, для недостижимого иначе есть прямое чтение полей рефлексией. Но если утверждение выражается через экран, полку, стол или письмо, выражать надо через них. «На столе есть предметы, заказ вернулся на полку» лучше, чем «поле контроллера равно трём»: первое сломается, когда сломается игра, второе ещё и когда кто-то переименует поле.
И третье правило, которое дисциплинирует сильнее всех. Сценарий верен, а прогон красный, значит сценарий под поведение игры не подгоняется. Сначала перепроверяем, верное ли ожидание: документация, канон прогрессии, история задачи. Подтвердился дефект игры, заводим задачу и оставляем сценарий красным.
Один наш сценарий про устойчивость к битому сохранению был красным несколько дней. Его краснота и вытащила критический баг: игра не просто теряла сохранение, она зависала намертво, со стопроцентной загрузкой ядра и без окна, и не снималась даже по SIGTERM. Причина нашлась в JSON-ридере EasySave, который читал строку и не проверял конец потока.
Билд агент собирает одной командой с указанием типа и платформы. Внутри два транспорта поверх общего ядра сборки, и выбирается транспорт автоматически, по наличию Temp/UnityLockfile.
Unity закрыта, запускается отдельный процесс в batchmode. Чисто, не зависит от живой сессии, нормальный код возврата.
Unity открыта, и batchmode невозможен: он берёт эксклюзивную блокировку на Library. Тогда команда идёт тем же файловым каналом внутрь живого редактора, тот собирает у себя, а скрипт снаружи ждёт результат.
В Steam уезжает IL2CPP, а не Mono. Причина не в производительности: Mono-сборка открывается dnSpy как исходники. Побочный эффект оказался болезненным. IL2CPP вырезает всё, на что нет статических ссылок, а EasySave сериализует сохранения рефлексией. Без явного link.xml поля сохранения вырезались бы, и файл грузился бы молча пустым. На эту тему у нас служебный сценарий-сторож, который покраснеет раньше, чем рефлексивные проверки начнут врать в боевых.
Ещё одно ограничение чисто платформенное. IL2CPP под Windows собирается только на Windows, там нужен MSVC. Поэтому в схеме есть второй компьютер, обычный Windows-ноут в той же сети, доступный по ssh. Исходники уезжают на него через rclone поверх SFTP, который уже встроен в Windows OpenSSH: на самой машине ставить не надо ничего. Первый синк долгий, дальше едут только дельты. Там собирается Windows-версия, там же на ней прогоняется регресс, и готовый артефакт забирается обратно. Всё это одна команда с несколькими флагами.
Про регресс на билд-боксе стоит сказать отдельно, потому что тут мы недавно исправляли собственную документацию. В рецепте релиза долго стоял флаг, который пропускает прогон и просто собирает. Имя флага описывает, что он делает, и молчит о том, что он отменяет. В результате Windows-половина релиза уезжала в Steam без единого функционального прогона. Собрать мало: надо прогнать реальный заказ, перезапустить процесс и убедиться, что сохранение читается обратно. Иначе последствия вырезанного стриппингом кода ловит игрок, а не мы.
Важная деталь, ради которой стоило городить всё остальное. Драйвер и дев-консоль живут не только в редакторе, они есть и в собранной игре, под тем же файловым флагом читов. Значит регресс можно гонять не на специальном тестовом билде, а ровно на том бинарнике, который поедет игрокам: положил файл-маркер рядом с исполняемым, добавил к запуску путь до папки билда, и скрипт разговаривает с игрой теми же глаголами.
Раннер сам поднимает игру, ждёт готовности драйвера, гоняет очередь сценариев и гасит процесс. В том числе если прогон упал на середине: осиротевший экземпляр держал бы файловый канал, и следующий прогон разговаривал бы с прошлой игрой. Такое у нас случалось и выглядело как загадочный регресс той самой фичи, которую в этот момент проверяли.
Один вход в Play Mode идёт на всю пачку, потому что domain reload это фиксированный налог в десяток секунд. Отсюда контракт: сценарий сам приводит игру в нужное состояние своими первыми шагами и оставляет её в хабе, чтобы следующий начинал с известного места.
Из этого же выросла самая полезная возможность раннера. Строка restart внутри сценария режет его на фазы: раннер гасит процесс игры и поднимает заново теми же аргументами, а потом сам заводит в слот. Иначе проверить сохранение честно невозможно вообще. Дев-команда чтения сейва в живой сессии реального пути не воспроизводит: при старте игра поднимается с нуля, и ровно там живут дефекты загрузки.
bot.start 1 # ФАЗА 1: играем заказ ботом
wait:botRunning=False
save.save
restart # игра закрывается и поднимается заново
wait:screen~Room # ФАЗА 2: то, что записали, читается с диска
expect:sealBookUnlocked=True # фотоальбом открыт, значит прогресс подтянулся
Последняя строка тут неспроста. Проверять надо не «файл прочитался без ошибок», а видимое следствие: фотоальбом открывается только на определённом дне, и если он на месте после перезапуска, значит с диска подтянулось именно то состояние, которое мы записали.
Ещё один шаг того же класса портит файл сохранения намеренно. Причём режет он не «треть от размера», а строго внутри строкового литерала. Исход порчи зависит от точки обрыва: попал внутрь строки, и парсер ищет закрывающую кавычку, которой уже нет; попал в другое место, и он честно падает с ошибкой. Обрез по доле размера попадал в дефект лотереей, то есть сценарий мог быть зелёным просто потому, что порезал не туда.
Есть и грабля, стоившая двадцати минут простоя. Запущенная из скрипта игра с графикой на macOS не создаёт окно, и Unity перестаёт крутить Update. Драйвер при этом пишет, что готов, слушает правильный путь, а на команды не отвечает. Снаружи это выглядит ровно как «билд не стартовал». Разделяющая проверка простая: тот же билд с -batchmode -nographics отвечает мгновенно, значит дело в среде, а не в игре. Отсюда правило: скриптовый прогон только headless, оконный режим рассчитан на запуск человеком.
Отдельно про вещи, которые кажутся мелочью, пока не наступишь. Общее у них одно: все три про то, что тестовый прогон это не чистая функция, он оставляет следы в реальном мире.
Прогон играет заказы и сохраняется. Игра, редактор и билд пишут сохранения в одну папку, и без изоляции регресс пишет прямо в слот разработчика, безвозвратно. У нас это случалось трижды, причём последний раз уже при действующем правиле, потому что правило было написано как ритуал вокруг одного прогона: положи маркер перед, убери после. А работа была многораундовой, маркер сняли после отчёта, работа продолжилась, следующие прогоны записали настоящий слот. Теперь это не правило, а сторож: инструмент отказывается выполнять любую команду, способную дойти до записи в сохранение, если маркер изоляции не стоит.
Прогон мусорит в папке, которая уезжает в Steam. Регресс гоняется прямо в ней и оставляет логи и телеметрию. Проверять надо не «на месте ли нужное», а «нет ли лишнего», и обязательно последним шагом перед заливкой, после регресса, а не до. Проверка, прогнанная до регресса, описывает состояние, которого на момент заливки уже нет. Мы на этом попались ровно так.
И самое дурацкое. TextMesh Pro держит CJK-шрифты динамическими атласами, куда игра дописывает глифы прямо во время работы. Погоняли игру на японском, корейском и китайском, и четыре файла по тридцать три мегабайта оказались изменёнными. Прирост микроскопический, восемьдесят килобайт на все четыре. Но Steam шлёт файл целиком, и ради этих килобайт в дельту патча уезжает сто тридцать мегабайт. Один наш патч весил сто пятьдесят мегабайт вместо обычных тридцати, и заметил я это глазами, случайно, когда патч показался толстым. Ни один допуск такого не видел: атласы печатались в общем списке из двух сотен путей, где их никто не читает. Теперь сборка релиза отказывается работать, если атласы разошлись с гитом, и называет файлы с весом.
Все три случая кончились одинаково, и в этом главный вывод раздела. Каждый раз сначала появлялось правило в документации, каждый раз оно было написано верно, и каждый раз его нарушали снова, включая меня самого через неделю после того, как я его и написал. Держать проверку памятью не выходит. Работает только инструмент, который отказывается делать неправильно: прогон не стартует без маркера изоляции, сборка релиза не идёт при разошедшихся атласах. Если правило нарушено больше двух раз, писать про него ещё один абзац бесполезно, надо переносить его в код.
Отдельный документ перечисляет то, чего автоматика структурно не поймает, и каждый пункт назван вместе с причиной. Причина важнее пункта: через полгода никто не должен пытаться «просто дописать сценарий» там, где сценарий бессилен.
Первое это слепые зоны бота. Предметы он не крутит, поэтому вращение и его влияние на укладку проверяются руками. В части заказов вещи приходят порванными на куски, и собрать их в целое это отдельная механика: вот эти куски бот не таскает вовсе, а склеивает служебным вызовом. Значит путь закрытия такого заказа проверен, а сам жест перетаскивания куска не проверялся ни разу.
Второе это тайминг и анимация. Снимок состояния отдаёт факты вроде «заказ завершён», а не факты качества. Штамп на закрытом заказе, который появляется раньше, чем доиграла анимация. Панель письма, улетающая за экран при первом открытии. Коробка, застывшая в закрытой, но не удалённой анимации. Ни один из этих реальных багов не даст ложного значения ни одному полю.
Третье самое важное и самое непереводимое в предикат. Игра построена на субъективном: покой, мягкий темп, нулевая цена ошибки. Снимок скажет «заказ завершён», но не скажет «завершение выглядело приятно». Сюда же тон записок и диалогов, баланс звукового микса, читаемость экрана глазами нового игрока.
Здесь работает обходной приём, который стоит рекомендовать. Кадр из игры мы отдаём не самому себе, а стороннему смотрящему, и намеренно не говорим ему, что чинили и что должно получиться. Названные критерии превращают свежий взгляд в подтверждение собственной рамки. Разница измерима: делегированный проход по экранам меню вернул две находки, которых автор работы не увидел на том же самом кадре.
Из той же серии наблюдение, которое далось дороже всего. Утверждение о восприятии закрывается предъявлением, а не разбором механизма. Мы однажды трижды подтвердили кодом, что механика работает именно так, как предполагали, объявили её причиной жалобы игрока, а слепой замер показал, что эффекта нет вообще. И предложенное лечение оказалось хуже исходного состояния.
Точно я не считал, поэтому цифра грубая. Инфраструктура набиралась кусками месяцев шесть, и если сложить всё вместе, выйдет недели три-четыре чистого времени. Много. Окупилось, по ощущению, где-то к третьему месяцу, когда прогон перед патчем стал занимать двадцать минут вместо вечера и перестал требовать меня в кресле.
Сейчас прогон набора на собранном билде занимает от нескольких минут до получаса, смотря какой уровень выбран. Заказ настоящим перетаскиванием проходится за тридцать четыре секунды на тридцать пять предметов, примерно секунда на предмет живым жестом. Полный цикл агент закрывает сам: правка кода, компиляция, запуск игры, прогон, вердикт, скриншот, коммит.
Но переносится это не везде, и об этом честнее сказать прямо. У нас детерминированная головоломка без боевой системы, без сети и без физики, от которой зависит исход. Состояние игры целиком описывается плоским снимком на полсотни полей, и именно поэтому текстовое условие вроде «на полке лежит этот заказ» вообще возможно. В шутере или в чём-то с настоящей симуляцией половина подхода не поедет: там снимок состояния не выражает то, что вы хотите проверить, и вопрос «правильно ли это выглядело» встаёт гораздо раньше, чем у нас.
Чего бы я сегодня не строил заново. Оценку игрового времени в часах: цифра красивая, а решений я на ней не принял ни одного. И, скорее всего, половину уровней критичности: на нашем объёме хватило бы красного набора и всего остального.
Регресс при этом ловит только известное. Нового он не находит, и мы это прямо записали в документацию, чтобы не обманываться. Самый неприятный баг проекта, тихую потерю сохранения, нашли чтением кода, а не игрой. Контур ловит то, что однажды сломалось и было записано, и в этом вся его ценность: сломаться второй раз он не даёт.
А самой дорогой частью работы оказалось не написание тестов. Дорого стоит то, чтобы тесты не врали. Разведённые коды возврата, проверка проверяющего, приёмка сценария по красному на сломанном коде, изоляция сохранений, отказ инструмента вместо правила в документации. Выглядит как бюрократия, пока не посчитаешь, сколько стоит один уверенный зелёный отчёт о работе, которая на самом деле не выполнялась.
Самый свежий пример как раз из этой статьи. Текст я писал с жёстким правилом «никаких длинных тире», проверил их счётчиком в исходнике, получил ноль и со спокойной душой опубликовал. На живой странице тире были: сборщик HTML переписывал двойной дефис в тире прямо внутри блоков кода, и команда qa/run.py --level red превратилась в такую, которую нельзя скопировать. Проверка была верной по методу и ложной по субъекту. Я мерил исходник, а наружу уезжал продукт его преобразования, и увидел я это только тогда, когда открыл опубликованную страницу глазами.