♟️ Фаза 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):
- Hot:
GameSessionв fiber’е = истина во время игры. Без записи на микро-ход. - Warm: компактный снапшот DFEN в Postgres, отдельная схема
play(НЕ загрязнять публичную analytics-БД), только на значимых событиях: завершённый ход, каждый бросок (критично: зафиксированный бросок должен пережить краш, иначе игрок перебросит на крахе ради преимущества), stake/draw. Снапшот ={gameId, version, dfen, clockW, clockB, stake, drawOffer, pendingDice}. DFEN самодостаточен для resume; event-sourcing не нужен. - Cold: analytics, только на конце партии.
Хэндофф в analytics — play-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 кубиков | 1× |
| валидация хода движком (replay) | 10×–1000× |
| сериализация + push в WebSocket | 10×–100× |
| удержание WS-соединения | практически безгранично |
| запись в БД | 100×–1000× |
Одно ядро ≈ 1 млн бросков/сек; партии идут в человеческом темпе. Кубики — самое дешёвое, что делает сервер за ход. Решать по доверию, не по нагрузке.
Структурный факт Dice Chess: сначала бросок, потом игрок выбирает ход — кубики это вход, на который реагируют, а не скрытый исход ставки вслепую (как в казино). Доминирующая угроза — предсказать/подсмотреть будущие броски и повлиять на броски оппонента, а не «изменил ли сервер результат после моей ставки». Это сдвигает выбор к более простым схемам.
Почему не блокчейн: он убирает доверенного оператора в децентрализованной среде. Оператор есть — ты; игроки уже доверяют тебе валидатор ходов и БД, блокчейн-кубики этого не чинят, зато +~2 сек латентности (Chainlink VRF) и реальные деньги за газ. Over-engineering.
Рекомендация v1 — server CSPRNG + per-game commit-reveal:
- Сервер генерит
server_seed(32 байтаSecureRandom), публикуетH = SHA256(server_seed)обоим до сбора клиентских seed-ов (закрывает seed-grinding). - Каждый игрок задаёт
client_seed(дефолт — guest-id или случайное), фиксируются на старте. - Бросок =
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). - На конце партии сервер раскрывает
server_seed; верификатор пере-выводит броски и проверяетSHA256(server_seed) == H.
Нулевая доп. латентность во время игры, ноль round-trip’ов на бросок, ничтожная стоимость — но доказуемо честно постфактум. Прячется за swappable-интерфейс DiceSource (см. §7): апгрейд на VRF — подмена реализации без смены протокола/клиента.
Лестница апгрейдов (лезть только по конкретному требованию): CSPRNG → commit-reveal → single-server VRF (если «не доверяю даже выбору seed оператором», деньги) → threshold VRF. Блокчейн вне лестницы.
3. Создание игр, лобби, наблюдение
Порядок поставки путей создания:
- Share-link challenge — первым. Open-challenge link (первый открывший забирает) или вызов по username. Работает с гостевой идентичностью, ноль матчмейкинга — самый ценный ранний HvH (двое друзей без регистрации). Линку нужен TTL + single-use.
- Open seek-пул (Lichess hooks) — вторым. Открытое предложение в общий пул, первый принявший спаривается. Один пул максимизирует встречу двух незнакомцев при малой базе. Параметры: time/increment/rated/color (rated=false по умолчанию до аккаунтов).
- 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 (единственная совместимая с обещанием «без регистрации»):
- Аноним-гость (сегодня):
guest:<uuidv7>в localStorage. - Заявленный аккаунт: гость claim’ит → сервер минтит durable
user:<uuid>, прежнийguest:<uuidv7>записывается как alias. - Метод 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-система, три вида (не две):
| Guest | User | Bot | |
|---|---|---|---|
| Идентичность | guest:<uuidv7> | user:<uuid> | bot:team:<team>:<name> |
| Credential | носитель guest-id | magic-link сессия | долгоживущий API-токен |
| Долговечность | per-browser | durable | durable |
| analytics player_type | guest | human | bot |
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 сильнейших людей; смешивание исказитpositionsequity/continuations). Механика — флагcorpus/is_reference, дефолт non-reference дляplaytourney. - Кросс-орг (DiceChess.com): single-host, bots-dial-in — один авторитетный хост (
auroraplay-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-memoryGameRoom(fiber+Ref+Topic+Queue); REST create-game; WS join/move/roll/endTurn/clock; server-dice черезDiceSource(CSPRNG+commit-reveal); in-processTopicfan-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 — приоритет, не «на потом для внешних команд». У владельца уже несколько собственных ботов (и будет больше); наши движок-боты становятся первыми клиентами бот-API —reference-bot= маленькая JVM-программа, оборачивающая движок (артефактlv.id.jcv1.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:
- Транспортно-нейтральные
GameCommand/GameEventADT — единственный публичный словарь комнаты (НЕ WS-фреймы; WS-edge и бот-edge — два codec поверх). Приёмочный тест: гонятьGameRoomдвумя in-memoryPlayerConnectionбез HTTP/WS вообще. - Шов
PlayerConnection/Principal/Seat— комната принимает абстрактныйPlayerConnection, никогда не называет «WebSocket». PrincipalADT с кейсомBot(team,name)с первого дня (добавить позже = трогать каждыйmatch).PrincipalResolverинтерфейс (3a реализует толькоGuestResolver; бот-токен — второй резолвер).DiceSourceабстрактный + commit-reveal на критическом пути для ВСЕХ партий + позиционно-независимый roll-streamf(seed,n).- Ingest-writer выводит
source/external_id/player_typeизPrincipal(никогда из клиентского ввода); резервsource='playtourney'иbot:team:*. - Per-principal admission/concurrency хук (даже no-op) в фабрике лобби/комнаты — точка подключения rate-limit ботов.
- 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, нужно до турниров/коммерции):
- Играют ли боты рейтингово против людей? Рекомендация — нет в турнирной фазе (боты только по явному вызову, вне анонимного human seek-pool).
- Кто хостит кросс-орг турнир? Рекомендация — мы (single-host
aurora), обе орг дозваниваются ботами; федерацию отложить. Зависит от договорённости с владельцами DiceChess.com. - Загрязняют ли bot-vs-bot партии публичный корпус? Рекомендация — нет (флаг
corpus/is_reference, дефолт non-reference дляplaytourney); headline-статистика остаётся на сильнейших людях. - human-vs-external-bot casual как «reference»? Тегировать по типу комнаты, дефолт tournament = non-reference.
- Формат
external_idbot:team:<team>:<name>вVARCHAR(50)— ограничить слаги или мигрировать колонку? - Персистенция турнирных партий — сэмпл+raw-архив (
dexus) vs полное в курируемый Postgres (защита изношенного SSDaurora). - Расписание — оконные турниры vs непрерывная игра ботов (общий ресурс-ограниченный
aurora). - Реальные деньги когда-нибудь? Триггер для 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