Я написал честный сайт с кейсами CS2 — и понял, почему честность тут ничего не решает

Пару месяцев назад я поймал себя на вопросе, на который не мог ответить внятно: когда открываешь кейс на одном из этих сайтов — тебя обманывают на самом ролле или как-то иначе? Сайтов с кейсами CS2 десятки, деньги там крутятся серьёзные, и при этом ни один не публикует исходники. Снаружи — крутящаяся лента и кнопка «открыть». Внутри — Steam-боты, эмуляция клиента, HMAC, целочисленная бухгалтерия и очень конкретная математика, устроенная так, что в среднем выигрывает сайт.
Чтобы ответить доказательно, а не на уровне «да там все скамеры», я написал такой сайт целиком и выложил под MIT. Не «демку с анимацией», а полный цикл: вход через Steam, открытие кейса с проверяемым роллом, продажа и апгрейд предмета, заявка на вывод, которую реально отправляет торговый бот, и админка, в которой кейс собирается по RTP до того, как уйдёт в прод. И пока я это писал, стало видно главное — оно не про жульничество в ролле, а про то, что жульничать там и не требуется.
Репозиторий: github.com/ialakey/caseforge.


Ниже — не пересказ README, а то, что я узнал по дороге и чего нигде не написано одним текстом: где в этих сайтах действительно математика, где её нет, где спрятан настоящий рычаг против игрока, и почему половина времени уходит не на игровую логику, а на то, чтобы договориться со Steam.
Дисклеймер, чтобы снять вопрос сразу. Покупка кейса за реальные деньги в большинстве юрисдикций регулируется как азартная игра: лицензия, KYC, возрастная верификация, ограничения по странам. В проекте нет платежей — пополнение баланса это заглушка, выключенная по умолчанию в проде. Это исследовательская и учебная кодовая база про архитектуру, а не готовый бизнес «под ключ». Запустить её на реальные деньги без юридической части — не «серая зона», а прямое нарушение, и MIT-лицензия от этого не спасает.
1. Самый неочевидный технический факт: бэкенд обязан быть на Node
Я начинал с мысли «возьму Go, там вебсокеты и нагрузка». Не вышло, и причина техническая, а не вкусовая.
Обмен предметами в Steam не проходит через официальный Web API. Web API умеет только читать: инвентарь, историю, профиль. Создать трейд-оффер, подтвердить его мобильным аутентификатором, отследить статус — это внутренние эндпоинты steamcommunity.com, и работа с ними означает эмуляцию Steam-клиента со всеми его протоколами.
Живая, поддерживаемая экосистема для этого существует ровно одна — набор библиотек DoctorMcKay на Node:
Библиотека | Что делает |
|---|---|
| вход в Steam как клиент, сессии, refresh-токены |
| куки и сессия |
| создание, отслеживание и подтверждение трейд-офферов |
| коды Steam Guard и ключи подтверждения из |
| коннект к game coordinator CS2: float, паттерн, стикеры |
Python-аналоги (steam, steampy) существуют, но заметно отстают: их чинят позже после очередного изменения Valve, покрытие мобильных подтверждений слабее. Для проекта, где ферма ботов — это касса, неделя отставания от Valve означает неделю без выводов.
Отсюда вывод, который определяет весь стек: торговый слой всё равно будет на Node. А раз так, писать второй язык для остального бэкенда — это две модели данных, два набора типов и мост между ними ради нескольких процентов RPS. Поэтому весь проект на TypeScript:
apps/web Next.js 15 (App Router), React 19, Tailwind, Zustand, socket.io-client
apps/api NestJS 11 на Fastify, Prisma 6, Postgres 16, Redis 7, BullMQ, socket.io
apps/bot Node-воркер: ферма Steam-ботов, консьюмер очереди выводов
packages/shared общие типы, zod-схемы, переводы, тикеты и provably fair
packages/shared тут не «папка с утилитами», а принципиальная вещь: одна и та же функция расчёта шанса апгрейда исполняется и на сервере при роллинге, и в браузере при отрисовке шкалы. Разъехаться они не могут по построению.
Одна деталь про монорепу, на которой легко потерять вечер: пакет намеренно разрезан на два входа. Основной изоморфный, а серверная криптография живёт за сабпасом @caseforge/shared/node. Иначе node:crypto оказывается в браузерном бандле и фронтенд просто не собирается.
2. Provably fair: где на самом деле можно жульничать
Схема индустриальная, её можно проверить руками.

