yumi-golang/scripts/wheel_rtp_random_simulate.go
2026-05-19 19:50:50 +08:00

535 lines
16 KiB
Go

//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
}