♟️ Фаза 3 — Human-vs-Human (дизайн)

Статус: черновик дизайна · 2026-06-26. Проработано multi-agent разбором с веб-исследованием прецедентов (Lichess lila/lila-ws/Bot API, provably-fair, TCEC/CCRL). Дискретные решения вынесены в ADR-0007, ADR-0008, ADR-0009.

Фаза 3 — это архитектурный разворот: от клиент-авторитета к серверному. Фазы 1–2 (vs-bot, локальная история, реплей, профиль, restore-code) были клиент-авторитетными потому, что нечего читерить — человек против локального бота, ставок нет. Human-vs-human ломает допущение: как только играют двое, патченый клиент тривиально подкручивает и кубики, и ходы. Поэтому HvH вынуждает серверный авторитет — сервер держит истину, валидирует каждый микро-ход движком (тот же Scala, на JVM), бросает кубики, владеет часами. Это роль lila у Lichess. См. ADR-0002 (предсказывал этот разворот).

Переносится без изменений из фаз 1–2: vs-bot остаётся 100% клиентским (ноль серверной стоимости, никогда не открывает WS); движок = истина в двух рантаймах (Scala.js в браузере + JVM на сервере, общий контракт DFEN+UCI); вью-компоненты (Board/Chessground/MoveHistory) — меняется только чем наполняется store; путь vs-bot → analytics (IndexedDB outbox → Koyeb-gateway, ADR-0005) остаётся для бот-партий; adapter-static SPA остаётся (ADR-0004).


1. Серверная архитектура

Решение: новый сервис play-api на Scala 3 / cats-effect, форма Lichess (lila + lila-ws), на homelab aurora. Один JVM-процесс на старте, физическое разделение на edge play-ws — позже (этап 3c).

                         ┌──────────── play-api (АВТОРИТЕТ) ────────────┐
 браузер (SvelteKit SPA) │  per-game fiber + Ref[GameState] + Topic     │
   chessground board     │  переиспользует dicechess-engine-scala (JVM):│
        │  WS             │   FenParser · makeMove · TurnGenerator ·     │
        ▼                 │   TimeManager · DiceSource (CSPRNG+commit)   │
   ┌──────────┐  Redis    │  Postgres (схема `play`: снапшоты + лобби)   │
   │ play-ws  │◄────────► │                                              │
   │ (edge,   │  pub/sub  └──────────────┬───────────────────────────────┘
   │ stateless)                          │ на конце партии: POST /api/games (Bearer)
   └──────────┘                          ▼
                              dicechess-analytics (read-only + token write)
                              GameReplay ре-валидирует через движок · Postgres (140k+)

  vs-bot: браузер ── engine.js (Scala.js) ── localStorage.  НИКОГДА не трогает play-api.
  внешние боты (фаза 3b): своя команда ── Bot-API (ndjson+REST) ── play-api
  скраперы/extension: по-прежнему ── Koyeb Node gateway ── POST /api/games.

Per-game состояние — cats-effect fiber + Ref + Topic + Queue, не Akka. Один consumer-fiber тянет GameCommand из Queue по одному и единственный пишет Ref[GameSession] — это сериализация мейлбокса актора без фреймворка. Topic — broadcast всем сокетам (2 игрока + N зрителей). Реестр комнат = Ref[Map[GameId, GameRoom]]. Почему cats-effect, а не Akka/Pekko: тот же стек, что у dicechess-analytics (http4s/tapir/doobie/Circe), общие кодеки; движок уже immutable (GameState) → идеально ложится в Ref.update; Apache-2 без BSL-вопроса Akka; fs2 Topic/Queue дают типизированный backpressure.

final case class GameSession(
  id: GameId, state: GameState /* immutable движка */, version: Long,
  clock: ClockSnapshot, stake: StakeState, players: Map[Seat, Principal],
  pendingDice: Option[List[Int]], drawOffer: Option[Seat],
  recentEvents: Vector[GameEvent] /* ring-buffer для reconnect */
)