Сервер генерирует
serverSeed(32 случайных байта) и публикует толькоsha256(serverSeed).Игрок задаёт свой
clientSeed(или получает случайный) и может менять его когда угодно.У пары сидов есть счётчик
nonce, который растёт на каждом открытии.Ролл:
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
}
Выпадает предмет, чей диапазон тикетов накрыл
roll.При ротации серверного сида старый раскрывается целиком — любой прошлый ролл пересчитывается и сверяется с опубликованным хешем.
Почему диапазоны тикетов, а не веса: диапазон проверяется в уме. [0, 749999] — это 75%, и порядок обхода массива на результат не влияет. С весами пользователю пришлось бы доверять вашей нормализации. Размер тикет-спейса зафиксирован навсегда: изменить его — значит сделать непроверяемыми все прошлые открытия.
Перепроверка в браузере сделана независимой реализацией на Web Crypto (packages/shared/src/verify.ts), а не вызовом того же серверного кода. Если бы страница верификации дёргала ту же функцию, что и ролл, она проверяла бы согласованность сервера с самим собой.
Честно про modulo bias
2^32 не делится на 1 000 000 нацело. Первые 967 296 тикетов получают по 4295 хешей, остальные — по 4294. Перекос — около 0,023%, одинаковый для всех и на порядки ниже гранулярности цен. Rejection sampling я не делаю сознательно: он ломает главное свойство схемы — возможность проверить ролл калькулятором и одной строчкой в консоли. Это записано в комментарии к коду, а не спрятано.
Настоящий рычаг — не ролл
Разбираясь в схеме, я понял вещь, которая переворачивает бытовое представление о «честности» таких сайтов, и на неё же держится весь остальной текст.
Подкручивать ролл никому не нужно. Это и рискованно (раскрытие сида превращает подкрутку в математическое доказательство мошенничества), и бессмысленно. Потому что настоящий рычаг — не ролл, а таблица диапазонов. Сайт может быть полностью честен в смысле provably fair и при этом собирать кейс с RTP 85%, то есть забирать в среднем 15% с каждого открытия. Обе вещи истинны одновременно, и вторая решает исход, а не первая.
Поэтому в проекте два жёстких правила:
serverSeedне зависит ни от предмета, ни от пользователя, и решение о дропе не имеет права смотреть на баланс игрока;если экономику нужно подкрутить — она крутится через диапазоны, в открытую, в админке, с пересчётом RTP.
И, соответственно, каждый кейс на витрине публикует свой состав: редкость, текущую цену и точный шанс каждого предмета. Не «примерно», а те самые диапазоны, против которых сервер роллит.
3. Экономика: «сделай кейс прибыльным» — это одно уравнение с 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;
}
Достижимый диапазон 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%, потому что подорожал нож. Поэтому:
ежечасная джоба тянет цены из Steam-маркета для предметов активных кейсов;
после каждой переоценки RTP всех активных кейсов пересчитывается;
выход из коридора логируется как warning, превышение 98% — как error.
Отдельная история — неподтверждённые цены. Предмет, цену которого Steam ни разу не вернул (нет лотов, кривое имя), несёт в себе выдуманное число, но в RTP участвует наравне со всеми. Такие предметы помечены и в списке кейсов, и в билдере — это самый тихий способ увести экономику в минус.
4. Апгрейд, который не нужно балансировать отдельно
Игрок ставит свой скин против более дорогого: выиграл — забрал дорогой, проиграл — отдал свой.
Соблазн — задать шансы руками. Я вывожу их из цен:
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);
Классика: clamp перед валидацией превращает валидацию в декорацию.
5. Деньги: целые копейки, условный UPDATE и резерв блока нонсов
Никаких Float. Все суммы — целые в минорных единицах (Int). Каждое изменение баланса создаёт строку в Transaction; баланс в User — денормализованный кеш, который обязан сходиться с суммой транзакций. Ночной крон сверяет; расхождение — алерт.
Списание — не read-compute-write, а условный UPDATE. Ноль затронутых строк — значит, денег не хватило; это убирает гонку между двумя открытиями из двух вкладок:
UPDATE users SET balance = balance - $1 WHERE id = $2 AND balance >= $1
Дальше — деталь, до которой я дошёл не сразу. Кейсы можно открывать пачкой до десяти штук, и вся пачка — одна транзакция: либо оплачены все и выданы все, либо не происходит ничего, включая счётчик nonce. Инкрементировать nonce в цикле нельзя: параллельный апгрейд или открытие из соседней вкладки вклинится в середину и заберёт номер из нашей пачки, а нонсы должны быть непрерывными, иначе история перестаёт пересчитываться по порядку. Поэтому весь блок резервируется одним UPDATE:
// Резервируем весь блок нонсов одним 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» это десять открытий, и защита от скриптов обязана видеть это именно так.
6. Steam: десяток проблем, которые стоили мне больше всего времени
Вот эта часть нигде не собрана в одном месте — а именно на ней уходит половина проекта.
1. У Steam нет OAuth. Только OpenID 2.0. Редирект на steamcommunity.com/openid/login, возврат с параметрами и обязательная серверная проверка через check_authentication. Без неё параметры подделываются тривиально — это не формальность, а вся безопасность входа.
2. OpenID возвращает только SteamID64. Ни ника, ни аватара в ответе нет, их надо получать отдельно.
3. Два источника профиля, и второй не костыль. GetPlayerSummaries требует STEAM_API_KEY (100k запросов в сутки на ключ), а /profiles/<id>?xml=1 не требует ничего и отдаёт то же самое. Web API — основной путь, XML — фоллбек: если ключ протух или Web API лежит, пользователь всё равно видит свой ник и аватар вместо user_123456.
4. XML профиля надо парсить аккуратно. Внутри лежат вложенные списки друзей со своими <steamID> и <avatarFull>. Наивное «взять первое совпадение» подставляет чужой аватар. Отлаживается это весело.
5. Steam никогда не отдаёт email. Ни одним из путей. Его не будет, пока пользователь сам не введёт. Все сценарии восстановления доступа строятся вокруг Steam-аккаунта.
6. Поиск по маркету не ест market_hash_name. Символ | и скобки экстерьера дают ноль результатов даже для предметов, которые точно продаются. Имя нужно нормализовать в ключевые слова.
7. У ножей и перчаток есть префикс ★. Karambit | Doppler (Factory New) для Steam не существует. ★ Karambit | Doppler (Factory New) — существует. Без звезды не резолвится ни цена, ни картинка.
8. Два эндпоинта с разными свойствами. market/search/render отдаёт имя, картинку, редкость и число лотов — и всегда в долларах, параметр currency он игнорирует. market/priceoverview отдаёт только цену, но уважает валюту и требует точный market_hash_name. Отсюда разделение: поиск — для выбора предмета и метаданных, цена в валюте проекта — всегда из priceoverview.
9. Берите медиану, а не минимум. lowest_price скачет от одиночных сливов, и кейс, посчитанный по нему, недооценивает свои предметы.
10. Лимит — примерно 20 запросов в минуту. Дальше 429 и бан на несколько минут. Поэтому запросы сериализованы с интервалом 3.5 секунды, поиск кешируется на час, цены — на полчаса, а 429 усыпляет сервис на пять минут. Негативные ответы кешируются тоже: предмет без лотов иначе ходит в Steam на каждой синхронизации.
Из-за пункта 10 команда наполнения каталога из 20 тематических кейсов честно предупреждает, что идёт около десяти минут. Зато состав не захардкожен именами: предметы подбираются поиском по маркету, и у каждого гарантированно есть картинка и реальная цена.
Та же джоба заодно дотягивает недостающие картинки: предметы, созданные в обход импорта, приезжают без изображения, а пустая витрина выглядит как сломанная. Кейс без собственной картинки одалживает изображение самого дорогого предмета — он его и продаёт.
7. Валюта отображения ≠ расчётная валюта
В шапке два переключателя: RU / EN и ₽ / $. Языка по умолчанию нет — берётся из браузера, валюта следует за языком, обе настройки запоминаются.
Ключевое правило: переключатель валюты — только отображение. Всё рассчитывается и хранится в рублях, целыми минорными единицами, и ни одна строка леджера никогда не содержит сконвертированную сумму. Долларовая цифра — это то же число, делённое на курс в момент рендера.
Почему это тянет на отдельный раздел: стоит один раз сохранить сконвертированное значение — и у вас две истины про одну сумму, а сайт после движения курса начинает продавать предметы ниже себестоимости. Курс берётся из публичного дневного фида ЦБ РФ (без ключа и квот, обновление раз в шесть часов, кеш в Redis); если фид недоступен, отдаётся предыдущий курс — устаревший курс лучше сломанного прайс-листа.
Побочный сюжет — ошибки. Сервер отдаёт машиночитаемый code рядом с английским message, интерфейс переводит код и падает обратно на message для кодов, которых эта сборка ещё не знает. Ошибка, добавленная на бэкенде, остаётся читаемой до того, как приедет её перевод.
8. Ферма ботов и вывод предметов
Один бот — отдельный Steam-аккаунт с включённым мобильным аутентификатором, у которого есть shared_secret и identity_secret из maFile (без них офферы не подтвердить автоматически). Секреты не лежат в базе открытым текстом: AES-256-GCM под ключом из окружения в деве, внешний секрет-стор в проде.

