🕸️ Граф игроков и перечисление

Эта страница про игроцкую сторону синхронизации — про то, как 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_idINTEGER PKdicechess userId. Отрицательный = сайт-бот.
usernameTEXTИмя (из истории/лидерборда). Guest… → гость.
player_typeTEXT NOT NULL 'human'human | bot | guest. Краулим только human.
rating_classicINTEGERРейтинг classic (из /api/leaderboard).
rating_x2INTEGERРейтинг x2 (из /api/x2Leaderboard).
discovered_ratingINTEGERСнимок рейтинга в момент, когда игрок найден как оппонент.
priorityINTEGER NOT NULL 0max известный рейтинг → порядок выборки из фронтира.
total_countINTEGERtotalCount из player/history — цель полноты (сколько партий у игрока всего).
games_discoveredINTEGER NOT NULL 0Сколько строк истории мы уже обработали (накопительно).
sync_statusTEXT NOT NULL 'pending'pending | in_progress | synced | error | skipped.
enumerate_offsetINTEGER NOT NULL 0Курсор first (offset пагинации) для возобновления прерванного свипа.
enumerate_completeINTEGER NOT NULL 00/1: полный проход хоть раз завершён до конца.
synced_through_msINTEGERmax startTime (unix ms) — нижняя граница для следующего инкрементального свипа (→ START_DATE).
discovered_viaTEXTleaderboard | x2_leaderboard | opponent | tournament | manual.
depthINTEGER NOT NULL 0Глубина BFS от сида.
attemptsINTEGER NOT NULL 0Счётчик неуспешных попыток (для backoff).
last_errorTEXTТекст последней ошибки.
next_retry_atTEXTISO — не трогать до этого момента (backoff-гейт для error).
last_synced_atTEXTISO — последний успешный проход (для requeueStale).
last_attempt_atTEXTISO — когда последний раз пытались.
created_at / updated_atTEXT NOT NULLISO-таймстемпы, авто-дефолт.

Два индекса под две горячие выборки:

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-гейт: упавший игрок не молотится повторно немедленно, а ждёт markErrornext_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_progressclaimNext схватил игрока на обход.
  • in_progress → syncedmarkSynced: свип дошёл до конца / до курсора.
  • in_progress → errormarkError: сбой транспорта (включая истёкший JWT, блок Cloudflare). Пишет last_error, next_retry_at (backoff), инкрементит attempts. Сам backoff-таймер вычисляет вызывающий (crawlFrontier, дефолт 5 минут).
  • error → (pending по факту выборки) — отдельной записи нет: как только next_retry_at <= now, claimNext снова видит строку и берёт её в in_progress.
  • synced → pendingrequeueStale (src/frontier.ts:149): возвращает в очередь всех synced, чей last_synced_at старше порога (инкрементальный рефреш). Дальше свип пойдёт инкрементальным, т.к. synced_through_ms уже задан.
  • in_progress → pendingresetInProgress (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_SIZE500 (подняли с 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 — твой основной рычаг, чтобы вообще уложиться под лимит перечисления.


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