1505 lines
49 KiB
Go
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()
|
|
}
|