package processor import ( "context" "math" "sort" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" ) // The kinds of reward component (D6). const ( // The game's own reward, paid by the global budget. gameRewardKindBase = "BASE" // What one event adds, paid by the event's budget. gameRewardKindEvent = "EVENT" ) // The event limits (PRD §35), by the name the breakdown and the response give them. const ( gameLimitEvent = "EVENT_LIMIT" gameLimitEventUserDaily = "EVENT_USER_DAILY" ) // eventLifetimeDay is the day of an event's counter over its whole run (§5.8). var eventLifetimeDay = time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC) // eventUserScope is the counter of one customer in one event: a name derived from // both, since a counter has one scope id (§5.8). func eventUserScope(eventID, customerID uuid.UUID) uuid.UUID { return uuid.NewSHA1(eventID, customerID[:]) } // eventExtra is what an event adds to a base reward: base × (multiplier − 1), rounded // down to whole EnakCoin, plus the bonus. Each event works on the base, so events do // not multiply each other (PRD §16, "controlled stacking"). func eventExtra(base int64, e *entities.GameEvent) int64 { var extra int64 if e.Multiplier != nil { hundredths := int64(math.Round(*e.Multiplier * 100)) extra = base * (hundredths - 100) / 100 } if e.Bonus != nil { extra += *e.Bonus } return extra } // rewardComponents prices a believed base reward into its parts: the base, capped by // max_reward, then what each active event adds, highest priority first. The cap holds // for the total: what passes it is cut from the lowest priority event first. An event // adds nothing to a play that earned nothing. func rewardComponents(base, maxReward int64, events []entities.GameEvent) ([]gameRewardComponent, bool) { capped := false if maxReward > 0 && base > maxReward { base, capped = maxReward, true } if base <= 0 { return nil, capped } parts := []gameRewardComponent{{Kind: gameRewardKindBase, Earned: base, Amount: base}} total := base for i := range events { e := &events[i] extra := eventExtra(base, e) if extra <= 0 { continue } id := e.ID parts = append(parts, gameRewardComponent{ Kind: gameRewardKindEvent, BudgetID: e.BudgetID, EventID: &id, Multiplier: e.Multiplier, Bonus: e.Bonus, Earned: extra, Amount: extra, }) total += extra } if maxReward > 0 && total > maxReward { capped = true trimFromEnd(parts, total-maxReward, nil) } return parts, capped } // trimFromEnd takes cut off the parts, lowest priority event first and the base last. // release, when given, is told what came off each part. func trimFromEnd(parts []gameRewardComponent, cut int64, release func(*gameRewardComponent, int64) error) error { for i := len(parts) - 1; i >= 0 && cut > 0; i-- { d := parts[i].Amount if d > cut { d = cut } parts[i].Amount -= d cut -= d if release != nil && d > 0 { if err := release(&parts[i], d); err != nil { return err } } } return nil } // guardReward runs the parts of a reward through every limit (§9, PRD §35): first // each event's own limits on what it adds, then the daily limits on the total, which // cut events before the base. It returns the limits that cut. // // Counters are taken in one order for every completion (events by id, then user, // game, organization), so no two completions wait on each other the other way round. func (p *GameSessionProcessor) guardReward(ctx context.Context, session *entities.GameSession, game *entities.Game, events []entities.GameEvent, settings *models.OrganizationLoyaltySettings, now time.Time, parts []gameRewardComponent) ([]gameGuardCut, error) { byID := map[uuid.UUID]*entities.GameEvent{} for i := range events { byID[events[i].ID] = &events[i] } day := walletDay(now) order := make([]int, 0, len(parts)) for i := range parts { if parts[i].Kind == gameRewardKindEvent { order = append(order, i) } } sort.Slice(order, func(a, b int) bool { return parts[order[a]].EventID.String() < parts[order[b]].EventID.String() }) var cuts []gameGuardCut for _, i := range order { part := &parts[i] e := byID[*part.EventID] asked := part.Amount lifetime, err := p.counters.Consume(ctx, session.OrganizationID, constants.GameRewardScopeEvent, e.ID, eventLifetimeDay, asked, optionalLimit(e.RewardLimit)) if err != nil { return nil, err } if lifetime < asked { cuts = append(cuts, gameGuardCut{Limit: gameLimitEvent, EventID: part.EventID, Value: optionalLimit(e.RewardLimit), Asked: asked, Allowed: lifetime}) part.LimitedBy = append(part.LimitedBy, gameLimitEvent) } perUser, err := p.counters.Consume(ctx, session.OrganizationID, constants.GameRewardScopeEvent, eventUserScope(e.ID, session.CustomerID), day, lifetime, optionalLimit(e.UserDailyLimit)) if err != nil { return nil, err } if perUser < lifetime { cuts = append(cuts, gameGuardCut{Limit: gameLimitEventUserDaily, EventID: part.EventID, Value: optionalLimit(e.UserDailyLimit), Asked: lifetime, Allowed: perUser}) part.LimitedBy = append(part.LimitedBy, gameLimitEventUserDaily) if err := p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, e.ID, eventLifetimeDay, lifetime-perUser); err != nil { return nil, err } } part.Amount = perUser } total := sumComponents(parts) if total == 0 { return cuts, nil } allowed, daily, err := p.applyGuard(ctx, session, game, settings, now, total) if err != nil { return nil, err } cuts = append(cuts, daily...) // The event counters took what the daily limits then cut: give it back. err = trimFromEnd(parts, total-allowed, func(part *gameRewardComponent, d int64) error { if part.Kind != gameRewardKindEvent { return nil } if err := p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, *part.EventID, eventLifetimeDay, d); err != nil { return err } return p.counters.Release(ctx, session.OrganizationID, constants.GameRewardScopeEvent, eventUserScope(*part.EventID, session.CustomerID), day, d) }) return cuts, err } func optionalLimit(limit *int64) int64 { if limit == nil { return 0 } return *limit } func sumComponents(parts []gameRewardComponent) int64 { var total int64 for _, part := range parts { total += part.Amount } return total } // rewardParts sums a stored breakdown's components into what the customer sees. func rewardParts(b gameRewardBreakdown) models.GameSessionRewardParts { var out models.GameSessionRewardParts for _, c := range b.Components { if c.Kind == gameRewardKindEvent { out.Event += c.Amount } else { out.Base += c.Amount } } return out } // paymentsByBudget merges the components paid by the same budget, in order, since a // budget pays one ledger row per session (D6). func paymentsByBudget(parts []gameRewardComponent) []gameRewardComponent { var out []gameRewardComponent index := map[uuid.UUID]int{} for _, part := range parts { if part.Amount <= 0 { continue } if i, ok := index[part.BudgetID]; ok { out[i].Amount += part.Amount continue } index[part.BudgetID] = len(out) out = append(out, gameRewardComponent{BudgetID: part.BudgetID, Kind: part.Kind, Amount: part.Amount}) } return out }