🔬 Ревью бот-платформы (2026-07) — блокеры и план развития

Что это

Архитектурное ревью четырёх репозиториев (dicechess-engine-scala, dicechess-play-api, dicechess-play, dicechess-reference-bot) через призму видения: платформа, где боты разных команд — на разных языках, с закрытым кодом — играют друг с другом и с людьми, в том числе с бесплатного облачного хостинга (Azure Functions). Развивает 05 Бот-платформа — анонимный Bot API (дизайн) от дизайна к конкретным находкам в коде: что уже есть, что блокирует, в каком порядке строить. Каждое утверждение проверено по коду (файл:строка).

Главный вывод. Платформа ближе к видению, чем кажется: Bot API уже сейчас language-agnostic (REST + ndjson, играбельно из curl, docs/bot-api.md — документация внешнего качества), а GameRoom транспорт-агностичен — webhook-адаптер для serverless добавляется без изменения игровой логики. Между «наши боты dogfood-ят API» и «внешние команды шлют приватных ботов» стоят четыре блокера (§3) и одна юридическая проблема — AGPL (§4). Идея Azure Functions реализуема, но не на текущем стрим-протоколе — нужен webhook turn-push (§5), что подтверждает и уточняет п.8 «лесенки» из заметки 05.


1. Что уже есть (и это сильные козыри)

АктивГдеПочему это важно для платформы
Language-agnostic Bot APIplay-api BotRoutes.scala, docs/bot-api.mdBearer + REST + два ndjson-стрима, WebSocket боту не нужен. Полный цикл играбелен из curl — SDK не обязателен
Zero-registration онбордингPOST /bot/anon (30/ч на IP, TTL 24ч)«Попробовать за 2 минуты» уже работает (ADR-0010 реализован)
Транспорт-агностичное ядроGameRoom.scala — только subscribe/submit/startЧеловеческий WS, бот-ndjson и in-process тесты — тонкие адаптеры. Webhook станет третьим, без правок логики
Provably-fair костиDiceSource.scala, рецепт в bot-api.mdCommit-reveal + клиентская энтропия + length-prefixed HMAC + rejection sampling — проверяемо на любом языке (06 Честность кубиков — провабли-фейр и верификация)
Серверные часыGameRoom.deadlineForЕдинственный таймкипер — медленный бот проигрывает по времени, а не вешает платформу. Валидация хода не тарифицируется (клок до receivedAt)
Persist-before-broadcast + resumeGameRoom.emit, GameRegistry.resume«Зафиксированный бросок не может раз-броситься»; партии переживают рестарт сервера
Спектатор-режим в клиентеliveApi.ts (tokenless WS), live/[id]Полностью работает — не хватает только списка активных игр, чтобы стать «Watch»-страницей
Зеркальные кости для турнировDiceSource.scala:13-17Position-independent броски сознательно спроектированы под duplicate-пары — уникальный турнирный формат (§6, ADR-0009)

Две неожиданно приятные находки для serverless-сценария:

  • У ботов нет heartbeat-обязанности. 25-секундные keep-alive — это сервер→клиент (против 60s read-idle Ember); бот не обязан ничего слать периодически.
  • У ботов нет forfeit-on-disconnect. 30s reconnect-grace вешается только на человеческий WS (PlayRoutes.scala:104 берёт room.connection, бот-стрим в BotRoutes.scala:106-109 — нет). Бот может вообще не держать стрим и играть чистым поллингом через публичный GET /games/{id}.

2. Карта протокола (для справки)

Полный цикл бота: POST /bot/anonGET /bot/stream/event (ndjson: ChallengeReceived/ChallengeDeclined/GameStart) → POST /bot/challenge или .../acceptPOST /bot/game/{id}/seed (гейт 5s) → GET /bot/game/stream/{id} (первый кадр всегда Snapshot, дальше DiceRolled/TurnPlayed/Rejected/GameEnded) → на свой DiceRolledPOST /bot/game/{id}/move (202, результат асинхронно на стриме). Доставка at-least-once, дедуп по монотонной версии v; отставший на 256 событий подписчик отбрасывается. Терминальное событие закрывает стрим.

Кодировка — Circe discriminated objects ({"Win":{"side":"White"}}, {"Unlimited":{}}), задокументирована в bot-api.md, но машиночитаемой схемы нет — и дрейф ручных зеркал уже случился дважды: Protocol.scala reference-bot’а потерял commit/seed/clientSeeds, а liveTypes.ts клиента заморожен с пометкой «do not clean up».

3. Четыре блокера до «внешних команд»

