Я написал сайт с кейсами CS2 целиком и выложил под MIT

от автора

Сайтов с кейсами CS2 десятки. У крупных обороты как у небольшого казино, про них знает каждый, кто хоть раз открывал кейс в игре. И при этом исходники не публикует ни один. Снаружи видно крутящуюся ленту и кнопку «открыть», а что под ней происходит — приходится додумывать.

Я решил не додумывать, а написать такой сайт сам. Целиком: вход через Steam, открытие кейса с проверяемым роллом, апгрейд, контракт из нескольких предметов в один, ежедневное колесо бонусов, промокоды на пополнение, инвентарь с историей и обратной продажей, очередь выводов с фермой торговых ботов, админка, где кейс собирается под нужный RTP до того, как уйдёт в прод.

Репозиторий: github.com/ialakey/caseforge, MIT.

apps/web          Next.js 15 (App Router), React 19, Tailwind, Zustand, socket.io-clientapps/api          NestJS 11 на Fastify, Prisma 6, Postgres 16, Redis 7, BullMQ, socket.ioapps/bot          Node-воркер: ферма Steam-ботов, разбор очереди выводовpackages/shared   общие типы, zod-схемы, переводы, тикеты и provably fair

Весь стек на TypeScript, и это не вкусовщина: выбор языка мне вообще не принадлежал. Почему — история отдельная и целиком про Steam, я вынес её во вторую статью, вместе со всем остальным адом интеграции. Здесь — про математику, деньги и то, как устроена честность, которую можно проверить.

Каталог и живая лента дропов. Цены приезжают из Steam-маркета, переключатели языка и валюты — в шапке.

Состав кейса: редкость, текущая цена и точный шанс каждого предмета. 0,90% на карамбит и 63,20% на M4A1-S — это не рекламные цифры, а те самые диапазоны тикетов, против которых роллит сервер.

Сразу, чтобы снять вопросы из комментариев. В большинстве юрисдикций покупка кейса за реальные деньги регулируется как азартная игра: лицензия, KYC, возрастная проверка, ограничения по странам. Платежей в проекте нет вообще, пополнение баланса — заглушка, в проде выключенная. Это учебный проект про архитектуру. Если взять её и запустить на живые деньги без юридической части, MIT-лицензия вас не прикроет.

Provably fair: где я искал подвох

Схема у всех примерно одинаковая, и её правда можно проверить руками.

  1. Сервер генерирует serverSeed (32 случайных байта) и публикует только sha256(serverSeed).

  2. Игрок задаёт свой clientSeed (или получает случайный) и может менять его когда угодно.

  3. У пары сидов есть счётчик nonce, который растёт на каждом открытии.

  4. Ролл:

