package luckygift import ( "errors" "fmt" "math" "math/big" "math/rand" "sort" "strings" ) const ( // StrategyPPMScale keeps probability, multiplier and adjustment arithmetic in one // integer unit. The strategy never uses float64 for money or eligibility gates. StrategyPPMScale int64 = 1_000_000 StrategyReasonZeroSelected = "zero_selected" StrategyReasonPaid = "paid" StrategyReasonNoPayableTier = "no_payable_nonzero_tier_fallback_zero" StrategyReasonMissProtectionBlocked = "miss_protection_blocked_no_payable_tier" StrategyReasonMilestoneJackpot = "milestone_token_jackpot" StrategyReasonRTPCompensationJackpot = "rtp_compensation_jackpot" StrategyReasonMilestoneTokenRetained = "milestone_token_retained_no_payable_jackpot" StrategyReasonDailyJackpotLimit = "daily_jackpot_limit" StrategyReasonPoolInsufficient = "pool_insufficient_w_gt_p" StrategyReasonRiskCapacity = "risk_capacity_exceeded" StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate" StrategyJackpotMechanismMilestone = "milestone_token" StrategyJackpotMechanismRTPCompensation = "rtp_compensation" StrategyDrawKindOriginal = "original" StrategyDrawKindRedraw = "redraw" StrategyDrawKindMilestone = "jackpot_mechanism_2" StrategyDrawKindRTPCompensation = "jackpot_mechanism_1" StrategyRemovalMissProtection = "miss_protection_excludes_zero" StrategyRemovalPWRedrawZero = "w_gt_p_redraw_excludes_zero" StrategyConditionGlobalRTP = "global_rtp" StrategyConditionUserDayRTP = "user_day_rtp" StrategyConditionUser72HourRTP = "user_72h_rtp" StrategyConditionDailyJackpotLimit = "daily_jackpot_limit" StrategyConditionPayableJackpot = "payable_jackpot_candidate" StrategyDefaultZeroTierID = "0x" ) var ( ErrStrategyConfig = errors.New("lucky gift strategy config is invalid") ErrStrategyInput = errors.New("lucky gift strategy input is invalid") ErrStrategyRandomSource = errors.New("lucky gift strategy random source is required") ) // StrategyRandomSource is the only nondeterministic boundary of the kernel. // Production should adapt crypto/rand; tests and strategy-sim deliberately inject a // scripted or seeded source so every deletion/redraw path is reproducible. type StrategyRandomSource interface { Int63n(n int64) (int64, error) } // StrategyRandomFunc lets infrastructure adapt an existing secure RNG without // making the pure domain package depend on a concrete implementation. type StrategyRandomFunc func(n int64) (int64, error) func (f StrategyRandomFunc) Int63n(n int64) (int64, error) { return f(n) } type seededStrategyRandom struct{ source *rand.Rand } // NewSeededStrategyRandom is intentionally for deterministic simulation and tests. // It must not be wired into the production draw path because math/rand is predictable. func NewSeededStrategyRandom(seed int64) StrategyRandomSource { return &seededStrategyRandom{source: rand.New(rand.NewSource(seed))} } func (r *seededStrategyRandom) Int63n(n int64) (int64, error) { if r == nil || r.source == nil || n <= 0 { return 0, fmt.Errorf("%w: random bound must be positive", ErrStrategyInput) } return r.source.Int63n(n), nil } // StrategyTier is one configured multiplier. BaseWeightPPM is the ordinary draw // weight; JackpotWeight only chooses among the configured large-prize set after a // jackpot mechanism has passed all RTP, pool, daily-limit and risk gates. type StrategyTier struct { ID string `json:"id"` MultiplierPPM int64 `json:"multiplier_ppm"` BaseWeightPPM int64 `json:"base_weight_ppm"` StageWeightPPM map[string]int64 `json:"stage_weight_ppm,omitempty"` Jackpot bool `json:"jackpot"` JackpotWeight int64 `json:"jackpot_weight,omitempty"` Enabled bool `json:"enabled"` } // StrategyRechargeStage is ordered by its explicit seven-day and thirty-day // recharge floors. A user enters the highest stage for which both floors pass. type StrategyRechargeStage struct { Name string `json:"name"` MinRecharge7DCoins int64 `json:"min_recharge_7d_coins"` MinRecharge30DCoins int64 `json:"min_recharge_30d_coins"` } // StrategyConfig contains only immutable rule-version data. Runtime counters, // pool money and persisted milestone tokens live in StrategyState instead. type StrategyConfig struct { Tiers []StrategyTier `json:"tiers"` PublicPoolRatePPM int64 `json:"public_pool_rate_ppm"` ProfitPoolRatePPM int64 `json:"profit_pool_rate_ppm"` AnchorReturnRatePPM int64 `json:"anchor_return_rate_ppm"` ColdStartPoolCoins int64 `json:"cold_start_pool_coins"` LowWaterThresholdCoins int64 `json:"low_water_threshold_coins"` HighWaterThresholdCoins int64 `json:"high_water_threshold_coins"` LowWaterFactorPPM int64 `json:"low_water_factor_ppm"` HighWaterFactorPPM int64 `json:"high_water_factor_ppm"` RechargeFactorPPM int64 `json:"recharge_factor_ppm"` RechargeBoostStartMS int64 `json:"recharge_boost_start_ms"` RechargeBoostEndMS int64 `json:"recharge_boost_end_ms"` MissProtectionZeroDraws int64 `json:"miss_protection_zero_draws"` DailyJackpotLimit int64 `json:"daily_jackpot_limit"` MilestoneSpendCoins int64 `json:"milestone_spend_coins"` JackpotMechanism1Enabled bool `json:"jackpot_mechanism_1_enabled"` JackpotMechanism2Enabled bool `json:"jackpot_mechanism_2_enabled"` GlobalRTPMaxPPM int64 `json:"global_rtp_max_ppm"` UserDayRTPMaxPPM int64 `json:"user_day_rtp_max_ppm"` User72HourRTPMaxPPM int64 `json:"user_72h_rtp_max_ppm"` RechargeStages []StrategyRechargeStage `json:"recharge_stages"` } // StrategyRTP stores the auditable numerator and denominator. Supplying a derived // ratio alone would lose the crucial denominator=0 distinction and could trigger a // compensating jackpot for a user or platform with no settled history. type StrategyRTP struct { WagerCoins int64 `json:"wager_coins"` PayoutCoins int64 `json:"payout_coins"` } // StrategyState is the locked, persisted snapshot consumed by one draw. A repository // must update it atomically with the draw record; Version is provided for CAS/optimistic // locking but the kernel itself intentionally performs no I/O. type StrategyState struct { PoolBalanceCoins int64 `json:"pool_balance_coins"` ConsecutiveZeroDraws int64 `json:"consecutive_zero_draws"` PendingMilestoneTokens int64 `json:"pending_milestone_tokens"` UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"` UserDaySpendCoins int64 `json:"user_day_spend_coins"` GlobalRTP StrategyRTP `json:"global_rtp"` UserDayRTP StrategyRTP `json:"user_day_rtp"` User72HourRTP StrategyRTP `json:"user_72h_rtp"` Version int64 `json:"version"` } // StrategyRiskCapacity contains the six hard payout ceilings from the product rule. // Enabled=false means the caller has not configured this gate. Once enabled, zero is // a real zero capacity (not “unlimited”), which prevents missing counters from silently // disabling risk control. type StrategyRiskCapacity struct { Enabled bool `json:"enabled"` SingleDrawCoins int64 `json:"single_draw_coins"` UserHourCoins int64 `json:"user_hour_coins"` UserDayCoins int64 `json:"user_day_coins"` DeviceDayCoins int64 `json:"device_day_coins"` RoomHourCoins int64 `json:"room_hour_coins"` AnchorDayCoins int64 `json:"anchor_day_coins"` } // StrategyInput contains current-request facts. PoolContributionCoins is credited // before P/W comparison, matching the transaction order “successful gift -> split -> // draw”. Recharge boost uses [start,end), guarded by HasRechargeFact. type StrategyInput struct { GiftPriceCoins int64 `json:"gift_price_coins"` PoolContributionCoins int64 `json:"pool_contribution_coins"` NowMS int64 `json:"now_ms"` HasRechargeFact bool `json:"has_recharge_fact"` LastRechargeAtMS int64 `json:"last_recharge_at_ms"` Recharge7DCoins int64 `json:"recharge_7d_coins"` Recharge30DCoins int64 `json:"recharge_30d_coins"` RiskCapacity StrategyRiskCapacity `json:"risk_capacity"` } // StrategyWeightFactor records why a non-zero tier changed. Factors are multiplied, // not added, so low-water+recharge is exactly 0.7*1.1 rather than an ambiguous 0.8. type StrategyWeightFactor struct { Name string `json:"name"` FactorPPM int64 `json:"factor_ppm"` } type StrategyWeightTrace struct { TierID string `json:"tier_id"` MultiplierPPM int64 `json:"multiplier_ppm"` BaseWeightPPM int64 `json:"base_weight_ppm"` RawAdjustedWeightPPM int64 `json:"raw_adjusted_weight_ppm"` AdjustedWeightPPM int64 `json:"adjusted_weight_ppm"` Factors []StrategyWeightFactor `json:"factors"` } type StrategyDrawTrace struct { Sequence int `json:"sequence"` Kind string `json:"kind"` TierID string `json:"tier_id"` MultiplierPPM int64 `json:"multiplier_ppm"` WeightPPM int64 `json:"weight_ppm"` PayoutCoins int64 `json:"payout_coins"` RemovedReason string `json:"removed_reason,omitempty"` } type StrategyRemovalTrace struct { TierID string `json:"tier_id"` Reason string `json:"reason"` } type StrategyConditionTrace struct { Name string `json:"name"` Numerator int64 `json:"numerator,omitempty"` Denominator int64 `json:"denominator,omitempty"` RatioPPM int64 `json:"ratio_ppm,omitempty"` LimitPPM int64 `json:"limit_ppm,omitempty"` Passed bool `json:"passed"` Reason string `json:"reason"` } // DecisionTrace is deliberately verbose enough to replay an incident without // reconstructing transient weights from the latest (possibly changed) config. type DecisionTrace struct { Stage string `json:"stage"` PoolBeforeCoins int64 `json:"pool_before_coins"` PoolContributionCoins int64 `json:"pool_contribution_coins"` AvailablePoolCoins int64 `json:"available_pool_coins"` EffectiveRiskCapacityCoins int64 `json:"effective_risk_capacity_coins"` Weights []StrategyWeightTrace `json:"weights"` Draws []StrategyDrawTrace `json:"draws"` Removed []StrategyRemovalTrace `json:"removed"` Conditions []StrategyConditionTrace `json:"conditions,omitempty"` OriginalTierID string `json:"original_tier_id,omitempty"` RedrawTierIDs []string `json:"redraw_tier_ids,omitempty"` FinalReason string `json:"final_reason"` BlockedReason string `json:"blocked_reason,omitempty"` JackpotMechanism string `json:"jackpot_mechanism,omitempty"` MilestoneTokenConsumed bool `json:"milestone_token_consumed"` MilestoneTokenRetained bool `json:"milestone_token_retained"` MilestoneTokensEarned int64 `json:"milestone_tokens_earned"` } type StrategyDecision struct { SelectedTier StrategyTier `json:"selected_tier"` PayoutCoins int64 `json:"payout_coins"` PoolAfterCoins int64 `json:"pool_after_coins"` Stage string `json:"stage"` Jackpot bool `json:"jackpot"` JackpotMechanism string `json:"jackpot_mechanism,omitempty"` ConsumedMilestoneToken bool `json:"consumed_milestone_token"` NextState StrategyState `json:"next_state"` Trace DecisionTrace `json:"trace"` } type StrategyFundSplit struct { PublicPoolCoins int64 `json:"public_pool_coins"` ProfitPoolCoins int64 `json:"profit_pool_coins"` AnchorReturnCoins int64 `json:"anchor_return_coins"` } type weightedStrategyTier struct { Tier StrategyTier Weight int64 } // DefaultLuckyGiftStrategyConfig is a financially safe production baseline: its // ordinary table has exactly 98% nominal EV. The deliberately unsafe 2650% table in // the supplied image belongs only to strategy-sim and must never become a runtime // fallback merely because a rule version is missing. func DefaultLuckyGiftStrategyConfig() StrategyConfig { return StrategyConfig{ Tiers: []StrategyTier{ {ID: "0x", MultiplierPPM: 0, BaseWeightPPM: 50_000, Enabled: true}, {ID: "0.5x", MultiplierPPM: 500_000, BaseWeightPPM: 40_000, Enabled: true}, {ID: "1x", MultiplierPPM: 1_000_000, BaseWeightPPM: 860_000, Enabled: true}, {ID: "2x", MultiplierPPM: 2_000_000, BaseWeightPPM: 50_000, Enabled: true}, {ID: "200x", MultiplierPPM: 200_000_000, BaseWeightPPM: 0, 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: 0, Jackpot: true, JackpotWeight: 1, Enabled: true}, }, PublicPoolRatePPM: 980_000, ProfitPoolRatePPM: 10_000, AnchorReturnRatePPM: 10_000, ColdStartPoolCoins: 0, LowWaterThresholdCoins: 10_000_000, HighWaterThresholdCoins: 20_000_000, LowWaterFactorPPM: 700_000, HighWaterFactorPPM: 1_300_000, RechargeFactorPPM: 1_100_000, RechargeBoostStartMS: 0, RechargeBoostEndMS: 5 * 60 * 1000, // The product threshold is USD 50 while the kernel operates in integer // coins. Zero forces adapters to supply an explicit versioned conversion. MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0, JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true, GlobalRTPMaxPPM: 980_000, UserDayRTPMaxPPM: 960_000, User72HourRTPMaxPPM: 960_000, RechargeStages: []StrategyRechargeStage{ {Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0}, {Name: StageNormal, MinRecharge7DCoins: 0, MinRecharge30DCoins: 1}, {Name: StageAdvanced, MinRecharge7DCoins: 1, MinRecharge30DCoins: 1}, }, } } func InitialLuckyGiftStrategyState(config StrategyConfig) StrategyState { return StrategyState{PoolBalanceCoins: config.ColdStartPoolCoins} } // SplitLuckyGiftStrategyFunds applies the 98/1/1 (or configured) split without // floating point. Public and anchor buckets use floor; all indivisible residue goes // to profit. This is intentionally conservative: a 10-coin draw becomes 9/1/0, never // 10/0/0, so integer rounding cannot inflate the public payout budget above 98%. func SplitLuckyGiftStrategyFunds(amount int64, config StrategyConfig) (StrategyFundSplit, error) { if amount < 0 { return StrategyFundSplit{}, fmt.Errorf("%w: split amount cannot be negative", ErrStrategyInput) } rates := []int64{config.PublicPoolRatePPM, config.ProfitPoolRatePPM, config.AnchorReturnRatePPM} var sum int64 for _, rate := range rates { if rate < 0 || rate > StrategyPPMScale { return StrategyFundSplit{}, fmt.Errorf("%w: split rate must be within [0,100%%]", ErrStrategyConfig) } var err error sum, err = safeAdd(sum, rate) if err != nil { return StrategyFundSplit{}, err } } if sum != StrategyPPMScale { return StrategyFundSplit{}, fmt.Errorf("%w: split rates sum=%d want=%d", ErrStrategyConfig, sum, StrategyPPMScale) } public := mulDiv(amount, config.PublicPoolRatePPM, StrategyPPMScale) anchor := mulDiv(amount, config.AnchorReturnRatePPM, StrategyPPMScale) if public < 0 || anchor < 0 || public > amount-anchor { return StrategyFundSplit{}, fmt.Errorf("%w: split arithmetic overflow", ErrStrategyInput) } return StrategyFundSplit{PublicPoolCoins: public, ProfitPoolCoins: amount - public - anchor, AnchorReturnCoins: anchor}, nil } // MilestoneTokensEarned computes only newly crossed whole milestones. This pure // delta is safe to use in a periodic persisted-token job and does not grant the same // 50-unit threshold again after a retry. func MilestoneTokensEarned(previousSpend, currentSpend, milestone int64) int64 { if milestone <= 0 || previousSpend < 0 || currentSpend <= previousSpend { return 0 } return currentSpend/milestone - previousSpend/milestone } // SelectLuckyGiftRechargeStage returns the highest explicitly configured stage whose // two recharge floors pass. It never infers a stage from local time or current balance. func SelectLuckyGiftRechargeStage(config StrategyConfig, recharge7D, recharge30D int64) string { stages := append([]StrategyRechargeStage(nil), config.RechargeStages...) sort.SliceStable(stages, func(i, j int) bool { if stages[i].MinRecharge30DCoins == stages[j].MinRecharge30DCoins { return stages[i].MinRecharge7DCoins < stages[j].MinRecharge7DCoins } return stages[i].MinRecharge30DCoins < stages[j].MinRecharge30DCoins }) selected := "" for _, stage := range stages { if recharge7D >= stage.MinRecharge7DCoins && recharge30D >= stage.MinRecharge30DCoins { selected = stage.Name } } return selected } // PreviewLuckyGiftStrategyWeights exposes exactly the same weight builder used by // DecideLuckyGiftStrategy, allowing admin preview and offline simulation to stay in // lockstep with production decisions. func PreviewLuckyGiftStrategyWeights(config StrategyConfig, state StrategyState, input StrategyInput) (string, []StrategyWeightTrace, error) { if err := validateStrategy(config, state, input, false); err != nil { return "", nil, err } available, err := safeAdd(state.PoolBalanceCoins, input.PoolContributionCoins) if err != nil { return "", nil, err } stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins) _, traces, err := buildStrategyWeights(config, stage, available, input) return stage, traces, err } // DecideLuckyGiftStrategy is the shared production/simulation kernel. Its ordering is // intentional and traceable: persisted mechanism-2 token -> mechanism-1 RTP gates -> // ordinary weighted P/W draw and redraw -> state transition. A blocked special // mechanism falls through to an ordinary draw while preserving its token. func DecideLuckyGiftStrategy(config StrategyConfig, state StrategyState, input StrategyInput, random StrategyRandomSource) (StrategyDecision, error) { if random == nil { return StrategyDecision{}, ErrStrategyRandomSource } if err := validateStrategy(config, state, input, true); err != nil { return StrategyDecision{}, err } available, err := safeAdd(state.PoolBalanceCoins, input.PoolContributionCoins) if err != nil { return StrategyDecision{}, err } stage := SelectLuckyGiftRechargeStage(config, input.Recharge7DCoins, input.Recharge30DCoins) weighted, weightTrace, err := buildStrategyWeights(config, stage, available, input) if err != nil { return StrategyDecision{}, err } trace := DecisionTrace{ Stage: stage, PoolBeforeCoins: state.PoolBalanceCoins, PoolContributionCoins: input.PoolContributionCoins, AvailablePoolCoins: available, EffectiveRiskCapacityCoins: input.RiskCapacity.capacity(), Weights: weightTrace, } // A persisted milestone token is a promise about the next draw, so it outranks the // opportunistic RTP compensation mechanism. Insufficient pool/risk capacity keeps // the token durable and lets the user retain the ordinary draw they paid for. if config.JackpotMechanism2Enabled && state.PendingMilestoneTokens > 0 { tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) if err != nil { return StrategyDecision{}, err } trace.Removed = append(trace.Removed, removed...) if ok { trace.JackpotMechanism = StrategyJackpotMechanismMilestone trace.MilestoneTokenConsumed = true trace.FinalReason = StrategyReasonMilestoneJackpot trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindMilestone, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, trace) } trace.MilestoneTokenRetained = true trace.BlockedReason = reason trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: false, Reason: reason}) } if config.JackpotMechanism1Enabled { conditions, eligible := mechanismOneConditions(config, state) trace.Conditions = append(trace.Conditions, conditions...) if eligible { tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random) if err != nil { return StrategyDecision{}, err } trace.Removed = append(trace.Removed, removed...) trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason}) if ok { trace.JackpotMechanism = StrategyJackpotMechanismRTPCompensation trace.FinalReason = StrategyReasonRTPCompensationJackpot trace.Draws = append(trace.Draws, StrategyDrawTrace{Sequence: 1, Kind: StrategyDrawKindRTPCompensation, TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, WeightPPM: jackpotWeight(tier), PayoutCoins: payout}) return finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, trace) } if trace.BlockedReason == "" { trace.BlockedReason = reason } } } tier, payout, ordinaryTrace, err := selectOrdinaryTier(config, state, input, available, weighted, random) if err != nil { return StrategyDecision{}, err } trace.Draws = append(trace.Draws, ordinaryTrace.Draws...) trace.Removed = append(trace.Removed, ordinaryTrace.Removed...) trace.OriginalTierID = ordinaryTrace.OriginalTierID trace.RedrawTierIDs = append(trace.RedrawTierIDs, ordinaryTrace.RedrawTierIDs...) trace.FinalReason = ordinaryTrace.FinalReason if ordinaryTrace.BlockedReason != "" { trace.BlockedReason = ordinaryTrace.BlockedReason } return finalizeStrategyDecision(config, state, input, available, tier, payout, "", false, trace) } func validateStrategy(config StrategyConfig, state StrategyState, input StrategyInput, requireTiers bool) error { if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 { return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput) } for _, rtp := range []StrategyRTP{state.GlobalRTP, state.UserDayRTP, state.User72HourRTP} { if rtp.WagerCoins < 0 || rtp.PayoutCoins < 0 { return fmt.Errorf("%w: RTP numerator and denominator cannot be negative", ErrStrategyInput) } } if input.GiftPriceCoins <= 0 || input.PoolContributionCoins < 0 || input.Recharge7DCoins < 0 || input.Recharge30DCoins < 0 { return fmt.Errorf("%w: gift price must be positive and input money cannot be negative", ErrStrategyInput) } if input.NowMS < 0 || (input.HasRechargeFact && input.LastRechargeAtMS < 0) { return fmt.Errorf("%w: request and recharge timestamps cannot be negative", ErrStrategyInput) } if config.LowWaterThresholdCoins < 0 || config.HighWaterThresholdCoins < config.LowWaterThresholdCoins || config.LowWaterFactorPPM <= 0 || config.HighWaterFactorPPM <= 0 || config.RechargeFactorPPM <= 0 { return fmt.Errorf("%w: water thresholds or dynamic factors are invalid", ErrStrategyConfig) } if config.RechargeBoostStartMS < 0 || config.RechargeBoostEndMS <= config.RechargeBoostStartMS || config.MissProtectionZeroDraws < 0 || config.DailyJackpotLimit < 0 || config.MilestoneSpendCoins < 0 || config.ColdStartPoolCoins < 0 { return fmt.Errorf("%w: recharge window, miss protection or daily limit is invalid", ErrStrategyConfig) } if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale || config.UserDayRTPMaxPPM < 0 || config.UserDayRTPMaxPPM > StrategyPPMScale || config.User72HourRTPMaxPPM < 0 || config.User72HourRTPMaxPPM > StrategyPPMScale { return fmt.Errorf("%w: RTP jackpot limits must be within [0,100%%]", ErrStrategyConfig) } var splitRateTotal int64 for _, rate := range []int64{config.PublicPoolRatePPM, config.ProfitPoolRatePPM, config.AnchorReturnRatePPM} { if rate < 0 || rate > StrategyPPMScale { return fmt.Errorf("%w: fund split rates must be within [0,100%%]", ErrStrategyConfig) } splitRateTotal += rate } if splitRateTotal != StrategyPPMScale { return fmt.Errorf("%w: fund split rates must sum to 100%%", ErrStrategyConfig) } if len(config.RechargeStages) != 3 { return fmt.Errorf("%w: exactly three recharge stages are required", ErrStrategyConfig) } seenStages := map[string]bool{} for index, stage := range config.RechargeStages { if strings.TrimSpace(stage.Name) == "" || stage.MinRecharge7DCoins < 0 || stage.MinRecharge30DCoins < 0 || seenStages[stage.Name] { return fmt.Errorf("%w: recharge stages must have unique names and non-negative floors", ErrStrategyConfig) } if index > 0 && (stage.MinRecharge7DCoins < config.RechargeStages[index-1].MinRecharge7DCoins || stage.MinRecharge30DCoins < config.RechargeStages[index-1].MinRecharge30DCoins) { return fmt.Errorf("%w: recharge stage floors must be monotonic", ErrStrategyConfig) } seenStages[stage.Name] = true } if !requireTiers && len(config.Tiers) == 0 { return nil } seen := map[string]bool{} zeroCount := 0 for _, tier := range config.Tiers { if !tier.Enabled { continue } if strings.TrimSpace(tier.ID) == "" || seen[tier.ID] || tier.MultiplierPPM < 0 || tier.BaseWeightPPM < 0 || tier.JackpotWeight < 0 { return fmt.Errorf("%w: enabled tiers require unique IDs and non-negative multiplier/weights", ErrStrategyConfig) } seen[tier.ID] = true if tier.MultiplierPPM == 0 { zeroCount++ } for stage, weight := range tier.StageWeightPPM { if !seenStages[stage] || weight < 0 { return fmt.Errorf("%w: tier %s has unknown stage or negative stage weight", ErrStrategyConfig, tier.ID) } } } if len(seen) == 0 || zeroCount != 1 { return fmt.Errorf("%w: enabled tiers require exactly one zero multiplier", ErrStrategyConfig) } if input.RiskCapacity.Enabled { for _, capacity := range input.RiskCapacity.values() { if capacity < 0 { return fmt.Errorf("%w: enabled risk capacities cannot be negative", ErrStrategyInput) } } } return nil } func buildStrategyWeights(config StrategyConfig, stage string, available int64, input StrategyInput) ([]weightedStrategyTier, []StrategyWeightTrace, error) { tiers := make([]weightedStrategyTier, 0, len(config.Tiers)) traces := make([]StrategyWeightTrace, 0, len(config.Tiers)) positiveIndexes := make([]int, 0, len(config.Tiers)) var positiveSum int64 zeroIndex := -1 for _, tier := range config.Tiers { if !tier.Enabled { continue } base := tier.BaseWeightPPM if stageWeight, ok := tier.StageWeightPPM[stage]; ok { base = stageWeight } trace := StrategyWeightTrace{TierID: tier.ID, MultiplierPPM: tier.MultiplierPPM, BaseWeightPPM: base} weight := base if tier.MultiplierPPM == 0 { zeroIndex = len(tiers) trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "zero_remainder", FactorPPM: StrategyPPMScale}) } else { if available < config.LowWaterThresholdCoins { weight = mulPPM(weight, config.LowWaterFactorPPM) trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "low_water", FactorPPM: config.LowWaterFactorPPM}) } else if available > config.HighWaterThresholdCoins { weight = mulPPM(weight, config.HighWaterFactorPPM) trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "high_water", FactorPPM: config.HighWaterFactorPPM}) } if rechargeBoostActive(config, input) { weight = mulPPM(weight, config.RechargeFactorPPM) trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "recent_recharge", FactorPPM: config.RechargeFactorPPM}) } if len(trace.Factors) == 0 { trace.Factors = append(trace.Factors, StrategyWeightFactor{Name: "identity", FactorPPM: StrategyPPMScale}) } if weight < 0 { return nil, nil, fmt.Errorf("%w: adjusted tier weight overflow", ErrStrategyConfig) } var err error positiveSum, err = safeAdd(positiveSum, weight) if err != nil { return nil, nil, err } positiveIndexes = append(positiveIndexes, len(tiers)) } trace.RawAdjustedWeightPPM = weight tiers = append(tiers, weightedStrategyTier{Tier: tier, Weight: weight}) traces = append(traces, trace) } if zeroIndex < 0 { return nil, nil, fmt.Errorf("%w: zero tier not found", ErrStrategyConfig) } if positiveSum <= StrategyPPMScale { tiers[zeroIndex].Weight = StrategyPPMScale - positiveSum } else { rates := make([]int64, 0, len(positiveIndexes)) for _, index := range positiveIndexes { rates = append(rates, tiers[index].Weight) } normalized, err := apportion(StrategyPPMScale, rates, positiveSum) if err != nil { return nil, nil, err } for i, index := range positiveIndexes { tiers[index].Weight = normalized[i] } tiers[zeroIndex].Weight = 0 } for index := range tiers { traces[index].AdjustedWeightPPM = tiers[index].Weight } return tiers, traces, nil } func rechargeBoostActive(config StrategyConfig, input StrategyInput) bool { if !input.HasRechargeFact || input.LastRechargeAtMS > input.NowMS { return false } age := input.NowMS - input.LastRechargeAtMS return age >= config.RechargeBoostStartMS && age < config.RechargeBoostEndMS } func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) { checks := []struct { name string rtp StrategyRTP limit int64 }{ {name: StrategyConditionGlobalRTP, rtp: state.GlobalRTP, limit: config.GlobalRTPMaxPPM}, {name: StrategyConditionUserDayRTP, rtp: state.UserDayRTP, limit: config.UserDayRTPMaxPPM}, {name: StrategyConditionUser72HourRTP, rtp: state.User72HourRTP, limit: config.User72HourRTPMaxPPM}, } traces := make([]StrategyConditionTrace, 0, len(checks)+1) eligible := true for _, check := range checks { ratio, valid := check.rtp.RatioPPM() passed := valid && ratio <= check.limit reason := "within_limit" if !valid { reason = "denominator_zero" } else if !passed { reason = "above_limit" } traces = append(traces, StrategyConditionTrace{Name: check.name, Numerator: check.rtp.PayoutCoins, Denominator: check.rtp.WagerCoins, RatioPPM: ratio, LimitPPM: check.limit, Passed: passed, Reason: reason}) eligible = eligible && passed } limitPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit traces = append(traces, StrategyConditionTrace{Name: StrategyConditionDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, Passed: limitPassed, Reason: boolReason(limitPassed, "below_limit", StrategyReasonDailyJackpotLimit)}) return traces, eligible && limitPassed } func selectPayableJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource) (StrategyTier, int64, bool, string, []StrategyRemovalTrace, error) { if state.UserDailyJackpotWins >= config.DailyJackpotLimit { return StrategyTier{}, 0, false, StrategyReasonDailyJackpotLimit, nil, nil } candidates := make([]weightedStrategyTier, 0, len(config.Tiers)) removed := make([]StrategyRemovalTrace, 0, len(config.Tiers)) for _, tier := range config.Tiers { if !tier.Enabled || !tier.Jackpot || tier.MultiplierPPM <= 0 { continue } payout, err := strategyPayout(input.GiftPriceCoins, tier.MultiplierPPM) if err != nil { return StrategyTier{}, 0, false, "", nil, err } if payout > available { removed = append(removed, StrategyRemovalTrace{TierID: tier.ID, Reason: StrategyReasonPoolInsufficient}) continue } if payout > input.RiskCapacity.capacity() { removed = append(removed, StrategyRemovalTrace{TierID: tier.ID, Reason: StrategyReasonRiskCapacity}) continue } candidates = append(candidates, weightedStrategyTier{Tier: tier, Weight: jackpotWeight(tier)}) } if len(candidates) == 0 { return StrategyTier{}, 0, false, StrategyReasonNoPayableJackpot, removed, nil } tier, _, err := drawWeightedStrategyTier(candidates, random) if err != nil { return StrategyTier{}, 0, false, "", removed, err } payout, err := strategyPayout(input.GiftPriceCoins, tier.MultiplierPPM) return tier, payout, true, "payable_jackpot_selected", removed, err } func selectOrdinaryTier(config StrategyConfig, state StrategyState, input StrategyInput, available int64, weighted []weightedStrategyTier, random StrategyRandomSource) (StrategyTier, int64, DecisionTrace, error) { trace := DecisionTrace{} zeroTier := zeroStrategyTier(weighted) candidates := append([]weightedStrategyTier(nil), weighted...) if state.ConsecutiveZeroDraws >= config.MissProtectionZeroDraws { trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: zeroTier.ID, Reason: StrategyRemovalMissProtection}) candidates = withoutStrategyTier(candidates, zeroTier.ID) if !hasPayableOrdinaryTier(config, state, input, available, candidates) { trace.FinalReason = StrategyReasonMissProtectionBlocked trace.BlockedReason = StrategyReasonMissProtectionBlocked return zeroTier, 0, trace, nil } } sequence := 0 redraw := false for len(candidates) > 0 { if !hasPositiveStrategyWeight(candidates) { break } selected, weight, err := drawWeightedStrategyTier(candidates, random) if err != nil { return StrategyTier{}, 0, trace, err } sequence++ payout, err := strategyPayout(input.GiftPriceCoins, selected.MultiplierPPM) if err != nil { return StrategyTier{}, 0, trace, err } draw := StrategyDrawTrace{Sequence: sequence, Kind: StrategyDrawKindOriginal, TierID: selected.ID, MultiplierPPM: selected.MultiplierPPM, WeightPPM: weight, PayoutCoins: payout} if redraw { draw.Kind = StrategyDrawKindRedraw trace.RedrawTierIDs = append(trace.RedrawTierIDs, selected.ID) } else { trace.OriginalTierID = selected.ID } if selected.MultiplierPPM == 0 { trace.Draws = append(trace.Draws, draw) trace.FinalReason = StrategyReasonZeroSelected return selected, 0, trace, nil } reason := ordinaryBlockedReason(config, state, input, available, selected, payout) if reason == "" { trace.Draws = append(trace.Draws, draw) trace.FinalReason = StrategyReasonPaid return selected, payout, trace, nil } draw.RemovedReason = reason trace.Draws = append(trace.Draws, draw) trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: selected.ID, Reason: reason}) candidates = withoutStrategyTier(candidates, selected.ID) // Product P/W semantics remove 0 together with the first unaffordable winner; // subsequent rolls therefore search only the remaining positive tiers. if reason == StrategyReasonPoolInsufficient && containsStrategyTier(candidates, zeroTier.ID) { candidates = withoutStrategyTier(candidates, zeroTier.ID) trace.Removed = append(trace.Removed, StrategyRemovalTrace{TierID: zeroTier.ID, Reason: StrategyRemovalPWRedrawZero}) } redraw = true } trace.FinalReason = StrategyReasonNoPayableTier trace.BlockedReason = StrategyReasonNoPayableTier return zeroTier, 0, trace, nil } func ordinaryBlockedReason(config StrategyConfig, state StrategyState, input StrategyInput, available int64, tier StrategyTier, payout int64) string { if payout > available { return StrategyReasonPoolInsufficient } if payout > input.RiskCapacity.capacity() { return StrategyReasonRiskCapacity } if tier.Jackpot && state.UserDailyJackpotWins >= config.DailyJackpotLimit { return StrategyReasonDailyJackpotLimit } return "" } func hasPayableOrdinaryTier(config StrategyConfig, state StrategyState, input StrategyInput, available int64, candidates []weightedStrategyTier) bool { for _, candidate := range candidates { if candidate.Weight <= 0 || candidate.Tier.MultiplierPPM <= 0 { continue } payout, err := strategyPayout(input.GiftPriceCoins, candidate.Tier.MultiplierPPM) if err == nil && ordinaryBlockedReason(config, state, input, available, candidate.Tier, payout) == "" { return true } } return false } func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input StrategyInput, available int64, tier StrategyTier, payout int64, mechanism string, consumeToken bool, trace DecisionTrace) (StrategyDecision, error) { if payout < 0 || payout > available { return StrategyDecision{}, fmt.Errorf("%w: payout=%d exceeds available pool=%d", ErrStrategyInput, payout, available) } next := state next.PoolBalanceCoins = available - payout if next.PoolBalanceCoins < 0 { return StrategyDecision{}, fmt.Errorf("%w: strategy would make pool negative", ErrStrategyInput) } if payout == 0 { next.ConsecutiveZeroDraws++ } else { next.ConsecutiveZeroDraws = 0 } if consumeToken { if next.PendingMilestoneTokens <= 0 { return StrategyDecision{}, fmt.Errorf("%w: cannot consume absent milestone token", ErrStrategyInput) } next.PendingMilestoneTokens-- } jackpot := payout > 0 && tier.Jackpot if jackpot { next.UserDailyJackpotWins++ } var err error previousDaySpend := next.UserDaySpendCoins if next.UserDaySpendCoins, err = safeAdd(next.UserDaySpendCoins, input.GiftPriceCoins); err != nil { return StrategyDecision{}, err } // A threshold crossed by this draw is persisted after selection, so the newly // earned token can affect only the next locked draw and never the current one. earnedTokens := MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins) if earnedTokens > 0 { next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens) if err != nil { return StrategyDecision{}, err } trace.MilestoneTokensEarned = earnedTokens } if next.GlobalRTP, err = next.GlobalRTP.add(input.GiftPriceCoins, payout); err != nil { return StrategyDecision{}, err } if next.UserDayRTP, err = next.UserDayRTP.add(input.GiftPriceCoins, payout); err != nil { return StrategyDecision{}, err } if next.User72HourRTP, err = next.User72HourRTP.add(input.GiftPriceCoins, payout); err != nil { return StrategyDecision{}, err } if next.Version == math.MaxInt64 { return StrategyDecision{}, fmt.Errorf("%w: state version overflow", ErrStrategyInput) } next.Version++ trace.FinalReason = strings.TrimSpace(trace.FinalReason) trace.JackpotMechanism = mechanism trace.MilestoneTokenConsumed = consumeToken return StrategyDecision{ SelectedTier: tier, PayoutCoins: payout, PoolAfterCoins: next.PoolBalanceCoins, Stage: trace.Stage, Jackpot: jackpot, JackpotMechanism: mechanism, ConsumedMilestoneToken: consumeToken, NextState: next, Trace: trace, }, nil } func (r StrategyRTP) RatioPPM() (int64, bool) { if r.WagerCoins <= 0 || r.PayoutCoins < 0 { return 0, false } ratio := mulDiv(r.PayoutCoins, StrategyPPMScale, r.WagerCoins) return ratio, ratio >= 0 } func (r StrategyRTP) add(wager, payout int64) (StrategyRTP, error) { if wager < 0 || payout < 0 { return StrategyRTP{}, fmt.Errorf("%w: RTP deltas cannot be negative", ErrStrategyInput) } var err error if r.WagerCoins, err = safeAdd(r.WagerCoins, wager); err != nil { return StrategyRTP{}, err } if r.PayoutCoins, err = safeAdd(r.PayoutCoins, payout); err != nil { return StrategyRTP{}, err } return r, nil } func (c StrategyRiskCapacity) values() []int64 { return []int64{c.SingleDrawCoins, c.UserHourCoins, c.UserDayCoins, c.DeviceDayCoins, c.RoomHourCoins, c.AnchorDayCoins} } func (c StrategyRiskCapacity) capacity() int64 { if !c.Enabled { return math.MaxInt64 } capacity := int64(math.MaxInt64) for _, value := range c.values() { if value < capacity { capacity = value } } if capacity < 0 { return 0 } return capacity } func drawWeightedStrategyTier(candidates []weightedStrategyTier, random StrategyRandomSource) (StrategyTier, int64, error) { var total int64 for _, candidate := range candidates { if candidate.Weight < 0 { return StrategyTier{}, 0, fmt.Errorf("%w: negative candidate weight", ErrStrategyConfig) } if candidate.Weight > 0 { var err error total, err = safeAdd(total, candidate.Weight) if err != nil { return StrategyTier{}, 0, err } } } if total <= 0 { return StrategyTier{}, 0, fmt.Errorf("%w: no positive candidate weight", ErrStrategyInput) } index, err := random.Int63n(total) if err != nil { return StrategyTier{}, 0, err } if index < 0 || index >= total { return StrategyTier{}, 0, fmt.Errorf("%w: random index=%d outside [0,%d)", ErrStrategyInput, index, total) } for _, candidate := range candidates { if candidate.Weight <= 0 { continue } if index < candidate.Weight { return candidate.Tier, candidate.Weight, nil } index -= candidate.Weight } return StrategyTier{}, 0, fmt.Errorf("%w: weighted selection exhausted", ErrStrategyInput) } func strategyPayout(price, multiplierPPM int64) (int64, error) { if price <= 0 || multiplierPPM < 0 { return 0, fmt.Errorf("%w: price/multiplier invalid", ErrStrategyInput) } result := mulDiv(price, multiplierPPM, StrategyPPMScale) if result < 0 { return 0, fmt.Errorf("%w: payout overflow", ErrStrategyInput) } return result, nil } func jackpotWeight(tier StrategyTier) int64 { if tier.JackpotWeight > 0 { return tier.JackpotWeight } return 1 } func zeroStrategyTier(candidates []weightedStrategyTier) StrategyTier { for _, candidate := range candidates { if candidate.Tier.MultiplierPPM == 0 { return candidate.Tier } } return StrategyTier{ID: StrategyDefaultZeroTierID, Enabled: true} } func containsStrategyTier(candidates []weightedStrategyTier, tierID string) bool { for _, candidate := range candidates { if candidate.Tier.ID == tierID { return true } } return false } func withoutStrategyTier(candidates []weightedStrategyTier, tierID string) []weightedStrategyTier { out := make([]weightedStrategyTier, 0, len(candidates)) for _, candidate := range candidates { if candidate.Tier.ID != tierID { out = append(out, candidate) } } return out } func hasPositiveStrategyWeight(candidates []weightedStrategyTier) bool { for _, candidate := range candidates { if candidate.Weight > 0 { return true } } return false } func boolReason(ok bool, yes, no string) string { if ok { return yes } return no } func mulPPM(value, factor int64) int64 { return mulDiv(value, factor, StrategyPPMScale) } func mulDiv(a, b, divisor int64) int64 { if a < 0 || b < 0 || divisor <= 0 { return -1 } n := new(big.Int).Mul(big.NewInt(a), big.NewInt(b)) n.Quo(n, big.NewInt(divisor)) if !n.IsInt64() { return -1 } return n.Int64() } func safeAdd(a, b int64) (int64, error) { if b > 0 && a > math.MaxInt64-b { return 0, fmt.Errorf("%w: integer overflow", ErrStrategyInput) } if b < 0 && a < math.MinInt64-b { return 0, fmt.Errorf("%w: integer underflow", ErrStrategyInput) } return a + b, nil } // apportion distributes integer total by non-negative weights using the largest // remainder method. It is used both by fund splitting and >100% dynamic-weight // normalization, so both paths conserve their exact integer total. func apportion(total int64, weights []int64, denominator int64) ([]int64, error) { if total < 0 || denominator <= 0 || len(weights) == 0 { return nil, fmt.Errorf("%w: apportion arguments invalid", ErrStrategyInput) } type remainder struct { index int value *big.Int } parts := make([]int64, len(weights)) remainders := make([]remainder, 0, len(weights)) var allocated int64 den := big.NewInt(denominator) for index, weight := range weights { if weight < 0 { return nil, fmt.Errorf("%w: apportion weight cannot be negative", ErrStrategyInput) } numerator := new(big.Int).Mul(big.NewInt(total), big.NewInt(weight)) quotient, rem := new(big.Int), new(big.Int) quotient.QuoRem(numerator, den, rem) if !quotient.IsInt64() { return nil, fmt.Errorf("%w: apportion overflow", ErrStrategyInput) } parts[index] = quotient.Int64() allocated += parts[index] remainders = append(remainders, remainder{index: index, value: new(big.Int).Set(rem)}) } left := total - allocated if left < 0 || left > int64(len(weights)) { return nil, fmt.Errorf("%w: apportion denominator does not cover weights", ErrStrategyInput) } sort.SliceStable(remainders, func(i, j int) bool { cmp := remainders[i].value.Cmp(remainders[j].value) if cmp == 0 { return remainders[i].index < remainders[j].index } return cmp > 0 }) for index := int64(0); index < left; index++ { parts[remainders[index].index]++ } return parts, nil }