Четверо в одной транзакции: кейс-батлы, PgBouncer под Prisma и реплика, которая показывает пустой инвентарь

Обе мои предыдущие статьи про этот проект заканчивались одним и тем же абзацем — списком того, чего в нём нет. «Нет: платежей, депозита предметов, кейс-баттлов, реферальной системы, KYC».
Список закрыт. Не весь одинаково честно — про оговорки будет в конце, и они существенные, — но каждая строчка из него теперь во что-то превратилась.
Это третья статья про сайт с кейсами CS2, который я написал целиком и выложил в открытый доступ. Первая — про математику: RTP, диапазоны тикетов, солверы и почему «provably fair» отвечает не на тот вопрос, который задаёт игрок. Вторая — про Steam: OpenID из 2007-го, звёздочка в названии ножа, цены строкой в чужой локали. Обе читаются отдельно, и эта тоже.
Только она не про то, что я добавил. Список фич читать скучно, а рассказывать «я сделал батлы, потом рефералку» — это пересказ гита. Интересным оказалось другое: почти всё сложное за эту неделю свелось к одному вопросу. Что происходит, когда несколько человек одновременно двигают одни и те же деньги — и что с этим делает база, когда их становится много.
Про это дальше. Код: github.com/ialakey/caseforge.

Батл на четверых. Четыре ленты крутятся синхронно, все предметы забирает один. Внизу — то, из-за чего эта статья.
Батл — это не новая механика, а четверо в одной транзакции
Правила простые. Несколько игроков покупают одинаковый набор кейсов и открывают его одновременно. Все выпавшие предметы забирает один: в обычном режиме тот, у кого сумма дропов больше, в безумном — тот, у кого меньше. Те же кейсы, обратная цель.
Первое, что я проверил, — не ломает ли это экономику. Не ломает, и это приятно скучный вывод: каждый платит полную стоимость набора, скидки за игру против другого нет, значит ожидаемая отдача — это просто взвешенный RTP кейсов в батле, ровно как если бы те же кейсы открывали поодиночке. Банк перемещается между игроками, а не между игроками и сайтом. Батл — перераспределение, а не вторая экономика, и отдельного разговора про его маржу не существует.
А вот с реализацией всё оказалось веселее.
Откуда берётся случайность: ниоткуда нового
Соблазн номер один — общий сид на весь батл. Красиво звучит, легко продаётся в маркетинге («один сид на всю игру, проверяйте»), и это неверный размен.
У сайта уже есть обязательство перед каждым игроком: опубликованный sha256(serverSeed), который раскрывается при ротации. Вторая схема рядом — это второе место, где можно ошибиться, и ноль нового для игрока. Каждый дроп в батле — обычная строка CaseOpening: ролл считается из пары сидов самого открывающего и его собственного блока nonce, точно так же, как в одиночном открытии. Добавились ровно две колонки: battleId и battleRound.
Побочный эффект приятнее самого решения. Отчёт по RTP, маржа по кейсу, живая лента дропов и история открытий игрока подхватили батлы, не зная, что батлы вообще существуют. Ни одного if (battle) за пределами модуля батлов.
Место в лобби — это условный UPDATE
Наивная версия: посчитать занятые места, если есть свободное — вставить строку. Два одновременных захода видят одно и то же свободное место, и в батл на двоих садятся трое.
Поэтому место занимается инкрементом денормализованного счётчика:
UPDATE battles SET "filledSlots" = "filledSlots" + 1
WHERE id = $1 AND status = 'WAITING' AND "filledSlots" < slots
RETURNING "filledSlots"
RETURNING возвращает уже увеличенное значение — это и есть номер места игрока. Ноль строк означает, что последнее место кто-то занял быстрее.
Списание за место — такой же условный UPDATE (WHERE balance >= цена), и — главное — в той же транзакции. Игрок, который не успел сесть, не получает возврат. У него откатывается списание, которого, с точки зрения базы, не было. Это разные вещи: возврат — это две записи в журнале, окно между ними и вопрос «а если процесс умер посередине».
Расчёт: сто двадцать открытий, которые нельзя разделить
Последнее занятое место играет батл прямо там же, в той же транзакции.
Четыре места по тридцать раундов — это 120 открытий, 120 строк инвентаря и резервирование сида на каждого игрока. Самая большая запись на сайте. Дефолтных пяти секунд Prisma ей не хватает, поэтому у неё поднят таймаут:
const SETTLE_TIMEOUT_MS = 30_000;
Разбить её нельзя, и это не оптимизация, а смысл. Батл, который рассчитал одних игроков и не рассчитал других, не имеет интерпретации: кому принадлежат предметы, которые уже выпали, если победитель ещё не определён?
Внутри транзакции важно не делать глупостей, поэтому идентификаторы открытий и предметов инвентаря генерируются заранее, а обе стороны уходят одним createMany. Сто двадцать отдельных create — это сто двадцать раундтрипов, пока транзакция держит блокировки.
Порядок, из-за которого не случается дедлок
Вот это место я считаю самым интересным во всём батле.
Резервирование сида — это UPDATE строки server_seeds игрока. Блок сразу на все раунды, одним запросом:
const seedRows = await tx.$queryRaw`
UPDATE server_seeds
SET nonce = nonce + ${battle.rounds}
WHERE "userId" = CAST(${player.userId} AS uuid) AND "isActive" = true
RETURNING id, seed, nonce
`;
Одним — потому что инкремент по раунду пустил бы в середину блока постороннее открытие или апгрейд, и в истории игрока появился бы батл с дыркой в нонсах.
Но UPDATE берёт блокировку на строку и держит её до конца транзакции. А два батла могут заполниться в один момент и иметь общего игрока. Если один обходит игроков в одном порядке, а другой — в другом, они возьмут блокировки навстречу друг другу, и Postgres честно убьёт одну из транзакций по дедлоку. С сообщением, по которому в логе вообще не понять, что произошло.
Лечится одной строкой:
// Сиды резервируются в фиксированном порядке по игрокам.
const seating = [...battle.players].sort((a, b) => a.userId.localeCompare(b.userId));
Сортировка не по месту в батле, а по userId — то есть по одному и тому же ключу в любой транзакции сайта. Порядок взятия блокировок становится глобально согласованным, и цикла ожидания не возникает в принципе.
Это ровно та вещь, которая не ловится ни юнит-тестом, ни ручным кликом, ни нагрузочным прогоном, где все игроки разные. Её либо видно на схеме, либо она ждёт вас в проде.
Зазор между тем, кто крутил, и тем, кто владеет
Каждый предмет создаётся в инвентаре победителя, но продолжает ссылаться на открытие, которое его выкатило. userId самого открытия остаётся тем, кто крутил.
Соблазн был записать одно значение вместо двух. Записать только владельца — и происхождение предмета потеряно: он «выпал» человеку, который его не открывал, и проверка ролла по сохранённым сидам не сходится ни у кого. Записать только крутившего — и владение оказывается неопределённым. Этот зазор и есть то, чем батл является; вся остальная механика — обёртка вокруг него.
Ничьи, которых оказалось неожиданно много
На дешёвых кейсах два места вполне регулярно дают одинаковую сумму. Я думал, что это редкий случай, который можно разрулить через «ну пусть выиграет первый». Оказалось — достаточно частый, чтобы понадобилось правило, которое не стыдно показать игроку.
Правило: внезапная смерть по лучшему одиночному дропу (в безумном режиме — по худшему), а если совпало и это — побеждает место, которое зашло раньше.
Оба шага — функции от дропов, которые уже записаны. Это важнее, чем кажется: исход воспроизводится по сохранённым роллам, и человек, который не согласен с результатом, может пересчитать его сам. Новый случайный бросок для разрешения ничьей таким свойством не обладал бы — и был бы единственным местом на сайте, где результат нельзя перепроверить.
Уборщик против расчёта
Батл, ждущий второго игрока, держит деньги хоста. А хост вполне мог закрыть вкладку.
Уборщик отменяет всё, что прождало дольше настроенного окна, и возвращает каждое место полностью — через журнал транзакций, как любое другое движение денег. Переход делается условным UPDATE из статуса WAITING, и вот это условие — единственное, что не даёт уборщику вернуть ставки из-под батла, который заполнился секунду назад и прямо сейчас играется.
Ещё одна мелочь, которую пришлось заметить отдельно. Отменённое место забирает с собой и реферальное начисление — иначе «создать батл и отменить» становится бесплатным способом заплатить пригласившему.
И выключение батлов в настройках заставляет тот же уборщик разобрать лобби целиком. Оператор, который щёлкает этим переключателем во время инцидента, хочет именно этого, а не «новые создавать нельзя, старые висят».
Розыгрыш, в котором дом знает сид, но не знает результата
Раз уж зашла речь про проверяемость, отдельная маленькая история — про розыгрыши скинов.
Розыгрыш устроен так: приз, дедлайн, и условие входа — пополнить на сумму не меньше пороговой, пока розыгрыш открыт. Победителя надо выбрать так, чтобы это не стало единственным местом на сайте, где случайность нельзя перепроверить.
Схема получилась симпатичнее, чем в кейсах.
serverSeedгенерируется при создании розыгрыша, и его хеш публикуется сразу. Сам сид раскрывается только после розыгрыша.clientSeed— это хеш списка участников в порядке их входа, посчитанный в момент розыгрыша.roll = HMAC_SHA256(serverSeed, clientSeed:0) % TICKET_SPACE, победитель — участник номерroll % entryCount.
Ключевое — во второй строке. В кейсах клиентский сид задаёт игрок, и это его страховка от подкрутки. В розыгрыше игроков много, и «чей сид взять» — вопрос без хорошего ответа. Ответ: ничей. Дом знает серверный сид с самого начала, но не знает его эффекта, потому что эффект зависит от того, кто придёт. И переиграть по-тихому нельзя: опубликованный хеш прибил сид к одному значению ещё до первого участника.
Modulo bias здесь тоже есть, и он тоже честно посчитан в комментарии к коду: на 336 участниках 64 из них получают шанс на 1/2976 выше остальных, около 0,03%. Тот же порядок, что и перекос в диапазонах тикетов, и по той же причине я его не убираю — rejection sampling ломает возможность проверить результат калькулятором.
Рефералы: начисления, которых нет в журнале
Реферальная комиссия выглядит тривиально ровно до того момента, как посмотришь на неё со стороны ночной сверки балансов.
Очевидная реализация: приглашённый открыл кейс — начислили пригласившему, записали в журнал. Проблема в объёме. Журнал транзакций — это то, что обходит ночная сверка, и он должен быть пропорционален числу движений денег, а не числу дропов. Начисление на каждое открытие каждого приглашённого превращает журнал активного реферера в поток.
Поэтому комиссия копится отдельными строками ReferralEarning, которых нет ни на одном балансе и нет в журнале. Деньгами они становятся ровно в один момент — при выплате, одной записью.
Ставка хранится в самой строке, а не берётся из настроек при выплате. Оператор, который понизил процент, не должен переписывать уже заработанное.
Выплата и порядок, в котором её нельзя делать
Накопленное забирается одним запросом:
UPDATE referral_earnings
SET "claimedAt" = NOW()
WHERE "referrerId" = $1 AND "claimedAt" IS NULL
RETURNING amount
И минимальная сумма проверяется по тому, что вернулось, а не по SUM, прочитанной заранее.
Разница кажется косметической, а она принципиальная. Прочитать сумму, проверить минимум, потом забрать — значит выплатить не ту сумму, которую проверил, всякий раз, когда между чтением и записью падает новое начисление. Для пригласившего с активными рефералами это не редкий случай, а обычный вторник.
Если минимум не набрался — исключение, транзакция откатывается, и захват откатывается вместе с ней. Начисления остаются неоплаченными, как были.
Привязка, которую защищает не время, а история
Приглашение приходит как ?ref=КОД на любую страницу, и зашедший ещё не авторизован — сейчас его унесёт в Steam и обратно, а редирект выбрасывает всё, что держала страница. Поэтому код паркуется в браузере и применяется отдельным вызовом после входа.
Значит, ручка «применить код» остаётся открытой и потом. Защищает её не таймер, а история аккаунта: код отклоняется, как только на аккаунте появилась хоть какая-то активность. Игрок, который уже открывал кейсы или двигал деньги, — не чей-то новый реферал. Без этого правила состоявшийся аккаунт можно приписать тому, кто попросил последним.
Плюс мелочи: своим кодом воспользоваться нельзя; регистрация с того же адреса, что у пригласившего, помечается, но не отклоняется — общий адрес бывает у квартиры и у мобильного оператора, и автоматически рубить тут значит регулярно рубить честных; один пригласивший на игрока держит уникальный индекс, а не проверка — две одновременные заявки обе пройдут проверку и разойдутся только на ограничении.

