Files
carvalho-finances/apps/api/internal/service/game.go
T
Mlcavalho1 8632251f8a feat(game): #49 motor de conquistas dinâmicas por categoria
- GameRepo: EnsureCategoryAchievement, RemoveCategoryAchievementIfNotEarned, ListCategoriesWithBudget, GetMonthlySpendByCategory
- GameService: OnCategoryBudgetSet, CheckCategoryBudgets
- ListAchievements filtra conquistas por categorias do perfil (sem vazamento entre usuários)
- NotifyAction chama CheckCategoryBudgets em transaction_created e import_confirmed
- CategoryHandler injeta GameService e chama OnCategoryBudgetSet após Create/Update
- 5 novos testes unitários cobrindo todos os cenários de AC
2026-05-28 23:26:46 -03:00

213 lines
6.6 KiB
Go

package service
import (
"context"
"fmt"
"time"
"financeiro-carvalho/internal/model"
)
type GameRepo interface {
GetProfile(ctx context.Context) (*model.PlayerProfile, error)
AddXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error)
ListActiveQuests(ctx context.Context) ([]model.PlayerQuest, error)
EnsurePlayerQuest(ctx context.Context, questID int, period string) (int, error)
IncrementQuestCount(ctx context.Context, questType, period string, delta int) error
UpdateQuestPct(ctx context.Context, period string, pct float64) error
ClaimQuest(ctx context.Context, playerQuestID int) (int, error)
ListAchievements(ctx context.Context) ([]model.Achievement, error)
IsAchievementUnlocked(ctx context.Context, condition string) (bool, error)
UnlockAchievement(ctx context.Context, condition string) (int, error)
ListCosmetics(ctx context.Context) ([]model.Cosmetic, error)
EquipCosmetic(ctx context.Context, cosmeticID int) error
UnlockCosmeticsForLevel(ctx context.Context, level int) error
EnsureCategoryAchievement(ctx context.Context, categoryID int, categoryName string) error
RemoveCategoryAchievementIfNotEarned(ctx context.Context, categoryID int) error
ListCategoriesWithBudget(ctx context.Context) ([]model.CategoryBudgetInfo, error)
GetMonthlySpendByCategory(ctx context.Context, month string, categoryID int) (float64, error)
}
type GameService struct {
repo GameRepo
}
func NewGameService(repo GameRepo) *GameService {
return &GameService{repo: repo}
}
func (s *GameService) GetProfile(ctx context.Context) (*model.PlayerProfile, error) {
return s.repo.GetProfile(ctx)
}
func (s *GameService) AwardXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error) {
prevProfile, _ := s.repo.GetProfile(ctx)
profile, err := s.repo.AddXP(ctx, eventType, amount, description)
if err != nil {
return nil, err
}
// unlock cosmetics if levelled up
if prevProfile != nil && profile.Level > prevProfile.Level {
_ = s.repo.UnlockCosmeticsForLevel(ctx, profile.Level)
if profile.Level >= 5 {
s.tryUnlockAchievement(ctx, "level_5")
}
if profile.Level >= 10 {
s.tryUnlockAchievement(ctx, "level_10")
}
}
return profile, nil
}
func (s *GameService) tryUnlockAchievement(ctx context.Context, condition string) {
unlocked, _ := s.repo.IsAchievementUnlocked(ctx, condition)
if unlocked {
return
}
xp, err := s.repo.UnlockAchievement(ctx, condition)
if err == nil && xp > 0 {
_, _ = s.repo.AddXP(ctx, "achievement_"+condition, xp, "Achievement desbloqueado")
}
}
func (s *GameService) CheckAndUnlockAchievement(ctx context.Context, condition string) {
s.tryUnlockAchievement(ctx, condition)
}
func (s *GameService) GetSummary(ctx context.Context) (*model.GameSummary, error) {
profile, err := s.repo.GetProfile(ctx)
if err != nil {
return nil, err
}
quests, err := s.repo.ListActiveQuests(ctx)
if err != nil {
return nil, err
}
if quests == nil {
quests = []model.PlayerQuest{}
}
achievements, err := s.repo.ListAchievements(ctx)
if err != nil {
return nil, err
}
recent := []model.Achievement{}
for _, a := range achievements {
if a.Earned {
recent = append(recent, a)
if len(recent) >= 3 {
break
}
}
}
cosmetics, err := s.repo.ListCosmetics(ctx)
if err != nil {
return nil, err
}
equipped := []model.Cosmetic{}
for _, c := range cosmetics {
if c.Equipped {
equipped = append(equipped, c)
}
}
return &model.GameSummary{
Profile: *profile,
ActiveQuests: quests,
RecentAchievements: recent,
EquippedCosmetics: equipped,
}, nil
}
func (s *GameService) ClaimQuest(ctx context.Context, playerQuestID int) error {
xp, err := s.repo.ClaimQuest(ctx, playerQuestID)
if err != nil || xp == 0 {
return err
}
_, err = s.AwardXP(ctx, "quest_claimed", xp, "Quest concluída")
return err
}
func (s *GameService) ListAllAchievements(ctx context.Context) ([]model.Achievement, error) {
return s.repo.ListAchievements(ctx)
}
func (s *GameService) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) {
return s.repo.ListCosmetics(ctx)
}
func (s *GameService) EquipCosmetic(ctx context.Context, cosmeticID int) error {
return s.repo.EquipCosmetic(ctx, cosmeticID)
}
// OnCategoryBudgetSet gera ou remove a conquista dinâmica ao salvar/limpar a meta mensal de uma categoria.
func (s *GameService) OnCategoryBudgetSet(ctx context.Context, categoryID int, categoryName string, budget *float64) {
if budget == nil {
_ = s.repo.RemoveCategoryAchievementIfNotEarned(ctx, categoryID)
return
}
_ = s.repo.EnsureCategoryAchievement(ctx, categoryID, categoryName)
}
// CheckCategoryBudgets verifica todas as categorias com meta do perfil e desbloqueia conquistas quando gasto ≤ meta.
func (s *GameService) CheckCategoryBudgets(ctx context.Context, month string) {
cats, err := s.repo.ListCategoriesWithBudget(ctx)
if err != nil {
return
}
for _, cat := range cats {
total, err := s.repo.GetMonthlySpendByCategory(ctx, month, cat.CategoryID)
if err != nil {
continue
}
if total <= cat.MonthlyBudget {
condition := fmt.Sprintf("budget_cat_%d", cat.CategoryID)
s.tryUnlockAchievement(ctx, condition)
}
}
}
// NotifyAction is called by other handlers after key financial actions to award XP and update quests.
func (s *GameService) NotifyAction(ctx context.Context, action string, extra map[string]any) {
now := time.Now()
monthlyPeriod := now.Format("2006-01")
weeklyPeriod := now.Format("2006") + "-W" + now.Format("01")
dailyPeriod := now.Format("2006-01-02")
switch action {
case "transaction_created":
_, _ = s.AwardXP(ctx, "transaction_created", 5, "Transação registrada")
if catID, ok := extra["category_id"]; ok && catID != nil {
_, _ = s.AwardXP(ctx, "transaction_categorized", 10, "Transação categorizada")
_ = s.repo.IncrementQuestCount(ctx, "daily", dailyPeriod, 1)
_ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1)
}
s.tryUnlockAchievement(ctx, "first_transaction")
s.CheckCategoryBudgets(ctx, monthlyPeriod)
case "import_confirmed":
count, _ := extra["count"].(int)
if count < 1 {
count = 1
}
_, _ = s.AwardXP(ctx, "import_confirmed", 50, "Extrato importado")
_ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1)
s.tryUnlockAchievement(ctx, "first_import")
s.CheckCategoryBudgets(ctx, monthlyPeriod)
case "savings_checked":
pct, _ := extra["pct"].(float64)
goal, _ := extra["goal"].(float64)
if goal <= 0 {
goal = 40
}
_ = s.repo.UpdateQuestPct(ctx, monthlyPeriod, pct)
if pct >= goal {
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de poupança atingida!")
s.tryUnlockAchievement(ctx, "first_40pct")
}
}
}