В бою игрок должен сражаться. А вместо этого он сказал: «Я отрезаю себе палец и кладу его в чашу, чтобы решить загадку». И тут я понял, что придумать правило на каждый кейс невозможно. Под катом про то, как заставить ИИ-движок соблюдать правила, которые нигде не прописаны.

3 месяца назад

Во время первого альфа-теста моего бота в Телеграм игрок пытался 4 раза отрезать себе палец, и у него ничего не получилось. Ответы ИИ-мастера каждый раз менялись, и каждый раз они оставались лишь текстом, который не влиял ни на что и даже противоречил реальному состоянию персонажа. Правила, чтобы обработать такой запрос, в движке не было.

Мне захотелось это исправить, но так, чтобы не превратить движок в «рельсы», прописывая реакцию на каждый кейс. Спустя 3 месяца я получил MVP, который может обрабатывать подобные заявки так, как задумано. И правил про пальцы в нём по-прежнему нет.

Обработка заявки с пальцем тогда и сейчас
Обработка заявки с пальцем тогда и сейчас

Судьи

Основная идея в том, что ИИ выдаёт суждения по каждому действию игрока, а код принимает решения. Действия игрока, при этом, могут быть типизированы, а могут быть и чистой импровизацией. В обоих случаях «судьи» определяют, что можно, а что нельзя, и каковы будут последствия.

Судьи у меня — это параллельный «веер» из нескольких LLM (сейчас их 8), которым на вход подаются действия игрока и необходимый контекст. Дуэлянт судит боевые действия, Резолвер разбирается с загадками, Часовой следит за реакцией врагов, Импровизатор судит импровизацию и т.д. Судьи выбраны так, чтобы, по возможности, их ответственность не пересекалась.

В целом, пайплайн такой:

Пайплайн
Пайплайн

Перед тем, как что-то отдать судьям, Роутер занимается первичной обработкой запроса, определяет атомарные действия и зависимости и, главное, определяет судей, суждения которых нужны. Подробно об этом я писал здесь, эта же статья посвящена «вееру» и правилам. Кстати, стоимость такого веера получается приемлемой, т.к. за счёт разделения ответственности каждый судья довольно «узкий», потребляет мало контекста и работает копеечной на gpt-4o-mini.

Возьмём запрос из кейса. Роутер раскладывает его на 2 атомарных действия: [1] игрок откусывает себе мизинец, [2] игрок кладёт мизинец в чашу. Также он определяет, что действие [2] зависит от действия [1]. Примерный перечень вопросов и судейских ответов по каждому из действий представлен ниже.

Вопросы

[1] откусить палец

[2] положить палец в чашу

возможно ли действие в принципе?

да

да

есть ли в сцене палец и чаша?

да

да и да

нужно ли сделать проверку (бросок d20), чтобы действие получилось?

Атлетика СЛ 15

нет

возможный исход в случае удачи и провала

успех: действие удалось + урон

неудача: действие не удалось

нет

решится ли при этом загадка, сработает ли ловушка?

нет

палец достаточно ценен, Загадка Порога разгадана

должны ли реагировать враги?

нет (нет подходящих триггеров)

нет (нет подходящих триггеров)

если бой: тратится ли на это действие, бонусное действие или что-то ещё?

действие

свободное действие

Состав вопросов может варьироваться в зависимости от действия и, соответственно, судей.

Учёт суждений

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

В кейсе выше как раз действие 1 зависит от броска и имеет зависимости, поэтому:

  • в случае успеха игрок получает урон, в случае провала не получает и проваливает действие — откладываем до результатов броска (т.е. сохраняем в БД и не применяем сейчас);

  • действие 2 решает загадку, но зависит от действия 1, которое зависит от броска — тоже откладываем.

Дашее игроку предлагается бросить кость — он просто нажимает кнопку. Уже известно по решению одного из судей, что нужен бросок атлетики, и определена сложность. Берём модификатор из карточки игрока, выбираем рандомное число, складываем, сравниваем со сложностью, получаем честный результат.

