package main import ( "fmt" "math" "sort" "sync" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql" ) type scriptedRandom struct { indexes []int64 bounds []int64 position int } // imageExampleConfig is deliberately local to strategy-sim. Its ordinary weights // have 26.5x EV and therefore must never be exposed as the domain's runtime default. func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig { // 模拟显式给出金币分层边界,避免把生产 disabled 草稿的 0/0 sentinel 当成可运行默认值。 config.RechargeStages = []domain.StrategyRechargeStage{ {Name: domain.StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0}, {Name: domain.StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500}, {Name: domain.StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000}, } config.Tiers = []domain.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}, } return config } func (r *scriptedRandom) Int63n(n int64) (int64, error) { if r.position >= len(r.indexes) { return 0, fmt.Errorf("scripted random exhausted at bound %d", n) } if len(r.bounds) > r.position && r.bounds[r.position] != n { return 0, fmt.Errorf("scripted random call %d bound=%d want=%d", r.position, n, r.bounds[r.position]) } value := r.indexes[r.position] r.position++ return value, nil } // baseProbabilityConfig isolates the image's ordinary probability table. Each // dynamic/special rule has a separate scenario, so scripted intervals remain exact. func baseProbabilityConfig(config domain.StrategyConfig) domain.StrategyConfig { config.LowWaterThresholdCoins = 0 config.HighWaterThresholdCoins = math.MaxInt64 config.LowWaterFactorPPM = domain.StrategyPPMScale config.HighWaterFactorPPM = domain.StrategyPPMScale config.RechargeFactorPPM = domain.StrategyPPMScale config.JackpotMechanism1Enabled = false config.JackpotMechanism2Enabled = false config.DailyJackpotLimit = math.MaxInt64 config.MilestoneSpendCoins = 0 return config } func failedScenario(name string, err error) scenarioResult { return scenarioResult{Name: name, Passed: false, Summary: err.Error()} } func simulateImage800Redraw(config domain.StrategyConfig) scenarioResult { const name = "图示800重抽序列" config = baseProbabilityConfig(config) random := &scriptedRandom{indexes: []int64{950_000, 359_999, 0}, bounds: []int64{1_000_000, 360_000, 350_000}} decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 800}, domain.StrategyInput{GiftPriceCoins: 10}, random) if err != nil { return failedScenario(name, err) } sequence := make([]string, 0, len(decision.Trace.Draws)) for _, draw := range decision.Trace.Draws { sequence = append(sequence, draw.TierID) } passed := decision.PayoutCoins == 50 && decision.PoolAfterCoins == 750 && fmt.Sprint(sequence) == "[200x 1000x 5x]" return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("P=800,固定脚本命中%s,最终赔付=%d,池后=%d", sequence, decision.PayoutCoins, decision.PoolAfterCoins), Data: map[string]any{"sequence": sequence, "trace": decision.Trace}, } } func simulatePWBoundaries(config domain.StrategyConfig) scenarioResult { const name = "P=W边界/候选穷尽" config = baseProbabilityConfig(config) equal, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 2_000}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}}) if err != nil { return failedScenario(name, err) } exhausted, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{999_999, 350_000, 349_999, 0}, bounds: []int64{1_000_000, 390_000, 350_000, 200_000}}) if err != nil { return failedScenario(name, err) } passed := equal.PayoutCoins == 2_000 && equal.PoolAfterCoins == 0 && exhausted.PayoutCoins == 0 && exhausted.Trace.FinalReason == domain.StrategyReasonNoPayableTier && exhausted.PoolAfterCoins == 49 return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("W=P赔付%d且池归零;P=49时正奖候选穷尽安全回0,池仍%d", equal.PayoutCoins, exhausted.PoolAfterCoins), Data: map[string]any{"equality": equal.Trace, "exhaustion": exhausted.Trace}, } } func simulateFundSplitAndColdStart(config domain.StrategyConfig) scenarioResult { const name = "拆账与冷启动" split, err := domain.SplitLuckyGiftStrategyFunds(100, config) if err != nil { return failedScenario(name, err) } initial := domain.InitialLuckyGiftStrategyState(config) small, err := domain.SplitLuckyGiftStrategyFunds(10, config) if err != nil { return failedScenario(name, err) } conserved := split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins == 100 passed := split.PublicPoolCoins == 98 && split.ProfitPoolCoins == 1 && split.AnchorReturnCoins == 1 && small.PublicPoolCoins == 9 && small.ProfitPoolCoins == 1 && small.AnchorReturnCoins == 0 && conserved && initial.PoolBalanceCoins == 0 return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("100金币拆为%d/%d/%d;10金币按公共池floor、余数归盈利拆为%d/%d/%d;动态池冷启动=%d,启动资金只能审计注资", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins), Data: map[string]any{ "split_100": split, "split_10_rounding": small, "rounding_policy": "public=floor, anchor=floor, residue=profit", "conserved": conserved, "cold_start_state": initial, }, } } type batchSummary struct { Requested int `json:"requested"` Executed int `json:"executed"` Positive int `json:"positive"` PayoutCoins int64 `json:"payout_coins"` PoolAfter int64 `json:"pool_after"` TierCounts map[string]int `json:"tier_counts"` } func simulateBatchCounts(config domain.StrategyConfig, seed int64) scenarioResult { const name = "1/5/6/99批量" config.JackpotMechanism1Enabled = false config.JackpotMechanism2Enabled = false config.MilestoneSpendCoins = 0 config.DailyJackpotLimit = math.MaxInt64 counts := []int{1, 5, 6, 99} results := make([]batchSummary, 0, len(counts)) passed := true for _, count := range counts { state := domain.StrategyState{} random := domain.NewSeededStrategyRandom(seed + int64(count)) summary := batchSummary{Requested: count, TierCounts: map[string]int{}} for index := 0; index < count; index++ { decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random) if err != nil { return failedScenario(name, err) } state = decision.NextState summary.Executed++ summary.TierCounts[decision.SelectedTier.ID]++ summary.PayoutCoins += decision.PayoutCoins if decision.PayoutCoins > 0 { summary.Positive++ } passed = passed && decision.PoolAfterCoins >= 0 } summary.PoolAfter = state.PoolBalanceCoins passed = passed && summary.Executed == count results = append(results, summary) } return scenarioResult{Name: name, Passed: passed, Summary: "每份礼物独立推进一次状态,1/5/6/99均完整执行且任一中间池余额不负", Data: results} } func simulateSettlementWagerWindow() scenarioResult { const name = "真实流水结算窗口" const ( settlementWager = int64(99) referencePrice = int64(10) totalSpend = int64(100) drawCount = int64(99) ) type window struct { Index int64 `json:"index"` Paid int64 `json:"paid_draws"` Wager int64 `json:"wager_coins"` } // 10 抽但只有 98 流水是专门构造的反例:ceil(99/10)=10 的旧抽数算法会提前关窗,真实流水算法仍允许下一抽进入窗口 1。 windows := []window{{Index: 1, Paid: 10, Wager: 98}} drawWindows := make([]int64, 0, drawCount) for index := int64(1); index <= drawCount; index++ { current := &windows[len(windows)-1] if current.Wager >= settlementWager { windows = append(windows, window{Index: current.Index + 1}) current = &windows[len(windows)-1] } unitSpend := totalSpend / drawCount if index <= totalSpend%drawCount { unitSpend++ } current.Paid++ current.Wager += unitSpend drawWindows = append(drawWindows, current.Index) } firstCount, secondCount := 0, 0 for _, index := range drawWindows { if index == 1 { firstCount++ } else if index == 2 { secondCount++ } } passed := len(windows) == 2 && windows[0].Paid == 11 && windows[0].Wager == 100 && windows[1].Paid == 98 && windows[1].Wager == 98 && firstCount == 1 && secondCount == 98 return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("阈值99、参考价10、预置10抽/98流水;99份共100金币后窗口流水=%d/%d,子抽归属=%d/%d", windows[0].Wager, windows[1].Wager, firstCount, secondCount), Data: map[string]any{ "settlement_window_wager": settlementWager, "reference_price": referencePrice, "legacy_derived_draw_threshold": int64(math.Ceil(float64(settlementWager) / float64(referencePrice))), "windows": windows, "draws_in_window_1": firstCount, "draws_in_window_2": secondCount, }, } } func simulateWaterBoundaries(config domain.StrategyConfig) scenarioResult { const name = "低中高水位边界" config.JackpotMechanism1Enabled = false config.JackpotMechanism2Enabled = false type waterPoint struct { Pool int64 `json:"pool"` FiveWeight int64 `json:"five_x_weight_ppm"` Factors []string `json:"factors"` } points := make([]waterPoint, 0, 4) for _, pool := range []int64{9_999_999, 10_000_000, 20_000_000, 20_000_001} { _, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: pool}, domain.StrategyInput{GiftPriceCoins: 100}) if err != nil { return failedScenario(name, err) } trace := strategyWeightTrace(traces, "5x") factors := make([]string, 0, len(trace.Factors)) for _, factor := range trace.Factors { factors = append(factors, factor.Name) } points = append(points, waterPoint{Pool: pool, FiveWeight: trace.AdjustedWeightPPM, Factors: factors}) } passed := points[0].FiveWeight == 140_000 && points[1].FiveWeight == 200_000 && points[2].FiveWeight == 200_000 && points[3].FiveWeight == 260_000 return scenarioResult{Name: name, Passed: passed, Summary: "<1000万应用0.7;等于1000万/2000万均为中水位;>2000万应用1.3", Data: points} } func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult { const name = "充值0/299999/300000ms" config.JackpotMechanism1Enabled = false config.JackpotMechanism2Enabled = false const now = int64(1_000_000) type rechargePoint struct { AgeMS int64 `json:"age_ms"` FiveWeight int64 `json:"five_x_weight_ppm"` Boosted bool `json:"boosted"` } points := make([]rechargePoint, 0, 3) for _, age := range []int64{0, 299_999, 300_000} { input := domain.StrategyInput{GiftPriceCoins: 100, NowMS: now, HasRechargeFact: true, LastRechargeAtMS: now - age} _, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: 15_000_000}, input) if err != nil { return failedScenario(name, err) } trace := strategyWeightTrace(traces, "5x") boosted := false for _, factor := range trace.Factors { boosted = boosted || factor.Name == "recent_recharge" } points = append(points, rechargePoint{AgeMS: age, FiveWeight: trace.AdjustedWeightPPM, Boosted: boosted}) } passed := points[0].Boosted && points[1].Boosted && !points[2].Boosted && points[0].FiveWeight == 220_000 && points[2].FiveWeight == 200_000 return scenarioResult{Name: name, Passed: passed, Summary: "充值加成严格使用[0,300000ms):0和299999生效,300000失效", Data: points} } func simulateProbabilityMonteCarlo(config domain.StrategyConfig, seed int64, draws int) scenarioResult { const name = "概率Monte Carlo" config = baseProbabilityConfig(config) config.MissProtectionZeroDraws = math.MaxInt64 state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000_000} random := domain.NewSeededStrategyRandom(seed) counts := map[string]int{} var multiplierSum float64 for index := 0; index < draws; index++ { decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, random) if err != nil { return failedScenario(name, err) } state = decision.NextState counts[decision.SelectedTier.ID]++ multiplierSum += float64(decision.SelectedTier.MultiplierPPM) / float64(domain.StrategyPPMScale) } expected := map[string]float64{"0x": 0.60, "5x": 0.20, "50x": 0.15, "200x": 0.04, "500x": 0, "1000x": 0.01} empirical := map[string]float64{} passed := true for tier, probability := range expected { empirical[tier] = float64(counts[tier]) / float64(draws) // Five standard deviations makes the assertion scale with -draws instead // of baking a tolerance that is too strict for a quick local smoke run. tolerance := 5*math.Sqrt(probability*(1-probability)/float64(draws)) + 1/float64(draws) passed = passed && math.Abs(empirical[tier]-probability) <= tolerance } empiricalEV := multiplierSum / float64(draws) const multiplierVariance = 11_277.75 // E[X^2]=11980, E[X]^2=702.25. evTolerance := 5 * math.Sqrt(multiplierVariance/float64(draws)) passed = passed && math.Abs(empiricalEV-26.5) <= evTolerance return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("%d抽经验EV=%.4fx(只验证图示概率采样,不声称通过98%%资金RTP)", draws, empiricalEV), Data: map[string]any{"counts": counts, "expected_probability": expected, "empirical_probability": empirical, "empirical_ev_multiplier": empiricalEV}, } } func simulateFundedRTPLongRun(config domain.StrategyConfig, seed int64, draws int) scenarioResult { const name = "RTP长跑" config.JackpotMechanism1Enabled = false config.JackpotMechanism2Enabled = false config.DailyJackpotLimit = math.MaxInt64 config.MilestoneSpendCoins = 0 state := domain.StrategyState{} random := domain.NewSeededStrategyRandom(seed) var contributed, payout int64 for index := 0; index < draws; index++ { decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random) if err != nil { return failedScenario(name, err) } state = decision.NextState contributed += 98 payout += decision.PayoutCoins if state.PoolBalanceCoins < 0 { return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d抽资金为负", index+1)} } } rtpPercent := float64(payout) / float64(draws*100) * 100 conserved := contributed-payout == state.PoolBalanceCoins passed := conserved && payout <= contributed && rtpPercent <= 98 return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("%d抽实付RTP=%.4f%%,投入=%d,赔付=%d,池余=%d", draws, rtpPercent, contributed, payout, state.PoolBalanceCoins), Data: map[string]any{"contributed": contributed, "payout": payout, "pool_after": state.PoolBalanceCoins, "rtp_percent": rtpPercent, "fund_conserved": conserved}, } } func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) domain.StrategyWeightTrace { for _, trace := range traces { if trace.TierID == tierID { return trace } } return domain.StrategyWeightTrace{} } type settledRoundQualificationRow struct { Case string `json:"case"` RegisteredAgeHours int64 `json:"registered_age_hours"` RoundWagerCoins int64 `json:"round_wager_coins"` RoundPayoutCoins int64 `json:"round_payout_coins"` RollingWagerCoins int64 `json:"rolling_48h_wager_coins"` RollingPayoutCoins int64 `json:"rolling_48h_payout_coins"` GlobalWagerCoins int64 `json:"global_wager_coins"` GlobalPayoutCoins int64 `json:"global_payout_coins"` Wanted bool `json:"wanted"` Triggered bool `json:"triggered"` SelectedTier string `json:"selected_tier"` } func simulateSettledRoundQualification(config domain.StrategyConfig, seed int64) scenarioResult { const name = "已结算轮次+注册48小时大奖资格" config = settledRoundJackpotConfig(config) const ( hourMS = int64(60 * 60 * 1000) closedAtMS = int64(100 * 60 * 60 * 1000) ) base := qualifiedSettledRoundState(20_000, closedAtMS) cases := []struct { name string state domain.StrategyState want bool }{ {name: "注册47小时,即使两个用户RTP都是95%也失败", state: withRegisteredAge(base, 47*hourMS), want: false}, {name: "注册恰满48小时,两个用户RTP都是95%通过", state: withRegisteredAge(base, 48*hourMS), want: true}, {name: "注册已满48小时且只在最后5分钟玩,48小时总RTP就是这5分钟的95%", state: withLastFiveMinuteHistory(withRegisteredAge(base, 72*hourMS)), want: true}, {name: "已结算用户轮次RTP恰好96%失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_600), want: false}, {name: "滚动48小时总RTP恰好96%失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_600), want: false}, {name: "已结算大盘RTP恰好98%仍通过", state: withRegisteredAge(base, 48*hourMS), want: true}, {name: "已结算大盘RTP超过98%失败", state: withGlobalRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_801), want: false}, {name: "仅用户轮次门失败,48小时门通过,仍失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_700), want: false}, {name: "仅48小时门失败,用户轮次门通过,仍失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 10_000, 9_700), want: false}, {name: "用户轮次没有消费样本失败", state: withUserRoundRTP(withRegisteredAge(base, 48*hourMS), 0, 0), want: false}, {name: "滚动48小时没有消费样本失败", state: withUser48HourRTP(withRegisteredAge(base, 48*hourMS), 0, 0), want: false}, } rows := make([]settledRoundQualificationRow, 0, len(cases)) passed := true for index, item := range cases { decision, err := domain.DecideLuckyGiftStrategy(config, item.state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index))) if err != nil { return failedScenario(name, err) } triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation passed = passed && triggered == item.want rows = append(rows, settledRoundQualificationRow{ Case: item.name, RegisteredAgeHours: (item.state.QualificationClosedAtMS - item.state.UserRegisteredAtMS) / hourMS, RoundWagerCoins: item.state.UserRoundRTP.WagerCoins, RoundPayoutCoins: item.state.UserRoundRTP.PayoutCoins, RollingWagerCoins: item.state.User48HourRTP.WagerCoins, RollingPayoutCoins: item.state.User48HourRTP.PayoutCoins, GlobalWagerCoins: item.state.GlobalRTP.WagerCoins, GlobalPayoutCoins: item.state.GlobalRTP.PayoutCoins, Wanted: item.want, Triggered: triggered, SelectedTier: decision.SelectedTier.ID, }) } return scenarioResult{ Name: name, Passed: passed, Summary: "注册<48h失败;恰48h及只玩最后5分钟按实际总流水判断;用户轮次/48h严格<96%,大盘允许=98%,超限或零样本失败", Data: rows, } } func settledRoundJackpotConfig(config domain.StrategyConfig) domain.StrategyConfig { config = baseProbabilityConfig(config) config.JackpotMechanism1Enabled = true config.JackpotMechanism2Enabled = false config.MilestoneSpendCoins = 0 config.SettledRoundRTPEligibility = true config.GlobalRTPMaxPPM = 980_000 config.UserRoundRTPMaxPPM = 960_000 config.User48HourRTPMaxPPM = 960_000 config.DailyJackpotLimit = 5 return config } func qualifiedSettledRoundState(pool, closedAtMS int64) domain.StrategyState { return domain.StrategyState{ PoolBalanceCoins: pool, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS, UserRegisteredAtMS: closedAtMS - 48*60*60*1000, GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800}, UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500}, User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_500}, } } func withRegisteredAge(state domain.StrategyState, ageMS int64) domain.StrategyState { state.UserRegisteredAtMS = state.QualificationClosedAtMS - ageMS return state } func withLastFiveMinuteHistory(state domain.StrategyState) domain.StrategyState { // 前47小时55分钟没有送礼不会制造“空白样本”;滚动48小时的总分子/分母就是最后5分钟实际发生的95/100。 state.UserRoundRTP = domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 95} state.User48HourRTP = domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 95} return state } func withUserRoundRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState { state.UserRoundRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout} return state } func withUser48HourRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState { state.User48HourRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout} return state } func withGlobalRTP(state domain.StrategyState, wager, payout int64) domain.StrategyState { state.GlobalRTP = domain.StrategyRTP{WagerCoins: wager, PayoutCoins: payout} return state } func simulatePoolBlockedQualificationConsumption(config domain.StrategyConfig, seed int64) scenarioResult { const name = "池不足仍消费已结算轮次资格" config = settledRoundJackpotConfig(config) // 普通档固定为0,隔离出“大奖不可支付后只消费一次资格”的状态变化。 config.Tiers = []domain.StrategyTier{ {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true}, {ID: "200x", MultiplierPPM: 200_000_000, Jackpot: true, JackpotWeight: 4, Enabled: true}, {ID: "500x", MultiplierPPM: 500_000_000, Jackpot: true, JackpotWeight: 2, Enabled: true}, {ID: "1000x", MultiplierPPM: 1_000_000_000, Jackpot: true, JackpotWeight: 1, Enabled: true}, } state := qualifiedSettledRoundState(49, 100*60*60*1000) first, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed)) if err != nil { return failedScenario(name, err) } afterTopUp := first.NextState afterTopUp.PoolBalanceCoins = 20_000 second, err := domain.DecideLuckyGiftStrategy(config, afterTopUp, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1)) if err != nil { return failedScenario(name, err) } passed := !first.Jackpot && !first.NextState.SettledRoundPending && !second.Jackpot && second.JackpotMechanism == "" return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("首抽池=%d不足支付200x,资格尝试后pending=%v;补池到20000再抽也不会复用旧资格", state.PoolBalanceCoins, first.NextState.SettledRoundPending), Data: map[string]any{"pool_blocked_attempt": first, "after_top_up_without_new_closed_round": second}, } } func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult { const name = "大奖集合" config = settledRoundJackpotConfig(config) all := map[string]int{} for index := int64(0); index < 120; index++ { decision, err := domain.DecideLuckyGiftStrategy(config, qualifiedSettledRoundState(10_000, 100*60*60*1000), domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index)) if err != nil { return failedScenario(name, err) } all[decision.SelectedTier.ID]++ } limited := map[string]int{} for index := int64(0); index < 40; index++ { decision, err := domain.DecideLuckyGiftStrategy(config, qualifiedSettledRoundState(4_999, 100*60*60*1000), domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index)) if err != nil { return failedScenario(name, err) } limited[decision.SelectedTier.ID]++ } _, has200 := all["jackpot_200x"] _, has500 := all["jackpot_500x"] _, has1000 := all["jackpot_1000x"] passed := has200 && has500 && has1000 && len(limited) == 1 && limited["jackpot_200x"] == 40 return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%v;P=4999时只可能200x=%v", has200 && has500 && has1000, limited), Data: map[string]any{"pool_10000_counts": all, "pool_4999_counts": limited}, } } func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult { const name = "日5次上限" config = settledRoundJackpotConfig(config) state := qualifiedSettledRoundState(1_000_000, 100*60*60*1000) for index := 0; index < 5; index++ { // 每次循环代表又结算了一个新的大盘轮次;同一轮资格在完成一次尝试后不会自动续期。 state.SettledRoundPending = true state.QualificationClosedAtMS += 60 * 60 * 1000 decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index))) if err != nil { return failedScenario(name, err) } state = decision.NextState if !decision.Jackpot { return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d次未按资格出大奖", index+1)} } } state.SettledRoundPending = true state.QualificationClosedAtMS += 60 * 60 * 1000 // 大奖路径达到上限后仍回落普通随机;同倍率 ordinary 200x 不属于大奖,不能被 cap 删除。 sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}}) if err != nil { return failedScenario(name, err) } passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.SelectedTier.ID == "200x" && sixth.NextState.UserDailyJackpotWins == 5 && !sixth.NextState.SettledRoundPending return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("5个独立补偿大奖后计数=%d;第6个轮次补偿受限但普通200x仍可命中且不增加大奖次数", state.UserDailyJackpotWins), Data: map[string]any{"sixth": sixth}, } } type laluLuckyV5TierFixture struct { id string multiplier int64 weight int64 high bool } // laluLuckyV5RuleFixture freezes the disabled test rule lalu/lucky/v5 that exposed the // overlap bug. Values are intentionally explicit rather than generated from a curve: // a later test-rule edit must produce a visible fixture diff before it can change this // regression report. The simulator never publishes or enables this snapshot. func laluLuckyV5RuleFixture() domain.RuleConfig { stage := func(name string, min7D, min30D int64, rows []laluLuckyV5TierFixture) domain.RuleStage { out := domain.RuleStage{Stage: name, MinRecharge7DCoins: min7D, MinRecharge30DCoins: min30D, Tiers: make([]domain.RuleTier, 0, len(rows))} for _, row := range rows { out.Tiers = append(out.Tiers, domain.RuleTier{ Stage: name, TierID: name + "_" + row.id, MultiplierPPM: row.multiplier, BaseWeightPPM: row.weight, HighWaterOnly: row.high, Enabled: true, }) } return out } return domain.RuleConfig{ AppCode: "lalu", PoolID: "lucky", RuleVersion: 5, StrategyVersion: domain.StrategyDynamicV3, Enabled: false, TargetRTPPPM: 890_000, PoolRatePPM: 890_000, ProfitRatePPM: 10_000, AnchorRatePPM: 100_000, SettlementWindowWager: 1_000_000, ControlBandPPM: 30_000, GiftPriceReference: 100, NoviceMaxEquivalentDraws: 2_000, NormalMaxEquivalentDraws: 20_000, EffectiveFromMS: 1_780_032_226_505, InitialPoolCoins: 0, LossStreakGuarantee: 4, LowWatermarkCoins: 10_000_000, LowWaterNonzeroFactorPPM: 700_000, HighWatermarkCoins: 20_000_000, HighWaterNonzeroFactorPPM: 1_300_000, RechargeBoostWindowMS: 300_000, RechargeBoostFactorPPM: 1_100_000, JackpotMultiplierPPMs: []int64{100_000_000, 200_000_000, 500_000_000, 1_000_000_000}, JackpotGlobalRTPMaxPPM: 980_000, JackpotUserRoundRTPMaxPPM: 950_000, JackpotUser48hRTPMaxPPM: 950_000, SettledRoundRTPEligibility: true, JackpotSpendThresholdCoins: 800_000, MaxJackpotHitsPerUserDay: 10, MaxSinglePayout: 4_000_000_000, UserHourlyPayoutCap: 4_000_000_000, UserDailyPayoutCap: 4_000_000_000, DeviceDailyPayoutCap: 4_000_000_000, RoomHourlyPayoutCap: 4_000_000_000, AnchorDailyPayoutCap: 4_000_000_000, Stages: []domain.RuleStage{ stage(domain.StageNovice, 0, 0, []laluLuckyV5TierFixture{ {id: "none", multiplier: 0, weight: 508_000}, {id: "0_5x", multiplier: 500_000, weight: 254_261}, {id: "1x", multiplier: 1_000_000, weight: 127_131}, {id: "2x", multiplier: 2_000_000, weight: 63_567}, {id: "5x", multiplier: 5_000_000, weight: 25_427}, {id: "10x", multiplier: 10_000_000, weight: 12_713, high: true}, {id: "20x", multiplier: 20_000_000, weight: 6_357, high: true}, {id: "50x", multiplier: 50_000_000, weight: 2_544, high: true}, }), stage(domain.StageNormal, 8, 100, []laluLuckyV5TierFixture{ {id: "none", multiplier: 0, weight: 530_785}, {id: "0_5x", multiplier: 500_000, weight: 161_748}, {id: "0_8x", multiplier: 800_000, weight: 101_094}, {id: "1x", multiplier: 1_000_000, weight: 80_875}, {id: "1_5x", multiplier: 1_500_000, weight: 53_917}, {id: "2x", multiplier: 2_000_000, weight: 40_438, high: true}, {id: "5x", multiplier: 5_000_000, weight: 16_175, high: true}, {id: "10x", multiplier: 10_000_000, weight: 8_088, high: true}, {id: "20x", multiplier: 20_000_000, weight: 4_046, high: true}, {id: "50x", multiplier: 50_000_000, weight: 1_619, high: true}, {id: "100x", multiplier: 100_000_000, weight: 810, high: true}, {id: "200x", multiplier: 200_000_000, weight: 405, high: true}, }), stage(domain.StageAdvanced, 10, 200, []laluLuckyV5TierFixture{ {id: "none", multiplier: 0, weight: 658_929}, {id: "0_5x", multiplier: 500_000, weight: 126_588}, {id: "1x", multiplier: 1_000_000, weight: 63_295}, {id: "1_2x", multiplier: 1_200_000, weight: 52_746}, {id: "1_5x", multiplier: 1_500_000, weight: 42_197}, {id: "2x", multiplier: 2_000_000, weight: 31_648}, {id: "5x", multiplier: 5_000_000, weight: 12_659, high: true}, {id: "10x", multiplier: 10_000_000, weight: 6_331, high: true}, {id: "20x", multiplier: 20_000_000, weight: 3_166, high: true}, {id: "50x", multiplier: 50_000_000, weight: 1_266, high: true}, {id: "100x", multiplier: 100_000_000, weight: 634, high: true}, {id: "200x", multiplier: 200_000_000, weight: 317, high: true}, {id: "500x", multiplier: 500_000_000, weight: 127, high: true}, {id: "1000x", multiplier: 1_000_000_000, weight: 64, high: true}, {id: "2000x", multiplier: 2_000_000_000, weight: 33, high: true}, }), }, } } func v5FixtureInput(config domain.Config, stage string, risk domain.StrategyRiskCapacity) domain.StrategyInput { input := domain.StrategyInput{GiftPriceCoins: config.GiftPrice, PoolContributionCoins: 89, RiskCapacity: risk} switch stage { case domain.StageNormal: input.Recharge7DCoins, input.Recharge30DCoins = 8, 100 case domain.StageAdvanced: input.Recharge7DCoins, input.Recharge30DCoins = 10, 200 } return input } func v5FixtureRiskCapacity(limit int64) domain.StrategyRiskCapacity { return domain.StrategyRiskCapacity{ Enabled: true, SingleDrawCoins: limit, UserHourCoins: limit, UserDayCoins: limit, DeviceDayCoins: limit, RoomHourCoins: limit, AnchorDayCoins: limit, } } func qualifiedV5FixtureState(pool int64) domain.StrategyState { const closedAtMS = int64(100 * 60 * 60 * 1000) return domain.StrategyState{ PoolBalanceCoins: pool, SettledRoundPending: true, QualificationClosedAtMS: closedAtMS, UserRegisteredAtMS: closedAtMS - 49*60*60*1000, GlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 8_900}, UserRoundRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, User48HourRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_400}, } } func strategyOrdinaryDrawPoint(config domain.StrategyConfig, state domain.StrategyState, input domain.StrategyInput, tierID string) (int64, int64, int64, error) { _, weights, err := domain.PreviewLuckyGiftStrategyWeights(config, state, input) if err != nil { return 0, 0, 0, err } var index, bound int64 tierWeight := int64(0) for _, item := range weights { if item.TierID == tierID { tierWeight = item.AdjustedWeightPPM index = bound } bound += item.AdjustedWeightPPM } if tierWeight <= 0 || bound <= 0 { return 0, 0, 0, fmt.Errorf("ordinary tier %s has no selectable weight", tierID) } return index, bound, tierWeight, nil } func fixtureStageExpectedRTP(stage domain.RuleStage) int64 { var expected int64 for _, tier := range stage.Tiers { if tier.Enabled { expected += tier.MultiplierPPM * tier.BaseWeightPPM / domain.StrategyPPMScale } } return expected } func removalCount(trace domain.DecisionTrace, reason string) int { count := 0 for _, removed := range trace.Removed { if removed.Reason == reason { count++ } } return count } func simulateDualSourceOverlap(_ domain.StrategyConfig) scenarioResult { const name = "test lalu/lucky/v5 同倍率双来源" rule := laluLuckyV5RuleFixture() runtimeConfig, config, err := mysqlstorage.BuildLuckyDynamicStrategyForRule(rule) if err != nil { return failedScenario(name, err) } passed := runtimeConfig.TargetRTPPPM == 890_000 && config.DailyJackpotLimit == 10 stageRTP := make(map[string]int64, len(rule.Stages)) for _, stage := range rule.Stages { stageRTP[stage.Stage] = fixtureStageExpectedRTP(stage) passed = passed && stageRTP[stage.Stage] == rule.TargetRTPPPM } const poolBefore = int64(15_000_000) // 真实低/高水位之间,基础权重不被水位因子改变。 fullRisk := v5FixtureRiskCapacity(4_000_000_000) type overlapCase struct { label string stage string multiplier int64 } overlaps := []overlapCase{ {label: "100x", stage: domain.StageNormal, multiplier: 100_000_000}, {label: "200x", stage: domain.StageNormal, multiplier: 200_000_000}, {label: "500x", stage: domain.StageAdvanced, multiplier: 500_000_000}, {label: "1000x", stage: domain.StageAdvanced, multiplier: 1_000_000_000}, } ordinaryEvidence := make(map[string]any, len(overlaps)) jackpotEvidence := make(map[string]any, len(overlaps)) for jackpotIndex, overlap := range overlaps { ordinaryID := fmt.Sprintf("multiplier_%d", overlap.multiplier) ordinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore} ordinaryInput := v5FixtureInput(runtimeConfig, overlap.stage, fullRisk) ordinaryIndex, ordinaryBound, ordinaryWeight, pointErr := strategyOrdinaryDrawPoint(config, ordinaryState, ordinaryInput, ordinaryID) if pointErr != nil { return failedScenario(name, pointErr) } ordinary, drawErr := domain.DecideLuckyGiftStrategy(config, ordinaryState, ordinaryInput, &scriptedRandom{indexes: []int64{ordinaryIndex}, bounds: []int64{ordinaryBound}}) if drawErr != nil { return failedScenario(name, drawErr) } ordinaryPayout := runtimeConfig.GiftPrice * overlap.multiplier / domain.StrategyPPMScale ordinaryOK := ordinary.SelectedTier.ID == ordinaryID && !ordinary.Jackpot && ordinary.NextState.UserDailyJackpotWins == 0 && ordinary.PayoutCoins == ordinaryPayout && ordinary.PoolAfterCoins == poolBefore+ordinaryInput.PoolContributionCoins-ordinaryPayout && len(ordinary.Trace.Draws) == 1 passed = passed && ordinaryOK ordinaryEvidence[overlap.label] = map[string]any{"tier_id": ordinary.SelectedTier.ID, "stage": ordinary.Stage, "weight": ordinaryWeight, "payout": ordinary.PayoutCoins, "pool_after": ordinary.PoolAfterCoins, "jackpot_hits": ordinary.NextState.UserDailyJackpotWins} jackpotState := qualifiedV5FixtureState(poolBefore) jackpotInput := v5FixtureInput(runtimeConfig, domain.StageAdvanced, fullRisk) jackpot, drawErr := domain.DecideLuckyGiftStrategy(config, jackpotState, jackpotInput, &scriptedRandom{indexes: []int64{int64(jackpotIndex)}, bounds: []int64{int64(len(overlaps))}}) if drawErr != nil { return failedScenario(name, drawErr) } jackpotID := fmt.Sprintf("jackpot_%d", overlap.multiplier) jackpotOK := jackpot.SelectedTier.ID == jackpotID && jackpot.Jackpot && jackpot.NextState.UserDailyJackpotWins == 1 && jackpot.PayoutCoins == ordinaryPayout && jackpot.PoolAfterCoins == poolBefore+jackpotInput.PoolContributionCoins-ordinaryPayout && len(jackpot.Trace.Draws) == 1 && jackpot.Trace.Draws[0].Kind == domain.StrategyDrawKindRTPCompensation passed = passed && jackpotOK jackpotEvidence[overlap.label] = map[string]any{"tier_id": jackpot.SelectedTier.ID, "weight": jackpot.SelectedTier.JackpotWeight, "payout": jackpot.PayoutCoins, "pool_after": jackpot.PoolAfterCoins, "jackpot_hits": jackpot.NextState.UserDailyJackpotWins, "draw_count": len(jackpot.Trace.Draws)} } // 真实日 cap=10 时补偿路径被挡住,但同倍率普通 1000x 仍按 advanced 的 64ppm 命中; // scriptedRandom 只提供一次随机数,若实现错误地“先发大奖再发普通奖”会立刻耗尽并失败。 capState := qualifiedV5FixtureState(poolBefore) capState.UserDailyJackpotWins = rule.MaxJackpotHitsPerUserDay capInput := v5FixtureInput(runtimeConfig, domain.StageAdvanced, fullRisk) capIndex, capBound, capWeight, err := strategyOrdinaryDrawPoint(config, capState, capInput, "multiplier_1000000000") if err != nil { return failedScenario(name, err) } afterCap, err := domain.DecideLuckyGiftStrategy(config, capState, capInput, &scriptedRandom{indexes: []int64{capIndex}, bounds: []int64{capBound}}) if err != nil { return failedScenario(name, err) } passed = passed && afterCap.SelectedTier.ID == "multiplier_1000000000" && !afterCap.Jackpot && len(afterCap.Trace.Draws) == 1 && afterCap.NextState.UserDailyJackpotWins == rule.MaxJackpotHitsPerUserDay && !afterCap.NextState.SettledRoundPending // 奖池不足会一次性消费本轮补偿资格并回落普通 1x;普通路径仍使用本抽 89 金币入池后的 P/W。 fallbackState := qualifiedV5FixtureState(100) fallbackInput := v5FixtureInput(runtimeConfig, domain.StageNormal, fullRisk) fallbackIndex, fallbackBound, _, err := strategyOrdinaryDrawPoint(config, fallbackState, fallbackInput, "multiplier_1000000") if err != nil { return failedScenario(name, err) } fallback, err := domain.DecideLuckyGiftStrategy(config, fallbackState, fallbackInput, &scriptedRandom{indexes: []int64{fallbackIndex}, bounds: []int64{fallbackBound}}) if err != nil { return failedScenario(name, err) } passed = passed && fallback.SelectedTier.ID == "multiplier_1000000" && !fallback.Jackpot && len(fallback.Trace.Draws) == 1 && removalCount(fallback.Trace, domain.StrategyReasonPoolInsufficient) == 4 && !fallback.NextState.SettledRoundPending && fallback.PoolAfterCoins == 89 // 六个维度逐个压到 9,999:所有补偿候选最小为 100x=10,000,必须被同一个硬门挡住; // 同时强抽普通 100x,证明普通奖也不能绕开该门,只是不会读取大奖日 cap。 type riskDimension struct { name string set func(*domain.StrategyRiskCapacity, int64) } riskDimensions := []riskDimension{ {name: "single", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }}, {name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }}, {name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }}, {name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }}, {name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }}, {name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }}, } riskEvidence := make(map[string]bool, len(riskDimensions)) for _, dimension := range riskDimensions { risk := fullRisk dimension.set(&risk, 9_999) blockedSpecialState := qualifiedV5FixtureState(poolBefore) blockedSpecialInput := v5FixtureInput(runtimeConfig, domain.StageNormal, risk) oneXIndex, oneXBound, _, pointErr := strategyOrdinaryDrawPoint(config, blockedSpecialState, blockedSpecialInput, "multiplier_1000000") if pointErr != nil { return failedScenario(name, pointErr) } blockedSpecial, drawErr := domain.DecideLuckyGiftStrategy(config, blockedSpecialState, blockedSpecialInput, &scriptedRandom{indexes: []int64{oneXIndex}, bounds: []int64{oneXBound}}) if drawErr != nil { return failedScenario(name, drawErr) } blockedOrdinaryState := domain.StrategyState{PoolBalanceCoins: poolBefore} blockedOrdinaryInput := v5FixtureInput(runtimeConfig, domain.StageNormal, risk) highIndex, highBound, highWeight, pointErr := strategyOrdinaryDrawPoint(config, blockedOrdinaryState, blockedOrdinaryInput, "multiplier_100000000") if pointErr != nil { return failedScenario(name, pointErr) } blockedOrdinary, drawErr := domain.DecideLuckyGiftStrategy(config, blockedOrdinaryState, blockedOrdinaryInput, &scriptedRandom{indexes: []int64{highIndex, 0}, bounds: []int64{highBound, highBound - highWeight}}) if drawErr != nil { return failedScenario(name, drawErr) } dimensionOK := blockedSpecial.SelectedTier.ID == "multiplier_1000000" && !blockedSpecial.Jackpot && removalCount(blockedSpecial.Trace, domain.StrategyReasonRiskCapacity) == 4 && blockedOrdinary.SelectedTier.ID == "multiplier_0" && len(blockedOrdinary.Trace.Draws) == 2 && blockedOrdinary.Trace.Draws[0].TierID == "multiplier_100000000" && blockedOrdinary.Trace.Draws[0].RemovedReason == domain.StrategyReasonRiskCapacity riskEvidence[dimension.name] = dimensionOK passed = passed && dimensionOK } return scenarioResult{ Name: name, Passed: passed, Summary: "真实v5三阶段RTP=89%;100/200/500/1000x普通与补偿独立,补偿优先且单抽单发,阻断/cap后回落普通,池与六维门共用", Data: map[string]any{ "fixture": map[string]any{"app": rule.AppCode, "pool": rule.PoolID, "version": rule.RuleVersion, "enabled": rule.Enabled, "target_rtp_ppm": rule.TargetRTPPPM, "stage_expected_rtp_ppm": stageRTP, "daily_jackpot_cap": rule.MaxJackpotHitsPerUserDay}, "ordinary_by_multiplier": ordinaryEvidence, "compensation_by_multiplier": jackpotEvidence, "ordinary_after_cap": map[string]any{"tier_id": afterCap.SelectedTier.ID, "weight": capWeight, "jackpot_hits": afterCap.NextState.UserDailyJackpotWins, "draw_count": len(afterCap.Trace.Draws)}, "pool_blocked_fallback": map[string]any{"tier_id": fallback.SelectedTier.ID, "pool_after": fallback.PoolAfterCoins, "removed_jackpots": removalCount(fallback.Trace, domain.StrategyReasonPoolInsufficient)}, "six_risk_dimensions": riskEvidence, }, } } func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult { const name = "六维risk capacity代表边界" config = baseProbabilityConfig(config) config.Tiers = []domain.StrategyTier{ {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true}, {ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true}, } type dimension struct { name string set func(*domain.StrategyRiskCapacity, int64) } dimensions := []dimension{ {name: "single_draw", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }}, {name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }}, {name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }}, {name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }}, {name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }}, {name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }}, } rows := map[string]map[string]any{} passed := true for _, item := range dimensions { capacity := domain.StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000} item.set(&capacity, 50) allowed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1)) if err != nil { return failedScenario(name, err) } item.set(&capacity, 49) blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1)) if err != nil { return failedScenario(name, err) } rowPass := allowed.PayoutCoins == 50 && blocked.PayoutCoins == 0 && blocked.PoolAfterCoins == 50 passed = passed && rowPass rows[item.name] = map[string]any{"equal_capacity_payout": allowed.PayoutCoins, "below_capacity_payout": blocked.PayoutCoins, "pass": rowPass} } return scenarioResult{Name: name, Passed: passed, Summary: "单次/用户小时/用户日/设备日/房间小时/主播日六维均验证W=capacity可赔、W=capacity+1硬阻断", Data: rows} } func simulateRechargeStages(config domain.StrategyConfig) scenarioResult { const name = "充值分层" type row struct { Recharge7D int64 `json:"recharge_7d"` Recharge30D int64 `json:"recharge_30d"` Stage string `json:"stage"` } inputs := [][2]int64{ {99, 499}, // 非零但两维低于 normal。 {100, 499}, // 7 日等于 normal,30 日未达,仍为 novice。 {99, 500}, // 30 日等于 normal,7 日未达,仍为 novice。 {100, 500}, // 两维等于 normal,进入 normal。 {500, 2_000}, } wants := []string{domain.StageNovice, domain.StageNovice, domain.StageNovice, domain.StageNormal, domain.StageAdvanced} rows := make([]row, 0, len(inputs)) passed := true for index, input := range inputs { stage := domain.SelectLuckyGiftRechargeStage(config, input[0], input[1]) passed = passed && stage == wants[index] rows = append(rows, row{Recharge7D: input[0], Recharge30D: input[1], Stage: stage}) } return scenarioResult{Name: name, Passed: passed, Summary: "normal/advanced同时达到7日与30日下限才提档;任一维低于normal即使非零也是novice", Data: map[string]any{"thresholds": config.RechargeStages, "boundary_rows": rows}} } func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult { const name = "组合优先级" config = settledRoundJackpotConfig(config) config.LowWaterThresholdCoins = 20_000 config.HighWaterThresholdCoins = 30_000 config.LowWaterFactorPPM = 700_000 config.HighWaterFactorPPM = 1_300_000 config.RechargeFactorPPM = 1_100_000 state := qualifiedSettledRoundState(10_000, 100*60*60*1000) config.JackpotMechanism2Enabled = true config.MilestoneSpendCoins = 50 state.PendingSpendJackpotTokens = 1 state.SpendGlobalRTP = domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800} state.ConsecutiveZeroDraws = 5 input := domain.StrategyInput{ GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true, LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000, } priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed)) if err != nil { return failedScenario(name, err) } // 两套资格同时存在时,48h 补偿先支付,消费 token 保留到下一个子抽。 priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && priority.NextState.PendingSpendJackpotTokens == 1 && priority.Stage == domain.StageAdvanced blockedState := state blockedState.PoolBalanceCoins = 50 blocked, err := domain.DecideLuckyGiftStrategy(config, blockedState, input, domain.NewSeededStrategyRandom(seed+1)) if err != nil { return failedScenario(name, err) } // 大奖资格不能透支奖池;48h 资格本轮结束,消费 token 则等待后续补水。 blockedPass := !blocked.NextState.SettledRoundPending && blocked.NextState.PendingSpendJackpotTokens == 1 && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0 return scenarioResult{ Name: name, Passed: priorityPass && blockedPass, Summary: fmt.Sprintf("优先级=48h补偿>消费达标>普通P/W;48h成功不扣消费token,池不足时消费token保留,本抽仅赔%s", blocked.SelectedTier.ID), Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked}, } } func simulateDualJackpotSpendStrategy(config domain.StrategyConfig, seed int64) scenarioResult { const name = "双大奖消费资格矩阵" config = settledRoundJackpotConfig(config) config.JackpotMechanism2Enabled = true config.MilestoneSpendCoins = 50 input := domain.StrategyInput{GiftPriceCoins: 10} base := domain.StrategyState{ PoolBalanceCoins: 10_000, PendingSpendJackpotTokens: 1, SpendGlobalRTP: domain.StrategyRTP{WagerCoins: 10_000, PayoutCoins: 9_800}, } type row struct { Case string `json:"case"` GlobalWager int64 `json:"global_wager"` GlobalPayout int64 `json:"global_payout"` TokensBefore int64 `json:"tokens_before"` TokensAfter int64 `json:"tokens_after"` Mechanism string `json:"mechanism"` Jackpot bool `json:"jackpot"` Payout int64 `json:"payout"` PoolAfter int64 `json:"pool_after"` Reason string `json:"reason"` } rows := make([]row, 0, 8) run := func(caseName string, state domain.StrategyState, drawInput domain.StrategyInput, offset int64) (domain.StrategyDecision, error) { decision, err := domain.DecideLuckyGiftStrategy(config, state, drawInput, domain.NewSeededStrategyRandom(seed+offset)) if err == nil { rows = append(rows, row{ Case: caseName, GlobalWager: state.SpendGlobalRTP.WagerCoins, GlobalPayout: state.SpendGlobalRTP.PayoutCoins, TokensBefore: state.PendingSpendJackpotTokens, TokensAfter: decision.NextState.PendingSpendJackpotTokens, Mechanism: decision.JackpotMechanism, Jackpot: decision.Jackpot, Payout: decision.PayoutCoins, PoolAfter: decision.PoolAfterCoins, Reason: decision.Trace.BlockedReason, }) } return decision, err } equal, err := run("大盘等于98%", base, input, 0) if err != nil { return failedScenario(name, err) } aboveState := base aboveState.SpendGlobalRTP.PayoutCoins = 9_801 above, err := run("大盘高于98%", aboveState, input, 1) if err != nil { return failedScenario(name, err) } noSampleState := base noSampleState.SpendGlobalRTP = domain.StrategyRTP{} noSample, err := run("无已结算大盘", noSampleState, input, 2) if err != nil { return failedScenario(name, err) } // 40->150 crosses 50/100/150 in one paid child draw. A scripted zero proves those three new // tokens cannot influence the current selection; the following child draw consumes only one. crossState := base crossState.PendingSpendJackpotTokens = 0 crossState.UserDaySpendCoins = 40 crossInput := domain.StrategyInput{GiftPriceCoins: 110} crossed, err := domain.DecideLuckyGiftStrategy(config, crossState, crossInput, &scriptedRandom{indexes: []int64{0}, bounds: []int64{1_000_000}}) if err != nil { return failedScenario(name, err) } rows = append(rows, row{Case: "当前子抽跨三档", GlobalWager: crossState.SpendGlobalRTP.WagerCoins, GlobalPayout: crossState.SpendGlobalRTP.PayoutCoins, TokensBefore: 0, TokensAfter: crossed.NextState.PendingSpendJackpotTokens, Mechanism: crossed.JackpotMechanism, Jackpot: crossed.Jackpot, Payout: crossed.PayoutCoins, PoolAfter: crossed.PoolAfterCoins, Reason: crossed.Trace.BlockedReason}) next, err := run("下一子抽消费一档", crossed.NextState, input, 3) if err != nil { return failedScenario(name, err) } qualified := qualifiedSettledRoundState(10_000, 100*60*60*1000) qualified.PendingSpendJackpotTokens = 1 qualified.SpendGlobalRTP = base.SpendGlobalRTP priority, err := run("双资格48h优先", qualified, input, 4) if err != nil { return failedScenario(name, err) } qualified.UserRoundRTP.PayoutCoins = 9_600 fallback, err := run("48h不合格消费接续", qualified, input, 5) if err != nil { return failedScenario(name, err) } poolBlockedState := base poolBlockedState.PoolBalanceCoins = 49 poolBlocked, err := run("奖池不足资格保留", poolBlockedState, input, 6) if err != nil { return failedScenario(name, err) } passed := equal.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && equal.NextState.PendingSpendJackpotTokens == 0 && !above.Jackpot && above.NextState.PendingSpendJackpotTokens == 1 && !noSample.Jackpot && noSample.NextState.PendingSpendJackpotTokens == 1 && !crossed.Jackpot && crossed.NextState.PendingSpendJackpotTokens == 3 && next.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && next.NextState.PendingSpendJackpotTokens == 2 && priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && priority.NextState.PendingSpendJackpotTokens == 1 && fallback.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && fallback.NextState.PendingSpendJackpotTokens == 0 && !poolBlocked.Jackpot && poolBlocked.NextState.PendingSpendJackpotTokens == 1 return scenarioResult{ Name: name, Passed: passed, Summary: "验证大盘<=边界、无样本关闭、每档累计、下一子抽生效、48h优先/接续和资金不足跨轮保留", Data: rows, } } // simulationStore models the repository contract that the pure kernel expects: // command idempotency and state locking wrap the draw in one critical section. The // simulator does not claim an in-memory mutex is the production implementation; it // verifies the state-machine invariants a row lock/CAS transaction must preserve. type simulationStore struct { mu sync.Mutex config domain.StrategyConfig state domain.StrategyState random domain.StrategyRandomSource seen map[string]domain.StrategyDecision transitions int } func newSimulationStore(config domain.StrategyConfig, state domain.StrategyState, seed int64) *simulationStore { return &simulationStore{config: config, state: state, random: domain.NewSeededStrategyRandom(seed), seen: map[string]domain.StrategyDecision{}} } func (s *simulationStore) execute(commandID string, input domain.StrategyInput) (domain.StrategyDecision, error) { s.mu.Lock() defer s.mu.Unlock() if existing, ok := s.seen[commandID]; ok { return existing, nil } decision, err := domain.DecideLuckyGiftStrategy(s.config, s.state, input, s.random) if err != nil { return domain.StrategyDecision{}, err } s.state = decision.NextState s.seen[commandID] = decision s.transitions++ return decision, nil } func simulateConcurrencyAndIdempotency(config domain.StrategyConfig, seed int64) scenarioResult { const name = "并发/幂等模型" config.JackpotMechanism1Enabled = false config.JackpotMechanism2Enabled = false config.DailyJackpotLimit = math.MaxInt64 config.MilestoneSpendCoins = 0 input := domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98} duplicateStore := newSimulationStore(config, domain.StrategyState{}, seed) duplicateErrors := runConcurrentCommands(duplicateStore, 32, func(int) string { return "same-command" }, input) duplicatePass := len(duplicateErrors) == 0 && duplicateStore.transitions == 1 && duplicateStore.state.Version == 1 && len(duplicateStore.seen) == 1 && duplicateStore.state.PoolBalanceCoins >= 0 uniqueStore := newSimulationStore(config, domain.StrategyState{}, seed+1) uniqueErrors := runConcurrentCommands(uniqueStore, 100, func(index int) string { return fmt.Sprintf("command-%03d", index) }, input) uniquePass := len(uniqueErrors) == 0 && uniqueStore.transitions == 100 && uniqueStore.state.Version == 100 && len(uniqueStore.seen) == 100 && uniqueStore.state.PoolBalanceCoins >= 0 return scenarioResult{ Name: name, Passed: duplicatePass && uniquePass, Summary: fmt.Sprintf("32个同幂等键仅1次状态迁移;100个并发唯一键串行化为100个版本且池余=%d", uniqueStore.state.PoolBalanceCoins), Data: map[string]any{ "duplicate": map[string]any{"requests": 32, "transitions": duplicateStore.transitions, "version": duplicateStore.state.Version, "errors": duplicateErrors}, "unique": map[string]any{"requests": 100, "transitions": uniqueStore.transitions, "version": uniqueStore.state.Version, "pool_after": uniqueStore.state.PoolBalanceCoins, "errors": uniqueErrors}, }, } } func runConcurrentCommands(store *simulationStore, count int, commandID func(int) string, input domain.StrategyInput) []string { var wait sync.WaitGroup errorsCh := make(chan string, count) for index := 0; index < count; index++ { wait.Add(1) go func(index int) { defer wait.Done() if _, err := store.execute(commandID(index), input); err != nil { errorsCh <- err.Error() } }(index) } wait.Wait() close(errorsCh) errorsFound := make([]string, 0, len(errorsCh)) for err := range errorsCh { errorsFound = append(errorsFound, err) } sort.Strings(errorsFound) return errorsFound } type groupLongRunSummary struct { ExpectedStage string `json:"expected_stage"` ObservedStage string `json:"observed_stage"` Draws int `json:"draws"` TierCounts map[string]int `json:"tier_counts"` PayoutCoins int64 `json:"payout_coins"` PoolAfter int64 `json:"pool_after"` RTPPercent float64 `json:"rtp_percent"` } func simulateLongRunGroups(config domain.StrategyConfig, seed int64, draws int) scenarioResult { const name = "长跑分群" config.JackpotMechanism1Enabled = false config.JackpotMechanism2Enabled = false config.DailyJackpotLimit = math.MaxInt64 config.MilestoneSpendCoins = 0 // This grouping run uses a production-safe table (stage EV 85%/97.5%/98%), // not the image's 2650% probability snapshot. That keeps distribution changes // visible instead of letting P/W drain every group into the same 5x-only cycle. config.LowWaterThresholdCoins = 0 config.HighWaterThresholdCoins = math.MaxInt64 config.LowWaterFactorPPM = domain.StrategyPPMScale config.HighWaterFactorPPM = domain.StrategyPPMScale config.RechargeFactorPPM = domain.StrategyPPMScale config.MissProtectionZeroDraws = math.MaxInt64 config.Tiers = []domain.StrategyTier{ {ID: "0x", MultiplierPPM: 0, Enabled: true}, {ID: "0.5x", MultiplierPPM: 500_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 100_000, domain.StageNormal: 50_000, domain.StageAdvanced: 40_000}}, {ID: "1x", MultiplierPPM: 1_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 800_000, domain.StageNormal: 850_000, domain.StageAdvanced: 840_000}}, {ID: "2x", MultiplierPPM: 2_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 0, domain.StageNormal: 50_000, domain.StageAdvanced: 60_000}}, } groups := []struct { stage string recharge7D int64 recharge30D int64 }{ {stage: domain.StageNovice, recharge7D: 99, recharge30D: 499}, {stage: domain.StageNormal, recharge7D: 100, recharge30D: 500}, {stage: domain.StageAdvanced, recharge7D: 500, recharge30D: 2_000}, } results := make([]groupLongRunSummary, 0, len(groups)) passed := true for groupIndex, group := range groups { state := domain.StrategyState{} random := domain.NewSeededStrategyRandom(seed + int64(groupIndex)) summary := groupLongRunSummary{ExpectedStage: group.stage, Draws: draws, TierCounts: map[string]int{}} for index := 0; index < draws; index++ { decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{ GiftPriceCoins: 100, PoolContributionCoins: 98, Recharge7DCoins: group.recharge7D, Recharge30DCoins: group.recharge30D, }, random) if err != nil { return failedScenario(name, err) } state = decision.NextState summary.ObservedStage = decision.Stage summary.TierCounts[decision.SelectedTier.ID]++ summary.PayoutCoins += decision.PayoutCoins if state.PoolBalanceCoins < 0 { passed = false } } summary.PoolAfter = state.PoolBalanceCoins summary.RTPPercent = float64(summary.PayoutCoins) / float64(draws*100) * 100 passed = passed && summary.ObservedStage == group.stage && summary.RTPPercent <= 98 && int64(draws*98)-summary.PayoutCoins == summary.PoolAfter results = append(results, summary) } return scenarioResult{Name: name, Passed: passed, Summary: fmt.Sprintf("novice/normal/advanced各%d抽,分层权重不同但三组资金RTP均<=98%%且守恒", draws), Data: results} }