Структура БД для записи партий Dice Chess

Контекст и масштаб

Мы проектируем базу данных для хранения и аналитики партий в Dice Chess. Основные вводные данные:

  • База данных: PostgreSQL (отлично подходит для планируемых объемов, поддерживает массивы и быстрые хеш-индексы).
  • Масштаб: Ожидается от нескольких миллионов до десятков миллионов партий (импорт 140k уже есть, планируется полная загрузка истории с сервера и генерация партий ботами).
  • Железо: Локальный сервер ASUS (8Gb RAM, 128Gb SSD + 1 Tb HDD). При 10-20 миллионах партий БД может занять десятки гигабайт, что вполне комфортно поместится на SSD (или HDD для архива).
  • Отсутствие промежуточных позиций: Нас интересует только позиция до броска кубиков и позиция в конце хода. Промежуточные микро-ходы (и позиции между ними) не требуют отдельной таблицы и индексов.

Zobrist Hashing vs xxHash64 (Сравнение подходов)

Ты просил объяснить разницу между этими двумя способами хеширования.

Zobrist Hashing

Это стандарт де-факто для шахматных движков (например, в твоем dicechess-engine-scala).

  • Как работает: Для каждой фигуры на каждой клетке генерируется случайное 64-битное число. Также генерируются числа для прав рокировки, en passant и очереди хода. Хеш позиции — это просто побитовое ИСКЛЮЧАЮЩЕЕ ИЛИ (XOR) всех этих чисел.
  • Главный плюс (Инкрементальность): Если мы двигаем коня с b1 на c3, нам не нужно пересчитывать хеш всей доски. Мы берем текущий хеш, делаем XOR числа для “конь на b1” (убираем его) и XOR числа для “конь на c3” (ставим его). Это происходит за наносекунды. Идеально для дерева перебора ходов.
  • Минус для базы данных: Хеш жестко привязан к конкретному набору случайных чисел (таблице Zobrist). Если мы захотим перенести данные в другую систему, другой язык программирования или скрипт аналитики, нам придется везде таскать за собой этот точный массив из сотен случайных чисел. Если массив потеряется или изменится хоть один бит — все хеши в БД станут невалидными.

xxHash64 (или аналоги вроде MurmurHash)

Это современная, универсальная функция хеширования для строк и данных.

  • Как работает: Берет готовую строку (в нашем случае нормализованный FEN) и прогоняет через математический алгоритм, получая 64-битное число.
  • Плюсы для базы данных:
    1. Детерминированность: Строка rnbqkbnr/pppppppp/8/... w KQkq - всегда и везде (в Scala, Python, Postgres, JS) даст один и тот же хеш. Нам не нужно хранить магические таблицы.
    2. В Postgres есть встроенная поддержка эффективных хеш-индексов: CREATE INDEX ... USING hash.
  • Минус: Неинктрементально. Чтобы получить хеш новой позиции, нужно сначала собрать FEN-строку, а потом прогнать ее через алгоритм. Для БД это не проблема, так как мы записываем уже готовые ходы, а не перебираем миллионы вариантов в секунду.

Вывод: Zobrist идеален внутри движка во время поиска лучшего хода (Expectimax). Но когда партия уже сыграна и мы кладем ее в базу для долгосрочного хранения и поиска, лучше взять FEN-строку и построить по ней классический индекс (через xxhash64 или встроенный в Postgres hash), так как это надежнее с точки зрения долговременного хранения.


Обновленная схема БД

Мы избавляемся от таблицы micro_moves. Сами ходы (например, “b1c3, e2e4”) мы можем сохранить просто массивом строк прямо внутри таблицы turns. Это колоссально сэкономит место и ускорит запись/чтение.

