Тесты для кода, который пишет ИИ: контракты вместо ассертов — создание сервера для онлайн ММО игр на PHP, ч. 20. ai.. ai. gamedev.. ai. gamedev. mcp.. ai. gamedev. mcp. mmo.. ai. gamedev. mcp. mmo. PHP.. ai. gamedev. mcp. mmo. PHP. Symfony.. ai. gamedev. mcp. mmo. PHP. Symfony. unity.. ai. gamedev. mcp. mmo. PHP. Symfony. unity. автотесты.. ai. gamedev. mcp. mmo. PHP. Symfony. unity. автотесты. искусственный интеллект.. ai. gamedev. mcp. mmo. PHP. Symfony. unity. автотесты. искусственный интеллект. Разработка игр.. ai. gamedev. mcp. mmo. PHP. Symfony. unity. автотесты. искусственный интеллект. Разработка игр. тестирование.. ai. gamedev. mcp. mmo. PHP. Symfony. unity. автотесты. искусственный интеллект. Разработка игр. тестирование. Тестирование IT-систем.. ai. gamedev. mcp. mmo. PHP. Symfony. unity. автотесты. искусственный интеллект. Разработка игр. тестирование. Тестирование IT-систем. Управление разработкой.
Часть 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. Тесты для кода, который пишет ИИ: контракты вместо ассертов

Автор: webrobot

Источник