Юнит-тест проверяет функцию. Но большая часть моего сервера — это не функции. Моб не должен уходить за границу своей зоны. Схема переходов обязана нарисовать граф из сотни карт и честно посчитать, сколько связей в игре сломано. Персонаж после перехода должен появиться в нужной клетке соседней карты, а не в стене. Исход тут живёт в ответе работающего сервера, в разметке страницы и в кадре игры — записать его ассертом невозможно.
А проверять надо постоянно: код пишет ИИ, и делает он это быстрее, чем я успеваю перечитывать диффы. Расскажу, как устроен контур проверки, который у меня из этого вырос.
Коротко о проекте
С 2022 года я в одиночку веду дневник разработки авторитарного сервера для 2D MMO RPG — игры с открытым бесшовным миром, где сотни игроков и существ живут в реальном времени. Сервер на PHP и Symfony, клиент на Unity, правду о мире считает только сервер. С семнадцатой части в проекте работает ИИ: игровую механику я описываю словами, а собирает её он — и на сервере, и в клиенте. Как устроен мост, по которому он это делает, разбиралось там же, здесь повторяться не буду.
Вопрос, который встал сразу же: как понять, что собранное работает. И как понять это завтра, когда та же механика будет переписана в пятый раз.
Тестов у меня два рода, и это не про пирамиду
Первый род привычный — юнит-тесты на phpunit. Они живут там, где исход детерминирован и дёшев: разбор формата, округление координат, сериализация словарей с числовыми ключами, разбор параметров ссылки. Запустил — получил зелёное или красное. Таких мест в проекте немного, и это нормально: игровой сервер почти целиком состоит из взаимодействий, а не из чистых функций.
Второй род я называю спекой — это контракт поведения на границе системы. Спека не содержит кода. Это текстовый файл: короткое описание предмета, условия прогона и таблица из трёх колонок — что дано, какое действие, какого исхода я жду. Проверку по этой таблице выполняет агент: он поднимает нужное состояние, шлёт команды на живой сервер, читает ответы и сверяет их с ожиданием.
Сейчас в проекте 32 таких контракта. Шесть из них привязаны к phpunit — там, где исход всё-таки удалось загнать в ассерт. Остальные 26 прогоняет агент, потому что иначе никак: проверять надо ответ живого игрового процесса с точностью до миллисекунд, разметку страницы админки или кадр игры.
Как выглядит спека
Вот реальная спека игровой механики — привязь моба к дому. Замысел простой: враждебное существо не должно бесконечно гнаться за игроком через полмира. У него либо есть именованная зона обитания, либо радиус вокруг точки появления.
---
name: sandbox-test-zone-leash
description: "Контракт зоны-привязи: моб-враг держится «дома» — именованной
зоны роума (компонент zone, приоритет) либо радиуса от точки спавна
(fallback при отсутствии или неразрешении зоны)"
runner: prompt
---
Дальше — сама таблица контракта:
|
Дано |
Действие |
Ожидание |
|---|---|---|
|
моб с зоной, оказался ВНЕ её |
тик блуждания |
идёт к ближайшей области зоны и входит в неё |
|
моб с зоной, ВНУТРИ неё |
тик блуждания |
клетки-кандидаты только внутри зоны, за границу не выходит |
|
цель ВНЕ зоны, дальше отступа агро; радиус поражения и видимость есть |
тик поиска цели |
атаки нет — цель отсекается |
|
та же цель подошла вплотную, в пределах отступа |
тик поиска цели |
удар, цель получает урон |
|
та же цель ВОШЛА в зону |
тик поиска цели |
удар, цель получает урон |
|
у моба зона задана, но на карте её нет |
тик |
откат на радиус от точки появления |
|
моб без зоны |
тик |
ушёл за радиус — возврат к точке появления |
Заканчивается спека инвариантом: приоритет у зоны, а радиус от точки появления — аварийный откат, не второй равноправный режим.
Обратите внимание на предпоследнюю и последнюю строки таблицы. Это те самые случаи, которые ИИ радостно ломает при рефакторинге: механика вроде работает, а аварийный откат тихо перестал срабатывать — и мобы на картах без размеченных зон разбредаются по всему миру. Строка в таблице стоит дешевле, чем поимка такого через месяц.
На этой последней строчке стоит остановиться: она важнее самой таблицы. Инвариант — это не тест. Это то, ради чего механика существует, и он объясняет проверяющему, что считать нарушением, а что мелочью. Ассертом такое не выражается вовсе.
Тест сам находит разработчика
Обычно тест ждёт, когда про него вспомнят. У спеки есть список файлов проекта, к которым она привязана: код механики, сервис, шаблон страницы, клиентский скрипт. Как только правка затрагивает любой из этих файлов, контракт спеки подъезжает в контекст работы сам — вместе с таблицей ожиданий.
Эффект неочевидный, но сильный: ИИ узнаёт о существовании контракта до того, как напишет первую строку, а не после прогона. Половина потенциальных нарушений отваливается на этом шаге — правка сразу пишется с оглядкой на таблицу. Оставшееся ловит прогон.
Прогон устроен буднично: агент читает спеку, поднимает предусловия, идёт по таблице строка за строкой и сводит результат в три исхода — сошлось, не сошлось, проверить не удалось. Третий исход отдельно: «стенд не готов» — это не провал контракта, и валить их в одну кучу нельзя, иначе красное перестают читать.
Ревью, где находку надо доказать
Тесты держат то, что уже описано. Отдельная задача — искать то, что не описано: мёртвый код после смены механики, дубли, комментарии, которые врут про новое поведение, места без покрытия. Это работа не для одного проверяющего — материала слишком много.
Поэтому у меня такие проходы устроены конвейером: скрипт запускает пачку агентов параллельно, каждому даётся своё измерение и свой набор правил проекта.
export const meta = {
name: "skill-audit-bundle",
description: "Аудит модуля против правил проекта: find → verify → synthesize",
phases: [
{ title: "Find", detail: "группы измерений параллельно" },
{ title: "Verify", detail: "скептик на каждую находку" },
{ title: "Synthesize", detail: "реестр подтверждённого по важности" },
],
}
const GROUPS = [
{ key: "core", dims: [
"ЕДИНЫЙ ИСТОЧНИК: дубли значений и логики вместо общего носителя",
"ROOT-FIX: обход у потребителя под дефект соседнего слоя вместо фикса в источнике",
]},
{ key: "boundary", dims: [
"ПАРИТЕТ ФРОНТОВ: операция есть в админке, отсутствует в инструментах, или наоборот",
"ОГОРАЖИВАНИЕ ВВОДА: справочное значение принимается свободным текстом",
]},
]
const results = await pipeline(
GROUPS,
g => agent(prompt(g), { phase: "Find", schema: FINDING_SCHEMA }),
// каждая находка уходит скептику сразу, как только она найдена,
// не дожидаясь, пока домолотят остальные группы
found => parallel(found.findings.map(f => () =>
agent("Опровергни находку: " + f.title + ". " +
"Назови КОНКРЕТНЫЙ сценарий вреда: что сломается, у кого, когда. " +
"Сценарий не воспроизводится по коду и данным — находка отклонена.",
{ phase: "Verify", schema: VERDICT_SCHEMA })
.then(v => ({ ...f, verdict: v }))
))
)
return results.flat().filter(f => f.verdict.isReal)
Ключевая часть — вторая. Находка сама по себе ничего не стоит: модель прекрасно умеет писать убедительные абзацы про проблемы, которых нет. Поэтому каждую находку получает отдельный агент с одной задачей — опровергнуть её. Он обязан назвать конкретный сценарий вреда: что именно сломается, у какого потребителя, при каких данных. Не назвал или сценарий не воспроизводится по коду — находка выбрасывается, сколь угодно правдоподобная.
Это правило родилось из практики. Первые прогоны выдавали реестры на сотню пунктов, где половина была «симметрией ради симметрии»: у соседнего модуля есть такая проверка, а тут нет — значит, надо добавить. Добавляешь — и получаешь код, который никогда не выполнится. Требование доказать вред срезает такие находки почти полностью, а заодно делает отчёт читаемым: в нём остаётся то, что действительно чинить.
Правила, которые нельзя нарушить мимоходом
Часть дисциплины не проверяется — она просто не даёт сделать неправильно. Проверки стоят на входе в инструмент: попытка правки перехватывается до того, как файл изменится.
#!/bin/bash
# Защищённый путь правит только агент-владелец.
# Из главной сессии — отказ.
case "$file_path" in
*/agents/*|*/hooks/*|*/workflows/*)
allowed="team-lead" ;; # правила о правилах
*.md|*/skills/*)
allowed="skill-editor" ;; # вся документация и правила проекта
esac
[ -z "$allowed" ] && exit 0 # незащищённый файл — пропустить
[ "$agent_type" = "$allowed" ] && exit 0 # владелец — пропустить
echo "Файл правит только $allowed" >&2
exit 2 # всё остальное — отказ
Зачем так: правила проекта — самый ценный актив всего контура, и меняться они должны осознанно, отдельным решением, а не походя, в середине правки игровой механики. Отказ здесь дешевле любого разбора последствий.
Рядом живут проверки помягче — те, что ничего не блокируют, а подкладывают напоминание в нужный момент. Тронул игровые элементы — получил список контрактов, которых это касается. Открыл браузер — получил напоминание освободить его после проверки.
Последняя линия обороны — это git
Ни контракты, ни ревью, ни запреты не дают гарантии. Гарантию даёт только одно: возможность увидеть, что именно изменилось, и вернуть как было. Поэтому под версионным контролем у меня лежит не только код.
Игровой контент — тоже git: карты, анимации, наборы графики, компоненты и события механик, баланс. В прошлой части я разбирал, как это устроено — каталог, из которого игра ставится в один клик, и репозитории, куда уезжает её содержимое. Здесь важно следствие: правка, которую сделал ИИ в игровых данных, ничем не отличается от правки в коде. Её видно в диффе, её можно посмотреть до применения и откатить после.
Это меняет цену ошибки. Без версий каждая автоматическая правка контента — риск, который надо предотвращать заранее, отсюда тяжёлые согласования и страх пускать машину к данным. С версиями правка становится обратимой, и вопрос смещается с «пускать ли» на «как быстро я увижу разницу». Перед отправкой изменений я смотрю предпросмотр: что уйдёт, что заменится, чьи чужие правки прилетят навстречу.
Пока часть игровых данных жила только в базе, неудачная массовая правка означала восстановление руками по памяти. Таких мест не осталось — и именно это, а не тесты, позволяет давать машине широкие полномочия.
Визуал: то, что проверяется только глазами
Отдельный класс проверок — то, что рисуется на клиенте. Ответ сервера может быть корректным, страница может отдавать код 200, а на экране — пусто, или граф наложился сам на себя, или половина элементов не прогрузилась. Здесь единственная приёмка — посмотреть.
Админку агент открывает браузером и снимает кадр. Клиент — через Play Mode: сцена запускается, снимается кадр игры, читается лог. Вот страница, на которой это видно нагляднее всего.
Это карта связности мира: каждая плитка — отдельная карта игры со своим превью, линии между ними — переходы. Сплошная зелёная — переход работает, пунктир — бесшовный стык открытого мира, красная — ошибка разметки. Справа разбор с точками отбытия и прибытия, вверху счётчик: 39 поломок. Всё это рисует скрипт в браузере, а данные собирает сервер.
Проверить такую страницу «по ответу сервера» бессмысленно. Смотреть надо на результат отрисовки — и это ровно тот случай, где кадр стоит дороже любого лога.
Тут есть подвох, на который я потратил некоторое время. Такая отрисовка идёт по кадрам анимации, а браузер фоновой вкладке кадры не выдаёт: рисование замирает на произвольной стадии. Снимок со свёрнутой вкладки показывает половину слоёв — и выглядит это ровно как настоящий баг рендера. Правило простое: вкладку вывести вперёд, дать отрисоваться, только потом мерить.
Где живут знания и почему это открывает удалённую работу
Всё описанное упирается в один вопрос: откуда проверяющий знает, что здесь правильно. Ответ — знание лежит там же, где действие.
Мост, по которому ИИ работает с проектом, я разбирал в семнадцатой части. Здесь важно не его устройство, а то, что вместе с доступом наружу уезжает и знание. У каждого из 186 инструментов сервера есть описание — что он делает и чего от него ждать, у каждого параметра своя схема и свои допустимые значения. Это не документация рядом с кодом, а сам контракт: подключился и видишь, чем можно оперировать, не спрашивая автора.
Второй слой — справка разделов админки. У каждого раздела есть текст: что означает вычисленное значение, по какому критерию отобраны записи, к чему приведёт действие, какие в сценарии ловушки. Пишется он по одному правилу: только то, чего на экране не видно. Названия кнопок и подписи полей пересказывать запрещено — это шум, который стареет вместе с вёрсткой.
На кадре — дерево разделов и справка к той самой схеме переходов, которая была выше. Читают её и люди, и ИИ: текст один, ходят за ним по одному адресу.
Третий слой — правила самой разработки. Это четыре десятка тематических сводов: работа с базой, вёрстка админки, сериализация, миграции, устройство игрового кода. У каждого свода — описание и список файлов, к которым он приложен, поэтому в работу он приезжает сам, когда правка касается его зоны.
Складывается из этого вот что: подключиться к проекту можно, ничего не разворачивая локально. Ни базы, ни игрового сервера, ни клиента — нужен только доступ. Дальше человек или его агент читает контракты инструментов, открывает справку раздела, берёт спеку и гоняет проверки по живому стенду в облаке.
Для меня это главный практический итог всего контура. Порог входа в чужой проект обычно состоит не из сложности кода, а из недельного ритуала «поднять окружение и узнать, где что лежит». Здесь ритуала нет: вводить участника в курс дела не требуется, он читает контракт на месте — тем же способом, что и ИИ.
Чего ИИ не умеет
Теперь честная часть. Всё описанное работает не потому, что модель стала умной, а потому что каждый пункт закрывает конкретную вещь, которую модель делает неправильно по умолчанию. Вот те, что стоили мне дороже всего.
Чинит симптом, а не причину. Соседний слой отдал не тот тип — модель добавит проверку типа у себя и пойдёт дальше. Симптом исчез, дефект остался в источнике и выстрелит у следующего потребителя. Правило: чинить в точке возникновения, у потребителя допустима только диагностика.
Глушит сигнал ошибки. Самый быстрый способ убрать сообщение об ошибке — убрать сообщение об ошибке. Модель делает это охотно и с чистой совестью: симптома нет, задача закрыта. Правило: сначала прочитать, почему ошибка возникает, и только потом трогать.
Судит о коде, не открыв его. Утверждение «этот класс делает то-то» модель выдаёт по названию и по общей эрудиции — и звучит убедительно. Правило: утверждение о поведении конкретного кода — только после чтения этого кода. Проверка идёт вперёд вывода, а не следом за ним.
Отчитывается по числам. «Импортировал 40 объектов, ошибок нет» — и не посмотрел, что получилось. Правило: результат действия принимается по артефакту, а не по счётчику. Есть превью — открыть превью. Есть страница — открыть страницу. «Инструмент вернул успех» приёмкой не считается.
Ни одно из этих правил не выводится из кода — их пришлось нажить. Каждое родилось из конкретного разбора: почему получилось не то, что я просил. И вот тут проходит граница, которую я не вижу способа сдвинуть: инварианты задаёт человек. Что считать дефектом, что by-design, где приоритет зоны над радиусом, а где наоборот — решение архитектора. Машина исполняет внутри рамок и делает это быстро; рамки ставлю я.
Мне это кажется куда более рабочей моделью, чем спор о том, заменит ли ИИ разработчика. Ответ зависит от того, кто ставит рамки. Если никто — получается ровно тот мусор, о котором пишут в комментариях.
Вопрос к вам
Меня интересует ровно одно: какие контракты в ваших проектах не ложатся в юнит-тест. Не «мы не успели написать тесты», а именно поведение, которое проверяется только глазами или руками — рендер, взаимодействие сервисов, гонки, состояние после долгой сессии. Напишите, чем это заканчивается у вас — соберу самые частые случаи и разберу, как такое ловится контрактом, в следующей части.
История
-
Redis как память игрового мира: сколько игроков и NPC он держит
-
Клиент на Unity для авторитарного сервера: что он обязан уметь
-
Почему персонаж телепортируется: пинг, интерполяция и экстраполяция
-
Event-driven и JSON-RPC: почему игровой сервер не собран из сервисов
-
Не размер пакета: где на самом деле узкое место сетевого сервера
-
MVP авторитарного сервера для 2D MMO: три года и что получилось
-
Конвейер контента для MMO: импорт анимации, экипировка и обновление игры без патчей
-
Каталог ассетов и git для MMO: как командой вести игру в облаке
-
Тесты для кода, который пишет ИИ: контракты вместо ассертов
Автор: webrobot


