package processor import ( "bytes" "crypto/rand" "encoding/json" "errors" "fmt" "io" "math/big" "strings" "apskel-pos-be/internal/constants" ) 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) } 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 } // 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"` Max *int64 `json:"max"` 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) } // 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 } // 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. type probabilityReward struct{} type probabilityEntry struct { Weight *int64 `json:"weight"` Amount *int64 `json:"amount"` } 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) } 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 { return *e.Amount, map[string]any{ "reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1, }, nil } } return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total) }