//go:build ignore package main import ( "encoding/json" "fmt" "log" "math" "math/rand" "os" "sort" "strconv" "strings" "time" "chatapp3-golang/internal/service/prizepool" ) const ( probabilityTotal = 1000000 defaultTarget = 1000000 defaultUsers = 10000 targetRTPBasis = 9800 ) type categoryConfig struct { Category string PricePerDraw int64 Rewards []*rewardConfig } type rewardConfig struct { ID int64 Category string Sort int GoldAmount int64 Probability int Hits int64 } type categorySummary struct { Category string `json:"category"` DrawItems int64 `json:"drawItems"` PaidGold int64 `json:"paidGold"` PayoutGold int64 `json:"payoutGold"` RTPPercent float64 `json:"rtpPercent"` TheoryRTP float64 `json:"theoryRtpPercent"` ExpectedPayout float64 `json:"expectedPayoutGold"` DeviationGold float64 `json:"deviationGold"` } type rewardSummary struct { Category string `json:"category"` Sort int `json:"sort"` RewardID int64 `json:"rewardId"` GoldAmount int64 `json:"goldAmount"` Probability int `json:"probability"` TheoryRate float64 `json:"theoryRatePercent"` Hits int64 `json:"hits"` ActualRate float64 `json:"actualRatePercent"` ExpectedHits float64 `json:"expectedHits"` HitDeviation float64 `json:"hitDeviation"` } type userSummary struct { ActiveUsers int `json:"activeUsers"` PositiveUsers int `json:"positiveUsers"` NegativeUsers int `json:"negativeUsers"` BreakEvenUsers int `json:"breakEvenUsers"` MinRTP float64 `json:"minRtpPercent"` P50RTP float64 `json:"p50RtpPercent"` P90RTP float64 `json:"p90RtpPercent"` P99RTP float64 `json:"p99RtpPercent"` MaxRTP float64 `json:"maxRtpPercent"` MinNetGold int64 `json:"minNetGold"` P50NetGold int64 `json:"p50NetGold"` P90NetGold int64 `json:"p90NetGold"` P99NetGold int64 `json:"p99NetGold"` MaxNetGold int64 `json:"maxNetGold"` AveragePaidGold float64 `json:"averagePaidGold"` AverageNetGold float64 `json:"averageNetGold"` } type summary struct { Seed int64 `json:"seed"` TargetRTPPercent float64 `json:"targetRtpPercent"` TargetDrawItems int `json:"targetDrawItems"` ActualDrawItems int64 `json:"actualDrawItems"` DrawRequests int64 `json:"drawRequests"` RandomUsers int `json:"randomUsers"` UniqueUsersDrawn int `json:"uniqueUsersDrawn"` DrawTimesRequests map[string]int64 `json:"drawTimesRequests"` DrawTimesItems map[string]int64 `json:"drawTimesItems"` PaidGold int64 `json:"paidGold"` PayoutGold int64 `json:"payoutGold"` NetGold int64 `json:"netGold"` ActualRTPPercent float64 `json:"actualRtpPercent"` ExpectedPayoutGold float64 `json:"expectedPayoutGold"` DeviationGold float64 `json:"deviationGold"` DeviationPercent float64 `json:"deviationPercent"` StopLossEnabled bool `json:"stopLossEnabled"` TotalLimitViolations int `json:"totalLimitViolations"` UserLimitViolations int `json:"userLimitViolations"` CategorySummaries []categorySummary `json:"categorySummaries"` UserSummary userSummary `json:"userSummary"` TopRewardsByHits []rewardSummary `json:"topRewardsByHits"` RewardSummaries map[string][]rewardSummary `json:"rewardSummaries"` ElapsedMillis int64 `json:"elapsedMillis"` } type userStat struct { paid int64 payout int64 } type categoryStat struct { items int64 paid int64 payout int64 } func main() { startedAt := time.Now() seed := envInt64("WHEEL_SIM_SEED", time.Now().UnixNano()) target := envInt("WHEEL_SIM_TARGET_DRAWS", defaultTarget) userCount := envInt("WHEEL_SIM_USERS", defaultUsers) rng := rand.New(rand.NewSource(seed)) categories := buildRTPConfig(rng) itemsByCategory := map[string][]prizepool.PickResult{} for _, category := range categories { itemsByCategory[category.Category] = prizePoolItems(category.Rewards) } userStats := make([]userStat, userCount) categoryStats := map[string]*categoryStat{} for _, category := range categories { categoryStats[category.Category] = &categoryStat{} } drawTimesRequests := map[string]int64{"1": 0, "10": 0, "50": 0} drawTimesItems := map[string]int64{"1": 0, "10": 0, "50": 0} uniqueUsers := map[int]struct{}{} picker := prizepool.Picker{RandomIntn: func(max int) (int, error) { return rng.Intn(max), nil }} var drawRequests int64 var drawItems int64 var paidGold int64 var payoutGold int64 for drawItems < int64(target) { userIndex := rng.Intn(userCount) category := categories[rng.Intn(len(categories))] times := randomDrawTimes(rng) timesKey := strconv.Itoa(times) requestPaid := category.PricePerDraw * int64(times) drawRequests++ drawTimesRequests[timesKey]++ drawTimesItems[timesKey] += int64(times) uniqueUsers[userIndex] = struct{}{} userStats[userIndex].paid += requestPaid categoryStats[category.Category].paid += requestPaid paidGold += requestPaid for i := 0; i < times; i++ { picked, err := picker.PickWeighted(itemsByCategory[category.Category]) if err != nil { log.Fatal(err) } reward := category.Rewards[picked.Index] reward.Hits++ drawItems++ payoutGold += reward.GoldAmount userStats[userIndex].payout += reward.GoldAmount categoryStats[category.Category].items++ categoryStats[category.Category].payout += reward.GoldAmount } } expectedPayout := float64(paidGold) * float64(targetRTPBasis) / 10000 categorySummaries := buildCategorySummaries(categories, categoryStats) rewardSummaries, topRewards := buildRewardSummaries(categories, categoryStats, 14) result := summary{ Seed: seed, TargetRTPPercent: float64(targetRTPBasis) / 100, TargetDrawItems: target, ActualDrawItems: drawItems, DrawRequests: drawRequests, RandomUsers: userCount, UniqueUsersDrawn: len(uniqueUsers), DrawTimesRequests: drawTimesRequests, DrawTimesItems: drawTimesItems, PaidGold: paidGold, PayoutGold: payoutGold, NetGold: payoutGold - paidGold, ActualRTPPercent: percent(float64(payoutGold), float64(paidGold)), ExpectedPayoutGold: expectedPayout, DeviationGold: float64(payoutGold) - expectedPayout, DeviationPercent: percent(float64(payoutGold)-expectedPayout, expectedPayout), StopLossEnabled: false, TotalLimitViolations: 0, UserLimitViolations: 0, CategorySummaries: categorySummaries, UserSummary: buildUserSummary(userStats), TopRewardsByHits: topRewards, RewardSummaries: rewardSummaries, ElapsedMillis: time.Since(startedAt).Milliseconds(), } out, err := json.MarshalIndent(result, "", " ") if err != nil { log.Fatal(err) } fmt.Println(string(out)) } func buildRTPConfig(rng *rand.Rand) []categoryConfig { configs := []struct { category string price int64 values []int64 }{ { category: "CLASSIC", price: 100, values: []int64{20, 40, 60, 80, 120, 200, 500, 2000}, }, { category: "LUXURY", price: 1000, values: []int64{200, 400, 700, 1000, 1500, 3000, 10000, 50000}, }, { category: "ADVANCED", price: 10000, values: []int64{2000, 4000, 7000, 10000, 15000, 30000, 80000, 200000, 500000, 1000000, 3000000, 10000000}, }, } var rewardID int64 = 1 result := make([]categoryConfig, 0, len(configs)) for _, cfg := range configs { probabilities := probabilitiesForTargetRTP(cfg.values, cfg.price, rng) rewards := make([]*rewardConfig, 0, len(cfg.values)) for index, value := range cfg.values { rewards = append(rewards, &rewardConfig{ ID: rewardID, Category: cfg.category, Sort: index + 1, GoldAmount: value, Probability: probabilities[index], }) rewardID++ } result = append(result, categoryConfig{ Category: cfg.category, PricePerDraw: cfg.price, Rewards: rewards, }) } return result } func probabilitiesForTargetRTP(values []int64, price int64, rng *rand.Rand) []int { targetValueNumerator := price * int64(targetRTPBasis) * int64(probabilityTotal) targetValueNumerator /= 10000 for attempt := 0; attempt < 200; attempt++ { probabilities := make([]int, len(values)) noiseTotal := 50000 remainingNoise := noiseTotal for i := range probabilities { if i == len(probabilities)-1 { probabilities[i] = remainingNoise break } value := rng.Intn(remainingNoise / 2) probabilities[i] = value remainingNoise -= value } rng.Shuffle(len(probabilities), func(i, j int) { probabilities[i], probabilities[j] = probabilities[j], probabilities[i] }) usedProbability := 0 usedValueNumerator := int64(0) for index, probability := range probabilities { usedProbability += probability usedValueNumerator += int64(probability) * values[index] } remainingProbability := probabilityTotal - usedProbability remainingValueNumerator := targetValueNumerator - usedValueNumerator if remainingProbability <= 0 || remainingValueNumerator <= 0 { continue } remainingAverage := float64(remainingValueNumerator) / float64(remainingProbability) lowIndex, highIndex := bracketIndexes(values, remainingAverage) if lowIndex < 0 || highIndex < 0 || lowIndex == highIndex { continue } low := values[lowIndex] high := values[highIndex] highProbability := int(math.Round(float64(remainingValueNumerator-int64(remainingProbability)*low) / float64(high-low))) if highProbability < 0 || highProbability > remainingProbability { continue } lowProbability := remainingProbability - highProbability probabilities[lowIndex] += lowProbability probabilities[highIndex] += highProbability normalizeProbabilityTotal(probabilities) return probabilities } log.Fatal("failed to build probability config for target RTP") return nil } func bracketIndexes(values []int64, target float64) (int, int) { lowIndex := -1 highIndex := -1 for index, value := range values { if float64(value) <= target { lowIndex = index } if float64(value) >= target { highIndex = index break } } return lowIndex, highIndex } func normalizeProbabilityTotal(probabilities []int) { total := 0 maxIndex := 0 for index, probability := range probabilities { total += probability if probability > probabilities[maxIndex] { maxIndex = index } } probabilities[maxIndex] += probabilityTotal - total } func prizePoolItems(rewards []*rewardConfig) []prizepool.PickResult { items := make([]prizepool.PickResult, 0, len(rewards)) for index, reward := range rewards { items = append(items, prizepool.PickResult{ Item: prizepool.Item{ ID: reward.ID, Enabled: true, Weight: reward.Probability, }, Index: index, }) } return items } func randomDrawTimes(rng *rand.Rand) int { switch rng.Intn(3) { case 0: return 1 case 1: return 10 default: return 50 } } func buildCategorySummaries(categories []categoryConfig, stats map[string]*categoryStat) []categorySummary { result := make([]categorySummary, 0, len(categories)) for _, category := range categories { stat := stats[category.Category] theoryRTP := theoreticalRTP(category) expectedPayout := float64(stat.paid) * theoryRTP / 100 result = append(result, categorySummary{ Category: category.Category, DrawItems: stat.items, PaidGold: stat.paid, PayoutGold: stat.payout, RTPPercent: percent(float64(stat.payout), float64(stat.paid)), TheoryRTP: theoryRTP, ExpectedPayout: expectedPayout, DeviationGold: float64(stat.payout) - expectedPayout, }) } return result } func buildRewardSummaries(categories []categoryConfig, stats map[string]*categoryStat, topLimit int) (map[string][]rewardSummary, []rewardSummary) { byCategory := map[string][]rewardSummary{} all := make([]rewardSummary, 0) for _, category := range categories { stat := stats[category.Category] rows := make([]rewardSummary, 0, len(category.Rewards)) for _, reward := range category.Rewards { expectedHits := float64(stat.items) * float64(reward.Probability) / probabilityTotal row := rewardSummary{ Category: reward.Category, Sort: reward.Sort, RewardID: reward.ID, GoldAmount: reward.GoldAmount, Probability: reward.Probability, TheoryRate: float64(reward.Probability) / 10000, Hits: reward.Hits, ActualRate: percent(float64(reward.Hits), float64(stat.items)), ExpectedHits: expectedHits, HitDeviation: float64(reward.Hits) - expectedHits, } rows = append(rows, row) all = append(all, row) } byCategory[category.Category] = rows } sort.SliceStable(all, func(i, j int) bool { if all[i].Hits == all[j].Hits { return all[i].RewardID < all[j].RewardID } return all[i].Hits > all[j].Hits }) if len(all) > topLimit { all = all[:topLimit] } return byCategory, all } func theoreticalRTP(category categoryConfig) float64 { expected := float64(0) for _, reward := range category.Rewards { expected += float64(reward.Probability) / probabilityTotal * float64(reward.GoldAmount) } return percent(expected, float64(category.PricePerDraw)) } func buildUserSummary(userStats []userStat) userSummary { rtps := make([]float64, 0, len(userStats)) nets := make([]int64, 0, len(userStats)) active := 0 positive := 0 negative := 0 breakEven := 0 totalPaid := int64(0) totalNet := int64(0) for _, stat := range userStats { if stat.paid <= 0 { continue } active++ net := stat.payout - stat.paid switch { case net > 0: positive++ case net < 0: negative++ default: breakEven++ } rtps = append(rtps, percent(float64(stat.payout), float64(stat.paid))) nets = append(nets, net) totalPaid += stat.paid totalNet += net } sort.Float64s(rtps) sort.Slice(nets, func(i, j int) bool { return nets[i] < nets[j] }) return userSummary{ ActiveUsers: active, PositiveUsers: positive, NegativeUsers: negative, BreakEvenUsers: breakEven, MinRTP: percentileFloat(rtps, 0), P50RTP: percentileFloat(rtps, 0.50), P90RTP: percentileFloat(rtps, 0.90), P99RTP: percentileFloat(rtps, 0.99), MaxRTP: percentileFloat(rtps, 1), MinNetGold: percentileInt(nets, 0), P50NetGold: percentileInt(nets, 0.50), P90NetGold: percentileInt(nets, 0.90), P99NetGold: percentileInt(nets, 0.99), MaxNetGold: percentileInt(nets, 1), AveragePaidGold: float64(totalPaid) / float64(active), AverageNetGold: float64(totalNet) / float64(active), } } func percentileFloat(values []float64, p float64) float64 { if len(values) == 0 { return 0 } index := int(math.Round(p * float64(len(values)-1))) return values[index] } func percentileInt(values []int64, p float64) int64 { if len(values) == 0 { return 0 } index := int(math.Round(p * float64(len(values)-1))) return values[index] } func percent(numerator, denominator float64) float64 { if denominator == 0 { return 0 } return numerator / denominator * 100 } func envInt(name string, fallback int) int { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return fallback } parsed, err := strconv.Atoi(raw) if err != nil || parsed <= 0 { return fallback } return parsed } func envInt64(name string, fallback int64) int64 { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return fallback } parsed, err := strconv.ParseInt(raw, 10, 64) if err != nil || parsed <= 0 { return fallback } return parsed }