775 lines
36 KiB
Go
775 lines
36 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"sync"
|
||
|
||
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
|
||
)
|
||
|
||
type scriptedRandom struct {
|
||
indexes []int64
|
||
bounds []int64
|
||
position int
|
||
}
|
||
|
||
// imageExampleConfig is deliberately local to strategy-sim. Its ordinary weights
|
||
// have 26.5x EV and therefore must never be exposed as the domain's runtime default.
|
||
func imageExampleConfig(config domain.StrategyConfig) domain.StrategyConfig {
|
||
// 模拟显式给出金币分层边界,避免把生产 disabled 草稿的 0/0 sentinel 当成可运行默认值。
|
||
config.RechargeStages = []domain.StrategyRechargeStage{
|
||
{Name: domain.StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
|
||
{Name: domain.StageNormal, MinRecharge7DCoins: 100, MinRecharge30DCoins: 500},
|
||
{Name: domain.StageAdvanced, MinRecharge7DCoins: 500, MinRecharge30DCoins: 2_000},
|
||
}
|
||
config.Tiers = []domain.StrategyTier{
|
||
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 600_000, Enabled: true},
|
||
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: 200_000, Enabled: true},
|
||
{ID: "50x", MultiplierPPM: 50_000_000, BaseWeightPPM: 150_000, Enabled: true},
|
||
{ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 40_000, Jackpot: true, JackpotWeight: 4, Enabled: true},
|
||
{ID: "500x", MultiplierPPM: 500_000_000, BaseWeightPPM: 0, Jackpot: true, JackpotWeight: 2, Enabled: true},
|
||
{ID: "1000x", MultiplierPPM: 1_000_000_000, BaseWeightPPM: 10_000, Jackpot: true, JackpotWeight: 1, Enabled: true},
|
||
}
|
||
return config
|
||
}
|
||
|
||
func (r *scriptedRandom) Int63n(n int64) (int64, error) {
|
||
if r.position >= len(r.indexes) {
|
||
return 0, fmt.Errorf("scripted random exhausted at bound %d", n)
|
||
}
|
||
if len(r.bounds) > r.position && r.bounds[r.position] != n {
|
||
return 0, fmt.Errorf("scripted random call %d bound=%d want=%d", r.position, n, r.bounds[r.position])
|
||
}
|
||
value := r.indexes[r.position]
|
||
r.position++
|
||
return value, nil
|
||
}
|
||
|
||
// baseProbabilityConfig isolates the image's ordinary probability table. Each
|
||
// dynamic/special rule has a separate scenario, so scripted intervals remain exact.
|
||
func baseProbabilityConfig(config domain.StrategyConfig) domain.StrategyConfig {
|
||
config.LowWaterThresholdCoins = 0
|
||
config.HighWaterThresholdCoins = math.MaxInt64
|
||
config.LowWaterFactorPPM = domain.StrategyPPMScale
|
||
config.HighWaterFactorPPM = domain.StrategyPPMScale
|
||
config.RechargeFactorPPM = domain.StrategyPPMScale
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
config.DailyJackpotLimit = math.MaxInt64
|
||
config.MilestoneSpendCoins = 0
|
||
return config
|
||
}
|
||
|
||
func failedScenario(name string, err error) scenarioResult {
|
||
return scenarioResult{Name: name, Passed: false, Summary: err.Error()}
|
||
}
|
||
|
||
func simulateImage800Redraw(config domain.StrategyConfig) scenarioResult {
|
||
const name = "图示800重抽序列"
|
||
config = baseProbabilityConfig(config)
|
||
random := &scriptedRandom{indexes: []int64{950_000, 359_999, 0}, bounds: []int64{1_000_000, 360_000, 350_000}}
|
||
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 800}, domain.StrategyInput{GiftPriceCoins: 10}, random)
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
sequence := make([]string, 0, len(decision.Trace.Draws))
|
||
for _, draw := range decision.Trace.Draws {
|
||
sequence = append(sequence, draw.TierID)
|
||
}
|
||
passed := decision.PayoutCoins == 50 && decision.PoolAfterCoins == 750 && fmt.Sprint(sequence) == "[200x 1000x 5x]"
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("P=800,固定脚本命中%s,最终赔付=%d,池后=%d", sequence, decision.PayoutCoins, decision.PoolAfterCoins),
|
||
Data: map[string]any{"sequence": sequence, "trace": decision.Trace},
|
||
}
|
||
}
|
||
|
||
func simulatePWBoundaries(config domain.StrategyConfig) scenarioResult {
|
||
const name = "P=W边界/候选穷尽"
|
||
config = baseProbabilityConfig(config)
|
||
equal, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 2_000}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000}, bounds: []int64{1_000_000}})
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
exhausted, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49}, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{999_999, 350_000, 349_999, 0}, bounds: []int64{1_000_000, 390_000, 350_000, 200_000}})
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
passed := equal.PayoutCoins == 2_000 && equal.PoolAfterCoins == 0 && exhausted.PayoutCoins == 0 && exhausted.Trace.FinalReason == domain.StrategyReasonNoPayableTier && exhausted.PoolAfterCoins == 49
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("W=P赔付%d且池归零;P=49时正奖候选穷尽安全回0,池仍%d", equal.PayoutCoins, exhausted.PoolAfterCoins),
|
||
Data: map[string]any{"equality": equal.Trace, "exhaustion": exhausted.Trace},
|
||
}
|
||
}
|
||
|
||
func simulateFundSplitAndColdStart(config domain.StrategyConfig) scenarioResult {
|
||
const name = "拆账与冷启动"
|
||
split, err := domain.SplitLuckyGiftStrategyFunds(100, config)
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
initial := domain.InitialLuckyGiftStrategyState(config)
|
||
small, err := domain.SplitLuckyGiftStrategyFunds(10, config)
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
conserved := split.PublicPoolCoins+split.ProfitPoolCoins+split.AnchorReturnCoins == 100
|
||
passed := split.PublicPoolCoins == 98 && split.ProfitPoolCoins == 1 && split.AnchorReturnCoins == 1 && small.PublicPoolCoins == 9 && small.ProfitPoolCoins == 1 && small.AnchorReturnCoins == 0 && conserved && initial.PoolBalanceCoins == 0
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("100金币拆为%d/%d/%d;10金币按公共池floor、余数归盈利拆为%d/%d/%d;动态池冷启动=%d,启动资金只能审计注资", split.PublicPoolCoins, split.ProfitPoolCoins, split.AnchorReturnCoins, small.PublicPoolCoins, small.ProfitPoolCoins, small.AnchorReturnCoins, initial.PoolBalanceCoins),
|
||
Data: map[string]any{
|
||
"split_100": split, "split_10_rounding": small,
|
||
"rounding_policy": "public=floor, anchor=floor, residue=profit",
|
||
"conserved": conserved, "cold_start_state": initial,
|
||
},
|
||
}
|
||
}
|
||
|
||
type batchSummary struct {
|
||
Requested int `json:"requested"`
|
||
Executed int `json:"executed"`
|
||
Positive int `json:"positive"`
|
||
PayoutCoins int64 `json:"payout_coins"`
|
||
PoolAfter int64 `json:"pool_after"`
|
||
TierCounts map[string]int `json:"tier_counts"`
|
||
}
|
||
|
||
func simulateBatchCounts(config domain.StrategyConfig, seed int64) scenarioResult {
|
||
const name = "1/5/6/99批量"
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
config.MilestoneSpendCoins = 0
|
||
config.DailyJackpotLimit = math.MaxInt64
|
||
counts := []int{1, 5, 6, 99}
|
||
results := make([]batchSummary, 0, len(counts))
|
||
passed := true
|
||
for _, count := range counts {
|
||
state := domain.StrategyState{}
|
||
random := domain.NewSeededStrategyRandom(seed + int64(count))
|
||
summary := batchSummary{Requested: count, TierCounts: map[string]int{}}
|
||
for index := 0; index < count; index++ {
|
||
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
state = decision.NextState
|
||
summary.Executed++
|
||
summary.TierCounts[decision.SelectedTier.ID]++
|
||
summary.PayoutCoins += decision.PayoutCoins
|
||
if decision.PayoutCoins > 0 {
|
||
summary.Positive++
|
||
}
|
||
passed = passed && decision.PoolAfterCoins >= 0
|
||
}
|
||
summary.PoolAfter = state.PoolBalanceCoins
|
||
passed = passed && summary.Executed == count
|
||
results = append(results, summary)
|
||
}
|
||
return scenarioResult{Name: name, Passed: passed, Summary: "每份礼物独立推进一次状态,1/5/6/99均完整执行且任一中间池余额不负", Data: results}
|
||
}
|
||
|
||
func simulateSettlementWagerWindow() scenarioResult {
|
||
const name = "真实流水结算窗口"
|
||
const (
|
||
settlementWager = int64(99)
|
||
referencePrice = int64(10)
|
||
totalSpend = int64(100)
|
||
drawCount = int64(99)
|
||
)
|
||
type window struct {
|
||
Index int64 `json:"index"`
|
||
Paid int64 `json:"paid_draws"`
|
||
Wager int64 `json:"wager_coins"`
|
||
}
|
||
// 10 抽但只有 98 流水是专门构造的反例:ceil(99/10)=10 的旧抽数算法会提前关窗,真实流水算法仍允许下一抽进入窗口 1。
|
||
windows := []window{{Index: 1, Paid: 10, Wager: 98}}
|
||
drawWindows := make([]int64, 0, drawCount)
|
||
for index := int64(1); index <= drawCount; index++ {
|
||
current := &windows[len(windows)-1]
|
||
if current.Wager >= settlementWager {
|
||
windows = append(windows, window{Index: current.Index + 1})
|
||
current = &windows[len(windows)-1]
|
||
}
|
||
unitSpend := totalSpend / drawCount
|
||
if index <= totalSpend%drawCount {
|
||
unitSpend++
|
||
}
|
||
current.Paid++
|
||
current.Wager += unitSpend
|
||
drawWindows = append(drawWindows, current.Index)
|
||
}
|
||
firstCount, secondCount := 0, 0
|
||
for _, index := range drawWindows {
|
||
if index == 1 {
|
||
firstCount++
|
||
} else if index == 2 {
|
||
secondCount++
|
||
}
|
||
}
|
||
passed := len(windows) == 2 && windows[0].Paid == 11 && windows[0].Wager == 100 && windows[1].Paid == 98 && windows[1].Wager == 98 && firstCount == 1 && secondCount == 98
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("阈值99、参考价10、预置10抽/98流水;99份共100金币后窗口流水=%d/%d,子抽归属=%d/%d", windows[0].Wager, windows[1].Wager, firstCount, secondCount),
|
||
Data: map[string]any{
|
||
"settlement_window_wager": settlementWager,
|
||
"reference_price": referencePrice, "legacy_derived_draw_threshold": int64(math.Ceil(float64(settlementWager) / float64(referencePrice))),
|
||
"windows": windows, "draws_in_window_1": firstCount, "draws_in_window_2": secondCount,
|
||
},
|
||
}
|
||
}
|
||
|
||
func simulateWaterBoundaries(config domain.StrategyConfig) scenarioResult {
|
||
const name = "低中高水位边界"
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
type waterPoint struct {
|
||
Pool int64 `json:"pool"`
|
||
FiveWeight int64 `json:"five_x_weight_ppm"`
|
||
Factors []string `json:"factors"`
|
||
}
|
||
points := make([]waterPoint, 0, 4)
|
||
for _, pool := range []int64{9_999_999, 10_000_000, 20_000_000, 20_000_001} {
|
||
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: pool}, domain.StrategyInput{GiftPriceCoins: 100})
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
trace := strategyWeightTrace(traces, "5x")
|
||
factors := make([]string, 0, len(trace.Factors))
|
||
for _, factor := range trace.Factors {
|
||
factors = append(factors, factor.Name)
|
||
}
|
||
points = append(points, waterPoint{Pool: pool, FiveWeight: trace.AdjustedWeightPPM, Factors: factors})
|
||
}
|
||
passed := points[0].FiveWeight == 140_000 && points[1].FiveWeight == 200_000 && points[2].FiveWeight == 200_000 && points[3].FiveWeight == 260_000
|
||
return scenarioResult{Name: name, Passed: passed, Summary: "<1000万应用0.7;等于1000万/2000万均为中水位;>2000万应用1.3", Data: points}
|
||
}
|
||
|
||
func simulateRechargeWindowBoundaries(config domain.StrategyConfig) scenarioResult {
|
||
const name = "充值0/299999/300000ms"
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
const now = int64(1_000_000)
|
||
type rechargePoint struct {
|
||
AgeMS int64 `json:"age_ms"`
|
||
FiveWeight int64 `json:"five_x_weight_ppm"`
|
||
Boosted bool `json:"boosted"`
|
||
}
|
||
points := make([]rechargePoint, 0, 3)
|
||
for _, age := range []int64{0, 299_999, 300_000} {
|
||
input := domain.StrategyInput{GiftPriceCoins: 100, NowMS: now, HasRechargeFact: true, LastRechargeAtMS: now - age}
|
||
_, traces, err := domain.PreviewLuckyGiftStrategyWeights(config, domain.StrategyState{PoolBalanceCoins: 15_000_000}, input)
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
trace := strategyWeightTrace(traces, "5x")
|
||
boosted := false
|
||
for _, factor := range trace.Factors {
|
||
boosted = boosted || factor.Name == "recent_recharge"
|
||
}
|
||
points = append(points, rechargePoint{AgeMS: age, FiveWeight: trace.AdjustedWeightPPM, Boosted: boosted})
|
||
}
|
||
passed := points[0].Boosted && points[1].Boosted && !points[2].Boosted && points[0].FiveWeight == 220_000 && points[2].FiveWeight == 200_000
|
||
return scenarioResult{Name: name, Passed: passed, Summary: "充值加成严格使用[0,300000ms):0和299999生效,300000失效", Data: points}
|
||
}
|
||
|
||
func simulateProbabilityMonteCarlo(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
|
||
const name = "概率Monte Carlo"
|
||
config = baseProbabilityConfig(config)
|
||
config.MissProtectionZeroDraws = math.MaxInt64
|
||
state := domain.StrategyState{PoolBalanceCoins: 1_000_000_000_000_000}
|
||
random := domain.NewSeededStrategyRandom(seed)
|
||
counts := map[string]int{}
|
||
var multiplierSum float64
|
||
for index := 0; index < draws; index++ {
|
||
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, random)
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
state = decision.NextState
|
||
counts[decision.SelectedTier.ID]++
|
||
multiplierSum += float64(decision.SelectedTier.MultiplierPPM) / float64(domain.StrategyPPMScale)
|
||
}
|
||
expected := map[string]float64{"0x": 0.60, "5x": 0.20, "50x": 0.15, "200x": 0.04, "500x": 0, "1000x": 0.01}
|
||
empirical := map[string]float64{}
|
||
passed := true
|
||
for tier, probability := range expected {
|
||
empirical[tier] = float64(counts[tier]) / float64(draws)
|
||
// Five standard deviations makes the assertion scale with -draws instead
|
||
// of baking a tolerance that is too strict for a quick local smoke run.
|
||
tolerance := 5*math.Sqrt(probability*(1-probability)/float64(draws)) + 1/float64(draws)
|
||
passed = passed && math.Abs(empirical[tier]-probability) <= tolerance
|
||
}
|
||
empiricalEV := multiplierSum / float64(draws)
|
||
const multiplierVariance = 11_277.75 // E[X^2]=11980, E[X]^2=702.25.
|
||
evTolerance := 5 * math.Sqrt(multiplierVariance/float64(draws))
|
||
passed = passed && math.Abs(empiricalEV-26.5) <= evTolerance
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("%d抽经验EV=%.4fx(只验证图示概率采样,不声称通过98%%资金RTP)", draws, empiricalEV),
|
||
Data: map[string]any{"counts": counts, "expected_probability": expected, "empirical_probability": empirical, "empirical_ev_multiplier": empiricalEV},
|
||
}
|
||
}
|
||
|
||
func simulateFundedRTPLongRun(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
|
||
const name = "RTP长跑"
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
config.DailyJackpotLimit = math.MaxInt64
|
||
config.MilestoneSpendCoins = 0
|
||
state := domain.StrategyState{}
|
||
random := domain.NewSeededStrategyRandom(seed)
|
||
var contributed, payout int64
|
||
for index := 0; index < draws; index++ {
|
||
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}, random)
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
state = decision.NextState
|
||
contributed += 98
|
||
payout += decision.PayoutCoins
|
||
if state.PoolBalanceCoins < 0 {
|
||
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d抽资金为负", index+1)}
|
||
}
|
||
}
|
||
rtpPercent := float64(payout) / float64(draws*100) * 100
|
||
conserved := contributed-payout == state.PoolBalanceCoins
|
||
passed := conserved && payout <= contributed && rtpPercent <= 98
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("%d抽实付RTP=%.4f%%,投入=%d,赔付=%d,池余=%d", draws, rtpPercent, contributed, payout, state.PoolBalanceCoins),
|
||
Data: map[string]any{"contributed": contributed, "payout": payout, "pool_after": state.PoolBalanceCoins, "rtp_percent": rtpPercent, "fund_conserved": conserved},
|
||
}
|
||
}
|
||
|
||
func strategyWeightTrace(traces []domain.StrategyWeightTrace, tierID string) domain.StrategyWeightTrace {
|
||
for _, trace := range traces {
|
||
if trace.TierID == tierID {
|
||
return trace
|
||
}
|
||
}
|
||
return domain.StrategyWeightTrace{}
|
||
}
|
||
|
||
type mechanismOneRow struct {
|
||
GlobalPass bool `json:"global_pass"`
|
||
UserDayPass bool `json:"user_day_pass"`
|
||
User72HourPass bool `json:"user_72h_pass"`
|
||
Triggered bool `json:"triggered"`
|
||
SelectedTier string `json:"selected_tier"`
|
||
}
|
||
|
||
func simulateMechanismOneTruthTable(config domain.StrategyConfig, seed int64) scenarioResult {
|
||
const name = "大奖机制1真值表"
|
||
config = baseProbabilityConfig(config)
|
||
config.JackpotMechanism1Enabled = true
|
||
config.DailyJackpotLimit = 5
|
||
rows := make([]mechanismOneRow, 0, 8)
|
||
passed := true
|
||
rowIndex := int64(0)
|
||
for mask := 0; mask < 8; mask++ {
|
||
globalPass := mask&1 != 0
|
||
dayPass := mask&2 != 0
|
||
hour72Pass := mask&4 != 0
|
||
state := domain.StrategyState{
|
||
PoolBalanceCoins: 20_000,
|
||
GlobalRTP: rtpForTruth(globalPass, 98),
|
||
UserDayRTP: rtpForTruth(dayPass, 96),
|
||
User72HourRTP: rtpForTruth(hour72Pass, 96),
|
||
}
|
||
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+rowIndex))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
triggered := decision.JackpotMechanism == domain.StrategyJackpotMechanismRTPCompensation
|
||
want := globalPass && dayPass && hour72Pass
|
||
passed = passed && triggered == want
|
||
rows = append(rows, mechanismOneRow{GlobalPass: globalPass, UserDayPass: dayPass, User72HourPass: hour72Pass, Triggered: triggered, SelectedTier: decision.SelectedTier.ID})
|
||
rowIndex++
|
||
}
|
||
// The denominator-zero row is separate from boolean “ratio above threshold” so
|
||
// the report proves an empty window never qualifies as an apparent 0% RTP.
|
||
zeroState := domain.StrategyState{
|
||
PoolBalanceCoins: 20_000, GlobalRTP: domain.StrategyRTP{},
|
||
UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||
User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||
}
|
||
zeroDecision, err := domain.DecideLuckyGiftStrategy(config, zeroState, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+99))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
zeroBlocked := zeroDecision.JackpotMechanism != domain.StrategyJackpotMechanismRTPCompensation
|
||
passed = passed && zeroBlocked
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: "global<=98%、用户日<=96%、用户72h<=96%三项全真才触发;任一分母为0明确阻断",
|
||
Data: map[string]any{"truth_table": rows, "denominator_zero_blocked": zeroBlocked, "denominator_zero_conditions": zeroDecision.Trace.Conditions},
|
||
}
|
||
}
|
||
|
||
func rtpForTruth(pass bool, limitPercent int64) domain.StrategyRTP {
|
||
payout := limitPercent
|
||
if !pass {
|
||
payout++
|
||
}
|
||
return domain.StrategyRTP{WagerCoins: 100, PayoutCoins: payout}
|
||
}
|
||
|
||
func simulateMechanismTwo(config domain.StrategyConfig, seed int64) scenarioResult {
|
||
const name = "机制2里程碑/池不足"
|
||
config = baseProbabilityConfig(config)
|
||
config.JackpotMechanism2Enabled = true
|
||
// 模拟只用一个便于验证跨档的金币值;生产值必须来自当前不可变规则版本。
|
||
config.MilestoneSpendCoins = 50
|
||
config.DailyJackpotLimit = 5
|
||
input := domain.StrategyInput{GiftPriceCoins: 10}
|
||
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 49, PendingMilestoneTokens: 1}, input, domain.NewSeededStrategyRandom(seed))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
payableState := blocked.NextState
|
||
payableState.PoolBalanceCoins = 20_000
|
||
paid, err := domain.DecideLuckyGiftStrategy(config, payableState, input, domain.NewSeededStrategyRandom(seed+1))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
crossed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 1_000, UserDaySpendCoins: 40}, input, domain.NewSeededStrategyRandom(seed+2))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
passed := blocked.NextState.PendingMilestoneTokens == 1 && blocked.Trace.MilestoneTokenRetained && paid.ConsumedMilestoneToken && paid.NextState.PendingMilestoneTokens == 0 && crossed.NextState.PendingMilestoneTokens == 1 && !crossed.ConsumedMilestoneToken
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("49池余额时资格保留=%v;补池后命中%s并消费;40→50本抽新增token=%d供下一抽", blocked.Trace.MilestoneTokenRetained, paid.SelectedTier.ID, crossed.Trace.MilestoneTokensEarned),
|
||
Data: map[string]any{"milestone_crossings_49_to_101": domain.MilestoneTokensEarned(49, 101, 50), "blocked": blocked, "paid": paid, "earned_for_next_draw": crossed},
|
||
}
|
||
}
|
||
|
||
func simulateJackpotSet(config domain.StrategyConfig, seed int64) scenarioResult {
|
||
const name = "大奖集合"
|
||
config = baseProbabilityConfig(config)
|
||
config.JackpotMechanism2Enabled = true
|
||
config.DailyJackpotLimit = 5
|
||
all := map[string]int{}
|
||
for index := int64(0); index < 120; index++ {
|
||
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 10_000, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+index))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
all[decision.SelectedTier.ID]++
|
||
}
|
||
limited := map[string]int{}
|
||
for index := int64(0); index < 40; index++ {
|
||
decision, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 4_999, PendingMilestoneTokens: 1}, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+1_000+index))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
limited[decision.SelectedTier.ID]++
|
||
}
|
||
_, has200 := all["200x"]
|
||
_, has500 := all["500x"]
|
||
_, has1000 := all["1000x"]
|
||
passed := has200 && has500 && has1000 && len(limited) == 1 && limited["200x"] == 40
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("池足时大奖集合覆盖200/500/1000x=%v;P=4999时只可能200x=%v", has200 && has500 && has1000, limited),
|
||
Data: map[string]any{"pool_10000_counts": all, "pool_4999_counts": limited},
|
||
}
|
||
}
|
||
|
||
func simulateDailyJackpotLimit(config domain.StrategyConfig, seed int64) scenarioResult {
|
||
const name = "日5次上限"
|
||
config = baseProbabilityConfig(config)
|
||
config.JackpotMechanism2Enabled = true
|
||
config.DailyJackpotLimit = 5
|
||
state := domain.StrategyState{PoolBalanceCoins: 1_000_000}
|
||
for index := 0; index < 5; index++ {
|
||
state.PendingMilestoneTokens = 1
|
||
decision, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, domain.NewSeededStrategyRandom(seed+int64(index)))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
state = decision.NextState
|
||
if !decision.Jackpot {
|
||
return scenarioResult{Name: name, Passed: false, Summary: fmt.Sprintf("第%d次未按资格出大奖", index+1)}
|
||
}
|
||
}
|
||
state.PendingMilestoneTokens = 1
|
||
// Force ordinary 200x after the special path is blocked, then force 0x on the
|
||
// retry. This proves the daily ceiling also constrains accidental base jackpots.
|
||
sixth, err := domain.DecideLuckyGiftStrategy(config, state, domain.StrategyInput{GiftPriceCoins: 10}, &scriptedRandom{indexes: []int64{950_000, 0}, bounds: []int64{1_000_000, 960_000}})
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
passed := state.UserDailyJackpotWins == 5 && !sixth.Jackpot && sixth.NextState.UserDailyJackpotWins == 5 && sixth.NextState.PendingMilestoneTokens == 1
|
||
return scenarioResult{
|
||
Name: name, Passed: passed,
|
||
Summary: fmt.Sprintf("前5次大奖计数=%d;第6次特殊与基础大奖均被硬阻断且资格保留", state.UserDailyJackpotWins),
|
||
Data: map[string]any{"sixth": sixth},
|
||
}
|
||
}
|
||
|
||
func simulateSixRiskCapacities(config domain.StrategyConfig) scenarioResult {
|
||
const name = "六维risk capacity代表边界"
|
||
config = baseProbabilityConfig(config)
|
||
config.Tiers = []domain.StrategyTier{
|
||
{ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 0, Enabled: true},
|
||
{ID: "5x", MultiplierPPM: 5_000_000, BaseWeightPPM: domain.StrategyPPMScale, Enabled: true},
|
||
}
|
||
type dimension struct {
|
||
name string
|
||
set func(*domain.StrategyRiskCapacity, int64)
|
||
}
|
||
dimensions := []dimension{
|
||
{name: "single_draw", set: func(c *domain.StrategyRiskCapacity, v int64) { c.SingleDrawCoins = v }},
|
||
{name: "user_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserHourCoins = v }},
|
||
{name: "user_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.UserDayCoins = v }},
|
||
{name: "device_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.DeviceDayCoins = v }},
|
||
{name: "room_hour", set: func(c *domain.StrategyRiskCapacity, v int64) { c.RoomHourCoins = v }},
|
||
{name: "anchor_day", set: func(c *domain.StrategyRiskCapacity, v int64) { c.AnchorDayCoins = v }},
|
||
}
|
||
rows := map[string]map[string]any{}
|
||
passed := true
|
||
for _, item := range dimensions {
|
||
capacity := domain.StrategyRiskCapacity{Enabled: true, SingleDrawCoins: 1_000, UserHourCoins: 1_000, UserDayCoins: 1_000, DeviceDayCoins: 1_000, RoomHourCoins: 1_000, AnchorDayCoins: 1_000}
|
||
item.set(&capacity, 50)
|
||
allowed, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
item.set(&capacity, 49)
|
||
blocked, err := domain.DecideLuckyGiftStrategy(config, domain.StrategyState{PoolBalanceCoins: 50}, domain.StrategyInput{GiftPriceCoins: 10, RiskCapacity: capacity}, domain.NewSeededStrategyRandom(1))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
rowPass := allowed.PayoutCoins == 50 && blocked.PayoutCoins == 0 && blocked.PoolAfterCoins == 50
|
||
passed = passed && rowPass
|
||
rows[item.name] = map[string]any{"equal_capacity_payout": allowed.PayoutCoins, "below_capacity_payout": blocked.PayoutCoins, "pass": rowPass}
|
||
}
|
||
return scenarioResult{Name: name, Passed: passed, Summary: "单次/用户小时/用户日/设备日/房间小时/主播日六维均验证W=capacity可赔、W=capacity+1硬阻断", Data: rows}
|
||
}
|
||
|
||
func simulateRechargeStages(config domain.StrategyConfig) scenarioResult {
|
||
const name = "充值分层"
|
||
type row struct {
|
||
Recharge7D int64 `json:"recharge_7d"`
|
||
Recharge30D int64 `json:"recharge_30d"`
|
||
Stage string `json:"stage"`
|
||
}
|
||
inputs := [][2]int64{
|
||
{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 = baseProbabilityConfig(config)
|
||
config.LowWaterThresholdCoins = 20_000
|
||
config.HighWaterThresholdCoins = 30_000
|
||
config.LowWaterFactorPPM = 700_000
|
||
config.HighWaterFactorPPM = 1_300_000
|
||
config.RechargeFactorPPM = 1_100_000
|
||
config.JackpotMechanism1Enabled = true
|
||
config.JackpotMechanism2Enabled = true
|
||
config.DailyJackpotLimit = 5
|
||
config.MilestoneSpendCoins = 50
|
||
state := domain.StrategyState{
|
||
PoolBalanceCoins: 10_000, ConsecutiveZeroDraws: 5, PendingMilestoneTokens: 1,
|
||
GlobalRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 98},
|
||
UserDayRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||
User72HourRTP: domain.StrategyRTP{WagerCoins: 100, PayoutCoins: 96},
|
||
}
|
||
input := domain.StrategyInput{
|
||
GiftPriceCoins: 10, NowMS: 1_000_000, HasRechargeFact: true,
|
||
LastRechargeAtMS: 700_001, Recharge7DCoins: 500, Recharge30DCoins: 2_000,
|
||
}
|
||
priority, err := domain.DecideLuckyGiftStrategy(config, state, input, domain.NewSeededStrategyRandom(seed))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
// When both jackpot mechanisms are eligible, the persisted token is contractual
|
||
// state and therefore wins before RTP compensation or sixth-draw protection.
|
||
priorityPass := priority.JackpotMechanism == domain.StrategyJackpotMechanismMilestone && priority.ConsumedMilestoneToken && priority.Stage == domain.StageAdvanced
|
||
|
||
blockedState := state
|
||
blockedState.PoolBalanceCoins = 50
|
||
blocked, err := domain.DecideLuckyGiftStrategy(config, blockedState, input, domain.NewSeededStrategyRandom(seed+1))
|
||
if err != nil {
|
||
return failedScenario(name, err)
|
||
}
|
||
// Neither special mechanism may overdraw the pool. The token survives and the
|
||
// ordinary sixth-draw protection may pay only the affordable 5x boundary.
|
||
blockedPass := blocked.Trace.MilestoneTokenRetained && blocked.NextState.PendingMilestoneTokens == 1 && blocked.PayoutCoins == 50 && blocked.PoolAfterCoins == 0
|
||
return scenarioResult{
|
||
Name: name, Passed: priorityPass && blockedPass,
|
||
Summary: fmt.Sprintf("优先级=持久token>%s>普通P/W/第6抽;特殊档池不足时资格保留并仅赔可支付%s", domain.StrategyJackpotMechanismRTPCompensation, blocked.SelectedTier.ID),
|
||
Data: map[string]any{"all_eligible": priority, "special_pool_blocked": blocked},
|
||
}
|
||
}
|
||
|
||
// simulationStore models the repository contract that the pure kernel expects:
|
||
// command idempotency and state locking wrap the draw in one critical section. The
|
||
// simulator does not claim an in-memory mutex is the production implementation; it
|
||
// verifies the state-machine invariants a row lock/CAS transaction must preserve.
|
||
type simulationStore struct {
|
||
mu sync.Mutex
|
||
config domain.StrategyConfig
|
||
state domain.StrategyState
|
||
random domain.StrategyRandomSource
|
||
seen map[string]domain.StrategyDecision
|
||
transitions int
|
||
}
|
||
|
||
func newSimulationStore(config domain.StrategyConfig, state domain.StrategyState, seed int64) *simulationStore {
|
||
return &simulationStore{config: config, state: state, random: domain.NewSeededStrategyRandom(seed), seen: map[string]domain.StrategyDecision{}}
|
||
}
|
||
|
||
func (s *simulationStore) execute(commandID string, input domain.StrategyInput) (domain.StrategyDecision, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if existing, ok := s.seen[commandID]; ok {
|
||
return existing, nil
|
||
}
|
||
decision, err := domain.DecideLuckyGiftStrategy(s.config, s.state, input, s.random)
|
||
if err != nil {
|
||
return domain.StrategyDecision{}, err
|
||
}
|
||
s.state = decision.NextState
|
||
s.seen[commandID] = decision
|
||
s.transitions++
|
||
return decision, nil
|
||
}
|
||
|
||
func simulateConcurrencyAndIdempotency(config domain.StrategyConfig, seed int64) scenarioResult {
|
||
const name = "并发/幂等模型"
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
config.DailyJackpotLimit = math.MaxInt64
|
||
config.MilestoneSpendCoins = 0
|
||
input := domain.StrategyInput{GiftPriceCoins: 100, PoolContributionCoins: 98}
|
||
|
||
duplicateStore := newSimulationStore(config, domain.StrategyState{}, seed)
|
||
duplicateErrors := runConcurrentCommands(duplicateStore, 32, func(int) string { return "same-command" }, input)
|
||
duplicatePass := len(duplicateErrors) == 0 && duplicateStore.transitions == 1 && duplicateStore.state.Version == 1 && len(duplicateStore.seen) == 1 && duplicateStore.state.PoolBalanceCoins >= 0
|
||
|
||
uniqueStore := newSimulationStore(config, domain.StrategyState{}, seed+1)
|
||
uniqueErrors := runConcurrentCommands(uniqueStore, 100, func(index int) string { return fmt.Sprintf("command-%03d", index) }, input)
|
||
uniquePass := len(uniqueErrors) == 0 && uniqueStore.transitions == 100 && uniqueStore.state.Version == 100 && len(uniqueStore.seen) == 100 && uniqueStore.state.PoolBalanceCoins >= 0
|
||
return scenarioResult{
|
||
Name: name, Passed: duplicatePass && uniquePass,
|
||
Summary: fmt.Sprintf("32个同幂等键仅1次状态迁移;100个并发唯一键串行化为100个版本且池余=%d", uniqueStore.state.PoolBalanceCoins),
|
||
Data: map[string]any{
|
||
"duplicate": map[string]any{"requests": 32, "transitions": duplicateStore.transitions, "version": duplicateStore.state.Version, "errors": duplicateErrors},
|
||
"unique": map[string]any{"requests": 100, "transitions": uniqueStore.transitions, "version": uniqueStore.state.Version, "pool_after": uniqueStore.state.PoolBalanceCoins, "errors": uniqueErrors},
|
||
},
|
||
}
|
||
}
|
||
|
||
func runConcurrentCommands(store *simulationStore, count int, commandID func(int) string, input domain.StrategyInput) []string {
|
||
var wait sync.WaitGroup
|
||
errorsCh := make(chan string, count)
|
||
for index := 0; index < count; index++ {
|
||
wait.Add(1)
|
||
go func(index int) {
|
||
defer wait.Done()
|
||
if _, err := store.execute(commandID(index), input); err != nil {
|
||
errorsCh <- err.Error()
|
||
}
|
||
}(index)
|
||
}
|
||
wait.Wait()
|
||
close(errorsCh)
|
||
errorsFound := make([]string, 0, len(errorsCh))
|
||
for err := range errorsCh {
|
||
errorsFound = append(errorsFound, err)
|
||
}
|
||
sort.Strings(errorsFound)
|
||
return errorsFound
|
||
}
|
||
|
||
type groupLongRunSummary struct {
|
||
ExpectedStage string `json:"expected_stage"`
|
||
ObservedStage string `json:"observed_stage"`
|
||
Draws int `json:"draws"`
|
||
TierCounts map[string]int `json:"tier_counts"`
|
||
PayoutCoins int64 `json:"payout_coins"`
|
||
PoolAfter int64 `json:"pool_after"`
|
||
RTPPercent float64 `json:"rtp_percent"`
|
||
}
|
||
|
||
func simulateLongRunGroups(config domain.StrategyConfig, seed int64, draws int) scenarioResult {
|
||
const name = "长跑分群"
|
||
config.JackpotMechanism1Enabled = false
|
||
config.JackpotMechanism2Enabled = false
|
||
config.DailyJackpotLimit = math.MaxInt64
|
||
config.MilestoneSpendCoins = 0
|
||
// This grouping run uses a production-safe table (stage EV 85%/97.5%/98%),
|
||
// not the image's 2650% probability snapshot. That keeps distribution changes
|
||
// visible instead of letting P/W drain every group into the same 5x-only cycle.
|
||
config.LowWaterThresholdCoins = 0
|
||
config.HighWaterThresholdCoins = math.MaxInt64
|
||
config.LowWaterFactorPPM = domain.StrategyPPMScale
|
||
config.HighWaterFactorPPM = domain.StrategyPPMScale
|
||
config.RechargeFactorPPM = domain.StrategyPPMScale
|
||
config.MissProtectionZeroDraws = math.MaxInt64
|
||
config.Tiers = []domain.StrategyTier{
|
||
{ID: "0x", MultiplierPPM: 0, Enabled: true},
|
||
{ID: "0.5x", MultiplierPPM: 500_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 100_000, domain.StageNormal: 50_000, domain.StageAdvanced: 40_000}},
|
||
{ID: "1x", MultiplierPPM: 1_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 800_000, domain.StageNormal: 850_000, domain.StageAdvanced: 840_000}},
|
||
{ID: "2x", MultiplierPPM: 2_000_000, Enabled: true, StageWeightPPM: map[string]int64{domain.StageNovice: 0, domain.StageNormal: 50_000, domain.StageAdvanced: 60_000}},
|
||
}
|
||
groups := []struct {
|
||
stage string
|
||
recharge7D int64
|
||
recharge30D int64
|
||
}{
|
||
{stage: domain.StageNovice, recharge7D: 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}
|
||
}
|