WebSocket-протокол — компактная версионированная обёртка Lichess: каждый кадр {"t": type, "d": payload}, серверные игровые события несут монотонный v (версия партии). dests (легальные цели) считаются server-side через TurnGenerator (chessground подсвечивает, но клиент не может читерить). ack+v дают exactly-once UX и replay при reconnect. Ключевые типы: roll, dice, move, endTurn, double/doubleResponse, drawOffer/drawResponse, gameOver, opponentGone.

Часы — сервер авторитетен. Переиспользовать форму ClockState + Fischer/sudden-death из TimeManager движка, но без бот-бюджетирования. Инкремент кредитуется за ход (ход = 1–3 микро-хода), на endTurn. Сервер хранит remainingMs + lastTickAt; таймер-fiber на комнату выстреливает flag-событие.

Персистентность (буферизуй, не пиши на каждый ход — модель Lichess):

  1. Hot: GameSession в fiber’е = истина во время игры. Без записи на микро-ход.
  2. Warm: компактный снапшот DFEN в Postgres, отдельная схема play (НЕ загрязнять публичную analytics-БД), только на значимых событиях: завершённый ход, каждый бросок (критично: зафиксированный бросок должен пережить краш, иначе игрок перебросит на крахе ради преимущества), stake/draw. Снапшот = {gameId, version, dfen, clockW, clockB, stake, drawOffer, pendingDice}. DFEN самодостаточен для resume; event-sourcing не нужен.
  3. Cold: analytics, только на конце партии.

Хэндофф в analyticsplay-api пишет POST /api/games напрямую с Bearer (идемпотентно 201/200 на UUID). НЕ через Koyeb-gateway: gateway — для внешних недоверенных источников (скраперы, extension); play-api — first-party доверенный JVM-бэкенд, уже линкует движок. Analytics всё равно ре-валидирует replay’ем (GameReplay) — defense-in-depth: баг в play-api не испортит 140k-корпус. Доставка — durable outbox в схеме play с retry/backoff.

Масштабирование: edge play-ws stateless, горизонтально (Cloudflare + sticky только на соединение); шина Redis pub/sub. Авторитет play-api — одна нода надолго (Lichess крутит все live-партии на одной 96-ядерной/192 ГиБ машине для 100k+ игроков; у нас на порядки меньше). Когда (если) перерастёшь — шардировать по хешу gameId; не строить заранее, спрятать реестр комнат за интерфейсом.


2. Кубики — кто и как генерирует (главный вопрос)

Решение: генерирует сервер, CSPRNG, в той же авторитетной петле, что валидирует ходы. v1 обёрнут в commit-reveal (provably-fair). Блокчейн — нет, никогда для этой игры. Полное обоснование — ADR-0008.

Тревога про «нагрузку от кубиков» опровергнута на 4–6 порядков. Бросок = ~1 µs (3 вызова SecureRandom) или ~100–300 нс (HMAC при commit-reveal). Всё остальное за ход дороже:

Операция за ходдороже броска (~1 µs)
бросок 3 кубиков
валидация хода движком (replay)10×–1000×
сериализация + push в WebSocket10×–100×
удержание WS-соединенияпрактически безгранично
запись в БД100×–1000×

Одно ядро ≈ 1 млн бросков/сек; партии идут в человеческом темпе. Кубики — самое дешёвое, что делает сервер за ход. Решать по доверию, не по нагрузке.

Структурный факт Dice Chess: сначала бросок, потом игрок выбирает ход — кубики это вход, на который реагируют, а не скрытый исход ставки вслепую (как в казино). Доминирующая угроза — предсказать/подсмотреть будущие броски и повлиять на броски оппонента, а не «изменил ли сервер результат после моей ставки». Это сдвигает выбор к более простым схемам.

Почему не блокчейн: он убирает доверенного оператора в децентрализованной среде. Оператор есть — ты; игроки уже доверяют тебе валидатор ходов и БД, блокчейн-кубики этого не чинят, зато +~2 сек латентности (Chainlink VRF) и реальные деньги за газ. Over-engineering.

