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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -11,12 +11,18 @@ 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user