Реферальный кабинет. Накопленное здесь — это строки, которых ещё нет ни на одном балансе.
Нагрузка: то, что должно лежать в репозитории, а не в тикете
Профиль трафика у таких сайтов резко неравномерный: обычный день, а потом двадцатикратный пик на розыгрыше у стримера. Считать надо по пику.
Я не люблю разделы «как бы мы масштабировались». Поэтому всё, что ниже, поднимается одной командой (pnpm infra:up:scale) и измеряется другой (pnpm test:load).
PgBouncer, и две вещи, без которых Prisma за ним не живёт
Node держит пул соединений на процесс. Значит max_connections в Postgres расходуется как число инстансов API, умноженное на размер их пула, и заканчивается задолго до того, как база реально чем-то занята. Transaction-пулинг привязывает серверное соединение к транзакции, а не к клиенту: тысяча простаивающих соединений приложения превращается в пару десятков настоящих. В конфиге это буквально MAX_CLIENT_CONN: 1000 против DEFAULT_POOL_SIZE: 25, и вот это отношение — весь смысл упражнения.
Дальше начинается специфика.
Подготовленные выражения. Они живут в рамках сессии, а transaction-пулер выдаёт каждый раз другую. Prisma их использует всегда. pgbouncer отслеживает prepared statements с версии 1.21, поэтому на пулере выставлен max_prepared_statements, а в URL добавлен ?pgbouncer=true — второй пояс к тем же подтяжкам.
Миграции через пулер ходить не должны вообще. Они открывают долгие сессии, создают типы и берут блокировки, которые transaction-пулинг между запросами не переносит. В схеме Prisma для этого есть directUrl, и он смотрит в DIRECT_DATABASE_URL мимо пулера. По умолчанию он равен DATABASE_URL, так что установка на одной машине не настраивает ничего.
connection_limit в URL за пулером стоит указать явно. Дефолт Prisma — «ядер × 2 + 1» на процесс, и он ровно противоположен тому, зачем вы поставили пулер.
Ещё одна деталь, чисто бытовая: образ pgbouncer слушает 5432 — единственный порт, который пулеру Postgres брать не стоит. «Это база или пулер?» не должно быть вопросом, на который нельзя ответить по строке подключения. Внутри контейнера 6432, наружу 6433.
Реплика и правило, которое нельзя выразить типом
Streaming-standby и второй клиент Prisma, который смотрит на него. Если REPLICA_DATABASE_URL не задан, читающий клиент — это и есть основной, поэтому ни одна ветка кода не зависит от конфигурации.
А дальше — правило, которое существует только в головах и в комментариях, потому что типом его не выразить:
Реплика отдаёт то, что читает оператор или зритель, и никогда то, что спрашивающий игрок только что записал.
Лаг репликации маленький, но настоящий. Полсекунды достаточно, чтобы игрок после выигрыша увидел инвентарь без предмета, который он только что выиграл, — и пошёл в поддержку. Это не «редкий рейс», это гарантированное поведение при достаточном числе игроков.
Через реплику читаются: отчёты и списки CRM, публичный каталог, лобби батлов, лента дропов. На основной базе остаются: всё внутри транзакций, инвентарь, баланс — и отдельный батл. Лобби можно показать чуть устаревшим, а игроку, который только что занял место, отдают именно то место, за которое он заплатил.
Отдельная мелочь, на которую я потратил больше времени, чем она заслуживает. pnpm infra:replica:init готовит работающую основную базу к standby: в стандартном контейнере postgres нет ни роли для репликации, ни строки pg_hba, которая её пустит. И добавить их надо не пересоздавая базу, в которой уже лежит наполненный каталог — выбрасывать дев-базу ради реплики я не готов. Заодно выяснилось, что pg_hba требует явной записи replication: это единственное место, где all в колонке базы не означает «все».
Кэш, который инвалидируется счётчиком
Каталог — самый запрашиваемый эндпоинт на сайте, ответ у него одинаковый для всех, а меняется он несколько раз в час. Идеальный кандидат в Redis.
Инвалидация — через счётчик версии, а не удаление ключей. Счётчик входит в каждый ключ пространства, инкремент обесценивает всё пространство разом: ни SCAN по кейспейсу, ни частичного удаления. И поскольку счётчик лежит в Redis, инкремент видят все инстансы API. Дёргают его сохранение кейса в админке, пересчёт RTP и часовая синхронизация цен; TTL остаётся страховкой на случай инкремента, который не дошёл.
Замка на промах намеренно нет. Два инстанса, промахнувшиеся одновременно, оба сходят в базу и оба запишут одинаковый ответ — цена один лишний запрос за TTL. Замок стоил бы раундтрипа на каждом попадании и залипания на каждом держателе, который умер посередине.
Путь открытия кейса этот кэш не читает. Он берёт кейс внутри своей транзакции, потому что устаревшая цена там — это устаревшая сумма денег.
И отдельный сюжет — лента дропов. У неё свой бэклог списком в Redis, который пишется по мере дропов. Каждый подключившийся сокет спрашивает последние дропы, и отвечать на это из Postgres — это джойн по четырём таблицам на каждое соединение. В шторме переподключений после деплоя это самая бессмысленная нагрузка на базу на всём сайте: тысяча человек одновременно спрашивают одно и то же.
Health, который не выводит инстанс из ротации
GET /api/health отдаёт доступность и задержку Postgres и Redis, а также лаг проигрывания реплики в секундах.
За балансировщиком интересен не мёртвый процесс — он и сам перестаёт отвечать. Интересен инстанс, который бодро отвечает 200, пока его реплика отстала на час и он раздаёт отчёты позавчерашнего дня.
При этом degraded едет в теле ответа, а не в статус-коде. Отставшая реплика — повод посмотреть, а не повод вывести инстанс из ротации: если по такому признаку балансировщик начнёт снимать инстансы, лаг репликации превратится в отказ сайта.
Генератор нагрузки, который печатает 429 отдельной строкой
Нагрузочный генератор лежит в репозитории и не имеет зависимостей. Это принципиально: инструмент, который надо поставить перед тем, как воспроизвести число, — это число, которое никто не воспроизведёт.
Сценарии — свои, а не «долбить / в сто потоков»:
Сценарий | Что делает |
|---|---|
| только публичные чтения — каталог, страница кейса, лобби, конфиг, health |
| путь записи: по одному открытию кейса на запрос, от одноразовых игроков |
| двое одноразовых игроков создают и заполняют батл на два раунда |
| четыре части browse на одну часть open |
Печатает он p50/p90/p99 — и отдельно каждый не-2xx статус. Вот это важнее, чем звучит.
Прогон, в котором треть запросов — 429, измеряет рейт-лимит, а не сайт. И если смотреть только на перцентили, картинка получается прекрасная: отказ обслуживать всегда быстрее, чем обслуживание. Ровно этим способом делается вывод «сайт держит нагрузку» про сайт, который в этот момент отказывается работать.
Одноразовые игроки и всё, что они успели натворить, удаляются после прогона.
Чисел я здесь не привожу, и это не кокетство. Генератор делит машину с API и базой и конкурирует с тем, что измеряет. Такие числа годятся только как сравнительные — до и после изменения; настоящая цифра требует нагрузки с другой машины, и когда она будет, она будет с оговорками, а не в заголовке.
Аналитика: сумма дневных уников — это не уник
Тут я чуть не сделал стандартную ошибку, и рассказываю, потому что она встречается в каждом втором дашборде.
Разделение, из которого всё следует: база уже является аналитическим хранилищем всего, что двигало деньги. Открытия, пополнения, батлы, рефералы и выводы — это строки с суммами и временем. Отчёт по ним — это запрос. Продублировать их событиями значило бы завести второй набор чисел, который может разойтись с журналом. А когда отчёт расходится с журналом, доверие теряет отчёт.
Поэтому поток событий несёт только то, на что база ответить не может: что человек пришёл, откуда, что смотрел и где остановился. Шесть типов событий, не больше. Ни IP, ни user-agent при этом не собираются — ни то, ни другое не отвечает на вопрос о поведении лучше, чем грубое «компьютер / телефон / планшет», зато оба превратили бы отчёт о трафике в хранилище персональных данных.
Дневные метрики сводятся раз в ночь в одну строку на метрику на день. Сегодняшняя строка пересчитывается по запросу — оператор, который следит за акцией, не может ждать до завтра.
А теперь ошибка. Заголовочные плитки («уникальных посетителей за период») не читаются из этих свёрток. Сумма дневных уников — это не уник: человек, зашедший в понедельник и во вторник, — это два дневных посетителя и один посетитель. KPI, сложивший график, завышает каждого вернувшегося, и чем лучше у вас retention, тем сильнее он врёт.
Поэтому путей два: плитки считаются честными distinct по всему диапазону, графики берутся из свёрток. Оба верны — каждый для своего вопроса.
Шов между отчётом и журналом закрыт смоук-тестом: wagered в отчёте должен в точности равняться сумме case_openings.casePrice, и дважды пересчитанная свёртка не должна ничего удваивать.
Ещё один момент, который выглядит занудством, пока не попробуешь. GGR по механикам нельзя посчитать одной формулой: кейсы и батлы — это ставка минус выпавшее, апгрейд — ставка минус выигранные цели, контракт — вложенное минус награда, а колесо только тратит деньги, для этого оно и есть. Одна формула на все пять была бы неверна четырежды.

