🎲 Честность кубиков — провабли-фейр и верификация

Статус: реализовано · play-api v0.4.0 · 2026-06-30. Это as-built описание — как работает и как проверить. Обоснование выбора (почему commit-reveal, а не голый CSPRNG или блокчейн) — в ADR-0008; место в общей картине — Фаза 3 и Бот-платформа.

Кости в Dice Chess — это вход хода: сперва бросок, потом игрок реагирует. Когда авторитет над броском переехал на сервер (см. ADR-0007), появился вопрос доверия к самому серверу: подкрученный бросок невидим. Ответ — провабли-фейр: сервер не может сместить кости, игрок не может их «намолоть», и никому не нужно верить на слово — любой пересчитывает всю партию постфактум.

Модель угрозы

Две наивные схемы не работают:

  • Кости на клиенте — читер с патченым клиентом накрутит себе нужные значения. В HvH неприемлемо.
  • Кости на сервере + «доверьтесь нам» — непроверяемо, а непроверяемая честность — это не честность.

Нужны кости, которые сервер не может сместить, а игрок не может намолоть, без взаимного доверия. Это даёт классический commit-reveal с одной добавкой — клиентской энтропией.

Как это работает

Аналогия: сервер пишет секрет на карточке, запечатывает в конверт и отдаёт игрокам запечатанный конверт до старта. Изменить карточку уже нельзя (конверт у игроков), подглядеть — тоже. Затем оба игрока подкидывают по щепотке своей случайности; каждый бросок считается из серверного секрета и обоих клиентских вкладов. В конце партии конверт вскрывается — любой пересчитывает математику.

sequenceDiagram
    participant W as Белые
    participant S as Сервер
    participant B as Чёрные
    S->>S: генерирует serverSeed (32 байта, CSPRNG)
    S-->>W: commit = SHA-256(serverSeed)
    S-->>B: commit = SHA-256(serverSeed)
    Note over S: запечатано — seed уже не изменить
    W->>S: clientSeedW (случайный)
    B->>S: clientSeedB (случайный)
    Note over S: каждый бросок = HMAC(serverSeed, W, B, ply)
    S-->>W: кости, ход за ходом
    S-->>B: кости, ход за ходом
    Note over S,B: ...партия заканчивается...
    S-->>W: раскрывает serverSeed + оба клиентских сида
    Note over W,B: любой пересчитывает всю партию

Добавка — клиентские сиды — закрывает последнюю лазейку. Один commit-reveal доказывает лишь, что сервер не менял seed; но сервер мог выбрать seed, зная, что тот даст выгодные броски. Клиентская энтропия приходит после фиксации commit, поэтому сервер коммитится вслепую — он не знает будущих костей в момент запечатывания.

Точный алгоритм (as-built)

Три примитива, без блокчейна. Реализация — swappable-интерфейс DiceSource в репозитории dicechess-play-api (src/main/scala/dicechess/play/dice/DiceSource.scala).

1. Commit. При создании партии сервер генерирует 32-байтный serverSeed (CSPRNG) и публикует хэш:

commit отдаётся сразу — в ответе на создание партии и на каждом Snapshot (чтобы бот, подключившийся только к game-стриму, тоже видел его до первого броска).

2. Contribute & roll. После commit каждое место присылает свой clientSeed. Бросок для хода ply — это HMAC серверного сида по length-prefixed сообщению из обоих клиентских сидов и индекса хода:

где — UTF-8-байты клиентских сидов, — длина в байтах, — конкатенация, -битное big-endian.

Зачем length-prefix

Без префикса длины пары ("a|b","c") и ("a","b|c") дали бы одинаковое сообщение. Префикс длины делает разбиение между сидами игроков однозначным.

Байты HMAC превращаются в три кости rejection sampling: байт отбрасывается, иначе кость . Здесь — наибольшее кратное шести в пределах байта; отбрасывание убирает смещение по модулю (иначе значения 1–4 были бы чуть вероятнее). Если блока не хватило на три кости, берётся следующий HMAC-блок с 32-битным счётчиком в хвосте сообщения.

3. Reveal. В GameEnded (и в терминальном Snapshot) сервер отдаёт serverSeed и оба clientSeeds. Конверт вскрыт — вся партия проверяема.

Гейт первого броска

Чтобы клиентские сиды реально ограничивали сервер, они обязаны прийти после commit — иначе сервер намолол бы seed под них. Поэтому комната придерживает первый бросок, пока оба места не пришлют сид.

  • Реализация — в единой consumer-петле GameRoom (game/GameRoom.scala): состояние гейта (awaitingSeeds) считается через тот же дедлайн-механизм, что и часы (deadlineFor / onTimeout), без отдельного фибера.
  • Grace + fallback: если место не прислало сид за DefaultSeedGrace = 5s, партия force-стартует, а недостающее место фолбэкается на свой публичный externalId. Серверный seed всё равно закоммичен → кости негриндабельны; теряется лишь вклад энтропии этого места. Партия никогда не зависает.