withdrawalId.Вся логика вывода построена вокруг четырёх ограничений Steam: инвентарь — 1000 слотов на аккаунт (значит ботов несколько, и заявку надо роутить на того, у кого предмет реально лежит); трейд-холд до 15 дней, если у получателя нет мобильного аутентификатора или он включён недавно (проверять надо до отправки оффера, иначе предмет зависает в эскроу); бот, недавно сменивший пароль или устройство, уходит в холд сам; и Valve лимитирует создание офферов, поэтому очередь с троттлингом обязательна.
Два места, где ошибка стоит инцидента. Первое: вся цепочка идемпотентна по withdrawalId — ретрай джобы без идемпотентности это выданный дважды предмет. Второе: trade URL проверяется на принадлежность аккаунту — partner в ссылке это младшие 32 бита SteamID64, и чужая ссылка отбрасывается до того, как по ней уедет предмет.
9. Фронт: рулетка, которая ничего не решает
Лента с предметами тормозит под маркером ровно как в игре. Важное свойство: исход уже известен — сервер вернул его вместе с роллом, анимация только проигрывает результат. Подкручивать анимацию «ради честности» не нужно и нельзя: ролл посчитан из пары сидов до того, как лента тронулась.
Три вещи, которые пришлось понять руками:
Два кадра вместо одного. Первый кадр отдаёт браузеру стартовую позицию без transition, второй — целевую с transition. В одном кадре браузер схлопнет оба стиля, и прокрутки просто не будет.
Небольшой случайный сдвиг внутри плитки. Приземление ровно в центр каждый раз выглядит фальшиво. Но разброс должен быть узким: с широким маркер оказывается на стыке плиток, и непонятно, что из двух соседних выпало.
Eager-загрузка картинок. Лента длиной около 8900px, победитель — у самого её конца, далеко за зоной предзагрузки ленивых картинок. На холодном кеше плитка под маркером приезжала бы уже после остановки. Здесь eager ничего не стоит: 60 плиток набраны из пула кейса и повторяют десяток одних и тех же URL.
И при открытии десяти кейсов ленты останавливаются по очереди, а проигравшие плитки гаснут после остановки: на десяти лентах одной подсветки мало — глаз всё равно теряет победителя.
10. Грабля, из-за которой сид базы теперь не создаёт ни одного кейса
Сначала db:seed создавал и предметы, и кейсы — с ценами, взятыми на глаз. Это оказалось ловушкой ровно из двух частей.
Первая: после первой же синхронизации реальные цены Steam отличались на порядок, и кейсы уходили в минус — то есть в состояние «сайт платит игрокам больше, чем получает».
Вторая, хуже: повторный запуск сида молча перетирал правки, сделанные в CRM.
Теперь db:seed создаёт только демо-администратора и его пару сидов. Каталог наполняется отдельной командой, которая ходит в Steam и собирает кейсы ровно так же, как это делалось бы руками в билдере: автобалансировка под целевой RTP и сохранение через ту же валидацию. Никаких «специальных» путей записи в обход бизнес-логики — это, пожалуй, главный архитектурный урок всего проекта.
11. Чем это проверяется
Юнит-тесты закрывают то, что имеет смысл проверять в изоляции: 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%. Про сам механизм я написал в разделе 2; здесь важнее два вывода, ради которых всё и затевалось.
Если вы играете, а не пишете код. «Provably fair» отвечает ровно на один вопрос — «не подкрутили ли конкретный ролл». На вопрос «выгодно ли это вам» он не отвечает вообще; на него отвечает RTP, и его величина обычно указана рядом со ставкой — если вообще указана. Значок «проверяемо честно» и ваш матожидаемый минус — совместимы, и это не баг, а устройство.
Если вы пишете код. Самое интересное здесь — не игровая логика, а слой между вами и Steam: OpenID без OAuth, маркет с двумя эндпоинтами в разных валютах, звёздочки в названиях ножей, 1000 слотов инвентаря и трейд-холды. Ровно тот случай, когда «просто прикрутить API» занимает больше времени, чем весь остальной продукт.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.