🔌 Контракт ingest и валидация движком

Это граница между приватной стороной добычи и публичной аналитикой. Всё, что знает о конкретном сайте — dicechess.com, его REST/Socket.IO, JWT, Cloudflare, форматы ходов — остаётся на стороне писателя (sync, observer, beturanga-observer, extension). Аналитика выставляет наружу один обобщённый, провалидированный, защищённый записью эндпоинт и не имеет ни малейшего представления, откуда пришла партия.

Контракт состоит из двух частей:

  1. Wire-схема GameIngest — source-agnostic JSON-описание завершённой партии.
  2. Реплей-гейт — каждая партия прогоняется через настоящий 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 Requestid в пути ≠ 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 Requestid пути ≠ 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)ТипНазначение
idUUIDUUID партии у источника и первичный ключ — ключ идемпотентности
sourcestringпроисхождение, напр. dicechess.com, beturanga.com
modestringрежим: classic или x2 (с удвоением)
resultint?результат с точки зрения белых: 1 победа, 0 ничья, -1 поражение, null неизвестно
terminationstring?как закончилась партия (см. ниже)
started_atdatetime?начало партии (ISO-8601 с офсетом)
time_initial_secint?стартовый контроль времени, сек
time_increment_secint?инкремент на ход, сек
initial_stake_amountint?начальный банк (пот); см. 🎓 Короткие коды и заметку про семантику ставки
final_stake_amountint?итоговый банк
white_money_deltadecimal?изменение баланса белого
black_money_deltadecimal?изменение баланса чёрного
stake_currencystring?валюта ставки
white_playerPlayerInput?белый игрок (резолвится по external_id)
black_playerPlayerInput?чёрный игрок
initial_fenstringстартовая позиция (FEN); классика или Фишер-960
turnsTurnInputDto[]ходы партии (см. ниже)
eventsGameEventInput[]не-ходовые события (удвоение, предложение ничьей)

PlayerInput

ПолеТипНазначение
external_idstringстабильный идентификатор у источника — ключ резолва в строку players
usernamestring?имя
player_typestring?human / bot (по умолчанию human)
ratingint?рейтинг на момент партии

Разрешение игроков, конвенция external_id (нативный отрицательный id ботов сайта vs наш bot:<algorithm>) и дедупликация — целиком в 08 Идентичность, источники и дедупликация.

TurnInputDto

Один ход одной стороны.

ПолеТипНазначение
turn_numberintномер хода
active_colorstringсторона хода: w / b
diceint[]выпавшие кости как типы фигур: 1=pawn, 2=knight, 3=bishop, 4=rook, 5=queen, 6=king
movesstring[]сыгранные UCI-микроходы (пустой массив = пас)
thinking_time_msint?время на ход (см. 08 thinking_time_ms)
fen_afterstring?позиция после хода — принимается, но НЕ доверяется

fen_after не доверяется

Поле fen_after существует в DTO для совместимости с писателями, которые его шлют, но реплей-гейт переигрывает партию и сам выводит все позиции из движка. Записанные в БД position_id/position_after_id берутся из состояния движка, а не из присланного FEN. Писатель sync (assemble.ts) fen_after и thinking_time_ms и вовсе не отправляет.

GameEventInput

ПолеТипНазначение
sequence_numberintпорядковый номер события
turn_numberint?к какому ходу привязано
event_typestringтип (game_event_type_enum)
actor_colorstring?кто инициировал
clock_white_ms / clock_black_msint?часы на момент события
payloadjson?произвольная нагрузка (JSONB)

IngestResult (ответ)

{ "id": "…UUID…", "created": true }

created=true201, created=false200.

Что собирает писатель

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, и партия отвергается целиком.

Правила сверки одного хода

  1. Неизвестная кость. Для каждого dice[i] берётся PieceType.fromDice(die). Если кость вне 1..6UnknownDie.
  2. Пас принимается только при пустых moves. Если у движка с этими костями нет легальных путей (legalPaths.isEmpty), ход считается пасом — и принимается только если moves пуст. Непустые ходы при отсутствии легальных = IllegalTurn.
  3. Точное совпадение пути. Если легальные пути есть, присланный moves должен в точности совпасть с UCI-разложением одного из полных легальных путей.
  4. Частичный ход — только последний и только при определённой терминации. 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

  1. Гард: число request.turns обязано совпадать с числом replayed.turns (1:1), иначе IllegalArgumentException.
  2. upsertPlayer для белого и чёрного (если заданы).
  3. getOrCreate для начальной и финальной позиции.
  4. insertGame (ON CONFLICT (id) DO NOTHING). Если inserted == 0 (партию занял параллельный запрос) — turns/events не пишутся.
  5. Иначе — 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. Связанные страницы