649 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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必须留在 novicenovice 不是“只有零充值”。
{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{}
}