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)
|
||||
}
|
||||
@@ -12,10 +12,11 @@ const maxUploadSize = 10 << 20 // 10 MB
|
||||
|
||||
type ImportHandler struct {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
|
||||
type TransactionHandler struct {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<RouterLink to="/transacoes">Transações</RouterLink>
|
||||
<RouterLink to="/importar">Importar</RouterLink>
|
||||
<RouterLink to="/contas">Contas</RouterLink>
|
||||
<RouterLink to="/personagem">Personagem</RouterLink>
|
||||
<RouterLink to="/configuracoes">Configurações</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div class="character-wrapper" :class="{ celebrate }">
|
||||
<svg class="character-svg" viewBox="0 0 32 48" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges">
|
||||
<!-- hair -->
|
||||
<rect x="10" y="2" width="12" height="4" :fill="hairColor" />
|
||||
<rect x="8" y="4" width="16" height="2" :fill="hairColor" />
|
||||
<!-- face -->
|
||||
<rect x="9" y="6" width="14" height="12" fill="#f5c5a3" />
|
||||
<!-- eyes -->
|
||||
<rect x="11" y="10" width="3" height="3" fill="#1e293b" />
|
||||
<rect x="18" y="10" width="3" height="3" fill="#1e293b" />
|
||||
<!-- mouth -->
|
||||
<rect x="13" y="15" width="6" height="2" fill="#e07070" />
|
||||
<!-- neck -->
|
||||
<rect x="13" y="18" width="6" height="3" fill="#f5c5a3" />
|
||||
<!-- body (shirt) -->
|
||||
<rect x="8" y="21" width="16" height="14" :fill="shirtColor" />
|
||||
<!-- collar -->
|
||||
<rect x="12" y="21" width="8" height="3" fill="#fff" opacity="0.4" />
|
||||
<!-- arms -->
|
||||
<rect x="2" y="21" width="6" height="12" :fill="shirtColor" />
|
||||
<rect x="24" y="21" width="6" height="12" :fill="shirtColor" />
|
||||
<!-- hands -->
|
||||
<rect x="2" y="33" width="6" height="4" fill="#f5c5a3" />
|
||||
<rect x="24" y="33" width="6" height="4" fill="#f5c5a3" />
|
||||
<!-- pants -->
|
||||
<rect x="8" y="35" width="7" height="10" :fill="pantsColor" />
|
||||
<rect x="17" y="35" width="7" height="10" :fill="pantsColor" />
|
||||
<!-- shoes -->
|
||||
<rect x="7" y="45" width="8" height="3" :fill="shoeColor" />
|
||||
<rect x="17" y="45" width="8" height="3" :fill="shoeColor" />
|
||||
<!-- accessory slot -->
|
||||
<slot name="accessory" />
|
||||
</svg>
|
||||
<div v-if="celebrate" class="sparkles">✨</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Cosmetic } from '@/stores/game'
|
||||
|
||||
const props = defineProps<{
|
||||
level: number
|
||||
cosmetics?: Cosmetic[]
|
||||
celebrate?: boolean
|
||||
}>()
|
||||
|
||||
function equippedOfType(type: string) {
|
||||
return props.cosmetics?.find(c => c.type === type && c.equipped)
|
||||
}
|
||||
|
||||
function cssValue(type: string, fallback: string) {
|
||||
const c = equippedOfType(type)
|
||||
if (!c) return fallback
|
||||
try {
|
||||
const data = JSON.parse(c.css_data)
|
||||
return data.color ?? fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const hairColor = computed(() => cssValue('hair', '#5a3a1a'))
|
||||
const shirtColor = computed(() => cssValue('shirt', '#3b82f6'))
|
||||
const pantsColor = computed(() => cssValue('pants', '#1e3a5f'))
|
||||
const shoeColor = computed(() => cssValue('shoes', '#1e293b'))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.character-svg {
|
||||
width: 96px;
|
||||
height: 144px;
|
||||
image-rendering: pixelated;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
.character-wrapper.celebrate .character-svg {
|
||||
animation: bounce 0.5s ease infinite alternate;
|
||||
}
|
||||
@keyframes bounce {
|
||||
from { transform: translateY(0); }
|
||||
to { transform: translateY(-8px); }
|
||||
}
|
||||
.sparkles {
|
||||
font-size: 1.5rem;
|
||||
animation: fadein 0.3s ease;
|
||||
}
|
||||
@keyframes fadein { from { opacity: 0; } to { opacity: 1; } }
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="xp-bar-wrap">
|
||||
<div class="xp-labels">
|
||||
<span class="level-badge">Nv {{ level }}</span>
|
||||
<span class="xp-text">{{ xp }} / {{ xpToNext }} XP</span>
|
||||
</div>
|
||||
<div class="xp-track">
|
||||
<div class="xp-fill" :style="{ width: pct + '%' }" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
level: number
|
||||
xp: number
|
||||
xpToNext: number
|
||||
}>()
|
||||
|
||||
const pct = computed(() => Math.min(100, Math.round((props.xp / props.xpToNext) * 100)))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.xp-bar-wrap { display: flex; flex-direction: column; gap: 4px; }
|
||||
.xp-labels { display: flex; justify-content: space-between; align-items: center; }
|
||||
.level-badge {
|
||||
background: #7c3aed; color: #fff; font-size: 0.75rem;
|
||||
font-weight: 700; padding: 2px 8px; border-radius: 4px;
|
||||
}
|
||||
.xp-text { font-size: 0.75rem; color: var(--color-text-secondary, #888); }
|
||||
.xp-track {
|
||||
height: 10px; background: #e5e7eb; border-radius: 5px; overflow: hidden;
|
||||
}
|
||||
.xp-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #7c3aed, #a855f7);
|
||||
border-radius: 5px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -29,6 +29,11 @@ const router = createRouter({
|
||||
name: 'accounts',
|
||||
component: () => import('../views/AccountsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/personagem',
|
||||
name: 'character',
|
||||
component: () => import('../views/CharacterView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/configuracoes',
|
||||
name: 'settings',
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface PlayerProfile {
|
||||
id: number
|
||||
level: number
|
||||
xp: number
|
||||
xp_to_next: number
|
||||
}
|
||||
|
||||
export interface PlayerQuest {
|
||||
id: number
|
||||
quest_id: number
|
||||
title: string
|
||||
quest_type: string
|
||||
target_count: number | null
|
||||
target_pct: number | null
|
||||
xp_reward: number
|
||||
period: string
|
||||
current_count: number
|
||||
current_pct: number
|
||||
completed: boolean
|
||||
claimed: boolean
|
||||
}
|
||||
|
||||
export interface Achievement {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
xp_reward: number
|
||||
unlock_condition: string
|
||||
earned: boolean
|
||||
earned_at: string
|
||||
}
|
||||
|
||||
export interface Cosmetic {
|
||||
id: number
|
||||
name: string
|
||||
type: string
|
||||
unlock_level: number
|
||||
css_data: string
|
||||
unlocked: boolean
|
||||
equipped: boolean
|
||||
}
|
||||
|
||||
export interface GameSummary {
|
||||
profile: PlayerProfile
|
||||
active_quests: PlayerQuest[]
|
||||
recent_achievements: Achievement[]
|
||||
equipped_cosmetics: Cosmetic[]
|
||||
}
|
||||
|
||||
export const useGameStore = defineStore('game', () => {
|
||||
const summary = ref<GameSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchSummary() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fetch('/api/game/summary')
|
||||
if (!res.ok) throw new Error('failed')
|
||||
summary.value = await res.json()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function claimQuest(playerQuestId: number) {
|
||||
await fetch(`/api/game/quests/${playerQuestId}/claim`, { method: 'POST' })
|
||||
await fetchSummary()
|
||||
}
|
||||
|
||||
async function equipCosmetic(cosmeticId: number) {
|
||||
await fetch(`/api/game/cosmetics/${cosmeticId}/equip`, { method: 'POST' })
|
||||
await fetchSummary()
|
||||
}
|
||||
|
||||
return { summary, loading, fetchSummary, claimQuest, equipCosmetic }
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
<template>
|
||||
<div class="character-page">
|
||||
<h1 class="page-title">Personagem</h1>
|
||||
|
||||
<div v-if="store.loading" class="loading">Carregando...</div>
|
||||
|
||||
<template v-else-if="store.summary">
|
||||
<!-- Profile panel -->
|
||||
<div class="profile-panel card">
|
||||
<div class="character-area">
|
||||
<CharacterWidget
|
||||
:level="store.summary.profile.level"
|
||||
:cosmetics="allCosmetics"
|
||||
:celebrate="justLeveledUp"
|
||||
/>
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<h2>Nível {{ store.summary.profile.level }}</h2>
|
||||
<XPBar
|
||||
:level="store.summary.profile.level"
|
||||
:xp="store.summary.profile.xp"
|
||||
:xp-to-next="store.summary.profile.xp_to_next"
|
||||
/>
|
||||
<p class="xp-hint">Continue registrando transações para ganhar XP!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quests -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Quests Ativas</h2>
|
||||
<div v-if="store.summary.active_quests.length === 0" class="empty">Nenhuma quest ativa.</div>
|
||||
<div class="quest-list">
|
||||
<div
|
||||
v-for="q in store.summary.active_quests"
|
||||
:key="q.id"
|
||||
class="quest-card card"
|
||||
:class="{ completed: q.completed, claimed: q.claimed }"
|
||||
>
|
||||
<div class="quest-header">
|
||||
<span class="quest-type-badge" :class="q.quest_type">{{ typeLabel(q.quest_type) }}</span>
|
||||
<span class="quest-xp">+{{ q.xp_reward }} XP</span>
|
||||
</div>
|
||||
<p class="quest-title">{{ q.title }}</p>
|
||||
<div class="quest-progress">
|
||||
<template v-if="q.target_count !== null">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" :style="{ width: countPct(q) + '%' }" />
|
||||
</div>
|
||||
<span class="progress-label">{{ q.current_count }} / {{ q.target_count }}</span>
|
||||
</template>
|
||||
<template v-else-if="q.target_pct !== null">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" :style="{ width: Math.min(100, q.current_pct) + '%' }" />
|
||||
</div>
|
||||
<span class="progress-label">{{ q.current_pct.toFixed(1) }}% / {{ q.target_pct }}%</span>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-if="q.completed && !q.claimed"
|
||||
class="btn-claim"
|
||||
@click="claim(q.id)"
|
||||
>Resgatar</button>
|
||||
<span v-else-if="q.claimed" class="claimed-badge">✓ Resgatado</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Achievements -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Conquistas</h2>
|
||||
<div class="achievement-list">
|
||||
<div
|
||||
v-for="a in achievements"
|
||||
:key="a.id"
|
||||
class="achievement-card card"
|
||||
:class="{ earned: a.earned }"
|
||||
>
|
||||
<span class="achievement-icon">{{ a.icon || '🏆' }}</span>
|
||||
<div class="achievement-info">
|
||||
<p class="achievement-title">{{ a.title }}</p>
|
||||
<p class="achievement-desc">{{ a.description }}</p>
|
||||
<span v-if="a.earned" class="earned-tag">+{{ a.xp_reward }} XP conquistado</span>
|
||||
<span v-else class="locked-tag">Bloqueado</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cosmetics -->
|
||||
<section class="section">
|
||||
<h2 class="section-title">Cosméticos</h2>
|
||||
<div class="cosmetic-list">
|
||||
<div
|
||||
v-for="c in allCosmetics"
|
||||
:key="c.id"
|
||||
class="cosmetic-card card"
|
||||
:class="{ equipped: c.equipped, locked: !c.unlocked }"
|
||||
@click="c.unlocked && !c.equipped && equip(c.id)"
|
||||
>
|
||||
<div class="cosmetic-swatch" :style="swatchStyle(c)" />
|
||||
<p class="cosmetic-name">{{ c.name }}</p>
|
||||
<p class="cosmetic-unlock">Nv {{ c.unlock_level }}</p>
|
||||
<span v-if="c.equipped" class="equipped-tag">Equipado</span>
|
||||
<span v-else-if="!c.unlocked" class="locked-tag">🔒</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useGameStore, type Achievement, type Cosmetic } from '@/stores/game'
|
||||
import CharacterWidget from '@/components/CharacterWidget.vue'
|
||||
import XPBar from '@/components/XPBar.vue'
|
||||
|
||||
const store = useGameStore()
|
||||
const justLeveledUp = ref(false)
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const allCosmetics = ref<Cosmetic[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchSummary()
|
||||
const [achRes, cosRes] = await Promise.all([
|
||||
fetch('/api/game/achievements'),
|
||||
fetch('/api/game/cosmetics'),
|
||||
])
|
||||
if (achRes.ok) achievements.value = await achRes.json()
|
||||
if (cosRes.ok) allCosmetics.value = await cosRes.json()
|
||||
})
|
||||
|
||||
function typeLabel(t: string) {
|
||||
return { daily: 'Diária', weekly: 'Semanal', monthly: 'Mensal' }[t] ?? t
|
||||
}
|
||||
|
||||
function countPct(q: { current_count: number; target_count: number | null }) {
|
||||
if (!q.target_count) return 0
|
||||
return Math.min(100, Math.round((q.current_count / q.target_count) * 100))
|
||||
}
|
||||
|
||||
async function claim(id: number) {
|
||||
await store.claimQuest(id)
|
||||
justLeveledUp.value = true
|
||||
setTimeout(() => (justLeveledUp.value = false), 2000)
|
||||
}
|
||||
|
||||
async function equip(id: number) {
|
||||
await store.equipCosmetic(id)
|
||||
const res = await fetch('/api/game/cosmetics')
|
||||
if (res.ok) allCosmetics.value = await res.json()
|
||||
}
|
||||
|
||||
function swatchStyle(c: Cosmetic) {
|
||||
try {
|
||||
const data = JSON.parse(c.css_data)
|
||||
return { background: data.color ?? '#ccc' }
|
||||
} catch {
|
||||
return { background: '#ccc' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.character-page { max-width: 860px; margin: 0 auto; padding: 1.5rem; }
|
||||
.page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 1.5rem; }
|
||||
.loading { text-align: center; padding: 2rem; color: #888; }
|
||||
|
||||
.card {
|
||||
background: var(--color-surface, #fff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.character-area { flex-shrink: 0; }
|
||||
.profile-info { flex: 1; }
|
||||
.profile-info h2 { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.5rem; }
|
||||
.xp-hint { font-size: 0.75rem; color: #888; margin-top: 0.5rem; }
|
||||
|
||||
.section { margin-bottom: 2rem; }
|
||||
.section-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
|
||||
.empty { color: #888; font-size: 0.9rem; }
|
||||
|
||||
.quest-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0.75rem; }
|
||||
.quest-card { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.quest-card.claimed { opacity: 0.6; }
|
||||
.quest-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.quest-type-badge {
|
||||
font-size: 0.65rem; font-weight: 700; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
.quest-type-badge.daily { background: #fef3c7; color: #92400e; }
|
||||
.quest-type-badge.weekly { background: #dbeafe; color: #1e40af; }
|
||||
.quest-type-badge.monthly { background: #ede9fe; color: #5b21b6; }
|
||||
.quest-xp { font-size: 0.75rem; font-weight: 600; color: #7c3aed; }
|
||||
.quest-title { font-size: 0.9rem; font-weight: 500; }
|
||||
.quest-progress { display: flex; flex-direction: column; gap: 4px; }
|
||||
.progress-track { height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: #7c3aed; border-radius: 4px; transition: width 0.3s; }
|
||||
.progress-label { font-size: 0.7rem; color: #888; }
|
||||
.btn-claim {
|
||||
background: #7c3aed; color: #fff; border: none; border-radius: 6px;
|
||||
padding: 6px 12px; font-size: 0.8rem; cursor: pointer; font-weight: 600;
|
||||
}
|
||||
.btn-claim:hover { background: #6d28d9; }
|
||||
.claimed-badge { font-size: 0.75rem; color: #16a34a; font-weight: 600; }
|
||||
|
||||
.achievement-list {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.75rem;
|
||||
}
|
||||
.achievement-card {
|
||||
display: flex; gap: 0.75rem; align-items: flex-start;
|
||||
opacity: 0.5; filter: grayscale(1);
|
||||
}
|
||||
.achievement-card.earned { opacity: 1; filter: none; }
|
||||
.achievement-icon { font-size: 1.75rem; flex-shrink: 0; }
|
||||
.achievement-title { font-weight: 600; font-size: 0.9rem; }
|
||||
.achievement-desc { font-size: 0.75rem; color: #888; margin-top: 2px; }
|
||||
.earned-tag { font-size: 0.7rem; color: #7c3aed; font-weight: 600; }
|
||||
.locked-tag { font-size: 0.7rem; color: #aaa; }
|
||||
|
||||
.cosmetic-list {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 0.75rem;
|
||||
}
|
||||
.cosmetic-card {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
|
||||
cursor: pointer; transition: border-color 0.2s;
|
||||
}
|
||||
.cosmetic-card:hover:not(.locked):not(.equipped) { border-color: #7c3aed; }
|
||||
.cosmetic-card.equipped { border-color: #7c3aed; background: #ede9fe; }
|
||||
.cosmetic-card.locked { opacity: 0.5; cursor: default; }
|
||||
.cosmetic-swatch { width: 48px; height: 48px; border-radius: 8px; border: 2px solid #e5e7eb; }
|
||||
.cosmetic-name { font-size: 0.8rem; font-weight: 500; text-align: center; }
|
||||
.cosmetic-unlock { font-size: 0.7rem; color: #888; }
|
||||
.equipped-tag { font-size: 0.65rem; color: #7c3aed; font-weight: 700; }
|
||||
</style>
|
||||
@@ -1,11 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
import XPBar from '@/components/XPBar.vue'
|
||||
|
||||
const store = useDashboardStore()
|
||||
const gameStore = useGameStore()
|
||||
const currentMonth = ref(new Date().toISOString().slice(0, 7))
|
||||
|
||||
onMounted(() => store.fetch(currentMonth.value))
|
||||
onMounted(() => {
|
||||
store.fetch(currentMonth.value)
|
||||
gameStore.fetchSummary()
|
||||
})
|
||||
|
||||
function changeMonth(delta: number) {
|
||||
const [y, m] = currentMonth.value.split('-').map(Number)
|
||||
@@ -83,6 +89,14 @@ const maxEvolution = computed(() => {
|
||||
</div>
|
||||
<div class="savings-target">todas as contas</div>
|
||||
</div>
|
||||
<RouterLink to="/personagem" class="card character-mini-card" v-if="gameStore.summary">
|
||||
<div class="card-label">Personagem</div>
|
||||
<XPBar
|
||||
:level="gameStore.summary.profile.level"
|
||||
:xp="gameStore.summary.profile.xp"
|
||||
:xp-to-next="gameStore.summary.profile.xp_to_next"
|
||||
/>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- Bottom section: two columns on wide, stacked on mobile -->
|
||||
@@ -194,6 +208,7 @@ h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; color: #374151;
|
||||
.savings-ok .savings-pct { color: #059669; }
|
||||
.savings-low .savings-pct { color: #dc2626; }
|
||||
.savings-target { font-size: 0.7rem; color: #9ca3af; margin-top: 0.2rem; }
|
||||
.character-mini-card { display: flex; flex-direction: column; gap: 0.5rem; text-decoration: none; }
|
||||
|
||||
/* Bottom grid */
|
||||
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
|
||||
Reference in New Issue
Block a user