2026-05-15 09:58:01 +08:00

244 lines
7.6 KiB
Go

package smashegg
import (
"fmt"
"math"
"net/http"
"sort"
"chatapp3-golang/internal/model"
)
type probabilityItem struct {
index int
value int64
}
func applyAutoProbabilitiesToInputs(targetRTP int, options []DrawOptionInput, rewards []RewardConfigInput, poolName string) ([]RewardConfigInput, error) {
if err := validateAutoProbabilitiesForInputs(targetRTP, options, rewards, poolName); err != nil {
return nil, err
}
baseOption, ok := firstEnabledInputOption(options)
if !ok {
return rewards, nil
}
probabilities, err := autoProbabilitiesForInputOption(targetRTP, baseOption, rewards, poolName)
if err != nil {
return nil, err
}
for index := range rewards {
rewards[index].Probability = probabilities[index]
}
return rewards, nil
}
func validateAutoProbabilitiesForInputs(targetRTP int, options []DrawOptionInput, rewards []RewardConfigInput, poolName string) error {
for _, option := range options {
if !option.Enabled {
continue
}
probabilities, err := autoProbabilitiesForInputOption(targetRTP, option, rewards, poolName)
if err != nil {
return err
}
weightedValue := weightedInputRewardValue(rewards, probabilities)
rtp := drawOptionRTPBasisPointsFromWeightedValue(weightedValue, option.Times, option.PriceGold)
if rtp < targetRTP-rtpToleranceBasisPoints || rtp > targetRTP+rtpToleranceBasisPoints {
return NewAppError(
http.StatusBadRequest,
"invalid_rtp",
fmt.Sprintf("%s pool option %s auto RTP %s%% is outside target %s%% ±1.00%%", poolName, option.Label, basisPointsPercent(rtp), basisPointsPercent(targetRTP)),
)
}
}
return nil
}
func rewardsWithAutoProbabilitiesForOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig) ([]model.SmashEggRewardConfig, error) {
probabilities, err := autoProbabilitiesForModelOption(targetRTP, option, rewards, normalizePoolType(firstPoolType(rewards)))
if err != nil {
return nil, err
}
next := append([]model.SmashEggRewardConfig(nil), rewards...)
for index := range next {
next[index].Probability = probabilities[index]
}
return next, nil
}
func firstPoolType(rewards []model.SmashEggRewardConfig) string {
for _, reward := range rewards {
if reward.PoolType != "" {
return reward.PoolType
}
}
return poolTypeGeneral
}
func expectedRewardValueForOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig) float64 {
next, err := rewardsWithAutoProbabilitiesForOption(targetRTP, option, rewards)
if err != nil {
return expectedRewardValue(rewards)
}
return expectedRewardValue(next)
}
func autoProbabilitiesForInputOption(targetRTP int, option DrawOptionInput, rewards []RewardConfigInput, poolName string) ([]int, error) {
items := make([]probabilityItem, 0, len(rewards))
for index, reward := range rewards {
if reward.Enabled {
items = append(items, probabilityItem{index: index, value: reward.RewardValueGold})
}
}
return calculateAutoProbabilities(targetWeightedValue(option.PriceGold, option.Times, targetRTP), len(rewards), items, poolName, option.Label)
}
func autoProbabilitiesForModelOption(targetRTP int, option model.SmashEggDrawOptionConfig, rewards []model.SmashEggRewardConfig, poolName string) ([]int, error) {
items := make([]probabilityItem, 0, len(rewards))
for index, reward := range rewards {
if reward.Enabled {
items = append(items, probabilityItem{index: index, value: reward.RewardValueGold})
}
}
return calculateAutoProbabilities(targetWeightedValue(option.PriceGold, option.Times, targetRTP), len(rewards), items, poolName, option.Label)
}
func calculateAutoProbabilities(targetWeighted int64, rewardCount int, items []probabilityItem, poolName string, optionLabel string) ([]int, error) {
probabilities := make([]int, rewardCount)
if len(items) == 0 {
return probabilities, NewAppError(http.StatusBadRequest, "empty_rewards", poolName+" pool requires at least one enabled reward")
}
if len(items) > probabilityTotal {
return probabilities, NewAppError(http.StatusBadRequest, "too_many_rewards", fmt.Sprintf("%s pool enabled reward count cannot exceed %d", poolName, probabilityTotal))
}
for _, item := range items {
if item.value <= 0 {
return probabilities, NewAppError(http.StatusBadRequest, "invalid_reward_value", poolName+" pool enabled reward value must be greater than 0")
}
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].value == items[j].value {
return items[i].index < items[j].index
}
return items[i].value < items[j].value
})
var baseWeighted int64
for _, item := range items {
probabilities[item.index] = 1
baseWeighted += item.value
}
remaining := probabilityTotal - len(items)
if remaining == 0 {
return probabilities, nil
}
residualTarget := targetWeighted - baseWeighted
minResidual := items[0].value * int64(remaining)
maxResidual := items[len(items)-1].value * int64(remaining)
if residualTarget < minResidual {
probabilities[items[0].index] += remaining
return probabilities, nil
}
if residualTarget > maxResidual {
probabilities[items[len(items)-1].index] += remaining
return probabilities, nil
}
low := items[0]
high := items[len(items)-1]
for index, item := range items {
if item.value*int64(remaining) <= residualTarget {
low = item
if index+1 < len(items) {
high = items[index+1]
} else {
high = item
}
}
}
if low.value == high.value {
probabilities[low.index] += remaining
return probabilities, nil
}
spread := high.value - low.value
highCount := roundDiv(residualTarget-low.value*int64(remaining), spread)
if highCount < 0 {
highCount = 0
}
if highCount > int64(remaining) {
highCount = int64(remaining)
}
highCount = bestHighCount(residualTarget, low.value, high.value, int64(remaining), highCount)
lowCount := int64(remaining) - highCount
probabilities[low.index] += int(lowCount)
probabilities[high.index] += int(highCount)
return probabilities, nil
}
func bestHighCount(target int64, lowValue int64, highValue int64, remaining int64, seed int64) int64 {
best := seed
bestDiff := weightedDiff(target, lowValue, highValue, remaining, seed)
for _, candidate := range []int64{seed - 1, seed + 1} {
if candidate < 0 || candidate > remaining {
continue
}
diff := weightedDiff(target, lowValue, highValue, remaining, candidate)
if diff < bestDiff {
best = candidate
bestDiff = diff
}
}
return best
}
func weightedDiff(target int64, lowValue int64, highValue int64, remaining int64, highCount int64) int64 {
value := lowValue*remaining + (highValue-lowValue)*highCount
return absInt64(target - value)
}
func weightedInputRewardValue(rewards []RewardConfigInput, probabilities []int) int64 {
var total int64
for index, reward := range rewards {
if index >= len(probabilities) || !reward.Enabled || probabilities[index] <= 0 {
continue
}
total += reward.RewardValueGold * int64(probabilities[index])
}
return total
}
func targetWeightedValue(priceGold int64, times int, targetRTP int) int64 {
if priceGold <= 0 || times <= 0 || targetRTP <= 0 {
return 0
}
return roundDiv(priceGold*int64(targetRTP)*int64(probabilityTotal), int64(times)*10000)
}
func firstEnabledInputOption(options []DrawOptionInput) (DrawOptionInput, bool) {
for _, option := range options {
if option.Enabled {
return option, true
}
}
return DrawOptionInput{}, false
}
func roundDiv(numerator int64, denominator int64) int64 {
if denominator == 0 {
return 0
}
if numerator >= 0 {
return (numerator + denominator/2) / denominator
}
return -int64(math.Round(float64(-numerator) / float64(denominator)))
}
func absInt64(value int64) int64 {
if value < 0 {
return -value
}
return value
}