erDiagram
    players ||--o{ games : "plays as white/black"
    games ||--o{ turns : "consists of"
    games ||--o{ game_events : "has events"
    positions ||--o{ turns : "referenced by"

    positions {
        bigint id PK
        varchar normalized_fen UK
        bigint fen_hash "INDEX"
        char active_color
        varchar castling
        varchar en_passant
    }

    games {
        uuid id PK
        varchar source
        uuid white_player_id FK
        uuid black_player_id FK
        varchar mode
        smallint result
        varchar termination
        bigint initial_position_id FK
        bigint final_position_id FK
        timestamptz started_at
        jsonb metadata
    }

    turns {
        bigint id PK
        uuid game_id FK
        smallint turn_number
        char active_color
        bigint position_id FK
        varchar dice_sorted
        varchar[] played_moves
        bigint position_after_id FK
    }

    game_events {
        bigint id PK
        uuid game_id FK
        smallint sequence_number
        varchar event_type
        char actor_color
        jsonb payload
    }

1. Таблица positions — дедуплицированный справочник позиций

Хранит только уникальные FEN-строки до кубиков.

CREATE TABLE positions (
    id              BIGSERIAL PRIMARY KEY,
    -- Первые 4 поля DFEN БЕЗ halfmove clock и fullmove counter
    normalized_fen  VARCHAR(100) NOT NULL,
    -- Хеш для сверхбыстрого точечного поиска
    fen_hash        BIGINT NOT NULL,
 
    -- Для специфичных аналитических запросов
    piece_placement VARCHAR(72) NOT NULL,
    active_color    CHAR(1)     NOT NULL,
    castling        VARCHAR(4)  NOT NULL DEFAULT '-',
    en_passant      VARCHAR(12) NOT NULL DEFAULT '-',
 
    CONSTRAINT uq_normalized_fen UNIQUE (normalized_fen)
);
 
CREATE INDEX idx_positions_hash ON positions USING hash (fen_hash);
-- Опционально: индекс для поиска просто по расстановке фигур
CREATE INDEX idx_positions_placement ON positions (piece_placement);

2. Таблица games — мастер-запись о партии

CREATE TABLE games (
    id                  UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    source              VARCHAR(20)  NOT NULL, -- 'online', 'bot', 'imported'
    white_player_id     UUID REFERENCES players(id),
    black_player_id     UUID REFERENCES players(id),
    mode                VARCHAR(10)  NOT NULL DEFAULT 'classic',
 
    result              SMALLINT,    -- 1 = белые, -1 = чёрные, 0 = ничья
    termination         VARCHAR(20), -- 'king_lost', 'timeout', 'resignation', etc.
 
    initial_position_id BIGINT REFERENCES positions(id),
    final_position_id   BIGINT REFERENCES positions(id),
 
    total_turns         SMALLINT,
    started_at          TIMESTAMPTZ,
 
    metadata            JSONB,       -- Вся специфика (time limits, ratings, ext IDs)
 
    created_at          TIMESTAMPTZ NOT NULL DEFAULT now()
);
 
CREATE INDEX idx_games_started   ON games (started_at);
CREATE INDEX idx_games_players   ON games (white_player_id, black_player_id);

3. Таблица turns — Оптимизированная история ходов

Каждая строка — это один полный ход игрока (позиция до, бросок, действия, позиция после).

CREATE TABLE turns (
    id                  BIGSERIAL PRIMARY KEY,
    game_id             UUID     NOT NULL REFERENCES games(id) ON DELETE CASCADE,
    turn_number         SMALLINT NOT NULL,
 
    active_color        CHAR(1)  NOT NULL,
 
    -- Позиция НА НАЧАЛО хода (до броска кубиков)
    position_id         BIGINT   NOT NULL REFERENCES positions(id),
 
    -- Отсортированные кубики для индексированного поиска ("BNP", "KQR", "-")
    dice_sorted         VARCHAR(3) NOT NULL,
 
    -- Массив микро-ходов, сыгранных в этот ход (например: ARRAY['b1c3', 'e2e4'])
    -- Пустой массив, если ход пропущен
    played_moves        VARCHAR(5)[],
 
    -- Позиция ПОСЛЕ завершения всех микро-ходов
    position_after_id   BIGINT   REFERENCES positions(id),
 
    -- Опционально: время размышления
    thinking_time_ms    INT,
 
    CONSTRAINT uq_game_turn UNIQUE (game_id, turn_number)
);
 
-- ★ КЛЮЧЕВОЙ ИНДЕКС: поиск по стартовой позиции хода
CREATE INDEX idx_turns_position ON turns (position_id);
 
-- ★ КЛЮЧЕВОЙ ИНДЕКС: поиск по стартовой позиции + кубикам
CREATE INDEX idx_turns_pos_dice ON turns (position_id, dice_sorted);

4. Таблица game_events — тайминги, удвоения, ничьи

CREATE TABLE game_events (
    id                  BIGSERIAL PRIMARY KEY,
    game_id             UUID       NOT NULL REFERENCES games(id) ON DELETE CASCADE,
    sequence_number     SMALLINT   NOT NULL,
    turn_number         SMALLINT,
 
    event_type          VARCHAR(20) NOT NULL, -- 'double_offer', 'resignation', etc.
    actor_color         CHAR(1),
 
    clock_white_ms      INT,
    clock_black_ms      INT,
 
    payload             JSONB,
 
    CONSTRAINT uq_game_event UNIQUE (game_id, sequence_number)
);

Оценка объёма данных (10 миллионов партий)

Убрав micro_moves и упаковав их в VARCHAR[] массив внутри turns, мы радикально сжимаем базу данных.

ТаблицаНа 10,000,000 партий (Оценка)
games~5 GB
turns (по ~30 ходов на партию)300M строк, ~25 GB
game_events30M строк, ~2 GB
positionsДопустим, 50M уникальных позиций, ~6 GB
Индексы Postgres~15-20 GB
Итоговый размер БД~50 - 60 GB

Это превосходно ляжет на 128Gb SSD локального сервера ASUS. Запросы по поиску конкретной позиции будут отрабатывать за миллисекунды за счет хеш-индекса в positions и B-tree индексов в turns.

User Review Required

Если эта архитектура (с удаленной таблицей micro_moves и использованием VARCHAR[] в turns) тебя полностью устраивает, мы можем считать фазу проектирования БД успешно завершенной. Останется только в будущем написать скрипты, которые возьмут 140k исходных JSON файлов с сайта и аккуратно зальют их в этот PostgreSQL.