🎲 Честность кубиков — провабли-фейр и верификация
Статус: реализовано · 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.
Связанное
- ADR-0008 — решение «почему commit-reveal, не блокчейн».
- Фаза 3 · Бот-платформа.
- Популярное изложение для игроков — статья в блоге «Can You Trust the Dice?» (rabestro.github.io).