From fe0b63083c7b902006c5640fd5d307bc4322b3ce Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Fri, 10 Jul 2026 00:56:03 +0330 Subject: [PATCH] feat: add characters --- cmd/server/main.go | 10 +- cmd/server/settler.go | 9 +- internal/admin/admin.go | 60 ++++++- internal/admin/api.go | 189 +++++++++++++++------- internal/admin/avatar_convert.go | 78 +++++++++ internal/admin/flex.go | 37 +++++ internal/config/config.go | 4 + internal/economy/avatar.go | 128 +++++++++++++++ internal/economy/carpet.go | 4 + internal/economy/economy.go | 31 +++- internal/economy/handler.go | 67 ++++++++ internal/static/static.go | 23 +++ internal/store/migrations/017_avatars.sql | 20 +++ internal/telescope/telescope.go | 23 ++- internal/ws/hub.go | 13 +- internal/ws/protocol.go | 14 +- internal/ws/room.go | 4 +- internal/ws/settler.go | 9 +- internal/ws/ws_test.go | 4 +- 19 files changed, 635 insertions(+), 92 deletions(-) create mode 100644 internal/admin/avatar_convert.go create mode 100644 internal/admin/flex.go create mode 100644 internal/economy/avatar.go create mode 100644 internal/store/migrations/017_avatars.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 7b278cd..5dea8c7 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -90,6 +90,9 @@ func main() { slog.Info("IAP bazaar (RSA) configured") } eco := economy.New(st.DB, economy.DevAdVerifier{}, iap) + eco.SetPublicURL(cfg.PublicURL) // برای ساختِ URL کاملِ تصاویر (آواتار/فرش) + // آواتارهای «svgِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن (یک‌بار، idempotent). + admin.ConvertRasterAvatars(context.Background(), st.DB, cfg.AvatarsDir) if err := eco.LoadCatalog(context.Background()); err != nil { slog.Error("load catalog", "err", err) os.Exit(1) @@ -134,9 +137,11 @@ func main() { r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir)) // تصاویرِ فرش (به‌جای میز) — از پنل ادمین آپلود می‌شوند. r.Handle("/carpets/*", static.CarpetsHandler(cfg.CarpetsDir)) + // تصاویرِ شخصیتِ آواتار — از پنل ادمین آپلود می‌شوند. + r.Handle("/avatars/*", static.AvatarsHandler(cfg.AvatarsDir)) // پنل ادمین (HTML سرور‌ساید، پشت Basic Auth) - r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass)) + r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir, cfg.AvatarsDir).Routes(cfg.AdminUser, cfg.AdminPass)) // بازرسِ درخواست‌ها (Telescope-lite) — پشتِ همان Basic Auth ادمین. r.Group(func(r chi.Router) { @@ -172,6 +177,9 @@ func main() { r.Get("/frames", ecoH.Frames) r.Post("/shop/buy-frame", ecoH.BuyFrame) r.Post("/shop/select-frame", ecoH.SelectFrame) + r.Get("/avatars", ecoH.Avatars) + r.Post("/shop/buy-avatar", ecoH.BuyAvatar) + r.Post("/shop/select-avatar", ecoH.SelectAvatar) r.Post("/shop/purchase", ecoH.Purchase) r.Post("/rewards/daily", ecoH.Daily) r.Post("/rewards/ad", ecoH.AdReward) diff --git a/cmd/server/settler.go b/cmd/server/settler.go index f4fd235..09136da 100644 --- a/cmd/server/settler.go +++ b/cmd/server/settler.go @@ -75,13 +75,14 @@ func (g gameSettler) RecordGameResult(userID int64, won bool) { g.eco.AddTournamentPoints(context.Background(), userID, won) } -// PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ بازیکن را برای نمایش سرِ میز برمی‌گرداند. -func (g gameSettler) PlayerProfile(userID int64) (int64, string, string, string) { +// PlayerProfile سکه، نشانِ رتبه، آواتار، قاب و شخصیتِ آواتارِ بازیکن را برای نمایشِ +// سرِ میز برمی‌گرداند. +func (g gameSettler) PlayerProfile(userID int64) (int64, string, string, string, string) { p, err := g.eco.GetProfile(context.Background(), userID) if err != nil || p == nil { - return 0, "", "", "" + return 0, "", "", "", "" } - return p.Coins, p.RankTier, p.Avatar, p.SelectedFrame + return p.Coins, p.RankTier, p.Avatar, p.SelectedFrame, p.SelectedAvatarImg } // ChargeTicket یک بلیت برای بُرِ مجدد مصرف می‌کند (خطا اگر بلیت کافی نباشد). diff --git a/internal/admin/admin.go b/internal/admin/admin.go index e5d4605..20ee713 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -33,11 +33,12 @@ type Handler struct { tpl *template.Template cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند) carpetsDir string // پوشه‌ی دیسکیِ آپلودِ تصاویرِ فرش + avatarsDir string // پوشه‌ی دیسکیِ آپلودِ تصاویرِ شخصیتِ آواتار } -func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir string) *Handler { +func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir, avatarsDir string) *Handler { tpl := template.Must(template.ParseFS(tplFS, "templates/*.html")) - return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir} + return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir, avatarsDir: avatarsDir} } // Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند. @@ -537,6 +538,61 @@ func (h *Handler) carpetUpload(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther) } +// avatarUpload یک تصویرِ png یا svg را برای یک شخصیتِ آواتار آپلود و با نامِ +// . ذخیره می‌کند و img_ext را در دیتابیس به‌روزرسانی می‌کند. نوعِ فایل از +// پسوندِ نامِ آپلودشده تشخیص داده می‌شود (svg → svg، وگرنه png). png برای شفافیت. +func (h *Handler) avatarUpload(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" || strings.ContainsAny(id, "/\\.") { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if h.avatarsDir == "" { + http.Error(w, "AVATARS_DIR تنظیم نشده است", http.StatusInternalServerError) + return + } + if err := r.ParseMultipartForm(16 << 20); err != nil { + http.Error(w, "bad upload", http.StatusBadRequest) + return + } + file, hdr, err := r.FormFile("image") + if err != nil { + http.Error(w, "image لازم است", http.StatusBadRequest) + return + } + defer file.Close() + buf, err := io.ReadAll(io.LimitReader(file, 16<<20)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + ext := "png" + if strings.HasSuffix(strings.ToLower(hdr.Filename), ".svg") { + ext = "svg" + // «SVGِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن تا اپ رندرش کند. + if raw, rext, ok := extractEmbeddedRaster(buf); ok { + buf, ext = raw, rext + } + } + if err := os.MkdirAll(h.avatarsDir, 0o755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // نسخه‌های قبلی با پسوندِ دیگر را پاک کن تا چند نسخه نماند. + for _, e := range []string{"png", "jpg", "svg"} { + if e != ext { + _ = os.Remove(filepath.Join(h.avatarsDir, id+"."+e)) + } + } + if err := os.WriteFile(filepath.Join(h.avatarsDir, id+"."+ext), buf, 0o644); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, _ = h.db.ExecContext(r.Context(), `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) +} + // --- مدیریتِ بسته‌های چت و پیام‌ها --- func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) { diff --git a/internal/admin/api.go b/internal/admin/api.go index 8bae7a3..0eb9aaa 100644 --- a/internal/admin/api.go +++ b/internal/admin/api.go @@ -39,6 +39,11 @@ func (h *Handler) APIRoutes() http.Handler { r.Post("/frames", h.apiFrameUpsert) r.Delete("/frames", h.apiFrameDelete) + r.Get("/avatars", h.apiAvatars) + r.Post("/avatars", h.apiAvatarUpsert) + r.Delete("/avatars", h.apiAvatarDelete) + r.Post("/avatars/{id}/upload", h.avatarUpload) // multipart (تصویرِ png) + r.Get("/rank-tiers", h.apiRankTiers) r.Post("/rank-tiers", h.apiRankTierUpsert) @@ -201,14 +206,14 @@ func (h *Handler) apiUsers(w http.ResponseWriter, r *http.Request) { func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) { var b struct { - UserID int64 `json:"user_id"` - Amount int64 `json:"amount"` + UserID flexInt `json:"user_id"` + Amount flexInt `json:"amount"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 { - httpx.Error(w, http.StatusBadRequest, "invalid body") + httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر") return } - if err := h.eco.Adjust(r.Context(), b.UserID, economy.CurrencyCoin, b.Amount, "admin_adjust", "panel"); err != nil { + if err := h.eco.Adjust(r.Context(), b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_adjust", "panel"); err != nil { httpx.Error(w, http.StatusBadRequest, err.Error()) return } @@ -217,26 +222,26 @@ func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) { func (h *Handler) apiUserGrant(w http.ResponseWriter, r *http.Request) { var b struct { - UserID int64 `json:"user_id"` - Kind string `json:"kind"` // coin | ticket | vip - Amount int64 `json:"amount"` - VIPDays int `json:"vip_days"` + UserID flexInt `json:"user_id"` + Kind string `json:"kind"` // coin | ticket | vip + Amount flexInt `json:"amount"` + VIPDays flexInt `json:"vip_days"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 { - httpx.Error(w, http.StatusBadRequest, "invalid body") + httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر") return } ctx := r.Context() var err error switch b.Kind { case "coin": - err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyCoin, b.Amount, "admin_grant", "panel") + err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_grant", "panel") case "ticket": - err = h.eco.Adjust(ctx, b.UserID, economy.CurrencyTicket, b.Amount, "admin_grant", "panel") + err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyTicket, b.Amount.i(), "admin_grant", "panel") case "vip": _, err = h.db.ExecContext(ctx, `UPDATE users SET vip_until = datetime('now', ?) WHERE id = ?`, - fmt.Sprintf("+%d days", b.VIPDays), b.UserID) + fmt.Sprintf("+%d days", b.VIPDays.i()), b.UserID.i()) default: httpx.Error(w, http.StatusBadRequest, "unknown kind") return @@ -260,15 +265,22 @@ func (h *Handler) apiCarpets(w http.ResponseWriter, r *http.Request) { func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) { var b struct { - ID string `json:"id"` - Title string `json:"title"` - PriceCoins int64 `json:"price_coins"` - VIP bool `json:"vip"` - Sort int `json:"sort"` + ID string `json:"id"` + Title string `json:"title"` + PriceCoins flexInt `json:"price_coins"` + VIP flexBool `json:"vip"` + Sort flexInt `json:"sort"` } - if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" || - strings.ContainsAny(b.ID, `/\.`) { - httpx.Error(w, http.StatusBadRequest, "invalid body") + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر") + return + } + if strings.TrimSpace(b.ID) == "" { + httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است") + return + } + if strings.ContainsAny(b.ID, `/\.`) { + httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد") return } vip := 0 @@ -277,7 +289,7 @@ func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) { } if _, err := h.db.ExecContext(r.Context(), `INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`, - b.ID, b.Title, b.PriceCoins, vip, b.Sort); err != nil { + b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } @@ -289,6 +301,57 @@ func (h *Handler) apiCarpetDelete(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } +// --- شخصیت‌های آواتار --- + +func (h *Handler) apiAvatars(w http.ResponseWriter, r *http.Request) { + httpx.JSON(w, http.StatusOK, map[string]any{ + "avatars": h.rows(r.Context(), + `SELECT id,title,price_coins,vip,sort,enabled,img_ext FROM avatars ORDER BY sort`, + "id", "title", "price_coins", "vip", "sort", "enabled", "img_ext"), + }) +} + +func (h *Handler) apiAvatarUpsert(w http.ResponseWriter, r *http.Request) { + var b struct { + ID string `json:"id"` + Title string `json:"title"` + PriceCoins flexInt `json:"price_coins"` + VIP flexBool `json:"vip"` + Sort flexInt `json:"sort"` + } + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر") + return + } + if strings.TrimSpace(b.ID) == "" { + httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است") + return + } + if strings.ContainsAny(b.ID, `/\.`) { + httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد") + return + } + vip := 0 + if b.VIP { + vip = 1 + } + // ON CONFLICT به‌جای REPLACE تا img_ext (نوعِ فایلِ آپلودشده) هنگامِ ویرایش پاک نشود. + if _, err := h.db.ExecContext(r.Context(), + `INSERT INTO avatars (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1) + ON CONFLICT(id) DO UPDATE SET title=excluded.title, price_coins=excluded.price_coins, + vip=excluded.vip, sort=excluded.sort, enabled=1`, + b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil { + httpx.Error(w, http.StatusInternalServerError, err.Error()) + return + } + httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) apiAvatarDelete(w http.ResponseWriter, r *http.Request) { + _, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatars WHERE id=?`, r.URL.Query().Get("id")) + httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // --- قاب‌های آواتار --- func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) { @@ -301,17 +364,17 @@ func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) { func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) { var b struct { - ID string `json:"id"` - Title string `json:"title"` - PriceCoins int64 `json:"price_coins"` - VIP bool `json:"vip"` - C1 string `json:"c1"` - C2 string `json:"c2"` - Sort int `json:"sort"` - Enabled *bool `json:"enabled"` + ID string `json:"id"` + Title string `json:"title"` + PriceCoins flexInt `json:"price_coins"` + VIP flexBool `json:"vip"` + C1 string `json:"c1"` + C2 string `json:"c2"` + Sort flexInt `json:"sort"` + Enabled *flexBool `json:"enabled"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" { - httpx.Error(w, http.StatusBadRequest, "invalid body") + httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است") return } vip := 0 @@ -326,7 +389,7 @@ func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) { clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") } if _, err := h.db.ExecContext(r.Context(), `INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,c1,c2,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`, - b.ID, b.Title, b.PriceCoins, vip, clean(b.C1), clean(b.C2), b.Sort, enabled); err != nil { + b.ID, b.Title, b.PriceCoins.i(), vip, clean(b.C1), clean(b.C2), b.Sort.i(), enabled); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } @@ -350,20 +413,20 @@ func (h *Handler) apiRankTiers(w http.ResponseWriter, r *http.Request) { func (h *Handler) apiRankTierUpsert(w http.ResponseWriter, r *http.Request) { var b struct { - ID string `json:"id"` - Label string `json:"label"` - C1 string `json:"c1"` - C2 string `json:"c2"` - Sort int `json:"sort"` + ID string `json:"id"` + Label string `json:"label"` + C1 string `json:"c1"` + C2 string `json:"c2"` + Sort flexInt `json:"sort"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" { - httpx.Error(w, http.StatusBadRequest, "invalid body") + httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است") return } clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") } if _, err := h.db.ExecContext(r.Context(), `INSERT OR REPLACE INTO rank_tiers (id,label,c1,c2,sort) VALUES (?,?,?,?,?)`, - b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort); err != nil { + b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort.i()); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } @@ -386,15 +449,15 @@ func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) { func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) { var b struct { - ID string `json:"id"` - Title string `json:"title"` - Kind string `json:"kind"` - PriceCoins int64 `json:"price_coins"` - VIP bool `json:"vip"` - Sort int `json:"sort"` + ID string `json:"id"` + Title string `json:"title"` + Kind string `json:"kind"` + PriceCoins flexInt `json:"price_coins"` + VIP flexBool `json:"vip"` + Sort flexInt `json:"sort"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" { - httpx.Error(w, http.StatusBadRequest, "invalid body") + httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است") return } if b.Kind != "emoji" { @@ -406,7 +469,7 @@ func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) { } if _, err := h.db.ExecContext(r.Context(), `INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`, - b.ID, b.Title, b.Kind, b.PriceCoins, vip, b.Sort); err != nil { + b.ID, b.Title, b.Kind, b.PriceCoins.i(), vip, b.Sort.i()); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } @@ -422,16 +485,16 @@ func (h *Handler) apiChatPackDelete(w http.ResponseWriter, r *http.Request) { func (h *Handler) apiChatMessageAdd(w http.ResponseWriter, r *http.Request) { var b struct { - PackID string `json:"pack_id"` - Body string `json:"body"` - Sort int `json:"sort"` + PackID string `json:"pack_id"` + Body string `json:"body"` + Sort flexInt `json:"sort"` } if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.PackID == "" || b.Body == "" { - httpx.Error(w, http.StatusBadRequest, "invalid body") + httpx.Error(w, http.StatusBadRequest, "بسته و متن لازم است") return } if _, err := h.db.ExecContext(r.Context(), - `INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort); err != nil { + `INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort.i()); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } @@ -452,13 +515,13 @@ func (h *Handler) apiTournaments(w http.ResponseWriter, r *http.Request) { } type tournamentBody struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - EntryFee int64 `json:"entry_fee"` - Prizes []int64 `json:"prizes"` - StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران - EndsAt string `json:"ends_at"` + ID flexInt `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + EntryFee flexInt `json:"entry_fee"` + Prizes []flexInt `json:"prizes"` + StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران + EndsAt string `json:"ends_at"` } // tournamentFromBody بدنه را به economy.Tournament (با زمانِ UTC) تبدیل می‌کند. @@ -471,9 +534,13 @@ func tournamentFromBody(b tournamentBody) economy.Tournament { } return t.UTC() } + prizes := make([]int64, len(b.Prizes)) + for i, p := range b.Prizes { + prizes[i] = p.i() + } return economy.Tournament{ - Title: b.Title, Description: b.Description, EntryFee: b.EntryFee, - Prizes: b.Prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt), + Title: b.Title, Description: b.Description, EntryFee: b.EntryFee.i(), + Prizes: prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt), } } @@ -496,7 +563,7 @@ func (h *Handler) apiTournamentUpdate(w http.ResponseWriter, r *http.Request) { httpx.Error(w, http.StatusBadRequest, "invalid body") return } - if err := h.eco.UpdateTournament(r.Context(), b.ID, tournamentFromBody(b)); err != nil { + if err := h.eco.UpdateTournament(r.Context(), b.ID.i(), tournamentFromBody(b)); err != nil { httpx.Error(w, http.StatusInternalServerError, err.Error()) return } diff --git a/internal/admin/avatar_convert.go b/internal/admin/avatar_convert.go new file mode 100644 index 0000000..923ea89 --- /dev/null +++ b/internal/admin/avatar_convert.go @@ -0,0 +1,78 @@ +package admin + +import ( + "context" + "database/sql" + "encoding/base64" + "os" + "path/filepath" + "regexp" + "strings" +) + +// برخی ابزارها (مثلِ خروجیِ AI) یک PNG را داخلِ یک با base64 قرار می‌دهند. +// flutter_svg نمی‌تواند این «SVGِ رستری» را رندر کند، پس آن را به PNG واقعی تبدیل +// می‌کنیم. +var rasterInSVG = regexp.MustCompile(`data:image/(png|jpe?g);base64,([A-Za-z0-9+/=\s]+)`) + +// extractEmbeddedRaster اگر محتوای SVG صرفاً یک تصویرِ رستریِ base64 باشد، آن را +// استخراج می‌کند (بایت‌های خام + پسوند). برای SVGهای واقعیِ برداری false برمی‌گرداند. +func extractEmbeddedRaster(svg []byte) (data []byte, ext string, ok bool) { + m := rasterInSVG.FindSubmatch(svg) + if m == nil { + return nil, "", false + } + ext = "png" + if strings.HasPrefix(string(m[1]), "jp") { + ext = "jpg" + } + b64 := regexp.MustCompile(`\s`).ReplaceAll(m[2], nil) + raw, err := base64.StdEncoding.DecodeString(string(b64)) + if err != nil || len(raw) == 0 { + return nil, "", false + } + return raw, ext, true +} + +// convertRasterAvatars فایل‌های آواتارِ svg را که در واقع رستر هستند به png/jpg +// تبدیل می‌کند و img_ext را در DB به‌روزرسانی می‌کند. یک‌بار در startup اجرا می‌شود +// (idempotent). برای رفعِ آواتارهایی که قبلاً به‌صورتِ «svgِ رستری» آپلود شده‌اند. +func convertRasterAvatars(ctx context.Context, db *sql.DB, dir string) { + if dir == "" { + return + } + rows, err := db.QueryContext(ctx, `SELECT id FROM avatars WHERE img_ext = 'svg'`) + if err != nil { + return + } + var ids []string + for rows.Next() { + var id string + if rows.Scan(&id) == nil { + ids = append(ids, id) + } + } + rows.Close() + + for _, id := range ids { + svgPath := filepath.Join(dir, id+".svg") + content, err := os.ReadFile(svgPath) + if err != nil { + continue + } + raw, ext, ok := extractEmbeddedRaster(content) + if !ok { + continue // SVGِ واقعیِ برداری — دست نزن. + } + if err := os.WriteFile(filepath.Join(dir, id+"."+ext), raw, 0o644); err != nil { + continue + } + _ = os.Remove(svgPath) + _, _ = db.ExecContext(ctx, `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id) + } +} + +// ConvertRasterAvatars نسخه‌ی عمومی برای فراخوانی از main در startup. +func ConvertRasterAvatars(ctx context.Context, db *sql.DB, dir string) { + convertRasterAvatars(ctx, db, dir) +} diff --git a/internal/admin/flex.go b/internal/admin/flex.go new file mode 100644 index 0000000..4d04755 --- /dev/null +++ b/internal/admin/flex.go @@ -0,0 +1,37 @@ +package admin + +import ( + "strconv" + "strings" +) + +// flexBool یک bool است که هنگامِ decode از JSON، مقادیرِ عددی/رشته‌ای را هم می‌پذیرد +// (true/false، 0/1، "1"/"true"). دلیل: SQLite بولین‌ها را int برمی‌گرداند و پنل +// همان مقدار را دوباره می‌فرستد؛ decodeِ سخت‌گیرِ Go عدد را در bool نمی‌پذیرد → 400. +type flexBool bool + +func (f *flexBool) UnmarshalJSON(b []byte) error { + s := strings.Trim(strings.TrimSpace(string(b)), `"`) + *f = s == "1" || s == "true" || s == "1.0" + return nil +} + +// flexInt یک int64 است که از عدد، رشته‌ی عددی، خالی یا null هم decode می‌شود +// (تا ورودی‌های متنیِ عددی مثلِ "2000" باعثِ 400 نشوند). +type flexInt int64 + +func (f *flexInt) UnmarshalJSON(b []byte) error { + s := strings.Trim(strings.TrimSpace(string(b)), `"`) + if s == "" || s == "null" { + *f = 0 + return nil + } + v, err := strconv.ParseFloat(s, 64) // "2000" و "2000.0" هر دو + if err != nil { + return err + } + *f = flexInt(int64(v)) + return nil +} + +func (f flexInt) i() int64 { return int64(f) } diff --git a/internal/config/config.go b/internal/config/config.go index 63d2b13..2d8320a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -23,6 +23,8 @@ type Config struct { AdminPass string // پسوردِ پنل ادمین (Basic Auth) CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار) CarpetsDir string // پوشه‌ی دیسکیِ تصاویرِ فرش (روی volume ماندگار) + AvatarsDir string // پوشه‌ی دیسکیِ تصاویرِ شخصیتِ آواتار (روی volume ماندگار) + PublicURL string // مبدأِ عمومیِ سرور (برای ساختِ URL کاملِ تصاویر مثلِ آواتار) TelescopeSize int // تعدادِ درخواستی که بازرسِ حافظه نگه می‌دارد // خرید درون‌برنامه‌ای (IAP) IAPPackage string // نامِ بسته‌ی اپ (برای مایکت/کافه) @@ -48,6 +50,8 @@ func Load() Config { AdminPass: env("ADMIN_PANEL_PASS", "admin"), CardsDir: env("CARDS_DIR", "carddata"), CarpetsDir: env("CARPETS_DIR", "carpetdata"), + AvatarsDir: env("AVATARS_DIR", "avatardata"), + PublicURL: strings.TrimRight(env("PUBLIC_URL", "https://hakem.approagency.ir"), "/"), TelescopeSize: envInt("TELESCOPE_SIZE", 200), IAPPackage: env("IAP_PACKAGE", ""), MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""), diff --git a/internal/economy/avatar.go b/internal/economy/avatar.go new file mode 100644 index 0000000..1f8f3d6 --- /dev/null +++ b/internal/economy/avatar.go @@ -0,0 +1,128 @@ +package economy + +import "context" + +// Avatar یک شخصیتِ آواتار (کازمتیک تصویری) که در کلِ بازی دیده می‌شود. تصویرِ آن +// از /avatars/.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 +} diff --git a/internal/economy/carpet.go b/internal/economy/carpet.go index 3c07e32..97edbda 100644 --- a/internal/economy/carpet.go +++ b/internal/economy/carpet.go @@ -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 { diff --git a/internal/economy/economy.go b/internal/economy/economy.go index e01441f..ed13b28 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -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/.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 { diff --git a/internal/economy/handler.go b/internal/economy/handler.go index 6dc7dbf..be68bd4 100644 --- a/internal/economy/handler.go +++ b/internal/economy/handler.go @@ -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) } diff --git a/internal/static/static.go b/internal/static/static.go index 83fb1d5..410bc08 100644 --- a/internal/static/static.go +++ b/internal/static/static.go @@ -49,6 +49,29 @@ func CardsHandler(diskDir string) http.Handler { }) } +// AvatarsHandler تصاویرِ شخصیتِ آواتار را سرو می‌کند: GET /avatars/.png +// فقط از پوشه‌ی دیسکیِ آپلودِ ادمین (بدونِ embed)؛ اگر نبود ۴۰۴ (اپ به آواتارِ +// تصادفیِ پیش‌فرض برمی‌گردد). +func AvatarsHandler(diskDir string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Cache-Control", "public, max-age=604800") + if diskDir != "" { + rel := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/avatars/")) + full := filepath.Join(diskDir, rel) + if st, err := os.Stat(full); err == nil && !st.IsDir() { + // SVG را با content-type درست بده تا مرورگر/پنل هم آن را نمایش دهد. + if strings.HasSuffix(strings.ToLower(full), ".svg") { + w.Header().Set("Content-Type", "image/svg+xml") + } + http.ServeFile(w, r, full) + return + } + } + http.NotFound(w, r) + }) +} + // CarpetsHandler تصاویرِ فرش را سرو می‌کند: GET /carpets/.jpg // ابتدا از پوشه‌ی دیسکی (آپلودِ ادمین)، سپس از فرش‌های embed‌شده‌ی پیش‌فرض. // اگر هیچ‌کدام نبود ۴۰۴ (اپ به میزِ ترسیمیِ classic برمی‌گردد). diff --git a/internal/store/migrations/017_avatars.sql b/internal/store/migrations/017_avatars.sql new file mode 100644 index 0000000..aa8a710 --- /dev/null +++ b/internal/store/migrations/017_avatars.sql @@ -0,0 +1,20 @@ +-- شخصیت‌های آواتار (کازمتیک تصویری): کاربر می‌خرد و در کلِ بازی نمایش داده می‌شود. +-- تصویرِ هر آواتار از پنل ادمین آپلود و در /avatars/.png سرو می‌شود. +CREATE TABLE IF NOT EXISTS avatars ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + price_coins INTEGER NOT NULL DEFAULT 0, + vip INTEGER NOT NULL DEFAULT 0, + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + img_ext TEXT NOT NULL DEFAULT 'png' -- png یا svg (بر اساسِ فایلِ آپلودشده) +); + +CREATE TABLE IF NOT EXISTS user_avatars ( + user_id INTEGER NOT NULL, + avatar_id TEXT NOT NULL, + PRIMARY KEY (user_id, avatar_id) +); + +-- شخصیتِ انتخابیِ کاربر؛ خالی ⇒ آواتارِ تصادفیِ پیش‌فرض (بر اساسِ seed). +ALTER TABLE users ADD COLUMN selected_avatar TEXT NOT NULL DEFAULT ''; diff --git a/internal/telescope/telescope.go b/internal/telescope/telescope.go index f68b500..2c5022d 100644 --- a/internal/telescope/telescope.go +++ b/internal/telescope/telescope.go @@ -19,8 +19,11 @@ import ( ) const ( - maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست + maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست (فقط برای نمایش) maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ + // سقفِ خواندنِ بدنه‌ی غیرِ multipart (JSON): کامل بازگردانده می‌شود تا هندلر + // همه را ببیند؛ فقط برای امنیت در برابرِ بدنه‌ی بسیار بزرگ محدود شده است. + maxCaptureBody = 4 << 20 // ۴ مگابایت ) // Entry یک درخواستِ ثبت‌شده را نگه می‌دارد. @@ -177,9 +180,21 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler { var reqBody string if req.Body != nil && req.Method != http.MethodGet { - b, _ := io.ReadAll(io.LimitReader(req.Body, maxReqBody)) - req.Body = io.NopCloser(bytes.NewReader(b)) - reqBody = maskSecrets(string(b)) + // آپلودِ فایل (multipart) را دست نزن؛ بدنه بزرگ است و باید کامل به + // هندلر برسد (وگرنه ParseMultipartForm شکست می‌خورد ⇒ «bad upload»). + if strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data") { + reqBody = "[multipart upload]" + } else { + // بدنه را کامل بخوان و کامل بازگردان (تا هندلر همه‌ی آن را ببیند)، + // اما فقط بخشِ ابتدایی را برای نمایش در تلسکوپ نگه‌دار. + b, _ := io.ReadAll(io.LimitReader(req.Body, maxCaptureBody)) + req.Body = io.NopCloser(bytes.NewReader(b)) + n := len(b) + if n > maxReqBody { + n = maxReqBody + } + reqBody = maskSecrets(string(b[:n])) + } } var uid int64 diff --git a/internal/ws/hub.go b/internal/ws/hub.go index c3f5d9d..b1474ba 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -391,13 +391,14 @@ func (h *Hub) removeFromTable(t *pendingTable, c *Client) { func (h *Hub) broadcastLobby(t *pendingTable) { players := make([]lobbyPlayer, len(t.clients)) for i, cl := range t.clients { - _, _, avatar, frame := h.settler.PlayerProfile(cl.UserID) + _, _, avatar, frame, avatarImg := h.settler.PlayerProfile(cl.UserID) players[i] = lobbyPlayer{ - Name: cl.Name, - Avatar: avatar, - Frame: frame, - Host: cl.UserID == t.host, - Seat: t.seatOf(i), + Name: cl.Name, + Avatar: avatar, + Frame: frame, + AvatarImg: avatarImg, + Host: cl.UserID == t.host, + Seat: t.seatOf(i), } } for i, cl := range t.clients { diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 623bec2..ec822a1 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -27,10 +27,11 @@ type chatMsg struct { // lobbyPlayer یک بازیکن در اتاق انتظارِ میز خصوصی. type lobbyPlayer struct { - Name string `json:"name"` - Avatar string `json:"avatar"` // seed آواتارِ کاربر - Frame string `json:"frame"` // قابِ آواتارِ انتخابی - Host bool `json:"host"` + Name string `json:"name"` + Avatar string `json:"avatar"` // seed آواتارِ کاربر + Frame string `json:"frame"` // قابِ آواتارِ انتخابی + AvatarImg string `json:"avatar_img"` // شخصیتِ آواتارِ انتخابی (خالی ⇒ تصادفی) + Host bool `json:"host"` Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳) } @@ -66,8 +67,9 @@ type PlayerInfo struct { Connected bool `json:"connected"` Coins int64 `json:"coins"` RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه) - Avatar string `json:"avatar"` // seed آواتارِ کاربر - Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک) + Avatar string `json:"avatar"` // seed آواتارِ کاربر + Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک) + AvatarImg string `json:"avatar_img"` // شخصیتِ آواتارِ انتخابی (خالی ⇒ تصادفی) } // matchedMsg به کلاینت پس از تشکیل میز. diff --git a/internal/ws/room.go b/internal/ws/room.go index e2b6a3d..7a1c01f 100644 --- a/internal/ws/room.go +++ b/internal/ws/room.go @@ -41,6 +41,7 @@ type seatInfo struct { rankTier string // نشانِ رتبه برای نمایش سرِ میز avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز) frame string // قابِ آواتارِ انتخابیِ کاربر (کازمتیک) + avatarImg string // شناسه‌ی شخصیتِ آواتارِ انتخابی (خالی ⇒ آواتارِ تصادفی) } // auto مشخص می‌کند جایگاه باید خودکار بازی شود (بات یا قطع‌شده). @@ -148,7 +149,7 @@ func (r *Room) start() { if s.isBot || s.userID == 0 { continue } - s.coins, s.rankTier, s.avatar, s.frame = r.settler.PlayerProfile(s.userID) + s.coins, s.rankTier, s.avatar, s.frame, s.avatarImg = r.settler.PlayerProfile(s.userID) } for seat, s := range r.seats { if s.client != nil { @@ -549,6 +550,7 @@ func (r *Room) playersInfo() []PlayerInfo { Seat: i, Name: s.name, Bot: s.isBot, Connected: s.isBot || s.connected, Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, Frame: s.frame, + AvatarImg: s.avatarImg, } } return out diff --git a/internal/ws/settler.go b/internal/ws/settler.go index 0a684de..2e127bc 100644 --- a/internal/ws/settler.go +++ b/internal/ws/settler.go @@ -12,8 +12,9 @@ type Settler interface { RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند RecordCut(userID int64) // بُرِش با حکم RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت) - // PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ انتخابیِ بازیکن را برای نمایش سرِ میز برمی‌گرداند. - PlayerProfile(userID int64) (coins int64, rankTier string, avatar string, frame string) + // PlayerProfile سکه، نشانِ رتبه، آواتار، قاب و شخصیتِ آواتارِ انتخابیِ بازیکن را + // برای نمایش سرِ میز برمی‌گرداند (avatarImg خالی ⇒ آواتارِ تصادفیِ پیش‌فرض). + PlayerProfile(userID int64) (coins int64, rankTier, avatar, frame, avatarImg string) // ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف می‌کند. ChargeTicket(userID int64) error // ChargeCoins/AwardCoins برای شرطِ سکه‌ایِ میزِ خصوصی (pot). Charge در صورتِ @@ -38,7 +39,9 @@ func (noopSettler) RecordGame(string, string, int, bool) {} func (noopSettler) RecordHand(int64, bool, bool, bool) {} func (noopSettler) RecordCut(int64) {} func (noopSettler) RecordGameResult(int64, bool) {} -func (noopSettler) PlayerProfile(int64) (int64, string, string, string) { return 0, "", "", "" } +func (noopSettler) PlayerProfile(int64) (int64, string, string, string, string) { + return 0, "", "", "", "" +} func (noopSettler) ChargeTicket(int64) error { return nil } func (noopSettler) ChargeCoins(int64, int64, string) error { return nil } func (noopSettler) AwardCoins(int64, int64, string) {} diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go index 9e2a62e..a4d1d16 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -481,7 +481,9 @@ func (s *recordingSettler) RecordGameResult(int64, bool) { s.mu.Unlock() } -func (s *recordingSettler) PlayerProfile(int64) (int64, string, string, string) { return 0, "", "", "" } +func (s *recordingSettler) PlayerProfile(int64) (int64, string, string, string, string) { + return 0, "", "", "", "" +} func (s *recordingSettler) ChargeTicket(int64) error { return nil } func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil } func (s *recordingSettler) AwardCoins(int64, int64, string) {}