Рекомендация v1 — server CSPRNG + per-game commit-reveal:

  1. Сервер генерит server_seed (32 байта SecureRandom), публикует H = SHA256(server_seed) обоим до сбора клиентских seed-ов (закрывает seed-grinding).
  2. Каждый игрок задаёт client_seed (дефолт — guest-id или случайное), фиксируются на старте.
  3. Бросок = HMAC-SHA256(server_seed, client_seed_W ‖ client_seed_B ‖ ply_index) → 3 кубика в [1,6] через rejection sampling (без modulo-bias). Позиционно-независимо: roll(n) = f(seed, n), потребление RNG не зависит от ходов (нужно для зеркальных турнирных пар, см. §6).
  4. На конце партии сервер раскрывает server_seed; верификатор пере-выводит броски и проверяет SHA256(server_seed) == H.

Нулевая доп. латентность во время игры, ноль round-trip’ов на бросок, ничтожная стоимость — но доказуемо честно постфактум. Прячется за swappable-интерфейс DiceSource (см. §7): апгрейд на VRF — подмена реализации без смены протокола/клиента.

Лестница апгрейдов (лезть только по конкретному требованию): CSPRNG → commit-reveal → single-server VRF (если «не доверяю даже выбору seed оператором», деньги) → threshold VRF. Блокчейн вне лестницы.


3. Создание игр, лобби, наблюдение

Порядок поставки путей создания:

  1. Share-link challenge — первым. Open-challenge link (первый открывший забирает) или вызов по username. Работает с гостевой идентичностью, ноль матчмейкинга — самый ценный ранний HvH (двое друзей без регистрации). Линку нужен TTL + single-use.
  2. Open seek-пул (Lichess hooks) — вторым. Открытое предложение в общий пул, первый принявший спаривается. Один пул максимизирует встречу двух незнакомцев при малой базе. Параметры: time/increment/rated/color (rated=false по умолчанию до аккаунтов).
  3. Quick-pairing / матчмейкинг — последним. Бессмыслен ниже критической массы; тонкий слой над seek-пулом, включить при ликвидности + рейтингах.

Лобби = два live-списка + счётчик: открытые seek-и (эфемерны, в памяти/Redis, жизнь привязана к сокету создателя), live-партии для наблюдения (проекция активных комнат), «X игроков / Y партий» (троттлить 1–2 сек). Feed по одной лобби-WS; fan-out через Redis. Гонка приёма seek-а: атомарно проверить→удалить→создать партию→push, first-writer-wins (как ingest).

Наблюдение = read-only подписчик игрового сокета (Spectator-Seat, команды отвергаются). Переиспользуем реплей-вьюер (/games/[id], src/lib/history/) + chessground: live-наблюдение = реплей, где следующий ход приходит по WS. Late-joiner получает текущий FEN + recent-events из in-memory ring-buffer (без хита БД). Зрителю показываем бросок и куб выпукло. Чат для гостей выключен (модерационная поверхность).

Жизненный цикл: created (seek) → pairing → active → finished | aborted | abandoned. Терминалы Dice Chess (победа = захват короля, не мат): kingCaptured, resign, outOfTime, drawAgreed, cubeDeclined, aborted, abandoned. Согласовать enum с termination analytics сразу. Abort — только до первого хода (окно 15с/45с, авто-abort no-start 30с, эскалирующие playban’ы). Реконнект: часы тикают во время дисконнекта; отдельный per-game «бюджет дисконнекта» держит партию; opponentGone → claim-victory через countdown.

Doubling cube (×2) — реально новый кусок, аналога у Lichess нет: cube: {value:1|2|4…, owner:white|black|center}. Игрок на ходу, владеющий кубом, до броска предлагает удвоение; оппонент берёт (×2, владение переходит) или отказывается (cubeDeclined, удваивающий берёт текущую ставку). Согласовать с stake-семантикой (initial_stake_amount = пот = 2× кнопки): финальная ставка = base × cube.

