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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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