Seedable PRNG для симуляции, CSPRNG для прода

В нашем real-time бэке (см. slot backend architecture) два разных RNG для двух разных задач. Их перепутать — типичная ошибка, дорогая в обнаружении.

Симулятор: seedable PRNG

В оффлайн-симуляции нужен детерминированный RNG: seed → identical sequence. Это даёт:

  • Replay: тот же sim_id → тот же seed → точный исход; cert-аудит и dispute resolution.
  • CI-baseline: фиксированный seed-сет → одинаковый RTP при тех же reelstrip-ах. Регрессия видна сразу.
  • Воспроизводимость багов: «упало на sim=42» → крутим только sim=42, отлаживаем.

Подходящие алгоритмы

АлгоритмStatePeriodСкоростьQualityКогда брать
Mulberry3232 bit2³²⚡⚡⚡OK для casualПрототип, простой симулятор
Xoshiro256++256 bit2²⁵⁶−1⚡⚡⚡ХорошееProduction-симулятор, высокая нагрузка
PCG (PCG32 / PCG64)64 / 128 bit2⁶⁴ / 2¹²⁸⚡⚡ОтличноеПо умолчанию рекомендуется
Splitmix6464 bit2⁶⁴⚡⚡⚡OKInit-helper для других PRNG

Warning

Math.random() нельзя: он не seedable, реализация зависит от engine, нет гарантии воспроизводимости между Node-версиями. Для симулятора — pure no-go.

Mulberry32 (минимальная имплементация)

function mulberry32(seed: number) {
  return function () {
    seed = (seed + 0x6D2B79F5) | 0;
    let t = seed;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

PCG32 (рекомендация для prod-симулятора)

Готовые npm-пакеты: pure-rand (рекомендуется, MIT, поддерживает PCG32 / xoroshiro128+ / xorshift), random-js (PCG-подобные, mersenne twister).

Использование в runSpin

class GameContext {
  rng: () => number;
  seedRng(sim: number) {
    this.rng = mulberry32(sim);  // или pcg32(sim)
  }
}
 
function runSpin(sim: number, ctx: GameContext) {
  ctx.seedRng(sim);
  // ... все RNG-draws — только через ctx.rng()
}

sim_id уникально определяет всю последовательность draws за раунд.

Scaling в индекс

Чтобы из [0, 1) получить индекс символа без bias:

function pickIndex(weights: number[], rng: () => number): number {
  const total = weights.reduce((a, b) => a + b, 0);
  let r = rng() * total;
  for (let i = 0; i < weights.length; i++) {
    if (r < weights[i]) return i;
    r -= weights[i];
  }
  return weights.length - 1;
}

Для частого вызова — cumsum + binary search, O(log n):

class WeightedPicker {
  cum: number[];
  constructor(weights: number[]) {
    this.cum = [];
    let s = 0;
    for (const w of weights) { s += w; this.cum.push(s); }
  }
  pick(rng: () => number): number {
    const r = rng() * this.cum[this.cum.length - 1];
    return binarySearch(this.cum, r);
  }
}

Прод: CSPRNG

На проде RGS использует cryptographically secure RNG. NIST SP 800-22 / UKGC RTS 7 / GLI-19 требуют unpredictable, non-reversible output. PRNG вроде PCG здесь нельзя — даже хороший period не гарантирует unpredictability.

Реализация

СредаAPI
Node.jscrypto.randomBytes(n) или crypto.randomInt(min, max)
Browser (если нужно)crypto.getRandomValues(new Uint32Array(...))
Pythonsecrets модуль
Gocrypto/rand
JavaSecureRandomSHA1PRNG инициализацией)

Important

crypto.randomBytes дороже PCG (system call под капотом). Для слотов это не bottleneck — на спин типично 30–50 draws, миллисекунды. Для high-throughput crash/dice — измерить и при необходимости batched (randomFillSync сразу на много draws).

Replay-долг

CSPRNG не reversible — невозможно «отмотать» к началу раунда. Поэтому replay требует одного из:

Вариант A — Seed на раунд

При старте раунда генерируется seed = randomBytes(32). Затем используется как seed для детерминированного PRNG для всех draws этого раунда:

function startRound(): RoundResult {
  const seed = crypto.randomBytes(32);
  const rng = pcg32FromBytes(seed);
  // ... runSpin использует rng()
  return {seed, payout, events};
}

Это похоже на provable fairness в crypto-играх (server seed + client seed). Плюс: replay = просто пересчитать раунд по seed.

Note

Внимание! Этот подход технически использует PRNG (детерминированный) с seed-ом из CSPRNG. Это acceptable для лабораторий (NIST-friendly), потому что seed unpredictable. Но нужно явно зафиксировать в cert-документации, что seed source = system CSPRNG.

Вариант B — Лог всех draws

Каждый rng() вызов пишется в audit-log:

function audit_rng(): number {
  const r = crypto.randomInt(0, 0xFFFFFFFF) / 0xFFFFFFFF;
  auditLog.push({roundId, drawIndex: drawIndex++, value: r});
  return r;
}

Replay = взять draws из лога и подменить rng на «итератор по логу».

Этот подход дороже по storage и сложнее реверс-инженерить, но не требует выделенного PRNG. Используется в сложных flows типа crash-игр, где «один seed на раунд» неудобен.

Рекомендация для нашего стека

Вариант A (seed на раунд + PCG32 для draws):

  • Простой replay по seed.
  • CSPRNG-source гарантирует unpredictability.
  • Audit-log меньше (только seed, не каждый draw).
  • Совместимо с result determination paradigm: seed = «commit» точка, исход = детерминированная функция от seed.
  • Хорошо ложится на provable fairness схему, если когда-то решим её внедрить.

Compliance

  • RNG requirements — UKGC RTS 7: «acceptably random», «no compensated/adaptive behavior».
  • Result determination — RTS 5: deterministic settle, fair correction.
  • NIST SP 800-22 — суит статтестов, который лаборатория обычно прогоняет на наш RNG-output.
  • Лаборатория проверит:
    • Что prod RNG = CSPRNG (исходник + конфиг).
    • Что симуляционный PRNG используется только в pre-deploy (CI / cert-evidence).
    • Что они изолированы (нельзя случайно дёрнуть mulberry32 на проде).

Warning

Изоляция — typed по-разному, или явный feature-flag, или вообще разные пакеты (@game/sim-rng и @game/prod-rng). Тестом property проверять, что прод-кодовая база не импортирует sim-rng.

Связанные