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