🪜 Рейтинговый лэддер и webhook — план выполнения
Что это
Исполняемый план (задачи PR-уровня, каждую можно отдать отдельному агенту) для двух целей: (1) бот-vs-бот рейтинговый лэддер, чтобы выяснить, какой алгоритм сильнее; (2) позже — serverless-подключение сторонних ботов через webhook. Это companion к стратегическому ревью 07-Revyu-bot-platformy-2026-07-blokery-i-plan (обоснование, экономика хостингов, AGPL) и к 01-Dorozhnaya-karta (продуктовые фазы 1–6). Здесь — что именно делать и в каком порядке. Все привязки к коду перепроверены аудитом от 2026-07-14.
0. Что изменилось с ревью 07
Три из «четырёх блокеров» §3 ревью 07-Revyu-bot-platformy-2026-07-blokery-i-plan с тех пор реализованы — DX-фундамент (Этап 1 в 07) по большей части готов, поэтому работа по лэддеру и webhook подтягивается ближе.
| Блокер §3 (стр. 07) | Статус сейчас | Код |
|---|---|---|
| 1. Легальные ходы не на wire | ✅ на wire | PublicGameState.legalMoves inline + GET /games/{id}/moves (PlayRoutes.scala:100-115) |
| 2. Discovery только live-стримом | ✅ есть polling | GET /bot/games (BotRoutes.scala:196-210); poll-only режим задокументирован в bot-api.md |
| 3. Нет durable self-service identity | ✅ есть | таблица bots (V3__play_bots.sql:4-14), POST /bot/register, ротация POST /bot/token |
| 4. Human-vs-bot не существует | ⚠️ частично | боты садятся по Principal без WS; серверного пейринга всё ещё нет — его закрывает лэддер (Фаза C) |
Следствие
«Фундамент DX» больше не блокер. Остаётся серверный пейринг (единственная непокрытая часть §3.4) и рейтинговая машинерия — ровно то, что ниже.
Порядок фаз
flowchart TB A["Фаза A · Первый ответ<br/>ранкинг из имеющихся данных · analytics · 0 схемы"] B["Фаза B · Фундамент данных<br/>rated · game_results · seed+pairing в ingest"] C["Фаза C · Лэддер<br/>рейтинг-состояние · CRN-пары · server-chosen пейринг"] D["Фаза D · Рейтинг + витрина<br/>Glicko-2 офлайн · лидерборд · BOT-бейдж"] E["Фаза E · Строгий вердикт<br/>SPRT + WHR/Ordo · пентаномиал на CRN"] F["Фаза F · Serverless<br/>webhook ADR → delivery → OpenAPI/SDK"] A -.->|первый сигнал, валидация пайплайна| D B --> C --> D --> F B --> E C --> E
Соответствие существующим документам. Фазы A–D ≈ Этап 3 «Экосистема» (07-Revyu-bot-platformy-2026-07-blokery-i-plan §7) и Фаза 5 «Рейтинги» (01-Dorozhnaya-karta); E–F ≈ Этап 4 «Киллер-фичи». Лестница из 05-Bot-platforma-anonimnyy-Bot-API-dizayn сохранена.
Сверка: где считается Glicko-2
01-Dorozhnaya-karta и 07 кладут Glicko-2 офлайн-батчем в analytics («ноль нагрузки на игровой JVM»). Этот план следует тому же решению: authoritative-рейтинг считает analytics (Фаза D/E), а
play-apiлишь хранит и отдаёт снапшот рейтинга бота для лидерборда и матчмейкинга. Живой пересчёт в игровом сервисе не делаем.
Глобальные правила для агентов
- Сначала прочитать
AGENTS.mdсоответствующего репозитория — он приоритетнее этой страницы по стилю/командам. - Никогда не коммитить в
main. Ветка<type>/<short-desc>; в теле PRCloses #<id>, если есть issue. - Стейджить файлы по имени (
git add -A/.запрещены). Английский в коммитах/PR. Conventional commits. - Мигелирования: писать, но не запускать — прогон против общей БД делает только оператор.
- DoD =
mise run check+mise run testзелёные в затронутом репо. Не пропускать вывод тестов черезgrep/head. - Кросс-репо задачи (⚠КОНТРАКТ) правят все зеркала контракта в одном наборе изменений.
sbt/npm installтребуют токена GitHub Packages (движок) —gh auth login/NODE_AUTH_TOKENдо установки.
Репозитории: play-api, analytics, play (SPA), house-bots, docs.
Фаза A — Первый ответ из имеющихся данных
Цель: получить грубый ответ «какой алгоритм сильнее» сейчас, без изменений схемы, и заодно провалидировать пайплайн до постройки машинерии. analytics уже пишет обе идентичности ботов + результат + цвет.
A.0 — Сгенерировать корпус бот-vs-бот · house-bots (+оператор) · Routine
- Использовать существующий механизм челленджей — без серверного кода: настроить oracle-ботов
челленджить друг друга (
BOT_CHALLENGE=team|name, см.docker-compose.yaml/Config.scala) или крошечный скрипт, логинящийся одним токеном и шлющийPOST /bot/challengeостальным по кругу. - Убедиться, что готовые партии доходят до analytics (
POST /api/games). - Готово когда: ≥ несколько сотен решённых партий по ≥3 идентичностям
bot:*, оба цвета; отчёт по парам.
A.1 — Провизорный ранкинг алгоритмов · analytics · Mid
- Новый standalone
IOAppвmaintenance/(паттернEvaluateMonteCarloApp.scala); обернутьmise-таском. - Фильтр: решённые бот-vs-бот (
termination != 'unknown', обаplayer_type='bot'). - На идентичность
bot:*: партии, W/D/L, win-rate с биномиальным 95% CI (Wilson), разбивка по цвету. Переиспользовать запросыPlayersRepository.scala:159,280и формулуapi/Protocol.scala:89. - Батч-Elo / Bradley-Terry (итеративный MLE, ~50 строк) → ранжированная таблица с колонкой неопределённости.
- Проверка цветового перекоса (win-rate белых vs 50%) — сколько удачи/первого хода в данных.
- Готово когда:
mise run <task>печатает ранжированную таблицу с CI; стабильно между прогонами; README-заметка о методе и его границах (unpaired, уровень алгоритма).
Фаза B — Фундамент данных для строгого измерения
Кладём
rated, dice-seed иpairing_idдо шедулера, чтобы партии лэддера сразу несли правильные измерения.
B.1 — Флаг rated на партии · play-api · Mid
- Сейчас различия rated/casual нет нигде (
GameSnapshotstore/GameStore.scala:26-43, seeks, challenges, миграции — отсутствует). - Добавить
rated: BooleanвGameSnapshot(Circe + persist), решается в момент создания, неизменяемо. - Политика (чистая функция
isRated(white, black, requested)):false, если хоть один участник анонимный (team == "anon") илиGuest; иначе — по запросу (дефолтfalse). - Протянуть через
GameRegistry.create(...)(новый параметр, дефолтfalse). - Готово когда: новые партии персистят
rated; anon/guest всегдаrated=false; тесты матрицы политики; check/test зелёные.
flowchart TD Q{"Оба участника<br/>зарегистрированы<br/>и не анонимны?"} Q -->|нет| N["rated = false<br/>(в рейтинг не идёт)"] Q -->|да| R{"Партия создана<br/>как рейтинговая?"} R -->|нет| N R -->|да| Y["rated = true<br/>(фиксируется на старте)"]
B.2 — Queryable-проекшн game_results · play-api · Frontier (схема)
- Данные игр — непрозрачный JSONB
snapshot(запрашиваемый толькоstatus). Лэддеру/рейтингу нужен перечислимый список партий по участнику/результату/rated/паре. - Миграция
Vn__game_results.sql:game_id uuid PK,white_external_id,black_external_id,result smallint(POV белых 1/-1/0/null),termination,rated bool,time_control,server_seed,pairing_id uuid null,finished_at. Индексы(rated, finished_at), по участникам. - Заполнять в той же транзакции, что и терминальную запись снапшота (
PgGameStore.scala:33-46). - Готово когда: финиш игры вставляет ровно одну строку; тест на testcontainers; миграция только пишется, не прогоняется.
B.3 — ⚠КОНТРАКТ: dice-seed + pairing_id в ingest · play-api + analytics + play(SPA) · Frontier
- Для строгого CRN-анализа (Фаза E) нужен seed и связка «какие две партии — зеркальная пара».
Сегодня на wire только выпавшие кости, без seed и пары (контракт
IngestProtocol.scala:51-71). Это verbatim-контракт — analytics реплеит партии и отвечает 422 на расхождение. - Добавить в
GameIngestопциональные поля:server_seed,client_seed_white,client_seed_black,pairing_id(опциональность = обратная совместимость, старые payload’ы валидны). - Обновить: analytics (декод + миграция + индекс по
pairing_id); эмиттер play-api (ingest/PlaysiteIngest.scala, сейчас:95хардкодитrating → null); verbatim-копию SPAsrc/lib/ingest/types.ts(заодно устранить уже существующий дрейф: отсутствуютwhite/black_money_delta,thinking_time_ms,fen_after,clock_white/black_ms). - Версию НЕ добавлять: версия кодируется в имени бота (
external_id), переименование = отдельная идентичность в analytics. - Готово когда: старые payload’ы ингестятся (201/200); новые персистят seed+pairing; round-trip тест;
check+testзелёные в обоих Scala-репо,npm run checkзелёный в SPA.
Фаза C — Лэддер (server-orchestrated)
C.1 — Рейтинг-состояние бота + on-ladder + owner · play-api · Frontier (схема)
bots(V3__play_bots.sql:4-14) сейчас без рейтинга и без владельца.- Миграция
Vn__bot_rating.sql:glicko_rating double precision default 1500,glicko_rd default 350,glicko_vol default 0.06,on_ladder bool default false,owner_external_id text null(owner закладываем заранее — под будущие человеческие аккаунты). BotStoreread/write; путь opt-inon_ladder.- Готово когда: регистрация → дефолтный провизорный рейтинг; переключение
on_ladderперсистит; check/test зелёные.
C.2 — CRN: создание зеркальной пары · play-api · Frontier (кости/авторитет)
Снижение дисперсии: каждый матч лэддера = две зеркальные партии (те же кости, цвета наоборот) → удача сокращается.
flowchart LR S["1 serverSeed +<br/>(clientSeedW, clientSeedB)<br/>фиксируются сервером"] S --> G1["Игра 1<br/>Бот A = White · Бот B = Black"] S --> G2["Игра 2 — зеркало<br/>Бот B = White · Бот A = Black"] G1 --> R["Идентичная<br/>последовательность костей<br/>по ply → удача нейтрализуется"] G2 --> R R --> P["общий pairing_id →<br/>пентаномиальный скоринг (Фаза E)"]
- Механизм:
roll(ply, clientSeedW, clientSeedB) = HMAC-SHA256(serverSeed, …), позиционно-независим (DiceSource.scala:41-53,71-100; коммент:13-18прямо про зеркальные пары). Seed инъектируется:GameRoom.create(...)принимаетDiceSource(GameRoom.scala:615-617), сборкаDiceSource.fromHexSeed(:66-69). - Подводный камень: клиентские seed привязаны к участнику (fallback
seedFor = principal.externalId,GameRoom.scala:582-587). При обмене цветов это меняет, какой seed достаётся белым/чёрным → кости разъедутся. Для игр лэддера нужно зафиксировать клиентские seed по цвету (в обходSubmitSeed/fallback). - Добавить внутренний путь
createMirroredPair(botA, botB, tc): одинserverSeed+ одна пара(clientSeedWhite, clientSeedBlack), две игры со свапом цветов, общийpairing_id(пишется через B.2). Только серверная сторона (server authority). Не менять алгоритмDiceSource— иначе включается требование golden-vectors + правкаdocs/bot-api.md. - Готово когда: тест создаёт пару и проверяет идентичную последовательность костей по ply при свопнутых цветах; общий
pairing_id; check/test зелёные.
C.3 — Пейринг-шедулер · play-api · Frontier (конкурентность)
- Net-new: серверного пейринга нет (единственный примитив
GameRegistry.create(white, black, tc)GameRegistry.scala:41-57; все вызовы accept-driven). Пары выбирает сервер — анти-фарм: бот не может выбрать соперника, владелец не накрутит рейтинг двумя марионетками (согласуется с §7 стр. 07: «same-team пары не рейтингуются»). - Supervised фоновый fiber (паттерн sweeper’ов
Lobby.scala:100-106): по интервалу выбирает пары из пулаon_ladderи зовётcreateMirroredPair, троттлинг по max-concurrent-games. - MVP-пейринг: рандом, избегать частого повтора одной пары; тяготеть к высокому RD/близкому рейтингу (Glicko-aware — опционально).
- Контроль времени: часы enforced (07 §6 + аудит; коммент
Protocol.scala:22-31устарел). Можно взять короткийFischer/PerMoveдля throughput; безопасная альтернатива —Unlimited+ ~120с idle-capGameRoom. Агент подтверждает enforcement перед тем, как полагаться на часы. - Готово когда: при ≥2 on-ladder ботах шедулер запускает зеркальные пары с нужной частотой, уважает кап, чисто гасится на shutdown; интеграционный тест на один тик; check/test зелёные.
Фаза D — Рейтинг + витрина
D.1 — Glicko-2 офлайн-батч (authoritative) · analytics · Frontier
- Реализовать Glicko-2 (rating/RD/volatility) чистой функцией + тесты на эталонном примере Гликмана.
- Батч по рейтинговым партиям (
rated=true, оба зарегистрированы; anon и same-team — исключены). Короткие рейтинг-периоды/сезоны (задокументировать). Провизорные (RD > порога, напр. 110) считаются, но скрыты из публичного лидерборда до сходимости. - Новый
IOAppвmaintenance/,mise-таск (паттерн Фазы A). - Готово когда: юнит-тест воспроизводит числа Гликмана; рейтинговая партия сдвигает оба рейтинга и ужимает RD; casual/anon — без изменений; check/test зелёные.
D.2 — Снапшот рейтинга в play-api + Leaderboard API · play-api · Mid
- play-api хранит последний рейтинг-снапшот бота (заполняется из analytics-батча) и отдаёт его.
GET /leaderboard(rated, непровизорные, по рейтингу, с RD/партиями/W-D-L) иGET /bots/{team}/{name}(профиль: сводка рейтинга + недавние результаты изgame_results). Публичные. Обновитьdocs/bot-api.md.- Готово когда: эндпоинты возвращают верные формы; провизорные скрыты; check/test зелёные.
D.3 — Страница лидерборда + BOT-бейдж · play (SPA) · Routine/Mid
- Прозрачность: человек всегда видит, что играет против бота (устраняет находку 07 §6 «боты невидимы как боты»).
- Роут
/leaderboard(заVITE_PLAY_API_URL); компонент BOT-бейджа везде, где показывается имя бота (доска, лидерборд, история) — сохранить ARIA/семантику (Svelte 5 runes, Tailwind 4, дом-конвенции AGENTS.md SPA). - Готово когда: страница рендерит ранкинг; бейдж на именах ботов;
npm run check+testзелёные; проверено в dev-сервере (hard-refresh мимо PWA).
Фаза E — Строгий вердикт
E.1 — SPRT (парный) + WHR/Ordo (пул) на CRN-парах · analytics · Frontier
Точный инструмент «B сильнее A» (как Fishtest у Stockfish) + пул-ранкинг с CI — на variance-reduced CRN-парах.
- Парный SPRT: по двум идентичностям и их head-to-head — последовательный log-likelihood ratio для
H0 (Δelo ≤ elo0) vs H1 (Δelo ≥ elo1) с конфигурируемыми α/β; вердикт accept/reject/continue и число партий.
Пары с
pairing_idскорить вместе как пентаномиал (5 исходов на пару) — та самая редукция дисперсии, ради которой seed. - Пул-ранкинг: WHR (Coulom) или Ordo/Bradley-Terry батчем с 95% CI и LOS между соседями.
- Новые
IOAppвmaintenance/,mise-таски. - Готово когда: SPRT воспроизводит синтетику (явный выигрыш → ранний accept; проигрыш → ранний reject); пул-ранкинг гоняется на реальном корпусе и печатает CI; заметка о методе; check/test зелёные.
Фаза F — Serverless для сторонних (после того как лэддер докажет ценность)
Что уже работает без изменений сервера
Poll-only serverless играбелен сегодня (
bot-api.mdописывает «cron-triggered cloud function», естьrandom_bot.py) для длинных/Unlimitedконтролей. Фаза F добавляет webhook-push — делает serverless первоклассным (любые контроли, честный zero-infra). Не начинать до лэддера. Экономику хостингов и AGPL-стартеры см. в 07-Revyu-bot-platformy-2026-07-blokery-i-plan §5, §4.
F.1 — ADR по webhook (только дизайн) · docs · Frontier
- ADR в разделе Play Site: решение (синхронный request-reply webhook), контракт эндпоинтов
(регистрация URL; payload = существующие
DiceRolled/PublicGameState; тело ответа = ход, применяемый какSubmitTurn), надёжность (опираемся на существующий per-turn deadline — dead-letter для MVP не нужен), и требования безопасности (ниже). Диаграмма последовательности. - Готово когда: ADR ревьюнут владельцем до старта F.2.
sequenceDiagram participant GR as GameRoom (play-api) participant WH as Webhook-dispatcher participant Fn as Бот (serverless-функция) GR->>WH: DiceRolled («твой ход») для сиденья WH->>Fn: POST callback-URL {gameId, dfen, dice, clocks} + HMAC Fn->>Fn: посчитать ход (cold start + inference) Fn-->>WH: 200 {moves:[...]} WH->>GR: SubmitTurn (как команда) Note over GR,Fn: нет ответа за min(бюджет, ~10–30с) → тикают часы (форфейт по клоку)
F.2 — Синхронная доставка webhook · play-api · Frontier (безопасность + конкурентность)
- Регистрация callback-URL на зарегистрированном боте (перк регистрации; anon — нет), с верификацией владения (сервер POST-ит nonce, бот эхом возвращает) до отправки игровых данных.
- На «твой ход» внутренний подписчик
GameRoomPOST-ит событие и ждёт ответ; тело ответа применяется какGameCommand.SubmitTurn. Таймаут =min(бюджет, конфиг ~10–30с); на таймаут/ошибку — ничего (клок/deadline разрулят). Доступность спящего бота для лэддера уже решена (пары выбирает сервер, Фаза C). - Безопасность (всё обязательно): SSRF-guard (блок RFC1918/loopback/link-local
169.254.169.254, только HTTPS, лимит редиректов/размера); HMAC-подпись (секрет на бота) для проверки подлинности; rate-limit доставки. - Уважать single-writer
GameRoom(подписчик подаёт команду, не второй писатель). Диспетчер может переиспользовать outbox/backoffIngestDeliverer(07 §5). - Готово когда: тест-бот с локальным HTTP-эндпоинтом играет партию через webhook; SSRF-кейсы отклонены; HMAC проверяется; мёртвый эндпоинт форфейтит по клоку без зависания комнаты; check/test зелёные.
- Позже (отдельная задача): async-вариант (событие + отдельный
POST /move) с retry/health для долгодумающих ботов и высокой конкурентности.
F.3 — DX: OpenAPI/AsyncAPI + SDK + serverless-темплейты · play-api + новый репо · Mid
- OpenAPI для REST Bot API + AsyncAPI для ndjson-стрима; тонкие клиенты (Python + TS) поверх auth и режимов; темплейты «бот за 5 минут»: timer-функция (poll-only, работает сегодня) и позже HTTP-функция (webhook, после F.2). Стартеры под MIT/Apache (растворяет AGPL, 07 §4).
- Готово когда: сгенерированные клиенты компилируются и играют против house-спарринг-бота; темплейты задокументированы.
Решения, зафиксированные в дизайн-обсуждении
- Идентичность: структура владелец→бот (≈ существующий
team/name); бот рождается ботом (без конверсии человек→бот); структурного поля версии нет — версия кодируется в имени, сравнение версий = эфемерные name-encoded участники + парный SPRT (E.1), не публичный лэддер. - Рейтинговая политика: нет рейтинга, если хоть один игрок анонимный; с аккаунтами — только если оба зарегистрированы и включили рейтинг; провизорные (высокий RD) считаются, но скрыты до сходимости; rated фиксируется на старте; пары рейтингового лэддера выбирает сервер (анти-фарм).
- Два инструмента измерения: Glicko-2 лэддер (публичный лидерборд + грубый ранкинг) vs Fishtest-стиль парный SPRT / WHR-пул (точный вердикт). CRN-зеркальные пары (общий seed, свапнутые цвета) → пентаномиальный SPRT.
🔗 Связанное
- 07-Revyu-bot-platformy-2026-07-blokery-i-plan — стратегическое ревью (экономика хостингов, AGPL, hardening).
- 01-Dorozhnaya-karta · 05-Bot-platforma-anonimnyy-Bot-API-dizayn · 06-Chestnost-kubikov-provabli-feyr-i-verifikatsiya
- ADR-0009-Bot-API-i-turniry · ADR-0010-Anonimnye-self-service-boty
- Публичный контракт: bot-api.md