Files
apskel-pos-backend/internal/processor/reward_calculator.go
T
efrilmandClaude Opus 5.5 798a36bd6c 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>
2026-10-07 20:53:14 +07:00

321 lines
9.4 KiB
Go

package processor
import (
"bytes"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"strings"
"apskel-pos-be/internal/constants"
)
var (
// ErrInvalidRewardRules means a reward configuration's rules do not fit its type
// (docs/rfc-enakgame.md §8). Checked when the configuration is created.
ErrInvalidRewardRules = errors.New("invalid reward rules")
// ErrRewardResultUnusable means a result lacks what the rules need to price it: no
// score for SCORE_BASED, an outcome the rules do not list, a score outside every
// band. The session earns nothing and is flagged.
ErrRewardResultUnusable = errors.New("result cannot be rewarded")
)
// rewardWeightLimit bounds the total weight of a PROBABILITY table, far below where
// the sum could overflow.
const rewardWeightLimit = int64(1_000_000_000_000)
// SessionResult is what the client reports about a play: data only, never a reward
// amount (P1).
type SessionResult struct {
Score *int64 `json:"score,omitempty"`
Outcome *string `json:"outcome,omitempty"`
}
// RewardRNG draws the random number of a PROBABILITY reward.
type RewardRNG interface {
// Int63n returns a uniform number in [0, n).
Int63n(n int64) (int64, error)
}
// CryptoRewardRNG draws from crypto/rand, so the outcome cannot be predicted from the
// time the way a reseeded math/rand can (§8).
type CryptoRewardRNG struct{}
func (CryptoRewardRNG) Int63n(n int64) (int64, error) {
v, err := rand.Int(rand.Reader, big.NewInt(n))
if err != nil {
return 0, err
}
return v.Int64(), nil
}
// RewardCalculator computes the base reward of one reward type (§8). Base rewards are
// whole EnakCoin; event modifiers and the max_reward cap come after.
type RewardCalculator interface {
// Validate checks rules when a configuration is created.
Validate(rules json.RawMessage) error
// Calculate prices a result under rules that passed Validate. detail says how the
// amount was reached, for reward_breakdown.
Calculate(rules json.RawMessage, result SessionResult, rng RewardRNG) (base int64, detail map[string]any, err error)
}
var rewardCalculators = map[string]RewardCalculator{
constants.GameRewardTypeFixed: fixedReward{},
constants.GameRewardTypeScoreBased: scoreBasedReward{},
constants.GameRewardTypeOutcomeBased: outcomeBasedReward{},
constants.GameRewardTypeProbability: probabilityReward{},
}
// RewardCalculatorFor returns the calculator of a reward type.
func RewardCalculatorFor(rewardType string) (RewardCalculator, error) {
c, ok := rewardCalculators[rewardType]
if !ok {
return nil, fmt.Errorf("%w: unknown reward type %q", ErrInvalidRewardRules, rewardType)
}
return c, nil
}
func invalidRules(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrInvalidRewardRules, fmt.Sprintf(format, args...))
}
func unusableResult(format string, args ...any) error {
return fmt.Errorf("%w: %s", ErrRewardResultUnusable, fmt.Sprintf(format, args...))
}
// decodeRules reads rules strictly: an unknown field is a typo, not something to
// ignore.
func decodeRules(rules json.RawMessage, v any) error {
dec := json.NewDecoder(bytes.NewReader(rules))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return invalidRules("%v", err)
}
if _, err := dec.Token(); err != io.EOF {
return invalidRules("unexpected data after the rules")
}
return nil
}
// FIXED: {"amount": 5}.
type fixedReward struct{}
type fixedRules struct {
Amount *int64 `json:"amount"`
}
func (fixedReward) parse(rules json.RawMessage) (fixedRules, error) {
var r fixedRules
if err := decodeRules(rules, &r); err != nil {
return r, err
}
if r.Amount == nil || *r.Amount < 0 {
return r, invalidRules("amount must be 0 or more")
}
return r, nil
}
func (f fixedReward) Validate(rules json.RawMessage) error {
_, err := f.parse(rules)
return err
}
func (f fixedReward) Calculate(rules json.RawMessage, _ SessionResult, _ RewardRNG) (int64, map[string]any, error) {
r, err := f.parse(rules)
if err != nil {
return 0, nil, err
}
return *r.Amount, map[string]any{"reward_type": constants.GameRewardTypeFixed}, nil
}
// SCORE_BASED: {"bands": [{"min": 0, "max": 100, "amount": 1}, {"min": 101, "amount": 20}]}.
// Bands run in order from 0, each starting right after the previous one ends; only the
// last may leave max out, to cover every higher score.
type scoreBasedReward struct{}
type scoreBand struct {
Min *int64 `json:"min"`
Max *int64 `json:"max"`
Amount *int64 `json:"amount"`
}
type scoreRules struct {
Bands []scoreBand `json:"bands"`
}
func (scoreBasedReward) parse(rules json.RawMessage) (scoreRules, error) {
var r scoreRules
if err := decodeRules(rules, &r); err != nil {
return r, err
}
if len(r.Bands) == 0 {
return r, invalidRules("at least one band is required")
}
for i, b := range r.Bands {
if b.Min == nil || b.Amount == nil {
return r, invalidRules("band %d needs min and amount", i+1)
}
if *b.Amount < 0 {
return r, invalidRules("band %d amount must be 0 or more", i+1)
}
if b.Max != nil && *b.Max < *b.Min {
return r, invalidRules("band %d max is below its min", i+1)
}
if i == 0 {
if *b.Min != 0 {
return r, invalidRules("the first band must start at 0")
}
continue
}
prev := r.Bands[i-1]
if prev.Max == nil {
return r, invalidRules("only the last band may leave max out")
}
switch {
case *b.Min <= *prev.Max:
return r, invalidRules("band %d overlaps band %d", i+1, i)
case *b.Min > *prev.Max+1:
return r, invalidRules("scores %d to %d are in no band", *prev.Max+1, *b.Min-1)
}
}
return r, nil
}
func (s scoreBasedReward) Validate(rules json.RawMessage) error {
_, err := s.parse(rules)
return err
}
func (s scoreBasedReward) Calculate(rules json.RawMessage, result SessionResult, _ RewardRNG) (int64, map[string]any, error) {
r, err := s.parse(rules)
if err != nil {
return 0, nil, err
}
if result.Score == nil {
return 0, nil, unusableResult("a score is required")
}
score := *result.Score
for i, b := range r.Bands {
if score >= *b.Min && (b.Max == nil || score <= *b.Max) {
return *b.Amount, map[string]any{"reward_type": constants.GameRewardTypeScoreBased, "score": score, "band": i + 1}, nil
}
}
return 0, nil, unusableResult("score %d is in no band", score)
}
// OUTCOME_BASED: {"outcomes": {"PERFECT": 20, "GOOD": 10, "FAIL": 0}}.
type outcomeBasedReward struct{}
type outcomeRules struct {
Outcomes map[string]int64 `json:"outcomes"`
}
func (outcomeBasedReward) parse(rules json.RawMessage) (outcomeRules, error) {
var r outcomeRules
if err := decodeRules(rules, &r); err != nil {
return r, err
}
if len(r.Outcomes) == 0 {
return r, invalidRules("at least one outcome is required")
}
for outcome, amount := range r.Outcomes {
if strings.TrimSpace(outcome) == "" {
return r, invalidRules("an outcome needs a name")
}
if amount < 0 {
return r, invalidRules("outcome %s amount must be 0 or more", outcome)
}
}
return r, nil
}
func (o outcomeBasedReward) Validate(rules json.RawMessage) error {
_, err := o.parse(rules)
return err
}
func (o outcomeBasedReward) Calculate(rules json.RawMessage, result SessionResult, _ RewardRNG) (int64, map[string]any, error) {
r, err := o.parse(rules)
if err != nil {
return 0, nil, err
}
if result.Outcome == nil {
return 0, nil, unusableResult("an outcome is required")
}
amount, ok := r.Outcomes[*result.Outcome]
if !ok {
return 0, nil, unusableResult("unknown outcome %q", *result.Outcome)
}
return amount, map[string]any{"reward_type": constants.GameRewardTypeOutcomeBased, "outcome": *result.Outcome}, nil
}
// PROBABILITY: {"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}.
// Weights are whole numbers, so no check depends on floating point (§8). The draw is
// kept in the detail for audit.
type probabilityReward struct{}
type probabilityEntry struct {
Weight *int64 `json:"weight"`
Amount *int64 `json:"amount"`
}
type probabilityRules struct {
Table []probabilityEntry `json:"table"`
}
func (probabilityReward) parse(rules json.RawMessage) (probabilityRules, int64, error) {
var r probabilityRules
if err := decodeRules(rules, &r); err != nil {
return r, 0, err
}
if len(r.Table) == 0 {
return r, 0, invalidRules("at least one entry is required")
}
var total int64
for i, e := range r.Table {
if e.Weight == nil || e.Amount == nil {
return r, 0, invalidRules("entry %d needs weight and amount", i+1)
}
if *e.Weight < 1 {
return r, 0, invalidRules("entry %d weight must be at least 1", i+1)
}
if *e.Amount < 0 {
return r, 0, invalidRules("entry %d amount must be 0 or more", i+1)
}
total += *e.Weight
if total > rewardWeightLimit {
return r, 0, invalidRules("the weights add up to more than %d", rewardWeightLimit)
}
}
return r, total, nil
}
func (p probabilityReward) Validate(rules json.RawMessage) error {
_, _, err := p.parse(rules)
return err
}
func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng RewardRNG) (int64, map[string]any, error) {
r, total, err := p.parse(rules)
if err != nil {
return 0, nil, err
}
roll, err := rng.Int63n(total)
if err != nil {
return 0, nil, fmt.Errorf("failed to draw a reward: %w", err)
}
cumulative := int64(0)
for i, e := range r.Table {
cumulative += *e.Weight
if roll < cumulative {
return *e.Amount, map[string]any{
"reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1,
}, nil
}
}
return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total)
}