🎲 Beturanga — второй источник

До сих пор вся синхронизация крутилась вокруг dicechess.com: REST-эндпоинты, curl-subprocess против Cloudflare, перечисление игроков через POST /api/player/history. Но Dice Chess живёт не только там. beturanga.com — второй сайт, где играют в те же кости-шахматы, и его партии так же ценны для аналитики: больше партий, больше сильных игроков, больше материала для статистики «позиция + бросок → win-rate».

Проблема в том, что beturanga устроен совершенно иначе. Это не REST, а Socket.IO поверх WebSocket. Идентификатор партии — не UUID, а Mongo ObjectId. Есть вариант Фишера (Chess960), которого на dicechess.com нет вовсе. Поэтому для него существует отдельный сервис — репозиторий beturanga-observer (Node/TypeScript), который повторяет роль live-наблюдателя, но говорит на чужом протоколе и сливает результат в тот же POST /api/games.

Эта страница — про то, чем beturanga отличается от dicechess, как мы вытаскиваем оттуда партии по Socket.IO, как приводим их к нашему ingest-контракту, и про две вещи, которых здесь нет ни у кого другого: гибридную стратегию по Фишеру и дыру в bronze-слое.

Где это в общей картине

Архитектура добычи в целом, обе машины состояний и medallion-слои (сайт → bronze → gold) описаны в 03 Синхронизация — обзор и архитектура. Контракт приёма и реплей-гейт движком — в 07 Контракт ingest и валидация движком. Идентичность игроков и партий по всем источникам — в 08 Идентичность, источники и дедупликация. Эта страница — про специфику именно beturanga.


1. beturanga vs dicechess — таблица отличий

Оба сайта дают на выходе одно и то же: завершённые партии в кости-шахматы, которые мы нормализуем в один ingest-контракт. Но всё, что между «сайтом» и «нормализованной партией», у них разное.