Это и есть ядро плана

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

  1. Легальные ходы не на wire. Сервер уже вычисляет все легальные пути каждый ход (GameRoom.beginTurn:344EngineOps.legalMovePaths) — и выбрасывает. bot-api.md:63 честно говорит «compute legal moves using the engine», но движок существует только как Scala-артефакт за авторизованным GitHub Packages. Пока ходов нет в DiceRolled/GET /bot/game/{id}/moves, каждая Python/Go-команда обязана реимплементировать правила (включая Maximum Micro-moves Rule — самое сложное). Фикс — поле в Circe + один роут; заодно растворяет AGPL-проблему (§4).
  2. Discovery только live-стримом. BotEvents — live-only без replay (BotEvents.scala:9-13): пропущенный ChallengeReceived/GameStart теряется навсегда; нет GET /bot/challenges, нет GET /bot/games, нет webhook. Это убивает serverless (спящий бот не узнает о работе) и калечит обычных ботов после обрыва стрима. Плюс pending-челленджи без TTL и без валидации цели — вызов офлайн-боту висит вечно.
  3. Нет durable self-service identity. Стабильный токен = оператор правит PLAY_BOT_TOKENS и редеплоит. Anon-токены — in-memory, 24ч; умирают на рестарте сервера, а партии персистятся и резюмятся → после каждого деплоя все живые партии anon-ботов гарантированно заканчиваются timeout-форфейтом. Нужна таблица ботов в существующей play-схеме (Flyway V3): регистрация, ротация, отзыв, зарезервированная колонка webhook-URL.
  4. Human-vs-bot не существует. Челленджи — только bot↔bot (BotRoutes.scala:78-83), lobby seeks — только guests (LobbyRoutes.scala:47,70), POST /games — два Guest. Центральный пункт видения — «боты играют с людьми и создают движение» — не имеет ни одного серверного роута. Мост: пустить авторизованных ботов в seek-лобби + расширить Seek полями {name, kind: 'bot'|'human'}, чтобы человек мог фильтровать ботов, а доска показывала «house greedy (bot)» вместо «Opponent».

4. AGPL и приватные боты

Игра по сети обязательств не накладывает; линковка движка (Maven/npm/WASM) — copyleft. Проблема в том, что единственный сегодня путь к корректным правилам — именно линковка, а шаблон «fork it, replace one method» (reference-bot) сам под AGPL-3.0. Серьёзная команда с юристами приватного бота на этом не построит.

Решение из трёх частей:

  • (а) легальные ходы на wire (§3.1) — движок ботам вообще перестаёт быть нужен;
  • (б) MIT/Apache старт-киты на Python и TypeScript (~150 строк транспорта — curl-цикл в доке это доказывает) с исправленными паттернами: настоящий reconnect с backoff, логирование HTTP-статусов и Retry-After, re-mint anon-токена на 401, re-challenge на GameEnded (цикл активности!);
  • (в) страница LICENSING-FOR-BOTS с явной границей wire/линковка. Копирайт наш — при желании возможен dual-license движка.

Reference-bot как шаблон сейчас учит плохому

README заявляет reconnect, которого нет (только restart: always); fireUnary глотает все HTTP-статусы (бот с отозванным токеном выглядит живым и молчит); бот считает ход на каждый бросок, включая чужие (на wire нет yourSeat → 2× compute и трафика); BOT_CHALLENGE стреляет один раз. Всё это унаследует каждый форк — чинить до открытия наружу.

5. Serverless / Azure — вердикт

Текущий стрим-протокол на Azure Functions нежизнеспособен: HTTP-триггер обязан ответить за 230 с, выполнение ≤10 мин (Consumption), входящие по удерживаемому сокету функцию не триггерят. Это подтверждает интуицию п.8 «лесенки» (заметка 05, §6) — «смена транспорта». Уточнения по свежим лимитам (2026):

ХостингБесплатноТекущий протоколWebhook-протокол
Azure Functions Flex Consumption250k выполнений + 100k ГБ·с/месрекомендация №1: партия ≈ 40 вызовов → тысячи партий/мес
Azure Functions Consumption (legacy, retire 2028)1M выполнений/мес
Azure Container Apps≈200 ч активной мин-реплики/мес (180k vCPU·с), scale-to-zero✅ WS as-is + wake-хук✅ — сюда JVM/Scala-ботов
Cloudflare Durable Objects100k req/день⚠️ исходящий WS держит объект ≤15 мин — хватает на короткую партию; только JS/WASM
Oracle Always Free VM24/7 VPS (квоты урезают)✅ с оговорками
Fly.io / Railwayбольше не бесплатны
Render Freeусыпляет без входящего трафика → сокет-бот вылетает⚠️
GitHub Actionsзапрещено ToS (serverless-use)

