1054 lines
44 KiB
Go
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
|
|
}
|