🎲 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.com | beturanga.com |
|---|---|---|
| Транспорт | REST/HTTPS через curl-subprocess (TLS-fingerprint Cloudflare) | Socket.IO поверх WebSocket (wss://beturanga.com) |
| Discovery (что играется сейчас) | POST /api/games/active (опрос) | namespace /lobby, событие state → payload.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_URL | wss://beturanga.com | origin Socket.IO (без хвостового слеша); namespace /lobby и /game дописываются |
FETCH_TIMEOUT_MS | 20000 | таймаут одного game-state |
MAX_RUNTIME_MS | 0 | автоостанов рекордера (0 = до Ctrl+C) |
OUT_DIR | samples | каталог, куда рекордер пишет сырые партии + index.jsonl |
ANALYTICS_BASE_URL | http://192.168.10.3:8020 | аналитика (LAN, aurora) |
ANALYTICS_INGEST_TOKEN | — | Bearer для POST /api/games |
DRY_RUN | (выкл.) | нормализовать + логировать, но не POST-ить |
PORT | 8041 | HTTP-порт панели статистики (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 == null | draw_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_deltanormalizeGameвообще не выставляет (в 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 — пока не сделано и остаётся известным пробелом.
Связанные страницы
- 03 Синхронизация — обзор и архитектура — общая картина добычи, medallion, машины состояний.
- 08 Идентичность, источники и дедупликация —
external_id/source/player_type, ObjectId→UUIDv5, first-writer-wins. - 07 Контракт ingest и валидация движком —
GameIngest, что значит 422, реплей-гейт. - Raw-архив — bronze-слой — bronze-слой, в который beturanga пока не сливается.
- Live-наблюдатель (dicechess-observer) — live-наблюдатель для dicechess.com (REST-аналог этого сервиса).
- Где лежит JWT после логина — откуда брать
userKey. - 🎓 Нормализованный FEN — формат позиции, который пересобирает
toInitialDfen.