EnakGame phase 9 of docs/tasks-enakgame.md (EG-901 to EG-903). Budget Controller (EG-901, EG-902) - GET /marketing/enakgame/budgets/:id/recommendation, GLOBAL budgets only: the multiplier (budget − realized) / (forecast − realized), within one step of 1, rounded down to two decimals, either way. Shows each game's new rules. - POST .../recommendation/accept with the multiplier the admin saw: recomputed in the transaction, then one new ACTIVE version per game, the old one RETIRED, audited with source budget_controller and RECOMMENDATION_ACCEPTED on the budget. - Migration 000112: base_config_id, multiplier and budget_id on game_reward_configs. Rules are always scaled from the admin's last version, so rounding does not compound and min/max are against what the admin set. - Guardrails in game_budgets.thresholds: max_step_percent 10, min/max multiplier 50-150%, cooldown_days 7 per organization. Provisional pending RFC §19.2 #4. - RewardCalculator.Scale for the four reward types: amounts only, rounded down. Analytics (EG-903) - GET /marketing/enakgame/analytics/games and /analytics/economy over a range of Asia/Jakarta days (at most 366), from game_sessions and the wallet ledger. - Migrations 000113 (game_sessions by organization and start) and 000114 (wallet_transactions by organization and time, CONCURRENTLY). The Postgres tests for accepting and analytics were not run: no test database here. Migrations 000112-000114 have not been run anywhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
12 KiB
Go
413 lines
12 KiB
Go
package processor
|
||
|
||
import (
|
||
"bytes"
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"math"
|
||
"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)
|
||
// Scale multiplies every amount in rules by multiplier/10000, rounded down, for
|
||
// the Budget Controller (§10). Everything else, such as score bands and weights,
|
||
// stays.
|
||
Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error)
|
||
}
|
||
|
||
// rewardMultiplierOne is a multiplier of 1 in the ten-thousandths Scale takes.
|
||
const rewardMultiplierOne = int64(10_000)
|
||
|
||
// scaleRewardAmount is amount × multiplier/10000, rounded down (RFC §19.2 #1).
|
||
func scaleRewardAmount(amount, multiplier int64) (int64, error) {
|
||
if multiplier < 0 {
|
||
return 0, invalidRules("multiplier cannot be negative")
|
||
}
|
||
if amount != 0 && multiplier > math.MaxInt64/amount {
|
||
return 0, invalidRules("amount %d is too large to scale", amount)
|
||
}
|
||
return amount * multiplier / rewardMultiplierOne, nil
|
||
}
|
||
|
||
func scaleRewardAmounts(multiplier int64, amounts ...*int64) error {
|
||
for _, a := range amounts {
|
||
scaled, err := scaleRewardAmount(*a, multiplier)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
*a = scaled
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func marshalRules(v any) (json.RawMessage, error) {
|
||
b, err := json.Marshal(v)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return b, nil
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func (f fixedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
|
||
r, err := f.parse(rules)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := scaleRewardAmounts(multiplier, r.Amount); err != nil {
|
||
return nil, err
|
||
}
|
||
return marshalRules(r)
|
||
}
|
||
|
||
// 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"`
|
||
// Left out of the last band, also when scaled rules are written back.
|
||
Max *int64 `json:"max,omitempty"`
|
||
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)
|
||
}
|
||
|
||
func (s scoreBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
|
||
r, err := s.parse(rules)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, b := range r.Bands {
|
||
if err := scaleRewardAmounts(multiplier, b.Amount); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return marshalRules(r)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
|
||
r, err := o.parse(rules)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for outcome, amount := range r.Outcomes {
|
||
scaled, err := scaleRewardAmount(amount, multiplier)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
r.Outcomes[outcome] = scaled
|
||
}
|
||
return marshalRules(r)
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// Scale changes the prizes, not their odds.
|
||
func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json.RawMessage, error) {
|
||
r, _, err := p.parse(rules)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, e := range r.Table {
|
||
if err := scaleRewardAmounts(multiplier, e.Amount); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
return marshalRules(r)
|
||
}
|