Воронка: верх из событий, низ из журнала. Плитки и графики считаются разными путями намеренно.
Баг недели: RTP у бесплатного кейса
Маленькая история, которая мне нравится тем, что баг был не в коде, а в определении.
Я добавил бесплатные кейсы — они ничего не стоят, а ограничены порогом пополнения и лимитом открытий за скользящие 24 часа. Скользящие, а не календарные сутки: сброс в полночь превращается в очередь ждущих полуночи, а игрок в другом часовом поясе получает условия хуже нашего без всякой причины.
Так вот. У бесплатного кейса нет RTP. RTP — это отдача на единицу ставки, а ставки нет; в базе там намеренно лежит null.
Часовая переоценка цен об этом не знала. Она делила на цену, равную нулю, замазывала это конструкцией price > 0 ? ... : 0, записывала ноль поверх намеренного null — и потом предупреждала в логи, раз в час, на каждый бесплатный кейс, что кейс с отдачей «0%» плохо продаётся.
Семь кейсов. Каждый час. Про число, которого не существует.
// У бесплатного кейса нет RTP, который можно пересчитать.
if (gameCase.isFree) continue;
const rtp = expected / gameCase.price;
Тернарник при этом уехал вместе с багом, и это вторая половина фикса. За continue цена нулём быть не может, и защита, которая делает вид, что может, — это не защита, а место, где спрячется следующая такая же ошибка. Она маскировала не деление на ноль, а то, что деление там вообще не имело смысла.
Что ещё изменилось, коротко
Чтобы не растягивать: остальное из закрытого списка.
Платежи — за портом провайдера. Баланс меняется только по вебхуку, никогда по возврату пользователя на сайт (возврат подделывается тривиально), вебхук идемпотентен по идентификатору платежа, подпись проверяется по сырому телу до разбора JSON. Живого провайдера нет — есть подписанный демо-адаптер; настоящий это адаптер плюс договор с банком, а не переделка.
Депозит скинами — в обратную сторону через ту же ферму ботов.
Проверка личности с разбором оператором, гейтящая вывод. Выключена по умолчанию, потому что собирать документы — это юридическое обязательство, а не фича.
Рейт-лимит на весь API — в Redis, чтобы значить одно и то же на любом инстансе, 300 запросов в минуту по умолчанию. Недоступный Redis пропускает запросы, а не отклоняет: ограничитель, падающий «в закрытую», — это отказ сайта целиком из-за проблемы с кэшом.
Потолок тела — мегабайт везде, кроме
POST /api/kyc, где двенадцать: документы приходят в base64, а снятый телефоном паспорт весит несколько мегабайт ещё до кодирования. Поднять лимит везде значило бы разрешить любому вызывающему заставлять сервер держать в памяти по двенадцать мегабайт.TRUST_PROXYвыключен по умолчанию. Если API открыт напрямую,X-Forwarded-Forпишет только тот, кто звонит, — доверять заголовку значит выдать каждому право на свежую корзину лимита и на записи в аудите под выдуманным адресом.
Честные оговорки
Две, и обе стоит сказать прямо.
Лицензия изменилась. Первая статья называлась «выложил под MIT», и это было правдой на момент публикации. Сейчас проект под PolyForm Noncommercial 1.0.0: некоммерческое использование свободно — личные проекты, учёба, исследования, — коммерческое требует письменного разрешения. Причина простая: это сайт с механикой, за которую платят реальными деньгами, и «берите и запускайте» в качестве позиции по умолчанию мне перестало казаться нормальным. Код открыт, читается, запускается и разбирается на части ровно как раньше.
Заглушки на месте, и их две. Пополнение начисляет баланс без оплаты (флагом, в проде выключенным), и ферма ботов по умолчанию простаивает — её надо поднимать отдельно, с живыми Steam-аккаунтами и их секретами. Обе заглушки намеренно единственные места, которые срезают настоящий путь: подключить его потом — это удаление, а не распутывание.
И то же предупреждение, что и в первой статье, потому что оно не устарело. В большинстве юрисдикций покупка кейса за реальные деньги регулируется как азартная игра: лицензия, KYC, возрастная проверка, ограничения по странам. Это учебный проект про архитектуру, и никакая лицензия вас от юридической части не прикроет.
Что я обо всём этом думаю
Забавно, как сместился центр тяжести. В первой статье самым сложным была математика, во второй — Steam. В третьей не оказалось ни одной задачи, которая была бы сложной сама по себе. Батл — это цикл. Рефералка — это процент. Кэш — это GET и SET.
Сложным оказалось всё, что появляется, когда этих операций становится много одновременно: порядок взятия блокировок, разница между «откатить» и «вернуть», выбор между чтением с реплики и правдой, проверка минимума по тому, что вернулось, а не по тому, что прочиталось. Ни одна из этих вещей не видна в фиче-листе, и ни одну из них нельзя добавить потом — они либо заложены в форму записи, либо нет.
Три места, где я не уверен, что выбрал лучший вариант, и по ним мне правда интересно, что скажут:
Расчёт батла в транзакции с таймаутом 30 секунд. Я считаю, что батл атомарен по определению и разделить его нельзя. Но 120 открытий в одной транзакции — это долгие блокировки на строках сидов и заметный кусок работы под ними. Альтернатива — статус-машина с отдельным шагом на раунд и компенсациями — мне кажется лекарством хуже болезни, потому что компенсация в деньгах это всегда новый класс багов. Где бы вы провели границу?
Реплика как правило, а не как тип. «Читать через реплику можно то-то и то-то» живёт в комментариях и в дисциплине. Один невнимательный
this.readв инвентаре — и игрок не видит свой предмет. Я думал развести это типами (два разных интерфейса репозитория), но получается много церемонии вокруг простой вещи. Кто-нибудь делал так, чтобы не было больно?Реферальный код, который принимается в любой момент, пока на аккаунте нет активности. Мне нравится, что защита тут — свойство аккаунта, а не таймер: таймер всегда либо слишком короткий для человека, который ушёл пить чай, либо слишком длинный. Но «нет активности» — это условие, которое я определил сам, и оно вполне может оказаться слишком мягким.
Код целиком, архитектурный документ на русском и запуск в три команды: github.com/ialakey/caseforge
cp .env.example .env # править ничего не нужно для локального запуска
pnpm setup # install + docker + build + миграции + сид
pnpm dev # api на :4000, web на :3000
Если в описанном выше видна дыра — напишите. На прошлых двух статьях это работало лучше, чем любое ревью, которое я мог устроить себе сам.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.