На проводе

Всё forward-compatible (новые поля/команда; старые клиенты их игнорируют):

  • commit — на каждом PublicGameState/Snapshot и в CreatedGame.
  • GameCommand.SubmitSeed(seed) — по WS; для ботов — POST /bot/game/{id}/seed. Сид 16..256 символов.
  • GameEvent.GameEnded(…, clientSeeds) и терминальный Snapshot.clientSeeds — раскрытие (seed + оба клиентских сида).

Wire-контракт для сторонних ботов задокументирован в dicechess-play-api/docs/bot-api.md (раздел Provably-Fair Dice + endpoint POST /bot/game/{id}/seed).

Верификация — пошагово

Реальная партия с прода. На старте сервер опубликовал:

commit = 7e3ad1923d05b412c56ee52bcc6de5a79f1f21aa5aab4fe41d3fbcd11df2136f

В конце раскрыл:

serverSeed  = 5b4c290e960e3c3a8c4cce633d38b04e136fae6c0e453f5a8dd3b9640157c7c6
clientSeeds = { white: "bot:team:anon:verifier-aa3534dd",
                black: "4c4bda73e3a467f691a6570ee047983b" }

Две проверки, нужна только стандартная библиотека Python:

import hashlib, hmac, struct
 
SEED   = "5b4c290e960e3c3a8c4cce633d38b04e136fae6c0e453f5a8dd3b9640157c7c6"
COMMIT = "7e3ad1923d05b412c56ee52bcc6de5a79f1f21aa5aab4fe41d3fbcd11df2136f"
WHITE  = "bot:team:anon:verifier-aa3534dd"
BLACK  = "4c4bda73e3a467f691a6570ee047983b"
 
def roll(server_seed_hex, client_w, client_b, ply):
    seed = bytes.fromhex(server_seed_hex)
    w, b = client_w.encode(), client_b.encode()
    # length-prefixed сообщение: be32(len W) ++ W ++ be32(len B) ++ B ++ be64(ply)
    msg = struct.pack(">I", len(w)) + w + struct.pack(">I", len(b)) + b + struct.pack(">q", ply)
    dice, block = [], 0
    while len(dice) < 3:
        h = hmac.new(seed, msg + struct.pack(">i", block), hashlib.sha256).digest()
        for byte in h:
            if len(dice) == 3:
                break
            if byte < 252:                 # rejection: убираем modulo-bias
                dice.append(byte % 6 + 1)
        block += 1
    return dice
 
# 1. Совпадает ли раскрытый seed с коммитом?
print("commitment opens:", hashlib.sha256(bytes.fromhex(SEED)).hexdigest() == COMMIT)
 
# 2. Пересчитать кости первых ходов и сверить с тем, что видел на доске.
for ply in range(4):
    print(f"roll(ply={ply}) =", roll(SEED, WHITE, BLACK, ply))

Вывод:

commitment opens: True
roll(ply=0) = [6, 3, 1]
roll(ply=1) = [6, 6, 5]
roll(ply=2) = [3, 3, 1]
roll(ply=3) = [1, 4, 6]

Первая строка доказывает, что сервер раскрыл тот же seed, что запечатал; остальное — точные кости партии для сверки с DiceRolled-событиями. Одно несовпадение = пойман читер.

Почему у белых сид — это идентификатор, а не hex

Это фолбэк для места, не приславшего свой сид до истечения grace (см. «Гейт первого броска»). В смоуке белым был сырой curl-«бот», не успевший засидиться; чёрными — house-бот (reference-bot), приславший настоящий 32-hex сид.

Что гарантируется, а что нет

Точная формулировка гарантии

Доказуемо: сервер закоммитил seed до того, как увидел клиентские сиды, не менял его и произвёл каждый бросок по опубликованной формуле. Смещённый или багнутый оператор ловится постфактум, бесплатно. НЕ доказывается по отдельности: что серверный seed сам по себе взят из хорошей случайности. Гарантия — в комбинации: commit сделан до появления клиентских сидов + энтропия игроков после. Именно это делает «оператор подобрал выгодный seed» невозможным, даже без доверия к RNG сервера.

Реализация

  • play-api — часть 1 (#56, раскрытие serverSeed) + часть 2 (#57, клиентская энтропия + гейт) → релиз v0.4.0, в проде на play-api.jc.id.lv.
  • клиент dicechess-play (#30) — сидящий игрок шлёт SubmitSeed при входе; зритель — нет.
  • reference-bot (#13) — шлёт сид при старте игры (покрывает и house-бота); reveal показывает его настоящий 32-hex сид.
  • Прод-смоук end-to-end пройден: гейт держит первый бросок, force-start работает, reveal раскрывает seed+clientSeeds, SHA-256(seed)==commit — MATCH.

Связанное