Wallet manager pattern

Паттерн явного многоуровневого учёта выигрышей внутри одного раунда. Внешний payoutMultiplier — это итог, а внутри математики нужно отдельно знать «выигрыш этого спина», «вклад tumble-цепочки», «вклад basegame и freegame отдельно», «cumulative по всем симуляциям». Если эти уровни смешаны — отлаживать игру с tumble + freegame + multipliers становится мучением.

Идиома взята из Stake math-sdk (src/wins/win_manager.py); универсальна для real-time бэка и оффлайн-симулятора.

Уровни кошелька

УровеньЧто считаетКогда обновляется
spinWinВыигрыш одного reveal (одного board-reveal без tumble).На каждый evaluateWins, обнуляется на новый reveal.
tumbleWinCumulative по цепочке tumble-ов внутри одного reveal.Внутри tumble-loop’а; обнуляется на новый reveal. Полезно для tumbleBanner event-а с глобальным multiplier-ом.
runningBetWinCumulative по всем спинам раунда (basegame + все freegame-spins).После каждого spinWin. Финальное значение = payoutMultiplier.
basegameWins / freegameWinsРазбивка runningBetWin по типу спина.На границе перехода basegame → freegame и в конце каждого freegame-spin.
cumulative*Wins (total, base, free)По всему батчу симуляций.Только в симуляторе, по imprintWins после каждого принятого раунда. Используется для runtime RTP-printout в терминале.

Жёсткий инвариант

assert runningBetWin === basegameWins + freegameWins

Проверяется в evaluateFinalWin() в конце каждого раунда. Если расходится — RuntimeError (или throw). Любое расхождение — это бажная логика: где-то спин обновил один счётчик, не обновив другой; или multiplier применился двойно; или freegame-trigger не проставил gametype = "freegame" корректно.

Этот invariant — главный страж корректности математики на этапе симуляции. Property-based тесты с миллионами random board-ов на нём ловят 80% багов.

Жизненный цикл в runSpin

Канва (по Stake 0_0_lines/gamestate.py):

function runSpin(sim, ctx) {
  ctx.rng.seed(sim);
  ctx.repeat = true;
  while (ctx.repeat) {
    ctx.resetBook();             // wallet.spinWin = 0; runningBetWin = 0; basegameWins = 0; freegameWins = 0; tumbleWin = 0
    ctx.drawBoard();
    evaluateLines(ctx);          // wallet.updateSpinWin(winData.totalWin) → runningBetWin += spinWin
    emitLineWinEvents(ctx);
    ctx.wallet.updateGameTypeWins(ctx.gametype);  // basegameWins += spinWin (на границе)
    if (checkFsCondition(ctx)) {
      runFreespinFromBase(ctx);  // внутри: gametype = "freegame", серия freespin'ов, на каждом updateGameTypeWins(freegame)
    }
    ctx.evaluateFinalWin();      // assert runningBetWin === basegameWins + freegameWins
    ctx.checkRepeat();
  }
  ctx.imprintWins();             // wallet.updateEndRoundWins(): cumulative+=, library.append(book), force-records flush
}

В freegame-цикле:

function runFreespin(ctx) {
  ctx.resetFsSpin();             // gametype = "freegame", spinWin = 0
  while (ctx.fs < ctx.totFs) {
    ctx.updateFreespin();        // ctx.fs += 1, emit spin counter event
    ctx.drawBoard();             // freegame reelstrip
    evaluateWins(ctx);           // wallet.updateSpinWin → runningBetWin += spinWin
    if (checkFsCondition(ctx)) ctx.updateFsRetriggerAmt();  // retrigger freespin-ов
    ctx.wallet.updateGameTypeWins(ctx.gametype);            // freegameWins += spinWin
  }
  ctx.endFreespin();             // emit final freegame summary event
}

Tumble-механика добавляет внутренний цикл (см. cascading-tumble):

while (winData.totalWin > 0 && !wincapTriggered) {
  ctx.tumbleGameBoard();
  winData = evaluateWins(ctx);
  ctx.wallet.updateSpinWin(winData.totalWin);   // spinWin += вклад tumble
  ctx.wallet.tumbleWin += winData.totalWin;     // tumble-cumulative для banner-а
  emitTumbleWinEvents(ctx);
}
emitTumbleBanner(ctx, ctx.wallet.tumbleWin);
ctx.wallet.tumbleWin = 0;                        // сброс на следующий reveal

Wincap-cap

evaluateWincap() после каждого update проверяет:

if (runningBetWin >= maxWin) {
  runningBetWin = maxWin;       // hard cap
  wincapTriggered = true;
  emit({type: "wincap", amount: maxWin});
}

Дальше игра не должна добавлять новые выигрыши: tumble-loop проверяет !wincapTriggered, freegame-loop — тоже. Это нужно явно проверить тестами; забыть = шанс на > maxWin payout, что ломает cert.

Money-формат: bigint × 10^−6

Внутри wallet и в API — integer micro-units, как в Stake RGS:

1.00 USD == 1_000_000n
0.10 USD == 100_000n

Преимущества:

  • Ноль floating-point дрейфа на больших суммах симуляций.
  • Безопасный assert-инвариант (равенство bigint детерминировано).
  • Совместимо с типичными RGS-форматами (Stake, open-rgs-go).

Реализация на TS:

type MicroAmount = bigint;          // 10^-6 базовой валюты
 
class WalletManager {
  spinWin: MicroAmount = 0n;
  runningBetWin: MicroAmount = 0n;
  basegameWins: MicroAmount = 0n;
  freegameWins: MicroAmount = 0n;
  tumbleWin: MicroAmount = 0n;
 
  updateSpinWin(amount: MicroAmount) {
    this.spinWin += amount;
    this.runningBetWin += amount;
  }
 
  updateGameTypeWins(gametype: "basegame" | "freegame") {
    if (gametype === "basegame") this.basegameWins += this.spinWin;
    else this.freegameWins += this.spinWin;
    this.spinWin = 0n;
  }
 
  evaluateFinalWin(maxWin: MicroAmount) {
    if (this.runningBetWin > maxWin) this.runningBetWin = maxWin;
    if (this.runningBetWin !== this.basegameWins + this.freegameWins) {
      throw new Error(
        `Wallet invariant: ${this.runningBetWin} !== ${this.basegameWins + this.freegameWins}`,
      );
    }
  }
}

Конвертация в payoutMultiplier для записи в книгу:

const payoutMultiplier = wallet.runningBetWin / betCost;  // bigint, всё кратное 10^-6

Cumulative vs per-round

Cumulative-счётчики (cumulativeBaseWins, cumulativeFreeWins, totalCumulativeWins) обновляются только при принятии раунда (imprintWins). Это даёт корректный runtime-RTP в терминале симулятора:

Thread 0 finished with 1.632 RTP. [baseGame: 0.043, freeGame: 1.588]

( 163.2% RTP на этом потоке симуляций — типично для bonus-mode ДО оптимизации lookup-table; см. lookup-table optimization.)

Для real-time бэка cumulative-счётчики живут в Datadog/Clickhouse, а не в process memory. Но архитектурный паттерн «считаем basegame_wins и freegame_wins отдельно на каждом раунде» тот же — это нужно для корректного RTP-сплита в reporting и cert-evidence.

Связанные страницы