feat(enakgame): budget controller recommendations and analytics

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>
This commit is contained in:
efrilm
2026-10-07 21:18:31 +07:00
co-authored by Claude Opus 5.5
parent 798a36bd6c
commit 296708244e
28 changed files with 1647 additions and 25 deletions
+94 -2
View File
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"io"
"math"
"math/big"
"strings"
@@ -60,6 +61,43 @@ type RewardCalculator interface {
// 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{
@@ -131,14 +169,26 @@ func (f fixedReward) Calculate(rules json.RawMessage, _ SessionResult, _ RewardR
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"`
Max *int64 `json:"max"`
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"`
}
@@ -206,6 +256,19 @@ func (s scoreBasedReward) Calculate(rules json.RawMessage, result SessionResult,
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{}
@@ -252,6 +315,21 @@ func (o outcomeBasedReward) Calculate(rules json.RawMessage, result SessionResul
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.
@@ -318,3 +396,17 @@ func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng
}
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)
}