Takeback отложить — в Dice Chess откат должен и кубик перебросить (нечестно/неинтуитивно); единственная честная модель потом — «откат до броска + переброс server-side».

Гости vs аккаунты: гость наблюдает, создаёт seek/ссылку, принимает seek, играет casual — без регистрации. rated отложен до аккаунтов. Анти-абуз: abort-дисциплина + playban по guest-id + кап одновременных seek-ов + IP rate-limit (бэкстоп, т.к. гость чистит localStorage чтобы уйти от бана).


4. Клиент и аккаунты

Сдвиг UI: store как редьюсер над серверным потоком событий. Вью-слой уже функция от store. Вводим второй store (liveGameStore) с тем же read-surface (currentBoardFen, activeColor, currentDice, legalMovesDests, часы, draw/double), но write-path через WS: ход шлётся {type:"move",uci,version}, не коммитится как истина до echo сервера; MoveRejected → откат + resync. Каждое авторитетное событие несёт монотонный v; клиент тегирует исходящий ход версией; сервер отвергает устаревшие.

АспектФаза 1–2 (vs-bot, оставить)Фаза 3 (HvH, новое)
Источник истинылокальный store + Scala.js движоксервер; store — проекция
Ходмутирует storeшлёт WS, ждёт echo
Кубикилокальный RNGсерверные, WS-событием
Часылокальный setIntervalсервер авторитет; клиент интерполирует
Конец партиилокальный движоксервер объявляет

