🔌 Контракт ingest и валидация движком
Это граница между приватной стороной добычи и публичной аналитикой. Всё, что знает о конкретном сайте — dicechess.com, его REST/Socket.IO, JWT, Cloudflare, форматы ходов — остаётся на стороне писателя (sync, observer, beturanga-observer, extension). Аналитика выставляет наружу один обобщённый, провалидированный, защищённый записью эндпоинт и не имеет ни малейшего представления, откуда пришла партия.
Контракт состоит из двух частей:
- Wire-схема
GameIngest— source-agnostic JSON-описание завершённой партии. - Реплей-гейт — каждая партия прогоняется через настоящий
dicechess.engine, прежде чем попасть в БД. Движок — единственный источник истины о легальности и позициях.
Где это лежит в коде
Эндпоинты —
dicechess-analytics/src/main/scala/dicechess/analytics/api/Endpoints.scala; маршрутизация, авторизация и маппинг статусов —api/Routes.scala; wire-схема —api/IngestProtocol.scala; реплей-гейт —ingest/GameReplay.scala; запись в БД —repository/IngestRepository.scala. Сторона писателя —dicechess-sync/src/assemble.ts(сборка) иsrc/ingest.ts(POST).
1. Два эндпоинта: create-only и replace
Записью занимаются ровно два метода, оба под Bearer-авторизацией.
POST /api/games — создание (идемпотентно)
Принимает GameIngest целиком (UUID партии — внутри тела). Логика — create-only: если партия с таким id уже есть, ничего не перезаписывается.
201 Created— партия создана этим запросом.200 OK— партия уже существовала; запрос — no-op (тело ответаIngestResult(id, created=false)).
Это и есть фундамент правила first-writer-wins: один и тот же gameId могут постить observer, sync и extension — кто записал первым, тот и определил строку (включая идентичности игроков). Подробности гонки — в 08 Идентичность, источники и дедупликация.
PUT /api/games/{id} — замена (путь переконвертации)
В отличие от POST, это путь пере-конвертации: правила нормализации, FEN и проверки движком со временем дорабатываются, и накопленное сырьё (raw_game_data локально + bronze-архив на dexus) позволяет переконвертировать всю историю и перезалить её, ни разу не обратившись к защищённому сайту (см. защиту от блокировок и raw-архив).
Семантика PUT:
- Удаляет существующую строку
games(каскадом сносит еёturnsиgame_events) и переинсертит из тела — исправленная нормализация затирает старую. 201 Created— партии не было (создана).200 OK— партия была заменена.400 Bad Request—idв пути ≠idв теле (защита от рассинхрона).- Общие
positionsиplayersостаются нетронутыми (удаляется только сама партия и её зависимые строки).
Авторизация и таблица статусов
Оба write-эндпоинта требуют Authorization: Bearer <token>. Проверка токена — closed-by-default и в постоянном времени, чтобы не утекал секрет по таймингу (Routes.scala, tokenAccepted):
private def tokenAccepted(provided: String): Boolean =
ingestToken.exists(expected =>
MessageDigest.isEqual(expected.getBytes(UTF_8), provided.getBytes(UTF_8))
)Если токен в конфиге не задан (ingestToken = None), exists ложно — любой запрос отклоняется. Read-эндпоинты (GET /api/games, /api/players/..., /api/positions/...) авторизации не требуют.
| Код | Когда | На каком эндпоинте |
|---|---|---|
201 Created | партия создана | POST, PUT |
200 OK | партия уже была (POST) / заменена (PUT) | POST, PUT |
400 Bad Request | id пути ≠ id тела | PUT |
401 Unauthorized | неверный или отсутствующий токен | POST, PUT |
422 Unprocessable Entity | реплей движком отклонил партию | POST, PUT |
Со стороны писателя
dicechess-sync/src/ingest.tsшлёт обычныйfetch(аналитика в LAN, без Cloudflare — обходить TLS-фингерпринт не нужно) и трактует ответ ровно так:201создано,200уже было,422— реплей не прошёл.
2. Wire-схема GameIngest
JSON на проводе — в snake_case (через общую конфигурацию кодека из Protocol). Это source-agnostic описание: ни одного поля, специфичного для конкретного сайта. Корневая запись — GameIngest.
| Поле (wire) | Тип | Назначение |
|---|---|---|
id | UUID | UUID партии у источника и первичный ключ — ключ идемпотентности |
source | string | происхождение, напр. dicechess.com, beturanga.com |
mode | string | режим: classic или x2 (с удвоением) |
result | int? | результат с точки зрения белых: 1 победа, 0 ничья, -1 поражение, null неизвестно |
termination | string? | как закончилась партия (см. ниже) |
started_at | datetime? | начало партии (ISO-8601 с офсетом) |
time_initial_sec | int? | стартовый контроль времени, сек |
time_increment_sec | int? | инкремент на ход, сек |
initial_stake_amount | int? | начальный банк (пот); см. 🎓 Короткие коды и заметку про семантику ставки |
final_stake_amount | int? | итоговый банк |
white_money_delta | decimal? | изменение баланса белого |
black_money_delta | decimal? | изменение баланса чёрного |
stake_currency | string? | валюта ставки |
white_player | PlayerInput? | белый игрок (резолвится по external_id) |
black_player | PlayerInput? | чёрный игрок |
initial_fen | string | стартовая позиция (FEN); классика или Фишер-960 |
turns | TurnInputDto[] | ходы партии (см. ниже) |
events | GameEventInput[] | не-ходовые события (удвоение, предложение ничьей) |
PlayerInput
| Поле | Тип | Назначение |
|---|---|---|
external_id | string | стабильный идентификатор у источника — ключ резолва в строку players |
username | string? | имя |
player_type | string? | human / bot (по умолчанию human) |
rating | int? | рейтинг на момент партии |
Разрешение игроков, конвенция external_id (нативный отрицательный id ботов сайта vs наш bot:<algorithm>) и дедупликация — целиком в 08 Идентичность, источники и дедупликация.
TurnInputDto
Один ход одной стороны.
| Поле | Тип | Назначение |
|---|---|---|
turn_number | int | номер хода |
active_color | string | сторона хода: w / b |
dice | int[] | выпавшие кости как типы фигур: 1=pawn, 2=knight, 3=bishop, 4=rook, 5=queen, 6=king |
moves | string[] | сыгранные UCI-микроходы (пустой массив = пас) |
thinking_time_ms | int? | время на ход (см. 08 thinking_time_ms) |
fen_after | string? | позиция после хода — принимается, но НЕ доверяется |
fen_afterне доверяетсяПоле
fen_afterсуществует в DTO для совместимости с писателями, которые его шлют, но реплей-гейт переигрывает партию и сам выводит все позиции из движка. Записанные в БДposition_id/position_after_idберутся из состояния движка, а не из присланного FEN. Писательsync(assemble.ts)fen_afterиthinking_time_msи вовсе не отправляет.
GameEventInput
| Поле | Тип | Назначение |
|---|---|---|
sequence_number | int | порядковый номер события |
turn_number | int? | к какому ходу привязано |
event_type | string | тип (game_event_type_enum) |
actor_color | string? | кто инициировал |
clock_white_ms / clock_black_ms | int? | часы на момент события |
payload | json? | произвольная нагрузка (JSONB) |
IngestResult (ответ)
{ "id": "…UUID…", "created": true }created=true ⇒ 201, created=false ⇒ 200.
Что собирает писатель
dicechess-sync/src/assemble.ts сливает метаданные из player/history с нормализованной картой состояний в GameIngestWire. Два ключевых решения, видимых прямо здесь:
result— авторитетно из истории игрока, а не с доски. История даёт результат с POV запрашиваемого игрока; assemble переводит его в POV белых (result === 0 ? 0 : playerIsWhite ? entry.result : -entry.result). Это заполняет случаи, где нормализация оставилаresult=null(например, сдача).modeвыводится изentry.allowDoubling(x2если удвоение разрешено, иначеclassic),time_initial_sec=entry.timeLimit * 60.
3. Реплей-гейт: что значит 422
Прежде чем что-либо записать, бэкенд прогоняет всю партию через настоящий dicechess.engine (GameReplay.replay). Гейт переигрывает каждый ход от initial_fen, заново выводя позиции до и после, и сверяет присланную последовательность ходов с легальными путями, которые порождает сам движок.
Движок — источник истины
Все правила Dice Chess — легальность, Maximum Micro-moves Rule (правило максимума микроходов), расход костей (включая рокировку), взятие короля — живут в движке (
TurnGenerator.generateAllLegalTurnPaths). Аналитика их не дублирует. Если присланный ход не совпал ни с одним легальным путём движка — это422, и партия отвергается целиком.
Правила сверки одного хода
- Неизвестная кость. Для каждого
dice[i]берётсяPieceType.fromDice(die). Если кость вне1..6—UnknownDie. - Пас принимается только при пустых
moves. Если у движка с этими костями нет легальных путей (legalPaths.isEmpty), ход считается пасом — и принимается только еслиmovesпуст. Непустые ходы при отсутствии легальных =IllegalTurn. - Точное совпадение пути. Если легальные пути есть, присланный
movesдолжен в точности совпасть с UCI-разложением одного из полных легальных путей. - Частичный ход — только последний и только при определённой терминации.
isPartialAllowedистинно лишь для последнего хода партии и лишь когдаtermination ∈ { timeout, draw_agreement, resign }. В этом случае присланныйmovesпринимается как префикс легального пути (игрок не доиграл ход до конца — таймаут/сдача/ничья по соглашению посреди мультиходовой последовательности).
Применённые ходы прогоняются через state.makeMove(...), ход завершается state.endTurn(), и в ReplayedTurn сохраняются FEN до и после — оба выведены движком.
Известная первопричина части
422Историческая причина реджектов — en passant не-первым микроходом (баг движка): взятие на проходе должно жить весь ход, а rank-sweep в
makeMoveего терял. Фикс — в релизной ветке движка; деталь см. в auto-memory «Dicechess ingest 422 causes». Второй класс — неполный терминальный ход на timeout/draw, который правилоisPartialAllowedкак раз и легализует.
Варианты ReplayError → текст ответа 422
Routes.describe превращает ошибку реплея в человекочитаемое ApiError:
| Вариант | Текст ответа |
|---|---|
InvalidInitialFen(fen, reason) | Invalid initial FEN: <reason> |
UnknownDie(turn, value) | Turn <n>: unknown die value <v> |
IllegalTurn(turn, played, legal) | Turn <n>: illegal move sequence [<moves>] |
Поток create/replace
sequenceDiagram participant W as Writer (sync / observer / extension) participant API as analytics API participant E as dicechess.engine (GameReplay) participant DB as PostgreSQL (gold) W->>API: POST /api/games (или PUT /api/games/{id}) API->>API: Bearer-проверка (constant-time, closed-by-default) alt токен неверен/отсутствует API-->>W: 401 Unauthorized else PUT и id пути ≠ id тела API-->>W: 400 Bad Request else токен принят API->>E: replay(initial_fen, turns, termination) alt реплей отверг E-->>API: ReplayError (InvalidInitialFen / UnknownDie / IllegalTurn) API-->>W: 422 Unprocessable Entity else реплей прошёл E-->>API: ReplayedGame (FEN до/после на каждый ход) API->>DB: persist (POST) / persistReplace (PUT), одна транзакция DB-->>API: created? (true/false) API-->>W: 201 Created (created) / 200 OK (уже было/заменено) end end
4. Запись в БД
После успешного реплея IngestRepository сохраняет партию в одной транзакции. Целевая схема (games / turns / positions / game_events) описана в 01 Структура БД для записи партий Dice Chess.
persist vs persistReplace
persist(POST):SELECT 1 FROM games WHERE id = ?. Если строка есть — возвращаетfalse(no-op, →200). Если нет —insertAll(→201).persistReplace(PUT):DELETE FROM games WHERE id = ?(каскадом сноситturns+game_events), затемinsertAll. Возвращает!existed(создано, если строки не было). Транзакция намеренно откатывается, если переинсерт ничего не вставил:insertGameиспользуетON CONFLICT (id) DO NOTHING, и если параллельный запрос успел переинсертить партию между нашимиDELETEиINSERT, мы бы тихо потерялиturns/events— поэтому приinserted == 0бросаетсяIllegalStateExceptionи весьDELETEоткатывается.
insertAll
- Гард: число
request.turnsобязано совпадать с числомreplayed.turns(1:1), иначеIllegalArgumentException. upsertPlayerдля белого и чёрного (если заданы).getOrCreateдля начальной и финальной позиции.insertGame(ON CONFLICT (id) DO NOTHING). Еслиinserted == 0(партию занял параллельный запрос) —turns/eventsне пишутся.- Иначе —
insertTurnдля каждого хода (1:1 с реплеем) иinsertEventдля каждого события.
insertGame пишет enum-колонки через явные касты: mode::game_mode_enum, termination::game_termination_enum, event_type::game_event_type_enum. Отсутствующая termination пишется как unknown.
upsertPlayer — first-writer-wins на уровне игрока
INSERT INTO players (id, external_id, username, player_type)
VALUES (…, :external_id, :username, COALESCE(:player_type, 'human'))
ON CONFLICT (external_id)
DO UPDATE SET username = COALESCE(EXCLUDED.username, players.username)
RETURNING idКлюч конфликта — external_id. На конфликте обновляется только username (и только если новое не NULL). player_type НЕ перезаписывается — тип игрока фиксирует тот, кто завёл строку первым. Это часть конвенции идентичности из 08 Идентичность, источники и дедупликация.
dice_sorted — нормализация костей
В insertTurn присланные числовые кости переводятся в буквы фигур p n b r q k (индекс d-1), сортируются и регистром кодируют сторону хода: белые — верхний регистр, чёрные — нижний.
val pieces = Array('p', 'n', 'b', 'r', 'q', 'k')
val letters = dto.dice.map(d => pieces(d - 1)).sorted.mkString
val diceSorted = if dto.activeColor == "w" then letters.toUpperCase else lettersПример: бросок [3,1,5] (bishop, pawn, queen) у белых → BPQ, у чёрных → bpq. В таком же виде ключ ищется в эксплорере позиций (PositionsRepository.continuations). Подробности — в 🎓 Короткие коды.
positions — дедуп по нормализованному FEN
PositionsRepository.getOrCreate дедуплицирует позиции по normalized_fen (4-польный FEN без счётчиков). Сначала SELECT (горячий путь — позиция уже видна), на новой — INSERT ... ON CONFLICT (normalized_fen), так что две параллельные вставки одного FEN сходятся в одну строку. Что именно нормализуется — в 🎓 Нормализованный FEN.
5. Связанные страницы
- 05 Конвейер партий и состояния — стадии fetch → normalize → post → archive и сырой кэш, который кормит
PUT-переконвертацию. - 08 Идентичность, источники и дедупликация —
external_id/source/player_type, отрицательные id ботов,bot:<algorithm>, гонка first-writer-wins. - 01 Структура БД для записи партий Dice Chess — gold-схема
games/turns/positions/game_events. - 07 Backend API Architecture — общая архитектура tapir-эндпоинтов аналитики.
- 🎓 Нормализованный FEN, 🎓 Короткие коды — как дедуплицируются позиции и кодируются кости.