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)
|
categorySvc := service.NewCategoryService(categoryRepo)
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
||||||
|
|
||||||
|
gameRepo := repository.NewGameRepository(pool)
|
||||||
|
gameSvc := service.NewGameService(gameRepo)
|
||||||
|
gameHandler := handler.NewGameHandler(gameSvc)
|
||||||
|
|
||||||
transactionRepo := repository.NewTransactionRepository(pool)
|
transactionRepo := repository.NewTransactionRepository(pool)
|
||||||
importSvc := service.NewImportService(transactionRepo)
|
importSvc := service.NewImportService(transactionRepo)
|
||||||
importHandler := handler.NewImportHandler(importSvc)
|
importHandler := handler.NewImportHandler(importSvc, gameSvc)
|
||||||
|
|
||||||
manualTxRepo := repository.NewManualTransactionRepository(pool)
|
manualTxRepo := repository.NewManualTransactionRepository(pool)
|
||||||
txSvc := service.NewTransactionService(manualTxRepo)
|
txSvc := service.NewTransactionService(manualTxRepo)
|
||||||
txHandler := handler.NewTransactionHandler(txSvc)
|
txHandler := handler.NewTransactionHandler(txSvc, gameSvc)
|
||||||
|
|
||||||
recurringRepo := repository.NewRecurringRepository(pool)
|
recurringRepo := repository.NewRecurringRepository(pool)
|
||||||
recurringSvc := service.NewRecurringService(recurringRepo, manualTxRepo)
|
recurringSvc := service.NewRecurringService(recurringRepo, manualTxRepo)
|
||||||
@@ -102,6 +106,13 @@ func main() {
|
|||||||
r.Delete("/accounts/{id}", accountHandler.Delete)
|
r.Delete("/accounts/{id}", accountHandler.Delete)
|
||||||
|
|
||||||
r.Get("/dashboard", dashboardHandler.Get)
|
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
|
// 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/jackc/pgx/v5 v5.7.2
|
||||||
github.com/joho/godotenv v1.5.1
|
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
|
const maxUploadSize = 10 << 20 // 10 MB
|
||||||
|
|
||||||
type ImportHandler struct {
|
type ImportHandler struct {
|
||||||
svc *service.ImportService
|
svc *service.ImportService
|
||||||
|
gameSvc *service.GameService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewImportHandler(svc *service.ImportService) *ImportHandler {
|
func NewImportHandler(svc *service.ImportService, gameSvc *service.GameService) *ImportHandler {
|
||||||
return &ImportHandler{svc: svc}
|
return &ImportHandler{svc: svc, gameSvc: gameSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preview parses the uploaded file and returns rows with duplicate flags.
|
// 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")
|
respondError(w, http.StatusInternalServerError, "failed to save transactions")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.gameSvc.NotifyAction(r.Context(), "import_confirmed", map[string]any{"count": result.Imported})
|
||||||
respondJSON(w, http.StatusOK, result)
|
respondJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TransactionHandler struct {
|
type TransactionHandler struct {
|
||||||
svc *service.TransactionService
|
svc *service.TransactionService
|
||||||
|
gameSvc *service.GameService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTransactionHandler(svc *service.TransactionService) *TransactionHandler {
|
func NewTransactionHandler(svc *service.TransactionService, gameSvc *service.GameService) *TransactionHandler {
|
||||||
return &TransactionHandler{svc: svc}
|
return &TransactionHandler{svc: svc, gameSvc: gameSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TransactionHandler) List(w http.ResponseWriter, r *http.Request) {
|
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())
|
respondError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
h.gameSvc.NotifyAction(r.Context(), "transaction_created", map[string]any{"category_id": out.CategoryID})
|
||||||
respondJSON(w, http.StatusCreated, out)
|
respondJSON(w, http.StatusCreated, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,20 @@ var m003 string
|
|||||||
//go:embed sql/004_accounts.sql
|
//go:embed sql/004_accounts.sql
|
||||||
var m004 string
|
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 {
|
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 {
|
for i, sql := range migrations {
|
||||||
if _, err := pool.Exec(ctx, sql); err != nil {
|
if _, err := pool.Exec(ctx, sql); err != nil {
|
||||||
return fmt.Errorf("migration %03d: %w", i+1, err)
|
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="/transacoes">Transações</RouterLink>
|
||||||
<RouterLink to="/importar">Importar</RouterLink>
|
<RouterLink to="/importar">Importar</RouterLink>
|
||||||
<RouterLink to="/contas">Contas</RouterLink>
|
<RouterLink to="/contas">Contas</RouterLink>
|
||||||
|
<RouterLink to="/personagem">Personagem</RouterLink>
|
||||||
<RouterLink to="/configuracoes">Configurações</RouterLink>
|
<RouterLink to="/configuracoes">Configurações</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</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',
|
name: 'accounts',
|
||||||
component: () => import('../views/AccountsView.vue'),
|
component: () => import('../views/AccountsView.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/personagem',
|
||||||
|
name: 'character',
|
||||||
|
component: () => import('../views/CharacterView.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/configuracoes',
|
path: '/configuracoes',
|
||||||
name: 'settings',
|
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">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useDashboardStore } from '@/stores/dashboard'
|
import { useDashboardStore } from '@/stores/dashboard'
|
||||||
|
import { useGameStore } from '@/stores/game'
|
||||||
|
import XPBar from '@/components/XPBar.vue'
|
||||||
|
|
||||||
const store = useDashboardStore()
|
const store = useDashboardStore()
|
||||||
|
const gameStore = useGameStore()
|
||||||
const currentMonth = ref(new Date().toISOString().slice(0, 7))
|
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) {
|
function changeMonth(delta: number) {
|
||||||
const [y, m] = currentMonth.value.split('-').map(Number)
|
const [y, m] = currentMonth.value.split('-').map(Number)
|
||||||
@@ -83,6 +89,14 @@ const maxEvolution = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="savings-target">todas as contas</div>
|
<div class="savings-target">todas as contas</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom section: two columns on wide, stacked on mobile -->
|
<!-- 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-ok .savings-pct { color: #059669; }
|
||||||
.savings-low .savings-pct { color: #dc2626; }
|
.savings-low .savings-pct { color: #dc2626; }
|
||||||
.savings-target { font-size: 0.7rem; color: #9ca3af; margin-top: 0.2rem; }
|
.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 */
|
||||||
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }
|
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user