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>
321 lines
9.4 KiB
Go
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)
|
|
}
|