1054 lines
44 KiB
Go

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
}