Партии в аналитику пишут четыре независимых писателя: observer, sync (бэкфилл), beturanga-observer и dicechess-extension. Ни один из них не координируется с остальными в момент записи. И всё-таки на выходе мы хотим ровно одну строку на реальную партию и ровно одну строку на реального игрока — иначе вся статистика (win-rate, число партий, сила алгоритма) поедет.
Эта страница — про сквозной контракт, который заставляет четырёх писателей сойтись на одних и тех же идентичностях. Он держится на двух ключах и одном дисциплинирующем правиле.
Два ключа идентичности
games.id (UUID) — ключ идентичности партии. Одна реальная партия → один UUID, у кого бы её ни записали.
players.external_id — ключ идентичности игрока. Одна сущность-игрок → один external_id, и через него — одна строка в players.
Само правило — first-writer-wins: кто первым долетел с данным games.id, тот и определяет строку партии и идентичности её игроков; все последующие писатели по тому же id получают 200 и ничего не перезаписывают (см. 07 Контракт ingest и валидация движком).
1. Ключи идентичности в схеме
В аналитике (Postgres на aurora, 192.168.10.3) обе идентичности закреплены ограничениями целостности в V1__initial_schema.sql:
CREATE TABLE players ( id UUID PRIMARY KEY, external_id VARCHAR(50) UNIQUE, -- ключ идентичности игрока username VARCHAR(50), player_type VARCHAR(10) NOT NULL DEFAULT 'human', metadata_json JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE games ( id UUID PRIMARY KEY, -- ключ идентичности партии source VARCHAR(20) NOT NULL, white_player_id UUID REFERENCES players (id), black_player_id UUID REFERENCES players (id), ...);
players.external_id — UNIQUE. Это и есть точка схождения: писатель присылает строковый external_id, а аналитика резолвит его в players.id (внутренний UUID) через upsertPlayer с ON CONFLICT (external_id). Внутренний players.id — случайный UUID, его никто снаружи не знает и не присылает; единственное, что писатель контролирует, — это external_id.
games.id — PRIMARY KEY. Писатель сам генерирует UUID партии (нативный либо детерминированный, см. ниже) и присылает его; ON CONFLICT (id) DO NOTHING обеспечивает идемпотентность.
Таким образом вся согласованность сводится к одному вопросу: договорились ли все писатели, какой external_id и какой games.id соответствуют одной и той же реальной сущности? Договорились — это и есть конвенция ниже.
2. Конвенция external_id по всем писателям
external_id — гетерогенная строка: в одной и той же колонке VARCHAR(50) живут целые id с dicechess.com, hex-ObjectId с beturanga.com и синтетические bot:<algorithm>. Это нормально — они не пересекаются между собой по форме, поэтому коллизий нет. Вот полная таблица:
Писатель
Сущность
external_id
source
Пример
observer / sync / extension
человек на dicechess.com
String(userId) — положительное целое
dicechess.com
"81542"
observer / sync / extension
сайт-бот dicechess.com
String(userId) — отрицательное целое (нативный id, знак = бот)
dicechess.com
"-42"
только extension
наш движок-бот
bot:<algorithm>
dicechess.com
"bot:aggressive"
beturanga-observer
игрок beturanga.com
сырой Mongo ObjectId (24 hex-символа)
beturanga.com
"6512a0f3…e91b"
И ключ партии:
Источник
games.id (UUID)
Как получен
dicechess.com
нативный UUID партии
сайт уже отдаёт gameId как UUID → прокидываем напрямую
beturanga.com
UUIDv5
детерминированно из ObjectId (Mongo не отдаёт UUID)
dicechess.com — знак id несёт смысл
На dicechess.com у людей положительныйuserId, у сайт-резидентных ботов — отрицательный. Этот знак — и есть пометка «это бот»: нативный id уже канонический, перекодировать его в синтетический bot:* нельзя.
В sync (src/pipeline.ts, buildPlayer) — та же логика, с фолбэком userId < 0 ? 'bot' : 'human', если фронтир ещё не классифицировал игрока.
beturanga.com — ObjectId напрямую, UUIDv5 для партии
beturanga — это Socket.IO-источник, а не REST (см. Beturanga — второй источник); идентичности приходят как Mongo ObjectId. Игрок кладётся напрямую (src/normalize.ts, toPlayerWire):
А вот ключ партии нужно синтезировать: у Mongo нет UUID. Берём детерминированный UUIDv5 в стандартном URL-namespace по канонической строке beturanga.com/game/<objectId> (src/uuid.ts):
Детерминизм здесь — не косметика, а условие идемпотентности: один и тот же ObjectId всегда даёт один и тот же games.id, поэтому повторный заброс той же партии корректно ловится first-writer-wins, а не плодит дубль.
Сайт-боты держат свой нативный отрицательный id; bot:<algorithm> зарезервирован под наш движок
На dicechess.com сайт-бот идентифицируется нативным отрицательным userId ("-42"), и так его пишут все три dicechess-писателя. Синтетическая схема bot:<algorithm> ("bot:aggressive") принадлежит исключительно нашему движку-боту и присваивается только расширением. Смешать эти две схемы — значит либо расщепить одного сайт-бота на несколько строк, либо склеить наш алгоритм с чужим ботом. Именно эту путаницу пришлось чинить миграцией V6 (см. §6).
3. source — откуда пришла партия
Колонка games.source (VARCHAR(20) NOT NULL) маркирует площадку:
"dicechess.com" — пишут observer, sync и extension;
"beturanga.com" — пишет beturanga-observer.
source относится к партии, а не к игроку — в players поля источника нет. Игрок узнаётся только по форме external_id (целое → dicechess, hex → beturanga, bot:… → наш движок).
4. player_type — липкая классификация
players.player_type (NOT NULL DEFAULT 'human') принимает значения, которые расставляют писатели:
bot — по знаку id (userId < 0) у dicechess, по флагу isBot у beturanga, и явно у нашего движка-бота.
guest — добавляет только sync (src/frontier.ts, classify): аккаунты с именем, начинающимся на Guest, — эфемерные, их не краулим как источники (паркуются в skipped).
human — значение по умолчанию для всех остальных.
function classify(userId: number, username: string | null | undefined): PlayerType { if (userId < 0) return 'bot'; if (username && username.startsWith('Guest')) return 'guest'; return 'human';}
player_type фактически first-writer-wins
Апсерт в аналитике (IngestRepository.upsertPlayer) обновляет только username:
ON CONFLICT (external_id)DO UPDATE SET username = COALESCE(EXCLUDED.username, players.username)
player_type ставится только при вставке (player_type берётся из присланного значения или 'human' по умолчанию) и больше не пересматривается. Значит, ошибочная классификация при первом ingest — липкая: если игрок впервые попал гостем или человеком, последующие забросы его уже не «дотипизируют». Это сознательный компромисс (upsert не должен затирать данные первого писателя), но о нём надо помнить при разборе аномалий.
5. Подмена игрока расширением — зачем оно вообще существует
Наблюдатели записывают площадку «как есть». Расширение делает нечто иное и в этом весь его смысл: оно подменяет нашу сторону — вместо хост-аккаунта (под которым физически залогинен браузер) записывает игрока как наш алгоритмbot:<algorithm>. Это нужно, чтобы измерять силу самого алгоритма: какой движок-бот сколько партий выиграл. Это знание невозможно восстановить из сырья сайта — для сайта это обычная партия обычного аккаунта; то, что за доской сидел наш aggressive, знает только расширение в момент игры (см. Расширение dicechess-extension).
В src/gameRecorder.js (_player) три ветки разрешения стороны в идентичность:
_player(id) { if (id == null) return null; // (1) наша сторона → алгоритм, а не хост-аккаунт if (id === this.myPlayerId && this.algorithm && !this.humanPlayed) { return { external_id: `bot:${this.algorithm}`, username: `${cap(this.algorithm)} Bot`, player_type: 'bot', rating: null }; } // (2) сайт-бот (отрицательный id) → нативный id, как у observer if (typeof id === 'number' && id < 0) { return { external_id: String(id), username: SITE_BOT_NAMES[id] ?? `Site Bot ${id}`, player_type: 'bot', rating: null }; } // (3) иначе — человеческий аккаунт const p = this.players[id] || {}; return { external_id: String(id), username: p.username ?? null, player_type: p.type ?? 'human', rating: p.rating ?? null };}
Три важных момента:
Подмена выключается при перехвате человеком. Если человек сыграл за нашу сторону (бот был на паузе — например, появилась цветная/сборная фигура, ход за нашим цветом сделан вручную), connector.js вызывает recorder.markHumanPlayed(), флаг humanPlayed встаёт, и ветка (1) пропускается: партия атрибутируется хост-аккаунту, а не алгоритму. Иначе мы бы записали человеческий ход на счёт бота и испортили статистику силы.
Сайт-боты — по нативному отрицательному id. Ветка (2) совпадает с observer/sync: external_id = String(id). SITE_BOT_NAMES (-40…-43 → DC Coach Rookie/Beginner/Amateur/Master) даёт только дружелюбное имя, но не меняет id.
dicechess gameId уже UUID → расширение прокидывает его напрямую как games.id, и поэтому повторная отправка той же партии идемпотентна.
6. First-writer-wins: гонка observer ↔ extension
Один и тот же dicechess gameId могут запостить observer, sync и extension — это не теоретическая, а реальная ситуация: расширение играет партию, которую наблюдатель видит в общем потоке active-партий. Кто долетел первым, тот определил строку партии и идентичности игроков; повторный POST получает 200 и не перезаписывает (см. 07 Контракт ingest и валидация движком).
Главное следствие — гонка вокруг нашей стороны: observer запишет её как хост-аккаунт (String(userId)), а extension — как bot:<algorithm>. Кто первый, тот и зафиксировал, кем «был» наш игрок в этой партии.
sequenceDiagram
autonumber
participant Ext as extension
participant Obs as observer
participant API as analytics (POST /api/games)
participant DB as Postgres (games / players)
Note over Ext,Obs: одна реальная партия, один gameId (UUID)
Ext->>API: POST gameId=G, наша сторона = bot:aggressive
API->>DB: id=G отсутствует → INSERT
DB-->>API: created
API-->>Ext: 201 Created
Obs->>API: POST gameId=G, наша сторона = String(userId)
API->>DB: id=G уже есть → ON CONFLICT (id) DO NOTHING
DB-->>API: no-op
API-->>Obs: 200 OK (не перезаписано)
Note over DB: партия осталась с bot:aggressive — победил первый писатель
Если бы первым успел observer, та же партия осталась бы за хост-аккаунтом, а заброс extension получил бы 200 и не смог бы переписать сторону на bot:aggressive. Это известный риск: измерение силы алгоритма по партиям, где наблюдатель опередил расширение, «теряет» подмену. Жёсткой синхронизации между писателями нет — мы сознательно платим этим за простоту и идемпотентность.
Идемпотентность ≠ согласие
First-writer-wins гарантирует, что дубля не будет, но не гарантирует, что победит «правильный» писатель. Для большинства партий (человек×человек, человек×сайт-бот) победитель неважен — идентичности у всех писателей совпадают. Расхождение возможно только на нашей стороне в партиях расширения.
7. Миграция V6 — дедуп сайт-ботов
V6__dedupe_site_bots.sql чинит исторический баг: более ранняя версия расширения писала четырёх сайт-ботов «DC Coach» под синтетическими id bot:dc-coach-* (и могла писать bot:site-<n> для прочих), хотя наблюдатель уже писал их по нативному отрицательному id. Результат — две строки players на одного бота: половина его партий висела на синтетической строке, половина на нативной. Статистика бота расщеплялась.
V6 сливает каждую синтетическую строку в её нативного «близнеца»:
flowchart TD
A["строка players с<br/>external_id LIKE 'bot:dc-coach-%'<br/>или 'bot:site-%'"] --> B{"маппится в<br/>нативный id?"}
B -->|"нет (неизвестный вариант)"| S["RAISE NOTICE,<br/>пропустить — не трогать"]
B -->|"да → native_id"| C{"есть строка-близнец<br/>с external_id = native_id?"}
C -->|"нет"| D["UPDATE players SET external_id = native_id<br/>(просто переименовать)"]
C -->|"да (survivor)"| E["UPDATE games: white/black_player_id → survivor<br/>DELETE дубль из players"]
Ключевые гарантии миграции:
Точный матч. Маппинг идёт по строгому списку (bot:dc-coach-rookie и т.п.) плюс регэксп ^bot:site-[0-9]+$. Что не распозналось — резолвится в NULL и пропускается (RAISE NOTICE … skipping unmapped), чтобы будущий неизвестный вариант никогда не нарезался в битый id.
Перецепка партий. Перед удалением дубля все ссылки games.white_player_id / games.black_player_id переводятся на выжившую строку — ни одна партия не теряет игрока.
Наш движок-бот не трогается.bot:<algorithm> (bot:aggressive, bot:montecarlo и т.д.) — это настоящий отдельный игрок, а не сайт-бот, и V6 его сознательно оставляет как есть.
И починка на стороне писателя: текущее расширение уже ключует сайт-ботов по нативному отрицательному id (см. SITE_BOT_NAMES и ветку (2) в §5), так что V6 — разовая зачистка наследия, а не постоянный костыль. Подробнее о понятии — в 🎓 Что такое “дедупликация”.
8. Гетерогенный столбец и отсутствие кросс-сайтовой связки
players.external_id хранит сразу три формы значений в одной колонке:
целые dicechess ("81542", "-42"),
hex-ObjectId beturanga ("6512a0f3…"),
синтетические bot:<algorithm> нашего движка.
Формы не пересекаются (целое ≠ 24-hex ≠ строка с префиксом bot:), поэтому коллизии исключены — столбец безопасно гетерогенный. Но из этого следует важное ограничение:
Кросс-сайтовой связки личностей нет
Один и тот же человек, играющий и на dicechess.com, и на beturanga.com, — это две разные строки в players (целочисленный external_id против ObjectId). Мы не связываем личности между площадками: у нас нет ни общего идентификатора, ни процедуры мэтчинга. Аналитика по игроку всегда в рамках одной площадки. Если когда-нибудь понадобится «единый игрок», это будет отдельный слой поверх players, а не правка external_id.