package main import ( "encoding/csv" "flag" "fmt" "log" "math" "math/rand" "os" "path/filepath" "sort" "strings" ) const ppmScale int64 = 1_000_000 const ( defaultNoviceDrawLimit = int64(2_000) defaultIntermediateDrawLimit = int64(20_000) defaultDrawsPerHour = int64(3_600) ) type experiencePool string const ( novicePool experiencePool = "novice" intermediatePool experiencePool = "intermediate" advancedPool experiencePool = "advanced" ) type budgetSource string const ( sourceBaseRTP budgetSource = "base_rtp" sourceRoomAtmosphere budgetSource = "room_atmosphere" sourceActivity budgetSource = "activity_subsidy" sourcePresentation budgetSource = "presentation_only" ) type rewardTier struct { id string reward int64 multiplierNum int64 multiplierDen int64 weight int64 highWaterOnly bool } func (t rewardTier) rewardFor(cost int64) int64 { if t.multiplierDen > 0 { return cost * t.multiplierNum / t.multiplierDen } return t.reward } type tierTable struct { maxReward int64 maxNum int64 maxDen int64 tiers []rewardTier } func (t tierTable) maxRewardFor(cost int64) int64 { if t.maxDen > 0 { return cost * t.maxNum / t.maxDen } return t.maxReward } type rewardCandidate struct { tierID string source budgetSource pool experiencePool weight int64 baseReward int64 roomReward int64 activityReward int64 presentation bool correction bool highMultiplier bool } func (c rewardCandidate) effectiveReward() int64 { return c.baseReward + c.roomReward + c.activityReward } type drawOutcome struct { rewardCandidate stageFeedback bool riskCapLimited bool poolCapLimited bool highWaterLimited bool } type rtpWindow struct { name string windowSize int64 cost int64 targetPPM int64 minFutureMax int64 carryMicros int64 windowIndex int64 remainingDraws int64 remainingPayout int64 totalWager int64 totalPayout int64 } type poolWeights struct { platformPPM int64 roomPPM int64 giftPPM int64 } type accountingPool struct { name string balance int64 reserveFloor int64 totalIn int64 totalOut int64 } func (p *accountingPool) capacity() int64 { capacity := p.balance - p.reserveFloor if capacity < 0 { return 0 } return capacity } func (p *accountingPool) credit(amount int64) { if amount <= 0 { return } p.balance += amount p.totalIn += amount } func (p *accountingPool) debit(amount int64) { if amount <= 0 { return } p.balance -= amount p.totalOut += amount } type roomAtmospherePool struct { roomID int balance int64 reserveFloor int64 totalIn int64 totalOut int64 } func (p *roomAtmospherePool) capacity() int64 { capacity := p.balance - p.reserveFloor if capacity < 0 { return 0 } return capacity } func (p *roomAtmospherePool) credit(amount int64) { if amount <= 0 { return } p.balance += amount p.totalIn += amount } func (p *roomAtmospherePool) debit(amount int64) { if amount <= 0 { return } p.balance -= amount p.totalOut += amount } type activityBudget struct { budget int64 spent int64 dailyLimit int64 currentDay int64 dailySpent int64 enabled bool totalOut int64 exhaustedHit int64 } func (b *activityBudget) refresh(day int64) { if day == b.currentDay { return } b.currentDay = day b.dailySpent = 0 } func (b *activityBudget) remaining() int64 { if !b.enabled { return 0 } totalRemaining := b.budget - b.spent dailyRemaining := b.dailyLimit - b.dailySpent if totalRemaining < 0 { totalRemaining = 0 } if dailyRemaining < 0 { dailyRemaining = 0 } return minInt64(totalRemaining, dailyRemaining) } func (b *activityBudget) debit(amount int64) { if amount <= 0 { return } b.spent += amount b.dailySpent += amount b.totalOut += amount } type riskLimits struct { singlePayoutCap int64 userHourlyCap int64 userDailyCap int64 deviceDailyCap int64 roomHourlyCap int64 anchorDailyCap int64 drawsPerHour int64 } type payoutCounter struct { bucket int64 payout int64 } func (c *payoutCounter) refresh(bucket int64) { if bucket == c.bucket { return } c.bucket = bucket c.payout = 0 } func (c *payoutCounter) add(amount int64) { c.payout += amount } func (c *payoutCounter) remaining(cap int64) int64 { remaining := cap - c.payout if remaining < 0 { return 0 } return remaining } type userStat struct { id int deviceID int roomID int anchorID int targetDraws int64 draws int64 wager int64 basePayout int64 roomPayout int64 activityPayout int64 effectivePay int64 hits int64 maxWin int64 balance int64 currentHour int64 currentDay int64 hourly payoutCounter daily payoutCounter } func (u *userStat) refreshRiskWindows(drawsPerHour int64) { u.currentHour = u.draws / drawsPerHour u.currentDay = u.draws / (drawsPerHour * 24) u.hourly.refresh(u.currentHour) u.daily.refresh(u.currentDay) } type roomState struct { id int anchorID int basePool accountingPool atmosphere roomAtmospherePool hourly payoutCounter } type anchorState struct { id int daily payoutCounter } type deviceState struct { id int daily payoutCounter } type aggregateStat struct { draws int64 wager int64 basePayout int64 roomPayout int64 activityPayout int64 effectivePayout int64 hits int64 maxWin int64 correctionHits int64 correctionPayout int64 noviceDraws int64 intermediateDraws int64 advancedDraws int64 stageFeedbacks int64 presentationHits int64 highMultiplierHits int64 riskCapLimitedDraws int64 poolCapLimitedDraws int64 highWaterLimitedDraws int64 } type blockTracker struct { size int64 lastWager int64 lastPayout int64 closed int64 maxAbsDevPP float64 } func main() { usersFlag := flag.Int("users", 1000, "number of simulated users") devicesFlag := flag.Int("devices", 900, "number of simulated devices") roomsFlag := flag.Int("rooms", 50, "number of simulated rooms") drawsFlag := flag.Int64("draws", 100000, "draws per user") drawsMinFlag := flag.Int64("draws-min", 0, "optional minimum random draws per user") drawsMaxFlag := flag.Int64("draws-max", 0, "optional maximum random draws per user") uniqueDrawsFlag := flag.Bool("unique-draws", false, "make random draw counts unique per user") initialFlag := flag.Int64("initial", 1_000_000_000, "initial coins per user") costFlag := flag.Int64("cost", 500, "coin cost per draw") costMinFlag := flag.Int64("cost-min", 0, "optional minimum random coin cost per draw") costMaxFlag := flag.Int64("cost-max", 0, "optional maximum random coin cost per draw") baseRTPFlag := flag.Float64("base-rtp", 95, "base RTP percent") globalWindowFlag := flag.Int64("global-window", 100000, "global RTP control window paid draws") giftWindowFlag := flag.Int64("gift-window", 100000, "gift RTP control window paid draws") noviceDrawLimitFlag := flag.Int64("novice-draws", defaultNoviceDrawLimit, "paid draws kept in the novice experience pool") intermediateDrawLimitFlag := flag.Int64("intermediate-draws", defaultIntermediateDrawLimit, "paid draws kept before advanced experience pool") drawsPerHourFlag := flag.Int64("draws-per-hour", defaultDrawsPerHour, "simulated paid draws per user hour for risk windows") singleCapFlag := flag.Int64("single-cap", 0, "max total reward per draw, 0 means cost*500") userHourlyCapFlag := flag.Int64("user-hourly-cap", 0, "max user reward per simulated hour, 0 means expected hourly payout*2") userDailyCapFlag := flag.Int64("user-daily-cap", 0, "max user reward per simulated day, 0 means expected daily payout*1.5") deviceDailyCapFlag := flag.Int64("device-daily-cap", 0, "max device reward per simulated day, 0 means expected user daily payout*2.5") roomHourlyCapFlag := flag.Int64("room-hourly-cap", 0, "max room reward per simulated hour, 0 means expected room hourly payout*2") anchorDailyCapFlag := flag.Int64("anchor-daily-cap", 0, "max anchor-related reward per simulated day, 0 means expected room daily payout*1.5") highMultiplierFlag := flag.Int64("high-multiplier", 100, "reward multiplier treated as high tier") highWaterPoolMultipleFlag := flag.Int64("high-water-pool-multiple", 2, "pool capacity multiple required before high tiers open") poolRateFlag := flag.Int64("pool-rate-ppm", 0, "base prize pool inflow rate, 0 means target RTP ppm") platformWeightFlag := flag.Int64("platform-weight-ppm", 200000, "platform liability weight for base payout") roomWeightFlag := flag.Int64("room-weight-ppm", 300000, "room liability weight for base payout") giftWeightFlag := flag.Int64("gift-weight-ppm", 500000, "gift liability weight for base payout") initialPlatformPoolFlag := flag.Int64("initial-platform-pool", 0, "initial platform base pool, 0 means one global window liability") initialGiftPoolFlag := flag.Int64("initial-gift-pool", 0, "initial gift base pool, 0 means one gift window liability") initialRoomPoolFlag := flag.Int64("initial-room-pool", 5_000_000, "initial base pool per room") platformReserveFlag := flag.Int64("platform-reserve", 1_000_000, "platform pool reserve floor") giftReserveFlag := flag.Int64("gift-reserve", 1_000_000, "gift pool reserve floor") roomReserveFlag := flag.Int64("room-reserve", 100_000, "room pool reserve floor") roomAtmosphereRateFlag := flag.Int64("room-atmosphere-rate-ppm", 10000, "room atmosphere inflow from wager, independent from base RTP") roomAtmosphereInitialFlag := flag.Int64("room-atmosphere-initial", 100_000, "initial room atmosphere budget per room") roomAtmosphereReserveFlag := flag.Int64("room-atmosphere-reserve", 10_000, "room atmosphere reserve floor") activityBudgetFlag := flag.Int64("activity-budget", 50_000_000, "activity subsidy budget, independent from base RTP") activityDailyLimitFlag := flag.Int64("activity-daily-limit", 10_000_000, "activity subsidy daily spend cap") seedFlag := flag.Int64("seed", 20260517, "deterministic random seed") userCSVFlag := flag.String("user-csv", "", "optional path to write per-user result CSV") flag.Parse() if *usersFlag <= 0 || *devicesFlag <= 0 || *roomsFlag <= 0 || *initialFlag < 0 || *costFlag <= 0 || *globalWindowFlag <= 0 || *giftWindowFlag <= 0 || *drawsPerHourFlag <= 0 { log.Fatal("users, devices, rooms, initial, cost, windows, and draws-per-hour must be valid positive values") } randomCost := *costMinFlag > 0 || *costMaxFlag > 0 if randomCost { if *costMinFlag <= 0 || *costMaxFlag < *costMinFlag { log.Fatal("cost-min and cost-max must satisfy 0 < cost-min <= cost-max") } } if *noviceDrawLimitFlag < 0 || *intermediateDrawLimitFlag < *noviceDrawLimitFlag || *highMultiplierFlag <= 0 || *highWaterPoolMultipleFlag <= 0 { log.Fatal("experience limits and high-water flags are invalid") } weights := poolWeights{platformPPM: *platformWeightFlag, roomPPM: *roomWeightFlag, giftPPM: *giftWeightFlag} if weights.platformPPM < 0 || weights.roomPPM < 0 || weights.giftPPM < 0 || weights.platformPPM+weights.roomPPM+weights.giftPPM != ppmScale { log.Fatal("platform, room, and gift pool weights must be non-negative and sum to 1000000") } 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") } if *uniqueDrawsFlag && *drawsMaxFlag-*drawsMinFlag+1 < int64(*usersFlag) { log.Fatal("draw range is too small to assign unique draw counts") } } else if *drawsFlag <= 0 { log.Fatal("draws must be positive when draws-min/draws-max are not set") } rng := rand.New(rand.NewSource(*seedFlag)) baseRTPPPM := percentToPPM(*baseRTPFlag) poolRatePPM := *poolRateFlag if poolRatePPM == 0 { poolRatePPM = baseRTPPPM } if poolRatePPM < baseRTPPPM { log.Fatalf("pool-rate-ppm %d cannot be below target RTP ppm %d in production mode", poolRatePPM, baseRTPPPM) } costBasis := *costFlag minCost := *costFlag maxCost := *costFlag if randomCost { minCost = *costMinFlag maxCost = *costMaxFlag costBasis = (*costMinFlag + *costMaxFlag) / 2 } noviceTable := defaultNoviceTierTable(costBasis) intermediateTable := defaultIntermediateTierTable(costBasis) advancedTable := defaultAdvancedTierTable(costBasis) singleCap := *singleCapFlag if randomCost && singleCap == 0 { singleCap = maxCost * 500 } risk := buildRiskLimits(riskInput{ cost: costBasis, rtpPPM: baseRTPPPM, drawsPerHour: *drawsPerHourFlag, users: *usersFlag, rooms: *roomsFlag, singleCap: singleCap, userHourlyCap: *userHourlyCapFlag, userDailyCap: *userDailyCapFlag, deviceDailyCap: *deviceDailyCapFlag, roomHourlyCap: *roomHourlyCapFlag, anchorDailyCap: *anchorDailyCapFlag, }) if err := validateRiskLimits(costBasis, baseRTPPPM, risk); err != nil { log.Fatal(err) } minFutureMaxReward := minInt64(noviceTable.maxRewardFor(minCost), intermediateTable.maxRewardFor(minCost)) minFutureMaxReward = minInt64(minFutureMaxReward, advancedTable.maxRewardFor(minCost)) minFutureMaxReward = minInt64(minFutureMaxReward, risk.singlePayoutCap) if err := validateRTPCapacity(maxCost, baseRTPPPM, maxCost*20); err != nil { log.Fatal(err) } initialPlatformPool := *initialPlatformPoolFlag if initialPlatformPool == 0 { initialPlatformPool = weightedAmount(expectedPayoutForDraws(costBasis, baseRTPPPM, *globalWindowFlag), weights.platformPPM) } initialGiftPool := *initialGiftPoolFlag if initialGiftPool == 0 { initialGiftPool = weightedAmount(expectedPayoutForDraws(costBasis, baseRTPPPM, *giftWindowFlag), weights.giftPPM) } platformPool := accountingPool{name: "platform", balance: initialPlatformPool, reserveFloor: *platformReserveFlag} giftPool := accountingPool{name: "gift:lucky_500", balance: initialGiftPool, reserveFloor: *giftReserveFlag} rooms := buildRooms(*roomsFlag, *initialRoomPoolFlag, *roomReserveFlag, *roomAtmosphereInitialFlag, *roomAtmosphereReserveFlag) anchors := buildAnchors(*roomsFlag) devices := buildDevices(*devicesFlag) activity := activityBudget{ budget: *activityBudgetFlag, dailyLimit: *activityDailyLimitFlag, enabled: *activityBudgetFlag > 0 && *activityDailyLimitFlag > 0, } highMultiplierMinReward := costBasis * *highMultiplierFlag globalRTP := newRTPWindow("global", *globalWindowFlag, *costFlag, baseRTPPPM, minFutureMaxReward) giftRTP := newRTPWindow("gift:lucky_500", *giftWindowFlag, *costFlag, baseRTPPPM, minFutureMaxReward) users := make([]userStat, *usersFlag) targetDraws := buildTargetDraws(*usersFlag, *drawsFlag, *drawsMinFlag, *drawsMaxFlag, *uniqueDrawsFlag, rng) for i := range users { roomID := i % *roomsFlag deviceID := i % *devicesFlag users[i] = userStat{ id: i + 1, deviceID: deviceID, roomID: roomID, anchorID: rooms[roomID].anchorID, targetDraws: targetDraws[i], balance: *initialFlag, } } total := &aggregateStat{} tracker100K := &blockTracker{size: 100000} tracker1M := &blockTracker{size: 1000000} maxRounds := *drawsFlag if randomDraws { maxRounds = *drawsMaxFlag } for round := int64(0); round < maxRounds; round++ { for userIndex := range users { user := &users[userIndex] if user.draws >= user.targetDraws { continue } drawCost := *costFlag if randomCost { drawCost = *costMinFlag + rng.Int63n(*costMaxFlag-*costMinFlag+1) } if user.balance < drawCost { log.Fatalf("user %d balance is not enough at draw %d", user.id, round+1) } room := &rooms[user.roomID] anchor := &anchors[user.anchorID] device := &devices[user.deviceID] user.refreshRiskWindows(risk.drawsPerHour) device.daily.refresh(user.currentDay) room.hourly.refresh(user.currentHour) anchor.daily.refresh(user.currentDay) activity.refresh(user.currentDay) creditBasePools(drawCost, poolRatePPM, weights, &platformPool, &room.basePool, &giftPool) room.atmosphere.credit(weightedAmount(drawCost, *roomAtmosphereRateFlag)) pool := selectExperiencePool(user.draws+1, *noviceDrawLimitFlag, *intermediateDrawLimitFlag) table := tableForPool(pool, noviceTable, intermediateTable, advancedTable) outcome, err := drawProductionReward(drawRequest{ table: table, pool: pool, stageFeedback: hasStageFeedback(user.draws+1, *noviceDrawLimitFlag), globalRTP: globalRTP, giftRTP: giftRTP, platformPool: &platformPool, roomPool: &room.basePool, giftPool: &giftPool, roomAtmosphere: &room.atmosphere, activity: &activity, weights: weights, user: user, device: device, room: room, anchor: anchor, risk: risk, highMultiplierMinReward: drawCost * *highMultiplierFlag, highWaterPoolMultiple: *highWaterPoolMultipleFlag, cost: drawCost, rng: rng, }) if err != nil { log.Fatalf("draw failed after %d paid draws: %v", total.draws, err) } applyProductionDraw(applyRequest{ user: user, room: room, anchor: anchor, device: device, activity: &activity, globalRTP: globalRTP, giftRTP: giftRTP, platformPool: &platformPool, giftPool: &giftPool, weights: weights, cost: drawCost, outcome: outcome, total: total, baseRTPPPM: baseRTPPPM, tracker100K: tracker100K, tracker1M: tracker1M, roomBasePool: &room.basePool, roomAtmosphere: &room.atmosphere, }) } } printResult(resultInput{ users: *usersFlag, devices: *devicesFlag, rooms: *roomsFlag, drawsPerUser: *drawsFlag, randomDraws: randomDraws, drawsMin: *drawsMinFlag, drawsMax: *drawsMaxFlag, uniqueDraws: *uniqueDrawsFlag, initialCoins: *initialFlag, cost: *costFlag, costMin: minCost, costMax: maxCost, randomCost: randomCost, baseRTPPPM: baseRTPPPM, poolRatePPM: poolRatePPM, globalWindow: *globalWindowFlag, giftWindow: *giftWindowFlag, noviceDraws: *noviceDrawLimitFlag, intermediateDraws: *intermediateDrawLimitFlag, risk: risk, highTierMin: highMultiplierMinReward, highPoolMultiple: *highWaterPoolMultipleFlag, roomAtmosphereRate: *roomAtmosphereRateFlag, activityBudget: *activityBudgetFlag, activityDailyLimit: *activityDailyLimitFlag, seed: *seedFlag, }, total, users, rooms, &platformPool, &giftPool, &activity, globalRTP, giftRTP, 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) } } type drawRequest struct { table tierTable pool experiencePool stageFeedback bool globalRTP *rtpWindow giftRTP *rtpWindow platformPool *accountingPool roomPool *accountingPool giftPool *accountingPool roomAtmosphere *roomAtmospherePool activity *activityBudget weights poolWeights user *userStat device *deviceState room *roomState anchor *anchorState risk riskLimits highMultiplierMinReward int64 highWaterPoolMultiple int64 cost int64 rng *rand.Rand } func drawProductionReward(req drawRequest) (drawOutcome, error) { req.globalRTP.ensureOpen() req.giftRTP.ensureOpen() req.globalRTP.accrue(req.cost) req.giftRTP.accrue(req.cost) minRequired := maxInt64(req.globalRTP.minRequired(), req.giftRTP.minRequired()) rtpMaxAllowed := minInt64(req.globalRTP.remainingPayout, req.giftRTP.remainingPayout) basePoolCapacity := weightedPoolCapacity(req.platformPool, req.roomPool, req.giftPool, req.weights) riskCapacity := riskRewardCapacity(req.user, req.device, req.room, req.anchor, req.risk) baseMaxAllowed := minInt64(rtpMaxAllowed, minInt64(basePoolCapacity, riskCapacity)) outcome := drawOutcome{stageFeedback: req.stageFeedback} candidates := make([]rewardCandidate, 0, len(req.table.tiers)+5) var totalWeight int64 var redirectedWeight int64 for _, tier := range req.table.tiers { tierReward := tier.rewardFor(req.cost) if tierReward < minRequired { continue } if tierReward > baseMaxAllowed { if tierReward > riskCapacity { outcome.riskCapLimited = true } if tierReward > basePoolCapacity { outcome.poolCapLimited = true } if minRequired == 0 { redirectedWeight += tier.weight } continue } highMultiplier := isHighMultiplier(tierReward, req.highMultiplierMinReward) || tier.highWaterOnly if highMultiplier && !hasProductionHighWater(tierReward, req) { outcome.highWaterLimited = true if minRequired == 0 { redirectedWeight += tier.weight } continue } candidates = append(candidates, rewardCandidate{ tierID: tier.id, source: sourceBaseRTP, pool: req.pool, weight: tier.weight, baseReward: tierReward, highMultiplier: highMultiplier, }) totalWeight += tier.weight } if redirectedWeight > 0 && minRequired == 0 { candidates = append(candidates, rewardCandidate{tierID: "disabled_to_none", source: sourceBaseRTP, pool: req.pool, weight: redirectedWeight}) totalWeight += redirectedWeight } // 房间气氛和活动补贴只能在基础 RTP 当前不强制追平时进入候选;它们不承担基础 RTP 结清责任。 if minRequired == 0 { roomCandidates := roomAtmosphereCandidates(req.cost, req.pool, req.roomAtmosphere, riskCapacity) for _, candidate := range roomCandidates { candidates = append(candidates, candidate) totalWeight += candidate.weight } activityCandidates := activitySubsidyCandidates(req.cost, req.pool, req.activity, riskCapacity) for _, candidate := range activityCandidates { candidates = append(candidates, candidate) totalWeight += candidate.weight } if req.stageFeedback { candidates = append(candidates, rewardCandidate{tierID: "stage_feedback", source: sourcePresentation, pool: req.pool, weight: 5_000, presentation: true}) totalWeight += 5_000 } } if len(candidates) == 0 { tableMaxReward := req.table.maxRewardFor(req.cost) if minRequired > tableMaxReward || minRequired > baseMaxAllowed { return drawOutcome{}, fmt.Errorf("cannot settle production windows: min_required=%d base_max_allowed=%d rtp_max=%d pool_capacity=%d risk_capacity=%d table_max=%d", minRequired, baseMaxAllowed, rtpMaxAllowed, basePoolCapacity, riskCapacity, tableMaxReward) } highMultiplier := isHighMultiplier(minRequired, req.highMultiplierMinReward) if highMultiplier && !hasProductionHighWater(minRequired, req) { return drawOutcome{}, fmt.Errorf("cannot pay high multiplier correction before high water: min_required=%d high_tier_min=%d", minRequired, req.highMultiplierMinReward) } outcome.rewardCandidate = rewardCandidate{ tierID: correctionTierID(minRequired), source: sourceBaseRTP, pool: req.pool, baseReward: minRequired, correction: minRequired > 0, highMultiplier: highMultiplier, } return outcome, nil } roll := req.rng.Int63n(totalWeight) selected := candidates[0] for _, candidate := range candidates { if roll < candidate.weight { selected = candidate break } roll -= candidate.weight } outcome.rewardCandidate = selected return outcome, nil } func roomAtmosphereCandidates(cost int64, pool experiencePool, room *roomAtmospherePool, riskCapacity int64) []rewardCandidate { capacity := minInt64(room.capacity(), riskCapacity) candidates := make([]rewardCandidate, 0, 2) if capacity >= cost { candidates = append(candidates, rewardCandidate{ tierID: "room_shared_1x", source: sourceRoomAtmosphere, pool: pool, weight: 1_200, roomReward: cost, }) } if capacity >= cost*5 { candidates = append(candidates, rewardCandidate{ tierID: "room_burst_5x", source: sourceRoomAtmosphere, pool: pool, weight: 200, roomReward: cost * 5, }) } return candidates } func activitySubsidyCandidates(cost int64, pool experiencePool, activity *activityBudget, riskCapacity int64) []rewardCandidate { capacity := minInt64(activity.remaining(), riskCapacity) if capacity < cost/2 { if activity.enabled { activity.exhaustedHit++ } return nil } return []rewardCandidate{{ tierID: "activity_rebate_0_5x", source: sourceActivity, pool: pool, weight: 400, activityReward: cost / 2, }} } func hasProductionHighWater(reward int64, req drawRequest) bool { if req.globalRTP.highWaterHeadroom() < reward || req.giftRTP.highWaterHeadroom() < reward { return false } requiredPoolCapacity := reward * req.highWaterPoolMultiple return weightedPoolCapacity(req.platformPool, req.roomPool, req.giftPool, req.weights) >= requiredPoolCapacity } type applyRequest struct { user *userStat room *roomState anchor *anchorState device *deviceState activity *activityBudget globalRTP *rtpWindow giftRTP *rtpWindow platformPool *accountingPool roomBasePool *accountingPool giftPool *accountingPool roomAtmosphere *roomAtmospherePool weights poolWeights cost int64 outcome drawOutcome total *aggregateStat baseRTPPPM int64 tracker100K *blockTracker tracker1M *blockTracker } func applyProductionDraw(req applyRequest) { effectiveReward := req.outcome.effectiveReward() req.user.balance -= req.cost req.user.balance += effectiveReward req.user.draws++ req.user.wager += req.cost req.user.basePayout += req.outcome.baseReward req.user.roomPayout += req.outcome.roomReward req.user.activityPayout += req.outcome.activityReward req.user.effectivePay += effectiveReward if effectiveReward > 0 { req.user.hits++ } if effectiveReward > req.user.maxWin { req.user.maxWin = effectiveReward } req.user.hourly.add(effectiveReward) req.user.daily.add(effectiveReward) req.device.daily.add(effectiveReward) req.room.hourly.add(effectiveReward) req.anchor.daily.add(effectiveReward) req.globalRTP.apply(req.cost, req.outcome.baseReward) req.giftRTP.apply(req.cost, req.outcome.baseReward) debitBasePools(req.outcome.baseReward, req.weights, req.platformPool, req.roomBasePool, req.giftPool) req.roomAtmosphere.debit(req.outcome.roomReward) req.activity.debit(req.outcome.activityReward) applyAggregate(req.total, req.cost, req.outcome) req.tracker100K.observe(req.total.draws, req.total.wager, req.total.basePayout, req.baseRTPPPM) req.tracker1M.observe(req.total.draws, req.total.wager, req.total.basePayout, req.baseRTPPPM) } func applyAggregate(stat *aggregateStat, cost int64, outcome drawOutcome) { stat.draws++ stat.wager += cost stat.basePayout += outcome.baseReward stat.roomPayout += outcome.roomReward stat.activityPayout += outcome.activityReward stat.effectivePayout += outcome.effectiveReward() if outcome.effectiveReward() > 0 { stat.hits++ } if outcome.effectiveReward() > stat.maxWin { stat.maxWin = outcome.effectiveReward() } if outcome.correction { stat.correctionHits++ stat.correctionPayout += outcome.baseReward } switch outcome.pool { case novicePool: stat.noviceDraws++ case intermediatePool: stat.intermediateDraws++ case advancedPool: stat.advancedDraws++ } if outcome.stageFeedback { stat.stageFeedbacks++ } if outcome.presentation { stat.presentationHits++ } if outcome.highMultiplier { stat.highMultiplierHits++ } if outcome.riskCapLimited { stat.riskCapLimitedDraws++ } if outcome.poolCapLimited { stat.poolCapLimitedDraws++ } if outcome.highWaterLimited { stat.highWaterLimitedDraws++ } } func newRTPWindow(name string, windowSize, cost, targetPPM, minFutureMax int64) *rtpWindow { return &rtpWindow{name: name, windowSize: windowSize, cost: cost, targetPPM: targetPPM, minFutureMax: minFutureMax} } func (w *rtpWindow) ensureOpen() { if w.remainingDraws > 0 { return } w.windowIndex++ w.remainingDraws = w.windowSize w.remainingPayout = 0 } func (w *rtpWindow) accrue(cost int64) { targetMicros := cost*w.targetPPM + w.carryMicros w.remainingPayout += targetMicros / ppmScale w.carryMicros = targetMicros % ppmScale } func (w *rtpWindow) minRequired() int64 { if w.remainingDraws <= 1 { return w.remainingPayout } required := w.remainingPayout - w.minFutureMax*(w.remainingDraws-1) if required < 0 { return 0 } return required } func (w *rtpWindow) apply(cost, reward int64) { w.remainingPayout -= reward w.remainingDraws-- w.totalWager += cost w.totalPayout += reward } func (w *rtpWindow) highWaterHeadroom() int64 { if w.remainingDraws <= 0 { return 0 } // 动态 stake 模型只把已发生抽奖的目标返奖计入窗口;这里的剩余应付就是已沉淀的高水位。 return maxInt64(0, w.remainingPayout) } func defaultNoviceTierTable(cost int64) tierTable { return tierTable{ maxReward: cost * 20, maxNum: 20, maxDen: 1, tiers: []rewardTier{ {id: "none", reward: 0, multiplierNum: 0, multiplierDen: 1, weight: 720000}, {id: "novice_feedback_0_2x", reward: cost / 5, multiplierNum: 1, multiplierDen: 5, weight: 90000}, {id: "novice_rebate_0_5x", reward: cost / 2, multiplierNum: 1, multiplierDen: 2, weight: 70000}, {id: "novice_rebate_1x", reward: cost, multiplierNum: 1, multiplierDen: 1, weight: 70000}, {id: "novice_small_2x", reward: cost * 2, multiplierNum: 2, multiplierDen: 1, weight: 35000}, {id: "novice_small_5x", reward: cost * 5, multiplierNum: 5, multiplierDen: 1, weight: 12000}, {id: "novice_medium_10x", reward: cost * 10, multiplierNum: 10, multiplierDen: 1, weight: 2500}, {id: "novice_cap_20x", reward: cost * 20, multiplierNum: 20, multiplierDen: 1, weight: 500}, }, } } func defaultIntermediateTierTable(cost int64) tierTable { return tierTable{ maxReward: cost * 50, maxNum: 50, maxDen: 1, tiers: []rewardTier{ {id: "none", reward: 0, multiplierNum: 0, multiplierDen: 1, weight: 830000}, {id: "inter_rebate_0_5x", reward: cost / 2, multiplierNum: 1, multiplierDen: 2, weight: 25000}, {id: "inter_rebate_1x", reward: cost, multiplierNum: 1, multiplierDen: 1, weight: 40000}, {id: "inter_small_2x", reward: cost * 2, multiplierNum: 2, multiplierDen: 1, weight: 45000}, {id: "inter_medium_5x", reward: cost * 5, multiplierNum: 5, multiplierDen: 1, weight: 35000}, {id: "inter_large_20x", reward: cost * 20, multiplierNum: 20, multiplierDen: 1, weight: 20000, highWaterOnly: true}, {id: "inter_large_50x", reward: cost * 50, multiplierNum: 50, multiplierDen: 1, weight: 5000, highWaterOnly: true}, }, } } func defaultAdvancedTierTable(cost int64) tierTable { return tierTable{ maxReward: cost * 500, maxNum: 500, maxDen: 1, tiers: []rewardTier{ {id: "none", reward: 0, multiplierNum: 0, multiplierDen: 1, weight: 900000}, {id: "adv_small_2x", reward: cost * 2, multiplierNum: 2, multiplierDen: 1, weight: 30000}, {id: "adv_medium_5x", reward: cost * 5, multiplierNum: 5, multiplierDen: 1, weight: 30000}, {id: "adv_large_20x", reward: cost * 20, multiplierNum: 20, multiplierDen: 1, weight: 30000, highWaterOnly: true}, {id: "adv_large_100x", reward: cost * 100, multiplierNum: 100, multiplierDen: 1, weight: 9000, highWaterOnly: true}, {id: "adv_jackpot_500x", reward: cost * 500, multiplierNum: 500, multiplierDen: 1, weight: 1000, highWaterOnly: true}, }, } } func selectExperiencePool(paidDrawNumber, noviceDrawLimit, intermediateDrawLimit int64) experiencePool { if noviceDrawLimit > 0 && paidDrawNumber <= noviceDrawLimit { return novicePool } if intermediateDrawLimit > 0 && paidDrawNumber <= intermediateDrawLimit { return intermediatePool } return advancedPool } func tableForPool(pool experiencePool, noviceTable, intermediateTable, advancedTable tierTable) tierTable { switch pool { case novicePool: return noviceTable case intermediatePool: return intermediateTable default: return advancedTable } } func hasStageFeedback(paidDrawNumber, noviceDrawLimit int64) bool { if noviceDrawLimit <= 0 || paidDrawNumber <= 0 || paidDrawNumber > noviceDrawLimit { return false } return paidDrawNumber == 1 || paidDrawNumber == noviceDrawLimit/4 || paidDrawNumber == noviceDrawLimit/2 || paidDrawNumber == noviceDrawLimit*3/4 || paidDrawNumber == noviceDrawLimit } func buildRooms(count int, initialBasePool, baseReserve, initialAtmosphere, atmosphereReserve int64) []roomState { rooms := make([]roomState, count) for i := range rooms { rooms[i] = roomState{ id: i, anchorID: i, basePool: accountingPool{ name: fmt.Sprintf("room:%d", i+1), balance: initialBasePool, reserveFloor: baseReserve, }, atmosphere: roomAtmospherePool{ roomID: i, balance: initialAtmosphere, reserveFloor: atmosphereReserve, }, } } return rooms } func buildAnchors(count int) []anchorState { anchors := make([]anchorState, count) for i := range anchors { anchors[i].id = i } return anchors } func buildDevices(count int) []deviceState { devices := make([]deviceState, count) for i := range devices { devices[i].id = i } return devices } func creditBasePools(cost, poolRatePPM int64, weights poolWeights, platform, room, gift *accountingPool) { poolIn := cost * poolRatePPM / ppmScale platformIn, roomIn, giftIn := splitWeighted(poolIn, weights) platform.credit(platformIn) room.credit(roomIn) gift.credit(giftIn) } func debitBasePools(reward int64, weights poolWeights, platform, room, gift *accountingPool) { platformOut, roomOut, giftOut := splitWeighted(reward, weights) platform.debit(platformOut) room.debit(roomOut) gift.debit(giftOut) } func splitWeighted(amount int64, weights poolWeights) (int64, int64, int64) { platformAmount := weightedAmount(amount, weights.platformPPM) roomAmount := weightedAmount(amount, weights.roomPPM) giftAmount := amount - platformAmount - roomAmount return platformAmount, roomAmount, giftAmount } func weightedAmount(amount, weightPPM int64) int64 { return amount * weightPPM / ppmScale } func weightedPoolCapacity(platform, room, gift *accountingPool, weights poolWeights) int64 { capacity := int64(math.MaxInt64) if weights.platformPPM > 0 { capacity = minInt64(capacity, platform.capacity()*ppmScale/weights.platformPPM) } if weights.roomPPM > 0 { capacity = minInt64(capacity, room.capacity()*ppmScale/weights.roomPPM) } if weights.giftPPM > 0 { capacity = minInt64(capacity, gift.capacity()*ppmScale/weights.giftPPM) } if capacity == int64(math.MaxInt64) { return 0 } return capacity } func riskRewardCapacity(user *userStat, device *deviceState, room *roomState, anchor *anchorState, risk riskLimits) int64 { capacity := risk.singlePayoutCap capacity = minInt64(capacity, user.hourly.remaining(risk.userHourlyCap)) capacity = minInt64(capacity, user.daily.remaining(risk.userDailyCap)) capacity = minInt64(capacity, device.daily.remaining(risk.deviceDailyCap)) capacity = minInt64(capacity, room.hourly.remaining(risk.roomHourlyCap)) capacity = minInt64(capacity, anchor.daily.remaining(risk.anchorDailyCap)) if capacity < 0 { return 0 } return capacity } type riskInput struct { cost int64 rtpPPM int64 drawsPerHour int64 users int rooms int singleCap int64 userHourlyCap int64 userDailyCap int64 deviceDailyCap int64 roomHourlyCap int64 anchorDailyCap int64 } func buildRiskLimits(input riskInput) riskLimits { expectedUserHour := expectedPayoutForDraws(input.cost, input.rtpPPM, input.drawsPerHour) expectedUserDay := expectedPayoutForDraws(input.cost, input.rtpPPM, input.drawsPerHour*24) usersPerRoom := int64((input.users + input.rooms - 1) / input.rooms) expectedRoomHour := expectedUserHour * usersPerRoom expectedRoomDay := expectedUserDay * usersPerRoom if input.singleCap == 0 { input.singleCap = input.cost * 500 } if input.userHourlyCap == 0 { input.userHourlyCap = expectedUserHour * 2 } if input.userDailyCap == 0 { input.userDailyCap = expectedUserDay * 3 / 2 } if input.deviceDailyCap == 0 { input.deviceDailyCap = expectedUserDay * 5 / 2 } if input.roomHourlyCap == 0 { input.roomHourlyCap = expectedRoomHour * 2 } if input.anchorDailyCap == 0 { input.anchorDailyCap = expectedRoomDay * 3 / 2 } return riskLimits{ singlePayoutCap: input.singleCap, userHourlyCap: input.userHourlyCap, userDailyCap: input.userDailyCap, deviceDailyCap: input.deviceDailyCap, roomHourlyCap: input.roomHourlyCap, anchorDailyCap: input.anchorDailyCap, drawsPerHour: input.drawsPerHour, } } func buildTargetDraws(users int, fixed, minDraws, maxDraws int64, unique bool, rng *rand.Rand) []int64 { targets := make([]int64, users) if minDraws == 0 && maxDraws == 0 { for i := range targets { targets[i] = fixed } return targets } if !unique { for i := range targets { targets[i] = minDraws + rng.Int63n(maxDraws-minDraws+1) } return targets } span := int(maxDraws - minDraws + 1) perm := rng.Perm(span) for i := range targets { targets[i] = minDraws + int64(perm[i]) } return targets } type resultInput struct { users int devices int rooms int drawsPerUser int64 randomDraws bool drawsMin int64 drawsMax int64 uniqueDraws bool initialCoins int64 cost int64 costMin int64 costMax int64 randomCost bool baseRTPPPM int64 poolRatePPM int64 globalWindow int64 giftWindow int64 noviceDraws int64 intermediateDraws int64 risk riskLimits highTierMin int64 highPoolMultiple int64 roomAtmosphereRate int64 activityBudget int64 activityDailyLimit int64 seed int64 } func printResult(input resultInput, total *aggregateStat, users []userStat, rooms []roomState, platformPool, giftPool *accountingPool, activity *activityBudget, globalRTP, giftRTP *rtpWindow, tracker100K, tracker1M *blockTracker) { fmt.Println("Lucky Gift V2 Production Control Demo Result") drawsLabel := fmt.Sprintf("draws_per_user=%d", input.drawsPerUser) if input.randomDraws { drawsLabel = fmt.Sprintf("draws_per_user=random[%d,%d] unique=%t", input.drawsMin, input.drawsMax, input.uniqueDraws) } fmt.Printf("users=%d devices=%d rooms=%d %s paid_draws=%s seed=%d\n", input.users, input.devices, input.rooms, drawsLabel, comma(total.draws), input.seed) costLabel := fmt.Sprintf("%s", comma(input.cost)) if input.randomCost { costLabel = fmt.Sprintf("random[%s,%s]", comma(input.costMin), comma(input.costMax)) } fmt.Printf("initial_coins=%s cost_per_draw=%s base_rtp_target=%.4f%% pool_rate=%.4f%% global_window=%s gift_window=%s\n", comma(input.initialCoins), costLabel, float64(input.baseRTPPPM)/10000, float64(input.poolRatePPM)/10000, comma(input.globalWindow), comma(input.giftWindow)) fmt.Printf("novice_draws=%s intermediate_draws=%s high_tier_min_reward=%s high_pool_multiple=%dx\n", comma(input.noviceDraws), comma(input.intermediateDraws), comma(input.highTierMin), input.highPoolMultiple) fmt.Printf("single_cap=%s user_hourly_cap=%s user_daily_cap=%s device_daily_cap=%s room_hourly_cap=%s anchor_daily_cap=%s draws_per_hour=%s\n", comma(input.risk.singlePayoutCap), comma(input.risk.userHourlyCap), comma(input.risk.userDailyCap), comma(input.risk.deviceDailyCap), comma(input.risk.roomHourlyCap), comma(input.risk.anchorDailyCap), comma(input.risk.drawsPerHour)) fmt.Printf("room_atmosphere_rate=%.4f%% activity_budget=%s activity_daily_limit=%s\n", float64(input.roomAtmosphereRate)/10000, comma(input.activityBudget), comma(input.activityDailyLimit)) fmt.Println() fmt.Println("Aggregate") fmt.Printf("platform_income=%s\n", comma(total.wager)) fmt.Printf("base_reward_payout=%s\n", comma(total.basePayout)) fmt.Printf("room_atmosphere_payout=%s\n", comma(total.roomPayout)) fmt.Printf("activity_subsidy_payout=%s\n", comma(total.activityPayout)) fmt.Printf("total_user_visible_payout=%s\n", comma(total.effectivePayout)) fmt.Printf("platform_net_after_visible_payout=%s\n", comma(total.wager-total.effectivePayout)) fmt.Printf("base_rtp=%.6f%% deviation=%+.6fpp\n", rtpPercent(total.wager, total.basePayout), deviationPP(total.wager, total.basePayout, input.baseRTPPPM)) fmt.Printf("effective_rtp=%.6f%% subsidy_lift=%+.6fpp\n", rtpPercent(total.wager, total.effectivePayout), rtpPercent(total.wager, total.effectivePayout)-rtpPercent(total.wager, total.basePayout)) fmt.Printf("hits=%s hit_rate=%.4f%% max_visible_win=%s\n", comma(total.hits), ratioPercent(total.hits, total.draws), comma(total.maxWin)) fmt.Printf("rtp_balance_hits=%s rtp_balance_payout=%s\n", comma(total.correctionHits), comma(total.correctionPayout)) fmt.Println() fmt.Println("Experience And Risk") fmt.Printf("novice_draws=%s intermediate_draws=%s advanced_draws=%s stage_feedback_events=%s presentation_hits=%s\n", comma(total.noviceDraws), comma(total.intermediateDraws), comma(total.advancedDraws), comma(total.stageFeedbacks), comma(total.presentationHits)) fmt.Printf("high_multiplier_hits=%s high_water_limited_draws=%s risk_cap_limited_draws=%s pool_cap_limited_draws=%s\n", comma(total.highMultiplierHits), comma(total.highWaterLimitedDraws), comma(total.riskCapLimitedDraws), comma(total.poolCapLimitedDraws)) fmt.Println() fmt.Println("RTP Windows") fmt.Printf("global_base_rtp=%.6f%% gift_base_rtp=%.6f%%\n", rtpPercent(globalRTP.totalWager, globalRTP.totalPayout), rtpPercent(giftRTP.totalWager, giftRTP.totalPayout)) fmt.Printf("100k_blocks=%d max_abs_deviation=%+.6fpp status=%s\n", tracker100K.closed, tracker100K.maxAbsDevPP, passFail(tracker100K.maxAbsDevPP < 1)) fmt.Printf("1m_blocks=%d max_abs_deviation=%+.6fpp status=%s\n", tracker1M.closed, tracker1M.maxAbsDevPP, passFail(tracker1M.maxAbsDevPP < 0.5)) fmt.Println() printPoolModel(rooms, platformPool, giftPool, activity) printUserModel(users) } func printPoolModel(rooms []roomState, platformPool, giftPool *accountingPool, activity *activityBudget) { roomBalances := make([]int64, len(rooms)) atmosphereBalances := make([]int64, len(rooms)) for i := range rooms { roomBalances[i] = rooms[i].basePool.balance atmosphereBalances[i] = rooms[i].atmosphere.balance } sort.Slice(roomBalances, func(i, j int) bool { return roomBalances[i] < roomBalances[j] }) sort.Slice(atmosphereBalances, func(i, j int) bool { return atmosphereBalances[i] < atmosphereBalances[j] }) fmt.Println("Pool Model") fmt.Printf("platform_pool_balance=%s gift_pool_balance=%s activity_remaining=%s\n", comma(platformPool.balance), comma(giftPool.balance), comma(activity.remaining())) fmt.Printf("room_pool_balance_p05=%s median=%s p95=%s\n", comma(quantile(roomBalances, 0.05)), comma(quantile(roomBalances, 0.50)), comma(quantile(roomBalances, 0.95))) fmt.Printf("room_atmosphere_balance_p05=%s median=%s p95=%s\n", comma(quantile(atmosphereBalances, 0.05)), comma(quantile(atmosphereBalances, 0.50)), comma(quantile(atmosphereBalances, 0.95))) fmt.Println() } func printUserModel(users []userStat) { baseRTPs := make([]float64, len(users)) effectiveRTPs := make([]float64, len(users)) nets := make([]int64, len(users)) balances := make([]int64, len(users)) for i, user := range users { baseRTPs[i] = rtpPercent(user.wager, user.basePayout) effectiveRTPs[i] = rtpPercent(user.wager, user.effectivePay) nets[i] = user.effectivePay - user.wager balances[i] = user.balance } sort.Slice(baseRTPs, func(i, j int) bool { return baseRTPs[i] < baseRTPs[j] }) sort.Slice(effectiveRTPs, func(i, j int) bool { return effectiveRTPs[i] < effectiveRTPs[j] }) 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] }) fmt.Println("User Result Model") fmt.Printf("user_base_rtp_p05=%.6f%% median=%.6f%% p95=%.6f%%\n", quantileFloat(baseRTPs, 0.05), quantileFloat(baseRTPs, 0.50), quantileFloat(baseRTPs, 0.95)) fmt.Printf("user_effective_rtp_p05=%.6f%% median=%.6f%% p95=%.6f%%\n", quantileFloat(effectiveRTPs, 0.05), quantileFloat(effectiveRTPs, 0.50), quantileFloat(effectiveRTPs, 0.95)) fmt.Printf("user_net_p05=%s median=%s p95=%s\n", comma(quantile(nets, 0.05)), comma(quantile(nets, 0.50)), comma(quantile(nets, 0.95))) fmt.Printf("final_balance_p05=%s median=%s p95=%s\n", comma(quantile(balances, 0.05)), comma(quantile(balances, 0.50)), comma(quantile(balances, 0.95))) } func writeUserCSV(path string, users []userStat) error { path = strings.TrimSpace(path) if path == "" { return nil } if dir := filepath.Dir(path); dir != "." && dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return err } } 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", "device_id", "room_id", "anchor_id", "target_draws", "actual_draws", "wager", "base_payout", "room_atmosphere_payout", "activity_subsidy_payout", "effective_payout", "base_rtp_percent", "effective_rtp_percent", "hits", "max_win", "net", "final_balance", }); err != nil { return err } for _, user := range users { if err := writer.Write([]string{ fmt.Sprintf("%d", user.id), fmt.Sprintf("%d", user.deviceID+1), fmt.Sprintf("%d", user.roomID+1), fmt.Sprintf("%d", user.anchorID+1), fmt.Sprintf("%d", user.targetDraws), fmt.Sprintf("%d", user.draws), fmt.Sprintf("%d", user.wager), fmt.Sprintf("%d", user.basePayout), fmt.Sprintf("%d", user.roomPayout), fmt.Sprintf("%d", user.activityPayout), fmt.Sprintf("%d", user.effectivePay), fmt.Sprintf("%.6f", rtpPercent(user.wager, user.basePayout)), fmt.Sprintf("%.6f", rtpPercent(user.wager, user.effectivePay)), fmt.Sprintf("%d", user.hits), fmt.Sprintf("%d", user.maxWin), fmt.Sprintf("%d", user.effectivePay-user.wager), fmt.Sprintf("%d", user.balance), }); err != nil { return err } } return writer.Error() } 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.closed++ b.lastWager = totalWager b.lastPayout = totalPayout } func percentToPPM(percent float64) int64 { return int64(math.Round(percent * 10000)) } func expectedPayoutForDraws(cost, rtpPPM, draws int64) int64 { return (cost*rtpPPM*draws + ppmScale - 1) / ppmScale } func validateRTPCapacity(cost, rtpPPM, maxReward int64) error { targetAverage := float64(cost) * float64(rtpPPM) / float64(ppmScale) if targetAverage > float64(maxReward) { return fmt.Errorf("target average payout %.2f exceeds max future reward %d", targetAverage, maxReward) } return nil } func validateRiskLimits(cost, rtpPPM int64, risk riskLimits) error { if risk.singlePayoutCap <= 0 || risk.userHourlyCap <= 0 || risk.userDailyCap <= 0 || risk.deviceDailyCap <= 0 || risk.roomHourlyCap <= 0 || risk.anchorDailyCap <= 0 { return fmt.Errorf("risk caps must be positive") } expectedUserHour := expectedPayoutForDraws(cost, rtpPPM, risk.drawsPerHour) expectedUserDay := expectedPayoutForDraws(cost, rtpPPM, risk.drawsPerHour*24) if risk.userHourlyCap < expectedUserHour { return fmt.Errorf("user hourly cap %d is below expected hourly base payout %d", risk.userHourlyCap, expectedUserHour) } if risk.userDailyCap < expectedUserDay { return fmt.Errorf("user daily cap %d is below expected daily base payout %d", risk.userDailyCap, expectedUserDay) } return nil } func correctionTierID(reward int64) string { if reward == 0 { return "none" } return "rtp_balance" } func isHighMultiplier(reward, highMultiplierMinReward int64) bool { return highMultiplierMinReward > 0 && reward >= highMultiplierMinReward } func minInt64(a, b int64) int64 { if a < b { return a } return b } func maxInt64(a, b int64) int64 { if a > b { return a } return b } func quantile(sorted []int64, q float64) int64 { if len(sorted) == 0 { return 0 } index := int(math.Round(q * float64(len(sorted)-1))) return sorted[index] } func quantileFloat(sorted []float64, q float64) float64 { if len(sorted) == 0 { return 0 } 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 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() }