Files
apskel-pos-backend/internal/processor/reward_calculator_test.go
T
efrilmandClaude Opus 5.5 296708244e feat(enakgame): budget controller recommendations and analytics
EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903).

Budget Controller (EG-901, EG-902)
- GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the
  multiplier (budget − realized) / (forecast − realized), within one step of 1,
  rounded down to two decimals, either way. Shows each game's new rules.
- POST .../recommendation/accept with the multiplier the admin saw: recomputed in the
  transaction, then one new ACTIVE version per game, the old one RETIRED, audited
  with source budget_controller and RECOMMENDATION_ACCEPTED on the budget.
- Migration 000112: base_config_id, multiplier and budget_id on
  game_reward_configs. Rules are always scaled from the admin's last version, so
  rounding does not compound and min/max are against what the admin set.
- Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier
  50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4.
- RewardCalculator.Scale for the four reward types: amounts only, rounded down.

Analytics (EG-903)
- GET /marketing/enakgame/analytics/games and /analytics/economy over a range of
  Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger.
- Migrations 000113 (game_sessions by organization and start) and 000114
  (wallet_transactions by organization and time, CONCURRENTLY).

The Postgres tests for accepting and analytics were not run: no test database here.
Migrations 000112-000114 have not been run anywhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:18:31 +07:00

182 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}