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 { 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 == 1_000_000 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 mechanismOneRow struct { GlobalPass bool `json:"global_pass"` UserDayPass bool `json:"user_day_pass"` User72HourPass bool `json:"user_72h_pass"` Triggered bool `json:"triggered"` SelectedTier string `json:"selected_tier"` } func simulateMechanismOneTruthTable(config domain.StrategyConfig, seed int64) scenarioResult { const name = "大奖机制1真值表" config = baseProbabilityConfig(config) config.JackpotMechanism1Enabled = true config.DailyJackpotLimit = 5 rows := make([]mechanismOneRow, 0, 8) passed := true rowIndex := int64(0) for mask := 0; mask < 8; mask++ { globalPass := mask&1 != 0 dayPass := mask&2 != 0 hour72Pass := mask&4 != 0 state := domain.StrategyState{ PoolBalanceCoins: 20_000, GlobalRTP: rtpForTruth(globalPass, 98), UserDayRTP: rtpForTruth(dayPass, 96), User72HourRTP: rtpForTruth(hour72Pass, 96), } decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+rowIndex)) if err != nil { return failedScenario(name, err) } triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation want := globalPass && dayPass && hour72Pass passed = passed && triggered == want rows = append(rows, mechanismOneRow{GlobalPass: globalPass, UserDayPass: dayPass, User72HourPass: hour72Pass, Triggered: triggered, SelectedTier: decision.SelectedTier.ID}) rowIndex++ } // The denominator-zero row is separate from boolean “ratio above threshold” so // the report proves an empty window never qualifies as an apparent 0% RTP. zeroState := domain.StrategyState{ PoolBalanceCoins: 20_000, GlobalRTP: domain.StrategyRTP{}, UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96}, User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96}, } zeroDecision, err := domain.DecideLuckyGiftStrategy(config, zeroState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+99)) if err != nil { return failedScenario(name, err) } zeroBlocked := zeroDecision.JackpotMechanism != domain.StrategyJackpotMechanismRTPCompensation passed = passed && zeroBlocked return scenarioResult{ Name: name, Passed: passed, Summary: "global<=98%、用户日<=96%、用户72h<=96%三项全真才触发;任一分母为0明确阻断", Data: map[string]any{"truth_table": rows, "denominator_zero_blocked": zeroBlocked, "denominator_zero_conditions": zeroDecision.Trace.Conditions}, } } func rtpForTruth(pass bool, limitPercent int64) domain.StrategyRTP { payout := limitPercent if !pass { payout++ } return domain.StrategyRTP{WagerCoins: 100, PayoutCoins: payout} } func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResult { const name = "机制2里程碑/池不足" config = baseProbabilityConfig(config) config.JackpotMechanism2Enabled = true // Simulator makes the product's USD 50 threshold explicit in its own demo unit; // production must inject the region/version-specific USD-to-coin conversion. config.MilestoneSpendCoins = 50 config.DailyJackpotLimit = 5 input := domain.StrategyInput{GiftPriceCoins: 10} blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}, input, domain.NewSeededStrategyRandom(seed)) if err != nil { return failedScenario(name, err) } payableState := blocked.NextState payableState.PoolBalanceCoins = 20_000 paid, err := domain.DecideLuckyGiftStrategy(config, payableState, input, domain.NewSeededStrategyRandom(seed+1)) if err != nil { return failedScenario(name, err) } crossed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}, input, domain.NewSeededStrategyRandom(seed+2)) if err != nil { return failedScenario(name, err) } passed := blocked.NextState.PendingMilestoneTokens == 1 && blocked.Trace.MilestoneTokenRetained && paid.ConsumedMilestoneToken && paid.NextState.PendingMilestoneTokens == 0 && crossed.NextState.PendingMilestoneTokens == 1 && !crossed.ConsumedMilestoneToken return scenarioResult{ Name: name, Passed: passed, Summary: fmt.Sprintf("49池余额时资格保留=%v;补池后命中%s并消费;40→50本抽新增token=%d供下一抽", blocked.Trace.MilestoneTokenRetained, paid.SelectedTier.ID, crossed.Trace.MilestoneTokensEarned), Data: map[string]any{"milestone_crossings_49_to_101": domain.MilestoneTokensEarned(49, 101, 50), "blocked": blocked, "paid": paid, "earned_for_next_draw": crossed}, } } func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult { const name = "大奖集合" config = baseProbabilityConfig(config) config.JackpotMechanism2Enabled = true config.DailyJackpotLimit = 5 all := map[string]int{} for index := int64(0); index < 120; index++ { decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 10_000, PendingMilestoneTokens: 1}, 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, domain.StrategyState{PoolBalanceCoins: 4_999, PendingMilestoneTokens: 1}, 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 = baseProbabilityConfig(config) config.JackpotMechanism2Enabled = true config.DailyJackpotLimit = 5 state := domain.StrategyState{PoolBalanceCoins: 1_000_000} for index := 0; index < 5; index++ { state.PendingMilestoneTokens = 1 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.PendingMilestoneTokens = 1 // 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.PendingMilestoneTokens == 1 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{{0, 0}, {0, 1}, {1, 1}} wants := []string{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: "三阶段同时要求7日与30日显式下限,选择满足条件的最高阶段", Data: map[string]any{"thresholds": config.RechargeStages, "boundary_rows": rows}} } func simulateCombinationPriority(config domain.StrategyConfig, seed int64) scenarioResult { const name = "组合优先级" config = baseProbabilityConfig(config) config.LowWaterThresholdCoins = 20_000 config.HighWaterThresholdCoins = 30_000 config.LowWaterFactorPPM = 700_000 config.HighWaterFactorPPM = 1_300_000 config.RechargeFactorPPM = 1_100_000 config.JackpotMechanism1Enabled = true config.JackpotMechanism2Enabled = true config.DailyJackpotLimit = 5 config.MilestoneSpendCoins = 50 state := domain.StrategyState{ PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1, GlobalRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 98}, UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96}, User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96}, } input := domain.StrategyInput{ GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true, LastRechargeAtMS: 700_001, Recharge7DCoins: 1, Recharge30DCoins: 1, } priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed)) if err != nil { return failedScenario(name, err) } // When both jackpot mechanisms are eligible, the persisted token is contractual // state and therefore wins before RTP compensation or sixth-draw protection. priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && priority.ConsumedMilestoneToken && 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) } // Neither special mechanism may overdraw the pool. The token survives and the // ordinary sixth-draw protection may pay only the affordable 5x boundary. blockedPass := blocked.Trace.MilestoneTokenRetained && blocked.NextState.PendingMilestoneTokens == 1 && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0 return scenarioResult{ Name: name, Passed: priorityPass && blockedPass, Summary: fmt.Sprintf("优先级=持久token>%s>普通P/W/第6抽;特殊档池不足时资格保留并仅赔可支付%s", domain.StrategyJackpotMechanismRTPCompensation, 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: 0, recharge30D: 0}, {stage: domain.StageNormal, recharge7D: 0, recharge30D: 1}, {stage: domain.StageAdvanced, recharge7D: 1, recharge30D: 1}, } 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} }