Webhook turn-push (ключевое протокольное дополнение): зарегистрированный бот привязывает callback-URL + HMAC-секрет; сервер на его ход POST-ит {gameId, dfen, dice, clocks, turnToken}; бот отвечает ходом синхронно в теле ответа или асинхронно идемпотентным POST с turnToken; retry/backoff, N подряд таймаутов = forfeit. Cold start 1–10 с лечится lag-allowance (плюминг есть — клок и так считается до receivedAt) либо минимальным контролем Fischer 3+2. Архитектурно — третий тонкий адаптер над GameRoom; dispatcher переиспользует outbox/backoff-машинерию IngestDeliverer. Создание игр из cron-триггера через REST закрывает «боты сами создают движение».

Работает уже сегодня без изменений сервера: одна инвокация функции минтит токен → челленджит house-бота (accept синхронный, gameId в ответе) → играет партию до конца поллингом GET /games/{id}. Ограничение — только против always-on оппонента и в рамках одной инвокации.

6. Находки по репозиториям

dicechess-play-api — самый сильный актив, но открывать наружу рано

Риски до открытия (всё проверено по коду):

  • DoS через спам ходов: каждый SubmitTurn запускает полный перебор легальных путей (EngineOps.findLegalPath), rate limit есть только на /bot/anon.
  • Write-amplification: каждый emit — включая Rejected! — персистит полный JSONB-снапшот партии (GameRoom.emit:299-307) → спам-бот превращает мусорные запросы в upsert-ы на изношенный SSD aurora.
  • Challenge-спам: Challenges.create без TTL, капов и проверки цели — map растёт вечно (у Lobby sweeper есть, у Challenges нет).
  • Загрязнение корпуса: неаутентифицированный POST /games + force-start через 5 с + timeout через 120 с → мусор уезжает в аналитику (исключается только Aborted).
  • Валидация TimeControl: SuddenDeath(-5)/PerMove(0) создают instant-forfeit игры.
  • Мелочи: README врёт «in-memory for now» (persistence смёржен), compose не отражает Postgres-режим, Protocol.scala:22-26 врёт «клоки не enforced».

dicechess-reference-bot — правильная идея, обманчивое исполнение

См. callout в §4. Плюс: пиннинг движка 1.6.1 против независимо обновляемого сервера = массовые Rejected при legality-расхождении (прецедент — ep-канонизация 1.6.1). Ценный актив: пиннед-JSON в ProtocolSuite.scala — готовые language-neutral протокольные фикстуры, надо только вынести из Scala-теста в .json-файлы.

dicechess-engine-scala — спек-качества ядро за стеной

  • GitHub Packages требует токен даже на чтение — первая стена для любого внешнего автора (и для free-tier облачных сборок).
  • WASM-пакет — это Scala.js-WASM с JS-загрузчиком, не WASI: из Python/Go не загрузить.
  • Конформанс-фикстуры (movegen_{1,2,3}_dice.json, perft_suite.json: DFEN → ожидаемые UCI-ходы) — ровно то, что нужно реимплементаторам — похоронены в test resources; опубликовать release-ассетами.
  • estimateEquity принимает rollouts/maxPlies без верхнего капа — DoS-вектор для любого будущего сервис-враппера.
  • Дрейф: committed dist/ (1.2.4) и dist-wasm/ (1.6.0) против 1.6.2-SNAPSHOT; рукописный d.ts без getAvailableBots/clock.

