646 lines
19 KiB
Go
646 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"math/rand"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
const ppmScale int64 = 1_000_000
|
|
|
|
type poolKind string
|
|
|
|
const (
|
|
poolNovice poolKind = "novice"
|
|
poolIntermediate poolKind = "intermediate"
|
|
poolAdvanced poolKind = "advanced"
|
|
)
|
|
|
|
type tier struct {
|
|
id string
|
|
reward int64
|
|
weight int64
|
|
}
|
|
|
|
// poolProfile is a UX profile, not a money owner. RTP is enforced by the
|
|
// controller; these weights only decide which payable tier is preferred.
|
|
type poolProfile struct {
|
|
kind poolKind
|
|
maxReward int64
|
|
tiers []tier
|
|
}
|
|
|
|
type drawOutcome struct {
|
|
tierID string
|
|
reward int64
|
|
correction bool
|
|
}
|
|
|
|
// rtpController keeps one aggregate payout budget per fixed paid-draw window.
|
|
// It filters before random selection, so the demo never rewrites a result
|
|
// after the tier has been selected.
|
|
type rtpController struct {
|
|
windowSize int64
|
|
cost int64
|
|
rtpPPM int64
|
|
minFutureMax int64
|
|
|
|
carryMicros int64
|
|
windowIndex int64
|
|
remainingDraws int64
|
|
remainingPayout int64
|
|
}
|
|
|
|
type userStat struct {
|
|
targetDraws int64
|
|
draws int64
|
|
wager int64
|
|
payout int64
|
|
hits int64
|
|
maxWin int64
|
|
balance int64
|
|
topUp int64
|
|
stopped bool
|
|
}
|
|
|
|
type aggregateStat struct {
|
|
draws int64
|
|
wager int64
|
|
payout int64
|
|
hits int64
|
|
maxWin int64
|
|
topUp int64
|
|
correctionHits int64
|
|
correctionPayout int64
|
|
stoppedUsers int64
|
|
}
|
|
|
|
type blockTracker struct {
|
|
size int64
|
|
lastDraws int64
|
|
lastWager int64
|
|
lastPayout int64
|
|
closedBlocks int64
|
|
maxAbsDevPP float64
|
|
lastBlockDevPP float64
|
|
}
|
|
|
|
func main() {
|
|
usersFlag := flag.Int("users", 1000, "number of simulated users")
|
|
drawsFlag := flag.Int64("draws", 100000, "paid draw attempts per user")
|
|
drawsMinFlag := flag.Int64("draws-min", 0, "optional minimum paid draw attempts per user")
|
|
drawsMaxFlag := flag.Int64("draws-max", 0, "optional maximum paid draw attempts per user")
|
|
rtpFlag := flag.Float64("rtp", 95, "target RTP as percent, so 95 means 95%")
|
|
initialFlag := flag.Int64("initial", 10000, "initial coins per user")
|
|
costFlag := flag.Int64("cost", 500, "coin cost per paid draw")
|
|
windowFlag := flag.Int64("window", 100000, "RTP control window paid draws")
|
|
noviceLimitFlag := flag.Int64("novice-draws", 2000, "inclusive paid draw count for novice pool")
|
|
intermediateLimitFlag := flag.Int64("intermediate-draws", 20000, "inclusive paid draw count for intermediate pool")
|
|
seedFlag := flag.Int64("seed", 20260516, "deterministic random seed")
|
|
strictWalletFlag := flag.Bool("strict-wallet", false, "stop a user when balance is below draw cost instead of adding test top-up")
|
|
userCSVFlag := flag.String("user-csv", "", "optional path to write per-user RTP and final balance CSV")
|
|
flag.Parse()
|
|
|
|
if *usersFlag <= 0 || *initialFlag < 0 || *costFlag <= 0 || *windowFlag <= 0 {
|
|
log.Fatal("users, cost, and window must be positive; initial must be non-negative")
|
|
}
|
|
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")
|
|
}
|
|
} else if *drawsFlag <= 0 {
|
|
log.Fatal("draws must be positive when draws-min/draws-max are not set")
|
|
}
|
|
if *noviceLimitFlag <= 0 || *intermediateLimitFlag < *noviceLimitFlag {
|
|
log.Fatal("pool limits must satisfy 0 < novice-draws <= intermediate-draws")
|
|
}
|
|
rtpPPM := percentToPPM(*rtpFlag)
|
|
if rtpPPM < 0 {
|
|
log.Fatal("rtp must be non-negative")
|
|
}
|
|
|
|
profiles := defaultProfiles(*costFlag)
|
|
minFutureMax := minProfileMaxReward(profiles)
|
|
if err := validateRTPCapacity(*costFlag, rtpPPM, minFutureMax); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
rng := rand.New(rand.NewSource(*seedFlag))
|
|
controller := newRTPController(*windowFlag, *costFlag, rtpPPM, minFutureMax)
|
|
users := make([]userStat, *usersFlag)
|
|
for i := range users {
|
|
users[i].balance = *initialFlag
|
|
users[i].targetDraws = *drawsFlag
|
|
if randomDraws {
|
|
users[i].targetDraws = *drawsMinFlag + rng.Int63n(*drawsMaxFlag-*drawsMinFlag+1)
|
|
}
|
|
}
|
|
|
|
total := &aggregateStat{}
|
|
poolStats := map[poolKind]*aggregateStat{
|
|
poolNovice: {},
|
|
poolIntermediate: {},
|
|
poolAdvanced: {},
|
|
}
|
|
tracker100K := &blockTracker{size: 100000}
|
|
tracker1M := &blockTracker{size: 1000000}
|
|
|
|
maxRounds := *drawsFlag
|
|
if randomDraws {
|
|
maxRounds = *drawsMaxFlag
|
|
}
|
|
for drawRound := int64(0); drawRound < maxRounds; drawRound++ {
|
|
for userIndex := range users {
|
|
u := &users[userIndex]
|
|
if u.stopped {
|
|
continue
|
|
}
|
|
if u.draws >= u.targetDraws {
|
|
continue
|
|
}
|
|
if u.balance < *costFlag {
|
|
if *strictWalletFlag {
|
|
u.stopped = true
|
|
total.stoppedUsers++
|
|
continue
|
|
}
|
|
// Fixed-draw RTP pressure tests need every requested draw to be paid.
|
|
// Top-up is test bankroll only; it is not counted as payout or RTP.
|
|
topUp := *initialFlag
|
|
if need := *costFlag - u.balance; topUp < need {
|
|
topUp = need
|
|
}
|
|
u.balance += topUp
|
|
u.topUp += topUp
|
|
total.topUp += topUp
|
|
}
|
|
|
|
pool := choosePool(u.draws+1, *noviceLimitFlag, *intermediateLimitFlag)
|
|
outcome, err := controller.next(profiles[pool], rng)
|
|
if err != nil {
|
|
log.Fatalf("draw failed after %d paid draws: %v", total.draws, err)
|
|
}
|
|
|
|
u.balance -= *costFlag
|
|
u.balance += outcome.reward
|
|
applyDraw(u, total, *costFlag, outcome)
|
|
applyDraw(nil, poolStats[pool], *costFlag, outcome)
|
|
|
|
tracker100K.observe(total.draws, total.wager, total.payout, rtpPPM)
|
|
tracker1M.observe(total.draws, total.wager, total.payout, rtpPPM)
|
|
}
|
|
}
|
|
|
|
if *strictWalletFlag {
|
|
for i := range users {
|
|
if users[i].stopped {
|
|
continue
|
|
}
|
|
if users[i].balance < *costFlag {
|
|
total.stoppedUsers++
|
|
}
|
|
}
|
|
}
|
|
|
|
printResult(resultInput{
|
|
users: *usersFlag,
|
|
drawsPerUser: *drawsFlag,
|
|
randomDraws: randomDraws,
|
|
drawsMin: *drawsMinFlag,
|
|
drawsMax: *drawsMaxFlag,
|
|
rtpPPM: rtpPPM,
|
|
initialCoins: *initialFlag,
|
|
cost: *costFlag,
|
|
windowSize: *windowFlag,
|
|
noviceLimit: *noviceLimitFlag,
|
|
intermediateLimit: *intermediateLimitFlag,
|
|
seed: *seedFlag,
|
|
strictWallet: *strictWalletFlag,
|
|
}, total, poolStats, users, 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)
|
|
}
|
|
}
|
|
|
|
func newRTPController(windowSize, cost, rtpPPM, minFutureMax int64) *rtpController {
|
|
return &rtpController{
|
|
windowSize: windowSize,
|
|
cost: cost,
|
|
rtpPPM: rtpPPM,
|
|
minFutureMax: minFutureMax,
|
|
}
|
|
}
|
|
|
|
func (c *rtpController) next(profile *poolProfile, rng *rand.Rand) (drawOutcome, error) {
|
|
if c.remainingDraws == 0 {
|
|
c.openWindow()
|
|
}
|
|
if c.remainingDraws == 1 {
|
|
return c.applyExactPayout(profile, c.remainingPayout)
|
|
}
|
|
|
|
// minRequired is the smallest payout that still lets future draws close the
|
|
// current RTP window without exceeding the lowest max reward across pools.
|
|
remainingAfter := c.remainingDraws - 1
|
|
minRequired := c.remainingPayout - c.minFutureMax*remainingAfter
|
|
if minRequired < 0 {
|
|
minRequired = 0
|
|
}
|
|
|
|
var candidates [16]tier
|
|
n := 0
|
|
var totalWeight int64
|
|
for _, candidate := range profile.tiers {
|
|
if candidate.reward < minRequired || candidate.reward > c.remainingPayout {
|
|
continue
|
|
}
|
|
candidates[n] = candidate
|
|
n++
|
|
totalWeight += candidate.weight
|
|
}
|
|
|
|
if n == 0 {
|
|
return c.applyExactPayout(profile, minRequired)
|
|
}
|
|
|
|
roll := rng.Int63n(totalWeight)
|
|
selected := candidates[0]
|
|
for i := 0; i < n; i++ {
|
|
if roll < candidates[i].weight {
|
|
selected = candidates[i]
|
|
break
|
|
}
|
|
roll -= candidates[i].weight
|
|
}
|
|
return c.applyTier(selected, false)
|
|
}
|
|
|
|
func (c *rtpController) openWindow() {
|
|
// carryMicros preserves fractional ppm value across windows. That keeps
|
|
// arbitrary RTP inputs stable without floating-point drift.
|
|
targetMicros := c.windowSize*c.cost*c.rtpPPM + c.carryMicros
|
|
c.windowIndex++
|
|
c.remainingDraws = c.windowSize
|
|
c.remainingPayout = targetMicros / ppmScale
|
|
c.carryMicros = targetMicros % ppmScale
|
|
}
|
|
|
|
func (c *rtpController) applyExactPayout(profile *poolProfile, reward int64) (drawOutcome, error) {
|
|
if reward < 0 {
|
|
return drawOutcome{}, fmt.Errorf("negative exact reward: %d", reward)
|
|
}
|
|
if reward > profile.maxReward {
|
|
return drawOutcome{}, fmt.Errorf("exact reward %d exceeds %s max reward %d", reward, profile.kind, profile.maxReward)
|
|
}
|
|
tierID := "none"
|
|
correction := false
|
|
if reward > 0 {
|
|
// rtp_balance is a pre-random eligible balancing tier. In production it
|
|
// should be represented in audit data, not hidden as a post-draw change.
|
|
tierID = "rtp_balance"
|
|
correction = true
|
|
}
|
|
return c.applyTier(tier{id: tierID, reward: reward, weight: 1}, correction)
|
|
}
|
|
|
|
func (c *rtpController) applyTier(selected tier, correction bool) (drawOutcome, error) {
|
|
if selected.reward < 0 || selected.reward > c.remainingPayout {
|
|
return drawOutcome{}, fmt.Errorf("tier %s reward %d cannot consume remaining payout %d", selected.id, selected.reward, c.remainingPayout)
|
|
}
|
|
c.remainingPayout -= selected.reward
|
|
c.remainingDraws--
|
|
return drawOutcome{
|
|
tierID: selected.id,
|
|
reward: selected.reward,
|
|
correction: correction,
|
|
}, nil
|
|
}
|
|
|
|
func defaultProfiles(cost int64) map[poolKind]*poolProfile {
|
|
// The three profiles deliberately use different variance shapes while the
|
|
// controller keeps the aggregate RTP equal to the configured target.
|
|
return map[poolKind]*poolProfile{
|
|
poolNovice: {
|
|
kind: poolNovice,
|
|
maxReward: cost * 10,
|
|
tiers: []tier{
|
|
{id: "none", reward: 0, weight: 270000},
|
|
{id: "rebate_0_5x", reward: cost / 2, weight: 200000},
|
|
{id: "rebate_1x", reward: cost, weight: 350000},
|
|
{id: "small_2x", reward: cost * 2, weight: 140000},
|
|
{id: "small_5x", reward: cost * 5, weight: 35000},
|
|
{id: "small_10x", reward: cost * 10, weight: 5000},
|
|
},
|
|
},
|
|
poolIntermediate: {
|
|
kind: poolIntermediate,
|
|
maxReward: cost * 50,
|
|
tiers: []tier{
|
|
{id: "none", reward: 0, weight: 760000},
|
|
{id: "rebate_1x", reward: cost, weight: 100000},
|
|
{id: "small_2x", reward: cost * 2, weight: 80000},
|
|
{id: "medium_5x", reward: cost * 5, weight: 45000},
|
|
{id: "medium_20x", reward: cost * 20, weight: 13000},
|
|
{id: "medium_50x", reward: cost * 50, weight: 2000},
|
|
},
|
|
},
|
|
poolAdvanced: {
|
|
kind: poolAdvanced,
|
|
maxReward: cost * 500,
|
|
tiers: []tier{
|
|
{id: "none", reward: 0, weight: 900000},
|
|
{id: "small_2x", reward: cost * 2, weight: 50000},
|
|
{id: "medium_5x", reward: cost * 5, weight: 30000},
|
|
{id: "large_20x", reward: cost * 20, weight: 15000},
|
|
{id: "large_100x", reward: cost * 100, weight: 4500},
|
|
{id: "jackpot_500x", reward: cost * 500, weight: 500},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func choosePool(paidDrawNumber, noviceLimit, intermediateLimit int64) poolKind {
|
|
if paidDrawNumber <= noviceLimit {
|
|
return poolNovice
|
|
}
|
|
if paidDrawNumber <= intermediateLimit {
|
|
return poolIntermediate
|
|
}
|
|
return poolAdvanced
|
|
}
|
|
|
|
func applyDraw(user *userStat, stat *aggregateStat, cost int64, outcome drawOutcome) {
|
|
stat.draws++
|
|
stat.wager += cost
|
|
stat.payout += outcome.reward
|
|
if outcome.reward > 0 {
|
|
stat.hits++
|
|
}
|
|
if outcome.reward > stat.maxWin {
|
|
stat.maxWin = outcome.reward
|
|
}
|
|
if outcome.correction {
|
|
stat.correctionHits++
|
|
stat.correctionPayout += outcome.reward
|
|
}
|
|
if user == nil {
|
|
return
|
|
}
|
|
user.draws++
|
|
user.wager += cost
|
|
user.payout += outcome.reward
|
|
if outcome.reward > 0 {
|
|
user.hits++
|
|
}
|
|
if outcome.reward > user.maxWin {
|
|
user.maxWin = outcome.reward
|
|
}
|
|
}
|
|
|
|
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.lastBlockDevPP = devPP
|
|
b.closedBlocks++
|
|
b.lastDraws = totalDraws
|
|
b.lastWager = totalWager
|
|
b.lastPayout = totalPayout
|
|
}
|
|
|
|
func percentToPPM(percent float64) int64 {
|
|
return int64(math.Round(percent * 10000))
|
|
}
|
|
|
|
func validateRTPCapacity(cost, rtpPPM, minFutureMax int64) error {
|
|
targetAverage := float64(cost) * float64(rtpPPM) / float64(ppmScale)
|
|
if targetAverage > float64(minFutureMax) {
|
|
return fmt.Errorf("target average payout %.2f exceeds minimum future max reward %d; increase pool max rewards or lower RTP", targetAverage, minFutureMax)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func minProfileMaxReward(profiles map[poolKind]*poolProfile) int64 {
|
|
minValue := int64(math.MaxInt64)
|
|
for _, profile := range profiles {
|
|
if profile.maxReward < minValue {
|
|
minValue = profile.maxReward
|
|
}
|
|
}
|
|
return minValue
|
|
}
|
|
|
|
type resultInput struct {
|
|
users int
|
|
drawsPerUser int64
|
|
randomDraws bool
|
|
drawsMin int64
|
|
drawsMax int64
|
|
rtpPPM int64
|
|
initialCoins int64
|
|
cost int64
|
|
windowSize int64
|
|
noviceLimit int64
|
|
intermediateLimit int64
|
|
seed int64
|
|
strictWallet bool
|
|
}
|
|
|
|
func printResult(input resultInput, total *aggregateStat, poolStats map[poolKind]*aggregateStat, users []userStat, tracker100K, tracker1M *blockTracker) {
|
|
actualRTP := rtpPercent(total.wager, total.payout)
|
|
targetRTP := float64(input.rtpPPM) / 10000
|
|
totalDeviation := actualRTP - targetRTP
|
|
plannedDraws := int64(input.users) * input.drawsPerUser
|
|
drawsLabel := fmt.Sprintf("draws_per_user=%d planned_paid_draws=%s", input.drawsPerUser, comma(plannedDraws))
|
|
if input.randomDraws {
|
|
drawsLabel = fmt.Sprintf("draws_per_user=random[%d,%d] actual_paid_draws=%s", input.drawsMin, input.drawsMax, comma(total.draws))
|
|
}
|
|
|
|
fmt.Println("Lucky Gift RTP Demo Result")
|
|
fmt.Printf("mode=%s users=%d %s seed=%d\n", modeName(input.strictWallet), input.users, drawsLabel, input.seed)
|
|
fmt.Printf("initial_coins=%s cost_per_draw=%s target_rtp=%.4f%% control_window=%s\n", comma(input.initialCoins), comma(input.cost), targetRTP, comma(input.windowSize))
|
|
fmt.Printf("pool_rule=novice[1,%d] intermediate[%d,%d] advanced[%d,+inf)\n", input.noviceLimit, input.noviceLimit+1, input.intermediateLimit, input.intermediateLimit+1)
|
|
fmt.Println()
|
|
|
|
fmt.Println("Aggregate")
|
|
fmt.Printf("paid_draws=%s wager=%s payout=%s actual_rtp=%.6f%% deviation=%+.6fpp\n", comma(total.draws), comma(total.wager), comma(total.payout), actualRTP, totalDeviation)
|
|
fmt.Printf("hits=%s hit_rate=%.4f%% max_win=%s test_top_up=%s stopped_users=%d\n", comma(total.hits), ratioPercent(total.hits, total.draws), comma(total.maxWin), comma(total.topUp), total.stoppedUsers)
|
|
fmt.Printf("rtp_balance_hits=%s rtp_balance_payout=%s\n", comma(total.correctionHits), comma(total.correctionPayout))
|
|
fmt.Println()
|
|
|
|
fmt.Println("RTP Window Check")
|
|
fmt.Printf("100k_blocks=%d max_abs_deviation=%+.6fpp required=<1.000000pp status=%s\n", tracker100K.closedBlocks, tracker100K.maxAbsDevPP, passFail(tracker100K.maxAbsDevPP < 1))
|
|
fmt.Printf("1m_blocks=%d max_abs_deviation=%+.6fpp required=<0.500000pp status=%s\n", tracker1M.closedBlocks, tracker1M.maxAbsDevPP, passFail(tracker1M.maxAbsDevPP < 0.5))
|
|
fmt.Println()
|
|
|
|
fmt.Println("Pool Model")
|
|
fmt.Printf("%-14s %14s %14s %14s %12s %12s %12s\n", "pool", "draws", "wager", "payout", "rtp", "hit_rate", "max_win")
|
|
for _, kind := range []poolKind{poolNovice, poolIntermediate, poolAdvanced} {
|
|
stat := poolStats[kind]
|
|
fmt.Printf("%-14s %14s %14s %14s %11.4f%% %11.4f%% %12s\n",
|
|
kind,
|
|
comma(stat.draws),
|
|
comma(stat.wager),
|
|
comma(stat.payout),
|
|
rtpPercent(stat.wager, stat.payout),
|
|
ratioPercent(stat.hits, stat.draws),
|
|
comma(stat.maxWin),
|
|
)
|
|
}
|
|
fmt.Println()
|
|
|
|
printUserModel(users)
|
|
if !input.strictWallet {
|
|
fmt.Println()
|
|
fmt.Println("Note")
|
|
fmt.Println("auto-top-up only keeps the requested fixed draw count running; RTP uses wager and payout only.")
|
|
}
|
|
}
|
|
|
|
func printUserModel(users []userStat) {
|
|
if len(users) == 0 {
|
|
return
|
|
}
|
|
nets := make([]int64, len(users))
|
|
balances := make([]int64, len(users))
|
|
draws := make([]int64, len(users))
|
|
var totalNet, totalBalance, totalTopUp int64
|
|
for i, u := range users {
|
|
net := u.payout - u.wager
|
|
nets[i] = net
|
|
balances[i] = u.balance
|
|
draws[i] = u.draws
|
|
totalNet += net
|
|
totalBalance += u.balance
|
|
totalTopUp += u.topUp
|
|
}
|
|
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] })
|
|
sort.Slice(draws, func(i, j int) bool { return draws[i] < draws[j] })
|
|
|
|
fmt.Println("User Result Model")
|
|
fmt.Printf("avg_net=%s p05_net=%s median_net=%s p95_net=%s\n", comma(totalNet/int64(len(users))), comma(quantile(nets, 0.05)), comma(quantile(nets, 0.50)), comma(quantile(nets, 0.95)))
|
|
fmt.Printf("avg_final_balance=%s p05_balance=%s median_balance=%s p95_balance=%s\n", comma(totalBalance/int64(len(users))), comma(quantile(balances, 0.05)), comma(quantile(balances, 0.50)), comma(quantile(balances, 0.95)))
|
|
fmt.Printf("avg_top_up=%s min_paid_draws=%s median_paid_draws=%s max_paid_draws=%s\n", comma(totalTopUp/int64(len(users))), comma(draws[0]), comma(quantile(draws, 0.50)), comma(draws[len(draws)-1]))
|
|
}
|
|
|
|
func writeUserCSV(path string, users []userStat) error {
|
|
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", "target_draws", "paid_draws", "wager", "payout", "rtp_percent", "final_coins", "net_profit", "hit_count", "max_win", "test_top_up"}); err != nil {
|
|
return err
|
|
}
|
|
for i, user := range users {
|
|
record := []string{
|
|
fmt.Sprintf("%d", i+1),
|
|
fmt.Sprintf("%d", user.targetDraws),
|
|
fmt.Sprintf("%d", user.draws),
|
|
fmt.Sprintf("%d", user.wager),
|
|
fmt.Sprintf("%d", user.payout),
|
|
fmt.Sprintf("%.6f", rtpPercent(user.wager, user.payout)),
|
|
fmt.Sprintf("%d", user.balance),
|
|
fmt.Sprintf("%d", user.payout-user.wager),
|
|
fmt.Sprintf("%d", user.hits),
|
|
fmt.Sprintf("%d", user.maxWin),
|
|
fmt.Sprintf("%d", user.topUp),
|
|
}
|
|
if err := writer.Write(record); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return writer.Error()
|
|
}
|
|
|
|
func quantile(sorted []int64, q float64) int64 {
|
|
if len(sorted) == 0 {
|
|
return 0
|
|
}
|
|
if q <= 0 {
|
|
return sorted[0]
|
|
}
|
|
if q >= 1 {
|
|
return sorted[len(sorted)-1]
|
|
}
|
|
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 modeName(strictWallet bool) string {
|
|
if strictWallet {
|
|
return "strict_wallet"
|
|
}
|
|
return "fixed_draws_with_test_top_up"
|
|
}
|
|
|
|
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()
|
|
}
|