Если успех, применяются последствия успеха действия 1 и последствия действия 2: игрок отрезал себе палец, получил урон, решил загадку — это ровно то, что показано на картинке выше. При этом, урон посчитает код, он же отнимет хп, проверит, не умер ли игрок, обновит статус загадки на «решена», откроет связанный с загадкой проход в соседнюю локацию — и положит всё это в базу данных.

Если провал, то действие 1 не получилось, действие 2 просто отменяется каскадом.

А если игрок попытался это всё провернуть в бою, в дополнение ко всему у игрока спишется ещё и слот действия, а если слотов нет, будет каскадная блокировка. Это тоже сделает код.

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

Как промежуточный итог, концепция такая: ИИ выносит суждения, код их применяет, ИИ-рассказчик описывает уже случившийся факт. Но где здесь правила, которые позволяют обработать неожиданный запрос?

Правила

Правила есть. Но они устроены, скажем так, по уровням.

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

Второй уровень — это отдельные механики судей, «зашитые» в их промптах. Тот же Дуэлянт понимает атаки вида «бросает, бьёт» и может отличить атаку оружием от атаки импровизированным оружием, а также решить, что «песок в морду орку» — это не попытка нанести урон песком, а попытка ослепить. На деле это превращается в набор инструкций, а на выходе — JSON с флагами суждения и нужными бросками попадания и урона.

Сам состав судей подсказывает, что действия как-то классифицируются, и разные классы обрабатываются по-разному. Очевидно, действие «я осматриваюсь» не должно попадать в боевой домен (к Дуэлянту) — туда идут атаки по NPC, а оценивать «ценность подношения» в виде пальца для чаши-загадки должен Резолвер. За выбор судей как раз отвечает Роутер, и ключевое здесь — развести домены в его промпте так, чтобы он правильно выбирал судей.

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

Длинный прыжок это импровизация, и у неё могут быть последствия
Длинный прыжок это импровизация, и у неё могут быть последствия

Если у движка есть нужные категории правил на нужных уровнях, неважно, что обрабатывать — прыжок, атаку или попытку выломать себе зуб. А коду неважно, что считать. Ниже альтернативный прогон того же кейса, где кубы выпали по-другому.

Альтернативный результат прыжка: успех
Альтернативный результат прыжка: успех

Нерешённые проблемы

Есть и проблемы, продиктованные такой архитектурой. Назову несколько.э

Во-первых, много точек отказа. Если Роутер неправильно выберет судей, результат будет непредсказуемым (и однозначно некорректным). Если судья неправильно интерпретирует триггер, враг не появится, когда должен, или появится, когда не должен.

Во-вторых, сама классификация действий в рамках одного судьи. Например, я недавно обнаружил, что Дуэлянт просто скипает запрос типа «плюю в морду орку», потому что он не может понять что это. Это не атака оружием, это не импровизированная атака. Игрок видит значок 🚫, действие просто срезалось.

В-третьих, вариативность суждения. В одном прогоне судья решает, что нужна проверка «получилось действие или нет», в другом — действие получается без проверки, но добавляется проверка на последствия («достаточно ли высоко прыгнул?», «получил ли урон при приземлении?»). Иногда это интересно и выглядит как фича, иногда — глупо.

Эти проблемы — не баги реализации, а цена архитектуры, в которой часть решений отдана ИИ. Код гарантирует, что вердикт по каждому действию есть и что он один, но не что он каждый раз одинаковый. Основная сложность здесь в чётком разделении доменов, определении правил суждения и рамок импровизации, а каждый выбор имеет свои плюсы и минусы.

Собственно, мой открытый вопрос: если выдумку игрока нельзя перечислить, кто вообще должен решать, бросок здесь — это «получилось ли», или «получилось, вопрос в цене»? И можно ли требовать, чтобы одно и то же действие судилось одинаково от прогона к прогону? У меня ответа нет.

А ещё ближе к концу сентября я планирую второй альфа-тест этого обновлённого движка — приходите ломать судей своими заявками, запись здесь, подробности в моём телеграм-канале.