2026-10-07 20:53:14 +07:00
|
|
|
|
package processor
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"bytes"
|
|
|
|
|
|
"crypto/rand"
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"errors"
|
|
|
|
|
|
"fmt"
|
|
|
|
|
|
"io"
|
2026-10-07 21:18:31 +07:00
|
|
|
|
"math"
|
2026-10-07 20:53:14 +07:00
|
|
|
|
"math/big"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
|
|
"apskel-pos-be/internal/constants"
|
2026-10-07 21:31:56 +07:00
|
|
|
|
"apskel-pos-be/internal/models"
|
2026-10-07 20:53:14 +07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
var (
|
|
|
|
|
|
// ErrInvalidRewardRules means a reward configuration's rules do not fit its type
|
|
|
|
|
|
// (docs/rfc-enakgame.md §8). Checked when the configuration is created.
|
|
|
|
|
|
ErrInvalidRewardRules = errors.New("invalid reward rules")
|
|
|
|
|
|
// ErrRewardResultUnusable means a result lacks what the rules need to price it: no
|
|
|
|
|
|
// score for SCORE_BASED, an outcome the rules do not list, a score outside every
|
|
|
|
|
|
// band. The session earns nothing and is flagged.
|
|
|
|
|
|
ErrRewardResultUnusable = errors.New("result cannot be rewarded")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// rewardWeightLimit bounds the total weight of a PROBABILITY table, far below where
|
|
|
|
|
|
// the sum could overflow.
|
|
|
|
|
|
const rewardWeightLimit = int64(1_000_000_000_000)
|
|
|
|
|
|
|
|
|
|
|
|
// SessionResult is what the client reports about a play: data only, never a reward
|
|
|
|
|
|
// amount (P1).
|
|
|
|
|
|
type SessionResult struct {
|
|
|
|
|
|
Score *int64 `json:"score,omitempty"`
|
|
|
|
|
|
Outcome *string `json:"outcome,omitempty"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RewardRNG draws the random number of a PROBABILITY reward.
|
|
|
|
|
|
type RewardRNG interface {
|
|
|
|
|
|
// Int63n returns a uniform number in [0, n).
|
|
|
|
|
|
Int63n(n int64) (int64, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// CryptoRewardRNG draws from crypto/rand, so the outcome cannot be predicted from the
|
|
|
|
|
|
// time the way a reseeded math/rand can (§8).
|
|
|
|
|
|
type CryptoRewardRNG struct{}
|
|
|
|
|
|
|
|
|
|
|
|
func (CryptoRewardRNG) Int63n(n int64) (int64, error) {
|
|
|
|
|
|
v, err := rand.Int(rand.Reader, big.NewInt(n))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return v.Int64(), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RewardCalculator computes the base reward of one reward type (§8). Base rewards are
|
|
|
|
|
|
// whole EnakCoin; event modifiers and the max_reward cap come after.
|
|
|
|
|
|
type RewardCalculator interface {
|
|
|
|
|
|
// Validate checks rules when a configuration is created.
|
|
|
|
|
|
Validate(rules json.RawMessage) error
|
|
|
|
|
|
// Calculate prices a result under rules that passed Validate. detail says how the
|
|
|
|
|
|
// amount was reached, for reward_breakdown.
|
|
|
|
|
|
Calculate(rules json.RawMessage, result SessionResult, rng RewardRNG) (base int64, detail map[string]any, err error)
|
2026-10-07 21:18:31 +07:00
|
|
|
|
// Scale multiplies every amount in rules by multiplier/10000, rounded down, for
|
|
|
|
|
|
// the Budget Controller (§10). Everything else, such as score bands and weights,
|
|
|
|
|
|
// stays.
|
|
|
|
|
|
Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// rewardMultiplierOne is a multiplier of 1 in the ten-thousandths Scale takes.
|
|
|
|
|
|
const rewardMultiplierOne = int64(10_000)
|
|
|
|
|
|
|
|
|
|
|
|
// scaleRewardAmount is amount × multiplier/10000, rounded down (RFC §19.2 #1).
|
|
|
|
|
|
func scaleRewardAmount(amount, multiplier int64) (int64, error) {
|
|
|
|
|
|
if multiplier < 0 {
|
|
|
|
|
|
return 0, invalidRules("multiplier cannot be negative")
|
|
|
|
|
|
}
|
|
|
|
|
|
if amount != 0 && multiplier > math.MaxInt64/amount {
|
|
|
|
|
|
return 0, invalidRules("amount %d is too large to scale", amount)
|
|
|
|
|
|
}
|
|
|
|
|
|
return amount * multiplier / rewardMultiplierOne, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func scaleRewardAmounts(multiplier int64, amounts ...*int64) error {
|
|
|
|
|
|
for _, a := range amounts {
|
|
|
|
|
|
scaled, err := scaleRewardAmount(*a, multiplier)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
*a = scaled
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func marshalRules(v any) (json.RawMessage, error) {
|
|
|
|
|
|
b, err := json.Marshal(v)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return b, nil
|
2026-10-07 20:53:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var rewardCalculators = map[string]RewardCalculator{
|
|
|
|
|
|
constants.GameRewardTypeFixed: fixedReward{},
|
|
|
|
|
|
constants.GameRewardTypeScoreBased: scoreBasedReward{},
|
|
|
|
|
|
constants.GameRewardTypeOutcomeBased: outcomeBasedReward{},
|
|
|
|
|
|
constants.GameRewardTypeProbability: probabilityReward{},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// RewardCalculatorFor returns the calculator of a reward type.
|
|
|
|
|
|
func RewardCalculatorFor(rewardType string) (RewardCalculator, error) {
|
|
|
|
|
|
c, ok := rewardCalculators[rewardType]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return nil, fmt.Errorf("%w: unknown reward type %q", ErrInvalidRewardRules, rewardType)
|
|
|
|
|
|
}
|
|
|
|
|
|
return c, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func invalidRules(format string, args ...any) error {
|
|
|
|
|
|
return fmt.Errorf("%w: %s", ErrInvalidRewardRules, fmt.Sprintf(format, args...))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func unusableResult(format string, args ...any) error {
|
|
|
|
|
|
return fmt.Errorf("%w: %s", ErrRewardResultUnusable, fmt.Sprintf(format, args...))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// decodeRules reads rules strictly: an unknown field is a typo, not something to
|
|
|
|
|
|
// ignore.
|
|
|
|
|
|
func decodeRules(rules json.RawMessage, v any) error {
|
|
|
|
|
|
dec := json.NewDecoder(bytes.NewReader(rules))
|
|
|
|
|
|
dec.DisallowUnknownFields()
|
|
|
|
|
|
if err := dec.Decode(v); err != nil {
|
|
|
|
|
|
return invalidRules("%v", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
if _, err := dec.Token(); err != io.EOF {
|
|
|
|
|
|
return invalidRules("unexpected data after the rules")
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// FIXED: {"amount": 5}.
|
|
|
|
|
|
type fixedReward struct{}
|
|
|
|
|
|
|
|
|
|
|
|
type fixedRules struct {
|
|
|
|
|
|
Amount *int64 `json:"amount"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (fixedReward) parse(rules json.RawMessage) (fixedRules, error) {
|
|
|
|
|
|
var r fixedRules
|
|
|
|
|
|
if err := decodeRules(rules, &r); err != nil {
|
|
|
|
|
|
return r, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.Amount == nil || *r.Amount < 0 {
|
|
|
|
|
|
return r, invalidRules("amount must be 0 or more")
|
|
|
|
|
|
}
|
|
|
|
|
|
return r, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (f fixedReward) Validate(rules json.RawMessage) error {
|
|
|
|
|
|
_, err := f.parse(rules)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (f fixedReward) Calculate(rules json.RawMessage, _ SessionResult, _ RewardRNG) (int64, map[string]any, error) {
|
|
|
|
|
|
r, err := f.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return *r.Amount, map[string]any{"reward_type": constants.GameRewardTypeFixed}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 21:18:31 +07:00
|
|
|
|
func (f fixedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
|
|
|
|
|
|
r, err := f.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := scaleRewardAmounts(multiplier, r.Amount); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
return marshalRules(r)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 20:53:14 +07:00
|
|
|
|
// SCORE_BASED: {"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}.
|
|
|
|
|
|
// Bands run in order from 0, each starting right after the previous one ends; only the
|
|
|
|
|
|
// last may leave max out, to cover every higher score.
|
|
|
|
|
|
type scoreBasedReward struct{}
|
|
|
|
|
|
|
|
|
|
|
|
type scoreBand struct {
|
2026-10-07 21:18:31 +07:00
|
|
|
|
Min *int64 `json:"min"`
|
|
|
|
|
|
// Left out of the last band, also when scaled rules are written back.
|
|
|
|
|
|
Max *int64 `json:"max,omitempty"`
|
2026-10-07 20:53:14 +07:00
|
|
|
|
Amount *int64 `json:"amount"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type scoreRules struct {
|
|
|
|
|
|
Bands []scoreBand `json:"bands"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (scoreBasedReward) parse(rules json.RawMessage) (scoreRules, error) {
|
|
|
|
|
|
var r scoreRules
|
|
|
|
|
|
if err := decodeRules(rules, &r); err != nil {
|
|
|
|
|
|
return r, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(r.Bands) == 0 {
|
|
|
|
|
|
return r, invalidRules("at least one band is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
for i, b := range r.Bands {
|
|
|
|
|
|
if b.Min == nil || b.Amount == nil {
|
|
|
|
|
|
return r, invalidRules("band %d needs min and amount", i+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if *b.Amount < 0 {
|
|
|
|
|
|
return r, invalidRules("band %d amount must be 0 or more", i+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if b.Max != nil && *b.Max < *b.Min {
|
|
|
|
|
|
return r, invalidRules("band %d max is below its min", i+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if i == 0 {
|
|
|
|
|
|
if *b.Min != 0 {
|
|
|
|
|
|
return r, invalidRules("the first band must start at 0")
|
|
|
|
|
|
}
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
prev := r.Bands[i-1]
|
|
|
|
|
|
if prev.Max == nil {
|
|
|
|
|
|
return r, invalidRules("only the last band may leave max out")
|
|
|
|
|
|
}
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case *b.Min <= *prev.Max:
|
|
|
|
|
|
return r, invalidRules("band %d overlaps band %d", i+1, i)
|
|
|
|
|
|
case *b.Min > *prev.Max+1:
|
|
|
|
|
|
return r, invalidRules("scores %d to %d are in no band", *prev.Max+1, *b.Min-1)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return r, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s scoreBasedReward) Validate(rules json.RawMessage) error {
|
|
|
|
|
|
_, err := s.parse(rules)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s scoreBasedReward) Calculate(rules json.RawMessage, result SessionResult, _ RewardRNG) (int64, map[string]any, error) {
|
|
|
|
|
|
r, err := s.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.Score == nil {
|
|
|
|
|
|
return 0, nil, unusableResult("a score is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
score := *result.Score
|
|
|
|
|
|
for i, b := range r.Bands {
|
|
|
|
|
|
if score >= *b.Min && (b.Max == nil || score <= *b.Max) {
|
|
|
|
|
|
return *b.Amount, map[string]any{"reward_type": constants.GameRewardTypeScoreBased, "score": score, "band": i + 1}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0, nil, unusableResult("score %d is in no band", score)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 21:18:31 +07:00
|
|
|
|
func (s scoreBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
|
|
|
|
|
|
r, err := s.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, b := range r.Bands {
|
|
|
|
|
|
if err := scaleRewardAmounts(multiplier, b.Amount); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return marshalRules(r)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 20:53:14 +07:00
|
|
|
|
// OUTCOME_BASED: {"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}.
|
|
|
|
|
|
type outcomeBasedReward struct{}
|
|
|
|
|
|
|
|
|
|
|
|
type outcomeRules struct {
|
|
|
|
|
|
Outcomes map[string]int64 `json:"outcomes"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (outcomeBasedReward) parse(rules json.RawMessage) (outcomeRules, error) {
|
|
|
|
|
|
var r outcomeRules
|
|
|
|
|
|
if err := decodeRules(rules, &r); err != nil {
|
|
|
|
|
|
return r, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(r.Outcomes) == 0 {
|
|
|
|
|
|
return r, invalidRules("at least one outcome is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
for outcome, amount := range r.Outcomes {
|
|
|
|
|
|
if strings.TrimSpace(outcome) == "" {
|
|
|
|
|
|
return r, invalidRules("an outcome needs a name")
|
|
|
|
|
|
}
|
|
|
|
|
|
if amount < 0 {
|
|
|
|
|
|
return r, invalidRules("outcome %s amount must be 0 or more", outcome)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return r, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (o outcomeBasedReward) Validate(rules json.RawMessage) error {
|
|
|
|
|
|
_, err := o.parse(rules)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (o outcomeBasedReward) Calculate(rules json.RawMessage, result SessionResult, _ RewardRNG) (int64, map[string]any, error) {
|
|
|
|
|
|
r, err := o.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if result.Outcome == nil {
|
|
|
|
|
|
return 0, nil, unusableResult("an outcome is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
amount, ok := r.Outcomes[*result.Outcome]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return 0, nil, unusableResult("unknown outcome %q", *result.Outcome)
|
|
|
|
|
|
}
|
|
|
|
|
|
return amount, map[string]any{"reward_type": constants.GameRewardTypeOutcomeBased, "outcome": *result.Outcome}, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 21:18:31 +07:00
|
|
|
|
func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
|
|
|
|
|
|
r, err := o.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
for outcome, amount := range r.Outcomes {
|
|
|
|
|
|
scaled, err := scaleRewardAmount(amount, multiplier)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
r.Outcomes[outcome] = scaled
|
|
|
|
|
|
}
|
|
|
|
|
|
return marshalRules(r)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-07 20:53:14 +07:00
|
|
|
|
// PROBABILITY: {"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}.
|
|
|
|
|
|
// Weights are whole numbers, so no check depends on floating point (§8). The draw is
|
2026-10-07 21:31:56 +07:00
|
|
|
|
// kept in the detail for audit. An entry may carry a label, such as a spin wheel's
|
|
|
|
|
|
// segment ("Zonk", "Jackpot"); the customer sees the entries and the one drawn, never
|
|
|
|
|
|
// the weights.
|
2026-10-07 20:53:14 +07:00
|
|
|
|
type probabilityReward struct{}
|
|
|
|
|
|
|
2026-10-07 21:31:56 +07:00
|
|
|
|
// probabilityLabelLimit bounds a label's length.
|
|
|
|
|
|
const probabilityLabelLimit = 100
|
|
|
|
|
|
|
2026-10-07 20:53:14 +07:00
|
|
|
|
type probabilityEntry struct {
|
2026-10-07 21:31:56 +07:00
|
|
|
|
Weight *int64 `json:"weight"`
|
|
|
|
|
|
Amount *int64 `json:"amount"`
|
|
|
|
|
|
Label *string `json:"label,omitempty"`
|
2026-10-07 20:53:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type probabilityRules struct {
|
|
|
|
|
|
Table []probabilityEntry `json:"table"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (probabilityReward) parse(rules json.RawMessage) (probabilityRules, int64, error) {
|
|
|
|
|
|
var r probabilityRules
|
|
|
|
|
|
if err := decodeRules(rules, &r); err != nil {
|
|
|
|
|
|
return r, 0, err
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(r.Table) == 0 {
|
|
|
|
|
|
return r, 0, invalidRules("at least one entry is required")
|
|
|
|
|
|
}
|
|
|
|
|
|
var total int64
|
|
|
|
|
|
for i, e := range r.Table {
|
|
|
|
|
|
if e.Weight == nil || e.Amount == nil {
|
|
|
|
|
|
return r, 0, invalidRules("entry %d needs weight and amount", i+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if *e.Weight < 1 {
|
|
|
|
|
|
return r, 0, invalidRules("entry %d weight must be at least 1", i+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
if *e.Amount < 0 {
|
|
|
|
|
|
return r, 0, invalidRules("entry %d amount must be 0 or more", i+1)
|
|
|
|
|
|
}
|
2026-10-07 21:31:56 +07:00
|
|
|
|
if e.Label != nil && (strings.TrimSpace(*e.Label) == "" || len(*e.Label) > probabilityLabelLimit) {
|
|
|
|
|
|
return r, 0, invalidRules("entry %d label must not be empty, at most %d characters", i+1, probabilityLabelLimit)
|
|
|
|
|
|
}
|
2026-10-07 20:53:14 +07:00
|
|
|
|
total += *e.Weight
|
|
|
|
|
|
if total > rewardWeightLimit {
|
|
|
|
|
|
return r, 0, invalidRules("the weights add up to more than %d", rewardWeightLimit)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return r, total, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (p probabilityReward) Validate(rules json.RawMessage) error {
|
|
|
|
|
|
_, _, err := p.parse(rules)
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng RewardRNG) (int64, map[string]any, error) {
|
|
|
|
|
|
r, total, err := p.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
roll, err := rng.Int63n(total)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return 0, nil, fmt.Errorf("failed to draw a reward: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
cumulative := int64(0)
|
|
|
|
|
|
for i, e := range r.Table {
|
|
|
|
|
|
cumulative += *e.Weight
|
|
|
|
|
|
if roll < cumulative {
|
2026-10-07 21:31:56 +07:00
|
|
|
|
detail := map[string]any{
|
2026-10-07 20:53:14 +07:00
|
|
|
|
"reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1,
|
2026-10-07 21:31:56 +07:00
|
|
|
|
}
|
|
|
|
|
|
if e.Label != nil {
|
|
|
|
|
|
detail["label"] = *e.Label
|
|
|
|
|
|
}
|
|
|
|
|
|
return *e.Amount, detail, nil
|
2026-10-07 20:53:14 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total)
|
|
|
|
|
|
}
|
2026-10-07 21:18:31 +07:00
|
|
|
|
|
|
|
|
|
|
// Scale changes the prizes, not their odds.
|
|
|
|
|
|
func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
|
|
|
|
|
|
r, _, err := p.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, e := range r.Table {
|
|
|
|
|
|
if err := scaleRewardAmounts(multiplier, e.Amount); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return marshalRules(r)
|
|
|
|
|
|
}
|
2026-10-07 21:31:56 +07:00
|
|
|
|
|
|
|
|
|
|
// probabilityPrizes lists the entries of PROBABILITY rules as the customer sees them:
|
|
|
|
|
|
// in order, without their weights.
|
|
|
|
|
|
func probabilityPrizes(rules json.RawMessage) ([]models.GamePrize, error) {
|
|
|
|
|
|
r, _, err := probabilityReward{}.parse(rules)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
prizes := make([]models.GamePrize, 0, len(r.Table))
|
|
|
|
|
|
for i, e := range r.Table {
|
|
|
|
|
|
prizes = append(prizes, models.GamePrize{Entry: i + 1, Label: e.Label, Amount: *e.Amount})
|
|
|
|
|
|
}
|
|
|
|
|
|
return prizes, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// drawnPrize is the entry a PROBABILITY reward drew, read back from its detail; nil
|
|
|
|
|
|
// for another type or when nothing was drawn.
|
|
|
|
|
|
func drawnPrize(rewardType string, base int64, detail map[string]any) *models.GamePrize {
|
|
|
|
|
|
if rewardType != constants.GameRewardTypeProbability {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
// A number read back from JSON is a float64; one just drawn is an int.
|
|
|
|
|
|
var entry int
|
|
|
|
|
|
switch v := detail["entry"].(type) {
|
|
|
|
|
|
case int:
|
|
|
|
|
|
entry = v
|
|
|
|
|
|
case float64:
|
|
|
|
|
|
entry = int(v)
|
|
|
|
|
|
default:
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
prize := &models.GamePrize{Entry: entry, Amount: base}
|
|
|
|
|
|
if label, ok := detail["label"].(string); ok {
|
|
|
|
|
|
prize.Label = &label
|
|
|
|
|
|
}
|
|
|
|
|
|
return prize
|
|
|
|
|
|
}
|