
В бою игрок должен сражаться. А вместо этого он сказал: «Я отрезаю себе палец и кладу его в чашу, чтобы решить загадку». И тут я понял, что придумать правило на каждый кейс невозможно. Под катом про то, как заставить ИИ-движок соблюдать правила, которые нигде не прописаны.
3 месяца назад
Во время первого альфа-теста моего бота в Телеграм игрок пытался 4 раза отрезать себе палец, и у него ничего не получилось. Ответы ИИ-мастера каждый раз менялись, и каждый раз они оставались лишь текстом, который не влиял ни на что и даже противоречил реальному состоянию персонажа. Правила, чтобы обработать такой запрос, в движке не было.
Мне захотелось это исправить, но так, чтобы не превратить движок в «рельсы», прописывая реакцию на каждый кейс. Спустя 3 месяца я получил MVP, который может обрабатывать подобные заявки так, как задумано. И правил про пальцы в нём по-прежнему нет.

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

Перед тем, как что-то отдать судьям, Роутер занимается первичной обработкой запроса, определяет атомарные действия и зависимости и, главное, определяет судей, суждения которых нужны. Подробно об этом я писал здесь, эта же статья посвящена «вееру» и правилам. Кстати, стоимость такого веера получается приемлемой, т.к. за счёт разделения ответственности каждый судья довольно «узкий», потребляет мало контекста и работает копеечной на gpt-4o-mini.
Возьмём запрос из кейса. Роутер раскладывает его на 2 атомарных действия: [1] игрок откусывает себе мизинец, [2] игрок кладёт мизинец в чашу. Также он определяет, что действие [2] зависит от действия [1]. Примерный перечень вопросов и судейских ответов по каждому из действий представлен ниже.
Вопросы |
|
|
возможно ли действие в принципе? | да | да |
есть ли в сцене палец и чаша? | да | да и да |
нужно ли сделать проверку (бросок d20), чтобы действие получилось? | Атлетика СЛ 15 | нет |
возможный исход в случае удачи и провала | успех: действие удалось + урон неудача: действие не удалось | нет |
решится ли при этом загадка, сработает ли ловушка? | нет | палец достаточно ценен, Загадка Порога разгадана |
должны ли реагировать враги? | нет (нет подходящих триггеров) | нет (нет подходящих триггеров) |
если бой: тратится ли на это действие, бонусное действие или что-то ещё? | действие | свободное действие |
Состав вопросов может варьироваться в зависимости от действия и, соответственно, судей.
Учёт суждений
Дальше с этой информацией уже может работать код. Реализация не принципиальна, а главная фишка тут в том, что делать с последствиями, если есть зависимые действия и бросок кости.
В кейсе выше как раз действие 1 зависит от броска и имеет зависимости, поэтому:
в случае успеха игрок получает урон, в случае провала не получает и проваливает действие — откладываем до результатов броска (т.е. сохраняем в БД и не применяем сейчас);
действие 2 решает загадку, но зависит от действия 1, которое зависит от броска — тоже откладываем.
Дашее игроку предлагается бросить кость — он просто нажимает кнопку. Уже известно по решению одного из судей, что нужен бросок атлетики, и определена сложность. Берём модификатор из карточки игрока, выбираем рандомное число, складываем, сравниваем со сложностью, получаем честный результат.
Если успех, применяются последствия успеха действия 1 и последствия действия 2: игрок отрезал себе палец, получил урон, решил загадку — это ровно то, что показано на картинке выше. При этом, урон посчитает код, он же отнимет хп, проверит, не умер ли игрок, обновит статус загадки на «решена», откроет связанный с загадкой проход в соседнюю локацию — и положит всё это в базу данных.
Если провал, то действие 1 не получилось, действие 2 просто отменяется каскадом.
А если игрок попытался это всё провернуть в бою, в дополнение ко всему у игрока спишется ещё и слот действия, а если слотов нет, будет каскадная блокировка. Это тоже сделает код.
Во всех случаях на выходе получается результирующий перечень: что получилось, что не получилось и почему. Этот перечень передаётся ИИ-рассказчику с требованием описать его литературно — и игрок получает чёткий, разложенный по полочкам, художественный ответ мастера.
Как промежуточный итог, концепция такая: ИИ выносит суждения, код их применяет, ИИ-рассказчик описывает уже случившийся факт. Но где здесь правила, которые позволяют обработать неожиданный запрос?
Правила
Правила есть. Но они устроены, скажем так, по уровням.
Самый очевидный уровень это правила, «прошитые» в коде движка. Например, расчёт экономики действий в бою: цену действия определяет один из судей, однако доступные действия и количество атак хранятся в карточке персонажа, а подсчёт потраченных действий ведёт код. Также код считает очерёдность ходов в бою, урон, хп, определяет начало и конец боя, меняет статусы загадок, открывает двери и т.д.
Второй уровень — это отдельные механики судей, «зашитые» в их промптах. Тот же Дуэлянт понимает атаки вида «бросает, бьёт» и может отличить атаку оружием от атаки импровизированным оружием, а также решить, что «песок в морду орку» — это не попытка нанести урон песком, а попытка ослепить. На деле это превращается в набор инструкций, а на выходе — JSON с флагами суждения и нужными бросками попадания и урона.
Сам состав судей подсказывает, что действия как-то классифицируются, и разные классы обрабатываются по-разному. Очевидно, действие «я осматриваюсь» не должно попадать в боевой домен (к Дуэлянту) — туда идут атаки по NPC, а оценивать «ценность подношения» в виде пальца для чаши-загадки должен Резолвер. За выбор судей как раз отвечает Роутер, и ключевое здесь — развести домены в его промпте так, чтобы он правильно выбирал судей.
И тут возникает вопрос: а что, если действие не классифицируется? Здесь нужен судья импровизированных действий, и это Импровизатор. Именно он решает, может ли игрок прыгнуть достаточно высоко в текущем сеттинге, или отрезать себе палец, или запрыгнуть на потолок. Плюс, при необходимости, он назначает броски и прописывает последствия в машиночитаемом формате.

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

