The Jerusalem PostTo understand Auschwitz, EJA first brings Europe's leaders back to Kristallnacht - interviewDaily MaverickRENEWABLES CRISIS : Pele’s governance test: Why a private boardroom matters to SA’s energy transitionZDF heuteEntdecken Sie das ZDF-NachrichtenstudioComplete SportsAmapakabo Worried By Abia Warriors Wastefulness In Games, Relying On A Single Player For GoalsRolling StoneIs This Pete Hegseth’s Last Stand?SudinfoQue se passe-t-il à la Maison-Blanche ? Les services de sécurité interviennent après une incident, le bâtiment placé en confinement !ESPNNFL Week 10 betting odds for every gameVanguardADP denies third-term agenda by Yabagi SaniNotJustOkSecret Lyrics by Veeiye & TelzWirtualna PolskaMarsz ciszy w Krakowie. Będą prezydent i premierABC NewsDOJ leadership shakeup continues as Civil Division chief steps downBillboardPaul Anka, Stevie Wonder, Cher & More Kennedy Center Honorees Who Were Teenagers When They First Hit the Billboard Hot 100 (Full List)
The Daily Newsstand · Free, Always
Monday, November 9, 2026

Как я написал ботов для мафии, которые подозревают, оправдываются и блефуют

Translate

Онлайн-мафия ломается о простую вещь: нужно 6–10 человек одновременно. Я сделал мини-приложение для Telegram и MAX, где пустые места занимают боты. Рассказываю, как устроен их «мозг», почему он не видит лишнего, как боты-мафия врут и как я подбирал баланс, прогоняя тысячи партий одних ботов.

Требования к боту

Бот должен быть не умнее и не глупее среднего игрока. Слишком умный бот — скучно (он всегда прав), слишком глупый — ломает партию людям (голосует наугад и топит город). Я записал характер прямо в шапку модуля: «не агрессивные, но и не тупые».

Три правила:

  1. Бот видит только то, что положено его роли. Никаких подглядываний в движок. Бот получает тот же viewFor(playerId), что и клиент живого игрока: свою роль, свои результаты ночных действий, открытые события стола, открытые голоса.

  2. Подозрения — только из фактов. Проверки комиссара, вскрытые роли выбывших, кто за кого голосовал.

  3. Ведёт себя по-человечески. Ходит с задержкой, в чат пишет коротко и нечасто, иногда пропускает голосование.

Память бота

interface Memory {
  sus: Map<string, number>;    // кто → насколько похож на мафию (≥3 — уверен, <0 — доверяет)
  known: Map<string, Role>;    // точно известные роли: свои проверки, вскрытия, признания
  seenEvents: number;          // сколько событий стола уже учтено
  saidRound: number;           // говорил ли уже сегодня
  revealed: Set<string>;       // о ком комиссар уже рассказал городу
  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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.