ИИ‑мастер для настольной RPG: один запрос на ход, кубик до ответа и баг, который удваивал здоровье. dnd.. dnd. json.. dnd. json. llm.. dnd. json. llm. Node.JS.. dnd. json. llm. Node.JS. геймдев.. dnd. json. llm. Node.JS. геймдев. искусственный интеллект.. dnd. json. llm. Node.JS. геймдев. искусственный интеллект. промпт-инжиниринг.. dnd. json. llm. Node.JS. геймдев. искусственный интеллект. промпт-инжиниринг. Разработка игр.. dnd. json. llm. Node.JS. геймдев. искусственный интеллект. промпт-инжиниринг. Разработка игр. ролевые игры.

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

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

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. Дать каждому стилю свой объём ответа. Хаму нужно место, чтобы и отыграть сцену, и поиздеваться над игроком, а Документалке — чтобы закадровый голос успел выдержать паузу.

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

Итого

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

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

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

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

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

Автор: Absurdia

Источник