feat(enakgame): game sessions, rewards, vouchers, budgets and events

EnakGame phases 1-8 of docs/tasks-enakgame.md (EG-101 to EG-803), built on the
existing EnakPoint/EnakCoin wallet (docs/rfc-enakgame.md).

Foundation (phase 1)
- Migrations 000103-000106: games extended with organization, slug, status,
  entry cost and result rules, old games archived (not deleted); budgets,
  versioned reward configs, sessions and session rewards; the ledger types
  GAME_SPEND_REFUND, GAME_REWARD and REWARD_REDEEM_REFUND; audit_logs.
- AuditLogger writes in the caller's transaction only.
- enakgame.limit.user_daily and global_daily organization settings.

Games and sessions (phases 2-4)
- Admin /marketing/enakgame: games, reward config versions (immutable but for
  status, one ACTIVE per game), budgets with non-overlapping global periods and
  a daily job opening the next month.
- Customer /customer/enakgame: start (Idempotency-Key, entry cost and config
  frozen on the session), complete (result validation, reward engine, max_reward
  cap, daily limits via game_reward_counters, one GAME_REWARD per budget),
  automatic refunds for system errors and deactivated games, and a session job.
- Reward engine: FIXED, SCORE_BASED, OUTCOME_BASED, PROBABILITY (crypto/rand),
  rounded down.

Vouchers and budgets (phases 5-6)
- Migration 000108 and 000107: vouchers, codes, redemptions, cost attribution;
  Economy Guard counters.
- STATIC and CODE_POOL redemption in one transaction with the REDEEM PIN action;
  realized cost traced through the lots to the budget that paid the reward.
- Budget metrics: realized cost, forecast, exposure and status. Migrations
  000109-000110 add the wallet_lots indexes they need, built CONCURRENTLY.

Events (phase 7)
- Migration 000111: game events, each with its own EVENT budget. Event extras
  stack per PRD §16 defaults, with event and per-customer limits.

External vouchers (phase 8)
- VoucherProvider contract, two-step PENDING redemption and a recovery job,
  tested with a fake provider. No provider adapter is registered yet, so
  EXTERNAL vouchers stay out of the catalog.

Not yet decided before release: reward rounding, event stacking, budget
exhaustion policy and thresholds (RFC §19.2). Migrations 000103-000111 have
not been run on any shared database.

Also fixes a leftover PAYMENT filter in a wallet test and a data race in a
test PIN fake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-07 20:53:14 +07:00
co-authored by Claude Opus 5.5
parent 2c9753fae7
commit 798a36bd6c
92 changed files with 12392 additions and 23 deletions
@@ -0,0 +1,146 @@
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)
}
}