1059 lines
45 KiB
Go
1059 lines
45 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,
|
||
// 累计消费门槛是不可变规则版本的动态金币配置;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. 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
|
||
}
|