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. |
tumbleWin | Cumulative по цепочке tumble-ов внутри одного reveal. | Внутри tumble-loop’а; обнуляется на новый reveal. Полезно для tumbleBanner event-а с глобальным multiplier-ом. |
runningBetWin | Cumulative по всем спинам раунда (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; // сброс на следующий revealWincap-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^-6Cumulative 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.