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, Jackpot: true, JackpotWeight: 4, Enabled: true}, {ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true}, {ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_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 TestLuckyGiftStrategyDailyJackpotLimitAlsoConstrainsBaseDraw(t *testing.T) { config := strategyTestConfig() config.JackpotMechanism2Enabled = true config.DailyJackpotLimit = 5 state := StrategyState{PoolBalanceCoins: 20_000, PendingMilestoneTokens: 1, UserDailyJackpotWins: 5} // Milestone is blocked first; ordinary draw then selects 200x, removes it for // the same hard daily limit, removes zero, and redraws an allowed 5x. random := &scriptedStrategyRandom{t: t, indexes: []int64{950_000, 600_000}, bounds: []int64{1_000_000, 960_000}} decision, err := DecideLuckyGiftStrategy(config, state, strategyTestInput(10), random) if err != nil { t.Fatal(err) } if decision.Jackpot || decision.SelectedTier.ID != "5x" || decision.NextState.PendingMilestoneTokens != 1 || decision.NextState.UserDailyJackpotWins != 5 { t.Fatalf("daily-limit decision=%+v", decision) } if decision.Trace.Draws[0].RemovedReason != StrategyReasonDailyJackpotLimit { t.Fatalf("daily-limit removal=%+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() stageCases := []struct { seven, thirty int64 want string }{ {seven: 0, thirty: 0, want: StageNovice}, {seven: 0, thirty: 1, want: StageNormal}, {seven: 1, thirty: 1, 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 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{} }