Аспектdicechess.combeturanga.com
ТранспортREST/HTTPS через curl-subprocess (TLS-fingerprint Cloudflare)Socket.IO поверх WebSocket (wss://beturanga.com)
Discovery (что играется сейчас)POST /api/games/active (опрос)namespace /lobby, событие statepayload.games[] (push, сервер сам шлёт снапшоты)
Данные партииGET .../game-move-history (канон)namespace /game (query gameId, viewer:1), событие game-state
АутентификацияBearer-заголовок (JWT)JWT в query-параметре userKey на хендшейке Socket.IO
ID партииUUID (сквозной, тот же на сайте)Mongo ObjectId (24 hex) — не UUID
Варианттолько классикаfenType: 1 = classic, 2 = Fischer / Chess960
Ходыкарта состояний (state-map)movesHistory[]: roll открывает ход, следующие за ним move — его микро-ходы (уже UCI)
Cloudflareда, поэтому curlнет (WebSocket к Socket.IO-серверу проходит обычным клиентом)

Ключевой сдвиг мышления

На dicechess мы опрашиваем сайт (pull) и сами решаем, когда дёрнуть эндпоинт. На beturanga сервер сам пушит снапшоты лобби — наблюдатель пассивно слушает событие state и реагирует на исчезновение партии из лобби. Это меняет всю механику discovery: «партия завершилась» = «её id пропал из последнего снапшота state».


2. Подключение по Socket.IO

Вся работа с сайтом изолирована в src/beturanga.ts. Там ровно две функции: подписка на лобби и разовый запрос состояния партии.

2.1 Лобби (connectLobby)

Наблюдатель открывает постоянное соединение к namespace /lobby, прокидывая JWT в query, и слушает событие state:

const socket = io(`${config.baseUrl}/lobby`, {
  query: { userKey: config.userKey },
  transports: ["websocket"],
});
socket.on("state", (payload: LobbyState) => {
  if (payload?.games) onState(payload);
});

config.baseUrl по умолчанию wss://beturanga.com (env BETURANGA_BASE_URL), transports: ["websocket"] отключает polling-fallback. Сервер пушит снапшот лобби LobbyState (со списком живых партий games[]) каждый раз, когда что-то меняется. Наблюдатель держит у себя множество id из предыдущего снапшота и на каждом новом вычисляет дельту: id, которые были и пропали, — это завершившиеся партии, их и надо забрать.

2.2 Состояние партии (fetchGameStateOnce)

Когда партия завершилась, наблюдатель открывает короткоживущее соединение к namespace /game как зритель и ждёт ровно одно событие game-state с полным документом партии:

const socket = io(`${config.baseUrl}/game`, {
  query: { userKey: config.userKey, gameId, viewer: "1" },
  transports: ["websocket"],
});
socket.on("game-state", (game: BeturangaGame) => {
  if (game?._id) done(() => resolve(game)); else reject(...);
});

Соединение живёт ровно столько, сколько нужно для одного game-state: пришёл документ с непустым _id → резолвим и закрываем сокет. На каждый запрос навешен таймаут config.fetchTimeoutMs (env FETCH_TIMEOUT_MS, дефолт 20 000 мс), а сам fetchGameState оборачивает попытку в 3 ретрая (attempts = 3) — изредка хендшейк подвисает, и одна повторная попытка обычно спасает.

2.3 Конфигурация (src/config.ts)

envПо умолчаниюНазначение
BETURANGA_USER_KEY— (обязателен)JWT из залогиненного браузера; уходит в query userKey. Истекает периодически
BETURANGA_BASE_URLwss://beturanga.comorigin Socket.IO (без хвостового слеша); namespace /lobby и /game дописываются
FETCH_TIMEOUT_MS20000таймаут одного game-state
MAX_RUNTIME_MS0автоостанов рекордера (0 = до Ctrl+C)
OUT_DIRsamplesкаталог, куда рекордер пишет сырые партии + index.jsonl
ANALYTICS_BASE_URLhttp://192.168.10.3:8020аналитика (LAN, aurora)
ANALYTICS_INGEST_TOKENBearer для POST /api/games
DRY_RUN(выкл.)нормализовать + логировать, но не POST-ить
PORT8041HTTP-порт панели статистики (service.ts)
AUTO_START(выкл.)начать наблюдение сразу при старте сервиса (иначе idle)

userKey протухает

BETURANGA_USER_KEY — это JWT, скопированный из браузера. Он живёт ограниченное время. Когда лобби перестаёт подключаться (connect_error в логах), ключ нужно обновить в .env и перезапустить контейнер. Где брать JWT — см. Где лежит JWT после логина. В отличие от dicechess, тут нет программного логина: ключ добывается вручную из живой сессии.


3. Сырые формы данных

Документ партии (BeturangaGame, src/types.ts) приходит ровно в том виде, в каком его шлёт сайт — camelCase, сайт-специфичные поля. Это зеркало wire-формата, чтобы рекордер мог сохранять партии дословно. Ключевые поля:

interface BeturangaGame {
  _id: string;             // Mongo ObjectId
  state: number;           // 4 = finished
  fenType: number;         // 1 = classic, 2 = Fischer/Chess960
  players: BeturangaPlayer[];
  movesHistory: MoveHistoryEntry[];
  winner: string | null;   // _id победителя или null
  fen?: string;            // финальная доска
  time?: number;           // контроль времени, мс
  bet?: number; bank?: number; isFree?: boolean;
  // ...плюс десятки прочих полей, которые мы не теряем ([key: string]: unknown)
}

Игрок (BeturangaPlayer) несёт _id, nickname, isBot, rating. Самое важное — isBot: именно по нему мы определяем player_type.

Запись истории ходов (MoveHistoryEntry) — плоский поток, и его структура определяет всю нормализацию:

interface MoveHistoryEntry {
  move: string | null;     // UCI ("b1c3" / "e7e8q") для type="move"; null для "roll"
  user: string;            // _id игрока, которому принадлежит запись
  type: "roll" | "move";
  fenAfter: string;        // снимок Dice-Chess FEN после этой записи
  beatFigure: string | null; // буква съеденной фигуры (lowercase) или null
  isCastling: boolean;
  figureTransform: string | null;
  figures?: string;        // только на "roll": выпавшие кости как буквы фигур, напр. "nqb"
}

Модель «бросок открывает ход»

movesHistory — это не «список ходов», а поток событий. Запись roll открывает ход и несёт выпавшие кости в figures (например "nqb" = конь, ферзь, слон). Все идущие за ним записи move — это микро-ходы этого хода (в Dice Chess за один бросок можно сделать несколько микро-ходов), уже в формате UCI. Следующий roll закрывает предыдущий ход и открывает новый. Вся сегментация на ходы строится на этой границе.


4. Нормализация в ingest-контракт

src/normalize.ts превращает BeturangaGame в GameIngestWire — тот же контракт POST /api/games, что и у dicechess (см. 07 Контракт ingest и валидация движком). Сложных мест несколько.

4.1 Пересборка DFEN

Главная засада: beturanga в fenAfter пишет - там, где FEN-парсер движка ждёт активный цвет (w/b). Сырой FEN вида …RNBQKBNR - - - - движок отвергает. Поэтому toInitialDfen пересобирает валидный DFEN из начальной позиции:

const placement = raw.split(" ")[0] ?? "";              // только расстановка фигур
const castling = variantOf(game.fenType) === "classic" ? "KQkq" : "-";
return `${placement} w ${castling} - 0 1`;

Правила:

  • активный цвет — всегда w (в Dice Chess первым ходит белый);
  • классика получает права рокировки KQkq;
  • Фишер (fenType === 2) получает - — рокировка 960 движком не поддерживается (см. §6).

Подробнее про формат позиции — 🎓 Нормализованный FEN.

4.2 Сегментация на ходы

buildTurns проходит movesHistory и режет его по roll:

if (e.type === "roll") {
  if (cur) turns.push(cur);
  cur = {
    turn_number: turns.length + 1,
    active_color: e.user === whiteId ? "w" : "b",   // цвет = чей это user
    dice: e.figures ? diceFromFigures(e.figures) : [],
    moves: [], fen_after: e.fenAfter ?? null,
  };
} else if (e.type === "move" && cur) {
  if (e.move) cur.moves.push(e.move);               // UCI как есть
  cur.fen_after = e.fenAfter ?? cur.fen_after;
}

Кости из букв ("nqb") переводятся в отсортированные int’ы ([2,3,5]) через diceFromFigures (p→1, n→2, b→3, r→4, q→5, k→6). Цвет хода определяется по простому правилу: whiteId = movesHistory[0].user (кто первым кинул кости — тот белый), а blackId — второй игрок из players.

4.3 Сброс хвостового пустого хода

Партия в Dice Chess может закончиться в момент броска: победитель кидает кости, но ходить уже не нужно (или незачем) — beturanga записывает этот финальный roll без единого move. По наблюдениям это ≈ половина всех партий. Такой хвостовой ход с пустым moves[] — не сыгранный ход, и движок при реплее увидит нелегальный «пустой пас при наличии легальных ходов» и отвергнет партию с 422. Поэтому он отбрасывается:

const turns = buildTurns(game, whiteId);
while (turns.length > 1 && turns[turns.length - 1]!.moves.length === 0) turns.pop();

Только хвостовой, не серединный

Цикл снимает только хвостовые пустые ходы. Пустые ходы в середине партии — это легитимные вынужденные пасы (нет легальных ходов под выпавший бросок), их трогать нельзя. Это та же логика «пасы vs no-op», что и в dicechess-конвейере: пустой ход в конце = артефакт «ended on a roll», пустой ход в середине = настоящий пас.

4.4 Termination и result

detectTermination разбирает, как закончилась партия:

Условиеtermination
Последний move съел короля (beatFigure === "k")king_captured
winner == nulldraw_agreement
Победитель есть, но финального взятия короля нет, и он впереди по материалуresign
Победитель есть, взятия короля нет, по материалу не впередиunknown

Третья и четвёртая строки — про те самые «ended on a roll» партии. Эмпирически это не таймаут (часы далеки от нуля) и не вынужденное взятие короля (движок не находит линии, бьющей короля). Это сдача в проигранной позиции: в каждом сэмпле победитель материально впереди (в среднем +7 против ~0 у побед взятием короля). Перевес считает winnerMaterialAdvantage (src/inspect.ts) по значениям фигур (p=1, n=b=3, r=5, q=9, k=0). Если перевес положительный → resign; иначе остаёмся честными и пишем unknown. Результат (победитель) корректен в любом случае.

detectResult даёт результат в POV белых: 1 = победа белых, -1 = победа чёрных, 0 = ничья, null = не завершена (state !== 4).

if (game.state !== 4) return null;
if (game.winner == null) return 0;
return game.winner === whiteId ? 1 : -1;

4.5 Что мапится, а что нет

Итоговый normalizeGame собирает payload:

{
  id: gameUuid(game._id),
  source: "beturanga.com",
  mode: "classic",                 // захардкожено
  result: detectResult(...),
  termination: detectTermination(...),
  started_at: game.startedAt ?? null,
  time_initial_sec: game.time ? Math.round(game.time / 1000) : null,
  time_increment_sec: null,
  initial_stake_amount: null,      // ставки НЕ мапятся
  final_stake_amount: null,
  stake_currency: null,
  white_player, black_player,
  initial_fen: toInitialDfen(game),
  turns, events: [],
}

Чего нет в аналитике из beturanga

  • mode захардкожен "classic" — даже у партий Фишера. То есть «классика/Фишер» в gold-слой как режим не попадает (хранится только локально в сэмплах как variant).
  • Ставки не мапятся: initial_stake_amount, final_stake_amount, stake_currency — все null, а white_money_delta/black_money_delta normalizeGame вообще не выставляет (в payload отсутствуют), хотя в сыром game.bet/bank информация есть. Это сознательно: семантика ставок beturanga ещё не сверена с нашей (про ставки dicechess см. внутреннюю заметку по stake-семантике).
  • time_increment_sec всегда null; начальное время — game.time / 1000, округлённое до секунд.
  • events: [] — поток событий (часы по микро-ходам и т.п.) пока не извлекается.

5. Идентичность партий и игроков

Канон — на отдельной странице

Полная картина external_id/source/player_type по всем четырём писателям (observer, sync, extension, beturanga) и дедуп V6 — в 08 Идентичность, источники и дедупликация. Здесь — только специфика beturanga.

Источник. Все партии помечаются source: "beturanga.com".

ID партии — UUIDv5. beturanga отдаёт Mongo ObjectId, а ключ ingest у аналитики — UUID. src/uuid.ts детерминированно отображает один в другой:

export function gameUuid(objectId: string): string {
  return uuidv5(`beturanga.com/game/${objectId}`);   // namespace = RFC 4122 URL
}

UUIDv5 (RFC 4122, namespace URL 6ba7b811-…) гарантирует, что один и тот же ObjectId всегда даёт один и тот же UUID. Это и есть основа идемпотентности: повторный ingest той же партии не создаёт дубль, а получает 200 (first-writer-wins по UUID).

ID игрока. external_id игрока — сырой ObjectId (p._id), как пришёл с сайта. player_type — из isBot: "bot" либо "human". Никакого негативного id, как у нативных ботов dicechess, тут нет — у beturanga свои ObjectId’ы и для людей, и для ботов.

flowchart LR
  OID["beturanga ObjectId<br/>507f1f77bcf86cd799439011"] -->|"uuidv5('beturanga.com/game/'+id)"| UUID["UUID партии<br/>(идемпотентный ключ)"]
  UUID -->|"POST /api/games"| ANA["dicechess-analytics<br/>first-writer-wins"]

6. Гибридная стратегия по Фишеру

Это самое интересное отличие beturanga: на сайте есть вариант Фишера (Chess960, fenType === 2), которого на dicechess.com нет вообще. Проблема в том, что наш движок не умеет рокировку Фишера — он хардкодит классические клетки e1/h1/a1 для короля и ладей. Партия Фишера, где случилась рокировка, при реплее упадёт в 422.

Решение — гибридная стратегия, закодированная флагом ingestableToday в src/inspect.ts:

ingestableToday: variant === "classic" || !castling,

То есть партию можно отправлять в аналитику сегодня, если она:

  • классика (рокировка движком поддерживается), или
  • Фишер без единой рокировки (hasCastling = ни одной записи с isCastling).

Партии Фишер + рокировка паркуются локально (в samples/) и ждут, пока движок научится 960-рокировке. После этого их можно будет пересчитать из сырья и долить — без единого обращения к сайту.

flowchart TD
  G["завершённая партия beturanga"] --> V{"fenType?"}
  V -->|"1 (classic)"| OK["ingestableToday = true<br/>→ нормализовать → ingest"]
  V -->|"2 (Fischer)"| C{"была рокировка?<br/>(hasCastling)"}
  C -->|"нет"| OK
  C -->|"да"| PARK["ingestableToday = false<br/>→ припарковать в samples/<br/>ждать поддержки 960 в движке"]

Почему это работает на практике

README сервиса фиксирует: на выборке 162/163 партий реплей проходит чисто против настоящего dicechess-engine — и классика, и позиции Фишера/960. Единственный промах — взятие на проходе (en passant), которое локальный per-micro-move чекер (src/replay.ts) не моделирует (он не переносит ep-состояние между микро-ходами, поэтому помечает такие случаи как ep-suspected, а не как жёсткий провал). Это известная разница реплей-чекера, а не баг нормализатора. Про ограничения движка по Фишеру — внутренняя заметка по «engine no Chess960».

Локальный реплей-гейт (replay.ts) резолвит движок как опубликованный JS-пакет @rabestro/dicechess-engine из соседнего репо (dicechess-analytics-ui, dicechess-extension, dicechess-lab) или по ENGINE_PATH — чтобы у beturanga-observer не было своей GitHub Packages-авторизации только ради проверки.


7. Сквозной поток: discovery → fetch → normalize → ingest

src/observerCore.ts (BeturangaObserver) связывает всё вместе. Он держит множество id из последнего снапшота лобби; как только id исчезает — партия в очередь на скачивание (ограниченный пул, maxConcurrent = 3), затем fetch → normalize → ingest. Ведётся статистика (ObserverStats) разбивает результат по исходам ingest (created/exists/rejected/error), по варианту (classic/fischer), по termination и — главная метрика — robot-vs-robot vs human-involved.

sequenceDiagram
    participant BL as beturanga /lobby
    participant OBS as BeturangaObserver
    participant BG as beturanga /game
    participant N as normalize.ts
    participant A as dicechess-analytics

    BL-->>OBS: state (push) — список живых партий
    Note over OBS: дельта vs прошлый снапшот:<br/>id пропал = партия завершилась
    OBS->>BG: connect /game (gameId, viewer:1, userKey)
    BG-->>OBS: game-state (BeturangaGame)
    OBS->>N: normalizeGame(game)
    Note over N: пересборка DFEN, сегментация ходов,<br/>сброс хвостового пустого хода,<br/>termination/result, UUIDv5
    N-->>OBS: GameIngestWire (source="beturanga.com")
    OBS->>A: POST /api/games (Bearer)
    A-->>OBS: 201 created / 200 exists / 422 rejected
    Note over OBS: учёт исходов в ObserverStats<br/>(rvr / human, variant, termination)

Исходы маппятся в src/ingest.ts: 201 → created, 200 → exists (идемпотентный дубль), 422 → rejected (невалидно), прочее → error. Под DRY_RUN POST не выполняется — нормализуем и логируем. README отмечает живую валидацию: 13 партий/мин через весь конвейер.

Эксплуатация. Образ запускает service.ts — наблюдатель плюс панель статистики на http://<host>:8041 (live-счётчик лобби, исходы ingest, разбивка robot-vs-robot/human, кнопки Start/Stop). Наблюдатель стартует idle; AUTO_START=1 (так делает compose) запускает его на буте. Деплой — на rpi4 (192.168.10.9), рядом с dicechess-observer’ами; ingest идёт в аналитику на aurora (192.168.10.3:8020). Ingest по умолчанию включён: config.ts ставит dryRun = (DRY_RUN === "1" || DRY_RUN === "true"), то есть при неустановленной переменной dryRun=false и POST выполняется. Заглушить отправку можно только DRY_RUN=1 (или DRY_RUN=true); значение DRY_RUN=0 ничего не меняет. При этом нужен валидный ANALYTICS_INGEST_TOKEN — иначе вне dry-run сервис отказывается стартовать.

Этапы зрелости (README)

  • Stage 1 — recorder (src/recorder.ts, сбор сырья). Готов.
  • Stage 2 — normalise (game-state → ingest payload). Готов; 162/163 реплея.
  • Stage 3 — ingest + observer-сервис (end-to-end). Готов.

8. 🧱 Дыра в bronze-слое (известный пробел)

У dicechess-observer и dicechess-sync сырьё уходит в immutable bronze-архив — Postgres на dexus (см. Raw-архив — bronze-слой). Это позволяет переконвертировать всю историю локально, когда правила нормализации/FEN дорабатываются, и перезалить через PUT /api/games/{id} — не обращаясь к защищённому сайту.

У beturanga-observer такого слива в bronze нет. Сырьё он сохраняет только как локальные файлы:

  • samples/game_<id>.json — дословный документ game-state;
  • samples/index.jsonl — по строке SampleIndexRow на партию (вариант, рокировка, счётчики ходов, победитель, игроки, ingestableToday).

Это пишет рекордер (recorder.ts); сам наблюдательный конвейер (observerCore.ts) сырьё вообще нигде не персистит — он работает memory-only, нормализует на лету и постит. То есть для партий, прошедших через live-наблюдателя, сырья не остаётся нигде, кроме как если их специально гонять через рекордер.

flowchart LR
  subgraph dicechess
    DO["observer / sync"] --> RAW[("bronze raw-архив<br/>Postgres @ dexus")]
  end
  subgraph beturanga
    BO["beturanga-observer<br/>(observerCore)"] -.->|"нет слива"| X["(нет bronze)"]
    BR["recorder"] --> F[("локальные файлы<br/>samples/*.json + index.jsonl")]
  end

Что это значит и что делать

Если правила нормализации Фишера/termination/DFEN изменятся, переконвертировать историю beturanga из bronze нельзя — её там нет. Останется только то, что осело в samples/ рекордером, и то, что уже залито в gold. Это прямой аналог дыры «observer memory-only», отмеченной в плане raw-архива. Закрытие — слив beturanga-сырья в тот же bronze-слой на dexus — пока не сделано и остаётся известным пробелом.


Связанные страницы