package main import ( "fmt" "math" "sort" "sync" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" ) 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, 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}, } 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["200x"] _, has500 := all["500x"] _, has1000 := all["1000x"] passed := has200 && has500 && has1000 && len(limited) == 1 && limited["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 // Force ordinary 200x after the special path is blocked, then force 0x on the // retry. This proves the daily ceiling also constrains accidental base jackpots. sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000, 0}, bounds: []int64{1_000_000, 960_000}}) if err != nil { return failedScenario(name, err) } passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && !sixth.NextState.SettledRoundPending return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("5个独立已结算轮次命中后计数=%d;第6个轮次受日上限阻断且该轮资格仍按一次尝试消费", state.UserDailyJackpotWins), Data: map[string]any{"sixth": sixth}, } } 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) 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) } // 已结算轮次资格在普通概率、第6抽保底和动态因子之前尝试;独立消费里程碑机制已经停用。 priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation && !priority.NextState.SettledRoundPending && 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) } // 大奖资格不能透支奖池;该轮资格被消费后,同一抽仍可按普通第6抽保底支付恰好可负担的5x。 blockedPass := !blocked.NextState.SettledRoundPending && blocked.JackpotMechanism == "" && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0 return scenarioResult{ Name: name, Passed: priorityPass && blockedPass, Summary: fmt.Sprintf("优先级=已结算轮次资格>普通P/W/第6抽;大奖池不足时资格消费,本抽仅赔可支付%s", blocked.SelectedTier.ID), Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked}, } } // 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} }