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:
co-authored by
Claude Opus 5.5
parent
798a36bd6c
commit
296708244e
+165
-2
@@ -84,13 +84,26 @@ type GameRewardConfig struct {
|
||||
EffectiveAt *time.Time `json:"effective_at"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
Reason *string `json:"reason"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
// Set on a version made by accepting a Budget Controller recommendation: the
|
||||
// admin's version it scales, by how much, and for which budget.
|
||||
BaseConfigID *uuid.UUID `json:"base_config_id"`
|
||||
Multiplier *float64 `json:"multiplier"`
|
||||
BudgetID *uuid.UUID `json:"budget_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32).
|
||||
// GameBudgetThresholds are percents of utilization or forecast (PRD §8, §32), and the
|
||||
// guardrails of the Budget Controller (PRD §31).
|
||||
type GameBudgetThresholds struct {
|
||||
Warning *int64 `json:"warning,omitempty"`
|
||||
Critical *int64 `json:"critical,omitempty"`
|
||||
// Percent one recommendation may move rewards by, either way.
|
||||
MaxStepPercent *int64 `json:"max_step_percent,omitempty"`
|
||||
// Bounds of a game's multiplier, in percent of the configuration its admin wrote.
|
||||
MinMultiplierPercent *int64 `json:"min_multiplier_percent,omitempty"`
|
||||
MaxMultiplierPercent *int64 `json:"max_multiplier_percent,omitempty"`
|
||||
// Days after an accepted recommendation before the organization gets another.
|
||||
CooldownDays *int64 `json:"cooldown_days,omitempty"`
|
||||
}
|
||||
|
||||
// GameBudgetInput creates or changes a budget (§5.6). Dates are YYYY-MM-DD and
|
||||
@@ -368,6 +381,156 @@ type GameBudgetExposure struct {
|
||||
Points int64 `json:"points"`
|
||||
}
|
||||
|
||||
// GameBudgetRecommendation is what the Budget Controller suggests for a global budget
|
||||
// (docs/rfc-enakgame.md §10, PRD §29–§31): a multiplier on every game's reward that
|
||||
// brings the forecast to the budget, within the guardrails.
|
||||
type GameBudgetRecommendation struct {
|
||||
BudgetID uuid.UUID `json:"budget_id"`
|
||||
// RECOMMENDED, NO_CHANGE, COOLDOWN, AT_LIMIT, INSUFFICIENT_DATA or OUT_OF_PERIOD.
|
||||
// Only RECOMMENDED can be accepted.
|
||||
State string `json:"state"`
|
||||
Message string `json:"message"`
|
||||
|
||||
Metrics GameBudgetMetrics `json:"metrics"`
|
||||
// The guardrails used: the budget's own, or the defaults for those it does not set.
|
||||
Guardrails GameBudgetThresholds `json:"guardrails"`
|
||||
|
||||
// The multiplier that would make the forecast meet the budget, before the
|
||||
// guardrails; nil when there is no cost to extrapolate.
|
||||
TargetMultiplier *float64 `json:"target_multiplier"`
|
||||
// The target within one step, rounded down to two decimals. Accepting sends it back.
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
// When the organization may accept again, during a cooldown.
|
||||
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
|
||||
// The games whose reward would change, each with its new version.
|
||||
Games []GameRewardAdjustment `json:"games"`
|
||||
}
|
||||
|
||||
// GameRewardAdjustment is how accepting a recommendation changes a game's reward: a
|
||||
// new version of its active configuration, with the base's amounts scaled.
|
||||
type GameRewardAdjustment struct {
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
// The active version, and the admin's version both are measured against.
|
||||
RewardConfigID uuid.UUID `json:"reward_config_id"`
|
||||
Version int `json:"version"`
|
||||
BaseConfigID uuid.UUID `json:"base_config_id"`
|
||||
RewardType string `json:"reward_type"`
|
||||
|
||||
CurrentMultiplier float64 `json:"current_multiplier"`
|
||||
NewMultiplier float64 `json:"new_multiplier"`
|
||||
CurrentRules json.RawMessage `json:"current_rules"`
|
||||
NewRules json.RawMessage `json:"new_rules"`
|
||||
CurrentMaxReward int64 `json:"current_max_reward"`
|
||||
NewMaxReward int64 `json:"new_max_reward"`
|
||||
}
|
||||
|
||||
// GameBudgetRecommendationAcceptInput accepts the recommendation the admin saw. When
|
||||
// the recommendation changed since, nothing is applied.
|
||||
type GameBudgetRecommendationAcceptInput struct {
|
||||
Multiplier *float64 `json:"multiplier"`
|
||||
Reason *string `json:"reason"`
|
||||
}
|
||||
|
||||
// GameBudgetRecommendationAccepted is what accepting made: one active version per
|
||||
// game.
|
||||
type GameBudgetRecommendationAccepted struct {
|
||||
BudgetID uuid.UUID `json:"budget_id"`
|
||||
Multiplier float64 `json:"multiplier"`
|
||||
RewardConfigs []GameRewardConfig `json:"reward_configs"`
|
||||
}
|
||||
|
||||
// EnakGameAnalyticsQuery is a range of days in Asia/Jakarta, both ends included.
|
||||
type EnakGameAnalyticsQuery struct {
|
||||
From string `form:"from"`
|
||||
To string `form:"to"`
|
||||
// Games analytics only: one game instead of all.
|
||||
GameID string `form:"game_id"`
|
||||
}
|
||||
|
||||
// EnakGameAnalytics is how an organization's games were played over a range of days
|
||||
// (PRD §36 Game), by the day each session started.
|
||||
type EnakGameAnalytics struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Totals EnakGameStats `json:"totals"`
|
||||
Games []EnakGameGameStats `json:"games"`
|
||||
}
|
||||
|
||||
type EnakGameGameStats struct {
|
||||
GameID uuid.UUID `json:"game_id"`
|
||||
GameName string `json:"game_name"`
|
||||
EnakGameStats
|
||||
}
|
||||
|
||||
type EnakGameStats struct {
|
||||
// Sessions started, whatever became of them.
|
||||
Plays int64 `json:"plays"`
|
||||
Completed int64 `json:"completed"`
|
||||
Refunded int64 `json:"refunded"`
|
||||
Expired int64 `json:"expired"`
|
||||
Flagged int64 `json:"flagged"`
|
||||
// Customers who started at least one session.
|
||||
Players int64 `json:"players"`
|
||||
// Over completed sessions that reported a score; nil when none did.
|
||||
AverageScore *float64 `json:"average_score"`
|
||||
// EnakCoin per completed session, and per play.
|
||||
AverageReward float64 `json:"average_reward"`
|
||||
RewardPerPlay float64 `json:"reward_per_play"`
|
||||
CoinIssued int64 `json:"coin_issued"`
|
||||
// EnakCoin paid to start, and the part refunded.
|
||||
EntryCostPaid int64 `json:"entry_cost_paid"`
|
||||
CoinRefunded int64 `json:"coin_refunded"`
|
||||
}
|
||||
|
||||
// EnakGameEconomyAnalytics is how EnakCoin and EnakPoint moved in an organization over
|
||||
// a range of days (PRD §36 Economy), from the ledger.
|
||||
type EnakGameEconomyAnalytics struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Coin EnakGameCoinFlows `json:"coin"`
|
||||
Point EnakGamePointFlows `json:"point"`
|
||||
// Every ledger type that moved, for what the headline numbers leave out.
|
||||
ByType []WalletFlowTotals `json:"by_type"`
|
||||
}
|
||||
|
||||
type EnakGameCoinFlows struct {
|
||||
// New EnakCoin: game rewards, earning less its reversals, migration and upward
|
||||
// adjustments.
|
||||
Generated int64 `json:"generated"`
|
||||
GameRewards int64 `json:"game_rewards"`
|
||||
// Entry costs, less the refunded ones.
|
||||
SpentOnGames int64 `json:"spent_on_games"`
|
||||
// Exchanged into EnakPoint.
|
||||
Exchanged int64 `json:"exchanged"`
|
||||
// SpentOnGames + Exchanged.
|
||||
Spent int64 `json:"spent"`
|
||||
Expired int64 `json:"expired"`
|
||||
// Held by customers at the end of the range.
|
||||
Outstanding int64 `json:"outstanding"`
|
||||
}
|
||||
|
||||
type EnakGamePointFlows struct {
|
||||
// From shopping, less its reversals.
|
||||
Earned int64 `json:"earned"`
|
||||
// Received from exchanging EnakCoin.
|
||||
Exchanged int64 `json:"exchanged"`
|
||||
// Spent on vouchers, less the refunded redemptions.
|
||||
Redeemed int64 `json:"redeemed"`
|
||||
Expired int64 `json:"expired"`
|
||||
// Held by customers at the end of the range.
|
||||
Balance int64 `json:"balance"`
|
||||
}
|
||||
|
||||
// WalletFlowTotals is what one ledger type moved in one currency.
|
||||
type WalletFlowTotals struct {
|
||||
Currency string `json:"currency"`
|
||||
Type string `json:"type"`
|
||||
Credit int64 `json:"credit"`
|
||||
Debit int64 `json:"debit"`
|
||||
Transactions int64 `json:"transactions"`
|
||||
}
|
||||
|
||||
// GameEventInput creates or changes an event (§5.5). On a change, fields left out
|
||||
// keep their value.
|
||||
type GameEventInput struct {
|
||||
|
||||
Reference in New Issue
Block a user