2026-05-20 10:22:35 +08:00

646 lines
19 KiB
Go

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()
}