2026-10-07 19:40:26 +03:30
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
2026-10-10 16:42:35 +03:30
|
|
|
"strconv"
|
2026-10-07 19:40:26 +03:30
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"pargar/backend/internal/config"
|
|
|
|
|
"pargar/backend/internal/models"
|
|
|
|
|
"pargar/backend/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-10 16:42:35 +03:30
|
|
|
// HeartsService manages a regenerating heart pool.
|
|
|
|
|
// Reaching zero waits for the next heart unless an admin turns lock-on-empty on.
|
2026-10-07 19:40:26 +03:30
|
|
|
type HeartsService struct {
|
|
|
|
|
store *store.Store
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 16:42:35 +03:30
|
|
|
type HeartPolicy struct {
|
|
|
|
|
Max int
|
|
|
|
|
Every time.Duration
|
|
|
|
|
LockEmpty bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-07 19:40:26 +03:30
|
|
|
func NewHeartsService(st *store.Store, _ *config.Config) *HeartsService {
|
|
|
|
|
return &HeartsService{store: st}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-10 16:42:35 +03:30
|
|
|
func (h *HeartsService) Policy(ctx context.Context) HeartPolicy {
|
|
|
|
|
p := HeartPolicy{Max: store.MaxHearts, Every: 4 * time.Hour, LockEmpty: false}
|
|
|
|
|
if v, err := h.store.GetSetting(ctx, "hearts_max"); err == nil {
|
|
|
|
|
if n, convErr := strconv.Atoi(v); convErr == nil && n >= 1 && n <= store.MaxHearts {
|
|
|
|
|
p.Max = n
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if v, err := h.store.GetSetting(ctx, "hearts_regen_minutes"); err == nil {
|
|
|
|
|
if n, convErr := strconv.Atoi(v); convErr == nil && n >= 5 && n <= 24*60 {
|
|
|
|
|
p.Every = time.Duration(n) * time.Minute
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if v, err := h.store.GetSetting(ctx, "hearts_lock_on_empty"); err == nil {
|
|
|
|
|
p.LockEmpty = v == "true" || v == "1"
|
|
|
|
|
}
|
|
|
|
|
return p
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// RefreshHearts adds hearts that have come due since hearts_updated_at.
|
|
|
|
|
func (h *HeartsService) RefreshHearts(ctx context.Context, u *models.User) (*models.User, error) {
|
|
|
|
|
if u == nil || u.Role.IsStaff() {
|
|
|
|
|
return u, nil
|
|
|
|
|
}
|
|
|
|
|
p := h.Policy(ctx)
|
|
|
|
|
if u.Hearts >= p.Max || u.HeartsUpdatedAt.IsZero() {
|
|
|
|
|
return u, nil
|
|
|
|
|
}
|
|
|
|
|
gain := int(time.Since(u.HeartsUpdatedAt) / p.Every)
|
|
|
|
|
if gain <= 0 {
|
|
|
|
|
return u, nil
|
|
|
|
|
}
|
|
|
|
|
next := u.Hearts + gain
|
|
|
|
|
if next > p.Max {
|
|
|
|
|
next = p.Max
|
|
|
|
|
}
|
|
|
|
|
if err := h.store.SetHearts(ctx, u.ID, next); err != nil {
|
|
|
|
|
return u, err
|
|
|
|
|
}
|
|
|
|
|
u.Hearts = next
|
|
|
|
|
u.HeartsUpdatedAt = time.Now()
|
2026-10-07 19:40:26 +03:30
|
|
|
return u, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ConsumeHearts decrements hearts for wrong answers; locks the account at 0.
|
|
|
|
|
// Admin and mentor accounts have infinite hearts and are never locked by this path.
|
|
|
|
|
// Returns the updated user and how many hearts were lost.
|
|
|
|
|
func (h *HeartsService) ConsumeHearts(ctx context.Context, u *models.User, loss int) (*models.User, int, error) {
|
|
|
|
|
if u.Role.IsStaff() {
|
|
|
|
|
return u, 0, nil
|
|
|
|
|
}
|
|
|
|
|
if loss < 0 {
|
|
|
|
|
loss = 0
|
|
|
|
|
}
|
|
|
|
|
prev := u.Hearts
|
|
|
|
|
cur := prev - loss
|
|
|
|
|
if cur < 0 {
|
|
|
|
|
cur = 0
|
|
|
|
|
}
|
|
|
|
|
actualLost := prev - cur
|
|
|
|
|
if err := h.store.SetHearts(ctx, u.ID, cur); err != nil {
|
|
|
|
|
return nil, 0, err
|
|
|
|
|
}
|
|
|
|
|
u.Hearts = cur
|
2026-10-10 16:42:35 +03:30
|
|
|
if cur == 0 && h.Policy(ctx).LockEmpty && !u.IsLocked {
|
2026-10-07 19:40:26 +03:30
|
|
|
locked, err := h.store.LockUser(ctx, u.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, 0, err
|
|
|
|
|
}
|
|
|
|
|
return locked, actualLost, nil
|
|
|
|
|
}
|
|
|
|
|
return u, actualLost, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Streak logic keyed on activity date.
|
|
|
|
|
func UpdateStreak(ctx context.Context, st *store.Store, u *models.User) (*models.User, error) {
|
|
|
|
|
today := time.Now()
|
|
|
|
|
current, err := st.GetUserByID(ctx, u.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
streak := current.StreakCurrent
|
|
|
|
|
longest := current.StreakLongest
|
|
|
|
|
if current.LastActivityDate != nil {
|
|
|
|
|
last := time.Date(current.LastActivityDate.Year(), current.LastActivityDate.Month(), current.LastActivityDate.Day(), 0, 0, 0, 0, time.UTC)
|
|
|
|
|
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
|
|
|
|
|
diff := int(todayDate.Sub(last).Hours() / 24)
|
|
|
|
|
switch {
|
|
|
|
|
case diff == 0:
|
|
|
|
|
// already active today
|
|
|
|
|
return current, nil
|
|
|
|
|
case diff == 1:
|
|
|
|
|
streak++
|
|
|
|
|
default:
|
|
|
|
|
streak = 1
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
streak = 1
|
|
|
|
|
}
|
|
|
|
|
if streak > longest {
|
|
|
|
|
longest = streak
|
|
|
|
|
}
|
|
|
|
|
lastDate := today
|
|
|
|
|
if err := st.UpdateStreakOnly(ctx, current.ID, streak, longest, &lastDate); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
current.StreakCurrent = streak
|
|
|
|
|
current.StreakLongest = longest
|
|
|
|
|
current.LastActivityDate = &lastDate
|
|
|
|
|
return current, nil
|
|
|
|
|
}
|