Files
apskel-pos-backend/internal/processor/reward_calculator.go
T
efrilmandClaude Opus 5.5 18e87398bb feat(enakgame): spin as an EnakGame game; remove the old game flow
EnakGame phase 10 of docs/tasks-enakgame.md (EG-1001 to EG-1003).

Spin (EG-1001)
- PROBABILITY entries take an optional label (a wheel segment). The customer game
  list shows a PROBABILITY game's prizes (entry, label, amount, never weights), and
  completing returns the drawn prize, so the client can draw the wheel and stop it
  on the server's draw.
- docs/enakgame-spin.md: the admin steps to set up spin per organization (no
  seeder) and the customer app flow. An HTTP test plays it end to end.

Old game flow removed (EG-1002)
- Routes POST /customer/spin, GET /customer/games, GET /customer/ferris-wheel, and
  admin /marketing/games, /marketing/game-prizes, /marketing/rewards, with their
  handlers, services, processors, repositories, validators, models, contracts,
  mappers and tests (GamePlayProcessor, SpinGameService, rewards, ...). This also
  closes RFC §15 findings 1 and 2 (double charge, spinning another org's game).
- Tables games, game_prizes, game_plays and rewards stay for ledger history.
  entities.StringSlice moves to its own file; the omset tracker (unrouted) keeps
  game_id but no longer embeds the old game response.

games.is_active dropped (EG-1003)
- Migration 000115; nothing reads metadata.coin_cost any more.

The EnakPoint integration docs now point at /customer/enakgame. The Postgres tests
were not run: no test database here. Migration 000115 has not been run anywhere.
The customer app must stop calling the removed endpoints before this is deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:31:56 +07:00

464 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package processor
import (
"bytes"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"math/big"
"strings"
"apskel-pos-be/internal/constants"
"apskel-pos-be/internal/models"
)
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. An entry may carry a label, such as a spin wheel's
// segment ("Zonk", "Jackpot"); the customer sees the entries and the one drawn, never
// the weights.
type probabilityReward struct{}
// probabilityLabelLimit bounds a label's length.
const probabilityLabelLimit = 100
type probabilityEntry struct {
Weight *int64 `json:"weight"`
Amount *int64 `json:"amount"`
Label *string `json:"label,omitempty"`
}
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)
}
if e.Label != nil && (strings.TrimSpace(*e.Label) == "" || len(*e.Label) > probabilityLabelLimit) {
return r, 0, invalidRules("entry %d label must not be empty, at most %d characters", i+1, probabilityLabelLimit)
}
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 {
detail := map[string]any{
"reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1,
}
if e.Label != nil {
detail["label"] = *e.Label
}
return *e.Amount, detail, 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)
}
// probabilityPrizes lists the entries of PROBABILITY rules as the customer sees them:
// in order, without their weights.
func probabilityPrizes(rules json.RawMessage) ([]models.GamePrize, error) {
r, _, err := probabilityReward{}.parse(rules)
if err != nil {
return nil, err
}
prizes := make([]models.GamePrize, 0, len(r.Table))
for i, e := range r.Table {
prizes = append(prizes, models.GamePrize{Entry: i + 1, Label: e.Label, Amount: *e.Amount})
}
return prizes, nil
}
// drawnPrize is the entry a PROBABILITY reward drew, read back from its detail; nil
// for another type or when nothing was drawn.
func drawnPrize(rewardType string, base int64, detail map[string]any) *models.GamePrize {
if rewardType != constants.GameRewardTypeProbability {
return nil
}
// A number read back from JSON is a float64; one just drawn is an int.
var entry int
switch v := detail["entry"].(type) {
case int:
entry = v
case float64:
entry = int(v)
default:
return nil
}
prize := &models.GamePrize{Entry: entry, Amount: base}
if label, ok := detail["label"].(string); ok {
prize.Label = &label
}
return prize
}