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
This commit is contained in:
2026-05-28 23:26:46 -03:00
parent f60ca423d0
commit 8632251f8a
3 changed files with 177 additions and 4 deletions
+34
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"fmt"
"time"
"financeiro-carvalho/internal/model"
@@ -21,6 +22,10 @@ type GameRepo interface {
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 {
@@ -137,6 +142,33 @@ 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()
@@ -153,6 +185,7 @@ func (s *GameService) NotifyAction(ctx context.Context, action string, extra map
_ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1)
}
s.tryUnlockAchievement(ctx, "first_transaction")
s.CheckCategoryBudgets(ctx, monthlyPeriod)
case "import_confirmed":
count, _ := extra["count"].(int)
@@ -162,6 +195,7 @@ func (s *GameService) NotifyAction(ctx context.Context, action string, extra map
_, _ = 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)
+77 -2
View File
@@ -9,14 +9,20 @@ import (
)
type mockGameRepo struct {
profile model.PlayerProfile
achievements map[string]bool
profile model.PlayerProfile
achievements map[string]bool
ensuredCats map[int]string
removedCats []int
catBudgets []model.CategoryBudgetInfo
catSpend map[int]float64
}
func newMockGameRepo() *mockGameRepo {
return &mockGameRepo{
profile: model.PlayerProfile{ID: 1, Level: 1, XP: 0, XPToNext: 100},
achievements: map[string]bool{},
ensuredCats: map[int]string{},
catSpend: map[int]float64{},
}
}
@@ -52,6 +58,20 @@ func (m *mockGameRepo) UnlockAchievement(_ context.Context, c string) (int, erro
func (m *mockGameRepo) ListCosmetics(_ context.Context) ([]model.Cosmetic, error) { return nil, nil }
func (m *mockGameRepo) EquipCosmetic(_ context.Context, _ int) error { return nil }
func (m *mockGameRepo) UnlockCosmeticsForLevel(_ context.Context, _ int) error { return nil }
func (m *mockGameRepo) EnsureCategoryAchievement(_ context.Context, catID int, name string) error {
m.ensuredCats[catID] = name
return nil
}
func (m *mockGameRepo) RemoveCategoryAchievementIfNotEarned(_ context.Context, catID int) error {
m.removedCats = append(m.removedCats, catID)
return nil
}
func (m *mockGameRepo) ListCategoriesWithBudget(_ context.Context) ([]model.CategoryBudgetInfo, error) {
return m.catBudgets, nil
}
func (m *mockGameRepo) GetMonthlySpendByCategory(_ context.Context, _ string, catID int) (float64, error) {
return m.catSpend[catID], nil
}
func TestGame_XPAccumulates(t *testing.T) {
repo := newMockGameRepo()
@@ -96,3 +116,58 @@ func TestGame_AchievementNotDuplicated(t *testing.T) {
t.Error("achievement should be marked unlocked")
}
}
func TestGame_EnsureCategoryAchievementOnBudgetSet(t *testing.T) {
repo := newMockGameRepo()
svc := service.NewGameService(repo)
budget := 800.0
svc.OnCategoryBudgetSet(context.Background(), 5, "Alimentação", &budget)
if repo.ensuredCats[5] != "Alimentação" {
t.Errorf("expected EnsureCategoryAchievement called for cat 5, got %v", repo.ensuredCats)
}
if len(repo.removedCats) != 0 {
t.Error("expected no removal when budget is set")
}
}
func TestGame_RemoveCategoryAchievementOnBudgetNull(t *testing.T) {
repo := newMockGameRepo()
svc := service.NewGameService(repo)
svc.OnCategoryBudgetSet(context.Background(), 5, "Alimentação", nil)
if len(repo.removedCats) == 0 || repo.removedCats[0] != 5 {
t.Errorf("expected RemoveCategoryAchievementIfNotEarned for cat 5, got %v", repo.removedCats)
}
if len(repo.ensuredCats) != 0 {
t.Error("expected no ensure when budget is nil")
}
}
func TestGame_CategoryBudgetUnlock(t *testing.T) {
repo := newMockGameRepo()
repo.catBudgets = []model.CategoryBudgetInfo{{CategoryID: 5, MonthlyBudget: 800}}
repo.catSpend[5] = 750
svc := service.NewGameService(repo)
svc.CheckCategoryBudgets(context.Background(), "2026-05")
if !repo.achievements["budget_cat_5"] {
t.Error("expected budget_cat_5 unlocked when spend 750 <= budget 800")
}
}
func TestGame_CategoryBudgetNoUnlockWhenOverBudget(t *testing.T) {
repo := newMockGameRepo()
repo.catBudgets = []model.CategoryBudgetInfo{{CategoryID: 5, MonthlyBudget: 800}}
repo.catSpend[5] = 850
svc := service.NewGameService(repo)
svc.CheckCategoryBudgets(context.Background(), "2026-05")
if repo.achievements["budget_cat_5"] {
t.Error("expected budget_cat_5 NOT unlocked when spend 850 > budget 800")
}
}