Нерешённые проблемы
Есть и проблемы, продиктованные такой архитектурой. Назову несколько.э
Во-первых, много точек отказа. Если Роутер неправильно выберет судей, результат будет непредсказуемым (и однозначно некорректным). Если судья неправильно интерпретирует триггер, враг не появится, когда должен, или появится, когда не должен.
Во-вторых, сама классификация действий в рамках одного судьи. Например, я недавно обнаружил, что Дуэлянт просто скипает запрос типа «плюю в морду орку», потому что он не может понять что это. Это не атака оружием, это не импровизированная атака. Игрок видит значок 🚫, действие просто срезалось.
В-третьих, вариативность суждения. В одном прогоне судья решает, что нужна проверка «получилось действие или нет», в другом — действие получается без проверки, но добавляется проверка на последствия («достаточно ли высоко прыгнул?», «получил ли урон при приземлении?»). Иногда это интересно и выглядит как фича, иногда — глупо.
Эти проблемы — не баги реализации, а цена архитектуры, в которой часть решений отдана ИИ. Код гарантирует, что вердикт по каждому действию есть и что он один, но не что он каждый раз одинаковый. Основная сложность здесь в чётком разделении доменов, определении правил суждения и рамок импровизации, а каждый выбор имеет свои плюсы и минусы.
Собственно, мой открытый вопрос: если выдумку игрока нельзя перечислить, кто вообще должен решать, бросок здесь — это «получилось ли», или «получилось, вопрос в цене»? И можно ли требовать, чтобы одно и то же действие судилось одинаково от прогона к прогону? У меня ответа нет.
А ещё ближе к концу сентября я планирую второй альфа-тест этого обновлённого движка — приходите ломать судей своими заявками, запись здесь, подробности в моём телеграм-канале.

