From b5420bac2199e85e9e6ab6a28ec4eabe921b9fbc Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Wed, 8 Jul 2026 12:34:21 +0330 Subject: [PATCH] feat: add frame --- cmd/server/main.go | 3 + cmd/server/settler.go | 8 +- internal/admin/api.go | 49 ++++++++++ internal/economy/economy.go | 5 +- internal/economy/frame.go | 118 +++++++++++++++++++++++ internal/economy/handler.go | 64 ++++++++++++ internal/store/migrations/014_frames.sql | 28 ++++++ internal/ws/hub.go | 3 +- internal/ws/protocol.go | 2 + internal/ws/room.go | 5 +- internal/ws/settler.go | 6 +- internal/ws/ws_test.go | 2 +- 12 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 internal/economy/frame.go create mode 100644 internal/store/migrations/014_frames.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index aa198e0..7b278cd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -169,6 +169,9 @@ func main() { r.Get("/carpets", ecoH.Carpets) r.Post("/shop/buy-carpet", ecoH.BuyCarpet) r.Post("/shop/select-carpet", ecoH.SelectCarpet) + r.Get("/frames", ecoH.Frames) + r.Post("/shop/buy-frame", ecoH.BuyFrame) + r.Post("/shop/select-frame", ecoH.SelectFrame) 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 5c08966..f4fd235 100644 --- a/cmd/server/settler.go +++ b/cmd/server/settler.go @@ -75,13 +75,13 @@ 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) { +// PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ بازیکن را برای نمایش سرِ میز برمی‌گرداند. +func (g gameSettler) PlayerProfile(userID int64) (int64, 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 + return p.Coins, p.RankTier, p.Avatar, p.SelectedFrame } // ChargeTicket یک بلیت برای بُرِ مجدد مصرف می‌کند (خطا اگر بلیت کافی نباشد). diff --git a/internal/admin/api.go b/internal/admin/api.go index d5a728f..66977e5 100644 --- a/internal/admin/api.go +++ b/internal/admin/api.go @@ -35,6 +35,10 @@ func (h *Handler) APIRoutes() http.Handler { r.Delete("/carpets", h.apiCarpetDelete) r.Post("/carpets/{id}/upload", h.carpetUpload) // multipart (اشتراکی) + r.Get("/frames", h.apiFrames) + r.Post("/frames", h.apiFrameUpsert) + r.Delete("/frames", h.apiFrameDelete) + r.Get("/chat", h.apiChat) r.Post("/chat/pack", h.apiChatPackUpsert) r.Delete("/chat/pack", h.apiChatPackDelete) @@ -282,6 +286,51 @@ func (h *Handler) apiCarpetDelete(w http.ResponseWriter, r *http.Request) { httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) } +// --- قاب‌های آواتار --- + +func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) { + httpx.JSON(w, http.StatusOK, map[string]any{ + "frames": h.rows(r.Context(), + `SELECT id,title,price_coins,vip,sort,enabled FROM avatar_frames ORDER BY sort`, + "id", "title", "price_coins", "vip", "sort", "enabled"), + }) +} + +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"` + Sort int `json:"sort"` + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + vip := 0 + if b.VIP { + vip = 1 + } + enabled := 1 + if b.Enabled != nil && !*b.Enabled { + enabled = 0 + } + if _, err := h.db.ExecContext(r.Context(), + `INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?)`, + b.ID, b.Title, b.PriceCoins, vip, b.Sort, enabled); err != nil { + httpx.Error(w, http.StatusInternalServerError, err.Error()) + return + } + httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (h *Handler) apiFrameDelete(w http.ResponseWriter, r *http.Request) { + _, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatar_frames WHERE id=?`, r.URL.Query().Get("id")) + httpx.JSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // --- چت --- func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) { diff --git a/internal/economy/economy.go b/internal/economy/economy.go index 335ffe6..e01441f 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -57,6 +57,7 @@ type Profile struct { VIPUntil *string `json:"vip_until"` SelectedCard string `json:"selected_card"` SelectedCarpet string `json:"selected_carpet"` + SelectedFrame string `json:"selected_frame"` RankPoints int64 `json:"rank_points"` RankTier string `json:"rank_tier"` // bronze..king RankIndex int `json:"rank_index"` // ۰..۴ @@ -72,8 +73,8 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error var p Profile var vipUntil, avatar, lastDaily sql.NullString err := s.db.QueryRowContext(ctx, - `SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, 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.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily) + `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) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } diff --git a/internal/economy/frame.go b/internal/economy/frame.go new file mode 100644 index 0000000..ba90d35 --- /dev/null +++ b/internal/economy/frame.go @@ -0,0 +1,118 @@ +package economy + +import "context" + +// Frame یک قابِ آواتار (کازمتیک). Owned یعنی قابلِ انتخاب. سبکِ رنگی سمتِ کلاینت است. +type Frame struct { + ID string `json:"id"` + Title string `json:"title"` + PriceCoins int64 `json:"price_coins"` + VIP bool `json:"vip"` + Owned bool `json:"owned"` +} + +// Frames قاب‌های فعال را با وضعیتِ مالکیتِ کاربر برمی‌گرداند. +func (s *Service) Frames(ctx context.Context, userID int64) ([]Frame, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, title, price_coins, vip FROM avatar_frames WHERE enabled = 1 ORDER BY sort`) + if err != nil { + return nil, err + } + defer rows.Close() + + var frames []Frame + for rows.Next() { + var f Frame + var vip int + if err := rows.Scan(&f.ID, &f.Title, &f.PriceCoins, &vip); err != nil { + return nil, err + } + f.VIP = vip == 1 + frames = append(frames, f) + } + if err := rows.Err(); err != nil { + return nil, err + } + + owned, err := s.ownedFrames(ctx, userID) + if err != nil { + return nil, err + } + isVIP := s.isVIP(ctx, userID) + for i := range frames { + f := &frames[i] + f.Owned = (f.PriceCoins == 0 && !f.VIP) || (f.VIP && isVIP) || owned[f.ID] + } + return frames, nil +} + +func (s *Service) ownedFrames(ctx context.Context, userID int64) (map[string]bool, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT frame_id FROM user_frames 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() +} + +// BuyFrame یک قاب را با سکه باز می‌کند. +func (s *Service) BuyFrame(ctx context.Context, userID int64, frameID string) error { + var price int64 + var vip, enabled int + err := s.db.QueryRowContext(ctx, + `SELECT price_coins, vip, enabled FROM avatar_frames WHERE id = ?`, frameID). + 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_frames WHERE user_id = ? AND frame_id = ?`, userID, frameID).Scan(&exists) + if exists == 1 { + return ErrAlreadyOwned + } + if price > 0 { + if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_frame", frameID); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO user_frames (user_id, frame_id) VALUES (?, ?)`, userID, frameID); err != nil { + return err + } + return tx.Commit() +} + +// SelectFrame قابِ انتخابیِ کاربر را تنظیم می‌کند (باید مالکش باشد یا رایگان/VIP). +func (s *Service) SelectFrame(ctx context.Context, userID int64, frameID string) error { + frames, err := s.Frames(ctx, userID) + if err != nil { + return err + } + for _, f := range frames { + if f.ID == frameID { + if !f.Owned { + return ErrNotOwned + } + _, err := s.db.ExecContext(ctx, + `UPDATE users SET selected_frame = ? WHERE id = ?`, frameID, userID) + return err + } + } + return ErrNotFound +} diff --git a/internal/economy/handler.go b/internal/economy/handler.go index 114da1d..445f0a4 100644 --- a/internal/economy/handler.go +++ b/internal/economy/handler.go @@ -155,6 +155,70 @@ func (h *Handler) carpetAction(w http.ResponseWriter, r *http.Request, sel bool) } } +// Frames — GET /api/frames (قاب‌های آواتار + مالکیت + انتخابِ کاربر) +func (h *Handler) Frames(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + frames, err := h.svc.Frames(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.SelectedFrame + } + httpx.JSON(w, http.StatusOK, map[string]any{"frames": frames, "selected": selected}) +} + +// BuyFrame — POST /api/shop/buy-frame +func (h *Handler) BuyFrame(w http.ResponseWriter, r *http.Request) { + h.frameAction(w, r, false) +} + +// SelectFrame — POST /api/shop/select-frame +func (h *Handler) SelectFrame(w http.ResponseWriter, r *http.Request) { + h.frameAction(w, r, true) +} + +func (h *Handler) frameAction(w http.ResponseWriter, r *http.Request, sel bool) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + var req struct { + FrameID string `json:"frame_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.SelectFrame(r.Context(), id, req.FrameID) + } else { + err = h.svc.BuyFrame(r.Context(), id, req.FrameID) + } + switch { + case err == nil: + httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"}) + case errors.Is(err, ErrNotFound): + httpx.Error(w, http.StatusNotFound, "frame not found") + case errors.Is(err, ErrAlreadyOwned): + httpx.Error(w, http.StatusConflict, "already owned") + case errors.Is(err, ErrNotOwned): + httpx.Error(w, http.StatusForbidden, "frame not owned") + case errors.Is(err, ErrInsufficient): + httpx.Error(w, http.StatusPaymentRequired, "insufficient coins") + default: + httpx.Error(w, http.StatusInternalServerError, "server error") + } +} + // ChatPacks — GET /api/chat-packs (بسته‌های پیام/شکلک + مالکیتِ کاربر) func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) { id, ok := uid(r) diff --git a/internal/store/migrations/014_frames.sql b/internal/store/migrations/014_frames.sql new file mode 100644 index 0000000..5e6f6b0 --- /dev/null +++ b/internal/store/migrations/014_frames.sql @@ -0,0 +1,28 @@ +-- قاب‌های آواتار (کازمتیکِ قابلِ‌خرید). سبکِ هر قاب (رنگ‌ها) سمتِ کلاینت است؛ +-- اینجا فقط شناسه/قیمت/فعال‌بودن نگه‌داری می‌شود. 'none' یعنی قابِ پیش‌فرضِ رتبه. +CREATE TABLE IF NOT EXISTS avatar_frames ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + price_coins INTEGER NOT NULL DEFAULT 0, -- ۰ یعنی رایگان (مگر vip) + vip INTEGER NOT NULL DEFAULT 0, -- ۱ یعنی برای VIP رایگان + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS user_frames ( + user_id INTEGER NOT NULL, + frame_id TEXT NOT NULL, + PRIMARY KEY (user_id, frame_id) +); + +-- قابِ انتخابیِ کاربر؛ خالی/'none' یعنی قابِ پیش‌فرضِ رتبه‌ای. +ALTER TABLE users ADD COLUMN selected_frame TEXT NOT NULL DEFAULT ''; + +INSERT OR IGNORE INTO avatar_frames (id, title, price_coins, vip, sort) VALUES + ('none', 'پیش‌فرض (رتبه)', 0, 0, 1), + ('gold', 'قابِ طلایی', 5000, 0, 2), + ('fire', 'قابِ آتش', 12000, 0, 3), + ('emerald', 'قابِ زمرد', 12000, 0, 4), + ('ocean', 'قابِ اقیانوس', 15000, 0, 5), + ('rose', 'قابِ گل‌سرخ', 15000, 0, 6), + ('royal', 'قابِ سلطنتی', 0, 1, 7); diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 8542729..c3f5d9d 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -391,10 +391,11 @@ 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 := h.settler.PlayerProfile(cl.UserID) + _, _, avatar, frame := h.settler.PlayerProfile(cl.UserID) players[i] = lobbyPlayer{ Name: cl.Name, Avatar: avatar, + Frame: frame, Host: cl.UserID == t.host, Seat: t.seatOf(i), } diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 3311cb0..623bec2 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -29,6 +29,7 @@ type chatMsg struct { type lobbyPlayer struct { Name string `json:"name"` Avatar string `json:"avatar"` // seed آواتارِ کاربر + Frame string `json:"frame"` // قابِ آواتارِ انتخابی Host bool `json:"host"` Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳) } @@ -66,6 +67,7 @@ type PlayerInfo struct { Coins int64 `json:"coins"` RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه) Avatar string `json:"avatar"` // seed آواتارِ کاربر + Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک) } // matchedMsg به کلاینت پس از تشکیل میز. diff --git a/internal/ws/room.go b/internal/ws/room.go index 33edd5e..e2b6a3d 100644 --- a/internal/ws/room.go +++ b/internal/ws/room.go @@ -40,6 +40,7 @@ type seatInfo struct { coins int64 // برای نمایش سرِ میز rankTier string // نشانِ رتبه برای نمایش سرِ میز avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز) + frame string // قابِ آواتارِ انتخابیِ کاربر (کازمتیک) } // auto مشخص می‌کند جایگاه باید خودکار بازی شود (بات یا قطع‌شده). @@ -147,7 +148,7 @@ func (r *Room) start() { if s.isBot || s.userID == 0 { continue } - s.coins, s.rankTier, s.avatar = r.settler.PlayerProfile(s.userID) + s.coins, s.rankTier, s.avatar, s.frame = r.settler.PlayerProfile(s.userID) } for seat, s := range r.seats { if s.client != nil { @@ -547,7 +548,7 @@ func (r *Room) playersInfo() []PlayerInfo { out[i] = PlayerInfo{ Seat: i, Name: s.name, Bot: s.isBot, Connected: s.isBot || s.connected, - Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, + Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, Frame: s.frame, } } return out diff --git a/internal/ws/settler.go b/internal/ws/settler.go index 521468e..0a684de 100644 --- a/internal/ws/settler.go +++ b/internal/ws/settler.go @@ -12,8 +12,8 @@ type Settler interface { RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند RecordCut(userID int64) // بُرِش با حکم RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت) - // PlayerProfile سکه، نشانِ رتبه و seedِ آواتارِ بازیکن را برای نمایش سرِ میز برمی‌گرداند. - PlayerProfile(userID int64) (coins int64, rankTier string, avatar string) + // PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ انتخابیِ بازیکن را برای نمایش سرِ میز برمی‌گرداند. + PlayerProfile(userID int64) (coins int64, rankTier string, avatar string, frame string) // ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف می‌کند. ChargeTicket(userID int64) error // ChargeCoins/AwardCoins برای شرطِ سکه‌ایِ میزِ خصوصی (pot). Charge در صورتِ @@ -38,7 +38,7 @@ 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) { return 0, "", "" } +func (noopSettler) PlayerProfile(int64) (int64, 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 73cbaf1..9e2a62e 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -481,7 +481,7 @@ func (s *recordingSettler) RecordGameResult(int64, bool) { s.mu.Unlock() } -func (s *recordingSettler) PlayerProfile(int64) (int64, string, string) { return 0, "", "" } +func (s *recordingSettler) PlayerProfile(int64) (int64, 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) {}