Часть 20: контракты вместо ассертов — проверка того, что не ложится в юнит-тест.
Часть 20: контракты вместо ассертов — проверка того, что не ложится в юнит-тест.

Юнит-тест проверяет функцию. Но большая часть моего сервера — это не функции. Моб не должен уходить за границу своей зоны. Схема переходов обязана нарисовать граф из сотни карт и честно посчитать, сколько связей в игре сломано. Персонаж после перехода должен появиться в нужной клетке соседней карты, а не в стене. Исход тут живёт в ответе работающего сервера, в разметке страницы и в кадре игры — записать его ассертом невозможно.

А проверять надо постоянно: код пишет ИИ, и делает он это быстрее, чем я успеваю перечитывать диффы. Расскажу, как устроен контур проверки, который у меня из этого вырос.

Коротко о проекте

С 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, где приоритет зоны над радиусом, а где наоборот — решение архитектора. Машина исполняет внутри рамок и делает это быстро; рамки ставлю я.

Мне это кажется куда более рабочей моделью, чем спор о том, заменит ли ИИ разработчика. Ответ зависит от того, кто ставит рамки. Если никто — получается ровно тот мусор, о котором пишут в комментариях.

Вопрос к вам

Меня интересует ровно одно: какие контракты в ваших проектах не ложатся в юнит-тест. Не «мы не успели написать тесты», а именно поведение, которое проверяется только глазами или руками — рендер, взаимодействие сервисов, гонки, состояние после долгой сессии. Напишите, чем это заканчивается у вас — соберу самые частые случаи и разберу, как такое ловится контрактом, в следующей части.

История

  1. Платформа для 2D MMO вместо очередного игрового сервера

  2. Почему одного процесса не хватит: асинхронность и масштаб

  3. Почему игровому серверу нужен WebSocket, а не HTTP

  4. Redis как память игрового мира: сколько игроков и NPC он держит

  5. Lua или JavaScript в песочнице: чей код быстрее на сервере

  6. Выбор технологий и ECS: почему архитектура быстрее языка

  7. Тайловые карты: импорт локаций из Tiled одной строкой

  8. Клиент на Unity для авторитарного сервера: что он обязан уметь

  9. Игровые механики без обновления клиента: события на сервере

  10. Бесшовный мир в 2D MMO: локации на разных машинах

  11. Почему персонаж телепортируется: пинг, интерполяция и экстраполяция

  12. Очереди без RabbitMQ: обмен между процессами в памяти

  13. Event-driven и JSON-RPC: почему игровой сервер не собран из сервисов

  14. Не размер пакета: где на самом деле узкое место сетевого сервера

  15. Почему сервер собран на процессах, а не на потоках

  16. MVP авторитарного сервера для 2D MMO: три года и что получилось

  17. Внедряю ИИ: механики из одного описания

  18. Конвейер контента для MMO: импорт анимации, экипировка и обновление игры без патчей

  19. Каталог ассетов и git для MMO: как командой вести игру в облаке

  20. Тесты для кода, который пишет ИИ: контракты вместо ассертов