feat: add characters
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package economy
|
||||
|
||||
import "context"
|
||||
|
||||
// Avatar یک شخصیتِ آواتار (کازمتیک تصویری) که در کلِ بازی دیده میشود. تصویرِ آن
|
||||
// از /avatars/<id>.png سرو میشود. Owned یعنی قابلِ انتخاب.
|
||||
type Avatar struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
Img string `json:"img"` // URL کاملِ تصویرِ شخصیت (URL-safe)
|
||||
Owned bool `json:"owned"`
|
||||
}
|
||||
|
||||
// Avatars آواتارهای فعال را با وضعیتِ مالکیتِ کاربر برمیگرداند.
|
||||
func (s *Service) Avatars(ctx context.Context, userID int64) ([]Avatar, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, title, price_coins, vip, img_ext FROM avatars WHERE enabled = 1 ORDER BY sort`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var avatars []Avatar
|
||||
for rows.Next() {
|
||||
var a Avatar
|
||||
var vip int
|
||||
var ext string
|
||||
if err := rows.Scan(&a.ID, &a.Title, &a.PriceCoins, &vip, &ext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.VIP = vip == 1
|
||||
a.Img = s.imgURL("avatars", a.ID+"."+ext) // URL کاملِ URL-safe
|
||||
avatars = append(avatars, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
owned, err := s.ownedAvatars(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isVIP := s.isVIP(ctx, userID)
|
||||
for i := range avatars {
|
||||
a := &avatars[i]
|
||||
a.Owned = (a.PriceCoins == 0 && !a.VIP) || (a.VIP && isVIP) || owned[a.ID]
|
||||
}
|
||||
return avatars, nil
|
||||
}
|
||||
|
||||
func (s *Service) ownedAvatars(ctx context.Context, userID int64) (map[string]bool, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT avatar_id FROM user_avatars WHERE user_id = ?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
owned := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned[id] = true
|
||||
}
|
||||
return owned, rows.Err()
|
||||
}
|
||||
|
||||
// BuyAvatar یک شخصیتِ آواتار را با سکه باز میکند.
|
||||
func (s *Service) BuyAvatar(ctx context.Context, userID int64, avatarID string) error {
|
||||
var price int64
|
||||
var vip, enabled int
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT price_coins, vip, enabled FROM avatars WHERE id = ?`, avatarID).
|
||||
Scan(&price, &vip, &enabled)
|
||||
if err != nil || enabled != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var exists int
|
||||
_ = tx.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM user_avatars WHERE user_id = ? AND avatar_id = ?`, userID, avatarID).Scan(&exists)
|
||||
if exists == 1 {
|
||||
return ErrAlreadyOwned
|
||||
}
|
||||
if price > 0 {
|
||||
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_avatar", avatarID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO user_avatars (user_id, avatar_id) VALUES (?, ?)`, userID, avatarID); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// SelectAvatar شخصیتِ انتخابیِ کاربر را تنظیم میکند (خالی ⇒ بازگشت به آواتارِ
|
||||
// تصادفی؛ وگرنه باید مالکش باشد یا رایگان/VIP).
|
||||
func (s *Service) SelectAvatar(ctx context.Context, userID int64, avatarID string) error {
|
||||
if avatarID == "" {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE users SET selected_avatar = '' WHERE id = ?`, userID)
|
||||
return err
|
||||
}
|
||||
avatars, err := s.Avatars(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range avatars {
|
||||
if a.ID == avatarID {
|
||||
if !a.Owned {
|
||||
return ErrNotOwned
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE users SET selected_avatar = ? WHERE id = ?`, avatarID, userID)
|
||||
return err
|
||||
}
|
||||
}
|
||||
return ErrNotFound
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type Carpet struct {
|
||||
Title string `json:"title"`
|
||||
PriceCoins int64 `json:"price_coins"`
|
||||
VIP bool `json:"vip"`
|
||||
Img string `json:"img"` // URL کاملِ تصویرِ فرش (خالی برای classic که تصویری ندارد)
|
||||
Owned bool `json:"owned"`
|
||||
}
|
||||
|
||||
@@ -28,6 +29,9 @@ func (s *Service) Carpets(ctx context.Context, userID int64) ([]Carpet, error) {
|
||||
return nil, err
|
||||
}
|
||||
c.VIP = vip == 1
|
||||
if c.ID != "classic" {
|
||||
c.Img = s.imgURL("carpets", c.ID+".jpg")
|
||||
}
|
||||
carpets = append(carpets, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -36,6 +37,8 @@ type Service struct {
|
||||
ad AdVerifier
|
||||
iap IAPVerifier
|
||||
|
||||
publicURL string // مبدأِ عمومیِ سرور برای ساختِ URL کاملِ تصاویر
|
||||
|
||||
mu sync.RWMutex // محافظ کشِ کاتالوگ
|
||||
cat Catalog // کشِ کاتالوگ خواندهشده از DB
|
||||
}
|
||||
@@ -44,6 +47,18 @@ func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
|
||||
return &Service{db: db, ad: ad, iap: iap}
|
||||
}
|
||||
|
||||
// SetPublicURL مبدأِ عمومیِ سرور را تنظیم میکند (برای URLهای کاملِ تصاویر).
|
||||
func (s *Service) SetPublicURL(u string) { s.publicURL = strings.TrimRight(u, "/") }
|
||||
|
||||
// imgURL یک URLِ کاملِ URL-safe برای فایلی در پوشهی [dir] میسازد ("" اگر نام خالی
|
||||
// باشد). نامِ فایل encode میشود تا فاصله/کاراکترِ خاص مشکلی ایجاد نکند.
|
||||
func (s *Service) imgURL(dir, name string) string {
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return s.publicURL + "/" + dir + "/" + url.PathEscape(name)
|
||||
}
|
||||
|
||||
// Profile وضعیت اقتصادی کاربر.
|
||||
type Profile struct {
|
||||
Coins int64 `json:"coins"`
|
||||
@@ -58,6 +73,9 @@ type Profile struct {
|
||||
SelectedCard string `json:"selected_card"`
|
||||
SelectedCarpet string `json:"selected_carpet"`
|
||||
SelectedFrame string `json:"selected_frame"`
|
||||
SelectedAvatar string `json:"selected_avatar"` // شناسهی شخصیتِ انتخابی (برای وضعیتِ انتخاب در فروشگاه)
|
||||
SelectedAvatarImg string `json:"selected_avatar_img"` // URL کاملِ تصویرِ شخصیت — خالی ⇒ آواتارِ تصادفی
|
||||
SelectedCarpetImg string `json:"selected_carpet_img"` // URL کاملِ تصویرِ فرش — خالی ⇒ میزِ کلاسیک
|
||||
RankPoints int64 `json:"rank_points"`
|
||||
RankTier string `json:"rank_tier"` // bronze..king
|
||||
RankIndex int `json:"rank_index"` // ۰..۴
|
||||
@@ -71,10 +89,12 @@ type Profile struct {
|
||||
// GetProfile وضعیت اقتصادی کاربر را میخواند.
|
||||
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
|
||||
var p Profile
|
||||
var vipUntil, avatar, lastDaily sql.NullString
|
||||
var vipUntil, avatar, lastDaily, avatarImg sql.NullString
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
|
||||
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
|
||||
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, selected_avatar,
|
||||
(SELECT id || '.' || img_ext FROM avatars WHERE id = users.selected_avatar AND enabled = 1),
|
||||
rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
|
||||
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.SelectedAvatar, &avatarImg, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -82,6 +102,11 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
|
||||
return nil, err
|
||||
}
|
||||
p.Avatar = avatar.String
|
||||
p.SelectedAvatarImg = s.imgURL("avatars", avatarImg.String)
|
||||
// فرش: 'classic' تصویری ندارد (میزِ ترسیمی)؛ بقیه از /carpets/<id>.jpg.
|
||||
if p.SelectedCarpet != "" && p.SelectedCarpet != "classic" {
|
||||
p.SelectedCarpetImg = s.imgURL("carpets", p.SelectedCarpet+".jpg")
|
||||
}
|
||||
p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP)
|
||||
p.RankTier, p.RankIndex = RankTier(p.RankPoints)
|
||||
if vipUntil.Valid {
|
||||
|
||||
@@ -180,6 +180,73 @@ func (h *Handler) Frames(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// BuyFrame — POST /api/shop/buy-frame
|
||||
// Avatars — GET /api/avatars: کاتالوگِ شخصیتها + انتخابیِ کاربر.
|
||||
func (h *Handler) Avatars(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
avatars, err := h.svc.Avatars(r.Context(), id)
|
||||
if err != nil {
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
return
|
||||
}
|
||||
selected := ""
|
||||
if p, _ := h.svc.GetProfile(r.Context(), id); p != nil {
|
||||
selected = p.SelectedAvatar
|
||||
}
|
||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||
"avatars": avatars,
|
||||
"selected": selected,
|
||||
})
|
||||
}
|
||||
|
||||
// BuyAvatar — POST /api/shop/buy-avatar
|
||||
func (h *Handler) BuyAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
h.avatarAction(w, r, false)
|
||||
}
|
||||
|
||||
// SelectAvatar — POST /api/shop/select-avatar (avatar_id خالی ⇒ آواتارِ پیشفرض)
|
||||
func (h *Handler) SelectAvatar(w http.ResponseWriter, r *http.Request) {
|
||||
h.avatarAction(w, r, true)
|
||||
}
|
||||
|
||||
func (h *Handler) avatarAction(w http.ResponseWriter, r *http.Request, sel bool) {
|
||||
id, ok := uid(r)
|
||||
if !ok {
|
||||
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
AvatarID string `json:"avatar_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
httpx.Error(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
if sel {
|
||||
err = h.svc.SelectAvatar(r.Context(), id, req.AvatarID)
|
||||
} else {
|
||||
err = h.svc.BuyAvatar(r.Context(), id, req.AvatarID)
|
||||
}
|
||||
switch {
|
||||
case err == nil:
|
||||
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
|
||||
case errors.Is(err, ErrNotFound):
|
||||
httpx.Error(w, http.StatusNotFound, "avatar not found")
|
||||
case errors.Is(err, ErrAlreadyOwned):
|
||||
httpx.Error(w, http.StatusConflict, "already owned")
|
||||
case errors.Is(err, ErrNotOwned):
|
||||
httpx.Error(w, http.StatusForbidden, "avatar not owned")
|
||||
case errors.Is(err, ErrInsufficient):
|
||||
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
|
||||
default:
|
||||
httpx.Error(w, http.StatusInternalServerError, "server error")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) BuyFrame(w http.ResponseWriter, r *http.Request) {
|
||||
h.frameAction(w, r, false)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user