2026-05-20 10:22:35 +08:00

1505 lines
49 KiB
Go

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()
}