package processor import ( "encoding/json" "math" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "apskel-pos-be/internal/constants" ) // fixedRNG returns the given draws in turn. type fixedRNG struct{ draws []int64 } func (r *fixedRNG) Int63n(n int64) (int64, error) { v := r.draws[0] r.draws = r.draws[1:] return v % n, nil } func calculate(t *testing.T, rewardType, rules string, result SessionResult, rng RewardRNG) (int64, map[string]any, error) { t.Helper() c, err := RewardCalculatorFor(rewardType) require.NoError(t, err) require.NoError(t, c.Validate(json.RawMessage(rules)), "the example rules are valid") return c.Calculate(json.RawMessage(rules), result, rng) } // The examples of docs/enakgame-prd.md §12. func TestRewardCalculator_PRDExamples(t *testing.T) { t.Run("FIXED", func(t *testing.T) { base, _, err := calculate(t, constants.GameRewardTypeFixed, `{"amount": 5}`, SessionResult{}, nil) require.NoError(t, err) assert.EqualValues(t, 5, base) }) t.Run("SCORE_BASED", func(t *testing.T) { rules := `{"bands": [ {"min": 0, "max": 100, "amount": 1}, {"min": 101, "max": 500, "amount": 5}, {"min": 501, "max": 1000, "amount": 10}, {"min": 1001, "amount": 20}]}` for score, want := range map[int64]int64{0: 1, 100: 1, 101: 5, 500: 5, 501: 10, 1000: 10, 1001: 20, 99999: 20} { score := score base, detail, err := calculate(t, constants.GameRewardTypeScoreBased, rules, SessionResult{Score: &score}, nil) require.NoError(t, err) assert.Equal(t, want, base, "score %d", score) assert.Equal(t, score, detail["score"]) } negative := int64(-1) _, _, err := calculate(t, constants.GameRewardTypeScoreBased, rules, SessionResult{Score: &negative}, nil) assert.ErrorIs(t, err, ErrRewardResultUnusable) _, _, err = calculate(t, constants.GameRewardTypeScoreBased, rules, SessionResult{}, nil) assert.ErrorIs(t, err, ErrRewardResultUnusable, "no score") capped := `{"bands": [{"min": 0, "max": 10, "amount": 1}]}` over := int64(11) _, _, err = calculate(t, constants.GameRewardTypeScoreBased, capped, SessionResult{Score: &over}, nil) assert.ErrorIs(t, err, ErrRewardResultUnusable, "above the last band's max") }) t.Run("OUTCOME_BASED", func(t *testing.T) { rules := `{"outcomes": {"PERFECT": 20, "GOOD": 10, "NORMAL": 5, "FAIL": 0}}` for outcome, want := range map[string]int64{"PERFECT": 20, "GOOD": 10, "NORMAL": 5, "FAIL": 0} { outcome := outcome base, detail, err := calculate(t, constants.GameRewardTypeOutcomeBased, rules, SessionResult{Outcome: &outcome}, nil) require.NoError(t, err) assert.Equal(t, want, base, outcome) assert.Equal(t, outcome, detail["outcome"]) } unknown := "LEGENDARY" _, _, err := calculate(t, constants.GameRewardTypeOutcomeBased, rules, SessionResult{Outcome: &unknown}, nil) assert.ErrorIs(t, err, ErrRewardResultUnusable) }) t.Run("PROBABILITY", func(t *testing.T) { // 0.1% 1000, 1% 100, 10% 10, 88.9% 0, as whole weights out of 1000. rules := `{"table": [{"weight": 1, "amount": 1000}, {"weight": 10, "amount": 100}, {"weight": 100, "amount": 10}, {"weight": 889, "amount": 0}]}` for roll, want := range map[int64]int64{0: 1000, 1: 100, 10: 100, 11: 10, 110: 10, 111: 0, 999: 0} { base, detail, err := calculate(t, constants.GameRewardTypeProbability, rules, SessionResult{}, &fixedRNG{draws: []int64{roll}}) require.NoError(t, err) assert.Equal(t, want, base, "roll %d", roll) assert.Equal(t, roll, detail["roll"], "the draw is kept for audit") assert.EqualValues(t, 1000, detail["total_weight"]) } }) } func TestRewardCalculator_ProbabilityDistribution(t *testing.T) { rules := json.RawMessage(`{"table": [{"weight": 1, "amount": 1000}, {"weight": 10, "amount": 100}, {"weight": 100, "amount": 10}, {"weight": 889, "amount": 0}]}`) c, err := RewardCalculatorFor(constants.GameRewardTypeProbability) require.NoError(t, err) const draws = 100_000 counts := map[int64]int{} for i := 0; i < draws; i++ { base, _, err := c.Calculate(rules, SessionResult{}, CryptoRewardRNG{}) require.NoError(t, err) counts[base]++ } // Within five standard deviations of the expected share: a correct table fails // this about once in a few million runs. for amount, p := range map[int64]float64{1000: 0.001, 100: 0.01, 10: 0.1, 0: 0.889} { got := float64(counts[amount]) / draws tolerance := 5 * math.Sqrt(p*(1-p)/draws) assert.InDelta(t, p, got, tolerance, "amount %d", amount) } } func TestRewardCalculator_RejectsInvalidRules(t *testing.T) { for name, tc := range map[string]struct{ rewardType, rules string }{ "unknown type": {"TIERED", `{}`}, "not JSON": {constants.GameRewardTypeFixed, `{amount: 5}`}, "unknown field": {constants.GameRewardTypeFixed, `{"amount": 5, "amonut": 6}`}, "trailing data": {constants.GameRewardTypeFixed, `{"amount": 5} {}`}, "FIXED without amount": {constants.GameRewardTypeFixed, `{}`}, "FIXED negative": {constants.GameRewardTypeFixed, `{"amount": -1}`}, "FIXED fraction": {constants.GameRewardTypeFixed, `{"amount": 1.5}`}, "no bands": {constants.GameRewardTypeScoreBased, `{"bands": []}`}, "bands not from 0": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 1, "amount": 1}]}`}, "bands overlap": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 100, "amount": 2}]}`}, "bands leave a gap": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 102, "amount": 2}]}`}, "open band not last": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "amount": 1}, {"min": 101, "amount": 2}]}`}, "band max below min": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": -1, "amount": 1}]}`}, "band out of order": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 501, "amount": 3}, {"min": 101, "max": 500, "amount": 2}]}`}, "band without amount": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0}]}`}, "band negative amount": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "amount": -2}]}`}, "no outcomes": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {}}`}, "blank outcome": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {" ": 1}}`}, "negative outcome": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {"WIN": -1}}`}, "empty table": {constants.GameRewardTypeProbability, `{"table": []}`}, "zero weight": {constants.GameRewardTypeProbability, `{"table": [{"weight": 0, "amount": 1}, {"weight": 1, "amount": 0}]}`}, "negative weight": {constants.GameRewardTypeProbability, `{"table": [{"weight": -1, "amount": 1}, {"weight": 2, "amount": 0}]}`}, "percent weight": {constants.GameRewardTypeProbability, `{"table": [{"weight": 0.1, "amount": 1000}]}`}, "entry without weight": {constants.GameRewardTypeProbability, `{"table": [{"amount": 1}]}`}, "negative probability pay": {constants.GameRewardTypeProbability, `{"table": [{"weight": 1, "amount": -1}]}`}, "weights overflow": {constants.GameRewardTypeProbability, `{"table": [{"weight": 9000000000000000000, "amount": 1}, {"weight": 9000000000000000000, "amount": 0}]}`}, } { c, err := RewardCalculatorFor(tc.rewardType) if err == nil { err = c.Validate(json.RawMessage(tc.rules)) } assert.ErrorIs(t, err, ErrInvalidRewardRules, name) } } // EG-902: the Budget Controller scales amounts, rounded down, and nothing else. func TestRewardCalculator_Scale(t *testing.T) { for name, tc := range map[string]struct { rewardType, rules string multiplier int64 want string }{ "FIXED ×0.9": {constants.GameRewardTypeFixed, `{"amount": 10}`, 9_000, `{"amount":9}`}, "FIXED rounds": {constants.GameRewardTypeFixed, `{"amount": 5}`, 9_000, `{"amount":4}`}, "FIXED to zero": {constants.GameRewardTypeFixed, `{"amount": 1}`, 9_000, `{"amount":0}`}, "FIXED ×1.1": {constants.GameRewardTypeFixed, `{"amount": 10}`, 11_000, `{"amount":11}`}, "FIXED ×1": {constants.GameRewardTypeFixed, `{"amount": 7}`, 10_000, `{"amount":7}`}, "SCORE_BASED": {constants.GameRewardTypeScoreBased, `{"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}`, 8_100, `{"bands":[{"min":0,"max":100,"amount":0},{"min":101,"amount":16}]}`}, "OUTCOME_BASED": {constants.GameRewardTypeOutcomeBased, `{"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}`, 9_000, `{"outcomes":{"FAIL":0,"GOOD":9,"PERFECT":18}}`}, "PROBABILITY": {constants.GameRewardTypeProbability, `{"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}`, 8_500, `{"table":[{"weight":1,"amount":850},{"weight":999,"amount":0}]}`}, } { c, err := RewardCalculatorFor(tc.rewardType) require.NoError(t, err) scaled, err := c.Scale(json.RawMessage(tc.rules), tc.multiplier) require.NoError(t, err, name) assert.JSONEq(t, tc.want, string(scaled), name) assert.NoError(t, c.Validate(scaled), "%s: scaled rules stay valid", name) } c, _ := RewardCalculatorFor(constants.GameRewardTypeFixed) _, err := c.Scale(json.RawMessage(`{"amount": 9223372036854775807}`), 15_000) assert.ErrorIs(t, err, ErrInvalidRewardRules, "overflow is refused, not wrapped") _, err = c.Scale(json.RawMessage(`{"amount": -1}`), 9_000) assert.ErrorIs(t, err, ErrInvalidRewardRules) v, err := scaleRewardAmount(math.MaxInt64/15_000, 15_000) require.NoError(t, err) assert.EqualValues(t, math.MaxInt64/15_000*15_000/10_000, v) }