Карточный бой, который сервер может переиграть: детерминированный движок браузерной MMO на TypeScript

Мы делаем «Зархолд» — браузерную MMORPG, в которой любой бой, от первого кобольда до рейдового босса, — это пошаговая карточная дуэль. Клиент написан на React и PixiJS 8, сервер — на Fastify и PostgreSQL, а между ними лежит пакет shared с движком боя на TypeScript. Один и тот же код движка работает в браузере игрока и на сервере.
В статье расскажем, как мы устроили движок, чтобы сервер мог переиграть бой ход в ход, как включали серверную проверку, не задев честных игроков, и на каких граблях учились. Код — настоящий, из репозитория, местами сокращён.
Зачем серверу переигрывать бой
В онлайн‑игре с рейтингом доверять клиенту нельзя: если клиент сообщает «я победил», изменённый клиент будет сообщать это всегда. Обычно выбирают одно из двух:
сервер ведёт бой сам, а клиент присылает только намерения и рисует результат;
клиент играет локально, а сервер потом проверяет исход.
Для пошаговой карточной дуэли второй путь очень дешёвый. Весь бой описывается начальным состоянием, одним целым числом (сидом) и списком ходов — их обычно несколько десятков. Чтобы проверить бой, серверу достаточно прогнать тот же движок по тому же списку. У нас есть оба варианта: живая дуэль двух игроков идёт на сервере ход за ходом, а бой на арене против соперника под управлением игры клиент играет у себя, и сервер затем переигрывает его по журналу ходов.
Но второй путь работает, только если движок строго детерминирован: одинаковый вход на любой машине даёт одинаковый результат. Об этом первая часть.
Правило 1: никакой случайности, кроме одного генератора
Шапка engine.ts (сейчас в нём около 9 тысяч строк) формулирует контракт прямо:
/**
* The deterministic, server-authoritative duel engine.
*
* Every exported function is PURE in the sense that the *only* state it reads
* or writes is the DuelState passed in (and a deterministic RNG seeded
* from it). There are no clock reads, no `Math.random`, and no I/O — given the
* same starting state and the same sequence of calls you always get the same
* result. That is what makes a duel replayable and validatable on the server.
*/Весь случайный выбор — тасовка колоды, выбор карт для эффекта «сбросить N карт» у босса и тому подобное — идёт через один генератор. Это mulberry32: крошечный, быстрый и одинаковый в любом движке JavaScript, потому что опирается только на 32-битную целочисленную арифметику.
export interface Rng {
/** Internal 32-bit state. Exposed so a duel can be serialized & resumed. */
state: number;
}
export function createRng(seed: number): Rng {
return { state: seed >>> 0 };
}
export function nextFloat(rng: Rng): number {
// mulberry32
rng.state = (rng.state + 0x6d2b79f5) | 0;
let t = rng.state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
export function shuffle<T>(rng: Rng, items: readonly T[]): T[] {
const out = items.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = nextInt(rng, 0, i);
[out[i], out[j]] = [out[j]!, out[i]!];
}
return out;
}Важная деталь: состояние генератора — это просто число внутри состояния боя. Поэтому бой целиком сериализуется в JSON и восстанавливается с того же места. Живую дуэль сервер так и хранит: снимок в базе, на каждый ход — разобрать, применить ход, записать обратно.
Генератор при этом не криптографический, и не должен им быть: сид выдаёт сервер, а от генератора нужна только воспроизводимость.
Правило 2: порядок, в котором тратится случайность, — часть протокола
Детерминизм легко сломать, не трогая сам генератор. Достаточно поменять порядок, в котором разные части кода берут из него числа. Например, бой начинается с тасовки колод всех участников. Когда в движке появились групповые бои с союзниками, порядок пришлось зафиксировать явно:
// Clone in a fixed order (player, then each ally, then each enemy) so the RNG
// stream — and therefore every shuffle — is identical for a given seed. With
// no allies this clones exactly [a, b…] — the byte-identical legacy stream.
const player = cloneChar(a);
const allies = (opts.allies ?? []).map(cloneChar);
const enemies = enemyInputs.map(cloneChar);Главное здесь — последняя строка комментария. Новая возможность не имеет права сдвигать поток случайных чисел для старых боёв: бой без союзников должен тратить ровно ту же последовательность, что и до появления союзников. Иначе старые записи боёв переиграются иначе, а одинаковые входы перестанут давать одинаковый результат между версиями.
Ещё одно решение, которое сильно упростило жизнь: в переработке характеристик мы убрали из игры критические удары. Числа на карте — ровно то, что произойдёт, и в PvP единственная случайность — тасовка и добор. Это решение пришло из геймдизайна (бой можно посчитать, а не надеяться на удачу), но и движку стало проще: меньше мест, где поток случайности может разойтись.
Как сервер переигрывает бой
Схема для боя на арене выглядит так:
Сервер выдаёт матч: создаёт запись с соперником и сидом и отдаёт клиенту идентификатор матча и сид.
Клиент играет бой тем же движком и записывает ходы: розыгрыш карты, силу класса, замену стартовой руки, расходник, расовую способность, активный предмет.
Клиент отправляет результат вместе с идентификатором матча, журналом ходов и версией движка.
Сервер собирает обоих бойцов из своих собственных данных, запускает
startDuelс выданным сидом и применяет ходы по одному.
Сердце проверки — короткий цикл:
const MAX_REPLAY_MOVES = 2000;
export function replayPvpDuel(player, opponent, seed, moves) {
// forPvp: Стойкость применяется, критов нет — повтор полностью детерминирован.
const state = startDuel(player, opponent, seed, { forPvp: true });
let movesApplied = 0;
for (const move of moves) {
if (movesApplied >= MAX_REPLAY_MOVES) break; // защита от журнала на миллион ходов
if (state.winner !== null) break; // бой уже кончился, дальше шум
applyReplayMove(state, move);
movesApplied += 1;
}
return { winner: state.winner, playerWon: state.winner === 'a', movesApplied, state };
}Несколько свойств, которые здесь важны:
Недопустимый ход не роняет проверку. Если в журнале карта, которой нет в руке, на которую не хватает кристаллов, или ход не в свою очередь, движок записывает это в журнал боя и ничего не меняет. Враждебный журнал не может ни уронить сервер, ни подвесить его: число ходов ограничено.
Нельзя «победить, не добив». Если клиент не прислал добивающий ход, бой заканчивается с
winner: null, и победа не засчитывается.Всё, что влияет на исход, берётся у сервера, а не из запроса. Например, у активного предмета в журнале есть только идентификатор. Эффект и перезарядку сервер берёт из своего каталога: иначе подделанный клиент подсунул бы себе кнопку с любым эффектом, а честный повтор подтвердил бы подделку.
Чья «кукла»: почему бойцов собирает сервер
Самое неочевидное место — не повтор ходов, а сборка бойцов. Клиент считает характеристики героя своим кодом снаряжения, который зависит от клиентских модулей, и сервер его не импортирует. Поэтому сервер строит своего бойца из сырых характеристик предметов, которые хранит у себя. Это правильно с точки зрения доверия — но значит, что клиентский и серверный бойцы могут разойтись, даже если оба кода честные.
Такие расхождения мы действительно ловили. Например, сервер по своему правилу не учитывал часть фамильных предметов, а клиент их учитывал — и у одного и того же героя получалось 352 единицы здоровья у клиента против 332 у сервера. Бой с таким расхождением при повторе может закончиться иначе, хотя игрок ничего не нарушал.
Три режима: выключено, наблюдение, строго
Отсюда главный принцип серверных проверок в проекте — «никогда не ломать честного игрока». Каждая проверка живёт за флагом окружения с тремя значениями, который читается один раз при старте процесса:
off— проверки нет;shadow— сервер переигрывает бой и пишет расхождение в журнал античита, но решение не меняет;enforce— решает исход сервера.
Режим наблюдения — это не формальность. Именно он показывает расхождения вроде истории с фамильными предметами до того, как они ударят по рейтингу честных игроков. Каждая запись о расхождении содержит матч, оба исхода, режим и версию движка клиента, так что по журналу видно, где проблема в нашей сборке бойцов, а где — в клиенте. Решение о строгом режиме принимаем по данным: считаем долю расхождений среди привязанных боёв и разбираем причину каждого, а не ориентируемся на «вроде бы мало».
То, что флаг читается один раз при старте, тоже осознанное решение: переключить режим можно только перезапуском, и не бывает так, чтобы половина запросов прошла по старым правилам, а половина — по новым.
Версия движка — тоже часть протокола
Правила боя меняются: появляются новые эффекты, меняется математика карт. Если старый клиент (вкладка, которую игрок не перезагружал со вчерашнего дня) сыграет бой по старым правилам, а сервер переиграет по новым, исходы разойдутся — и честный игрок будет наказан за то, что не обновил страницу.
Поэтому у движка есть строковая версия, которая меняется синхронно на клиенте и сервере при любой правке, способной изменить исход повтора. Сейчас она выглядит так:
export const DUEL_ENGINE_VERSION = '2026-10-14.v44-rashodniki-po-tekstu';Это 44-я версия с июня. Клиент присылает её вместе с результатом. Если версия не совпадает с серверной, сервер для этого боя смягчает строгий режим до наблюдения: расхождение пишется в журнал, но игрок не страдает.
Вторая половина — старые записи. Когда меняется правило, мы не переписываем историю: правило включается только для боёв, сыгранных движком нужной версии или новее.
/**
* Включать ли правило при ПОВТОРЕ записи боя, сыгранного движком версии `versiya`:
* v43 и раньше — нет (повтор по старым правилам), v44 и новее — да.
*/
export function rashodnikiPoTekstuDlyaVersii(versiya: string | null | undefined): boolean {
if (typeof versiya !== 'string') return true;
const m = /\.v(\d+)(?:-|$)/.exec(versiya);
if (m === null) return true;
return Number(m[1]) >= RASHODNIKI_PO_TEKSTU_S_VERSII;
}Таких флагов в движке несколько: правило «двух карт за ход с 10-го хода», новое поведение расходников и другие. Повтор старой записи проходит со старым набором правил, новый бой — с новым.
Грабли из эксплуатации
Детерминированный движок не избавляет от проблем на выкладке — он делает их очень заметными.
Смена версии движка днём. Однажды мы выложили изменение правил боя посреди дня, и у игроков с открытыми, но не перезагруженными вкладками бои начали расходиться с сервером. Теперь такие выпуски — только ночью и с объявлением «обновите игру».
Порядок выкладки. В другой раз сервер переключился на новую версию движка раньше, чем был готов клиент, и несколько минут живые бои не проходили. Теперь порядок жёсткий: сначала собрать и проверить клиент, потом переключить сервер и сразу выложить клиент.
Обе ошибки — не про код движка, а про то, что движок стал общим контрактом между двумя программами, которые обновляются в разное время.
Что ещё даёт детерминизм
Тесты. В пакете shared сейчас 270 тестовых файлов и около 2 700 тестов. Значительная часть из них проверяет не «работает ли эффект», а «не изменился ли старый бой»: комментарии вида «боец без комплекта переигрывается байт‑в‑байт как раньше» в коде — это не пожелание, а утверждение, которое проверяет тест.
Баланс симуляциями. Раз бой воспроизводим, баланс можно проверять пачками. Наш симулятор сводит каждую специализацию с каждой: 30 специализаций, 3 сида, каждый матч в обе стороны, чтобы убрать преимущество первого хода, — 5 400 боёв за прогон. Зеркальные бои (специализация против самой себя) должны давать ровно 50 на 50: это заодно проверяет, что ИИ одинаково честен к обеим сторонам.
Отладка жалоб. Если игрок пишет «я проиграл, хотя не должен был», а у нас есть сид и журнал ходов, бой можно воспроизвести у себя ровно таким, каким он был, а не гадать по скриншоту.
Итог
Пошаговый карточный бой отлично подходит для серверной проверки: сид плюс журнал ходов — и бой воспроизводится на любой машине.
Детерминизм — это не только «нет
Math.random». Это ещё и фиксированный порядок трат случайности, и запрет новым возможностям сдвигать поток для старых боёв.Бойцов для проверки собирает сервер из своих данных. Расхождения его «куклы» с клиентской — главный источник ложных срабатываний.
Любую проверку сначала включайте в режиме наблюдения: она покажет ваши собственные ошибки раньше, чем ошибки читеров.
Версия движка — часть протокола: клиент, сервер и записи боёв должны знать, по каким правилам их переигрывать.
Если интересно посмотреть, как всё это выглядит со стороны игрока, — «Зархолд» бесплатный и открывается в браузере: ru.zarhold.com. Вопросы по архитектуре задавайте в комментариях, ответим.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.