diff --git a/Dockerfile b/Dockerfile index 9aa0b54..54a2a14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/serv FROM gcr.io/distroless/static-debian12 WORKDIR /app COPY --from=build /out/server /app/server -ENV ADDR=:8080 DB_PATH=/data/hakemsho.db +ENV ADDR=:8080 DB_PATH=/data/hakemsho.db CARDS_DIR=/data/cards EXPOSE 8080 VOLUME ["/data"] USER nonroot:nonroot diff --git a/cmd/server/main.go b/cmd/server/main.go index 8194a5f..23c43bb 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "hakemsho/internal/config" "hakemsho/internal/economy" "hakemsho/internal/httpx" + "hakemsho/internal/static" "hakemsho/internal/store" "hakemsho/internal/user" "hakemsho/internal/ws" @@ -80,8 +81,11 @@ func main() { // WebSocket بازی (احراز هویت با توکن در ?token= یا هدر Authorization) r.Get("/ws", hub.ServeWS) + // تصاویرِ کارت (هر اسکین یک پوشه) — از backend سرویس می‌شود تا اپ سبک بماند. + r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir)) + // پنل ادمین (HTML سرور‌ساید، پشت Basic Auth) - r.Mount("/admin", admin.New(st.DB, eco).Routes(cfg.AdminUser, cfg.AdminPass)) + r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir).Routes(cfg.AdminUser, cfg.AdminPass)) r.Route("/api", func(r chi.Router) { r.Route("/auth", func(r chi.Router) { @@ -97,6 +101,7 @@ func main() { // کیف‌پول و فروشگاه r.Get("/wallet", ecoH.Wallet) r.Get("/stats", ecoH.Stats) + r.Get("/leaderboard", ecoH.Leaderboard) r.Get("/tables/info", ecoH.TablesInfo) r.Get("/shop", ecoH.Shop) r.Post("/shop/buy-card", ecoH.BuyCard) diff --git a/cmd/server/settler.go b/cmd/server/settler.go index 8bb14fe..3fb4eb9 100644 --- a/cmd/server/settler.go +++ b/cmd/server/settler.go @@ -41,6 +41,17 @@ func (g gameSettler) AwardWinner(userID int64, tier string) { } } +// AwardRank امتیازِ رتبه را در میزِ رتبه‌بندی به‌روزرسانی می‌کند. +func (g gameSettler) AwardRank(userID int64, tier string, won bool) { + t := g.eco.FindTier(tier) + if t == nil || t.RankReward <= 0 { + return + } + if err := g.eco.AwardRank(context.Background(), userID, t.RankReward, won); err != nil { + slog.Error("award rank", "user", userID, "err", err) + } +} + func (g gameSettler) RecordGame(room, playersJSON string, winnerTeam int, kot bool) { if err := g.eco.RecordGame(context.Background(), room, playersJSON, winnerTeam, kot); err != nil { slog.Error("record game", "room", room, "err", err) @@ -62,6 +73,15 @@ func (g gameSettler) RecordGameResult(userID int64, won bool) { g.eco.RecordGameResult(context.Background(), userID, won) } +// PlayerProfile سکه و نشانِ رتبه‌ی بازیکن را برای نمایش سرِ میز برمی‌گرداند. +func (g gameSettler) PlayerProfile(userID int64) (int64, string) { + p, err := g.eco.GetProfile(context.Background(), userID) + if err != nil || p == nil { + return 0, "" + } + return p.Coins, p.RankTier +} + // ChargePrivateTable یک میز خصوصیِ رایگان را برای کاربر مصرف می‌کند. func (g gameSettler) ChargePrivateTable(userID int64) error { return g.eco.ConsumePrivateTable(context.Background(), userID) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 799a619..4ca6eb7 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -3,12 +3,18 @@ package admin import ( + "archive/zip" "context" "database/sql" "embed" + "fmt" "html/template" + "io" "net/http" + "os" + "path/filepath" "strconv" + "strings" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" @@ -20,14 +26,15 @@ import ( var tplFS embed.FS type Handler struct { - db *sql.DB - eco *economy.Service - tpl *template.Template + db *sql.DB + eco *economy.Service + tpl *template.Template + cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند) } -func New(db *sql.DB, eco *economy.Service) *Handler { +func New(db *sql.DB, eco *economy.Service, cardsDir string) *Handler { tpl := template.Must(template.ParseFS(tplFS, "templates/*.html")) - return &Handler{db: db, eco: eco, tpl: tpl} + return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir} } // Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند. @@ -38,10 +45,12 @@ func (h *Handler) Routes(user, pass string) http.Handler { r.Get("/shop", h.shop) r.Post("/shop/{kind}/add", h.addItem) r.Post("/shop/{kind}/delete", h.deleteItem) + r.Post("/shop/card/{id}/upload", h.uploadDeck) r.Post("/shop/{kind}", h.updateItem) r.Get("/users", h.users) r.Post("/users/coins", h.adjustCoins) r.Post("/users/grant", h.grantToUser) + r.Post("/season/reset", h.resetSeason) return r } @@ -66,6 +75,8 @@ func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) { "Purchases": count(`SELECT COUNT(*) FROM purchases WHERE status='verified'`), "Games": count(`SELECT COUNT(*) FROM game_history`), "Coins": count(`SELECT COALESCE(SUM(coins),0) FROM users`), + "Season": h.eco.Season(ctx), + "Saved": r.URL.Query().Get("saved") == "1", "Nav": "dashboard", } h.render(w, "dashboard.html", data) @@ -87,8 +98,8 @@ func (h *Handler) shop(w http.ResponseWriter, r *http.Request) { "id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"), "VIP": h.rows(ctx, `SELECT id,title,months,price_toman,sort,enabled FROM vip_packages ORDER BY sort`, "id", "title", "months", "price_toman", "sort", "enabled"), - "Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,sort,enabled FROM table_tiers ORDER BY sort`, - "id", "title", "hands", "entry", "prize", "xp", "trophy", "sort", "enabled"), + "Tiers": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled FROM table_tiers ORDER BY sort`, + "id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"), "Saved": r.URL.Query().Get("saved") == "1", } h.render(w, "shop.html", data) @@ -127,8 +138,8 @@ func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) { q = `UPDATE vip_packages SET title=?,months=?,price_toman=?,sort=?,enabled=? WHERE id=?` args = []any{r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en, id} case "tier": - q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,sort=?,enabled=? WHERE id=?` - args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en, id} + q = `UPDATE table_tiers SET title=?,hands=?,entry=?,prize=?,xp=?,trophy=?,rank_reward=?,sort=?,enabled=? WHERE id=?` + args = []any{r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en, id} default: http.Error(w, "unknown kind", http.StatusBadRequest) return @@ -179,8 +190,8 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) { q = `INSERT INTO vip_packages (id,title,months,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` args = []any{id, r.FormValue("title"), f("months"), f("price_toman"), f("sort"), en} case "tier": - q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?)` - args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("sort"), en} + q = `INSERT INTO table_tiers (id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled) VALUES (?,?,?,?,?,?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("hands"), f("entry"), f("prize"), f("xp"), f("trophy"), f("rank_reward"), f("sort"), en} default: http.Error(w, "unknown kind", http.StatusBadRequest) return @@ -194,6 +205,109 @@ func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) } +// validCardFile نامِ کانونیِ خروجی را برای یک ورودیِ zip برمی‌گرداند (یا ok=false). +// مجاز: back.(jpg|png) → "back.jpg" و <کد>.png → "<کدِ بزرگ>.png" +// که <کد> = رتبه (A,2..10,J,Q,K) + خال (S,H,D,C)، مثل AS.png یا 10H.png. +// خروجی همیشه «کدِ بزرگ + پسوندِ کوچک» است تا با درخواست‌های اپ روی فایل‌سیستمِ +// حساس‌به‌حروف (لینوکس) دقیقاً مطابقت کند. +func validCardFile(name string) (string, bool) { + base := filepath.Base(name) + low := strings.ToLower(base) + if low == "back.jpg" || low == "back.png" { + return "back.jpg", true // اپ پشتِ کارت را با نامِ back.jpg می‌خواهد + } + if !strings.HasSuffix(low, ".png") { + return "", false + } + code := strings.ToUpper(base[:len(base)-len(".png")]) + suits := "SHDC" + if len(code) < 2 || !strings.Contains(suits, code[len(code)-1:]) { + return "", false + } + ranks := map[string]bool{"A": true, "2": true, "3": true, "4": true, "5": true, + "6": true, "7": true, "8": true, "9": true, "10": true, "J": true, "Q": true, "K": true} + if !ranks[code[:len(code)-1]] { + return "", false + } + return code + ".png", true +} + +// uploadDeck یک فایلِ zip شاملِ تصاویرِ کارت را برای یک اسکین آپلود می‌کند و +// در پوشه‌ی دیسکیِ ماندگار (cardsDir//) استخراج می‌کند تا بدونِ کامپایلِ +// دوباره از همان مسیرِ /cards//... سرو شود. +func (h *Handler) uploadDeck(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" || strings.ContainsAny(id, "/\\.") { + http.Error(w, "invalid skin id", http.StatusBadRequest) + return + } + if h.cardsDir == "" { + http.Error(w, "CARDS_DIR تنظیم نشده است", http.StatusInternalServerError) + return + } + if err := r.ParseMultipartForm(64 << 20); err != nil { // حداکثر ۶۴MB + http.Error(w, "bad upload", http.StatusBadRequest) + return + } + file, _, err := r.FormFile("deck") + if err != nil { + http.Error(w, "deck (zip) لازم است", http.StatusBadRequest) + return + } + defer file.Close() + buf, err := io.ReadAll(io.LimitReader(file, 64<<20)) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + zr, err := zip.NewReader(strings.NewReader(string(buf)), int64(len(buf))) + if err != nil { + http.Error(w, "فایل zip معتبر نیست", http.StatusBadRequest) + return + } + + dir := filepath.Join(h.cardsDir, id) + if err := os.MkdirAll(dir, 0o755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + written := 0 + for _, zf := range zr.File { + if zf.FileInfo().IsDir() { + continue + } + out, ok := validCardFile(zf.Name) + if !ok { + continue // فایل‌های نامربوط/ناقص نادیده گرفته می‌شوند + } + if err := extractZipEntry(zf, filepath.Join(dir, out)); err != nil { + http.Error(w, fmt.Sprintf("استخراج %s: %v", zf.Name, err), http.StatusInternalServerError) + return + } + written++ + } + if written == 0 { + http.Error(w, "هیچ تصویرِ کارتِ معتبری در zip یافت نشد (مثل AS.png، 10H.png، back.jpg)", http.StatusBadRequest) + return + } + http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) +} + +func extractZipEntry(zf *zip.File, dst string) error { + rc, err := zf.Open() + if err != nil { + return err + } + defer rc.Close() + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, io.LimitReader(rc, 16<<20)) // سقفِ هر فایل ۱۶MB + return err +} + // deleteItem یک ردیفِ کاتالوگ را حذف می‌کند. func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { @@ -218,6 +332,15 @@ func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) } +// resetSeason امتیازِ رتبه‌ی همه را صفر کرده و فصلِ جدید را آغاز می‌کند. +func (h *Handler) resetSeason(w http.ResponseWriter, r *http.Request) { + if err := h.eco.ResetSeason(r.Context()); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/admin/?saved=1", http.StatusSeeOther) +} + // --- کاربران --- func (h *Handler) users(w http.ResponseWriter, r *http.Request) { diff --git a/internal/admin/templates/dashboard.html b/internal/admin/templates/dashboard.html index f59b8e4..87de16e 100644 --- a/internal/admin/templates/dashboard.html +++ b/internal/admin/templates/dashboard.html @@ -5,12 +5,21 @@ {{template "nav" .}}

داشبورد

+ {{if .Saved}}
انجام شد ✓
{{end}}
کاربران{{.Users}}
خریدهای موفق{{.Purchases}}
بازی‌های ثبت‌شده{{.Games}}
مجموع سکه‌ی کاربران{{.Coins}}
+
فصلِ رتبه‌بندی{{.Season}}
+ +

فصلِ رتبه‌بندی

+

شروعِ فصلِ جدید، امتیازِ رتبه‌ی همه‌ی کاربران را صفر می‌کند.

+
+ +
diff --git a/internal/admin/templates/shop.html b/internal/admin/templates/shop.html index 65c7ab8..27758d3 100644 --- a/internal/admin/templates/shop.html +++ b/internal/admin/templates/shop.html @@ -65,8 +65,11 @@

اسکین کارت‌ها

+

+ آپلودِ تصاویرِ هر اسکین: یک فایلِ zip شاملِ ۵۲ کارت با نامِ AS.png، 10H.png، KD.png … و یک back.jpg (پشتِ کارت). نام‌ها بزرگ‌وکوچک مهم نیست. +

- + {{range .Cards}} @@ -76,7 +79,12 @@ - + + + {{end}} @@ -143,8 +151,9 @@
شناسهعنوانقیمت (سکه)ترتیبفعال
شناسهعنوانقیمت (سکه)ترتیبفعالتصاویر
{{.id}}
+ + +

انواع میز

+

«امتیاز رتبه» اگر بزرگ‌تر از ۰ باشد، آن میز رتبه‌بندی است و به برنده امتیازِ رتبه می‌دهد.

- + {{range .Tiers}} @@ -154,6 +163,7 @@ + @@ -168,6 +178,7 @@ + diff --git a/internal/config/config.go b/internal/config/config.go index 0c89131..168472e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,6 +20,7 @@ type Config struct { AdminOTP string // کد ثابت ادمین AdminUser string // یوزرنیمِ پنل ادمین (Basic Auth) AdminPass string // پسوردِ پنل ادمین (Basic Auth) + CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار) } // Load کانفیگ را از env با مقادیر پیش‌فرض معقول می‌خواند. @@ -37,6 +38,7 @@ func Load() Config { AdminOTP: env("ADMIN_OTP", ""), AdminUser: env("ADMIN_PANEL_USER", "admin"), AdminPass: env("ADMIN_PANEL_PASS", "admin"), + CardsDir: env("CARDS_DIR", "carddata"), } } diff --git a/internal/economy/catalog.go b/internal/economy/catalog.go index ff665a3..ce25aa5 100644 --- a/internal/economy/catalog.go +++ b/internal/economy/catalog.go @@ -50,14 +50,16 @@ type VIPPackage struct { } // TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام. +// RankReward > 0 یعنی میزِ رتبه‌بندی است (به برنده امتیازِ رتبه می‌دهد). type TableTier struct { - ID string `json:"id"` - Title string `json:"title"` - Hands int `json:"hands"` - Entry int64 `json:"entry"` - Prize int64 `json:"prize"` - XP int `json:"xp"` - Trophy int `json:"trophy"` + ID string `json:"id"` + Title string `json:"title"` + Hands int `json:"hands"` + Entry int64 `json:"entry"` + Prize int64 `json:"prize"` + XP int `json:"xp"` + Trophy int `json:"trophy"` + RankReward int `json:"rank_reward"` } // Catalog کل کاتالوگ فروشگاه (برای GET /api/shop). از دیتابیس بارگذاری می‌شود. @@ -70,6 +72,29 @@ type Catalog struct { TableTiers []TableTier `json:"table_tiers"` } +// rankTiers آستانه‌ی امتیازِ هر نشانِ رتبه (از پایین به بالا). +var rankTiers = []struct { + Name string + Min int64 +}{ + {"bronze", 0}, + {"silver", 500}, + {"gold", 1500}, + {"diamond", 3000}, + {"king", 5000}, +} + +// RankTier نام و اندیسِ نشانِ رتبه را از امتیازِ رتبه برمی‌گرداند. +func RankTier(points int64) (name string, index int) { + name, index = rankTiers[0].Name, 0 + for i, t := range rankTiers { + if points >= t.Min { + name, index = t.Name, i + } + } + return name, index +} + // LevelInfo سطح فعلی، XP درون‌سطح و XP لازم برای سطح بعد را از XP کل محاسبه می‌کند. // نیاز هر سطح: ۵۰ × شماره سطح (مثلاً سطح ۸ ⇒ ۴۰۰). func LevelInfo(totalXP int64) (level int, into int64, need int64) { diff --git a/internal/economy/catalog_store.go b/internal/economy/catalog_store.go index 5a2c703..bfd86e8 100644 --- a/internal/economy/catalog_store.go +++ b/internal/economy/catalog_store.go @@ -83,13 +83,13 @@ func (s *Service) LoadCatalog(ctx context.Context) error { rows.Close() rows, err = s.db.QueryContext(ctx, - `SELECT id, title, hands, entry, prize, xp, trophy FROM table_tiers WHERE enabled = 1 ORDER BY sort`) + `SELECT id, title, hands, entry, prize, xp, trophy, rank_reward FROM table_tiers WHERE enabled = 1 ORDER BY sort`) if err != nil { return err } for rows.Next() { var p TableTier - if err := rows.Scan(&p.ID, &p.Title, &p.Hands, &p.Entry, &p.Prize, &p.XP, &p.Trophy); err != nil { + if err := rows.Scan(&p.ID, &p.Title, &p.Hands, &p.Entry, &p.Prize, &p.XP, &p.Trophy, &p.RankReward); err != nil { rows.Close() return err } diff --git a/internal/economy/economy.go b/internal/economy/economy.go index 31c44a1..ccfc432 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -56,6 +56,9 @@ type Profile struct { VIP bool `json:"vip"` VIPUntil *string `json:"vip_until"` SelectedCard string `json:"selected_card"` + RankPoints int64 `json:"rank_points"` + RankTier string `json:"rank_tier"` // bronze..king + RankIndex int `json:"rank_index"` // ۰..۴ } // GetProfile وضعیت اقتصادی کاربر را می‌خواند. @@ -63,8 +66,8 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error var p Profile var vipUntil sql.NullString err := s.db.QueryRowContext(ctx, - `SELECT coins, tickets, xp, trophies, vip_until, selected_card FROM users WHERE id = ?`, userID). - Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard) + `SELECT coins, tickets, xp, trophies, vip_until, selected_card, rank_points FROM users WHERE id = ?`, userID). + Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.RankPoints) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } @@ -72,6 +75,7 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error return nil, err } p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP) + p.RankTier, p.RankIndex = RankTier(p.RankPoints) if vipUntil.Valid { p.VIPUntil = &vipUntil.String p.VIP = parseTS(vipUntil.String).After(nowUTC()) diff --git a/internal/economy/handler.go b/internal/economy/handler.go index ca768ab..07f25ac 100644 --- a/internal/economy/handler.go +++ b/internal/economy/handler.go @@ -176,6 +176,23 @@ func (h *Handler) TablesInfo(w http.ResponseWriter, r *http.Request) { }) } +// Leaderboard — GET /api/leaderboard (جدولِ رتبه‌بندیِ فصل) +func (h *Handler) Leaderboard(w http.ResponseWriter, r *http.Request) { + if _, ok := uid(r); !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + list, err := h.svc.Leaderboard(r.Context(), 50) + if err != nil { + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + httpx.JSON(w, http.StatusOK, map[string]any{ + "season": h.svc.Season(r.Context()), + "entries": list, + }) +} + // Daily — POST /api/rewards/daily func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) { id, ok := uid(r) diff --git a/internal/economy/ranked.go b/internal/economy/ranked.go new file mode 100644 index 0000000..2d71c69 --- /dev/null +++ b/internal/economy/ranked.go @@ -0,0 +1,88 @@ +package economy + +import ( + "context" + "database/sql" +) + +// RankEntry یک ردیف از جدولِ رتبه‌بندی. +type RankEntry struct { + Rank int `json:"rank"` + Name string `json:"name"` + Avatar string `json:"avatar"` + RankPoints int64 `json:"rank_points"` + Tier string `json:"tier"` +} + +// AwardRank امتیازِ رتبه را پس از یک بازیِ رتبه‌بندی به‌روزرسانی می‌کند. +// برنده +reward، بازنده −reward/2 (کفِ صفر). برای میزِ غیرِرتبه‌بندی بی‌اثر است. +func (s *Service) AwardRank(ctx context.Context, userID int64, reward int, won bool) error { + if reward <= 0 { + return nil + } + delta := reward + if !won { + delta = -(reward / 2) + } + _, err := s.db.ExecContext(ctx, + `UPDATE users SET rank_points = MAX(0, rank_points + ?) WHERE id = ?`, + delta, userID) + return err +} + +// Leaderboard فهرستِ برترین بازیکنان بر اساس امتیازِ رتبه را برمی‌گرداند. +func (s *Service) Leaderboard(ctx context.Context, limit int) ([]RankEntry, error) { + if limit <= 0 || limit > 100 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + `SELECT first_name, avatar, rank_points FROM users + WHERE rank_points > 0 ORDER BY rank_points DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]RankEntry, 0, limit) + rank := 0 + for rows.Next() { + var name, avatar sql.NullString + var pts int64 + if err := rows.Scan(&name, &avatar, &pts); err != nil { + return nil, err + } + rank++ + tier, _ := RankTier(pts) + nm := name.String + if nm == "" { + nm = "بازیکن" + } + out = append(out, RankEntry{ + Rank: rank, Name: nm, Avatar: avatar.String, RankPoints: pts, Tier: tier, + }) + } + return out, rows.Err() +} + +// ResetSeason امتیازِ رتبه‌ی همه‌ی کاربران را صفر کرده و شماره‌ی فصل را زیاد می‌کند. +func (s *Service) ResetSeason(ctx context.Context) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, `UPDATE users SET rank_points = 0`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `UPDATE app_settings SET value = value + 1 WHERE key = 'season'`); err != nil { + return err + } + return tx.Commit() +} + +// Season شماره‌ی فصلِ جاری را برمی‌گرداند. +func (s *Service) Season(ctx context.Context) int { + var v int + _ = s.db.QueryRowContext(ctx, `SELECT value FROM app_settings WHERE key = 'season'`).Scan(&v) + return v +} diff --git a/internal/static/cards/epic/10C.png b/internal/static/cards/epic/10C.png new file mode 100644 index 0000000..148943e Binary files /dev/null and b/internal/static/cards/epic/10C.png differ diff --git a/internal/static/cards/epic/10D.png b/internal/static/cards/epic/10D.png new file mode 100644 index 0000000..49dd693 Binary files /dev/null and b/internal/static/cards/epic/10D.png differ diff --git a/internal/static/cards/epic/10H.png b/internal/static/cards/epic/10H.png new file mode 100644 index 0000000..c980a86 Binary files /dev/null and b/internal/static/cards/epic/10H.png differ diff --git a/internal/static/cards/epic/10S.png b/internal/static/cards/epic/10S.png new file mode 100644 index 0000000..8b8a7e3 Binary files /dev/null and b/internal/static/cards/epic/10S.png differ diff --git a/internal/static/cards/epic/2C.png b/internal/static/cards/epic/2C.png new file mode 100644 index 0000000..2b1010c Binary files /dev/null and b/internal/static/cards/epic/2C.png differ diff --git a/internal/static/cards/epic/2D.png b/internal/static/cards/epic/2D.png new file mode 100644 index 0000000..bd389b6 Binary files /dev/null and b/internal/static/cards/epic/2D.png differ diff --git a/internal/static/cards/epic/2H.png b/internal/static/cards/epic/2H.png new file mode 100644 index 0000000..932acaf Binary files /dev/null and b/internal/static/cards/epic/2H.png differ diff --git a/internal/static/cards/epic/2S.png b/internal/static/cards/epic/2S.png new file mode 100644 index 0000000..a04d1ea Binary files /dev/null and b/internal/static/cards/epic/2S.png differ diff --git a/internal/static/cards/epic/3C.png b/internal/static/cards/epic/3C.png new file mode 100644 index 0000000..5a510c7 Binary files /dev/null and b/internal/static/cards/epic/3C.png differ diff --git a/internal/static/cards/epic/3D.png b/internal/static/cards/epic/3D.png new file mode 100644 index 0000000..99e99d9 Binary files /dev/null and b/internal/static/cards/epic/3D.png differ diff --git a/internal/static/cards/epic/3H.png b/internal/static/cards/epic/3H.png new file mode 100644 index 0000000..2904d00 Binary files /dev/null and b/internal/static/cards/epic/3H.png differ diff --git a/internal/static/cards/epic/3S.png b/internal/static/cards/epic/3S.png new file mode 100644 index 0000000..83f3964 Binary files /dev/null and b/internal/static/cards/epic/3S.png differ diff --git a/internal/static/cards/epic/4C.png b/internal/static/cards/epic/4C.png new file mode 100644 index 0000000..d8263f5 Binary files /dev/null and b/internal/static/cards/epic/4C.png differ diff --git a/internal/static/cards/epic/4D.png b/internal/static/cards/epic/4D.png new file mode 100644 index 0000000..f962cce Binary files /dev/null and b/internal/static/cards/epic/4D.png differ diff --git a/internal/static/cards/epic/4H.png b/internal/static/cards/epic/4H.png new file mode 100644 index 0000000..ee370c6 Binary files /dev/null and b/internal/static/cards/epic/4H.png differ diff --git a/internal/static/cards/epic/4S.png b/internal/static/cards/epic/4S.png new file mode 100644 index 0000000..2469582 Binary files /dev/null and b/internal/static/cards/epic/4S.png differ diff --git a/internal/static/cards/epic/5C.png b/internal/static/cards/epic/5C.png new file mode 100644 index 0000000..a0dbda6 Binary files /dev/null and b/internal/static/cards/epic/5C.png differ diff --git a/internal/static/cards/epic/5D.png b/internal/static/cards/epic/5D.png new file mode 100644 index 0000000..f4e3d21 Binary files /dev/null and b/internal/static/cards/epic/5D.png differ diff --git a/internal/static/cards/epic/5H.png b/internal/static/cards/epic/5H.png new file mode 100644 index 0000000..65217ee Binary files /dev/null and b/internal/static/cards/epic/5H.png differ diff --git a/internal/static/cards/epic/5S.png b/internal/static/cards/epic/5S.png new file mode 100644 index 0000000..fd93476 Binary files /dev/null and b/internal/static/cards/epic/5S.png differ diff --git a/internal/static/cards/epic/6C.png b/internal/static/cards/epic/6C.png new file mode 100644 index 0000000..791b64d Binary files /dev/null and b/internal/static/cards/epic/6C.png differ diff --git a/internal/static/cards/epic/6D.png b/internal/static/cards/epic/6D.png new file mode 100644 index 0000000..1d5f658 Binary files /dev/null and b/internal/static/cards/epic/6D.png differ diff --git a/internal/static/cards/epic/6H.png b/internal/static/cards/epic/6H.png new file mode 100644 index 0000000..2efd99e Binary files /dev/null and b/internal/static/cards/epic/6H.png differ diff --git a/internal/static/cards/epic/6S.png b/internal/static/cards/epic/6S.png new file mode 100644 index 0000000..9467169 Binary files /dev/null and b/internal/static/cards/epic/6S.png differ diff --git a/internal/static/cards/epic/7C.png b/internal/static/cards/epic/7C.png new file mode 100644 index 0000000..1367d38 Binary files /dev/null and b/internal/static/cards/epic/7C.png differ diff --git a/internal/static/cards/epic/7D.png b/internal/static/cards/epic/7D.png new file mode 100644 index 0000000..1a8623d Binary files /dev/null and b/internal/static/cards/epic/7D.png differ diff --git a/internal/static/cards/epic/7H.png b/internal/static/cards/epic/7H.png new file mode 100644 index 0000000..3ab2850 Binary files /dev/null and b/internal/static/cards/epic/7H.png differ diff --git a/internal/static/cards/epic/7S.png b/internal/static/cards/epic/7S.png new file mode 100644 index 0000000..f3af936 Binary files /dev/null and b/internal/static/cards/epic/7S.png differ diff --git a/internal/static/cards/epic/8C.png b/internal/static/cards/epic/8C.png new file mode 100644 index 0000000..655f537 Binary files /dev/null and b/internal/static/cards/epic/8C.png differ diff --git a/internal/static/cards/epic/8D.png b/internal/static/cards/epic/8D.png new file mode 100644 index 0000000..9dea0f5 Binary files /dev/null and b/internal/static/cards/epic/8D.png differ diff --git a/internal/static/cards/epic/8H.png b/internal/static/cards/epic/8H.png new file mode 100644 index 0000000..ef4035d Binary files /dev/null and b/internal/static/cards/epic/8H.png differ diff --git a/internal/static/cards/epic/8S.png b/internal/static/cards/epic/8S.png new file mode 100644 index 0000000..9500128 Binary files /dev/null and b/internal/static/cards/epic/8S.png differ diff --git a/internal/static/cards/epic/9C.png b/internal/static/cards/epic/9C.png new file mode 100644 index 0000000..5ec2d9b Binary files /dev/null and b/internal/static/cards/epic/9C.png differ diff --git a/internal/static/cards/epic/9D.png b/internal/static/cards/epic/9D.png new file mode 100644 index 0000000..dcfbf63 Binary files /dev/null and b/internal/static/cards/epic/9D.png differ diff --git a/internal/static/cards/epic/9H.png b/internal/static/cards/epic/9H.png new file mode 100644 index 0000000..6e77bf4 Binary files /dev/null and b/internal/static/cards/epic/9H.png differ diff --git a/internal/static/cards/epic/9S.png b/internal/static/cards/epic/9S.png new file mode 100644 index 0000000..a188b47 Binary files /dev/null and b/internal/static/cards/epic/9S.png differ diff --git a/internal/static/cards/epic/AC.png b/internal/static/cards/epic/AC.png new file mode 100644 index 0000000..c4706e9 Binary files /dev/null and b/internal/static/cards/epic/AC.png differ diff --git a/internal/static/cards/epic/AD.png b/internal/static/cards/epic/AD.png new file mode 100644 index 0000000..a8173bd Binary files /dev/null and b/internal/static/cards/epic/AD.png differ diff --git a/internal/static/cards/epic/AH.png b/internal/static/cards/epic/AH.png new file mode 100644 index 0000000..0a6b597 Binary files /dev/null and b/internal/static/cards/epic/AH.png differ diff --git a/internal/static/cards/epic/AS.png b/internal/static/cards/epic/AS.png new file mode 100644 index 0000000..d851e25 Binary files /dev/null and b/internal/static/cards/epic/AS.png differ diff --git a/internal/static/cards/epic/JC.png b/internal/static/cards/epic/JC.png new file mode 100644 index 0000000..7d3c1d7 Binary files /dev/null and b/internal/static/cards/epic/JC.png differ diff --git a/internal/static/cards/epic/JD.png b/internal/static/cards/epic/JD.png new file mode 100644 index 0000000..afff8e1 Binary files /dev/null and b/internal/static/cards/epic/JD.png differ diff --git a/internal/static/cards/epic/JH.png b/internal/static/cards/epic/JH.png new file mode 100644 index 0000000..1940daa Binary files /dev/null and b/internal/static/cards/epic/JH.png differ diff --git a/internal/static/cards/epic/JS.png b/internal/static/cards/epic/JS.png new file mode 100644 index 0000000..7aca500 Binary files /dev/null and b/internal/static/cards/epic/JS.png differ diff --git a/internal/static/cards/epic/KC.png b/internal/static/cards/epic/KC.png new file mode 100644 index 0000000..f6ba2f0 Binary files /dev/null and b/internal/static/cards/epic/KC.png differ diff --git a/internal/static/cards/epic/KD.png b/internal/static/cards/epic/KD.png new file mode 100644 index 0000000..92829d8 Binary files /dev/null and b/internal/static/cards/epic/KD.png differ diff --git a/internal/static/cards/epic/KH.png b/internal/static/cards/epic/KH.png new file mode 100644 index 0000000..c31a66f Binary files /dev/null and b/internal/static/cards/epic/KH.png differ diff --git a/internal/static/cards/epic/KS.png b/internal/static/cards/epic/KS.png new file mode 100644 index 0000000..6c5f71f Binary files /dev/null and b/internal/static/cards/epic/KS.png differ diff --git a/internal/static/cards/epic/QC.png b/internal/static/cards/epic/QC.png new file mode 100644 index 0000000..1f0e62d Binary files /dev/null and b/internal/static/cards/epic/QC.png differ diff --git a/internal/static/cards/epic/QD.png b/internal/static/cards/epic/QD.png new file mode 100644 index 0000000..5679ab5 Binary files /dev/null and b/internal/static/cards/epic/QD.png differ diff --git a/internal/static/cards/epic/QH.png b/internal/static/cards/epic/QH.png new file mode 100644 index 0000000..bb7d9d0 Binary files /dev/null and b/internal/static/cards/epic/QH.png differ diff --git a/internal/static/cards/epic/QS.png b/internal/static/cards/epic/QS.png new file mode 100644 index 0000000..82f2e78 Binary files /dev/null and b/internal/static/cards/epic/QS.png differ diff --git a/internal/static/cards/epic/back.jpg b/internal/static/cards/epic/back.jpg new file mode 100644 index 0000000..3c35264 Binary files /dev/null and b/internal/static/cards/epic/back.jpg differ diff --git a/internal/static/cards/simple/10C.png b/internal/static/cards/simple/10C.png new file mode 100644 index 0000000..18af741 Binary files /dev/null and b/internal/static/cards/simple/10C.png differ diff --git a/internal/static/cards/simple/10D.png b/internal/static/cards/simple/10D.png new file mode 100644 index 0000000..3bbc4e0 Binary files /dev/null and b/internal/static/cards/simple/10D.png differ diff --git a/internal/static/cards/simple/10H.png b/internal/static/cards/simple/10H.png new file mode 100644 index 0000000..3eb83d7 Binary files /dev/null and b/internal/static/cards/simple/10H.png differ diff --git a/internal/static/cards/simple/10S.png b/internal/static/cards/simple/10S.png new file mode 100644 index 0000000..0b3d294 Binary files /dev/null and b/internal/static/cards/simple/10S.png differ diff --git a/internal/static/cards/simple/2C.png b/internal/static/cards/simple/2C.png new file mode 100644 index 0000000..291ed97 Binary files /dev/null and b/internal/static/cards/simple/2C.png differ diff --git a/internal/static/cards/simple/2D.png b/internal/static/cards/simple/2D.png new file mode 100644 index 0000000..4deee7c Binary files /dev/null and b/internal/static/cards/simple/2D.png differ diff --git a/internal/static/cards/simple/2H.png b/internal/static/cards/simple/2H.png new file mode 100644 index 0000000..75a014f Binary files /dev/null and b/internal/static/cards/simple/2H.png differ diff --git a/internal/static/cards/simple/2S.png b/internal/static/cards/simple/2S.png new file mode 100644 index 0000000..1ce0ffe Binary files /dev/null and b/internal/static/cards/simple/2S.png differ diff --git a/internal/static/cards/simple/3C.png b/internal/static/cards/simple/3C.png new file mode 100644 index 0000000..076ab31 Binary files /dev/null and b/internal/static/cards/simple/3C.png differ diff --git a/internal/static/cards/simple/3D.png b/internal/static/cards/simple/3D.png new file mode 100644 index 0000000..8ee0b4b Binary files /dev/null and b/internal/static/cards/simple/3D.png differ diff --git a/internal/static/cards/simple/3H.png b/internal/static/cards/simple/3H.png new file mode 100644 index 0000000..8e74673 Binary files /dev/null and b/internal/static/cards/simple/3H.png differ diff --git a/internal/static/cards/simple/3S.png b/internal/static/cards/simple/3S.png new file mode 100644 index 0000000..f9e06b4 Binary files /dev/null and b/internal/static/cards/simple/3S.png differ diff --git a/internal/static/cards/simple/4C.png b/internal/static/cards/simple/4C.png new file mode 100644 index 0000000..8be9e08 Binary files /dev/null and b/internal/static/cards/simple/4C.png differ diff --git a/internal/static/cards/simple/4D.png b/internal/static/cards/simple/4D.png new file mode 100644 index 0000000..70e82e8 Binary files /dev/null and b/internal/static/cards/simple/4D.png differ diff --git a/internal/static/cards/simple/4H.png b/internal/static/cards/simple/4H.png new file mode 100644 index 0000000..ceecbfe Binary files /dev/null and b/internal/static/cards/simple/4H.png differ diff --git a/internal/static/cards/simple/4S.png b/internal/static/cards/simple/4S.png new file mode 100644 index 0000000..95abe3e Binary files /dev/null and b/internal/static/cards/simple/4S.png differ diff --git a/internal/static/cards/simple/5C.png b/internal/static/cards/simple/5C.png new file mode 100644 index 0000000..bde9777 Binary files /dev/null and b/internal/static/cards/simple/5C.png differ diff --git a/internal/static/cards/simple/5D.png b/internal/static/cards/simple/5D.png new file mode 100644 index 0000000..bb92525 Binary files /dev/null and b/internal/static/cards/simple/5D.png differ diff --git a/internal/static/cards/simple/5H.png b/internal/static/cards/simple/5H.png new file mode 100644 index 0000000..d923456 Binary files /dev/null and b/internal/static/cards/simple/5H.png differ diff --git a/internal/static/cards/simple/5S.png b/internal/static/cards/simple/5S.png new file mode 100644 index 0000000..53a1aad Binary files /dev/null and b/internal/static/cards/simple/5S.png differ diff --git a/internal/static/cards/simple/6C.png b/internal/static/cards/simple/6C.png new file mode 100644 index 0000000..a9660a0 Binary files /dev/null and b/internal/static/cards/simple/6C.png differ diff --git a/internal/static/cards/simple/6D.png b/internal/static/cards/simple/6D.png new file mode 100644 index 0000000..78a80ad Binary files /dev/null and b/internal/static/cards/simple/6D.png differ diff --git a/internal/static/cards/simple/6H.png b/internal/static/cards/simple/6H.png new file mode 100644 index 0000000..361643e Binary files /dev/null and b/internal/static/cards/simple/6H.png differ diff --git a/internal/static/cards/simple/6S.png b/internal/static/cards/simple/6S.png new file mode 100644 index 0000000..40242a7 Binary files /dev/null and b/internal/static/cards/simple/6S.png differ diff --git a/internal/static/cards/simple/7C.png b/internal/static/cards/simple/7C.png new file mode 100644 index 0000000..9d6b545 Binary files /dev/null and b/internal/static/cards/simple/7C.png differ diff --git a/internal/static/cards/simple/7D.png b/internal/static/cards/simple/7D.png new file mode 100644 index 0000000..6ad5f15 Binary files /dev/null and b/internal/static/cards/simple/7D.png differ diff --git a/internal/static/cards/simple/7H.png b/internal/static/cards/simple/7H.png new file mode 100644 index 0000000..19b89a2 Binary files /dev/null and b/internal/static/cards/simple/7H.png differ diff --git a/internal/static/cards/simple/7S.png b/internal/static/cards/simple/7S.png new file mode 100644 index 0000000..b9f1b93 Binary files /dev/null and b/internal/static/cards/simple/7S.png differ diff --git a/internal/static/cards/simple/8C.png b/internal/static/cards/simple/8C.png new file mode 100644 index 0000000..cec743c Binary files /dev/null and b/internal/static/cards/simple/8C.png differ diff --git a/internal/static/cards/simple/8D.png b/internal/static/cards/simple/8D.png new file mode 100644 index 0000000..ed12951 Binary files /dev/null and b/internal/static/cards/simple/8D.png differ diff --git a/internal/static/cards/simple/8H.png b/internal/static/cards/simple/8H.png new file mode 100644 index 0000000..fb39723 Binary files /dev/null and b/internal/static/cards/simple/8H.png differ diff --git a/internal/static/cards/simple/8S.png b/internal/static/cards/simple/8S.png new file mode 100644 index 0000000..b6b3b38 Binary files /dev/null and b/internal/static/cards/simple/8S.png differ diff --git a/internal/static/cards/simple/9C.png b/internal/static/cards/simple/9C.png new file mode 100644 index 0000000..2174db5 Binary files /dev/null and b/internal/static/cards/simple/9C.png differ diff --git a/internal/static/cards/simple/9D.png b/internal/static/cards/simple/9D.png new file mode 100644 index 0000000..0b933fb Binary files /dev/null and b/internal/static/cards/simple/9D.png differ diff --git a/internal/static/cards/simple/9H.png b/internal/static/cards/simple/9H.png new file mode 100644 index 0000000..7b196d6 Binary files /dev/null and b/internal/static/cards/simple/9H.png differ diff --git a/internal/static/cards/simple/9S.png b/internal/static/cards/simple/9S.png new file mode 100644 index 0000000..3c3b5ff Binary files /dev/null and b/internal/static/cards/simple/9S.png differ diff --git a/internal/static/cards/simple/AC.png b/internal/static/cards/simple/AC.png new file mode 100644 index 0000000..42bf5ec Binary files /dev/null and b/internal/static/cards/simple/AC.png differ diff --git a/internal/static/cards/simple/AD.png b/internal/static/cards/simple/AD.png new file mode 100644 index 0000000..79cd3b8 Binary files /dev/null and b/internal/static/cards/simple/AD.png differ diff --git a/internal/static/cards/simple/AH.png b/internal/static/cards/simple/AH.png new file mode 100644 index 0000000..b422124 Binary files /dev/null and b/internal/static/cards/simple/AH.png differ diff --git a/internal/static/cards/simple/AS.png b/internal/static/cards/simple/AS.png new file mode 100644 index 0000000..fbc3a2d Binary files /dev/null and b/internal/static/cards/simple/AS.png differ diff --git a/internal/static/cards/simple/JC.png b/internal/static/cards/simple/JC.png new file mode 100644 index 0000000..5e003be Binary files /dev/null and b/internal/static/cards/simple/JC.png differ diff --git a/internal/static/cards/simple/JD.png b/internal/static/cards/simple/JD.png new file mode 100644 index 0000000..131a977 Binary files /dev/null and b/internal/static/cards/simple/JD.png differ diff --git a/internal/static/cards/simple/JH.png b/internal/static/cards/simple/JH.png new file mode 100644 index 0000000..bf342bc Binary files /dev/null and b/internal/static/cards/simple/JH.png differ diff --git a/internal/static/cards/simple/JS.png b/internal/static/cards/simple/JS.png new file mode 100644 index 0000000..f539c19 Binary files /dev/null and b/internal/static/cards/simple/JS.png differ diff --git a/internal/static/cards/simple/KC.png b/internal/static/cards/simple/KC.png new file mode 100644 index 0000000..68e5774 Binary files /dev/null and b/internal/static/cards/simple/KC.png differ diff --git a/internal/static/cards/simple/KD.png b/internal/static/cards/simple/KD.png new file mode 100644 index 0000000..e21d6a0 Binary files /dev/null and b/internal/static/cards/simple/KD.png differ diff --git a/internal/static/cards/simple/KH.png b/internal/static/cards/simple/KH.png new file mode 100644 index 0000000..1d3c468 Binary files /dev/null and b/internal/static/cards/simple/KH.png differ diff --git a/internal/static/cards/simple/KS.png b/internal/static/cards/simple/KS.png new file mode 100644 index 0000000..2edbbc1 Binary files /dev/null and b/internal/static/cards/simple/KS.png differ diff --git a/internal/static/cards/simple/QC.png b/internal/static/cards/simple/QC.png new file mode 100644 index 0000000..7be5f9a Binary files /dev/null and b/internal/static/cards/simple/QC.png differ diff --git a/internal/static/cards/simple/QD.png b/internal/static/cards/simple/QD.png new file mode 100644 index 0000000..928f650 Binary files /dev/null and b/internal/static/cards/simple/QD.png differ diff --git a/internal/static/cards/simple/QH.png b/internal/static/cards/simple/QH.png new file mode 100644 index 0000000..21839e6 Binary files /dev/null and b/internal/static/cards/simple/QH.png differ diff --git a/internal/static/cards/simple/QS.png b/internal/static/cards/simple/QS.png new file mode 100644 index 0000000..7983d03 Binary files /dev/null and b/internal/static/cards/simple/QS.png differ diff --git a/internal/static/cards/simple/back.jpg b/internal/static/cards/simple/back.jpg new file mode 100644 index 0000000..2a5ce27 Binary files /dev/null and b/internal/static/cards/simple/back.jpg differ diff --git a/internal/static/static.go b/internal/static/static.go new file mode 100644 index 0000000..1b779f0 --- /dev/null +++ b/internal/static/static.go @@ -0,0 +1,47 @@ +// Package static سرویس‌دهیِ تصاویرِ کارت (هر اسکین یک پوشه) را انجام می‌دهد. +// دو منبع: ۱) پوشه‌ی دیسکیِ ماندگار (diskDir) برای اسکین‌هایی که از پنل ادمین +// آپلود می‌شوند، ۲) فایل‌سیستمِ embed‌شده برای اسکین‌های پیش‌فرض (simple/epic). +// ابتدا دیسک بررسی می‌شود، سپس embed؛ پس آپلودها بدونِ کامپایلِ دوباره سرو می‌شوند. +// مسیر: GET /cards//.png و /cards//back.jpg +package static + +import ( + "embed" + "io/fs" + "net/http" + "os" + "path/filepath" + "strings" +) + +//go:embed cards +var cardsFS embed.FS + +// CardsHandler یک http.Handler برای سرویس‌دهیِ فایل‌های کارت زیر /cards/ است. +// diskDir پوشه‌ی اسکین‌های آپلودی است (می‌تواند خالی/ناموجود باشد). +// CORS باز است تا نسخهٔ وب (Flutter web / canvaskit) هم بتواند تصاویر را بکشد. +func CardsHandler(diskDir string) http.Handler { + sub, err := fs.Sub(cardsFS, "cards") + if err != nil { + panic(err) + } + embedded := http.StripPrefix("/cards/", http.FileServer(http.FS(sub))) + + 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 := strings.TrimPrefix(r.URL.Path, "/cards/") + rel = filepath.Clean("/" + rel) // جلوگیری از path traversal + full := filepath.Join(diskDir, rel) + if st, err := os.Stat(full); err == nil && !st.IsDir() { + http.ServeFile(w, r, full) + return + } + } + // تلاشِ دوم: اسکین‌های embed‌شده (simple/epic). + embedded.ServeHTTP(w, r) + }) +} diff --git a/internal/store/migrations/007_ranked.sql b/internal/store/migrations/007_ranked.sql new file mode 100644 index 0000000..c69e0d0 --- /dev/null +++ b/internal/store/migrations/007_ranked.sql @@ -0,0 +1,16 @@ +-- فصلِ رتبه‌بندی: امتیازِ رتبه‌ی کاربر + جایزه‌ی رتبه برای انواع میز. +ALTER TABLE users ADD COLUMN rank_points INTEGER NOT NULL DEFAULT 0; +ALTER TABLE table_tiers ADD COLUMN rank_reward INTEGER NOT NULL DEFAULT 0; + +-- میزِ رتبه‌بندی (ورودی ۵۰۰ سکه، قابلِ ویرایش در پنل ادمین). +INSERT OR IGNORE INTO table_tiers + (id, title, hands, entry, prize, xp, trophy, rank_reward, sort, enabled) +VALUES + ('ranked', 'رتبه‌بندی', 7, 500, 0, 20, 0, 30, 10, 1); + +-- شماره‌ی فصلِ جاری (با ریست‌شدن توسط ادمین زیاد می‌شود). +CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL +); +INSERT OR IGNORE INTO app_settings (key, value) VALUES ('season', 1); diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index d382108..9ac9a24 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -50,6 +50,8 @@ type PlayerInfo struct { Name string `json:"name"` Bot bool `json:"bot"` Connected bool `json:"connected"` + Coins int64 `json:"coins"` + RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه) } // matchedMsg به کلاینت پس از تشکیل میز. diff --git a/internal/ws/room.go b/internal/ws/room.go index 34e31c8..8192b0f 100644 --- a/internal/ws/room.go +++ b/internal/ws/room.go @@ -36,7 +36,9 @@ type seatInfo struct { userID int64 name string connected bool - left bool // خروج دائمی داوطلبانه (در تسویه از بازگشت ورودی محروم) + left bool // خروج دائمی داوطلبانه (در تسویه از بازگشت ورودی محروم) + coins int64 // برای نمایش سرِ میز + rankTier string // نشانِ رتبه برای نمایش سرِ میز } // auto مشخص می‌کند جایگاه باید خودکار بازی شود (بات یا قطع‌شده). @@ -119,6 +121,13 @@ func (r *Room) start() { } r.game = game.NewGame(target, game.FirstHakem()) r.graceUntil = time.Now().Add(r.firstHandDelay) // بُر زدن + پخشِ ۵ کارت + // اطلاعاتِ نمایشیِ بازیکنان (سکه و نشانِ رتبه) را یک‌بار هنگام شروع می‌گیریم. + for _, s := range r.seats { + if s.isBot || s.userID == 0 { + continue + } + s.coins, s.rankTier = r.settler.PlayerProfile(s.userID) + } for seat, s := range r.seats { if s.client != nil { s.client.trySend(mustJSON(r.matchedFor(seat))) @@ -439,6 +448,9 @@ func (r *Room) settleGameOver(res *game.HandResult) { if won && !r.private { r.settler.AwardWinner(s.userID, r.tier) // میز دورهمی جایزه‌ی سکه ندارد } + if !r.private { + r.settler.AwardRank(s.userID, r.tier, won) // امتیازِ رتبه (اگر میزِ رتبه‌بندی باشد) + } r.settler.RecordGameResult(s.userID, won) } r.settler.RecordGame(r.ID, r.playersJSON(res.WinnerTeam), res.WinnerTeam, res.Kot) @@ -485,7 +497,11 @@ func (r *Room) connectedHumans() int { func (r *Room) playersInfo() []PlayerInfo { out := make([]PlayerInfo, 4) for i, s := range r.seats { - out[i] = PlayerInfo{Seat: i, Name: s.name, Bot: s.isBot, Connected: s.isBot || s.connected} + out[i] = PlayerInfo{ + Seat: i, Name: s.name, Bot: s.isBot, + Connected: s.isBot || s.connected, + Coins: s.coins, RankTier: s.rankTier, + } } return out } diff --git a/internal/ws/settler.go b/internal/ws/settler.go index 94a717f..6087d7f 100644 --- a/internal/ws/settler.go +++ b/internal/ws/settler.go @@ -6,11 +6,14 @@ type Settler interface { ChargeEntry(userID int64, tier string) error // کسر ورودی هنگام ورود به صف Refund(userID int64, tier string) // بازگرداندن ورودی در صورت لغو AwardWinner(userID int64, tier string) // جایزه/XP/جام به برنده + AwardRank(userID int64, tier string, won bool) // امتیازِ رتبه (میزِ رتبه‌بندی) RecordGame(room, playersJSON string, winnerTeam int, kot bool) // آمار پروفایل (همگی غیرحیاتی و fire-and-forget). RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند RecordCut(userID int64) // بُرِش با حکم RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت) + // PlayerProfile سکه و نشانِ رتبه‌ی بازیکن را برای نمایش سرِ میز برمی‌گرداند. + PlayerProfile(userID int64) (coins int64, rankTier string) // میز خصوصی (دورهمی). ChargePrivateTable(userID int64) error // مصرفِ یک میز رایگان (خطا اگر سقف پر باشد) PrivateTableInfo(userID int64) (remaining int, unlimited bool) @@ -24,10 +27,12 @@ type noopSettler struct{} func (noopSettler) ChargeEntry(int64, string) error { return nil } func (noopSettler) Refund(int64, string) {} func (noopSettler) AwardWinner(int64, string) {} +func (noopSettler) AwardRank(int64, string, bool) {} 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) { return 0, "" } func (noopSettler) ChargePrivateTable(int64) error { return nil } func (noopSettler) PrivateTableInfo(int64) (int, bool) { return 0, true } func (noopSettler) TargetHands(string) int { return 7 } diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go index dd97dd5..401325c 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -456,6 +456,7 @@ func (s *recordingSettler) AwardWinner(int64, string) { s.awards++ s.mu.Unlock() } +func (s *recordingSettler) AwardRank(int64, string, bool) {} func (s *recordingSettler) RecordGame(string, string, int, bool) { s.mu.Lock() s.records++ @@ -478,7 +479,8 @@ func (s *recordingSettler) RecordGameResult(int64, bool) { s.mu.Unlock() } -func (s *recordingSettler) ChargePrivateTable(int64) error { return nil } +func (s *recordingSettler) PlayerProfile(int64) (int64, string) { return 0, "" } +func (s *recordingSettler) ChargePrivateTable(int64) error { return nil } func (s *recordingSettler) PrivateTableInfo(int64) (int, bool) { return 5, false } // برای سرعتِ تست، بازی پس از ۲ هَند تمام می‌شود.
شناسهعنواندستورودیجایزهXPجامترتیبفعال
شناسهعنواندستورودیجایزهXPجامامتیاز رتبهترتیبفعال
{{.id}}