//go:build simulation package smashegg import ( "fmt" "testing" "chatapp3-golang/internal/model" ) func TestSimulateNewbiePoolRTP1000Users100000Draws(t *testing.T) { const ( userCount = 1000 totalDraws = 100000 pricePerDraw = int64(500) targetRTPBasis = 9500 ) option := model.SmashEggDrawOptionConfig{ ID: 1, OptionKey: optionKeyOne, Label: "Smash 1", Times: 1, PriceGold: pricePerDraw, Enabled: true, } rewardTiers := []model.SmashEggRewardConfig{ {ID: 1, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "50 Gold", RewardValueGold: 50, GoldAmount: 50, Enabled: true}, {ID: 2, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "100 Gold", RewardValueGold: 100, GoldAmount: 100, Enabled: true}, {ID: 3, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "500 Gold", RewardValueGold: 500, GoldAmount: 500, Enabled: true}, {ID: 4, PoolType: poolTypeNewbie, RewardType: rewardTypeGold, RewardGroupName: "5000 Gold", RewardValueGold: 5000, GoldAmount: 5000, Enabled: true}, } rewards, err := rewardsWithAutoProbabilitiesForOption(targetRTPBasis, option, rewardTiers) if err != nil { t.Fatalf("calculate auto probabilities failed: %v", err) } totalProbability := 0 for _, reward := range rewards { totalProbability += reward.Probability } if totalProbability != probabilityTotal { t.Fatalf("total probability = %d, want %d", totalProbability, probabilityTotal) } theoreticalValue := expectedRewardValue(rewards) totalPaid := pricePerDraw * totalDraws totalPayout := int64(0) userPaid := make([]int64, userCount) userPayout := make([]int64, userCount) rewardHits := make(map[int64]int, len(rewards)) for drawIndex := 0; drawIndex < totalDraws; drawIndex++ { userIndex := drawIndex % userCount reward, err := pickReward(rewards) if err != nil { t.Fatalf("pickReward failed: %v", err) } totalPayout += reward.RewardValueGold userPaid[userIndex] += pricePerDraw userPayout[userIndex] += reward.RewardValueGold rewardHits[reward.ID]++ } actualRTP := float64(totalPayout) / float64(totalPaid) * 100 theoreticalRTP := theoreticalValue / float64(pricePerDraw) * 100 deviation := actualRTP - float64(targetRTPBasis)/100 minUserRTP, maxUserRTP := userRTPRange(userPaid, userPayout) if absFloat64(theoreticalRTP-float64(targetRTPBasis)/100) > 1 { t.Fatalf("theoretical RTP %.4f%% is outside target %.2f%% ±1.00%%", theoreticalRTP, float64(targetRTPBasis)/100) } if absFloat64(deviation) > 1 { t.Fatalf("actual RTP %.4f%% is outside target %.2f%% ±1.00%%", actualRTP, float64(targetRTPBasis)/100) } t.Logf("users=%d totalDraws=%d pricePerDraw=%d random=crypto/rand no-seed", userCount, totalDraws, pricePerDraw) t.Logf("targetRTP=%.2f%% theoreticalRTP=%.2f%% actualRTP=%.4f%% deviation=%+.4f%%", float64(targetRTPBasis)/100, theoreticalRTP, actualRTP, deviation) t.Logf("totalPaid=%d totalPayout=%d net=%d", totalPaid, totalPayout, totalPayout-totalPaid) t.Logf("perUserDraws=%d minUserRTP=%.2f%% maxUserRTP=%.2f%%", totalDraws/userCount, minUserRTP, maxUserRTP) for _, reward := range rewards { hits := rewardHits[reward.ID] t.Logf( "reward=%s value=%d probability=%s%% hits=%d hitRate=%.4f%%", reward.RewardGroupName, reward.RewardValueGold, probabilityPercent(reward.Probability), hits, float64(hits)/float64(totalDraws)*100, ) } } func userRTPRange(paid []int64, payout []int64) (float64, float64) { minRTP := 0.0 maxRTP := 0.0 for index := range paid { if paid[index] <= 0 { continue } rtp := float64(payout[index]) / float64(paid[index]) * 100 if minRTP == 0 || rtp < minRTP { minRTP = rtp } if rtp > maxRTP { maxRTP = rtp } } return minRTP, maxRTP } func absFloat64(value float64) float64 { if value < 0 { return -value } return value } func Example_simulationCommand() { fmt.Println("go test -tags simulation ./internal/service/smashegg -run TestSimulateNewbiePoolRTP1000Users100000Draws -count=1 -v") // Output: // go test -tags simulation ./internal/service/smashegg -run TestSimulateNewbiePoolRTP1000Users100000Draws -count=1 -v }