Локальный движок остаётся (vs-bot + оптимистичный UI/premove + подсветка ходов без round-trip). Версия движка клиент==сервер критична — иначе оптимистичное состояние десинхронизируется (история багов этого класса: en-passant replay #351, terminal-color, EP split). Boot-handshake: сервер объявляет версию, при mismatch клиент падает в server-echo-only рендер.

Сосуществование vs-bot и HvH: два store, одно дерево компонентов; Board.svelte обобщить на приём store через prop/context (сейчас импортит playWithBotStore напрямую). Spectator = liveGameStore read-only.

Идентичность: anonymous-first (единственная совместимая с обещанием «без регистрации»):

  1. Аноним-гость (сегодня): guest:<uuidv7> в localStorage.
  2. Заявленный аккаунт: гость claim’ит → сервер минтит durable user:<uuid>, прежний guest:<uuidv7> записывается как alias.
  3. Метод claim: email magic-link (нет паролей/OAuth-ревью); OAuth отложить.

Restore-code (guestIdentity.ts, уже выведен в UI на /me в фазе 2) = примитив миграции: claim POST /account/link-guest {guestId}, first-claim-wins на биндинг. analytics players: добавляем namespace user:<uuid>; alias-таблица player_aliases (guest:<uuidv7> → user:<uuid>), исторические партии остаются под исходным external_id (immutable history), запросы роллапят оба. Не переписывать external_id (бьётся с immutable-grain + гонка с in-flight ingest).

Сессии/токены: httpOnly Secure SameSite cookie для сессии (XSS не вытащит); короткоживущий WS-ticket (POST /ws-ticket → одноразовый ~30с токен → wss://…?ticket=…), т.к. браузер не даёт кастомные заголовки на WS-handshake и WS — отдельный origin при сплите. Обязательная проверка Origin (CSWSH). Избегать long-lived JWT в localStorage.

Адаптер: остаёмся на adapter-static SPA. Авторитет — Scala/JVM + WS, не Node; magic-link/OAuth-callback/cookie — эндпоинты Scala-сервиса, SPA просто навигирует. Триггеры для SSR/adapter-cloudflare (потом): SEO/share-unfurl публичных /game/[id]/профилей; first-paint live-партии. Миграция механическая (убрать ssr=false, guard’ы typeof window на module-load) и развязана с WS/account-работой.

Путь HvH-партий в analytics: сервер авторитетен → сервер и есть рекордер, клиентский ingest для HvH исчезает (пишет server-to-server, source='playsite', идемпотентный UUID). Клиент→gateway остаётся для vs-bot. Итог — два ingest-пути.


5. Сторонний бот-API

Решение: внутри — одна комната, два адаптера; наружу для чужих команд — выделенный Bot-API в форме Lichess. Полностью — ADR-0009.

Ядро — игрок транспортно-нейтрален. GameRoom не должен знать, по какому проводу подключён игрок:

Игрок — сущность, которая (а) получает поток игровых событий и (б) шлёт поток команд, опознанная стабильным Principal и привязанная к Seat.

Браузерный WS и подключение бота — два адаптера (PlayerConnection) над одной комнатой. Логика комнаты пишется один раз, идентична для HvH / Hv-bot / bot-vs-bot. Дублировать движок было бы фатально — реплей-валидация analytics начнёт отвергать бот-партии по тонким расхождениям.

Почему не заставлять ботов говорить на человеческом WS: для не-браузерных авторов (Python/Go/Rust/C++) WS враждебен (фрейминг, keepalive, ресинк курсора v на каждом обрыве), теряет данные на reconnect, нет естественного request/response, не дебажится curl-ом. Поэтому наружу — выделенный Bot-API (языко-агностичен, устойчив к reconnect, тривиально дебажится), а это всего лишь второй codec над тем же Topic/Queue.

Транспорт Bot-API (форма Lichess):

  • READ — одно долгоживущее HTTP-стриминг-соединение application/x-ndjson (по событию на строку): GET /stream/event (account-wide: challenge/gameStart/gameFinish) + GET /bot/game/stream/{id} (per-game, первая строка = полный снапшот gameFull, дальше дельты).
  • WRITE — маленькие stateless идемпотентные POST: /bot/game/{id}/move/{uci}, /turn (батч всего хода), /endturn, /cube/{offer,accept,decline}, /resign, /abort, /draw/{yes,no}, /chat, /claim-victory.

Dice-Chess-специфика протокола: событие roll несёт мультимножество костей (["N","P","P"]), FEN до хода и пару commit/reveal (бот сам проверяет честность). Легальные turn-path не пушим (дорого) — бот считает легальность общим движком из (FEN, кости); opt-in ?legal=true отдаёт per-ply легальные микро-ходы для простых ботов; сервер всё равно валидирует при сабмите. turnId+plyInTurn делают структуру ход/микро-ход явной. Идемпотентность: move-POST несёт ожидаемые turnId/plyInTurn, сервер отвечает 409 на устаревший/дубль.

Аккаунты/токены — одна Principal-система, три вида (не две):

GuestUserBot
Идентичностьguest:<uuidv7>user:<uuid>bot:team:<team>:<name>
Credentialноситель guest-idmagic-link сессиядолгоживущий API-токен
Долговечностьper-browserdurabledurable
analytics player_typeguesthumanbot

isBot — свойство principal из типа токена: бот-токен не выпускает human/guest-сессию (anti-masquerade на уровне типов); человек не управляет бот-аккаунтом (анти-sandbagging). Токены: opaque Bearer (храним хеш), скоупы bot:play/bot:challenge, выдача через существующий single-author admin gate (ручная для 4–5 команд, токен показывается раз), ротация/отзыв, per-token rate-limit.

Namespace analytics (без поломок): bot:team:<team>:<name> — строгое надмножество bot:<algorithm> (односегментные id = наш движок-алгоритм, двухсегментные team:* = внешние durable-боты). Ни один существующий id не меняется, коллизии команд структурно невозможны. bot:team:* — защищённый namespace (будущий V6-дедупе не трогает). source='playtourney' (≠ playsite человеческой игры, ≠ dicechess.com extension) — дизъюнктный namespace = инжесты play-api не сталкиваются с extension на first-writer-wins. Гоча: external_id это VARCHAR(50) — проверить, что bot:team:<team>:<name> влезает, иначе ограничить слаги или мигрировать колонку.


6. Турниры между командами

Главный факт: кости делают одну партию очень шумной — бот может быть сильнее и проиграть из-за серии бросков. Честность, размер выборки, воспроизводимость — всё течёт отсюда.

  • Формат: двойной круговой с зеркальными костями (стандарт TCEC/CCRL). N парных партий на пару. Arena/нокаут избегать при малом поле и высокой дисперсии. Финал (опц.) — нокаут, где каждый стык = длинный парный мини-матч, никогда одна партия.
  • Зеркальные пары — фокус гашения удачи + Dice-Chess-апгрейд: так как кости генерируем мы, реплеим ту же последовательность костей зеркальной паре (A=белые/B=чёрные seed S; затем B=белые/A=чёрные тот же S). Неудачная серия отдаётся другой стороне → удача гасится, остаток = мастерство. Требует позиционно-независимых костей roll(n)=f(seed,n) — заложить в 3a.
  • Воспроизводимость (commit-reveal): турнирный DiceSource детерминирован roll(n)=KDF(seed,gameId,n); commit до игры, reveal после; любой пересчитывает броски. Персист per game: seed/commit/reveal, лог ходов+бросков, build-хеши ботов, часы, финал.
  • Заморозка версий ботов на событие (TCEC); часы серверные, проигрыш по времени — реальный результат (помним: тяжёлые Prudent/MonteCarlo проигрывают по времени → бэкстоп wall-clock cap).
  • Рейтинги: Glicko-2, отдельный бот-ладдер (боты дают огромный объём → RD быстро схлопывается); показывать с RD; тай-брейки суммарный счёт → личные встречи → Glicko-2.
  • Отчёт в аналитику — «обе, но за забором»: инжестим bot-vs-bot (source='playtourney', ценны для анализа силы/регрессий), но держим вне публичных человеческих агрегатов (движок слабый/жадный, не эталон — эталон = win-rate сильнейших людей; смешивание исказит positions equity/continuations). Механика — флаг corpus/is_reference, дефолт non-reference для playtourney.
  • Кросс-орг (DiceChess.com): single-host, bots-dial-in — один авторитетный хост (aurora play-api) ведёт турнир и кости, боты обеих орг подключаются как бот-аккаунты по Bot-API. Снимает труднейшую распределённую проблему (кто авторитетен по костям). Общий контракт фиксирует: протокол подключения, auth/регистрацию, правила как данные (формат, N/пара, контроль времени, dice-seed/commit-reveal, version-freeze, crash/forfeit), шеринг аудит-логов + кросс-таблицы, паритет движка правил (пинить версию; помним: нет рокировки Фишера, en-passant/terminal-color легаси-гочи). Федерацию (два авторитетных сервера) отложить.
  • Зрители — реюз спектатор-дизайна (§3); турнирный вид = живая кросс-таблица + текущие доски; пост-гейм детерминированный реплей по seed/commit/reveal (уникальная trust-фича).

7. Поэтапный план фазы 3

Принцип: наименьший жизнеспособный real-time срез первым; кросс-инвариант — определить finished-game payload (история бросков, cube, termination) под контракт analytics до первой live-партии.

  • 3a — серверное ядро HvH на одной ноде (bot-aware швы, без Redis, без сплита). play-api: http4s + JVM-движок; in-memory GameRoom (fiber+Ref+Topic+Queue); REST create-game; WS join/move/roll/endTurn/clock; server-dice через DiceSource (CSPRNG+commit-reveal); in-process Topic fan-out. Параллельно: рефактор Board.svelte на store-через-prop, liveGameStore + /game/[id], оптимистичный apply + version-reconciliation, engine-version handshake. Двое играют HvH end-to-end на одной ноде, кубики серверные.
  • 3b — durability + хэндофф + бот-API. Схема play в Postgres; снапшоты/crash-recovery (бросок переживает краш); durable outbox → POST /api/games (Bearer прямой, source='playsite'); enum termination + cube + user:<uuid>/alias в контракте. Bot-API адаптер (ndjson+REST) + бот-аккаунты/токены + админ-выдача + инжест bot:team:<team>:<name> (благодаря швам 3a — адаптер, не редизайн). Решено 2026-06-26: бот-API — приоритет, не «на потом для внешних команд». У владельца уже несколько собственных ботов (и будет больше); наши движок-боты становятся первыми клиентами бот-APIreference-bot = маленькая JVM-программа, оборачивающая движок (артефакт lv.id.jc v1.6.1+, GitHub Packages) в lichess-bot-цикл (стрим событий → per-game стрим → POST хода). Это даёт: дог-фудинг ровно того API, что получат внешние команды; всегда-онлайн серверного оппонента (играть против бота, не открывая два таба); bot-vs-bot бесплатно. Референс-бот может жить в существующем репо dicechess-bots.
  • 3c — разделить edge + Redis. Вынести play-ws (stateless); Redis pub/sub; presence/лобби. Реконнект-полировка (sri/v/event-ring replay, heartbeat, disconnect/flag UX); claim-flow аккаунтов.
  • 3d — share-link challenge + наблюдение. Open-challenge link (работает с гостём); spectator = view-only; live-lobby route; cube/draw/resign UX.
  • 3e — open seek-пул + лобби-feed + турниры. In-memory seek-и, Redis fan-out, троттленые дельты, first-writer-wins пэйринг, анти-абуз; турнир-оркестратор (двойной круговой, зеркальные кости, Glicko-2, кросс-таблица) как отдельный сервис над комнатой.
  • Отложено: аккаунты+рейтинги → rated + quick-pairing; verifiable VRF; takeback; SSR/adapter-cloudflare; authority-шардинг.

Что заложить в 3a СЕЙЧАС vs отложить (ключевой вывод)

Требование ботов пересекает протокол + аккаунты + auth, поэтому 3a должен быть bot-aware, хотя сам бот-API строится в 3b. Дёшево заложить сейчас, дорого ретрофитить.

Закладываем в 3a:

  1. Транспортно-нейтральные GameCommand/GameEvent ADT — единственный публичный словарь комнаты (НЕ WS-фреймы; WS-edge и бот-edge — два codec поверх). Приёмочный тест: гонять GameRoom двумя in-memory PlayerConnection без HTTP/WS вообще.
  2. Шов PlayerConnection / Principal / Seat — комната принимает абстрактный PlayerConnection, никогда не называет «WebSocket».
  3. Principal ADT с кейсом Bot(team,name) с первого дня (добавить позже = трогать каждый match).
  4. PrincipalResolver интерфейс (3a реализует только GuestResolver; бот-токен — второй резолвер).
  5. DiceSource абстрактный + commit-reveal на критическом пути для ВСЕХ партий + позиционно-независимый roll-stream f(seed,n).
  6. Ingest-writer выводит source/external_id/player_type из Principal (никогда из клиентского ввода); резерв source='playtourney' и bot:team:*.
  7. Per-principal admission/concurrency хук (даже no-op) в фабрике лобби/комнаты — точка подключения rate-limit ботов.
  8. Spectator как first-class read-only Seat.
enum Principal:
  case Guest(id: Uuid)                  // guest:<uuidv7>
  case User(id: Uuid)                   // user:<uuid>
  case Bot(team: String, name: String)  // bot:team:<team>:<name>
  def externalId: String
 
trait PlayerConnection:
  def principal: Principal
  def seat: Seat                         // White | Black | Spectator
  def deliver(event: GameEvent): F[Unit]
 
enum GameCommand:
  case SubmitTurn(moves: List[Uci]); case Pass
  case OfferDouble; case RespondDouble(accept: Boolean)
  case OfferDraw;   case RespondDraw(accept: Boolean)
  case Resign;      case Claim(reason: ClaimReason)
  case Resync(fromVersion: Long)
 
enum GameEvent:                          // каждое несёт монотонную версию v
  case Snapshot(v: Long, state: PublicGameState)
  case DiceRolled(v: Long, seat: Seat, dice: List[Int])
  case TurnPlayed(v: Long, seat: Seat, moves: List[Uci], fenAfter: Fen)
  case ClockUpdate(v: Long, whiteMs: Int, blackMs: Int)
  case DoubleOffered(v: Long, by: Seat)
  case GameEnded(v: Long, result: Result, termination: Termination)
  case Error(v: Long, code: ErrorCode)

Отложить безопасно: сам бот-эндпоинт/адаптер; выдачу бот-токенов/админ-UI; матчмейкинг и турнир-оркестратор (отдельный сервис над комнатой); точный фрейминг бот-протокола (рекомендация — переиспользовать WS-протокол сайта дословно, один codec); механику отдельного хранилища корпуса (но тегирование резервировать сейчас); real-money/stake-семантику для ботов.

Нагрузка: боты — ось масштабирования, не люди. 4–5 команд с self-play+cross-play за час нагенерят больше партий, чем сайт за неделю; авторитет — RAM-ограниченный aurora (+ прод-аналитика + Immich). Заложить хуки (concurrency caps per-principal/team, global admission с приоритетом людям, heap/fiber-бюджет с потолком комнат, изоляция от analytics+Immich, ограниченная throttled-очередь writer→analytics — token-bucket+single-flight+jitter+backoff как backfill); реализовать политику при включении турниров. Не персистить каждую турнирную партию в курируемый Postgres — результаты+сэмпл в analytics, полные логи в raw/bronze-архив на dexus.


8. Открытые вопросы для Жегорса

Решённое (2026-06-26): ставки = виртуальные очки сейчас, со швами (DiceSource-интерфейс, абстрактная stake/cube-модель) под возможную коммерциализацию позже; аккаунты anonymous-first; хостинг play-api = homelab aurora.

Остаётся (не блокирует 3a, нужно до турниров/коммерции):

  1. Играют ли боты рейтингово против людей? Рекомендация — нет в турнирной фазе (боты только по явному вызову, вне анонимного human seek-pool).
  2. Кто хостит кросс-орг турнир? Рекомендация — мы (single-host aurora), обе орг дозваниваются ботами; федерацию отложить. Зависит от договорённости с владельцами DiceChess.com.
  3. Загрязняют ли bot-vs-bot партии публичный корпус? Рекомендация — нет (флаг corpus/is_reference, дефолт non-reference для playtourney); headline-статистика остаётся на сильнейших людях.
  4. human-vs-external-bot casual как «reference»? Тегировать по типу комнаты, дефолт tournament = non-reference.
  5. Формат external_id bot:team:<team>:<name> в VARCHAR(50) — ограничить слаги или мигрировать колонку?
  6. Персистенция турнирных партий — сэмпл+raw-архив (dexus) vs полное в курируемый Postgres (защита изношенного SSD aurora).
  7. Расписание — оконные турниры vs непрерывная игра ботов (общий ресурс-ограниченный aurora).
  8. Реальные деньги когда-нибудь? Триггер для VRF + платёжный/KYC/анти-фрод-слой (ортогонален ядру, пристёгивается сбоку); аккаунты станут обязательными, хостинг уедет с homelab.

Источники

Архитектура/Lichess: lila-ws · lila · move flow · Lila Scavenger Hunt (масштаб) Кубики/честность: provably-fair server/client seed · commit-reveal attacks · SoK randomness beacons · Chainlink VRF латентность/стоимость · nextgammon provable dice · mental poker Бот-API: Lichess Bot API · lichess-bot reference · berserk · stream events ndjson Турниры: TCEC Rules · TCEC Openings FAQ · CCRL About · Glicko-2 · Lichess Arena FAQ · Lichess Swiss

🔗 02 Архитектура — авторитет и стек · 01 Дорожная карта · 00 Журнал решений (ADR)