764 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

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