1117 lines
55 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

package 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/%d10金币按公共池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=%vP=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 日等于 normal30 日未达,仍为 novice。
{99, 500}, // 30 日等于 normal7 日未达,仍为 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}
}