455 lines
19 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, 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()
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 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{}
}