Files
apskel-pos-backend/internal/processor/reward_calculator_test.go
T
efrilmandClaude Opus 5.5 18e87398bb feat(enakgame): spin as an EnakGame game; remove the old game flow
EnakGame phase 10 of docs/tasks-enakgame.md (EG-1001 to EG-1003).

Spin (EG-1001)
- PROBABILITY entries take an optional label (a wheel segment). The customer game
  list shows a PROBABILITY game's prizes (entry, label, amount, never weights), and
  completing returns the drawn prize, so the client can draw the wheel and stop it
  on the server's draw.
- docs/enakgame-spin.md: the admin steps to set up spin per organization (no
  seeder) and the customer app flow. An HTTP test plays it end to end.

Old game flow removed (EG-1002)
- Routes POST /customer/spin, GET /customer/games, GET /customer/ferris-wheel, and
  admin /marketing/games, /marketing/game-prizes, /marketing/rewards, with their
  handlers, services, processors, repositories, validators, models, contracts,
  mappers and tests (GamePlayProcessor, SpinGameService, rewards, ...). This also
  closes RFC §15 findings 1 and 2 (double charge, spinning another org's game).
- Tables games, game_prizes, game_plays and rewards stay for ledger history.
  entities.StringSlice moves to its own file; the omset tracker (unrouted) keeps
  game_id but no longer embeds the old game response.

games.is_active dropped (EG-1003)
- Migration 000115; nothing reads metadata.coin_cost any more.

The EnakPoint integration docs now point at /customer/enakgame. The Postgres tests
were not run: no test database here. Migration 000115 has not been run anywhere.
The customer app must stop calling the removed endpoints before this is deployed.

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

230 lines
12 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"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
)
// 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)
}
// 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)
}
}