132 lines
3.8 KiB
Go
132 lines
3.8 KiB
Go
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 是抽奖引擎唯一关心的奖档视图。
|
|
// lucky-gift-service 在进入引擎前已经完成规则版本、单池水位和用户阶段过滤;
|
|
// 引擎只负责不可预测的权重随机,不能再读取数据库或感知商户、房间、钱包等业务边界。
|
|
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 把后台配置同义词收敛到引擎口径。
|
|
// 装扮/道具类可以有展示价值,但不能进入现金化 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 的唯一计值口。
|
|
// 金币和可折价礼物按配置值计入,装扮/道具固定归零,负数归零以阻断后台脏数据扩大池水。
|
|
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
|
|
}
|