feat: add frame

This commit is contained in:
2026-07-08 12:34:21 +03:30
parent 525cd554ef
commit b5420bac21
12 changed files with 280 additions and 13 deletions
+3
View File
@@ -169,6 +169,9 @@ func main() {
r.Get("/carpets", ecoH.Carpets) r.Get("/carpets", ecoH.Carpets)
r.Post("/shop/buy-carpet", ecoH.BuyCarpet) r.Post("/shop/buy-carpet", ecoH.BuyCarpet)
r.Post("/shop/select-carpet", ecoH.SelectCarpet) 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("/shop/purchase", ecoH.Purchase)
r.Post("/rewards/daily", ecoH.Daily) r.Post("/rewards/daily", ecoH.Daily)
r.Post("/rewards/ad", ecoH.AdReward) r.Post("/rewards/ad", ecoH.AdReward)
+4 -4
View File
@@ -75,13 +75,13 @@ func (g gameSettler) RecordGameResult(userID int64, won bool) {
g.eco.AddTournamentPoints(context.Background(), userID, won) g.eco.AddTournamentPoints(context.Background(), userID, won)
} }
// PlayerProfile سکه، نشانِ رتبه و آواتارِ بازیکن را برای نمایش سرِ میز برمی‌گرداند. // PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ بازیکن را برای نمایش سرِ میز برمی‌گرداند.
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string) { func (g gameSettler) PlayerProfile(userID int64) (int64, string, string, string) {
p, err := g.eco.GetProfile(context.Background(), userID) p, err := g.eco.GetProfile(context.Background(), userID)
if err != nil || p == nil { 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 یک بلیت برای بُرِ مجدد مصرف می‌کند (خطا اگر بلیت کافی نباشد). // ChargeTicket یک بلیت برای بُرِ مجدد مصرف می‌کند (خطا اگر بلیت کافی نباشد).
+49
View File
@@ -35,6 +35,10 @@ func (h *Handler) APIRoutes() http.Handler {
r.Delete("/carpets", h.apiCarpetDelete) r.Delete("/carpets", h.apiCarpetDelete)
r.Post("/carpets/{id}/upload", h.carpetUpload) // multipart (اشتراکی) 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.Get("/chat", h.apiChat)
r.Post("/chat/pack", h.apiChatPackUpsert) r.Post("/chat/pack", h.apiChatPackUpsert)
r.Delete("/chat/pack", h.apiChatPackDelete) 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}) 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) { func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) {
+3 -2
View File
@@ -57,6 +57,7 @@ type Profile struct {
VIPUntil *string `json:"vip_until"` VIPUntil *string `json:"vip_until"`
SelectedCard string `json:"selected_card"` SelectedCard string `json:"selected_card"`
SelectedCarpet string `json:"selected_carpet"` SelectedCarpet string `json:"selected_carpet"`
SelectedFrame string `json:"selected_frame"`
RankPoints int64 `json:"rank_points"` RankPoints int64 `json:"rank_points"`
RankTier string `json:"rank_tier"` // bronze..king RankTier string `json:"rank_tier"` // bronze..king
RankIndex int `json:"rank_index"` // ۰..۴ RankIndex int `json:"rank_index"` // ۰..۴
@@ -72,8 +73,8 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
var p Profile var p Profile
var vipUntil, avatar, lastDaily sql.NullString var vipUntil, avatar, lastDaily sql.NullString
err := s.db.QueryRowContext(ctx, 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). `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.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily) 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
+118
View File
@@ -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
}
+64
View File
@@ -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 (بسته‌های پیام/شکلک + مالکیتِ کاربر) // ChatPacks — GET /api/chat-packs (بسته‌های پیام/شکلک + مالکیتِ کاربر)
func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) { func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r) id, ok := uid(r)
+28
View File
@@ -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);
+2 -1
View File
@@ -391,10 +391,11 @@ func (h *Hub) removeFromTable(t *pendingTable, c *Client) {
func (h *Hub) broadcastLobby(t *pendingTable) { func (h *Hub) broadcastLobby(t *pendingTable) {
players := make([]lobbyPlayer, len(t.clients)) players := make([]lobbyPlayer, len(t.clients))
for i, cl := range t.clients { for i, cl := range t.clients {
_, _, avatar := h.settler.PlayerProfile(cl.UserID) _, _, avatar, frame := h.settler.PlayerProfile(cl.UserID)
players[i] = lobbyPlayer{ players[i] = lobbyPlayer{
Name: cl.Name, Name: cl.Name,
Avatar: avatar, Avatar: avatar,
Frame: frame,
Host: cl.UserID == t.host, Host: cl.UserID == t.host,
Seat: t.seatOf(i), Seat: t.seatOf(i),
} }
+2
View File
@@ -29,6 +29,7 @@ type chatMsg struct {
type lobbyPlayer struct { type lobbyPlayer struct {
Name string `json:"name"` Name string `json:"name"`
Avatar string `json:"avatar"` // seed آواتارِ کاربر Avatar string `json:"avatar"` // seed آواتارِ کاربر
Frame string `json:"frame"` // قابِ آواتارِ انتخابی
Host bool `json:"host"` Host bool `json:"host"`
Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳) Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳)
} }
@@ -66,6 +67,7 @@ type PlayerInfo struct {
Coins int64 `json:"coins"` Coins int64 `json:"coins"`
RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه) RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه)
Avatar string `json:"avatar"` // seed آواتارِ کاربر Avatar string `json:"avatar"` // seed آواتارِ کاربر
Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک)
} }
// matchedMsg به کلاینت پس از تشکیل میز. // matchedMsg به کلاینت پس از تشکیل میز.
+3 -2
View File
@@ -40,6 +40,7 @@ type seatInfo struct {
coins int64 // برای نمایش سرِ میز coins int64 // برای نمایش سرِ میز
rankTier string // نشانِ رتبه برای نمایش سرِ میز rankTier string // نشانِ رتبه برای نمایش سرِ میز
avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز) avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز)
frame string // قابِ آواتارِ انتخابیِ کاربر (کازمتیک)
} }
// auto مشخص می‌کند جایگاه باید خودکار بازی شود (بات یا قطع‌شده). // auto مشخص می‌کند جایگاه باید خودکار بازی شود (بات یا قطع‌شده).
@@ -147,7 +148,7 @@ func (r *Room) start() {
if s.isBot || s.userID == 0 { if s.isBot || s.userID == 0 {
continue 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 { for seat, s := range r.seats {
if s.client != nil { if s.client != nil {
@@ -547,7 +548,7 @@ func (r *Room) playersInfo() []PlayerInfo {
out[i] = PlayerInfo{ out[i] = PlayerInfo{
Seat: i, Name: s.name, Bot: s.isBot, Seat: i, Name: s.name, Bot: s.isBot,
Connected: s.isBot || s.connected, 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 return out
+3 -3
View File
@@ -12,8 +12,8 @@ type Settler interface {
RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند
RecordCut(userID int64) // بُرِش با حکم RecordCut(userID int64) // بُرِش با حکم
RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت) RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت)
// PlayerProfile سکه، نشانِ رتبه و seedِ آواتارِ بازیکن را برای نمایش سرِ میز برمی‌گرداند. // PlayerProfile سکه، نشانِ رتبه، آواتار و قابِ انتخابیِ بازیکن را برای نمایش سرِ میز برمی‌گرداند.
PlayerProfile(userID int64) (coins int64, rankTier string, avatar string) PlayerProfile(userID int64) (coins int64, rankTier string, avatar string, frame string)
// ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف می‌کند. // ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف می‌کند.
ChargeTicket(userID int64) error ChargeTicket(userID int64) error
// ChargeCoins/AwardCoins برای شرطِ سکه‌ایِ میزِ خصوصی (pot). Charge در صورتِ // ChargeCoins/AwardCoins برای شرطِ سکه‌ایِ میزِ خصوصی (pot). Charge در صورتِ
@@ -38,7 +38,7 @@ func (noopSettler) RecordGame(string, string, int, bool) {}
func (noopSettler) RecordHand(int64, bool, bool, bool) {} func (noopSettler) RecordHand(int64, bool, bool, bool) {}
func (noopSettler) RecordCut(int64) {} func (noopSettler) RecordCut(int64) {}
func (noopSettler) RecordGameResult(int64, bool) {} 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) ChargeTicket(int64) error { return nil }
func (noopSettler) ChargeCoins(int64, int64, string) error { return nil } func (noopSettler) ChargeCoins(int64, int64, string) error { return nil }
func (noopSettler) AwardCoins(int64, int64, string) {} func (noopSettler) AwardCoins(int64, int64, string) {}
+1 -1
View File
@@ -481,7 +481,7 @@ func (s *recordingSettler) RecordGameResult(int64, bool) {
s.mu.Unlock() 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) ChargeTicket(int64) error { return nil }
func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil } func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil }
func (s *recordingSettler) AwardCoins(int64, int64, string) {} func (s *recordingSettler) AwardCoins(int64, int64, string) {}