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:
2026-05-26 21:46:41 -03:00
co-authored by Claude Sonnet 4.6
parent 9a964423fd
commit c1964ea036
21 changed files with 1346 additions and 10 deletions
+13 -2
View File
@@ -52,13 +52,17 @@ func main() {
categorySvc := service.NewCategoryService(categoryRepo)
categoryHandler := handler.NewCategoryHandler(categorySvc)
gameRepo := repository.NewGameRepository(pool)
gameSvc := service.NewGameService(gameRepo)
gameHandler := handler.NewGameHandler(gameSvc)
transactionRepo := repository.NewTransactionRepository(pool)
importSvc := service.NewImportService(transactionRepo)
importHandler := handler.NewImportHandler(importSvc)
importHandler := handler.NewImportHandler(importSvc, gameSvc)
manualTxRepo := repository.NewManualTransactionRepository(pool)
txSvc := service.NewTransactionService(manualTxRepo)
txHandler := handler.NewTransactionHandler(txSvc)
txHandler := handler.NewTransactionHandler(txSvc, gameSvc)
recurringRepo := repository.NewRecurringRepository(pool)
recurringSvc := service.NewRecurringService(recurringRepo, manualTxRepo)
@@ -102,6 +106,13 @@ func main() {
r.Delete("/accounts/{id}", accountHandler.Delete)
r.Get("/dashboard", dashboardHandler.Get)
r.Get("/game/summary", gameHandler.Summary)
r.Post("/game/xp", gameHandler.AwardXP)
r.Post("/game/quests/{id}/claim", gameHandler.ClaimQuest)
r.Get("/game/achievements", gameHandler.Achievements)
r.Get("/game/cosmetics", gameHandler.Cosmetics)
r.Post("/game/cosmetics/{id}/equip", gameHandler.EquipCosmetic)
})
// Serve Vue SPA — non-API routes fall through to index.html
+9
View File
@@ -7,3 +7,12 @@ require (
github.com/jackc/pgx/v5 v5.7.2
github.com/joho/godotenv v1.5.1
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect
)
+90
View File
@@ -0,0 +1,90 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"financeiro-carvalho/internal/service"
)
type GameHandler struct {
svc *service.GameService
}
func NewGameHandler(svc *service.GameService) *GameHandler {
return &GameHandler{svc: svc}
}
func (h *GameHandler) Summary(w http.ResponseWriter, r *http.Request) {
s, err := h.svc.GetSummary(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get game summary")
return
}
respondJSON(w, http.StatusOK, s)
}
func (h *GameHandler) AwardXP(w http.ResponseWriter, r *http.Request) {
var body struct {
EventType string `json:"event_type"`
Amount int `json:"amount"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respondError(w, http.StatusBadRequest, "invalid JSON")
return
}
p, err := h.svc.AwardXP(r.Context(), body.EventType, body.Amount, body.Description)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to award xp")
return
}
respondJSON(w, http.StatusOK, p)
}
func (h *GameHandler) ClaimQuest(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.svc.ClaimQuest(r.Context(), id); err != nil {
respondError(w, http.StatusBadRequest, err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *GameHandler) Achievements(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListAllAchievements(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list achievements")
return
}
respondJSON(w, http.StatusOK, list)
}
func (h *GameHandler) Cosmetics(w http.ResponseWriter, r *http.Request) {
list, err := h.svc.ListCosmetics(r.Context())
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list cosmetics")
return
}
respondJSON(w, http.StatusOK, list)
}
func (h *GameHandler) EquipCosmetic(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
respondError(w, http.StatusBadRequest, "invalid id")
return
}
if err := h.svc.EquipCosmetic(r.Context(), id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to equip cosmetic")
return
}
w.WriteHeader(http.StatusNoContent)
}
+5 -3
View File
@@ -11,11 +11,12 @@ import (
const maxUploadSize = 10 << 20 // 10 MB
type ImportHandler struct {
svc *service.ImportService
svc *service.ImportService
gameSvc *service.GameService
}
func NewImportHandler(svc *service.ImportService) *ImportHandler {
return &ImportHandler{svc: svc}
func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService) *ImportHandler {
return &ImportHandler{svc: svc, gameSvc: gameSvc}
}
// Preview parses the uploaded file and returns rows with duplicate flags.
@@ -78,5 +79,6 @@ func (h *ImportHandler) Confirm(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to save transactions")
return
}
h.gameSvc.NotifyAction(r.Context(), "import_confirmed", map[string]any{"count": result.Imported})
respondJSON(w, http.StatusOK, result)
}
+5 -3
View File
@@ -14,11 +14,12 @@ import (
)
type TransactionHandler struct {
svc *service.TransactionService
svc *service.TransactionService
gameSvc *service.GameService
}
func NewTransactionHandler(svc *service.TransactionService) *TransactionHandler {
return &TransactionHandler{svc: svc}
func NewTransactionHandler(svc *service.TransactionService, gameSvc *service.GameService) *TransactionHandler {
return &TransactionHandler{svc: svc, gameSvc: gameSvc}
}
func (h *TransactionHandler) List(w http.ResponseWriter, r *http.Request) {
@@ -42,6 +43,7 @@ func (h *TransactionHandler) Create(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, err.Error())
return
}
h.gameSvc.NotifyAction(r.Context(), "transaction_created", map[string]any{"category_id": out.CategoryID})
respondJSON(w, http.StatusCreated, out)
}
+13 -1
View File
@@ -20,8 +20,20 @@ var m003 string
//go:embed sql/004_accounts.sql
var m004 string
//go:embed sql/005_game_profile.sql
var m005 string
//go:embed sql/006_quests.sql
var m006 string
//go:embed sql/007_achievements.sql
var m007 string
//go:embed sql/008_cosmetics.sql
var m008 string
func Run(ctx context.Context, pool *pgxpool.Pool) error {
migrations := []string{m001, m002, m003, m004}
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008}
for i, sql := range migrations {
if _, err := pool.Exec(ctx, sql); err != nil {
return fmt.Errorf("migration %03d: %w", i+1, err)
@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS player_profile (
id SERIAL PRIMARY KEY,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0,
xp_to_next INTEGER NOT NULL DEFAULT 100
);
INSERT INTO player_profile (level, xp, xp_to_next) VALUES (1, 0, 100)
ON CONFLICT DO NOTHING;
CREATE TABLE IF NOT EXISTS xp_events (
id SERIAL PRIMARY KEY,
event_type VARCHAR(50) NOT NULL,
xp_earned INTEGER NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO schema_migrations (version) VALUES (5) ON CONFLICT DO NOTHING;
@@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS quests (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
description TEXT,
quest_type VARCHAR(20) NOT NULL CHECK (quest_type IN ('daily','weekly','monthly')),
target_count INTEGER,
target_pct NUMERIC(5,2),
xp_reward INTEGER NOT NULL DEFAULT 50,
active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE IF NOT EXISTS player_quests (
id SERIAL PRIMARY KEY,
quest_id INTEGER NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
period VARCHAR(10) NOT NULL,
current_count INTEGER NOT NULL DEFAULT 0,
current_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
completed BOOLEAN NOT NULL DEFAULT FALSE,
completed_at TIMESTAMP WITH TIME ZONE,
claimed BOOLEAN NOT NULL DEFAULT FALSE,
UNIQUE (quest_id, period)
);
INSERT INTO quests (title, description, quest_type, target_count, target_pct, xp_reward) VALUES
('Economista do Mês', 'Poupe ≥40% da receita este mês', 'monthly', NULL, 40, 200),
('Capitão Organizado', 'Importe 1 extrato esta semana', 'weekly', 1, NULL, 50),
('Categorizador Ativo', 'Categorize 5 transações hoje', 'daily', 5, NULL, 30),
('Controlador Semanal', 'Registre ao menos 3 transações esta semana', 'weekly', 3, NULL, 40),
('Mês Sem Surpresas', 'Cubra todas as recorrências do mês', 'monthly', NULL, NULL, 150)
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (6) ON CONFLICT DO NOTHING;
@@ -0,0 +1,30 @@
CREATE TABLE IF NOT EXISTS achievements (
id SERIAL PRIMARY KEY,
title VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(10) NOT NULL DEFAULT '🏆',
xp_reward INTEGER NOT NULL DEFAULT 100,
unlock_condition VARCHAR(100) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS player_achievements (
id SERIAL PRIMARY KEY,
achievement_id INTEGER NOT NULL REFERENCES achievements(id) ON DELETE CASCADE UNIQUE,
earned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO achievements (title, description, icon, xp_reward, unlock_condition) VALUES
('Primeiro Passo', 'Registre sua primeira transação manual', '👣', 50, 'first_transaction'),
('Explorador de Extratos', 'Importe seu primeiro extrato', '📄', 100, 'first_import'),
('Meta Atingida!', 'Poupe 40% pela primeira vez', '🎯', 200, 'first_40pct'),
('Tri-Campeão', 'Poupe 40% por 3 meses seguidos', '🥇', 500, 'three_months_40pct'),
('Veleirista Econômico', 'Gaste menos de R$500 no Veleiro em um mês', '', 150, 'veleiro_under_500'),
('Nível 5', 'Alcance o nível 5', '', 100, 'level_5'),
('Nível 10', 'Alcance o nível 10', '🌟', 300, 'level_10'),
('Mestre das Categorias', 'Categorize 100 transações', '🗂️', 200, 'categorize_100'),
('Semana Perfeita', 'Importe extrato por 4 semanas seguidas', '📅', 250, 'four_weeks_import'),
('Guardião da Saúde', 'Registre gasto com Saúde/Insulina em 3 meses', '💉', 300, 'health_3months'),
('Mês Limpo', 'Todas as recorrências cobertas em um mês', '', 200, 'full_recurring_month')
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (7) ON CONFLICT DO NOTHING;
@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS cosmetics (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('hat','outfit','accessory')),
unlock_level INTEGER NOT NULL,
css_data JSONB NOT NULL DEFAULT '{}'
);
CREATE TABLE IF NOT EXISTS player_cosmetics (
id SERIAL PRIMARY KEY,
cosmetic_id INTEGER NOT NULL REFERENCES cosmetics(id) ON DELETE CASCADE UNIQUE,
equipped BOOLEAN NOT NULL DEFAULT FALSE,
unlocked_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
INSERT INTO cosmetics (name, type, unlock_level, css_data) VALUES
('Chapéu de Aventureiro', 'hat', 2, '{"color":"#8B4513","accent":"#d4a85a"}'),
('Roupa de Marinheiro', 'outfit', 3, '{"primary":"#1e3a5f","accent":"#ffffff"}'),
('Óculos de Sol', 'accessory', 4, '{"color":"#1a1a1a"}'),
('Chapéu de Capitão', 'hat', 5, '{"color":"#1e293b","badge":"#ffd700"}'),
('Roupa Dourada', 'outfit', 7, '{"primary":"#d4af37","accent":"#ffd700"}'),
('Coroa de Campeão', 'hat', 10, '{"color":"#ffd700","gems":"#e11d48"}')
ON CONFLICT DO NOTHING;
INSERT INTO schema_migrations (version) VALUES (8) ON CONFLICT DO NOTHING;
+69
View File
@@ -0,0 +1,69 @@
package model
type PlayerProfile struct {
ID int `json:"id"`
Level int `json:"level"`
XP int `json:"xp"`
XPToNext int `json:"xp_to_next"`
}
type XPEvent struct {
ID int `json:"id"`
EventType string `json:"event_type"`
XPEarned int `json:"xp_earned"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
}
type Quest struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
QuestType string `json:"quest_type"`
TargetCount *int `json:"target_count"`
TargetPct *float64 `json:"target_pct"`
XPReward int `json:"xp_reward"`
}
type PlayerQuest struct {
ID int `json:"id"`
QuestID int `json:"quest_id"`
Title string `json:"title"`
QuestType string `json:"quest_type"`
TargetCount *int `json:"target_count"`
TargetPct *float64 `json:"target_pct"`
XPReward int `json:"xp_reward"`
Period string `json:"period"`
CurrentCount int `json:"current_count"`
CurrentPct float64 `json:"current_pct"`
Completed bool `json:"completed"`
Claimed bool `json:"claimed"`
}
type Achievement struct {
ID int `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
XPReward int `json:"xp_reward"`
UnlockCondition string `json:"unlock_condition"`
Earned bool `json:"earned"`
EarnedAt string `json:"earned_at,omitempty"`
}
type Cosmetic struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
UnlockLevel int `json:"unlock_level"`
CSSData string `json:"css_data"`
Unlocked bool `json:"unlocked"`
Equipped bool `json:"equipped"`
}
type GameSummary struct {
Profile PlayerProfile `json:"profile"`
ActiveQuests []PlayerQuest `json:"active_quests"`
RecentAchievements []Achievement `json:"recent_achievements"`
EquippedCosmetics []Cosmetic `json:"equipped_cosmetics"`
}
+282
View File
@@ -0,0 +1,282 @@
package repository
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/model"
)
type GameRepository struct {
pool *pgxpool.Pool
}
func NewGameRepository(pool *pgxpool.Pool) *GameRepository {
return &GameRepository{pool: pool}
}
func (r *GameRepository) GetProfile(ctx context.Context) (*model.PlayerProfile, error) {
var p model.PlayerProfile
err := r.pool.QueryRow(ctx, `SELECT id, level, xp, xp_to_next FROM player_profile LIMIT 1`).
Scan(&p.ID, &p.Level, &p.XP, &p.XPToNext)
return &p, err
}
// xpThreshold returns the XP required to reach the given level.
func xpThreshold(level int) int {
return level * level * 100
}
func (r *GameRepository) AddXP(ctx context.Context, eventType string, amount int, description string) (*model.PlayerProfile, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `INSERT INTO xp_events (event_type, xp_earned, description) VALUES ($1, $2, $3)`,
eventType, amount, description)
if err != nil {
return nil, err
}
var p model.PlayerProfile
err = tx.QueryRow(ctx, `SELECT id, level, xp, xp_to_next FROM player_profile LIMIT 1`).
Scan(&p.ID, &p.Level, &p.XP, &p.XPToNext)
if err != nil {
return nil, err
}
p.XP += amount
for p.XP >= p.XPToNext {
p.XP -= p.XPToNext
p.Level++
p.XPToNext = xpThreshold(p.Level)
}
_, err = tx.Exec(ctx, `UPDATE player_profile SET level=$1, xp=$2, xp_to_next=$3 WHERE id=$4`,
p.Level, p.XP, p.XPToNext, p.ID)
if err != nil {
return nil, err
}
return &p, tx.Commit(ctx)
}
func (r *GameRepository) ListActiveQuests(ctx context.Context) ([]model.PlayerQuest, error) {
now := time.Now()
dailyPeriod := now.Format("2006-01-02")
weeklyPeriod := now.Format("2006") + "-W" + now.Format("01")
monthlyPeriod := now.Format("2006-01")
rows, err := r.pool.Query(ctx, `
SELECT q.id, q.title, q.quest_type, q.target_count, q.target_pct, q.xp_reward,
COALESCE(pq.id, 0),
COALESCE(pq.period, ''),
COALESCE(pq.current_count, 0),
COALESCE(pq.current_pct, 0),
COALESCE(pq.completed, false),
COALESCE(pq.claimed, false)
FROM quests q
LEFT JOIN player_quests pq ON pq.quest_id = q.id AND pq.period = CASE
WHEN q.quest_type = 'daily' THEN $1
WHEN q.quest_type = 'weekly' THEN $2
WHEN q.quest_type = 'monthly' THEN $3
END
WHERE q.active = true
ORDER BY q.quest_type, q.id
`, dailyPeriod, weeklyPeriod, monthlyPeriod)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.PlayerQuest
for rows.Next() {
var pq model.PlayerQuest
var pqID int
if err := rows.Scan(&pq.QuestID, &pq.Title, &pq.QuestType, &pq.TargetCount, &pq.TargetPct,
&pq.XPReward, &pqID, &pq.Period, &pq.CurrentCount, &pq.CurrentPct, &pq.Completed, &pq.Claimed); err != nil {
return nil, err
}
pq.ID = pqID
if pq.Period == "" {
switch pq.QuestType {
case "daily":
pq.Period = dailyPeriod
case "weekly":
pq.Period = weeklyPeriod
case "monthly":
pq.Period = monthlyPeriod
}
}
out = append(out, pq)
}
return out, rows.Err()
}
func (r *GameRepository) EnsurePlayerQuest(ctx context.Context, questID int, period string) (int, error) {
var id int
err := r.pool.QueryRow(ctx, `
INSERT INTO player_quests (quest_id, period) VALUES ($1, $2)
ON CONFLICT (quest_id, period) DO UPDATE SET quest_id = EXCLUDED.quest_id
RETURNING id
`, questID, period).Scan(&id)
return id, err
}
func (r *GameRepository) IncrementQuestCount(ctx context.Context, questType, period string, delta int) error {
_, err := r.pool.Exec(ctx, `
UPDATE player_quests pq
SET current_count = current_count + $1,
completed = (current_count + $1 >= q.target_count),
completed_at = CASE WHEN (current_count + $1 >= q.target_count) AND NOT completed THEN NOW() ELSE completed_at END
FROM quests q
WHERE pq.quest_id = q.id
AND q.quest_type = $2
AND pq.period = $3
AND q.target_count IS NOT NULL
AND NOT pq.claimed
`, delta, questType, period)
return err
}
func (r *GameRepository) UpdateQuestPct(ctx context.Context, period string, pct float64) error {
_, err := r.pool.Exec(ctx, `
UPDATE player_quests pq
SET current_pct = $1,
completed = ($1 >= q.target_pct),
completed_at = CASE WHEN ($1 >= q.target_pct) AND NOT completed THEN NOW() ELSE completed_at END
FROM quests q
WHERE pq.quest_id = q.id
AND q.quest_type = 'monthly'
AND pq.period = $2
AND q.target_pct IS NOT NULL
AND NOT pq.claimed
`, pct, period)
return err
}
func (r *GameRepository) ClaimQuest(ctx context.Context, playerQuestID int) (int, error) {
var xpReward int
err := r.pool.QueryRow(ctx, `
UPDATE player_quests pq
SET claimed = true
FROM quests q
WHERE pq.quest_id = q.id AND pq.id = $1 AND pq.completed AND NOT pq.claimed
RETURNING q.xp_reward
`, playerQuestID).Scan(&xpReward)
return xpReward, err
}
func (r *GameRepository) ListAchievements(ctx context.Context) ([]model.Achievement, error) {
rows, err := r.pool.Query(ctx, `
SELECT a.id, a.title, a.description, a.icon, a.xp_reward, a.unlock_condition,
pa.id IS NOT NULL,
COALESCE(pa.earned_at::text, '')
FROM achievements a
LEFT JOIN player_achievements pa ON pa.achievement_id = a.id
ORDER BY pa.earned_at DESC NULLS LAST, a.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Achievement
for rows.Next() {
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 {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
func (r *GameRepository) IsAchievementUnlocked(ctx context.Context, condition string) (bool, error) {
var count int
err := r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM player_achievements pa
JOIN achievements a ON a.id = pa.achievement_id
WHERE a.unlock_condition = $1
`, condition).Scan(&count)
return count > 0, err
}
func (r *GameRepository) UnlockAchievement(ctx context.Context, condition string) (int, error) {
var xpReward int
err := r.pool.QueryRow(ctx, `
INSERT INTO player_achievements (achievement_id)
SELECT id FROM achievements WHERE unlock_condition = $1
ON CONFLICT (achievement_id) DO NOTHING
RETURNING (SELECT xp_reward FROM achievements WHERE unlock_condition = $1)
`, condition).Scan(&xpReward)
return xpReward, err
}
func (r *GameRepository) ListCosmetics(ctx context.Context) ([]model.Cosmetic, error) {
var level int
_ = r.pool.QueryRow(ctx, `SELECT level FROM player_profile LIMIT 1`).Scan(&level)
rows, err := r.pool.Query(ctx, `
SELECT c.id, c.name, c.type, c.unlock_level, c.css_data::text,
(c.unlock_level <= $1) AS unlocked,
COALESCE(pc.equipped, false)
FROM cosmetics c
LEFT JOIN player_cosmetics pc ON pc.cosmetic_id = c.id
ORDER BY c.unlock_level, c.id
`, level)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.Cosmetic
for rows.Next() {
var c model.Cosmetic
if err := rows.Scan(&c.ID, &c.Name, &c.Type, &c.UnlockLevel, &c.CSSData, &c.Unlocked, &c.Equipped); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
func (r *GameRepository) EquipCosmetic(ctx context.Context, cosmeticID int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
// unequip same type
_, err = tx.Exec(ctx, `
UPDATE player_cosmetics pc SET equipped = false
FROM cosmetics c WHERE pc.cosmetic_id = c.id
AND c.type = (SELECT type FROM cosmetics WHERE id = $1)
`, cosmeticID)
if err != nil {
return err
}
_, err = tx.Exec(ctx, `
INSERT INTO player_cosmetics (cosmetic_id, equipped) VALUES ($1, true)
ON CONFLICT (cosmetic_id) DO UPDATE SET equipped = true
`, cosmeticID)
if err != nil {
return err
}
return tx.Commit(ctx)
}
func (r *GameRepository) UnlockCosmeticsForLevel(ctx context.Context, level int) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO player_cosmetics (cosmetic_id)
SELECT id FROM cosmetics WHERE unlock_level <= $1
ON CONFLICT (cosmetic_id) DO NOTHING
`, level)
return err
}
+174
View File
@@ -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")
}
}
}
+98
View File
@@ -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")
}
}