2026-06-18 17:13:56 +08:00

131 lines
3.8 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 lotteryengine
import (
crand "crypto/rand"
"errors"
"math/big"
"strings"
)
const (
PrizeKindCoin = "coin"
PrizeKindGift = "gift"
PrizeKindProp = "prop"
PrizeKindDress = "dress"
)
var (
ErrNoCandidate = errors.New("lottery candidate is empty")
ErrInvalidWeight = errors.New("lottery candidate weight is invalid")
)
// Candidate 是抽奖引擎唯一关心的奖档视图。
// 业务模块负责在进入引擎前完成资格、奖池和风控过滤;引擎只保证权重随机和 RTP 计值口径一致。
type Candidate struct {
ID string
Weight int64
PrizeKind string
RTPValueCoins int64
}
// NormalizeCandidates 复制并清洗候选集,避免调用方的业务快照被引擎内部兜底权重改写。
// 全部候选权重都为 0 时退化成等权随机,是为了兼容旧配置或脏数据;负权重直接拒绝,防止配置错误改变概率方向。
func NormalizeCandidates(candidates []Candidate) ([]Candidate, error) {
if len(candidates) == 0 {
return nil, ErrNoCandidate
}
out := make([]Candidate, 0, len(candidates))
var totalWeight int64
for _, candidate := range candidates {
if candidate.Weight < 0 {
return nil, ErrInvalidWeight
}
candidate.PrizeKind = NormalizePrizeKind(candidate.PrizeKind)
candidate.RTPValueCoins = NormalizeRTPValueCoins(candidate.PrizeKind, candidate.RTPValueCoins)
if candidate.Weight > 0 {
totalWeight += candidate.Weight
out = append(out, candidate)
}
}
if totalWeight > 0 {
return out, nil
}
out = out[:0]
for _, candidate := range candidates {
candidate.PrizeKind = NormalizePrizeKind(candidate.PrizeKind)
candidate.RTPValueCoins = NormalizeRTPValueCoins(candidate.PrizeKind, candidate.RTPValueCoins)
candidate.Weight = 1
out = append(out, candidate)
}
return out, nil
}
// SelectWeighted 从已经归一化的候选中按权重抽一档。
// 随机源使用 crypto/rand避免高价值奖档被时间种子或并发顺序预测。
func SelectWeighted(candidates []Candidate) (Candidate, error) {
normalized, err := NormalizeCandidates(candidates)
if err != nil {
return Candidate{}, err
}
var totalWeight int64
for _, candidate := range normalized {
totalWeight += candidate.Weight
}
if totalWeight <= 0 {
return Candidate{}, ErrNoCandidate
}
index, err := secureIndex(totalWeight)
if err != nil {
return Candidate{}, err
}
for _, candidate := range normalized {
if index < candidate.Weight {
return candidate, nil
}
index -= candidate.Weight
}
return normalized[len(normalized)-1], nil
}
// NormalizePrizeKind 把后台和活动配置里的同义奖品类型收敛到引擎内部口径。
// prop 和 dress 都表示装扮/道具类权益,后续发放可以不同,但 RTP 口径必须一致归零。
func NormalizePrizeKind(kind string) string {
switch strings.ToLower(strings.TrimSpace(kind)) {
case PrizeKindCoin:
return PrizeKindCoin
case PrizeKindGift:
return PrizeKindGift
case PrizeKindProp, "item", "decoration", "resource":
return PrizeKindProp
case PrizeKindDress, "dress_up", "costume":
return PrizeKindDress
default:
return strings.ToLower(strings.TrimSpace(kind))
}
}
// NormalizeRTPValueCoins 是转盘和其它抽奖共用的 RTP 计值入口。
// 装扮/道具无论后台配置了多少展示价值,都不能进入 RTP 返奖分子;金币和礼物保留配置值,负数按 0 处理。
func NormalizeRTPValueCoins(kind string, value int64) int64 {
if value < 0 {
value = 0
}
switch NormalizePrizeKind(kind) {
case PrizeKindProp, PrizeKindDress:
return 0
default:
return value
}
}
func secureIndex(totalWeight int64) (int64, error) {
if totalWeight <= 0 {
return 0, ErrNoCandidate
}
n, err := crand.Int(crand.Reader, big.NewInt(totalWeight))
if err != nil {
return 0, err
}
return n.Int64(), nil
}