1285 lines
56 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 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"
StrategyReasonSpendGlobalRTPBlocked = "spend_global_rtp_gate_failed"
StrategyJackpotMechanismMilestone = "spend_milestone"
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"
StrategyConditionSpendGlobalRTP = "spend_global_rtp"
StrategyConditionSpendDailyJackpotLimit = "spend_daily_jackpot_limit"
StrategyConditionSettledRound = "settled_round_qualification"
StrategyConditionUserRoundRTP = "user_round_rtp"
StrategyConditionUser48HourRTP = "user_48h_rtp"
StrategyConditionUser48HourMaturity = "user_registered_48h"
// Deprecated trace names remain available to the pure legacy compatibility path only.
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 source-specific selectable tier, not a multiplier identity.
// The same multiplier may have an ordinary tier and a separate jackpot tier:
// BaseWeightPPM drives only ordinary draws, while JackpotWeight is used only after
// all RTP, pool, daily-limit and risk gates pass.
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"`
UserRoundRTPMaxPPM int64 `json:"user_round_rtp_max_ppm"`
User48HourRTPMaxPPM int64 `json:"user_48h_rtp_max_ppm"`
// SettledRoundRTPEligibility switches production V3 to the closed-window qualification flow.
// False preserves the isolated legacy kernel API used by fixed historical simulations; runtime V3 always sets true.
SettledRoundRTPEligibility bool `json:"settled_round_rtp_eligibility"`
// Deprecated aliases are read only by the legacy kernel branch.
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 is the legacy pure-kernel field. Production dynamic_v3 never reads it:
// the separate field below prevents tokens created by the retired implementation from becoming
// a jackpot promise after the dual-strategy rollout.
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"`
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
// SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is
// deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification.
SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"`
GlobalRTP StrategyRTP `json:"global_rtp"`
UserRoundRTP StrategyRTP `json:"user_round_rtp"`
User48HourRTP StrategyRTP `json:"user_48h_rtp"`
SettledRoundPending bool `json:"settled_round_pending"`
QualificationClosedAtMS int64 `json:"qualification_closed_at_ms"`
UserRegisteredAtMS int64 `json:"user_registered_at_ms"`
// Deprecated aliases are updated only by the legacy kernel branch.
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:"spend_milestone_token_consumed"`
MilestoneTokenRetained bool `json:"spend_milestone_token_retained"`
MilestoneTokensEarned int64 `json:"spend_milestone_tokens_earned"`
// The exact before/after balance is persisted for every draw, including a successful 48h
// compensation that deliberately leaves an existing spend token untouched. The boolean fields
// above keep their event meaning; these counters make multi-token incident replay unambiguous.
SpendMilestoneTokensBefore int64 `json:"pending_spend_jackpot_tokens_before"`
SpendMilestoneTokensAfter int64 `json:"pending_spend_jackpot_tokens_after"`
}
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,
// 累计消费门槛是不可变规则版本的动态金币配置0 表示该内核默认值尚未配置。
MissProtectionZeroDraws: 5, DailyJackpotLimit: 5, MilestoneSpendCoins: 0,
JackpotMechanism1Enabled: true, JackpotMechanism2Enabled: true,
GlobalRTPMaxPPM: 980_000, UserDayRTPMaxPPM: 960_000, User72HourRTPMaxPPM: 960_000,
RechargeStages: []StrategyRechargeStage{
// 三个 0/0 仅用于未配置内核默认值;可运行规则必须由发布层注入 normal/advanced 门槛。
{Name: StageNovice, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
{Name: StageNormal, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
{Name: StageAdvanced, MinRecharge7DCoins: 0, MinRecharge30DCoins: 0},
},
}
}
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
// configured cumulative-spend 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 只用 normal/advanced 的版本化门槛向上提档novice 是无条件兜底,
// 其 0/0 仅为协议和存储 sentinel。两维必须同时达到才能提档所以任一维低于 normal 都是 novice等于门槛则通过该维。
func SelectLuckyGiftRechargeStage(config StrategyConfig, recharge7D, recharge30D int64) string {
stages := make(map[string]StrategyRechargeStage, len(config.RechargeStages))
for _, stage := range config.RechargeStages {
stages[stage.Name] = stage
}
if rechargeStageConfiguredAndReached(stages[StageAdvanced], recharge7D, recharge30D) {
return StageAdvanced
}
if rechargeStageConfiguredAndReached(stages[StageNormal], recharge7D, recharge30D) {
return StageNormal
}
return StageNovice
}
func rechargeStageConfiguredAndReached(stage StrategyRechargeStage, recharge7D, recharge30D int64) bool {
// normal/advanced 的 0/0 表示 disabled 草稿尚未配置,不能因为任意非负值都 >=0 而意外提档。
if stage.MinRecharge7DCoins == 0 && stage.MinRecharge30DCoins == 0 {
return false
}
return recharge7D >= stage.MinRecharge7DCoins && recharge30D >= stage.MinRecharge30DCoins
}
// 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. Production V3 evaluates the
// one-shot closed-round/48h qualification first, then the independent durable spend milestone, and
// finally the ordinary weighted draw. A spend token is consumed only by a paid spend jackpot; all
// failed global-RTP, daily-limit, pool and risk checks leave it available for a later paid draw.
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,
SpendMilestoneTokensBefore: pendingSpendJackpotTokens(config, state),
SpendMilestoneTokensAfter: pendingSpendJackpotTokens(config, state),
}
if config.SettledRoundRTPEligibility {
decision, decided, err := tryRTPCompensationJackpot(config, state, input, available, random, &trace)
if err != nil {
return StrategyDecision{}, err
}
if decided {
return decision, nil
}
decision, decided, err = trySpendMilestoneJackpot(config, state, input, available, random, true, &trace)
if err != nil {
return StrategyDecision{}, err
}
if decided {
return decision, nil
}
} else {
// The isolated legacy kernel keeps its historical order and gate shape. Runtime dynamic_v3
// always takes the branch above and therefore never reads PendingMilestoneTokens.
decision, decided, err := trySpendMilestoneJackpot(config, state, input, available, random, false, &trace)
if err != nil {
return StrategyDecision{}, err
}
if decided {
return decision, nil
}
decision, decided, err = tryRTPCompensationJackpot(config, state, input, available, random, &trace)
if err != nil {
return StrategyDecision{}, err
}
if decided {
return decision, nil
}
}
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 tryRTPCompensationJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, trace *DecisionTrace) (StrategyDecision, bool, error) {
if !config.JackpotMechanism1Enabled {
return StrategyDecision{}, false, nil
}
conditions, eligible := mechanismOneConditions(config, state)
trace.Conditions = append(trace.Conditions, conditions...)
if !eligible {
return StrategyDecision{}, false, nil
}
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
if err != nil {
return StrategyDecision{}, false, err
}
trace.Removed = append(trace.Removed, removed...)
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
if !ok {
if trace.BlockedReason == "" {
trace.BlockedReason = reason
}
return StrategyDecision{}, false, nil
}
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})
decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismRTPCompensation, false, *trace)
return decision, err == nil, err
}
func trySpendMilestoneJackpot(config StrategyConfig, state StrategyState, input StrategyInput, available int64, random StrategyRandomSource, requireSettledGlobal bool, trace *DecisionTrace) (StrategyDecision, bool, error) {
if !config.JackpotMechanism2Enabled || pendingSpendJackpotTokens(config, state) <= 0 {
return StrategyDecision{}, false, nil
}
if requireSettledGlobal {
ratio, valid := state.SpendGlobalRTP.RatioPPM()
globalPassed := valid && rtpAtOrBelowExact(state.SpendGlobalRTP, config.GlobalRTPMaxPPM)
trace.Conditions = append(trace.Conditions, rtpAtOrBelowConditionTrace(StrategyConditionSpendGlobalRTP, state.SpendGlobalRTP, ratio, config.GlobalRTPMaxPPM, valid, globalPassed))
dailyPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{
Name: StrategyConditionSpendDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit,
Passed: dailyPassed, Reason: boolReason(dailyPassed, "below_limit", StrategyReasonDailyJackpotLimit),
})
if !globalPassed || !dailyPassed {
trace.MilestoneTokenRetained = true
if trace.BlockedReason == "" {
trace.BlockedReason = StrategyReasonSpendGlobalRTPBlocked
if globalPassed {
trace.BlockedReason = StrategyReasonDailyJackpotLimit
}
}
return StrategyDecision{}, false, nil
}
}
tier, payout, ok, reason, removed, err := selectPayableJackpot(config, state, input, available, random)
if err != nil {
return StrategyDecision{}, false, err
}
trace.Removed = append(trace.Removed, removed...)
trace.Conditions = append(trace.Conditions, StrategyConditionTrace{Name: StrategyConditionPayableJackpot, Passed: ok, Reason: reason})
if !ok {
trace.MilestoneTokenRetained = true
if trace.BlockedReason == "" {
trace.BlockedReason = reason
}
return StrategyDecision{}, false, nil
}
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})
decision, err := finalizeStrategyDecision(config, state, input, available, tier, payout, StrategyJackpotMechanismMilestone, true, *trace)
return decision, err == nil, err
}
func pendingSpendJackpotTokens(config StrategyConfig, state StrategyState) int64 {
if config.SettledRoundRTPEligibility {
return state.PendingSpendJackpotTokens
}
return state.PendingMilestoneTokens
}
func validateStrategy(config StrategyConfig, state StrategyState, input StrategyInput, requireTiers bool) error {
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.PendingSpendJackpotTokens < 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.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, 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.UserRoundRTPMaxPPM < 0 || config.UserRoundRTPMaxPPM > StrategyPPMScale ||
config.User48HourRTPMaxPPM < 0 || config.User48HourRTPMaxPPM > 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)
}
// 大奖身份属于受 RTP 条件控制的独立路径,不能同时携带普通随机权重;
// 同倍率普通奖应使用另一个 Jackpot=false tier避免每日 cap 和审计来源再次耦合。
if tier.Jackpot && tier.BaseWeightPPM > 0 {
return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary base weight", ErrStrategyConfig, tier.ID)
}
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 tier.Jackpot && weight > 0 {
return fmt.Errorf("%w: jackpot tier %s cannot carry ordinary 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) {
if config.SettledRoundRTPEligibility {
return settledRoundJackpotConditions(config, state)
}
// Historical pure-kernel callers used opportunistic current/day/72h checks. Keeping this branch
// source-compatible does not expose it to dynamic_v3: the production adapter always enables the
// settled-round flag and disables the independent milestone mechanism.
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 settledRoundJackpotConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
traces := make([]StrategyConditionTrace, 0, 6)
pending := state.SettledRoundPending
traces = append(traces, StrategyConditionTrace{
Name: StrategyConditionSettledRound, Passed: pending,
Reason: boolReason(pending, "closed_round_pending", "no_closed_round_pending"),
})
eligible := pending
// The platform permits equality at 98%, while both user gates are deliberately strict: an exact
// 96% user ratio is not “losing more than two points” and must not receive compensation.
globalRatio, globalValid := state.GlobalRTP.RatioPPM()
globalPassed := globalValid && rtpAtOrBelowExact(state.GlobalRTP, config.GlobalRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionGlobalRTP, state.GlobalRTP, globalRatio, config.GlobalRTPMaxPPM, globalValid, globalPassed))
eligible = eligible && globalPassed
roundRatio, roundValid := state.UserRoundRTP.RatioPPM()
roundPassed := roundValid && rtpBelowExact(state.UserRoundRTP, config.UserRoundRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionUserRoundRTP, state.UserRoundRTP, roundRatio, config.UserRoundRTPMaxPPM, roundValid, roundPassed))
eligible = eligible && roundPassed
const maturityMS = int64(48 * 60 * 60 * 1000)
ageMS := int64(0)
if state.UserRegisteredAtMS > 0 && state.QualificationClosedAtMS >= state.UserRegisteredAtMS {
ageMS = state.QualificationClosedAtMS - state.UserRegisteredAtMS
}
mature := state.UserRegisteredAtMS > 0 && ageMS >= maturityMS
traces = append(traces, StrategyConditionTrace{
Name: StrategyConditionUser48HourMaturity, Numerator: ageMS, Denominator: maturityMS,
Passed: mature, Reason: boolReason(mature, "registered_48h", "registered_under_48h_or_unknown"),
})
eligible = eligible && mature
rollingRatio, rollingValid := state.User48HourRTP.RatioPPM()
rollingPassed := rollingValid && rtpBelowExact(state.User48HourRTP, config.User48HourRTPMaxPPM)
traces = append(traces, rtpConditionTrace(StrategyConditionUser48HourRTP, state.User48HourRTP, rollingRatio, config.User48HourRTPMaxPPM, rollingValid, rollingPassed))
eligible = eligible && rollingPassed
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 rtpConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
reason := "within_limit"
if !valid {
reason = "denominator_zero"
} else if !passed {
reason = "above_or_equal_limit"
}
return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
}
func rtpAtOrBelowConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
reason := "within_limit"
if !valid {
reason = "denominator_zero"
} else if !passed {
reason = "above_limit"
}
return StrategyConditionTrace{Name: name, Numerator: rtp.PayoutCoins, Denominator: rtp.WagerCoins, RatioPPM: ratio, LimitPPM: limit, Passed: passed, Reason: reason}
}
func rtpBelowExact(rtp StrategyRTP, limitPPM int64) bool {
return compareRTPExact(rtp, limitPPM) < 0
}
func rtpAtOrBelowExact(rtp StrategyRTP, limitPPM int64) bool {
return compareRTPExact(rtp, limitPPM) <= 0
}
func compareRTPExact(rtp StrategyRTP, limitPPM int64) int {
// Cross multiplication keeps the configured mathematical boundary exact even when payout*1e6
// exceeds int64. RatioPPM remains a display/audit value and is never trusted for eligibility.
left := new(big.Int).Mul(big.NewInt(rtp.PayoutCoins), big.NewInt(StrategyPPMScale))
right := new(big.Int).Mul(big.NewInt(rtp.WagerCoins), big.NewInt(limitPPM))
return left.Cmp(right)
}
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
if config.SettledRoundRTPEligibility {
// A closed-round qualification is exactly one attempt. Pool/risk rejection falls through to the
// ordinary draw in this same call, but the qualification can never repeat on a later paid draw.
next.SettledRoundPending = false
}
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 pendingSpendJackpotTokens(config, next) <= 0 {
return StrategyDecision{}, fmt.Errorf("%w: cannot consume absent milestone token", ErrStrategyInput)
}
if config.SettledRoundRTPEligibility {
next.PendingSpendJackpotTokens--
} else {
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 subdraw and never the current one. Production V3 stores it in the
// dedicated field; the retired legacy token is neither read nor migrated into the new strategy.
earnedTokens := int64(0)
if config.JackpotMechanism2Enabled {
earnedTokens = MilestoneTokensEarned(previousDaySpend, next.UserDaySpendCoins, config.MilestoneSpendCoins)
}
if earnedTokens > 0 {
if config.SettledRoundRTPEligibility {
next.PendingSpendJackpotTokens, err = safeAdd(next.PendingSpendJackpotTokens, earnedTokens)
} else {
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)
}
if err != nil {
return StrategyDecision{}, err
}
trace.MilestoneTokensEarned = earnedTokens
}
// Set the final balance after both the optional consumption and threshold-crossing accrual.
// This assignment runs for ordinary, 48h and spend-jackpot decisions alike, so a 48h win with
// one pending spend promise is durably recorded as 1 -> 1 instead of looking like no promise existed.
trace.SpendMilestoneTokensAfter = pendingSpendJackpotTokens(config, next)
if !config.SettledRoundRTPEligibility {
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
}