dicechess-play — витрина почти готова, но выключена

  • Спектатор работает end-to-end, недостижим без списка игр → Watch-страница = самый дешёвый видимый шаг (нужен только GET /games на сервере).
  • Боты невидимы как боты; входа «сыграть с серверным ботом» нет (карточка «Play a bot» — только шесть браузерных).
  • Live-партии не сохраняются в историю//me (человек, проигравший внешнему боту, не может посмотреть партию).
  • commit костей приходит в CreateGameResponse и выбрасывается — fairness невидим (фоллоу-апы в PR#30, см. 06 Честность кубиков — провабли-фейр и верификация).
  • Клиентский vs-bot путь с Math.random-костями — готовый вектор отравления будущих лидербордов → нужен trust-tier в ingest-контракте (server-authoritative vs client-reported).
  • Seat-токены в query string (утечка через history/скриншоты) → перенести во fragment.

7. План развития

Соответствие «лесенке» из заметки 05 §8 сохранено (номера в скобках); ревью уточняет содержимое и порядок внутри пунктов.

flowchart TB
    H["Этап 0 · Hardening<br/>rate limits · TTL челленджей ·<br/>skip Rejected-persist · trust-tier ингеста"]
    F["Этап 1 · Фундамент DX<br/>legal moves на wire · polling-REST ·<br/>durable bot registry"]
    V["Этап 2 · Видимость и активность<br/>Watch/TV · activity-loop ·<br/>MIT старт-киты · sandbox-in-a-box"]
    E["Этап 3 · Экосистема<br/>боты в лобби (HvB) · Glicko-2 ·<br/>профили и directory"]
    K["Этап 4 · Киллер-фичи<br/>webhook turn-push · mirrored-dice турниры ·<br/>лиги с боссами · resource classes"]
    H --> F --> V --> E --> K

Этап 0 — Hardening (гейт для всего; S-effort, паттерны уже в коде)

  • Обобщить AnonMintLimiter (он уже generic) на per-token лимиты /bot/challenge и команд в партии; per-IP на POST /games и seeks.
  • TTL-sweep + валидация цели для челленджей (зеркало существующего Lobby.sweep); кап pending на бота.
  • Не персистить снапшот на Rejected; валидировать границы TimeControl.
  • Trust-tier в ingest (server-authoritative-commit-reveal vs client-reported) — согласованно в трёх ручных копиях контракта; карантин мусорных сигнатур (0 ходов одной стороны, sub-N-ply timeout). Защитить корпус до прихода бот-трафика.
  • Адъюдикация: партия, где сторона не сделала ни хода, — Aborted, не Timeout (мусор не рейтингуется и не уезжает в корпус).

Этап 1 — Фундамент DX (три блокера из §3; лесенка п.4→5)

  • Legal moves на wire: списки UCI-путей в DiceRolled + GET /bot/game/{id}/moves (сервер уже всё считает).
  • Polling-полный REST: GET /bot/challenges, GET /bot/games, синхронная причина реджекта (409/422 тело вместо вечного 202) — заодно путь resume после рестарта для самого reference-bot.
  • Durable bot registry: таблица в play-схеме, self-service регистрация/ротация/отзыв, дешёвая верификация владения (GitHub-линк или magic link), зарезервированный webhook-URL. Фиксит форфейты после деплоя.
  • Версионировать протокол: /v1 или поле версии, OpenAPI + JSON Schema, фикстуры из ProtocolSuite — release-ассетами.

Этап 2 — Видимость и активность (лесенка п.3)

  • Watch + Dice Chess TV: GET /games (фильтр по GameRegistry) + /watch-роут по паттерну /lobby; TV-режим авто-следует топовой партии. Витринный эффект максимальный при S-effort.
  • Activity-loop в reference-bot: re-challenge на GameEnded c задержкой + Semaphore-кап параллельных игр.
  • MIT старт-киты Python/TS (template-репо, «Use this template») + фикс паттернов Scala-шаблона.
  • Sandbox-in-a-box: docker-compose из готовых ghcr-образов (play-api умеет in-memory без Postgres) — команды перестают тестировать в проде.
  • Легальный движок без стен: зеркало npm-пакета на публичный npmjs, конформанс-фикстуры release-ассетами, неинтерактивный line-режим CLI (echo 'legal <dfen>' | dicechess) как subprocess-оракул для любого языка.

Этап 3 — Экосистема (лесенка п.5–6)

  • Боты в human-лобби: авторизованные боты постят/принимают seeks; Seek + {name, kind}; вход «Play an online bot» в клиенте. Idle house-боты в лобби решают проблему пустого лобби.
  • Glicko-2 в analytics офлайн-батчем (ноль нагрузки на игровой JVM): рейтингуются только server-authoritative партии зарегистрированных identity; anon и same-team пары — нет (закрывает фарм марионетками); версии ботов (#v2) — отдельные рейтинговые сущности; месячные сезоны.
  • Профили ботов, bot directory (GET /bots: online-статус из BotEvents.subscribers, предпочтения по контролю, auto-accept флаг), бейджи.
  • Live-партии в историю клиента; commit + «verify this game» на доске.

Этап 4 — Киллер-фичи (лесенка п.7–8)

  • Webhook turn-push (§5) + «Deploy to Azure»-кнопка в Python-старт-ките = онбординг внешней команды за один вечер.
  • Mirrored-dice турниры: duplicate-пары (одинаковая последовательность костей, цвета наоборот, счёт парой) — нейтрализация удачи, формата нет ни у кого. TimedArenaRunner движка уже прототипирует пейринг и скоринг офлайн.
  • Лиги с боссами (CodinGame-модель): шесть difficulty-ботов — готовые боссы; «мой Python-бот обыграл greedy-v2» — конкретный майлстоун для команды.
  • Resource classes (serverless / standard / unbounded): lag-allowance с клока, минимальный контроль, сегментированные пулы рейтинга — асимметрия compute из проблемы честности становится фичей.
  • Пост-геймовый аудит: GET /bot/game/{id}/record из уже персистируемого JSONB (все события, receivedAt, причины реджектов, commit/seed/clientSeeds) — фундамент споров и целостности рейтинга.
  • Метрики: Prometheus из play-api (комнаты, стримы, rejected-rate, outbox depth, latency перебора) + Grafana на dexus/rpi4; публичные health-карточки ботов («последнее подключение, доля реджектов») — команды сами диагностируют «глухих» ботов.

Лесенка п.9 (submit-and-run песочница) — без изменений: после обкатки connect-in.


🔗 Связанное