2026-05-20 18:07:33 +08:00

196 lines
6.3 KiB
Go

package luckygift
import (
"fmt"
"sort"
"strings"
"hyapp/pkg/xerr"
domain "hyapp/services/activity-service/internal/domain/luckygift"
)
const ppmScale int64 = 1_000_000
// DefaultConfig 给后台指定奖池提供规则草稿;真正生效必须由后台显式 enabled。
func DefaultConfig(appCode, poolID string) domain.Config {
const giftPrice int64 = 10_000
poolID = normalizePoolID(poolID)
return domain.Config{
AppCode: appCode,
GiftID: poolID,
PoolID: poolID,
Enabled: false,
GiftPrice: giftPrice,
TargetRTPPPM: 950_000,
PoolRatePPM: 950_000,
GlobalWindowDraws: 100_000,
GiftWindowDraws: 100_000,
NoviceDrawLimit: 2_000,
IntermediateDrawLimit: 20_000,
HighMultiplier: 100,
HighWaterPoolMultiple: 2,
PlatformPoolWeightPPM: 200_000,
RoomPoolWeightPPM: 300_000,
GiftPoolWeightPPM: 500_000,
InitialPlatformPool: 9_500_000,
InitialGiftPool: 23_750_000,
InitialRoomPool: 5_000_000,
PlatformReserve: 1_000_000,
GiftReserve: 1_000_000,
RoomReserve: 100_000,
MaxSinglePayout: giftPrice * 500,
UserHourlyPayoutCap: 3_420_000,
UserDailyPayoutCap: 61_560_000,
DeviceDailyPayoutCap: 102_600_000,
RoomHourlyPayoutCap: 68_400_000,
AnchorDailyPayoutCap: 1_231_200_000,
RoomAtmosphereRatePPM: 10_000,
RoomAtmosphereInitial: 100_000,
RoomAtmosphereReserve: 10_000,
ActivityBudget: 0,
ActivityDailyLimit: 0,
LargeTierEnabled: true,
MultiplierPPMs: defaultMultiplierPPMs(),
Tiers: defaultTiers(giftPrice),
}
}
func validateConfig(config domain.Config) error {
if normalizePoolID(config.PoolID) == "" {
return xerr.New(xerr.InvalidArgument, "lucky gift pool id is required")
}
config.GiftID = normalizePoolID(config.PoolID)
if config.GiftPrice <= 0 || config.TargetRTPPPM <= 0 || config.TargetRTPPPM > ppmScale {
return xerr.New(xerr.InvalidArgument, "gift price or target RTP is invalid")
}
if config.PoolRatePPM < config.TargetRTPPPM || config.PoolRatePPM > ppmScale {
return xerr.New(xerr.InvalidArgument, "pool rate must be between target RTP and 100%")
}
if config.GlobalWindowDraws <= 0 || config.GiftWindowDraws <= 0 {
return xerr.New(xerr.InvalidArgument, "RTP window draws must be positive")
}
if config.NoviceDrawLimit < 0 || config.IntermediateDrawLimit < config.NoviceDrawLimit {
return xerr.New(xerr.InvalidArgument, "experience pool draw limits are invalid")
}
if config.HighMultiplier <= 0 || config.HighWaterPoolMultiple <= 0 {
return xerr.New(xerr.InvalidArgument, "high multiplier config is invalid")
}
if config.PlatformPoolWeightPPM < 0 || config.RoomPoolWeightPPM < 0 || config.GiftPoolWeightPPM < 0 ||
config.PlatformPoolWeightPPM+config.RoomPoolWeightPPM+config.GiftPoolWeightPPM != ppmScale {
return xerr.New(xerr.InvalidArgument, "pool weights must sum to 1000000")
}
if config.MaxSinglePayout <= 0 || config.UserHourlyPayoutCap <= 0 || config.UserDailyPayoutCap <= 0 ||
config.DeviceDailyPayoutCap <= 0 || config.RoomHourlyPayoutCap <= 0 || config.AnchorDailyPayoutCap <= 0 {
return xerr.New(xerr.InvalidArgument, "risk payout caps must be positive")
}
if len(config.Tiers) == 0 {
return xerr.New(xerr.InvalidArgument, "reward tiers are required")
}
maxMultiplierPPM := int64(0)
for _, tier := range config.Tiers {
if tier.Pool == "" || tier.TierID == "" || tier.Weight < 0 || tier.RewardCoins < 0 || tier.MultiplierPPM < 0 {
return xerr.New(xerr.InvalidArgument, fmt.Sprintf("invalid reward tier: %s", tier.TierID))
}
if tier.Enabled && tier.MultiplierPPM > maxMultiplierPPM {
maxMultiplierPPM = tier.MultiplierPPM
}
}
if maxMultiplierPPM < config.TargetRTPPPM {
return xerr.New(xerr.InvalidArgument, "max lucky gift multiplier must not be lower than target RTP")
}
return nil
}
func normalizeTiers(tiers []domain.Tier, multipliers []int64, giftPrice, highMultiplier int64) ([]domain.Tier, []int64) {
multiplierPPMs := normalizeMultiplierPPMs(multipliers, tiers, giftPrice)
return buildMultiplierTiers(giftPrice, highMultiplier, multiplierPPMs), multiplierPPMs
}
func normalizeMultiplierPPMs(input []int64, tiers []domain.Tier, giftPrice int64) []int64 {
values := append([]int64(nil), input...)
if len(values) == 0 {
for _, tier := range tiers {
if tier.MultiplierPPM > 0 || tier.RewardCoins == 0 {
values = append(values, tier.MultiplierPPM)
continue
}
if giftPrice > 0 && tier.RewardCoins > 0 {
values = append(values, tier.RewardCoins*ppmScale/giftPrice)
}
}
}
if len(values) == 0 {
values = defaultMultiplierPPMs()
}
seen := map[int64]bool{0: true}
out := []int64{0}
for _, value := range values {
if value < 0 || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func buildMultiplierTiers(cost, highMultiplier int64, multipliers []int64) []domain.Tier {
out := make([]domain.Tier, 0, len(multipliers)*3)
for _, pool := range []string{domain.PoolNovice, domain.PoolIntermediate, domain.PoolAdvanced} {
for _, multiplierPPM := range multipliers {
out = append(out, domain.Tier{
Pool: pool,
TierID: pool + "_" + multiplierTierID(multiplierPPM),
RewardCoins: cost * multiplierPPM / ppmScale,
MultiplierPPM: multiplierPPM,
Weight: 0,
HighWaterOnly: highMultiplier > 0 && multiplierPPM >= highMultiplier*ppmScale,
Enabled: true,
})
}
}
return out
}
func defaultMultiplierPPMs() []int64 {
return []int64{
0,
200_000,
500_000,
1_000_000,
2_000_000,
5_000_000,
10_000_000,
20_000_000,
50_000_000,
100_000_000,
500_000_000,
}
}
func defaultTiers(cost int64) []domain.Tier {
return buildMultiplierTiers(cost, 100, defaultMultiplierPPMs())
}
func multiplierTierID(multiplierPPM int64) string {
if multiplierPPM == 0 {
return "none"
}
whole := multiplierPPM / ppmScale
fraction := multiplierPPM % ppmScale
if fraction == 0 {
return fmt.Sprintf("%dx", whole)
}
text := strings.TrimRight(strings.TrimRight(fmt.Sprintf("%d_%06dx", whole, fraction), "0"), "_")
return text
}
func normalizePoolID(poolID string) string {
poolID = strings.TrimSpace(poolID)
if poolID == "" {
return domain.DefaultPoolID
}
return poolID
}