🕸️ Граф игроков и перечисление
Эта страница про игроцкую сторону синхронизации — про то, как dicechess-sync отвечает на вопрос «а какие вообще партии существуют?». Сама закачка ходов и сборка партии описаны отдельно (см. конвейер партий); здесь — про обнаружение (discovery) игроков и перечисление (enumeration) их истории. Это и есть то, что в обзоре называется «синхронизация информации об игроках».
Ключевая идея простая: у dicechess.com нет эндпоинта «дай мне все партии сайта». Есть только «дай историю конкретного игрока». Значит, чтобы добыть всю историю сайта, нужно построить граф игроков и обойти его — стартуя от верхушки рейтинга и расползаясь по оппонентам. Краулер — это паук (spider), а очередь обхода мы называем фронтиром (frontier).
Где это живёт
Сервис
dicechess-sync(Node/TypeScript), исходники локальны. Состояние обхода — в собственной SQLite-базе сервиса (DB_PATH, по умолчанию./data/sync.db), а не в аналитике. Деплой — на rpi4 (192.168.10.9), с residential IP и egress через Cloudflare. Эксплуатационная справка — в 09 Pipeline - dicechess-sync.
1. Discovery: как граф вообще возникает
Обнаружение работает в две стадии, и обе сходятся в одну таблицу players (фронтир).
1.1 Seeds — точки входа
Кран нужно с чего-то начать. Сиды (seeds) — это идентификаторы игроков, которыми мы засеиваем фронтир на старте. На текущей итерации сиды задаются вручную через env-переменную SEED_IDS (список через запятую); такие записи попадают в players с discovered_via = 'manual'.
Значения discovered_via leaderboard и x2_leaderboard (см. тип DiscoveredVia в src/types.ts:10) пока зарезервированы: автоматическая выкачка лидербордов (classic + x2) как источник сидов — запланированный, но ещё не реализованный механизм. В dicechess.ts есть только getPlayerHistory и getMoveHistory; HTTP-вызова лидербордов нет. Засев происходит на старте каждого режима запуска:
// sync-crawl.ts / crawl-service.ts — на старте
frontier.resetInProgress(now()); // вернуть зависшие in_progress в очередь
for (const id of config.seedIds) // засеять фронтир
frontier.upsert({ userId: id, discoveredVia: 'manual' }, now());Почему именно верхушка рейтинга? Потому что сильнейшие игроки — это эталон для аналитики (наш движок слабый и не образец; ориентир — win-rate сильнейших из БД). А ещё топы играют много и со всеми, так что от них граф расходится быстрее всего.
1.2 Opponent BFS — паук расползается
Дальше включается обход в ширину (BFS) по оппонентам. Когда мы перечисляем историю игрока, каждый встреченный соперник — это новая вершина графа. Мы кладём его на фронтир с глубиной depth + 1:
// enumerate.ts — внутри цикла по строкам истории
const { game, opponent } = mapHistoryEntry(entry, target.userId, target.depth);
if (deps.catalog.upsertDiscovered(game)) newGames++;
deps.frontier.upsert(opponent, deps.now()); // ← оппонент уходит на фронтир, depth+1Обход не рекурсивный и не одноразовый: enumeratePlayer только добавляет оппонентов в players, а кто перечисляется следующим — решает планировщик crawlFrontier (src/crawl.ts), выбирая из фронтира самого приоритетного. Поэтому граф проявляется постепенно, проход за проходом (и между перезапусками): обнаружение и перечисление чередуются, всё состояние лежит в SQLite, прогон в любой момент можно прервать и продолжить.
graph TD LB["🌱 Сиды<br/>(SEED_IDS, вручную)"] -->|seeds| S1["Игрок A<br/>depth 0"] LB -->|seeds| S2["Игрок B<br/>depth 0"] S1 -->|opponent| O1["Игрок C<br/>depth 1"] S1 -->|opponent| O2["Игрок D<br/>depth 1"] S2 -->|opponent| O2 S2 -->|opponent| O3["Игрок E<br/>depth 1"] O1 -->|opponent| O4["Игрок F<br/>depth 2"] O2 -->|opponent| O4 O3 -->|opponent| O5["…"] O4 -->|opponent| O6["…"]
Один оппонент — много рёбер
upsertидемпотентен поuser_id: если игрока D нашли через A и через B, вplayersодна строка. Конфликт по PK не плодит дубли, а обогащает запись (см. ниже). Глубина в строке остаётся той, что записали первой при вставке —upsertполеdepthсуществующей строки не понижает.
2. Таблица players — фронтир и состояние синка по игроку
players совмещает две роли: это и очередь обхода (что красть дальше), и журнал состояния синхронизации по каждому игроку (докуда уже добрали). Схема — src/db/migrations/0001_init.sql.
| Колонка | Тип | Назначение |
|---|---|---|
user_id | INTEGER PK | dicechess userId. Отрицательный = сайт-бот. |
username | TEXT | Имя (из истории/лидерборда). Guest… → гость. |
player_type | TEXT NOT NULL 'human' | human | bot | guest. Краулим только human. |
rating_classic | INTEGER | Рейтинг classic (из /api/leaderboard). |
rating_x2 | INTEGER | Рейтинг x2 (из /api/x2Leaderboard). |
discovered_rating | INTEGER | Снимок рейтинга в момент, когда игрок найден как оппонент. |
priority | INTEGER NOT NULL 0 | max известный рейтинг → порядок выборки из фронтира. |
total_count | INTEGER | totalCount из player/history — цель полноты (сколько партий у игрока всего). |
games_discovered | INTEGER NOT NULL 0 | Сколько строк истории мы уже обработали (накопительно). |
sync_status | TEXT NOT NULL 'pending' | pending | in_progress | synced | error | skipped. |
enumerate_offset | INTEGER NOT NULL 0 | Курсор first (offset пагинации) для возобновления прерванного свипа. |
enumerate_complete | INTEGER NOT NULL 0 | 0/1: полный проход хоть раз завершён до конца. |
synced_through_ms | INTEGER | max startTime (unix ms) — нижняя граница для следующего инкрементального свипа (→ START_DATE). |
discovered_via | TEXT | leaderboard | x2_leaderboard | opponent | tournament | manual. |
depth | INTEGER NOT NULL 0 | Глубина BFS от сида. |
attempts | INTEGER NOT NULL 0 | Счётчик неуспешных попыток (для backoff). |
last_error | TEXT | Текст последней ошибки. |
next_retry_at | TEXT | ISO — не трогать до этого момента (backoff-гейт для error). |
last_synced_at | TEXT | ISO — последний успешный проход (для requeueStale). |
last_attempt_at | TEXT | ISO — когда последний раз пытались. |
created_at / updated_at | TEXT NOT NULL | ISO-таймстемпы, авто-дефолт. |
Два индекса под две горячие выборки:
CREATE INDEX ix_players_frontier ON players (sync_status, priority DESC); -- claimNext
CREATE INDEX ix_players_retry ON players (sync_status, next_retry_at); -- гейт ретраев
priority= «насколько ценен этот игрок»
priorityпересчитывается при каждомupsertкакmax(rating_classic, rating_x2, discovered_rating)и в записи только растёт (MAX(players.priority, excluded.priority)). Так сильный игрок, найденный сначала как тусклый оппонент, а потом — как топ лидерборда, всплывает наверх очереди. Аналогично растётdiscovered_rating(MAX), аusername/rating_classic/rating_x2обновляются поCOALESCE(новое непустое значение перекрывает старое NULL).
upsert: обогащение без перезаписи
Frontier.upsert (src/frontier.ts:96) никогда не перезаписывает sync_status и player_type существующей строки. Уже синхронизированный игрок остаётся synced, даже если его снова встретили как оппонента; его тип не «переклассифицируется» задним числом. Это важно: перечисление чужой истории не должно сбивать прогресс по игроку, которого мы уже обошли.
3. classify() — кого вообще краулить
Тип игрока выводится дёшево, без обращения к профилю, прямо по id и имени (src/frontier.ts:23):
function classify(userId: number, username: string | null | undefined): PlayerType {
if (userId < 0) return 'bot'; // отрицательный id → сайт-бот
if (username && username.startsWith('Guest')) return 'guest';
return 'human';
}И статус на вставке следует из типа: human → 'pending', всё остальное (bot/guest) → 'skipped'.
Боты и гости — игроки, но не источники
skippedозначает «этого игрока не обходим как источник партий», но он по-прежнему остаётся легитимной вершиной графа: он фигурирует белым или чёрным в партиях, которые мы добыли через его human-оппонента. Мы просто не перечисляем его историю. Гости эфемерны и бессмысленны для статистики; сайт-боты — отдельная история идентичности (нативный отрицательный id), подробнее в 08 Идентичность, источники и дедупликация.
4. POST /api/player/history — страница истории
Перечисление одного игрока — это пагинация по POST /api/player/history, отсортированной новейшими сверху. Транспорт — curl-subprocess (Node fetch режется Cloudflare по TLS-фингерпринту); детали защиты — в 06 Защита от блокировок и Cloudflare.
4.1 Тело запроса
getPlayerHistory (src/dicechess.ts:41) формирует:
{
"filters": { "PLAYER_ID": "12345", "START_DATE": "1711829324793" },
"startBets": [],
"allowedTimes": [],
"pageSize": 1000,
"first": 0,
"sortColumn": "DATE",
"isAsc": false
}Фильтры уходят на провод строками
PLAYER_IDиSTART_DATEсериализуются какString(...), хотя по смыслу это числа (id и unix-ms).START_DATEприсутствует только при инкрементальном свипе (когдаstartDateMs != null).isAsc: false+sortColumn: 'DATE'дают порядок «свежие первыми» — на нём держится вся логика курсора.
4.2 Ответ
interface PlayerHistoryResponse {
gameHistoryList: PlayerHistoryEntry[];
totalCount: number; // ВСЕ партии игрока, игнорируя фильтры → цель полноты (total_count)
filteredCount: number; // партии под текущими фильтрами
}4.3 mapHistoryEntry — из POV игрока в POV белых
Каждая строка истории (PlayerHistoryEntry) дана с точки зрения запрошенного игрока, а каталог и аналитика хранят всё с точки зрения белых. mapHistoryEntry (src/enumerate.ts:67) переводит:
- Результат.
entry.resultотносительный (1 win / 0 draw / −1 loss для запрошенного игрока). Переводим в POV белых: ничья остаётся0, иначе если игрок играл белыми — берём как есть, если чёрными — инвертируем знак.const resultWhite = entry.result === 0 ? 0 : playerIsWhite ? entry.result : -entry.result; - Рейтинг оппонента. Берём рейтинг той стороны, которой соперник играл:
playerIsWhite ? startRatingBlack : startRatingWhite. Он идёт вdiscovered_ratingоппонента. - Раскладка по цвету.
whiteUserId/blackUserIdсобираются изqueriedUserIdиopponentIdпоentry.color. - Тайм-контроль.
time_initial_sec = timeLimit * 60(на проводеtimeLimitв минутах),time_increment_sec = timeBonus(в секундах). - Режим.
allow_doubling(true = режим x2 с удвоением ставки). historyMetaJson— вся строка истории сохраняется без потерь (JSON.stringify(entry)) вraw_game_data.history_meta_json. Из неё на этапе сборки можно до-вывести что угодно ещё.
Результат партии — авторитетно из
player/historyМетаданные истории — источник истины для результата (POV игрока → переводим в POV белых), потому что нормализация одной доски может оставить
result = null(например, при сдаче). История это заполняет. Подробнее о реплее и контракте — в 07 Контракт ingest и валидация движком.
startTime парсится как UTC
entry.startTimeприходит без таймзоны ("2026-03-30T20:08:44.793").parseStartTimeMs(src/enumerate.ts:58) добавляетZ, если зоны нет, и парсит как UTC. Это держит курсорsynced_through_msстабильным и монотонным внутри нашей БД (точная стыковка с сервернымSTART_DATE-фильтром — вопрос инкрементального синка, отложен на v0.2).
5. Полный свип против инкрементального
Один и тот же цикл enumeratePlayer (src/enumerate.ts:139) обслуживает оба режима. Развилка — значение synced_through_ms (курсор), которое читается из строки players:
- Полный свип (
synced_through_ms == null): игрока ещё ни разу не обходили. Идём страница за страницей до конца — пока страница не придёт неполной (rows.length < pageSize).START_DATEв запрос не кладём. - Инкрементальный свип (
synced_through_msзадан): игрока уже обходили, добираем только новое. КладёмSTART_DATE = synced_through_msи, поскольку строки идут свежими сверху, останавливаемся на первой же строкеstartedMs <= cursor— остальное относится к предыдущему свипу.
for (const entry of rows) {
const startedMs = parseStartTimeMs(entry.startTime);
if (cursor != null && startedMs <= cursor) { reachedCursor = true; break; } // дошли до курсора
// ... mapHistoryEntry, upsertDiscovered, frontier.upsert ...
}
first += rows.length;
deps.frontier.setEnumerateProgress(target.userId, first, false, deps.now());
if (reachedCursor || rows.length < pageSize) break;Новый курсор — это максимальный виденный startedMs за свип (maxStartedAtMs); он же запишется в synced_through_ms при завершении. На инкрементальном свипе у активного игрока это обычно одна страница (просто больше тело ответа при крупном pageSize).
6. Резюмируемость: enumerate_offset и «киты»
У dicechess.com есть киты (whales) — игроки с 30k+ партий. Полный свип такого игрока — это сотни страниц, и прерваться он может в любой момент (краш, истёкший JWT, блок Cloudflare, перезапуск пода). Чтобы не начинать заново, после каждой страницы фиксируется прогресс пагинации:
deps.frontier.setEnumerateProgress(target.userId, first, false, deps.now());
// → UPDATE players SET enumerate_offset = :offset ...При следующем заходе enumeratePlayer стартует с first = target.enumerateOffset, а не с нуля. Так свип кита продолжается с того места, где встал.
Почему
enumerate_offset, а не только курсорКурсор
synced_through_msрешает «дошли ли мы до уже-синхронизированной границы».enumerate_offsetрешает «на какой странице внутри текущего, ещё незавершённого прохода мы стоим». Для кита при первом, полном свипе курсора ещё нет — резюмируемость держится исключительно наenumerate_offset.
При успешном завершении свипа markSynced (src/frontier.ts:125) подчищает за собой: sync_status = 'synced', enumerate_complete = 1, enumerate_offset = 0, пишет synced_through_ms, games_discovered, total_count, обнуляет attempts/next_retry_at/last_error и ставит last_synced_at.
7. Приоритет выборки: claimNext
Кого перечислять следующим, решает Frontier.claimNext (src/frontier.ts:115) — атомарно, в транзакции (tx): выбрать самого приоритетного и тут же пометить in_progress, чтобы два прохода не схватили одного игрока.
SELECT * FROM players
WHERE sync_status = 'pending'
OR (sync_status = 'error' AND (next_retry_at IS NULL OR next_retry_at <= :now))
ORDER BY priority DESC, user_id ASC
LIMIT 1;priority DESC— сначала самые ценные (= с наибольшим известным рейтингом). Сильнейшие игроки обходятся раньше.user_id ASC— детерминированный тай-брейк при равном приоритете.error-строки возвращаются в выборку только когдаnext_retry_atуже наступил (или NULL). Это и есть backoff-гейт: упавший игрок не молотится повторно немедленно, а ждётmarkError→next_retry_at.
8. Машина состояний игрока
stateDiagram-v2 [*] --> pending : upsert (human seed/opponent) [*] --> skipped : upsert (bot/guest) pending --> in_progress : claimNext in_progress --> synced : markSynced (свип завершён) in_progress --> error : markError (transport/JWT/CF) error --> pending : next_retry_at наступил → claimNext synced --> pending : requeueStale (рефреш по threshold) in_progress --> pending : resetInProgress (краш на старте) synced --> [*] skipped --> [*]
Переходы (все — методы Frontier):
pending → in_progress—claimNextсхватил игрока на обход.in_progress → synced—markSynced: свип дошёл до конца / до курсора.in_progress → error—markError: сбой транспорта (включая истёкший JWT, блок Cloudflare). Пишетlast_error,next_retry_at(backoff), инкрементитattempts. Сам backoff-таймер вычисляет вызывающий (crawlFrontier, дефолт 5 минут).error → (pending по факту выборки)— отдельной записи нет: как толькоnext_retry_at <= now,claimNextснова видит строку и берёт её вin_progress.synced → pending—requeueStale(src/frontier.ts:149): возвращает в очередь всехsynced, чейlast_synced_atстарше порога (инкрементальный рефреш). Дальше свип пойдёт инкрементальным, т.к.synced_through_msуже задан.in_progress → pending—resetInProgress(src/frontier.ts:154): на старте сервиса возвращает в очередь всех зависшихin_progress(прерванных крашем), чтобы их подобрали заново.
9. Сквозной поток: один проход перечисления
sequenceDiagram autonumber participant CR as crawlFrontier participant FR as Frontier (players) participant EN as enumeratePlayer participant API as POST /api/player/history participant CAT as Catalog (games) CR->>FR: claimNext(now) FR-->>CR: PlayerRow (in_progress, priority DESC) CR->>EN: enumeratePlayer(target) loop по страницам (newest-first) EN->>API: { PLAYER_ID, START_DATE?, pageSize, first, DATE desc } API-->>EN: gameHistoryList, totalCount loop по строкам страницы Note over EN: startedMs <= cursor? → break (инкремент) EN->>CAT: upsertDiscovered(game) (POV белых) EN->>FR: upsert(opponent, depth+1) end EN->>FR: setEnumerateProgress(first) Note over EN: rows.length < pageSize? → конец end EN->>FR: markSynced(syncedThroughMs, gamesDiscovered, totalCount)
Что происходит с найденными партиями дальше (fetch ходов → нормализация → POST в аналитику → архив сырья) — это конвейер партий, отдельная машина состояний над таблицами games и raw_game_data. См. 05 Конвейер партий и состояния. Здесь же мы только пополнили каталог (upsertDiscovered, идемпотентно, first-perspective-wins) и расширили фронтир (upsert оппонентов).
10. Рычаг pageSize (кратко)
Размер страницы перечисления — главный регулятор количества запросов к самому узкому эндпоинту синхронизации. /api/player/history имеет жёсткий и «липкий» rate-limit (троттл держится десятками минут, это сам сайт, не Cloudflare), а вот скачивание ходов (GET /api/game-move-history) гораздо свободнее.
Поэтому крупная страница = радикально меньше вызовов на полный свип:
ENUMERATE_PAGE_SIZE | Запросов на полный свип кита с 34k партий |
|---|---|
| 50 | ~685 |
| 1000 (проверено в бою) | ~35 |
Не путать 1000 (значение, проверенное в бою) с дефолтом: дефолт ENUMERATE_PAGE_SIZE — 500 (подняли с 50, src/config.ts:42); до 1000 env переопределяет точечно для китов.
Полное лечение rate-limit — на отдельной странице
Спейсинг (
REQUEST_MIN_DELAY_MS, дефолт поднят 1500→3000), джиттер, экспоненциальный backoff, retry-on-throttle (REQUEST_MAX_RETRIES, дефолт 5), часовой бюджет и Cloudflare/TLS-фингерпринт разобраны в 06 Защита от блокировок и Cloudflare. Здесь важно одно:pageSize— твой основной рычаг, чтобы вообще уложиться под лимит перечисления.
Связанные страницы
- 03 Синхронизация — обзор и архитектура — точка входа: два режима добычи, medallion, концепция графа игроков.
- 05 Конвейер партий и состояния — что происходит с найденной партией (fetch → normalize → post → archive).
- 06 Защита от блокировок и Cloudflare — rate-limiter,
pageSize, Cloudflare/TLS, уроки 429. - 07 Контракт ingest и валидация движком — стык с аналитикой, реплей-гейт движком, 422.
- 08 Идентичность, источники и дедупликация —
external_id/source/player_type, боты, first-writer-wins. - 09 Pipeline - dicechess-sync — эксплуатация сервиса: CLI, env, демон-цикл, деплой.
- Где лежит JWT после логина — откуда берётся
DICECHESS_JWT(Bearer дляcurl-запросов). - 01 Структура БД для записи партий Dice Chess — целевая схема аналитики.