Files
apskel-pos-backend/internal/processor/reward_calculator.go
T

464 lines
14 KiB
Go
Raw Normal View History

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
}