export function computeRoll(serverSeed: string, clientSeed: string, nonce: number): number {  const hmac = createHmac('sha256', serverSeed)    .update(`${clientSeed}:${nonce}`)    .digest('hex');  return parseInt(hmac.slice(0, 8), 16) % TICKET_SPACE; // TICKET_SPACE = 1_000_000}
  1. Выпадает предмет, чей диапазон тикетов накрыл roll.

  2. При ротации серверного сида старый раскрывается целиком, и любой прошлый ролл пересчитывается и сверяется с опубликованным хешем.

Почему диапазоны тикетов, а не веса. Диапазон проверяется в уме: [0, 749999] из миллиона — это 75%, и порядок обхода массива на результат никак не влияет. С весами пользователю пришлось бы верить моей нормализации на слово. Размер тикет-спейса зафиксирован навсегда: поменяете его — все прошлые открытия станут непроверяемыми.

Перепроверку в браузере я написал отдельной реализацией на Web Crypto (packages/shared/src/verify.ts). Соблазн дёрнуть ту же серверную функцию был большой, но тогда страница верификации проверяла бы согласованность сервера с самим собой, что бессмысленно.

packages/shared у меня не помойка для утилит: одна и та же функция расчёта шанса апгрейда считается и на сервере при ролле, и в браузере при отрисовке шкалы, разъехаться они физически не могут. Но пакет пришлось разрезать на два входа. Основной изоморфный, серверная криптография живёт отдельно, за сабпасом @caseforge/shared/node. Иначе node:crypto уезжает в браузерный бандл и фронт перестаёт собираться, причём ошибка выглядит настолько невнятно, что первые минут сорок я искал её совсем не там.

Modulo bias, который я не стал убирать

2^32 на миллион нацело не делится. Первые 967 296 тикетов получают по 4295 хешей, остальные по 4294. Перекос порядка 0,023%, одинаковый для всех, и он на порядки меньше шага цен.

Rejection sampling я не делаю, и это не недосмотр. Он ломает главное свойство схемы: возможность проверить ролл калькулятором и одной строчкой в консоли. Как только появляется цикл «не подошло — берём следующий хеш», объяснить проверку обычному человеку становится втрое сложнее. Я решил, что 0,023% того не стоят, и написал это в комментарии к коду прямым текстом. Если считаете, что я неправ, скажите в комментариях, мне правда интересно.

И вот тут до меня дошло

Пока я вылизывал схему и искал, где здесь можно жульничать, выяснилось, что искал я не там.

Подкручивать ролл никому не нужно. Во-первых, рискованно: сид рано или поздно раскрывается, и подкрутка превращается в математическое доказательство мошенничества, которое любой желающий пересчитает у себя на коленке. Во-вторых, незачем.

Всё решает таблица диапазонов.

Сайт может быть кристально честен в смысле provably fair и при этом собирать кейс с RTP 85%, то есть забирать в среднем 15% с каждого открытия. Эти два утверждения друг другу не противоречат вообще никак, оба верны одновременно.

Получается, «provably fair» отвечает на вопрос «не подкрутили ли конкретно мой ролл». На вопрос «выгодно ли мне вообще нажимать эту кнопку» он не отвечает. На него отвечает RTP, и его читают единицы.

Поэтому в проекте два правила, которые я выдерживал жёстко:

  • serverSeed не зависит ни от предмета, ни от пользователя, а решение о дропе не имеет права смотреть на баланс игрока;

  • если экономику надо крутить, её крутят через диапазоны, открыто, в админке, с пересчётом RTP.

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

«Сделать кейс прибыльным» — это одно уравнение с N неизвестными

RTP кейса считается просто: Σ(шанс × цена) / цена кейса.

На практике держат 85–95%. Выше 100% кейс убыточен гарантированно, ниже 80% его никто не купит. Жёсткий потолок я поставил на 98%: выше сервер просто отказывается сохранять кейс, чтобы дыру в экономике нельзя было выкатить одним неаккуратным кликом.

Теперь задача оператора. Звучит она как «разложить шансы так, чтобы игроку было интересно, а сайту прибыльно». Если записать формально, это одно уравнение с N неизвестными, решений бесконечно много. Нужна модель, иначе админ будет двигать проценты вручную и смотреть, что вышло.

Я взял очевидную: чем дороже предмет, тем он реже. Вес w_i = price_i^(-k), шанс s_i = w_i / Σw. При k = 0 всё равновероятно, с ростом k дешёвые предметы вытесняют дорогие. Матожидание по k монотонно убывает, значит нужный показатель ищется бинарным поиском:

const evAt = (k: number): number => {  // Нормализуем цены по минимуму: price^(-k) переполняет double  // при больших ценах и k, а отношения весов от нормировки не меняются.  const weights = prices.map((p) => Math.pow(p / minPrice, -k));  const total = weights.reduce((a, b) => a + b, 0);  return prices.reduce((sum, p, i) => sum + (weights[i]! / total) * p, 0);};let lo = -60, hi = 60;                   // k = -60 прижимает всё к самому дорогому,for (let iter = 0; iter < 200; iter++) { // k = 60 — к самому дешёвому  const mid = (lo + hi) / 2;  if (evAt(mid) > targetEv) lo = mid;  else hi = mid;}

Комментарий про переполнение появился после того, как я на это наступил. Нож за двести тысяч в минорных единицах и k около двадцати дают Infinity, весь массив весов становится NaN, и билдер молча возвращает кейс с нулевыми шансами. Ошибки при этом нет нигде.

Диапазон достижимых RTP жёстко ограничен крайними предметами: никакое распределение не даст среднее меньше самого дешёвого предмета и больше самого дорогого. Если цель вне коридора, билдер не подгоняет молча, а пишет, что конкретно делать:

При цене кейса 250.00 достижимый RTP — 12.4%…940.0% (предметы от 31.00 до 2350.00). Цель ниже — поднимите цену кейса или добавьте предмет подешевле.

Обратная задача, «вот лут-таблица, сколько должен стоить кейс», решается в одну строчку: цена = матожидание дропа / целевой RTP. На практике кейс так и собирается: сначала состав, потом цена.

Остаток тикетов, который тихо кормил нож

Шансы дробные, тикеты целые. Округление вниз оставляет остаток, и его надо кому-то отдать.

Первая версия отдавала последнему в списке. Работает, сумма сходится, тесты зелёные. А потом я посмотрел на реальный кейс и сообразил, что список-то отсортирован по цене, и последний в нём — нож. То есть остаток каждый раз незаметно поднимал и шанс ножа, и RTP всего кейса. На двадцати дешёвых предметах это единицы тикетов, ерунда. На кейсе, где нож стоит половину всего пула, уже не ерунда.

// Остаток уходит предмету с самой широкой долей, то есть самому дешёвому.let widest = 0;for (let i = 1; i < widths.length; i++) {  if (widths[i]! > widths[widest]!) widest = i;}widths[widest]! += remainder;

И ещё: предмет, чья доля округлилась в ноль, всё равно получает один тикет. Слот в кейсе с нулевым шансом — это просто враньё на витрине.

Цены уезжают, и экономика ломается сама

Кейс, собранный на 92% RTP, через месяц оказывается на 105%, потому что нож подорожал. Никто ничего не делал, а сайт уже платит игрокам больше, чем получает.

Поэтому ежечасная джоба тянет цены из маркета для предметов активных кейсов, после каждой переоценки RTP пересчитывается, выход за границы логируется предупреждением, а превышение 98% — ошибкой.

Хуже с предметами, цену которых Steam ни разу не вернул: нет лотов, кривое имя, что угодно. Такой предмет несёт в себе выдуманное число, но в RTP участвует наравне со всеми и тихо тянет экономику куда захочет. Их видно и в списке кейсов, и в билдере, отдельной пометкой.

Апгрейд, который не пришлось балансировать отдельно

Игрок ставит свой скин против более дорогого: выиграл — забрал дорогой, проиграл — отдал свой.

Соблазн был задать шансы руками, табличкой множителей. Вместо этого я вывел их из цен:

chance = stakeValue / targetValue × UPGRADE_RTP,  UPGRADE_RTP = 0.9

Тот же 0.9, на котором живут кейсы. При таком определении матожидание апгрейда равно ставке × RTP при любом множителе, это одна строчка алгебры и один тест. Апгрейд автоматически оказывается в той же экономике, что и кейсы, и отдельной балансировки не требует вообще. Пять минут с бумажкой избавили от кучи ручной возни потом.

Ролл тот же самый, из той же пары сидов и общего счётчика nonce:

winThreshold: Math.floor(chance * TICKET_SPACE)  // выигрыш, когда roll < winThreshold

Это не ради переиспользования кода. У пользователя получается одна страница проверки честности на все механики, и апгрейд проверяется точно так же, как дроп.

Границы: множитель ниже 1.05 отсекается (это уже обмен с комиссией, а не улучшение), шанс выше 85% тоже (это уже почти гарантированный обмен), и разрыв цен, при котором шанс падает ниже 0.5%. Список доступных целей на фронте считается той же формулой, что и шанс на сервере, чтобы интерфейс не предлагал вариант, который сервер потом отклонит.

Ставка сгорает при любом исходе, это ставка, а не депозит. Списывается она условным updateMany по status = AVAILABLE: если предмет тем временем ушёл в продажу или на вывод, апгрейд не происходит вместо того, чтобы выдать предмет дважды.

А вот баг, на котором я поймал сам себя. Оставлю комментарий из кода как есть:

// Проверяем шанс ДО клэмпа, а не после. Если сначала зажать в диапазон,// сравнение всегда проходит — минимум уже подставлен клэмпом,// и слишком широкий разрыв цен остаётся незамеченным.if (raw < UPGRADE_MIN_CHANCE) { ... }const chance = Math.min(UPGRADE_MAX_CHANCE, raw);

Если зажать значение раньше, чем проверить, проверка перестаёт что-либо проверять. Тест на это я написал уже после того, как минут десять тупил, почему разрыв цен в четыреста раз спокойно проходит проверку.

Контракты: как посчитать таблицу исходов, которую никто не назначал руками

Контракт берёт от 3 до 10 предметов и возвращает ровно один. От апгрейда он отличается тем, что проигрышной ветки нет вообще: контракт выплачивает всегда, вопрос только в том, чем именно. Награда берётся из каталога в коридоре от 0,1× до 5× вложенной суммы, исход определяет тот же ролл по той же паре сидов.

Вложено пять предметов на 61,50 ₽, коридор награды 6,15–307,50 ₽. Двенадцать исходов, и видно, как шанс растёт от дорогих к дешёвым: 4,26% на предмет за 286 ₽ и 14,31% на предмет за 7,59 ₽.

Соблазн тут тот же, что и с апгрейдом: расписать веса руками. И он ещё опаснее, потому что рука незаметно создаёт вторую экономику рядом с кейсами, живущую по своей математике. Любая переоценка каталога начинает сдвигать маржу непонятно куда.

Поэтому веса решаются. Цель простая: матожидание награды должно равняться stakeValue × 0.9, тот же RTP, что у кейсов и апгрейда.

Модель я взял другую, чем в кейсах. Там был степенной закон price^(-k), здесь — экспоненциальный наклон:

w_i ∝ exp(-alpha × u_i)

где u_i — цена исхода в логарифмической шкале, нормированной на [0, 1] по всему пулу. Нормировка тут не косметика. На сырых лог-ценах одна и та же alpha значила бы разное для пула с разбросом в 10 раз и для пула с разбросом в 10 000 раз, и границы поиска перестали бы быть константой.

Alpha ищется бисекцией, и она сходится по приятной причине: матожидание монотонно убывает по alpha, потому что производная равна минус дисперсии наклонённого распределения. Это не пришлось проверять численно, это просто выписывается.

let low = -ALPHA_BOUND;let high = ALPHA_BOUND;for (let i = 0; i < 80; i++) {  const mid = (low + high) / 2;  if (tiltedExpectedValue(prices, logs, span, mid) > target) low = mid;  else high = mid;}

Восьмидесяти делений хватает, чтобы исчерпать мантиссу. И ещё одна мелочь, на которой легко получить NaN: перед возведением в экспоненту показатели сдвигаются на максимум. Сами по себе они ничего не значат, значимы только их разности, а сдвиг держит exp() подальше от переполнения на краях диапазона.

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

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

И последнее, что оказалось важным. Решённая таблица складывается в строку контракта как JSON — id предмета, цена и диапазон тикетов каждого исхода. Восстановить её потом из каталога нельзя, потому что цены с тех пор уехали. Без снимка ролл остался бы воспроизводимым, но перестал бы что-либо значить: вы бы пересчитали хеш, получили тот же номер тикета и не смогли бы сказать, чему этот тикет тогда соответствовал.

Колесо бонусов: когда солверу нечего минимизировать

Ежедневный бонус я добавлял последним и рассчитывал, что это будет полчаса работы: машинерия для ролла уже есть, надо только прикрутить сектора. Полчаса ушло на код и ещё вечер — на то, чтобы понять, почему здесь нельзя сделать так же, как везде.

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

А в колесе призы разнородные. 10 ₽ на баланс, скидка 25% на следующее открытие, бесплатное открытие кейса до 300 ₽, скин до 200 ₽. Общего числа, к которому это сводится, нет. Скидка вообще ничего не стоит, пока игрок не пошёл открывать кейс, и стоит ровно столько, сколько он в итоге потратит. Солверу тут нечего минимизировать.

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

export function wheelGrantCost(wheel = WHEEL): number {  return wheel.reduce(    (sum, slice) => (slice.kind === BonusKind.DISCOUNT ? sum : sum + slice.chance * slice.value),    0,  );}

Скидки из суммы выкинуты сознательно: они стоят долю от того, что игрок потратит потом, и относятся к марже кейсов, а не к бюджету колеса. Остальное считается честно и на текущей таблице даёт 46,50 ₽ за вращение. Тест падает, если перенастройка секторов выведет эту цифру за 60 ₽. Это не про красоту, это чтобы через полгода кто-нибудь (я же) не поднял «скин до 200 ₽» до «скин до 5000 ₽», не заметив, во что это обошлось.

Сектора рисуются ровно по тем тикет-диапазонам, по которым роллятся. Приз с тремя процентами занимает три процента обода, и на скрине это видно: «скин до 200 ₽» — узкая полоска, «10 ₽» — почти треть круга. Колесо, нарисованное равными дольками поверх неравных шансов, — самая старая ложь в жанре, и не хотелось её повторять просто потому, что так проще считать SVG.

Восемь секторов, нарисованных по своим тикет-диапазонам. Шансы под колесом — те же числа, по которым идёт ролл.

Кулдаун, который нельзя проверять

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

Дальше проблемы, на которые я уже напоролся с балансом, и всё равно столкнулся снова. Первая версия читала lastBonusAt, сравнивала с текущим временем и, если прошло больше суток, писала новое значение. Между чтением и записью два одновременных запроса проходят проверку оба.

const claimed = await tx.user.updateMany({  where: { id: userId, OR: [{ lastBonusAt: null }, { lastBonusAt: { lte: cutoff } }] },  data: { lastBonusAt: new Date() },});if (claimed.count === 0) throw badRequest(ErrorCode.BONUS_ON_COOLDOWN, '...');

Ноль затронутых строк — вращение уже забрал кто-то другой. Ровно тот же приём, что и со списанием баланса, и колонка lastBonusAt денормализована именно ради него: выводить последний спин из таблицы бонусов было бы аккуратнее, но захватить одним запросом уже нельзя.

В смоук-тесте на это есть отдельная проверка: пять параллельных запросов на вращение, принят должен быть ровно один.

Ваучеры и тест, который оказался неправ

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

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

А дальше я на этом правиле поймал собственный тест.

Смоук-сьют выдавал аккаунту скидку 50%, открывал кейс и ждал, что спишется половина. Списалось ноль. Первая мысль была, что сломался расчёт скидки.

Сломан был тест. На аккаунте с предыдущей секции осталось невыигранное бесплатное открытие, и выбор честно взял его: бесплатное открытие кейса за 9,99 ₽ экономит все 9,99, скидка 50% — 499. Код сделал ровно то, что я ему велел, просто тест не изолировал ваучер, который проверял.

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

И раз уж сервер тратит ваучер молча, интерфейс обязан сказать, что именно потратил. Иначе награда исчезает из списка вместе с тихо изменившейся ценой, и это неотличимо от бага.

Скин выбирается тем же роллом

Сектор «скин до 200 ₽» должен превратиться в конкретный предмет. Второй скрытый розыгрыш здесь был бы иронией: всё колесо построено на том, что исход выводится из пары сидов, а дальше — «ну а предмет мы вам как-нибудь сами выберем».

Поэтому предмет берётся тем же роллом: список кандидатов сортируется по id (это стабильно, в отличие от сортировки по плывущей цене), и индекс — остаток от деления ролла на длину списка.

Отдельно пришлось решить, что делать, если в каталоге вообще нет ничего дешевле потолка. Выдать ничего — единственный исход, которого у колеса быть не должно, поэтому сектор в этом случае выплачивается деньгами, а в комментарий к строке журнала пишется, почему.

И последнее: колесо надо показывать

Колесо можно сделать идеально и спрятать на отдельной странице, куда игрок зайдёт один раз из любопытства. Награда, за которой надо идти самому, — это награда, которую большинство не заберёт.

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

Каталог с готовым вращением. Когда кулдаун не вышел, на этом же месте тикает обратный отсчёт.

Деньги: целые копейки, условный UPDATE и блок нонсов

Никаких Float нигде. Все суммы целые в минорных единицах. Каждое изменение баланса создаёт строку в Transaction, а баланс в User — денормализованный кеш, который обязан сходиться с суммой транзакций. Ночной крон сверяет, расхождение уходит алертом.

Списание сделано не через read-compute-write, а одним условным апдейтом:

UPDATE users SET balance = balance - $1 WHERE id = $2 AND balance >= $1

Ноль затронутых строк означает, что денег не хватило. Это убирает гонку между двумя открытиями из двух вкладок, классический способ увести сайт в минус.

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

// Резервируем весь блок нонсов одним UPDATE.const seedRows = await tx.$queryRaw`  UPDATE server_seeds  SET nonce = nonce + ${count}  WHERE "userId" = CAST(${userId} AS uuid) AND "isActive" = true  RETURNING id, seed, "seedHash", nonce`;// RETURNING отдаёт уже увеличенное значение — наш блок это последние count номеров до него.const firstNonce = serverSeed.nonce - count + 1;

Ещё момент: на пачку пишется одна строка в журнал транзакций, а не десять. Сверка смотрит на сумму, а десять строк на один клик только засоряют историю.

И рейт-лимит считает кейсы, а не запросы. Одно нажатие «×10» — это десять открытий, защита от скриптов обязана видеть это именно так.

Промокод, который ничего не удешевляет

Промокоды я делал уже с оглядкой на то, что платежей пока нет, но когда-нибудь будут. И это сразу задало форму.

Код не уменьшает сумму пополнения — он добавляет сверху. Ввёл WELCOME10, пополнил на 500 ₽, на баланс пришло 550 ₽.

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

Начисление пишется отдельной строкой журнала, а не увеличенным депозитом:

DEPOSIT | 50000 | balanceAfter 2110093 | Top-up (stub, no real payment)BONUS   |  5000 | balanceAfter 2115093 | Promo code WELCOME10

То, что игрок оплатил, и то, что ему подарили, — разные деньги. Отчёт, который их не разделяет, не сможет ответить на единственный вопрос, который про акцию вообще задают: сколько она стоила.

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

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

Ещё одна мелочь, которая экономит поддержке день: коды деактивируются, а не удаляются. На них ссылаются погашения, и игрок, спрашивающий, откуда у него взялись 50 ₽ полгода назад, заслуживает строки, которая это всё ещё объясняет.

Инвентарь: строка, которую нельзя удалять

Предмет на аккаунте — это строка со статусом, и она не удаляется никогда:

AVAILABLE → SOLD | WITHDRAWN | LOCKED | UPGRADED | CONTRACTED

Действия доступны только из AVAILABLE. LOCKED принадлежит выводу в процессе, остальные три терминальные и складываются в историю.

Вкладка «История»: «Продан», «Выведен», «В контракте». Строки остаются на месте вместе с ценой, по которой предмет ушёл.

Удалять строку было бы проще, и это неверно сразу с двух сторон. Игрок, продавший нож, всё ещё хочет видеть, сколько за него получил. А поддержка не разберёт жалобу на предмет, который исчез из интерфейса в момент списания, потому что в базе про него не осталось вообще ничего.

Каждый переход — тот же условный updateMany по status = AVAILABLE, что и в апгрейде. Он одновременно и проверка, и захват: предмет, успевший уйти в продажу, вывод или контракт, просто не попадает в затронутые строки. Падает вызов на расхождении между количеством затронутых строк и числом запрошенных id, и предварительное чтение строки здесь не нужно вообще.

Вкладки и ценовые диапазоны. Числа на вкладках считаются одним запросом, а не пятью.

Две вещи, которые выглядят мелочью, но таковой не являются.

«Продать всё» собирает свой набор внутри транзакции по тому же фильтру, который видел интерфейс, а не по списку id из браузера. Список id ограничил бы операцию той страницей, что была открыта, и проиграл бы гонку любому изменению в промежутке. Фильтр не проигрывает. Сумма по той же причине возвращается с сервера: диалог подтверждения способен назвать только оценку.

Границы ценовых диапазонов заданы в базовой валюте, а не в отображаемой. Диапазон, плавающий вместе с курсом, пересортировывал бы инвентарь при каждом обновлении курсов. Предмет в понедельник лежал бы в одном диапазоне, во вторник в другом, вообще не изменившись в цене. Подписи при этом рендерятся обычным денежным форматтером, так что в рублях границы круглые, а в долларах пересчитанные.

Сессии: один refresh на всех

Пара JWT: короткий access и длинный refresh. Access несёт id и роль, проверяется без похода в базу и поэтому обязан быстро истекать, иначе бан или смена роли не вступят в силу. Refresh лежит в httpOnly-куке месяц.

Разделение работает, только если refresh кто-то тратит. Браузер делает это молча: 401 от любого вызова запускает один refresh и один повтор исходного запроса, а сессию заканчивает именно неудавшийся refresh.

Вот на слове «один» я и попался. Загрузка страницы поднимает несколько запросов сразу — профиль, инвентарь, сиды, история открытий. Когда access протух, они получают 401 одновременно, и если каждый начнёт собственный refresh, кука провернётся несколько раз подряд. Проигравшие гонку повторят запрос с токеном, который уже заменён. Со стороны игрока это неотличимо от случайного разлогина, причём воспроизводится только через пятнадцать минут простоя, то есть тогда, когда ты уже не смотришь.

let refreshing: Promise<string | null> | null = null;async function refreshAccessToken(): Promise<string | null> {  refreshing ??= (async () => { /* ... */ })();  return refreshing;}

Оператор ??= тут делает всю работу: параллельные 401 делят один промис.

Отдельно вебсокет. Он читает токен только на хендшейке, поэтому берёт его колбэком и переподключается при смене. Токен, зафиксированный при загрузке страницы, ломал бы любое переподключение после первого истечения.

И раз уж речь зашла про договорённости между фронтом и API, ещё одна из той же серии. Сервер отдаёт машиночитаемый code рядом с английским message, интерфейс переводит код и откатывается на message для тех кодов, которых эта сборка ещё не знает. Ошибка, добавленная на бэкенде, остаётся читаемой до того, как приедет её перевод. А названия кейсов живут в базе: это контент, а не интерфейс, там базовое имя плюс необязательное английское, оба редактируются в билдере.

Рулетка, которая ничего не решает

Лента тормозит под маркером примерно как в игре. Исход при этом уже известен: сервер вернул его вместе с роллом, анимация просто проигрывает результат. Подкручивать её «ради честности» не нужно и нельзя, ролл посчитан из пары сидов до того, как лента тронулась.

Три вещи, которые я понял только руками.

Два кадра вместо одного. Первый кадр отдаёт браузеру стартовую позицию без transition, второй — целевую с transition. Если сделать в одном кадре, браузер схлопнет оба стиля и прокрутки не будет вообще.

Небольшой случайный сдвиг внутри плитки. Приземление точно в центр каждый раз выглядит искусственно. Но разброс должен быть узким: с широким маркер оказывается на стыке плиток, и непонятно, что вообще выпало.

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

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

Лента в фоновой вкладке

А вот это я поймал недавно и считаю лучшим багом за весь проект.

Фоновая вкладка не выполняет кадры анимации. Таймеры в ней при этом продолжают идти. А приземление ленты у меня было таймером — логично, длительность известна заранее.

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

Починка вышла из двух частей. Первая — запасной таймер на старте: кто первым доберётся, тот и трогает ленту с места, второй ничего не делает.

let started = false;const start = (): void => {  if (started) return;  started = true;  setAnimating(true);  setOffset(target);};innerRaf = requestAnimationFrame(start);const startFallback = setTimeout(start, START_FALLBACK_MS);

Задержка в 250 мс выбрана так, чтобы на видимой вкладке этот таймер всегда приходил вторым и молча отваливался.

Вторая часть — не доверять переходу довезти ленту до места. Фоновая вкладка стопорит transition даже после того, как transform уже выставлен, и лента может простоять в начальной позиции сколько угодно. Поэтому приземление, которое всё равно таймер и поэтому выполняется всегда, дополнительно ставит ленту туда, где ей полагается быть по роллу. На экране это не меняет ничего: к этому моменту плавность уже выположена и лента и так на месте.

Мораль, которую я записал себе: если в анимации есть таймер и есть кадры, надо отдельно подумать, что будет, когда кадров не станет.

Сид базы, который перетирал правки в админке

Сначала db:seed создавал и предметы, и кейсы, с ценами, взятыми на глаз. Ловушка оказалась двойная.

Первое: после первой же синхронизации реальные цены Steam отличались на порядок, и кейсы уходили в минус, то есть в состояние, когда сайт платит игрокам больше, чем получает.

Второе, и это хуже: повторный запуск сида молча перетирал всё, что было поправлено в CRM.

Теперь db:seed создаёт только демо-администратора и его пару сидов. Каталог наполняется отдельной командой, которая ходит в Steam и собирает кейсы ровно так же, как это делал бы человек в билдере: автобалансировка под целевой RTP и сохранение через ту же валидацию. Никаких специальных путей записи в обход бизнес-логики. Наверное, это главное, что я вынес из всего проекта, и оно вообще не про кейсы.

Настраивать через админку можно не всё

Когда набралось три механики, стало понятно, что констант, которые оператор захочет покрутить без деплоя, уже прилично: комиссия обратной продажи, рейт-лимит открытий, границы пополнения, кулдаун колеса, сами сектора колеса, режим техобслуживания.

Очевидный путь — завести табличку settings и читать оттуда. Я его прошёл до середины и вернулся, потому что на каждую настройку получалось три места: поле в форме админки, валидатор на сохранение и читатель на сервере. Три места, которые надо держать в согласии руками. Это ровно та конструкция, из-за которой в бэк-офисах заводятся настройки, существующие в базе и нигде в интерфейсе.

Поэтому настройка объявляется один раз:

'economy.sellFeeBps': {  group: 'economy',  kind: 'int',  schema: bounded(0, 5000),  default: 0,  label: 'Sell-back fee, basis points',  hint: '100 bps = 1%. Taken when a player sells an item back to the site.',},

Форма админки рисуется из этого реестра, валидация берётся отсюда же, сервер читает по тому же ключу. Добавить ручку — строка в одном файле, панель подхватит сама.

Поля вместе с ключами. Ничего из этого в самой панели не описано — она просто рендерит реестр.

Две детали, которые выяснились уже в работе.

Значения кешируются на пятнадцать секунд. Они читаются почти на каждом запросе — рейт-лимит, комиссия, колесо, — и запрос в базу на каждое чтение данных, меняющихся пару раз в месяц, это чистая трата. Запись сразу обновляет пишущий инстанс, поэтому оператор видит эффект, пока ещё смотрит в панель.

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

А вот это настраивать нельзя

И тут я упёрся в вопрос, который оказался интереснее самой реализации: что нельзя отдавать оператору вообще.

TICKET_SPACE. Алгоритм сидов. Форма ролла.

Это не параметры тюнинга. Это условия обещания. Каждое прошлое открытие опубликовано против них, и именно их игрок пересчитывает у себя, когда проверяет дроп. Оператор, у которого есть поле «размер тикет-пространства», может одним сохранением сделать так, что вчерашние дропы перестанут сходиться. Причём даже не со зла — достаточно любопытства.

Это не настройка. Это способ сломать единственное утверждение, которое сайт вообще делает.

Граница получилась простая, и мне она нравится: настраивается то, насчёт чего сайт имеет право передумать. Комиссия — имеет. Лимит на открытия — имеет. Честность — нет.

Диапазоны тикетов кейса того же рода, и они правятся там, где им место: в билдере, через валидацию, которая отказывается сохранять кейс с дыркой в покрытии или с RTP выше ста процентов. Не «поле в настройках», а инструмент, который знает правила предметной области.

Чем это проверяется

Юнит-тесты закрывают то, что имеет смысл проверять в изоляции: provably fair, диапазоны тикетов, балансировку, шансы апгрейда, парсинг ответов маркета и профиля.

Но больше всего пользы принёс смоук-тест против живого API. Он ловит то, что юнитами не ловится в принципе:

  • атомарность списания баланса;

  • что активный серверный сид никогда не утекает в ответах;

  • что открытие пересчитывается после ротации сида;

  • что баланс сходится с журналом транзакций;

  • что роли реально закрывают админку;

  • что у каждого предмета в активном кейсе есть картинка и подтверждённая Steam цена;

  • что сервер отказывается сохранять убыточный кейс и кейс с дыркой в диапазонах тикетов.

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

Смоук-тестов теперь пять: общий, контракты, инвентарь, бонусы и админка. И на них я поймал ошибку, которой в коде не было вообще.

Инвентарный сьют где-то в середине делает «продать всё» — проверяет, что операция уважает фильтр. Контрактный сьют в начале требует минимум три предмета на аккаунте. По отдельности оба зелёные. Запущенные подряд — второй падает, потому что первый всё продал.

Чинится это в одну строку: контрактный тест теперь сам открывает пару дешёвых кейсов, если инвентарь пуст. Но пока я это не увидел, я минут двадцать искал регрессию в контрактах, которой там не было. Тест, зависящий от порядка запуска, врёт убедительнее любого бага.

Отдельно живёт проверка на конкурентность: пять одновременных запросов на вращение колеса, принят ровно один. Такие вещи юнит-тестом не поймать в принципе — нужна настоящая база с настоящими транзакциями.

Что есть и чего нет

Работает: вход через Steam OpenID с серверной верификацией и автообновлением токена, RU/EN интерфейс с переключателем валюты, открытие кейсов (в том числе пачкой до 10) с анимацией, provably fair с перепроверкой в браузере, апгрейд, контракты, ежедневное колесо с ваучерами, промокоды на пополнение, инвентарь с фильтрами, историей и обратной продажей, живая лента дропов через Redis Pub/Sub с батчингом раз в 300 мс, заявки на вывод с блокировкой предмета и идемпотентностью, воркер ботов, CRM с дашбордом GGR, билдером кейсов, настройками рантайма и аудит-логом, ночная сверка балансов с журналом транзакций.

Нет: платежей, депозита предметов, кейс-баттлов, реферальной системы, KYC.

И одна честная оговорка, потому что иначе на неё наткнутся при первом же запуске. Цепочка вывода — очередь, выбор бота, оффер, автоподтверждение, опрос статуса — в коде есть и работает, но из интерфейса она пока не дёргается: кнопка «Вывести» в инвентаре помечает предмет выведенным и на этом останавливается. Ровно как пополнение, которое начисляет баланс без платёжного провайдера. Обе заглушки намеренно единственные места, которые срезают настоящий путь, чтобы подключить его потом было удалением, а не распутыванием.

Код, архитектурный документ (на русском тоже) и инструкция на три команды лежат в репозитории: github.com/ialakey/caseforge · MIT

cp .env.example .env   # править ничего не нужно для локального запускаpnpm setup             # install + docker + build + миграции + сидpnpm dev               # api на :4000, web на :3000

Что я обо всём этом думаю

Я лез в эту тему с ожиданием найти жульничество в ролле. Его там нет, и не потому, что все вокруг честные, а потому что оно не нужно. Криптографически проверяемая честность прекрасно уживается с RTP 85%. Математика полностью на стороне сайта ещё до того, как пользователь нажал кнопку, и делается это совершенно открыто, в таблице шансов, которую почти никто не открывает.

Если вы играете, а не пишете код, то главное тут вот что. «Provably fair» отвечает на вопрос «не подкрутили ли мой конкретный ролл». На вопрос «выгодно ли мне это» отвечает RTP, и он обычно указан где-то рядом. Если вообще указан.

С технической стороны самое интересное здесь оказалось не в игровой логике. Оно в слое между вами и Steam: OpenID без OAuth, два эндпоинта маркета в разных валютах, звёздочки в названиях ножей, цены строками в локальных форматах, тысяча слотов инвентаря, трейд-холды. Тот самый случай, когда «просто прикрутить API» занимает больше времени, чем весь остальной продукт. Этому посвящена вторая статья — она самостоятельная и читается отдельно.

Три места, в которых я не уверен, что выбрал лучший вариант, и по ним мне правда интересно, что скажут в комментариях:

  1. У меня в проекте теперь две разные модели балансировки: степенной закон price^(-k) в кейсах и экспоненциальный наклон exp(-alpha × u) в контрактах. Обе решаются бисекцией, обе попадают в целевой RTP, но кривые редкости дают заметно разные. Я выбирал их по отдельности, под конкретную задачу, и до сих пор не уверен, что не стоило свести к одной. Что выбрали бы вы, если цель — управляемая «интересность», а не только попадание в RTP?

  2. Отказ от rejection sampling ради простоты ручной проверки. 0,023% перекоса за возможность проверить ролл калькулятором мне кажутся нормальной ценой, но я вижу, как здесь можно спорить.

  3. Граница настраиваемого. Я провёл её по линии «настраивается то, насчёт чего сайт имеет право передумать», и честность за неё не пустил. Но линия эта проведена мной и держится только на моём же коде: прав доступа, которые запрещали бы админу трогать тикет-спейс, не существует — его просто нет в реестре настроек. Интересно, где бы её провели вы и стали бы вообще проводить.

Ну и если найдёте в коде дыру — пишите, это в общем-то и была цель всей затеи.

ссылка на оригинал статьи https://habr.com/ru/articles/1082714/