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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user