package processor import ( "bytes" "crypto/rand" "encoding/json" "errors" "fmt" "io" "math" "math/big" "strings" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/models" ) 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) // 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 } 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 } 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) } // 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 { Min *int64 `json:"min"` // Left out of the last band, also when scaled rules are written back. Max *int64 `json:"max,omitempty"` 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) } 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) } // 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 } 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) } // 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 // 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. type probabilityReward struct{} // probabilityLabelLimit bounds a label's length. const probabilityLabelLimit = 100 type probabilityEntry struct { Weight *int64 `json:"weight"` Amount *int64 `json:"amount"` Label *string `json:"label,omitempty"` } 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) } 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) } 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 { detail := map[string]any{ "reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1, } if e.Label != nil { detail["label"] = *e.Label } return *e.Amount, detail, nil } } return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total) } // 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) } // 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 }