- BrainTools - https://www.braintools.ru -

ИИ‑мастер для настольной RPG: один запрос на ход, кубик до ответа и баг, который удваивал здоровье

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

Стек: Node.js 24, Express, встроенный node:sqlite, модели через OpenRouter и общий для сервера и браузера модуль движка.

1. Механику ведёт код, а не модель

Первая версия была наивной: модели дают весь лист персонажа и просят «ответь и обнови состояние». Это быстро ломается. Модель забывает [1] предметы, раздаёт мечи +100, путает, сколько у героя денег, и через десяток ходов лист персонажа живёт своей жизнью.

Поэтому модель отвечает строгим JSON, в котором кроме рассказа есть только изменения:

{
  "check": { "stat": "ХАР", "dc": 12, "why": "уговорить дракона", "kind": "other", "ok": true },
  "narration": "Дракон открывает один глаз…",
  "hp": -3,
  "add": [{ "name": "Чужой сапог", "icon": "👢", "slot": "feet", "def": 1 }],
  "fact": "Гоша назвал дракона папой"
}

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

let dhp = num(r.hp);
if (dhp < 0 && !r.pierce) { // броня снижает урон, но не больше чем вдвое
  const def = Math.max(0, gear(ch).def);
  const dmg = -dhp, after = Math.max(Math.ceil(dmg / 2), dmg - def);
  dhp = -after;
}

Поле fact уходит в «хронику» — короткий список событий, который подмешивается в промпт следующих ходов. Так мастер помнит, что двадцать ходов назад герой назвал дракона папой, а вся история целиком в контекст не тащится.

2. Один запрос на ход вместо двух

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

Решение — бросать кубик заранее. Сервер кидает d20 до запроса и кладёт результат в промпт:

КУБИК: выпало 14, с бонусом Харизмы итог 16.

Системный промпт объясняет, как с этим работать: если проверка нужна, первым полем ответа идёт check (характеристика, сложность по шкале 8 / 12 / 15 / 18+, успех или провал), а рассказ пишется уже с учётом исхода. Если проверка не нужна, check нет и кубик не упоминается.

Сервер хранит такой ответ «запечатанным». Игроку показывается только «нужна проверка Харизмы, сложность 12» и анимация кубика, а поле ok вырезается:

const publicCheck = (c) => { const { ok, ...rest } = c; return rest; };

Игрок нажимает «Бросить», и рассказ открывается без нового запроса к модели.

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

function sealedRoll(ch, check, die, used) {
  let roll = resolveCheck(ch, check, die, used);
  if (typeof check.ok === 'boolean' && !roll.crit && roll.ok !== check.ok && Number.isFinite(roll.total)) {
    roll = resolveCheck(ch, { ...check, dc: check.ok ? roll.total : roll.total + 1 }, die, used);
  }
  return roll;
}

Критические 1 и 20 не трогаем — это святое. И ещё одна мелочь: потоковую выдачу пришлось научить придерживать текст, пока не ясно, будет ли проверка, иначе игрок успевает прочитать исход до броска.

3. Баг, который удваивал здоровье

Тестировщики пожаловались: стоит мастеру прибавить герою максимум здоровья, как дальше он начинает удваиваться. Причина нашлась в одной фразе промпта: «hp, money, stats — изменения, а не итоговые значения». Про max_hp в ней не было ни слова. Модель видела «Здоровье 10/20», решала, что герой стал крепче, и писала max_hp: 20 — итоговое число. А движок честно прибавлял: 20, 40, 80…

Лечение получилось двухслойным. В промпте max_hp добавлен в список изменений с правилом «меняй редко и не больше чем на четверть; лечение, отдых и еда — это hp». А в движке стоит страховка на случай, если модель всё равно пришлёт итог:

function maxHpDelta(cur, v) {
  if (!v) return 0;
  const lim = Math.max(2, Math.round(cur / 4));
  return clamp(v > lim ? v - cur : v, -lim, lim);
}

Число больше допустимого шага считается итоговым значением и превращается в разницу, а сам шаг ограничен ±25%. Из той же истории выросло ещё одно правило: когда максимум растёт, текущее здоровье растёт на ту же величину. Иначе «благословение: +5 к здоровью» выглядит для игрока так, будто ничего не изменилось.

Вывод банальный, но полезный: у каждого числового поля в JSON‑ответе модели должен быть явный тип — дельта или абсолютное значение — и проверка на стороне кода, а не только в промпте.

4. Стили мастера: где в промпте живёт характер

Последнее нововведение — «характер» ведущего: Классика, Угар, Хам, Эпос и Документалка. Первая версия разочаровала: стили звучали почти одинаково. Блок стиля стоял в середине длинного системного промпта, после него шли правила формата, механики и примеры, и модель про стиль просто забывала.

Помогли три вещи:

  1. Перенести блок стиля в самый конец системного промпта и прямо написать, что стиль важнее привычной манеры и тона мира, а правила игры, проверки и формат JSON не меняются.

  2. Продублировать его одной строкой прямо перед действием игрока: «(Стиль ведущего: Хам.)».

  3. Дать каждому стилю свой объём ответа. Хаму нужно место, чтобы и отыграть сцену, и поиздеваться над игроком, а Документалке — чтобы закадровый голос успел выдержать паузу.

Строка‑напоминание оказалась самой дешёвой и самой действенной правкой: десяток токенов, а разница — как между двумя разными играми.

Итого

  • Модель придумывает, код считает. Всё, что влияет на баланс, проходит через движок с жёсткими границами.

  • Случайность [2] — до запроса, а не после. Это экономит запрос и делает ответ цельным.

  • У каждого числа в ответе модели есть явный тип и страховка в коде.

  • Важные инструкции — в конец промпта и короткой строкой рядом с вводом пользователя.

Проект называется «Абсурдия», посмотреть, как это работает вживую, можно здесь: https://absurdia.online/?src=habr [3]. Буду рад вопросам про промпты и архитектуру в комментариях.

Автор: Absurdia

Источник [4]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/37117

URLs in this post:

[1] забывает: http://www.braintools.ru/article/333

[2] Случайность: http://www.braintools.ru/article/6560

[3] https://absurdia.online/?src=habr: https://absurdia.online/

[4] Источник: https://habr.com/ru/articles/1095802/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1095802

www.BrainTools.ru

Rambler's Top100