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
+66 -2
View File
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -187,11 +188,13 @@ func (r *GameRepository) ClaimQuest(ctx context.Context, playerQuestID int) (int
func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievement, error) { func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievement, error) {
pid := middleware.ProfileIDFromCtx(ctx) pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.pool.Query(ctx, ` rows, err := r.pool.Query(ctx, `
SELECT a.id, a.title, a.description, a.icon, a.xp_reward, a.unlock_condition, SELECT a.id, a.title, a.description, a.icon, a.xp_reward, a.unlock_condition, a.category_id,
pa.id IS NOT NULL, pa.id IS NOT NULL,
COALESCE(pa.earned_at::text, '') COALESCE(pa.earned_at::text, '')
FROM achievements a FROM achievements a
LEFT JOIN player_achievements pa ON pa.achievement_id = a.id AND pa.profile_id = $1 LEFT JOIN player_achievements pa ON pa.achievement_id = a.id AND pa.profile_id = $1
WHERE a.category_id IS NULL
OR a.category_id IN (SELECT id FROM categories WHERE profile_id = $1)
ORDER BY pa.earned_at DESC NULLS LAST, a.id ORDER BY pa.earned_at DESC NULLS LAST, a.id
`, pid) `, pid)
if err != nil { if err != nil {
@@ -202,7 +205,7 @@ func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievem
var out []model.Achievement var out []model.Achievement
for rows.Next() { for rows.Next() {
var a model.Achievement var a model.Achievement
if err := rows.Scan(&a.ID, &a.Title, &a.Description, &a.Icon, &a.XPReward, &a.UnlockCondition, &a.Earned, &a.EarnedAt); err != nil { if err := rows.Scan(&a.ID, &a.Title, &a.Description, &a.Icon, &a.XPReward, &a.UnlockCondition, &a.CategoryID, &a.Earned, &a.EarnedAt); err != nil {
return nil, err return nil, err
} }
out = append(out, a) out = append(out, a)
@@ -210,6 +213,67 @@ func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievem
return out, rows.Err() return out, rows.Err()
} }
func (r *GameRepository) EnsureCategoryAchievement(ctx context.Context, categoryID int, categoryName string) error {
title := "Econômico em " + categoryName
description := "Mantenha os gastos dentro da meta em " + categoryName
condition := fmt.Sprintf("budget_cat_%d", categoryID)
_, err := r.pool.Exec(ctx, `
INSERT INTO achievements (title, description, icon, xp_reward, unlock_condition, category_id)
VALUES ($1, $2, '💰', 100, $3, $4)
ON CONFLICT (unlock_condition) DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description
`, title, description, condition, categoryID)
return err
}
func (r *GameRepository) RemoveCategoryAchievementIfNotEarned(ctx context.Context, categoryID int) error {
pid := middleware.ProfileIDFromCtx(ctx)
_, err := r.pool.Exec(ctx, `
DELETE FROM achievements
WHERE category_id = $1
AND NOT EXISTS (
SELECT 1 FROM player_achievements pa
WHERE pa.achievement_id = achievements.id AND pa.profile_id = $2
)
`, categoryID, pid)
return err
}
func (r *GameRepository) ListCategoriesWithBudget(ctx context.Context) ([]model.CategoryBudgetInfo, error) {
pid := middleware.ProfileIDFromCtx(ctx)
rows, err := r.pool.Query(ctx, `
SELECT id, monthly_budget FROM categories
WHERE profile_id = $1 AND monthly_budget IS NOT NULL
`, pid)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.CategoryBudgetInfo
for rows.Next() {
var c model.CategoryBudgetInfo
if err := rows.Scan(&c.CategoryID, &c.MonthlyBudget); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *GameRepository) GetMonthlySpendByCategory(ctx context.Context, month string, categoryID int) (float64, error) {
pid := middleware.ProfileIDFromCtx(ctx)
var total float64
err := r.pool.QueryRow(ctx, `
SELECT COALESCE(SUM(amount), 0)
FROM transactions
WHERE to_char(date, 'YYYY-MM') = $1
AND category_id = $2
AND type = 'expense'
AND profile_id = $3
`, month, categoryID, pid).Scan(&total)
return total, err
}
func (r *GameRepository) IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) { func (r *GameRepository) IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) {
pid := middleware.ProfileIDFromCtx(ctx) pid := middleware.ProfileIDFromCtx(ctx)
var count int var count int
+34
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"fmt"
"time" "time"
"financeiro-carvalho/internal/model" "financeiro-carvalho/internal/model"
@@ -21,6 +22,10 @@ type GameRepo interface {
ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error)
EquipCosmetic(ctx context.Context, cosmeticID int) error EquipCosmetic(ctx context.Context, cosmeticID int) error
UnlockCosmeticsForLevel(ctx context.Context, level 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 { type GameService struct {
@@ -137,6 +142,33 @@ func (s *GameService) EquipCosmetic(ctx context.Context, cosmeticID int) error {
return s.repo.EquipCosmetic(ctx, cosmeticID) 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. // 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) { func (s *GameService) NotifyAction(ctx context.Context, action string, extra map[string]any) {
now := time.Now() 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.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1)
} }
s.tryUnlockAchievement(ctx, "first_transaction") s.tryUnlockAchievement(ctx, "first_transaction")
s.CheckCategoryBudgets(ctx, monthlyPeriod)
case "import_confirmed": case "import_confirmed":
count, _ := extra["count"].(int) 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.AwardXP(ctx, "import_confirmed", 50, "Extrato importado")
_ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1) _ = s.repo.IncrementQuestCount(ctx, "weekly", weeklyPeriod, 1)
s.tryUnlockAchievement(ctx, "first_import") s.tryUnlockAchievement(ctx, "first_import")
s.CheckCategoryBudgets(ctx, monthlyPeriod)
case "savings_checked": case "savings_checked":
pct, _ := extra["pct"].(float64) pct, _ := extra["pct"].(float64)
+77 -2
View File
@@ -9,14 +9,20 @@ import (
) )
type mockGameRepo struct { type mockGameRepo struct {
profile model.PlayerProfile profile model.PlayerProfile
achievements map[string]bool achievements map[string]bool
ensuredCats map[int]string
removedCats []int
catBudgets []model.CategoryBudgetInfo
catSpend map[int]float64
} }
func newMockGameRepo() *mockGameRepo { func newMockGameRepo() *mockGameRepo {
return &mockGameRepo{ return &mockGameRepo{
profile: model.PlayerProfile{ID: 1, Level: 1, XP: 0, XPToNext: 100}, profile: model.PlayerProfile{ID: 1, Level: 1, XP: 0, XPToNext: 100},
achievements: map[string]bool{}, 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) ListCosmetics(_ context.Context) ([]model.Cosmetic, error) { return nil, nil }
func (m *mockGameRepo) EquipCosmetic(_ context.Context, _ int) error { return nil } func (m *mockGameRepo) EquipCosmetic(_ context.Context, _ int) error { return nil }
func (m *mockGameRepo) UnlockCosmeticsForLevel(_ 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) { func TestGame_XPAccumulates(t *testing.T) {
repo := newMockGameRepo() repo := newMockGameRepo()
@@ -96,3 +116,58 @@ func TestGame_AchievementNotDuplicated(t *testing.T) {
t.Error("achievement should be marked unlocked") 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")
}
}