1059 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package luckygift
import (
"errors"
"fmt"
"math"
"math/big"
"math/rand"
"sort"
"strings"
)
const (
// StrategyPPMScale keeps probability, multiplier and adjustment arithmetic in one
// integer unit. The strategy never uses float64 for money or eligibility gates.
StrategyPPMScale int64 = 1_000_000
StrategyReasonZeroSelected = "zero_selected"
StrategyReasonPaid = "paid"
StrategyReasonNoPayableTier = "no_payable_nonzero_tier_fallback_zero"
StrategyReasonMissProtectionBlocked = "miss_protection_blocked_no_payable_tier"
StrategyReasonMilestoneJackpot = "milestone_token_jackpot"
StrategyReasonRTPCompensationJackpot = "rtp_compensation_jackpot"
StrategyReasonMilestoneTokenRetained = "milestone_token_retained_no_payable_jackpot"
StrategyReasonDailyJackpotLimit = "daily_jackpot_limit"
StrategyReasonPoolInsufficient = "pool_insufficient_w_gt_p"
StrategyReasonRiskCapacity = "risk_capacity_exceeded"
StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate"
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
}