feat(#22): gamificação RPG — XP, quests, conquistas, cosméticos e personagem SVG pixel art
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
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")
|
||||
|
||||
case "savings_checked":
|
||||
pct, _ := extra["pct"].(float64)
|
||||
_ = s.repo.UpdateQuestPct(ctx, monthlyPeriod, pct)
|
||||
if pct >= 40 {
|
||||
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de 40% atingida!")
|
||||
s.tryUnlockAchievement(ctx, "first_40pct")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"financeiro-carvalho/internal/model"
|
||||
"financeiro-carvalho/internal/service"
|
||||
)
|
||||
|
||||
type mockGameRepo struct {
|
||||
profile model.PlayerProfile
|
||||
achievements map[string]bool
|
||||
}
|
||||
|
||||
func newMockGameRepo() *mockGameRepo {
|
||||
return &mockGameRepo{
|
||||
profile: model.PlayerProfile{ID: 1, Level: 1, XP: 0, XPToNext: 100},
|
||||
achievements: map[string]bool{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockGameRepo) GetProfile(_ context.Context) (*model.PlayerProfile, error) {
|
||||
cp := m.profile
|
||||
return &cp, nil
|
||||
}
|
||||
func (m *mockGameRepo) AddXP(_ context.Context, _ string, amount int, _ string) (*model.PlayerProfile, error) {
|
||||
m.profile.XP += amount
|
||||
for m.profile.XP >= m.profile.XPToNext {
|
||||
m.profile.XP -= m.profile.XPToNext
|
||||
m.profile.Level++
|
||||
m.profile.XPToNext = m.profile.Level * m.profile.Level * 100
|
||||
}
|
||||
cp := m.profile
|
||||
return &cp, nil
|
||||
}
|
||||
func (m *mockGameRepo) ListActiveQuests(_ context.Context) ([]model.PlayerQuest, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockGameRepo) EnsurePlayerQuest(_ context.Context, _ int, _ string) (int, error) { return 1, nil }
|
||||
func (m *mockGameRepo) IncrementQuestCount(_ context.Context, _, _ string, _ int) error { return nil }
|
||||
func (m *mockGameRepo) UpdateQuestPct(_ context.Context, _ string, _ float64) error { return nil }
|
||||
func (m *mockGameRepo) ClaimQuest(_ context.Context, _ int) (int, error) { return 50, nil }
|
||||
func (m *mockGameRepo) ListAchievements(_ context.Context) ([]model.Achievement, error) { return nil, nil }
|
||||
func (m *mockGameRepo) IsAchievementUnlocked(_ context.Context, c string) (bool, error) {
|
||||
return m.achievements[c], nil
|
||||
}
|
||||
func (m *mockGameRepo) UnlockAchievement(_ context.Context, c string) (int, error) {
|
||||
m.achievements[c] = true
|
||||
return 100, 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) UnlockCosmeticsForLevel(_ context.Context, _ int) error { return nil }
|
||||
|
||||
func TestGame_XPAccumulates(t *testing.T) {
|
||||
repo := newMockGameRepo()
|
||||
svc := service.NewGameService(repo)
|
||||
|
||||
p, err := svc.AwardXP(context.Background(), "test", 50, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.XP != 50 {
|
||||
t.Errorf("expected xp=50, got %d", p.XP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGame_LevelUpAtThreshold(t *testing.T) {
|
||||
repo := newMockGameRepo()
|
||||
svc := service.NewGameService(repo)
|
||||
|
||||
// level 1 threshold is 100 XP
|
||||
p, err := svc.AwardXP(context.Background(), "test", 100, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Level != 2 {
|
||||
t.Errorf("expected level=2 after 100 XP, got %d", p.Level)
|
||||
}
|
||||
if p.XP != 0 {
|
||||
t.Errorf("expected remaining xp=0, got %d", p.XP)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGame_AchievementNotDuplicated(t *testing.T) {
|
||||
repo := newMockGameRepo()
|
||||
svc := service.NewGameService(repo)
|
||||
|
||||
svc.CheckAndUnlockAchievement(context.Background(), "first_transaction")
|
||||
svc.CheckAndUnlockAchievement(context.Background(), "first_transaction")
|
||||
|
||||
// second call should be a no-op — XP only awarded once
|
||||
// repo tracks it as unlocked after first call
|
||||
if !repo.achievements["first_transaction"] {
|
||||
t.Error("achievement should be marked unlocked")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user