package main import ( "encoding/csv" "flag" "fmt" "log" "math" "math/rand" "os" "sort" "strings" ) const ppmScale int64 = 1_000_000 type poolKind string const ( poolNovice poolKind = "novice" poolIntermediate poolKind = "intermediate" poolAdvanced poolKind = "advanced" ) type tier struct { id string reward int64 weight int64 } // poolProfile is a UX profile, not a money owner. RTP is enforced by the // controller; these weights only decide which payable tier is preferred. type poolProfile struct { kind poolKind maxReward int64 tiers []tier } type drawOutcome struct { tierID string reward int64 correction bool } // rtpController keeps one aggregate payout budget per fixed paid-draw window. // It filters before random selection, so the demo never rewrites a result // after the tier has been selected. type rtpController struct { windowSize int64 cost int64 rtpPPM int64 minFutureMax int64 carryMicros int64 windowIndex int64 remainingDraws int64 remainingPayout int64 } type userStat struct { targetDraws int64 draws int64 wager int64 payout int64 hits int64 maxWin int64 balance int64 topUp int64 stopped bool } type aggregateStat struct { draws int64 wager int64 payout int64 hits int64 maxWin int64 topUp int64 correctionHits int64 correctionPayout int64 stoppedUsers int64 } type blockTracker struct { size int64 lastDraws int64 lastWager int64 lastPayout int64 closedBlocks int64 maxAbsDevPP float64 lastBlockDevPP float64 } func main() { usersFlag := flag.Int("users", 1000, "number of simulated users") drawsFlag := flag.Int64("draws", 100000, "paid draw attempts per user") drawsMinFlag := flag.Int64("draws-min", 0, "optional minimum paid draw attempts per user") drawsMaxFlag := flag.Int64("draws-max", 0, "optional maximum paid draw attempts per user") rtpFlag := flag.Float64("rtp", 95, "target RTP as percent, so 95 means 95%") initialFlag := flag.Int64("initial", 10000, "initial coins per user") costFlag := flag.Int64("cost", 500, "coin cost per paid draw") windowFlag := flag.Int64("window", 100000, "RTP control window paid draws") noviceLimitFlag := flag.Int64("novice-draws", 2000, "inclusive paid draw count for novice pool") intermediateLimitFlag := flag.Int64("intermediate-draws", 20000, "inclusive paid draw count for intermediate pool") seedFlag := flag.Int64("seed", 20260516, "deterministic random seed") strictWalletFlag := flag.Bool("strict-wallet", false, "stop a user when balance is below draw cost instead of adding test top-up") userCSVFlag := flag.String("user-csv", "", "optional path to write per-user RTP and final balance CSV") flag.Parse() if *usersFlag <= 0 || *initialFlag < 0 || *costFlag <= 0 || *windowFlag <= 0 { log.Fatal("users, cost, and window must be positive; initial must be non-negative") } randomDraws := *drawsMinFlag > 0 || *drawsMaxFlag > 0 if randomDraws { if *drawsMinFlag <= 0 || *drawsMaxFlag < *drawsMinFlag { log.Fatal("draws-min and draws-max must satisfy 0 < draws-min <= draws-max") } } else if *drawsFlag <= 0 { log.Fatal("draws must be positive when draws-min/draws-max are not set") } if *noviceLimitFlag <= 0 || *intermediateLimitFlag < *noviceLimitFlag { log.Fatal("pool limits must satisfy 0 < novice-draws <= intermediate-draws") } rtpPPM := percentToPPM(*rtpFlag) if rtpPPM < 0 { log.Fatal("rtp must be non-negative") } profiles := defaultProfiles(*costFlag) minFutureMax := minProfileMaxReward(profiles) if err := validateRTPCapacity(*costFlag, rtpPPM, minFutureMax); err != nil { log.Fatal(err) } rng := rand.New(rand.NewSource(*seedFlag)) controller := newRTPController(*windowFlag, *costFlag, rtpPPM, minFutureMax) users := make([]userStat, *usersFlag) for i := range users { users[i].balance = *initialFlag users[i].targetDraws = *drawsFlag if randomDraws { users[i].targetDraws = *drawsMinFlag + rng.Int63n(*drawsMaxFlag-*drawsMinFlag+1) } } total := &aggregateStat{} poolStats := map[poolKind]*aggregateStat{ poolNovice: {}, poolIntermediate: {}, poolAdvanced: {}, } tracker100K := &blockTracker{size: 100000} tracker1M := &blockTracker{size: 1000000} maxRounds := *drawsFlag if randomDraws { maxRounds = *drawsMaxFlag } for drawRound := int64(0); drawRound < maxRounds; drawRound++ { for userIndex := range users { u := &users[userIndex] if u.stopped { continue } if u.draws >= u.targetDraws { continue } if u.balance < *costFlag { if *strictWalletFlag { u.stopped = true total.stoppedUsers++ continue } // Fixed-draw RTP pressure tests need every requested draw to be paid. // Top-up is test bankroll only; it is not counted as payout or RTP. topUp := *initialFlag if need := *costFlag - u.balance; topUp < need { topUp = need } u.balance += topUp u.topUp += topUp total.topUp += topUp } pool := choosePool(u.draws+1, *noviceLimitFlag, *intermediateLimitFlag) outcome, err := controller.next(profiles[pool], rng) if err != nil { log.Fatalf("draw failed after %d paid draws: %v", total.draws, err) } u.balance -= *costFlag u.balance += outcome.reward applyDraw(u, total, *costFlag, outcome) applyDraw(nil, poolStats[pool], *costFlag, outcome) tracker100K.observe(total.draws, total.wager, total.payout, rtpPPM) tracker1M.observe(total.draws, total.wager, total.payout, rtpPPM) } } if *strictWalletFlag { for i := range users { if users[i].stopped { continue } if users[i].balance < *costFlag { total.stoppedUsers++ } } } printResult(resultInput{ users: *usersFlag, drawsPerUser: *drawsFlag, randomDraws: randomDraws, drawsMin: *drawsMinFlag, drawsMax: *drawsMaxFlag, rtpPPM: rtpPPM, initialCoins: *initialFlag, cost: *costFlag, windowSize: *windowFlag, noviceLimit: *noviceLimitFlag, intermediateLimit: *intermediateLimitFlag, seed: *seedFlag, strictWallet: *strictWalletFlag, }, total, poolStats, users, tracker100K, tracker1M) if strings.TrimSpace(*userCSVFlag) != "" { if err := writeUserCSV(*userCSVFlag, users); err != nil { log.Fatalf("write user csv failed: %v", err) } fmt.Printf("\nuser_csv=%s\n", *userCSVFlag) } } func newRTPController(windowSize, cost, rtpPPM, minFutureMax int64) *rtpController { return &rtpController{ windowSize: windowSize, cost: cost, rtpPPM: rtpPPM, minFutureMax: minFutureMax, } } func (c *rtpController) next(profile *poolProfile, rng *rand.Rand) (drawOutcome, error) { if c.remainingDraws == 0 { c.openWindow() } if c.remainingDraws == 1 { return c.applyExactPayout(profile, c.remainingPayout) } // minRequired is the smallest payout that still lets future draws close the // current RTP window without exceeding the lowest max reward across pools. remainingAfter := c.remainingDraws - 1 minRequired := c.remainingPayout - c.minFutureMax*remainingAfter if minRequired < 0 { minRequired = 0 } var candidates [16]tier n := 0 var totalWeight int64 for _, candidate := range profile.tiers { if candidate.reward < minRequired || candidate.reward > c.remainingPayout { continue } candidates[n] = candidate n++ totalWeight += candidate.weight } if n == 0 { return c.applyExactPayout(profile, minRequired) } roll := rng.Int63n(totalWeight) selected := candidates[0] for i := 0; i < n; i++ { if roll < candidates[i].weight { selected = candidates[i] break } roll -= candidates[i].weight } return c.applyTier(selected, false) } func (c *rtpController) openWindow() { // carryMicros preserves fractional ppm value across windows. That keeps // arbitrary RTP inputs stable without floating-point drift. targetMicros := c.windowSize*c.cost*c.rtpPPM + c.carryMicros c.windowIndex++ c.remainingDraws = c.windowSize c.remainingPayout = targetMicros / ppmScale c.carryMicros = targetMicros % ppmScale } func (c *rtpController) applyExactPayout(profile *poolProfile, reward int64) (drawOutcome, error) { if reward < 0 { return drawOutcome{}, fmt.Errorf("negative exact reward: %d", reward) } if reward > profile.maxReward { return drawOutcome{}, fmt.Errorf("exact reward %d exceeds %s max reward %d", reward, profile.kind, profile.maxReward) } tierID := "none" correction := false if reward > 0 { // rtp_balance is a pre-random eligible balancing tier. In production it // should be represented in audit data, not hidden as a post-draw change. tierID = "rtp_balance" correction = true } return c.applyTier(tier{id: tierID, reward: reward, weight: 1}, correction) } func (c *rtpController) applyTier(selected tier, correction bool) (drawOutcome, error) { if selected.reward < 0 || selected.reward > c.remainingPayout { return drawOutcome{}, fmt.Errorf("tier %s reward %d cannot consume remaining payout %d", selected.id, selected.reward, c.remainingPayout) } c.remainingPayout -= selected.reward c.remainingDraws-- return drawOutcome{ tierID: selected.id, reward: selected.reward, correction: correction, }, nil } func defaultProfiles(cost int64) map[poolKind]*poolProfile { // The three profiles deliberately use different variance shapes while the // controller keeps the aggregate RTP equal to the configured target. return map[poolKind]*poolProfile{ poolNovice: { kind: poolNovice, maxReward: cost * 10, tiers: []tier{ {id: "none", reward: 0, weight: 270000}, {id: "rebate_0_5x", reward: cost / 2, weight: 200000}, {id: "rebate_1x", reward: cost, weight: 350000}, {id: "small_2x", reward: cost * 2, weight: 140000}, {id: "small_5x", reward: cost * 5, weight: 35000}, {id: "small_10x", reward: cost * 10, weight: 5000}, }, }, poolIntermediate: { kind: poolIntermediate, maxReward: cost * 50, tiers: []tier{ {id: "none", reward: 0, weight: 760000}, {id: "rebate_1x", reward: cost, weight: 100000}, {id: "small_2x", reward: cost * 2, weight: 80000}, {id: "medium_5x", reward: cost * 5, weight: 45000}, {id: "medium_20x", reward: cost * 20, weight: 13000}, {id: "medium_50x", reward: cost * 50, weight: 2000}, }, }, poolAdvanced: { kind: poolAdvanced, maxReward: cost * 500, tiers: []tier{ {id: "none", reward: 0, weight: 900000}, {id: "small_2x", reward: cost * 2, weight: 50000}, {id: "medium_5x", reward: cost * 5, weight: 30000}, {id: "large_20x", reward: cost * 20, weight: 15000}, {id: "large_100x", reward: cost * 100, weight: 4500}, {id: "jackpot_500x", reward: cost * 500, weight: 500}, }, }, } } func choosePool(paidDrawNumber, noviceLimit, intermediateLimit int64) poolKind { if paidDrawNumber <= noviceLimit { return poolNovice } if paidDrawNumber <= intermediateLimit { return poolIntermediate } return poolAdvanced } func applyDraw(user *userStat, stat *aggregateStat, cost int64, outcome drawOutcome) { stat.draws++ stat.wager += cost stat.payout += outcome.reward if outcome.reward > 0 { stat.hits++ } if outcome.reward > stat.maxWin { stat.maxWin = outcome.reward } if outcome.correction { stat.correctionHits++ stat.correctionPayout += outcome.reward } if user == nil { return } user.draws++ user.wager += cost user.payout += outcome.reward if outcome.reward > 0 { user.hits++ } if outcome.reward > user.maxWin { user.maxWin = outcome.reward } } func (b *blockTracker) observe(totalDraws, totalWager, totalPayout, rtpPPM int64) { if totalDraws == 0 || totalDraws%b.size != 0 { return } blockWager := totalWager - b.lastWager blockPayout := totalPayout - b.lastPayout devPP := deviationPP(blockWager, blockPayout, rtpPPM) if abs := math.Abs(devPP); abs > b.maxAbsDevPP { b.maxAbsDevPP = abs } b.lastBlockDevPP = devPP b.closedBlocks++ b.lastDraws = totalDraws b.lastWager = totalWager b.lastPayout = totalPayout } func percentToPPM(percent float64) int64 { return int64(math.Round(percent * 10000)) } func validateRTPCapacity(cost, rtpPPM, minFutureMax int64) error { targetAverage := float64(cost) * float64(rtpPPM) / float64(ppmScale) if targetAverage > float64(minFutureMax) { return fmt.Errorf("target average payout %.2f exceeds minimum future max reward %d; increase pool max rewards or lower RTP", targetAverage, minFutureMax) } return nil } func minProfileMaxReward(profiles map[poolKind]*poolProfile) int64 { minValue := int64(math.MaxInt64) for _, profile := range profiles { if profile.maxReward < minValue { minValue = profile.maxReward } } return minValue } type resultInput struct { users int drawsPerUser int64 randomDraws bool drawsMin int64 drawsMax int64 rtpPPM int64 initialCoins int64 cost int64 windowSize int64 noviceLimit int64 intermediateLimit int64 seed int64 strictWallet bool } func printResult(input resultInput, total *aggregateStat, poolStats map[poolKind]*aggregateStat, users []userStat, tracker100K, tracker1M *blockTracker) { actualRTP := rtpPercent(total.wager, total.payout) targetRTP := float64(input.rtpPPM) / 10000 totalDeviation := actualRTP - targetRTP plannedDraws := int64(input.users) * input.drawsPerUser drawsLabel := fmt.Sprintf("draws_per_user=%d planned_paid_draws=%s", input.drawsPerUser, comma(plannedDraws)) if input.randomDraws { drawsLabel = fmt.Sprintf("draws_per_user=random[%d,%d] actual_paid_draws=%s", input.drawsMin, input.drawsMax, comma(total.draws)) } fmt.Println("Lucky Gift RTP Demo Result") fmt.Printf("mode=%s users=%d %s seed=%d\n", modeName(input.strictWallet), input.users, drawsLabel, input.seed) fmt.Printf("initial_coins=%s cost_per_draw=%s target_rtp=%.4f%% control_window=%s\n", comma(input.initialCoins), comma(input.cost), targetRTP, comma(input.windowSize)) fmt.Printf("pool_rule=novice[1,%d] intermediate[%d,%d] advanced[%d,+inf)\n", input.noviceLimit, input.noviceLimit+1, input.intermediateLimit, input.intermediateLimit+1) fmt.Println() fmt.Println("Aggregate") fmt.Printf("paid_draws=%s wager=%s payout=%s actual_rtp=%.6f%% deviation=%+.6fpp\n", comma(total.draws), comma(total.wager), comma(total.payout), actualRTP, totalDeviation) fmt.Printf("hits=%s hit_rate=%.4f%% max_win=%s test_top_up=%s stopped_users=%d\n", comma(total.hits), ratioPercent(total.hits, total.draws), comma(total.maxWin), comma(total.topUp), total.stoppedUsers) fmt.Printf("rtp_balance_hits=%s rtp_balance_payout=%s\n", comma(total.correctionHits), comma(total.correctionPayout)) fmt.Println() fmt.Println("RTP Window Check") fmt.Printf("100k_blocks=%d max_abs_deviation=%+.6fpp required=<1.000000pp status=%s\n", tracker100K.closedBlocks, tracker100K.maxAbsDevPP, passFail(tracker100K.maxAbsDevPP < 1)) fmt.Printf("1m_blocks=%d max_abs_deviation=%+.6fpp required=<0.500000pp status=%s\n", tracker1M.closedBlocks, tracker1M.maxAbsDevPP, passFail(tracker1M.maxAbsDevPP < 0.5)) fmt.Println() fmt.Println("Pool Model") fmt.Printf("%-14s %14s %14s %14s %12s %12s %12s\n", "pool", "draws", "wager", "payout", "rtp", "hit_rate", "max_win") for _, kind := range []poolKind{poolNovice, poolIntermediate, poolAdvanced} { stat := poolStats[kind] fmt.Printf("%-14s %14s %14s %14s %11.4f%% %11.4f%% %12s\n", kind, comma(stat.draws), comma(stat.wager), comma(stat.payout), rtpPercent(stat.wager, stat.payout), ratioPercent(stat.hits, stat.draws), comma(stat.maxWin), ) } fmt.Println() printUserModel(users) if !input.strictWallet { fmt.Println() fmt.Println("Note") fmt.Println("auto-top-up only keeps the requested fixed draw count running; RTP uses wager and payout only.") } } func printUserModel(users []userStat) { if len(users) == 0 { return } nets := make([]int64, len(users)) balances := make([]int64, len(users)) draws := make([]int64, len(users)) var totalNet, totalBalance, totalTopUp int64 for i, u := range users { net := u.payout - u.wager nets[i] = net balances[i] = u.balance draws[i] = u.draws totalNet += net totalBalance += u.balance totalTopUp += u.topUp } sort.Slice(nets, func(i, j int) bool { return nets[i] < nets[j] }) sort.Slice(balances, func(i, j int) bool { return balances[i] < balances[j] }) sort.Slice(draws, func(i, j int) bool { return draws[i] < draws[j] }) fmt.Println("User Result Model") fmt.Printf("avg_net=%s p05_net=%s median_net=%s p95_net=%s\n", comma(totalNet/int64(len(users))), comma(quantile(nets, 0.05)), comma(quantile(nets, 0.50)), comma(quantile(nets, 0.95))) fmt.Printf("avg_final_balance=%s p05_balance=%s median_balance=%s p95_balance=%s\n", comma(totalBalance/int64(len(users))), comma(quantile(balances, 0.05)), comma(quantile(balances, 0.50)), comma(quantile(balances, 0.95))) fmt.Printf("avg_top_up=%s min_paid_draws=%s median_paid_draws=%s max_paid_draws=%s\n", comma(totalTopUp/int64(len(users))), comma(draws[0]), comma(quantile(draws, 0.50)), comma(draws[len(draws)-1])) } func writeUserCSV(path string, users []userStat) error { file, err := os.Create(path) if err != nil { return err } defer file.Close() writer := csv.NewWriter(file) defer writer.Flush() if err := writer.Write([]string{"user_id", "target_draws", "paid_draws", "wager", "payout", "rtp_percent", "final_coins", "net_profit", "hit_count", "max_win", "test_top_up"}); err != nil { return err } for i, user := range users { record := []string{ fmt.Sprintf("%d", i+1), fmt.Sprintf("%d", user.targetDraws), fmt.Sprintf("%d", user.draws), fmt.Sprintf("%d", user.wager), fmt.Sprintf("%d", user.payout), fmt.Sprintf("%.6f", rtpPercent(user.wager, user.payout)), fmt.Sprintf("%d", user.balance), fmt.Sprintf("%d", user.payout-user.wager), fmt.Sprintf("%d", user.hits), fmt.Sprintf("%d", user.maxWin), fmt.Sprintf("%d", user.topUp), } if err := writer.Write(record); err != nil { return err } } return writer.Error() } func quantile(sorted []int64, q float64) int64 { if len(sorted) == 0 { return 0 } if q <= 0 { return sorted[0] } if q >= 1 { return sorted[len(sorted)-1] } index := int(math.Round(q * float64(len(sorted)-1))) return sorted[index] } func rtpPercent(wager, payout int64) float64 { if wager == 0 { return 0 } return float64(payout) * 100 / float64(wager) } func ratioPercent(numerator, denominator int64) float64 { if denominator == 0 { return 0 } return float64(numerator) * 100 / float64(denominator) } func deviationPP(wager, payout, rtpPPM int64) float64 { return rtpPercent(wager, payout) - float64(rtpPPM)/10000 } func modeName(strictWallet bool) string { if strictWallet { return "strict_wallet" } return "fixed_draws_with_test_top_up" } func passFail(ok bool) string { if ok { return "PASS" } return "FAIL" } func comma(value int64) string { if value == 0 { return "0" } negative := value < 0 if negative { value = -value } raw := fmt.Sprintf("%d", value) var b strings.Builder if negative { b.WriteByte('-') } prefix := len(raw) % 3 if prefix == 0 { prefix = 3 } b.WriteString(raw[:prefix]) for i := prefix; i < len(raw); i += 3 { b.WriteByte(',') b.WriteString(raw[i : i+3]) } return b.String() }