Онлайн-мафия ломается о простую вещь: нужно 6–10 человек одновременно. Я сделал мини-приложение для Telegram и MAX, где пустые места занимают боты. Рассказываю, как устроен их «мозг», почему он не видит лишнего, как боты-мафия врут и как я подбирал баланс, прогоняя тысячи партий одних ботов.
Требования к боту
Бот должен быть не умнее и не глупее среднего игрока. Слишком умный бот — скучно (он всегда прав), слишком глупый — ломает партию людям (голосует наугад и топит город). Я записал характер прямо в шапку модуля: «не агрессивные, но и не тупые».
Три правила:
-
Бот видит только то, что положено его роли. Никаких подглядываний в движок. Бот получает тот же
viewFor(playerId), что и клиент живого игрока: свою роль, свои результаты ночных действий, открытые события стола, открытые голоса. -
Подозрения — только из фактов. Проверки комиссара, вскрытые роли выбывших, кто за кого голосовал.
-
Ведёт себя по-человечески. Ходит с задержкой, в чат пишет коротко и нечасто, иногда пропускает голосование.
Память бота
interface Memory { sus: Map; // кто → насколько похож на мафию (≥3 — уверен, <0 — доверяет) known: Map; // точно известные роли: свои проверки, вскрытия, признания seenEvents: number; // сколько событий стола уже учтено saidRound: number; // говорил ли уже сегодня revealed: Set; // о ком комиссар уже рассказал городу bluffed?: boolean; // мафия уже блефовала в этой партии}
Перед каждым решением бот «досматривает» новые события стола и свои результаты:
private learnResult(m: Memory, r: ActionResult): void { if (r.type === 'check') { m.known.set(r.targetId, r.role); m.sus.set(r.targetId, ['don', 'mafia', 'maniac'].includes(r.role) ? 10 : -5); } else if (r.type === 'don_check' && r.isCommissar) { m.known.set(r.targetId, 'commissar'); }}
Голоса как улики
Самый богатый источник информации у города — открытые голоса. Когда кого-то изгоняют и его роль вскрывается, каждый бот пересчитывает подозрения:
-
изгнали мирного → все, кто голосовал за него, становятся подозрительнее;
-
изгнали мафию, а кто-то голосовал за другого → тоже чуть подозрительно.
Плюс мелкие сигналы: публичное обвинение добавляет цели +0.4, шёпот — немного обоим. Ни один сигнал сам по себе не решающий — бот не должен «прозревать» от одной фразы.
Ведущий (тоже бот) пользуется теми же открытыми данными и иногда подсказывает людям: «X уже трижды голосовал за изгнание мирных. Совпадение?»
Общая шина слухов
Боты за одним столом не общаются тайно — но слышат одно и то же. Когда бот говорит в чат что-то значимое, он кладёт в общую шину «слух» с весом:
this.env.broadcast({ about: found, delta: 3 }); // «проверил X — это мафия»this.env.broadcast({ about: bot.id, delta: -4, role: 'commissar' }); // и раскрылся сам
Каждый бот добавляет delta к своему подозрению. Это грубая модель «насколько стол поверил словам», но её хватает, чтобы город начал действовать как город.
Комиссар не раскрывается сразу
Если комиссар-бот всегда сразу кричит «нашёл мафию!», мафия убивает его следующей же ночью — и город остаётся слепым. Поэтому раскрытие вероятностное: комиссар рискует не каждый день. Доктор, в свою очередь, часто (но не всегда) лечит раскрывшегося комиссара — иначе мафии вообще не пробиться.
Как врёт мафия
Бот-мафиози:
-
на обвинение отвечает «я мирный» и переводит стрелки;
-
днём обвиняет того, кто сам выглядит подозрительно, — чтобы увести внимание;
-
ночью идёт за выбором семьи (иначе голоса мафии рассыпаются);
-
изредка идёт ва-банк: «Я комиссар, проверил Анну — это мафия!» — теми же словами, что и настоящий комиссар.
Последнее оказалось самым опасным для баланса. В первой версии блеф выдавался с той же силой, что и настоящее раскрытие, — и мафия-боты начали выигрывать большинство партий: город раз за разом казнил своих. Пришлось сделать блеф редким и «менее убедительным» для остальных ботов — как в жизни: заявлению без подтверждения верят меньше.
Баланс: тысячи партий одних ботов
Чтобы не гадать, я написал скрипт: стол из ботов от 6 до 24 мест, тысячи партий, каждая — с детерминированным генератором случайных чисел (seed), чтобы любую странную партию можно было повторить.
function makeRng(seed: number) { let x = seed >>> 0 || 1; return () => ((x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 2 ** 32);}
Партии независимы, поэтому скрипт форкает процесс на каждое ядро — 10 000 партий проходят примерно за секунду. На выходе — таблица по размерам стола: кто выигрывает, сколько длится партия, как часто город изгоняет именно злодеев.
Что нашлось таким способом:
-
Баг в самом прогоне. Фаза иногда «перещёлкивалась» дважды: все боты походили (движок сам перешёл к утру), а скрипт ещё раз вызывал
advance(). Лечится сравнением ключа фазыround:phaseдо и после. -
Маньяк был слишком сильным на больших столах. Бот-маньяк теперь голосует как живой: идёт за толпой или против тех, кому город доверяет, — и этим постепенно себя выдаёт.
-
Первая ночь — лотерея. Игроки жаловались, что мафия в первую ночь стреляет наугад. Сделал «ночь знакомства»: мафия только узнаёт своих, дон ищет комиссара, комиссар уже проверяет. Мафия потеряла один выстрел — и симуляция сразу показала перекос в пользу города. Компенсировал одним мафиози сверху на столах от 6 мест.
Важно: симуляция ботов ≠ игра людей. Но она отлично ловит грубые перекосы и регрессии — прогон стал обязательным перед каждым изменением правил.
Один стол на Telegram и MAX
Игра — веб-приложение (React + Fastify + Postgres), открытое как мини-приложение в обоих мессенджерах. Оба передают initData, подписанную HMAC-SHA256 от токена бота по одной и той же схеме:
const secret = createHmac('sha256', 'WebAppData').update(botToken).digest();const expected = createHmac('sha256', secret).update(pairs.join('n')).digest();
Отличия мелкие: у MAX SDK строка приходит дополнительно закодированной, поэтому проверяем оба варианта. Пользователь получает id вида tg:123 или max:456, а дальше это просто игрок за общим столом. Ссылки-приглашения строятся под платформу того, кто приглашает, а приглашение в «чужой» мессенджер — второй кнопкой.
Что бы я сделал иначе
-
Сразу писать движок как чистую функцию от состояния и времени (
advance(now)), без таймеров внутри — это и сделало возможными быстрые симуляции и тесты. -
Раньше выделить «что видит игрок» (
viewFor) в одно место: тогда боты автоматически не могут подглядывать.
Если хотите посмотреть, как это играется: игра бесплатная, без доната — https://mafnight.ru/?src=habr
Источник: habr.com