649 lines
27 KiB
Go
649 lines
27 KiB
Go
package luckygift
|
||
|
||
import (
|
||
"fmt"
|
||
"reflect"
|
||
"testing"
|
||
)
|
||
|
||
type scriptedStrategyRandom struct {
|
||
t *testing.T
|
||
indexes []int64
|
||
bounds []int64
|
||
position int
|
||
}
|
||
|
||
func (r *scriptedStrategyRandom) Int63n(n int64) (int64, error) {
|
||
r.t.Helper()
|
||
if r.position >= len(r.indexes) {
|
||
return 0, fmt.Errorf("scripted RNG exhausted at bound %d", n)
|
||
}
|
||
if len(r.bounds) > r.position && r.bounds[r.position] != n {
|
||
r.t.Fatalf("random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
|
||
}
|
||
value := r.indexes[r.position]
|
||
r.position++
|
||
return value, nil
|
||
}
|
||
|
||
func strategyTestConfig() StrategyConfig {
|
||
config := DefaultLuckyGiftStrategyConfig()
|
||
// The 0/5/50/200/1000 table is the supplied image's deliberately unsafe
|
||
// 2650% example. It is a test fixture only; the exported default stays at 98%.
|
||
config.Tiers = []StrategyTier{
|
||
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
|
||
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
|
||
{ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true},
|
||
{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Enabled: true},
|
||
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Enabled: true},
|
||
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Enabled: true},
|
||
{ID: "jackpot_200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
|
||
{ID: "jackpot_500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true},
|
||
{ID: "jackpot_1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
|
||
}
|
||
// Core P/W tests isolate the base distribution; dynamic factors have dedicated
|
||
// boundary tests below and must not silently move scripted intervals here.
|
||
config.LowWaterThresholdCoins = 0
|
||
config.HighWaterThresholdCoins = 1_000_000_000
|
||
config.LowWaterFactorPPM = StrategyPPMScale
|
||
config.HighWaterFactorPPM = StrategyPPMScale
|
||
config.RechargeFactorPPM = StrategyPPMScale
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
config.DailyJackpotLimit = 1_000_000
|
||
return config
|
||
}
|
||
|
||
func strategyTestInput(price int64) StrategyInput {
|
||
return StrategyInput{GiftPriceCoins: price}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyDeletesUnaffordableTierAndZeroBeforeRedraw(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
random := &scriptedStrategyRandom{
|
||
t: t,
|
||
// 950000 selects 200x from the original 1,000,000 range. After deleting
|
||
// 200x+0x, 359999 selects 1000x; the last zero selects affordable 5x.
|
||
indexes: []int64{950_000, 359_999, 0},
|
||
bounds: []int64{1_000_000, 360_000, 350_000},
|
||
}
|
||
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 800}, strategyTestInput(10), random)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.PoolAfterCoins != 750 {
|
||
t.Fatalf("decision=%+v", decision)
|
||
}
|
||
if got, want := decision.Trace.OriginalTierID, "200x"; got != want {
|
||
t.Fatalf("original tier=%s want=%s", got, want)
|
||
}
|
||
if got, want := decision.Trace.RedrawTierIDs, []string{"1000x", "5x"}; !reflect.DeepEqual(got, want) {
|
||
t.Fatalf("redraws=%v want=%v", got, want)
|
||
}
|
||
wantRemoved := []StrategyRemovalTrace{
|
||
{TierID: "200x", Reason: StrategyReasonPoolInsufficient},
|
||
{TierID: "0x", Reason: StrategyRemovalPWRedrawZero},
|
||
{TierID: "1000x", Reason: StrategyReasonPoolInsufficient},
|
||
}
|
||
if !reflect.DeepEqual(decision.Trace.Removed, wantRemoved) {
|
||
t.Fatalf("removed=%+v want=%+v", decision.Trace.Removed, wantRemoved)
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyPWEqualityAndCandidateExhaustion(t *testing.T) {
|
||
t.Run("W equals P is payable", func(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 2_000}, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.PoolAfterCoins != 0 {
|
||
t.Fatalf("P=W decision=%+v", decision)
|
||
}
|
||
})
|
||
|
||
t.Run("all positive tiers impossible falls back to zero", func(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
random := &scriptedStrategyRandom{
|
||
t: t,
|
||
// Exhaust 1000x, 200x, 50x, then 5x. Zero is removed with the first
|
||
// failed P/W comparison, so no extra random call can hide exhaustion.
|
||
indexes: []int64{999_999, 350_000, 349_999, 0},
|
||
bounds: []int64{1_000_000, 390_000, 350_000, 200_000},
|
||
}
|
||
decision, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49}, strategyTestInput(10), random)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if decision.PayoutCoins != 0 || decision.SelectedTier.ID != "0x" || decision.Trace.FinalReason != StrategyReasonNoPayableTier {
|
||
t.Fatalf("exhausted decision=%+v", decision)
|
||
}
|
||
if decision.NextState.PoolBalanceCoins < 0 {
|
||
t.Fatalf("pool became negative: %d", decision.NextState.PoolBalanceCoins)
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestLuckyGiftStrategyMissProtectionSixthDraw(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
state := StrategyState{PoolBalanceCoins: 800, ConsecutiveZeroDraws: 5}
|
||
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{400_000}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if decision.SelectedTier.ID != "5x" || decision.PayoutCoins != 50 || decision.NextState.ConsecutiveZeroDraws != 0 {
|
||
t.Fatalf("protected decision=%+v", decision)
|
||
}
|
||
if len(decision.Trace.Removed) == 0 || decision.Trace.Removed[0].Reason != StrategyRemovalMissProtection {
|
||
t.Fatalf("zero exclusion not traced: %+v", decision.Trace.Removed)
|
||
}
|
||
|
||
// Protection cannot manufacture money: when even 5x exceeds P, the strategy
|
||
// returns zero and leaves an explicit blocked reason instead of overdrawing.
|
||
blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 49, ConsecutiveZeroDraws: 5}, strategyTestInput(10), &scriptedStrategyRandom{t: t})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonMissProtectionBlocked || blocked.NextState.PoolBalanceCoins != 49 {
|
||
t.Fatalf("blocked protection=%+v", blocked)
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyDynamicWeightBoundariesAndMultiplication(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
config.LowWaterThresholdCoins = 1_000
|
||
config.HighWaterThresholdCoins = 2_000
|
||
config.LowWaterFactorPPM = 700_000
|
||
config.HighWaterFactorPPM = 1_300_000
|
||
config.RechargeFactorPPM = 1_100_000
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
tests := []struct {
|
||
name string
|
||
pool int64
|
||
rechargeAge int64
|
||
hasRecharge bool
|
||
want5x int64
|
||
wantFactorNames []string
|
||
}{
|
||
{name: "low below threshold", pool: 999, want5x: 140_000, wantFactorNames: []string{"low_water"}},
|
||
{name: "low equality is middle", pool: 1_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
|
||
{name: "high equality is middle", pool: 2_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
|
||
{name: "high above threshold", pool: 2_001, want5x: 260_000, wantFactorNames: []string{"high_water"}},
|
||
{name: "recharge starts inclusive and multiplies low", pool: 999, hasRecharge: true, rechargeAge: 0, want5x: 154_000, wantFactorNames: []string{"low_water", "recent_recharge"}},
|
||
{name: "recharge end minus one inclusive", pool: 1_500, hasRecharge: true, rechargeAge: 299_999, want5x: 220_000, wantFactorNames: []string{"recent_recharge"}},
|
||
{name: "recharge end exclusive", pool: 1_500, hasRecharge: true, rechargeAge: 300_000, want5x: 200_000, wantFactorNames: []string{"identity"}},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
input := strategyTestInput(10)
|
||
input.NowMS = 1_000_000
|
||
input.HasRechargeFact = tt.hasRecharge
|
||
input.LastRechargeAtMS = input.NowMS - tt.rechargeAge
|
||
_, traces, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{PoolBalanceCoins: tt.pool}, input)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
trace := findWeightTrace(t, traces, "5x")
|
||
if trace.AdjustedWeightPPM != tt.want5x {
|
||
t.Fatalf("5x adjusted=%d want=%d full=%+v", trace.AdjustedWeightPPM, tt.want5x, trace)
|
||
}
|
||
factorNames := make([]string, 0, len(trace.Factors))
|
||
for _, factor := range trace.Factors {
|
||
factorNames = append(factorNames, factor.Name)
|
||
}
|
||
if !reflect.DeepEqual(factorNames, tt.wantFactorNames) {
|
||
t.Fatalf("factor names=%v want=%v", factorNames, tt.wantFactorNames)
|
||
}
|
||
var total int64
|
||
for _, item := range traces {
|
||
total += item.AdjustedWeightPPM
|
||
}
|
||
if total != StrategyPPMScale {
|
||
t.Fatalf("weights sum=%d", total)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyJackpotMechanismOneRequiresAllNonZeroWindows(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
config.JackpotMechanism1Enabled = true
|
||
config.DailyJackpotLimit = 5
|
||
base := StrategyState{
|
||
PoolBalanceCoins: 10_000,
|
||
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||
UserDayRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||
User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||
}
|
||
tests := []struct {
|
||
name string
|
||
mutate func(*StrategyState)
|
||
wantPrize bool
|
||
}{
|
||
{name: "all inclusive boundaries pass", wantPrize: true},
|
||
{name: "global above 98", mutate: func(s *StrategyState) { s.GlobalRTP.PayoutCoins = 99 }},
|
||
{name: "day above 96", mutate: func(s *StrategyState) { s.UserDayRTP.PayoutCoins = 97 }},
|
||
{name: "72h above 96", mutate: func(s *StrategyState) { s.User72HourRTP.PayoutCoins = 97 }},
|
||
{name: "global denominator zero", mutate: func(s *StrategyState) { s.GlobalRTP = StrategyRTP{} }},
|
||
{name: "day denominator zero", mutate: func(s *StrategyState) { s.UserDayRTP = StrategyRTP{} }},
|
||
{name: "72h denominator zero", mutate: func(s *StrategyState) { s.User72HourRTP = StrategyRTP{} }},
|
||
}
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
state := base
|
||
if tt.mutate != nil {
|
||
tt.mutate(&state)
|
||
}
|
||
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(7))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
got := decision.JackpotMechanism == StrategyJackpotMechanismRTPCompensation
|
||
if got != tt.wantPrize {
|
||
t.Fatalf("mechanism1=%v want=%v trace=%+v", got, tt.wantPrize, decision.Trace)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyMilestoneTokenRetainedThenConsumed(t *testing.T) {
|
||
if got := MilestoneTokensEarned(49, 101, 50); got != 2 {
|
||
t.Fatalf("crossed tokens=%d want=2", got)
|
||
}
|
||
config := strategyTestConfig()
|
||
config.JackpotMechanism2Enabled = true
|
||
config.DailyJackpotLimit = 5
|
||
config.MilestoneSpendCoins = 50
|
||
state := StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}
|
||
blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(11))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if blocked.ConsumedMilestoneToken || blocked.NextState.PendingMilestoneTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
|
||
t.Fatalf("blocked token decision=%+v", blocked)
|
||
}
|
||
if len(blocked.Trace.Removed) < 3 {
|
||
t.Fatalf("blocked jackpot removals=%+v want all 200/500/1000 tiers", blocked.Trace.Removed)
|
||
}
|
||
for _, removed := range blocked.Trace.Removed[:3] {
|
||
if removed.Reason != StrategyReasonPoolInsufficient {
|
||
t.Fatalf("blocked jackpot removal=%+v", removed)
|
||
}
|
||
}
|
||
|
||
state = blocked.NextState
|
||
state.PoolBalanceCoins = 10_000
|
||
paid, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(12))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !paid.ConsumedMilestoneToken || paid.NextState.PendingMilestoneTokens != 0 || !paid.Jackpot || paid.PayoutCoins <= 0 {
|
||
t.Fatalf("consumed token decision=%+v", paid)
|
||
}
|
||
|
||
// Crossing happens during finalize, after this draw has already been selected.
|
||
// The token must therefore be persisted for the next request, not consumed now.
|
||
crossingState := StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}
|
||
crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(10), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if crossing.ConsumedMilestoneToken || crossing.NextState.PendingMilestoneTokens != 1 || crossing.Trace.MilestoneTokensEarned != 1 {
|
||
t.Fatalf("crossing decision=%+v", crossing)
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyDualJackpotSpendGlobalGateAndDurability(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
config.SettledRoundRTPEligibility = true
|
||
config.JackpotMechanism1Enabled = true
|
||
config.JackpotMechanism2Enabled = true
|
||
config.MilestoneSpendCoins = 50
|
||
config.GlobalRTPMaxPPM = 980_000
|
||
config.UserRoundRTPMaxPPM = 960_000
|
||
config.User48HourRTPMaxPPM = 960_000
|
||
config.DailyJackpotLimit = 5
|
||
|
||
base := StrategyState{PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98}}
|
||
paid, err := DecideLuckyGiftStrategy(config, base, strategyTestInput(10), NewSeededStrategyRandom(80))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if paid.JackpotMechanism != StrategyJackpotMechanismMilestone || !paid.ConsumedMilestoneToken || paid.NextState.PendingSpendJackpotTokens != 0 ||
|
||
paid.Trace.SpendMilestoneTokensBefore != 1 || paid.Trace.SpendMilestoneTokensAfter != 0 {
|
||
t.Fatalf("global equality should pay exactly one spend token: %+v", paid)
|
||
}
|
||
|
||
for _, test := range []struct {
|
||
name string
|
||
rtp StrategyRTP
|
||
}{
|
||
{name: "no closed platform sample", rtp: StrategyRTP{}},
|
||
{name: "platform above configured maximum", rtp: StrategyRTP{WagerCoins: 100, PayoutCoins: 99}},
|
||
} {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
state := base
|
||
state.SpendGlobalRTP = test.rtp
|
||
blocked, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(81))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if blocked.JackpotMechanism != "" || blocked.NextState.PendingSpendJackpotTokens != 1 || !blocked.Trace.MilestoneTokenRetained {
|
||
t.Fatalf("blocked spend token must remain durable: %+v", blocked)
|
||
}
|
||
})
|
||
}
|
||
|
||
capState := base
|
||
capState.UserDailyJackpotWins = config.DailyJackpotLimit
|
||
blockedBySharedCap, err := DecideLuckyGiftStrategy(config, capState, strategyTestInput(10), NewSeededStrategyRandom(82))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if blockedBySharedCap.NextState.PendingSpendJackpotTokens != 1 || blockedBySharedCap.Jackpot {
|
||
t.Fatalf("daily cap must be shared without consuming spend token: %+v", blockedBySharedCap)
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyDualJackpotCrossingAndPriority(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
config.SettledRoundRTPEligibility = true
|
||
config.JackpotMechanism1Enabled = true
|
||
config.JackpotMechanism2Enabled = true
|
||
config.MilestoneSpendCoins = 50
|
||
config.GlobalRTPMaxPPM = 980_000
|
||
config.UserRoundRTPMaxPPM = 960_000
|
||
config.User48HourRTPMaxPPM = 960_000
|
||
config.DailyJackpotLimit = 5
|
||
|
||
// One paid subdraw can cross several exact multiples, but its new promises are appended only
|
||
// after selection. Even with a valid global sample this draw must stay on the ordinary path.
|
||
crossingState := StrategyState{
|
||
PoolBalanceCoins: 10_000, UserDaySpendCoins: 40,
|
||
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||
}
|
||
crossing, err := DecideLuckyGiftStrategy(config, crossingState, strategyTestInput(110), &scriptedStrategyRandom{t: t, indexes: []int64{0}, bounds: []int64{1_000_000}})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if crossing.Jackpot || crossing.NextState.PendingSpendJackpotTokens != 3 || crossing.Trace.MilestoneTokensEarned != 3 ||
|
||
crossing.Trace.SpendMilestoneTokensBefore != 0 || crossing.Trace.SpendMilestoneTokensAfter != 3 {
|
||
t.Fatalf("current draw must only earn three next-draw tokens: %+v", crossing)
|
||
}
|
||
|
||
const hourMS = int64(60 * 60 * 1000)
|
||
qualified := StrategyState{
|
||
PoolBalanceCoins: 10_000,
|
||
PendingSpendJackpotTokens: 1,
|
||
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||
SettledRoundPending: true,
|
||
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||
UserRoundRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 95},
|
||
User48HourRTP: StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500},
|
||
UserRegisteredAtMS: hourMS,
|
||
QualificationClosedAtMS: 49 * hourMS,
|
||
}
|
||
compensated, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(83))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if compensated.JackpotMechanism != StrategyJackpotMechanismRTPCompensation || compensated.NextState.PendingSpendJackpotTokens != 1 || compensated.NextState.SettledRoundPending ||
|
||
compensated.Trace.SpendMilestoneTokensBefore != 1 || compensated.Trace.SpendMilestoneTokensAfter != 1 {
|
||
t.Fatalf("48h qualification must win first without consuming spend token: %+v", compensated)
|
||
}
|
||
|
||
// A failed personal RTP condition consumes only the one-shot 48h qualification. The same child
|
||
// draw may still use the independent spend promise because registration and personal RTP do not gate it.
|
||
qualified.UserRoundRTP.PayoutCoins = 96
|
||
spendFallback, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(84))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if spendFallback.JackpotMechanism != StrategyJackpotMechanismMilestone || spendFallback.NextState.PendingSpendJackpotTokens != 0 || spendFallback.NextState.SettledRoundPending {
|
||
t.Fatalf("ineligible 48h path should fall through to spend jackpot: %+v", spendFallback)
|
||
}
|
||
|
||
qualified.PoolBalanceCoins = 49
|
||
qualified.UserRoundRTP.PayoutCoins = 95
|
||
poolBlocked, err := DecideLuckyGiftStrategy(config, qualified, strategyTestInput(10), NewSeededStrategyRandom(85))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if poolBlocked.Jackpot || poolBlocked.NextState.SettledRoundPending || poolBlocked.NextState.PendingSpendJackpotTokens != 1 {
|
||
t.Fatalf("48h is one-shot while pool-blocked spend token remains: %+v", poolBlocked)
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategySpendJackpotSharesAllSixInclusiveRiskCaps(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
config.SettledRoundRTPEligibility = true
|
||
config.JackpotMechanism1Enabled = true
|
||
config.JackpotMechanism2Enabled = true
|
||
config.MilestoneSpendCoins = 50
|
||
config.GlobalRTPMaxPPM = 980_000
|
||
config.UserRoundRTPMaxPPM = 960_000
|
||
config.User48HourRTPMaxPPM = 960_000
|
||
config.DailyJackpotLimit = 5
|
||
setters := []struct {
|
||
name string
|
||
set func(*StrategyRiskCapacity, int64)
|
||
}{
|
||
{name: "single", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
|
||
{name: "user-hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
|
||
{name: "user-day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
|
||
{name: "device-day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
|
||
{name: "room-hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
|
||
{name: "anchor-day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
|
||
}
|
||
for _, test := range setters {
|
||
t.Run(test.name, func(t *testing.T) {
|
||
state := StrategyState{
|
||
PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1,
|
||
SpendGlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||
}
|
||
capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 10_000, UserHourCoins: 10_000, UserDayCoins: 10_000, DeviceDayCoins: 10_000, RoomHourCoins: 10_000, AnchorDayCoins: 10_000}
|
||
test.set(&capacity, 1_999)
|
||
input := strategyTestInput(10)
|
||
input.RiskCapacity = capacity
|
||
blocked, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(90))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if blocked.Jackpot || blocked.NextState.PendingSpendJackpotTokens != 1 {
|
||
t.Fatalf("capacity below minimum jackpot must retain token: %+v", blocked)
|
||
}
|
||
|
||
test.set(&capacity, 2_000)
|
||
input.RiskCapacity = capacity
|
||
paid, err := DecideLuckyGiftStrategy(config, state, input, NewSeededStrategyRandom(91))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !paid.Jackpot || paid.PayoutCoins != 2_000 || paid.NextState.PendingSpendJackpotTokens != 0 {
|
||
t.Fatalf("capacity equality must permit the 200x jackpot: %+v", paid)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyDailyJackpotLimitDoesNotConstrainOrdinarySameMultiplier(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
config.JackpotMechanism2Enabled = true
|
||
config.DailyJackpotLimit = 5
|
||
state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5}
|
||
// 大奖 token 受每日上限阻断,但普通概率里的同倍率 200x 仍按原权重命中;
|
||
// 倍率相同不代表来源相同,也不能消耗大奖次数或 token。
|
||
random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000}, bounds: []int64{1_000_000}}
|
||
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if decision.Jackpot || decision.SelectedTier.ID != "200x" || decision.PayoutCoins != 2_000 || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 {
|
||
t.Fatalf("daily-limit decision=%+v", decision)
|
||
}
|
||
if len(decision.Trace.Draws) != 1 || decision.Trace.Draws[0].RemovedReason != "" {
|
||
t.Fatalf("ordinary overlap must not be removed by jackpot cap: %+v", decision.Trace.Draws)
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategySixRiskCapacitiesAreHardInclusiveBounds(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
config.Tiers = []StrategyTier{
|
||
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
|
||
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: StrategyPPMScale, Enabled: true},
|
||
}
|
||
setters := []struct {
|
||
name string
|
||
set func(*StrategyRiskCapacity, int64)
|
||
}{
|
||
{name: "single draw", set: func(c *StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
|
||
{name: "user hour", set: func(c *StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
|
||
{name: "user day", set: func(c *StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
|
||
{name: "device day", set: func(c *StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
|
||
{name: "room hour", set: func(c *StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
|
||
{name: "anchor day", set: func(c *StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
|
||
}
|
||
for _, setter := range setters {
|
||
t.Run(setter.name, func(t *testing.T) {
|
||
capacity := StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
|
||
setter.set(&capacity, 50)
|
||
input := strategyTestInput(10)
|
||
input.RiskCapacity = capacity
|
||
allowed, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if allowed.PayoutCoins != 50 || allowed.PoolAfterCoins != 0 {
|
||
t.Fatalf("boundary must pass: %+v", allowed)
|
||
}
|
||
setter.set(&capacity, 49)
|
||
input.RiskCapacity = capacity
|
||
blocked, err := DecideLuckyGiftStrategy(config, StrategyState{PoolBalanceCoins: 50}, input, NewSeededStrategyRandom(1))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if blocked.PayoutCoins != 0 || blocked.Trace.FinalReason != StrategyReasonNoPayableTier {
|
||
t.Fatalf("below boundary must block: %+v", blocked)
|
||
}
|
||
if len(blocked.Trace.Removed) != 1 || blocked.Trace.Removed[0].TierID != "5x" || blocked.Trace.Removed[0].Reason != StrategyReasonRiskCapacity {
|
||
t.Fatalf("risk retry must not remove zero: %+v", blocked.Trace.Removed)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyRechargeStagesAndFundConservation(t *testing.T) {
|
||
config := DefaultLuckyGiftStrategyConfig()
|
||
config.RechargeStages = []StrategyRechargeStage{
|
||
{Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
|
||
{Name: StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
|
||
{Name: StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
|
||
}
|
||
stageCases := []struct {
|
||
seven, thirty int64
|
||
want string
|
||
}{
|
||
{seven: 0, thirty: 0, want: StageNovice},
|
||
// 两个非零充值仍低于 normal,必须留在 novice;novice 不是“只有零充值”。
|
||
{seven: 99, thirty: 499, want: StageNovice},
|
||
{seven: 100, thirty: 499, want: StageNovice},
|
||
{seven: 99, thirty: 500, want: StageNovice},
|
||
// 两维等于门槛都算达到;任一维未达 advanced 时仍保持 normal。
|
||
{seven: 100, thirty: 500, want: StageNormal},
|
||
{seven: 500, thirty: 1_999, want: StageNormal},
|
||
{seven: 499, thirty: 2_000, want: StageNormal},
|
||
{seven: 500, thirty: 2_000, want: StageAdvanced},
|
||
}
|
||
for _, tt := range stageCases {
|
||
if got := SelectLuckyGiftRechargeStage(config, tt.seven, tt.thirty); got != tt.want {
|
||
t.Fatalf("stage(%d,%d)=%s want=%s", tt.seven, tt.thirty, got, tt.want)
|
||
}
|
||
}
|
||
split, err := SplitLuckyGiftStrategyFunds(100, config)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if split != (StrategyFundSplit{PublicPoolCoins: 98, ProfitPoolCoins: 1, AnchorReturnCoins: 1}) {
|
||
t.Fatalf("split=%+v", split)
|
||
}
|
||
if split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins != 100 {
|
||
t.Fatal("fund split did not conserve money")
|
||
}
|
||
small, err := SplitLuckyGiftStrategyFunds(10, config)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if small != (StrategyFundSplit{PublicPoolCoins: 9, ProfitPoolCoins: 1, AnchorReturnCoins: 0}) {
|
||
t.Fatalf("small split=%+v want public floor with residue in profit", small)
|
||
}
|
||
if initial := InitialLuckyGiftStrategyState(config); initial.PoolBalanceCoins != config.ColdStartPoolCoins || initial.PoolBalanceCoins < 0 {
|
||
t.Fatalf("initial=%+v", initial)
|
||
}
|
||
}
|
||
|
||
func TestDefaultLuckyGiftStrategyNominalEVIsExactlyNinetyEightPercent(t *testing.T) {
|
||
config := DefaultLuckyGiftStrategyConfig()
|
||
var weightedMultiplierMicros int64
|
||
for _, tier := range config.Tiers {
|
||
weightedMultiplierMicros += tier.BaseWeightPPM * tier.MultiplierPPM / StrategyPPMScale
|
||
}
|
||
if weightedMultiplierMicros != 980_000 {
|
||
t.Fatalf("default nominal EV=%dppm want=980000ppm", weightedMultiplierMicros)
|
||
}
|
||
for _, tier := range config.Tiers {
|
||
if tier.Jackpot && tier.BaseWeightPPM != 0 {
|
||
t.Fatalf("default jackpot tier %s leaked into ordinary probability", tier.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyRejectsJackpotTierWithOrdinaryWeight(t *testing.T) {
|
||
for _, mutate := range []func(*StrategyTier){
|
||
func(tier *StrategyTier) { tier.BaseWeightPPM = 1 },
|
||
func(tier *StrategyTier) { tier.StageWeightPPM = map[string]int64{StageNovice: 1} },
|
||
} {
|
||
config := DefaultLuckyGiftStrategyConfig()
|
||
for index := range config.Tiers {
|
||
if config.Tiers[index].Jackpot {
|
||
mutate(&config.Tiers[index])
|
||
break
|
||
}
|
||
}
|
||
if _, _, err := PreviewLuckyGiftStrategyWeights(config, StrategyState{}, strategyTestInput(10)); err == nil {
|
||
t.Fatal("jackpot tier carrying an ordinary weight should be rejected")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestLuckyGiftStrategyCombinationPriority(t *testing.T) {
|
||
config := strategyTestConfig()
|
||
config.JackpotMechanism1Enabled = true
|
||
config.JackpotMechanism2Enabled = true
|
||
config.DailyJackpotLimit = 5
|
||
state := StrategyState{
|
||
PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
|
||
GlobalRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||
UserDayRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||
User72HourRTP: StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||
}
|
||
decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), NewSeededStrategyRandom(8))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if decision.JackpotMechanism != StrategyJackpotMechanismMilestone || !decision.ConsumedMilestoneToken {
|
||
t.Fatalf("milestone must outrank mechanism1/miss protection: %+v", decision)
|
||
}
|
||
}
|
||
|
||
func findWeightTrace(t *testing.T, traces []StrategyWeightTrace, tierID string) StrategyWeightTrace {
|
||
t.Helper()
|
||
for _, trace := range traces {
|
||
if trace.TierID == tierID {
|
||
return trace
|
||
}
|
||
}
|
||
t.Fatalf("weight trace %s not found", tierID)
|
||
return StrategyWeightTrace{}
|
||
}
|