2026-10-07 20:53:14 +07:00
|
|
|
|
package processor
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"math"
|
2026-10-07 21:31:56 +07:00
|
|
|
|
"strings"
|
2026-10-07 20:53:14 +07:00
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
|
|
|
|
|
|
|
|
"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
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-10-07 21:18:31 +07:00
|
|
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-10-07 21:31:56 +07:00
|
|
|
|
|
|
|
|
|
|
// EG-1001: a spin wheel is a PROBABILITY game whose entries carry labels.
|
|
|
|
|
|
func TestRewardCalculator_SpinPrizes(t *testing.T) {
|
|
|
|
|
|
rules := json.RawMessage(`{"table": [
|
|
|
|
|
|
{"weight": 50, "amount": 0, "label": "Zonk"},
|
|
|
|
|
|
{"weight": 45, "amount": 5, "label": "5 Coin"},
|
|
|
|
|
|
{"weight": 5, "amount": 50}]}`)
|
|
|
|
|
|
c, err := RewardCalculatorFor(constants.GameRewardTypeProbability)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
require.NoError(t, c.Validate(rules))
|
|
|
|
|
|
|
|
|
|
|
|
prizes, err := probabilityPrizes(rules)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
assert.Equal(t, []models.GamePrize{
|
|
|
|
|
|
{Entry: 1, Label: ptr("Zonk"), Amount: 0}, {Entry: 2, Label: ptr("5 Coin"), Amount: 5}, {Entry: 3, Amount: 50},
|
|
|
|
|
|
}, prizes, "no weights")
|
|
|
|
|
|
|
|
|
|
|
|
base, detail, err := c.Calculate(rules, SessionResult{}, &fixedRNG{draws: []int64{60}})
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
assert.EqualValues(t, 5, base)
|
|
|
|
|
|
assert.Equal(t, &models.GamePrize{Entry: 2, Label: ptr("5 Coin"), Amount: 5}, drawnPrize(constants.GameRewardTypeProbability, base, detail))
|
|
|
|
|
|
|
|
|
|
|
|
// As read back from reward_breakdown.
|
|
|
|
|
|
stored, err := json.Marshal(detail)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
var back map[string]any
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(stored, &back))
|
|
|
|
|
|
assert.Equal(t, &models.GamePrize{Entry: 2, Label: ptr("5 Coin"), Amount: 5}, drawnPrize(constants.GameRewardTypeProbability, base, back))
|
|
|
|
|
|
|
|
|
|
|
|
_, detail, err = c.Calculate(rules, SessionResult{}, &fixedRNG{draws: []int64{99}})
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
assert.Equal(t, &models.GamePrize{Entry: 3, Amount: 50}, drawnPrize(constants.GameRewardTypeProbability, 50, detail), "no label")
|
|
|
|
|
|
assert.Nil(t, drawnPrize(constants.GameRewardTypeFixed, 5, map[string]any{"reward_type": "FIXED"}))
|
|
|
|
|
|
assert.Nil(t, drawnPrize(constants.GameRewardTypeProbability, 0, nil), "rejected: nothing drawn")
|
|
|
|
|
|
|
|
|
|
|
|
scaled, err := c.Scale(rules, 9_000)
|
|
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
assert.Contains(t, string(scaled), `"label":"Zonk"`, "scaling keeps the labels")
|
|
|
|
|
|
|
|
|
|
|
|
for name, bad := range map[string]string{
|
|
|
|
|
|
"empty label": `{"table": [{"weight": 1, "amount": 1, "label": " "}]}`,
|
|
|
|
|
|
"label too long": `{"table": [{"weight": 1, "amount": 1, "label": "` + strings.Repeat("x", 101) + `"}]}`,
|
|
|
|
|
|
} {
|
|
|
|
|
|
assert.ErrorIs(t, c.Validate(json.RawMessage(bad)), ErrInvalidRewardRules, name)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|