535 lines
16 KiB
Go
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
|
|
}
|