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, отлаживаем.
Подходящие алгоритмы
| Алгоритм | State | Period | Скорость | Quality | Когда брать |
|---|---|---|---|---|---|
| Mulberry32 | 32 bit | 2³² | ⚡⚡⚡ | OK для casual | Прототип, простой симулятор |
| Xoshiro256++ | 256 bit | 2²⁵⁶−1 | ⚡⚡⚡ | Хорошее | Production-симулятор, высокая нагрузка |
| PCG (PCG32 / PCG64) | 64 / 128 bit | 2⁶⁴ / 2¹²⁸ | ⚡⚡ | Отличное | По умолчанию рекомендуется |
| Splitmix64 | 64 bit | 2⁶⁴ | ⚡⚡⚡ | OK | Init-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.js | crypto.randomBytes(n) или crypto.randomInt(min, max) |
| Browser (если нужно) | crypto.getRandomValues(new Uint32Array(...)) |
| Python | secrets модуль |
| Go | crypto/rand |
| Java | SecureRandom (с SHA1PRNG инициализацией) |
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.