From 0827858752c3346d9857ad26375233c8091c74b9 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Wed, 17 Jun 2026 11:32:51 +0330 Subject: [PATCH] feat: add private and profile --- cmd/server/main.go | 3 + cmd/server/settler.go | 25 +++ internal/admin/admin.go | 120 ++++++++++ internal/admin/templates/shop.html | 92 +++++++- internal/admin/templates/users.html | 15 +- internal/auth/handler.go | 31 +++ internal/economy/catalog.go | 9 + internal/economy/catalog_store.go | 27 +++ internal/economy/economy.go | 31 ++- internal/economy/handler.go | 38 ++++ internal/economy/stats.go | 86 +++++++ internal/economy/tables.go | 44 ++++ internal/game/hokm.go | 2 + internal/store/migrations/004_stats.sql | 12 + internal/store/migrations/005_vip.sql | 14 ++ .../store/migrations/006_private_tables.sql | 2 + internal/user/user.go | 8 + internal/ws/client.go | 7 +- internal/ws/hub.go | 210 +++++++++++++++++- internal/ws/protocol.go | 31 ++- internal/ws/room.go | 46 +++- internal/ws/settler.go | 12 + internal/ws/ws_test.go | 132 +++++++++++ 23 files changed, 972 insertions(+), 25 deletions(-) create mode 100644 internal/economy/stats.go create mode 100644 internal/economy/tables.go create mode 100644 internal/store/migrations/004_stats.sql create mode 100644 internal/store/migrations/005_vip.sql create mode 100644 internal/store/migrations/006_private_tables.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index b53a317..8194a5f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -92,9 +92,12 @@ func main() { r.Group(func(r chi.Router) { r.Use(jwt.Middleware) r.Get("/me", authH.Me) + r.Post("/profile", authH.UpdateProfile) // کیف‌پول و فروشگاه r.Get("/wallet", ecoH.Wallet) + r.Get("/stats", ecoH.Stats) + r.Get("/tables/info", ecoH.TablesInfo) r.Get("/shop", ecoH.Shop) r.Post("/shop/buy-card", ecoH.BuyCard) r.Post("/shop/select-card", ecoH.SelectCard) diff --git a/cmd/server/settler.go b/cmd/server/settler.go index ca542fb..8bb14fe 100644 --- a/cmd/server/settler.go +++ b/cmd/server/settler.go @@ -47,6 +47,31 @@ func (g gameSettler) RecordGame(room, playersJSON string, winnerTeam int, kot bo } } +// RecordHand آمار یک هَندِ پایان‌یافته را برای کاربر ثبت می‌کند. +func (g gameSettler) RecordHand(userID int64, won, kot, asHakem bool) { + g.eco.RecordHand(context.Background(), userID, won, kot, asHakem) +} + +// RecordCut آمار بُرِش با حکم را برای کاربر ثبت می‌کند. +func (g gameSettler) RecordCut(userID int64) { + g.eco.RecordCut(context.Background(), userID) +} + +// RecordGameResult آمار پایان بازی (برد/باخت) را ثبت می‌کند. +func (g gameSettler) RecordGameResult(userID int64, won bool) { + g.eco.RecordGameResult(context.Background(), userID, won) +} + +// ChargePrivateTable یک میز خصوصیِ رایگان را برای کاربر مصرف می‌کند. +func (g gameSettler) ChargePrivateTable(userID int64) error { + return g.eco.ConsumePrivateTable(context.Background(), userID) +} + +// PrivateTableInfo باقیمانده‌ی میزهای رایگان و نامحدود بودن را برمی‌گرداند. +func (g gameSettler) PrivateTableInfo(userID int64) (int, bool) { + return g.eco.PrivateTableInfo(context.Background(), userID) +} + // TargetHands تعداد هَندِ لازم برای برد بازی در این tier (مثلاً مبتدی=۳). func (g gameSettler) TargetHands(tier string) int { t := g.eco.FindTier(tier) diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 834583d..799a619 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -36,9 +36,12 @@ func (h *Handler) Routes(user, pass string) http.Handler { r.Use(middleware.BasicAuth("hakemsho-admin", map[string]string{user: pass})) r.Get("/", h.dashboard) r.Get("/shop", h.shop) + r.Post("/shop/{kind}/add", h.addItem) + r.Post("/shop/{kind}/delete", h.deleteItem) r.Post("/shop/{kind}", h.updateItem) r.Get("/users", h.users) r.Post("/users/coins", h.adjustCoins) + r.Post("/users/grant", h.grantToUser) return r } @@ -82,6 +85,8 @@ func (h *Handler) shop(w http.ResponseWriter, r *http.Request) { "id", "title", "price_coins", "sort", "enabled"), "Boosters": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sort,enabled FROM boosters ORDER BY sort`, "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"), "Saved": r.URL.Query().Get("saved") == "1", @@ -118,6 +123,9 @@ func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) { case "booster": q = `UPDATE boosters SET title=?,multiplier=?,hours=?,price_toman=?,sort=?,enabled=? WHERE id=?` args = []any{r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en, id} + case "vip": + 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} @@ -134,6 +142,82 @@ func (h *Handler) updateItem(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) } +// addItem یک ردیفِ جدید به کاتالوگ اضافه می‌کند (افزودن محصول). +func (h *Handler) addItem(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + kind := chi.URLParam(r, "kind") + id := r.FormValue("id") + if id == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + f := func(k string) int { v, _ := strconv.Atoi(r.FormValue(k)); return v } + en := 0 + if r.FormValue("enabled") == "on" { + en = 1 + } + + var q string + var args []any + switch kind { + case "coin": + q = `INSERT INTO coin_packages (id,title,coins,vip_days,price_toman,bonus_pct,sort,enabled) VALUES (?,?,?,?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("coins"), f("vip_days"), f("price_toman"), f("bonus_pct"), f("sort"), en} + case "ticket": + q = `INSERT INTO ticket_packages (id,title,tickets,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("tickets"), f("price_toman"), f("sort"), en} + case "card": + q = `INSERT INTO card_skins (id,title,price_coins,sort,enabled) VALUES (?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("price_coins"), f("sort"), en} + case "booster": + q = `INSERT INTO boosters (id,title,multiplier,hours,price_toman,sort,enabled) VALUES (?,?,?,?,?,?)` + args = []any{id, r.FormValue("title"), f("multiplier"), f("hours"), f("price_toman"), f("sort"), en} + case "vip": + 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} + default: + http.Error(w, "unknown kind", http.StatusBadRequest) + return + } + + if _, err := h.db.ExecContext(r.Context(), q, args...); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = h.eco.LoadCatalog(r.Context()) + http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) +} + +// deleteItem یک ردیفِ کاتالوگ را حذف می‌کند. +func (h *Handler) deleteItem(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + tables := map[string]string{ + "coin": "coin_packages", "ticket": "ticket_packages", "card": "card_skins", + "booster": "boosters", "vip": "vip_packages", "tier": "table_tiers", + } + tbl, ok := tables[chi.URLParam(r, "kind")] + if !ok { + http.Error(w, "unknown kind", http.StatusBadRequest) + return + } + if _, err := h.db.ExecContext(r.Context(), + "DELETE FROM "+tbl+" WHERE id = ?", r.FormValue("id")); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = h.eco.LoadCatalog(r.Context()) + http.Redirect(w, r, "/admin/shop?saved=1", http.StatusSeeOther) +} + // --- کاربران --- func (h *Handler) users(w http.ResponseWriter, r *http.Request) { @@ -194,6 +278,42 @@ func (h *Handler) adjustCoins(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/users?saved=1", http.StatusSeeOther) } +// grantToUser یک محصول (سکه/بلیط/VIP/کارت) را به کاربر اعطا می‌کند. +func (h *Handler) grantToUser(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad form", http.StatusBadRequest) + return + } + userID, _ := strconv.ParseInt(r.FormValue("user_id"), 10, 64) + kind := r.FormValue("kind") + value := r.FormValue("value") + ctx := r.Context() + if userID <= 0 { + http.Redirect(w, r, "/admin/users", http.StatusSeeOther) + return + } + + var err error + switch kind { + case "coins": + amount, _ := strconv.ParseInt(value, 10, 64) + err = h.eco.Adjust(ctx, userID, economy.CurrencyCoin, amount, "admin_grant", "") + case "tickets": + amount, _ := strconv.ParseInt(value, 10, 64) + err = h.eco.Adjust(ctx, userID, economy.CurrencyTicket, amount, "admin_grant", "") + case "vip": + days, _ := strconv.Atoi(value) + err = h.eco.GrantVIPDays(ctx, userID, days) + case "card": + err = h.eco.GrantCard(ctx, userID, value) + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Redirect(w, r, "/admin/users?saved=1", http.StatusSeeOther) +} + // rows یک کوئری را به []map[string]any تبدیل می‌کند (برای رندرِ عمومیِ جدول‌ها). func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any { rs, err := h.db.QueryContext(ctx, query) diff --git a/internal/admin/templates/shop.html b/internal/admin/templates/shop.html index 436537e..65c7ab8 100644 --- a/internal/admin/templates/shop.html +++ b/internal/admin/templates/shop.html @@ -6,10 +6,11 @@

فروشگاه

{{if .Saved}}
تغییرات ذخیره شد ✓
{{end}} +

برای ویرایش مقدارها «ذخیره»، برای حذف «حذف» و در ردیف آخر هر جدول محصول جدید «افزودن».

بسته‌های سکه

- + {{range .Coins}} @@ -21,13 +22,25 @@ + {{end}} + + + + + + + + + + +
شناسهعنوانسکهVIP (روز)قیمت (تومان)بونوس٪ترتیبفعال
شناسهعنوانسکهVIP (روز)قیمت (تومان)بونوس٪ترتیبفعال
{{.id}}

بسته‌های بلیط

- + {{range .Tickets}} @@ -37,13 +50,23 @@ + {{end}} + + + + + + + + +
شناسهعنوانبلیطقیمت (تومان)ترتیبفعال
شناسهعنوانبلیطقیمت (تومان)ترتیبفعال
{{.id}}

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

- + {{range .Cards}} @@ -52,13 +75,22 @@ + {{end}} + + + + + + + +
شناسهعنوانقیمت (سکه)ترتیبفعال
شناسهعنوانقیمت (سکه)ترتیبفعال
{{.id}}

بوسترها

- + {{range .Boosters}} @@ -69,13 +101,50 @@ + {{end}} + + + + + + + + + + +
شناسهعنوانضریبساعتقیمت (تومان)ترتیبفعال
شناسهعنوانضریبساعتقیمت (تومان)ترتیبفعال
{{.id}}
+ +

اشتراک‌های VIP

+ + + {{range .VIP}} + + + + + + + + + + + {{end}} + + + + + + + + +
شناسهعنوانماهقیمت (تومان)ترتیبفعال
{{.id}}

انواع میز

- + {{range .Tiers}} @@ -88,8 +157,21 @@ + {{end}} + + + + + + + + + + + +
شناسهعنواندستورودیجایزهXPجامترتیبفعال
شناسهعنواندستورودیجایزهXPجامترتیبفعال
{{.id}}
diff --git a/internal/admin/templates/users.html b/internal/admin/templates/users.html index fa4d138..795c808 100644 --- a/internal/admin/templates/users.html +++ b/internal/admin/templates/users.html @@ -13,7 +13,7 @@ - + {{range .Users}} @@ -30,6 +30,19 @@ + {{end}}
#موبایلسکهبلیطسطحجامادمینتغییر سکه (+/-)
#موبایلسکهبلیطسطحجامادمینتغییر سکه (+/-)اعطای محصول
{{.ID}} +
+ + + + +
+
diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 1411821..fff2833 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "regexp" + "strings" "time" "hakemsho/internal/httpx" @@ -158,3 +159,33 @@ func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { } httpx.JSON(w, http.StatusOK, map[string]any{"user": u}) } + +type profileReq struct { + FirstName string `json:"first_name"` + Avatar string `json:"avatar"` +} + +// UpdateProfile — POST /api/profile (تنظیم نام نمایشی و آواتار، نیازمند JWT) +func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) { + id, ok := UserID(r.Context()) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + var req profileReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + name := strings.TrimSpace(req.FirstName) + if len([]rune(name)) < 2 || len([]rune(name)) > 20 { + httpx.Error(w, http.StatusUnprocessableEntity, "name must be 2-20 characters") + return + } + if err := h.users.UpdateProfile(r.Context(), id, name, req.Avatar); err != nil { + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + u, _ := h.users.FindByID(r.Context(), id) + httpx.JSON(w, http.StatusOK, map[string]any{"user": u}) +} diff --git a/internal/economy/catalog.go b/internal/economy/catalog.go index e6a561e..ff665a3 100644 --- a/internal/economy/catalog.go +++ b/internal/economy/catalog.go @@ -41,6 +41,14 @@ type Booster struct { PriceToman int `json:"price_toman"` } +// VIPPackage بسته‌ی اشتراک VIP با پول واقعی (IAP). +type VIPPackage struct { + ID string `json:"id"` + Title string `json:"title"` + Months int `json:"months"` + PriceToman int `json:"price_toman"` +} + // TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام. type TableTier struct { ID string `json:"id"` @@ -58,6 +66,7 @@ type Catalog struct { TicketPackages []TicketPackage `json:"ticket_packages"` CardSkins []CardSkin `json:"card_skins"` Boosters []Booster `json:"boosters"` + VIPPackages []VIPPackage `json:"vip_packages"` TableTiers []TableTier `json:"table_tiers"` } diff --git a/internal/economy/catalog_store.go b/internal/economy/catalog_store.go index dc1dc4b..5a2c703 100644 --- a/internal/economy/catalog_store.go +++ b/internal/economy/catalog_store.go @@ -67,6 +67,21 @@ func (s *Service) LoadCatalog(ctx context.Context) error { } rows.Close() + rows, err = s.db.QueryContext(ctx, + `SELECT id, title, months, price_toman FROM vip_packages WHERE enabled = 1 ORDER BY sort`) + if err != nil { + return err + } + for rows.Next() { + var p VIPPackage + if err := rows.Scan(&p.ID, &p.Title, &p.Months, &p.PriceToman); err != nil { + rows.Close() + return err + } + c.VIPPackages = append(c.VIPPackages, p) + } + 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`) if err != nil { @@ -143,6 +158,18 @@ func (s *Service) findBooster(id string) *Booster { return nil } +func (s *Service) findVIPPackage(id string) *VIPPackage { + s.mu.RLock() + defer s.mu.RUnlock() + for i := range s.cat.VIPPackages { + if s.cat.VIPPackages[i].ID == id { + p := s.cat.VIPPackages[i] + return &p + } + } + return nil +} + // FindTier نوع میز را از کش برمی‌گرداند (برای لایه بازی). func (s *Service) FindTier(id string) *TableTier { s.mu.RLock() diff --git a/internal/economy/economy.go b/internal/economy/economy.go index 46ca3c3..31c44a1 100644 --- a/internal/economy/economy.go +++ b/internal/economy/economy.go @@ -242,7 +242,7 @@ func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base in // PurchaseRequest درخواست تأیید خرید IAP. type PurchaseRequest struct { Store string `json:"store"` // bazaar | myket - Kind string `json:"kind"` // coin | ticket | booster + Kind string `json:"kind"` // coin | ticket | booster | vip ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ Token string `json:"token"` // purchaseToken } @@ -278,6 +278,12 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase if booster == nil { return ErrNotFound } + case "vip": + p := s.findVIPPackage(req.ProductID) + if p == nil { + return ErrNotFound + } + vipDays = p.Months * 30 // هر ماه = ۳۰ روز default: return ErrNotFound } @@ -341,6 +347,29 @@ func extendVIPTx(ctx context.Context, tx *sql.Tx, userID int64, days int) error return err } +// GrantVIPDays به‌صورت دستی (ادمین) اشتراک VIP را تمدید می‌کند. +func (s *Service) GrantVIPDays(ctx context.Context, userID int64, days int) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err := extendVIPTx(ctx, tx, userID, days); err != nil { + return err + } + return tx.Commit() +} + +// GrantCard به‌صورت دستی (ادمین) یک اسکین کارت را برای کاربر باز می‌کند. +func (s *Service) GrantCard(ctx context.Context, userID int64, cardID string) error { + if s.findCardSkin(cardID) == nil { + return ErrNotFound + } + _, err := s.db.ExecContext(ctx, + `INSERT OR IGNORE INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID) + return err +} + func laterOf(a, b time.Time) time.Time { if a.After(b) { return a diff --git a/internal/economy/handler.go b/internal/economy/handler.go index 2d3fd4e..ca768ab 100644 --- a/internal/economy/handler.go +++ b/internal/economy/handler.go @@ -138,6 +138,44 @@ func (h *Handler) Purchase(w http.ResponseWriter, r *http.Request) { } } +// Stats — GET /api/stats (آمار پروفایل؛ نمایشِ کامل ویژه‌ی کاربران VIP) +func (h *Handler) Stats(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + p, err := h.svc.GetProfile(r.Context(), id) + if err != nil { + httpx.Error(w, http.StatusNotFound, "user not found") + return + } + resp := map[string]any{"vip": p.VIP} + if p.VIP { + st, err := h.svc.GetStats(r.Context(), id) + if err != nil { + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + resp["stats"] = st + } + httpx.JSON(w, http.StatusOK, resp) +} + +// TablesInfo — GET /api/tables/info (باقیمانده‌ی میزهای خصوصیِ رایگان) +func (h *Handler) TablesInfo(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + remaining, unlimited := h.svc.PrivateTableInfo(r.Context(), id) + httpx.JSON(w, http.StatusOK, map[string]any{ + "remaining": remaining, + "unlimited": unlimited, + }) +} + // Daily — POST /api/rewards/daily func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) { id, ok := uid(r) diff --git a/internal/economy/stats.go b/internal/economy/stats.go new file mode 100644 index 0000000..568c3e7 --- /dev/null +++ b/internal/economy/stats.go @@ -0,0 +1,86 @@ +package economy + +import ( + "context" + "database/sql" +) + +// Stats آمار بازیِ کاربر برای نمایش در پروفایل. +type Stats struct { + Games int64 `json:"games"` // بازی کل + Wins int64 `json:"wins"` // برد کل + Losses int64 `json:"losses"` // باخت کل + KotMade int64 `json:"kot_made"` // کُت کردن + KotReceived int64 `json:"kot_received"` // کُت شدن + Cuts int64 `json:"cuts"` // بریدن (با حکم) + HakemCount int64 `json:"hakem_count"` // دست حاکم +} + +// GetStats آمار کاربر را می‌خواند (اگر ردیفی نباشد، صفر برمی‌گرداند). +func (s *Service) GetStats(ctx context.Context, userID int64) (*Stats, error) { + var st Stats + err := s.db.QueryRowContext(ctx, + `SELECT games, wins, losses, kot_made, kot_received, cuts, hakem_count + FROM user_stats WHERE user_id = ?`, userID). + Scan(&st.Games, &st.Wins, &st.Losses, &st.KotMade, &st.KotReceived, &st.Cuts, &st.HakemCount) + if err == sql.ErrNoRows { + return &st, nil // هنوز بازی‌ای ثبت نشده + } + if err != nil { + return nil, err + } + return &st, nil +} + +// bumpStats یک یا چند شمارنده را به‌صورت upsert افزایش می‌دهد. +// fields نگاشتِ ستون→مقدار افزوده است؛ ستون‌ها از مجموعه‌ی ثابتِ زیر می‌آیند (نه ورودی کاربر). +func (s *Service) bumpStats(ctx context.Context, userID int64, games, wins, losses, kotMade, kotRecv, cuts, hakem int64) { + _, err := s.db.ExecContext(ctx, + `INSERT INTO user_stats (user_id, games, wins, losses, kot_made, kot_received, cuts, hakem_count) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + games = games + excluded.games, + wins = wins + excluded.wins, + losses = losses + excluded.losses, + kot_made = kot_made + excluded.kot_made, + kot_received = kot_received + excluded.kot_received, + cuts = cuts + excluded.cuts, + hakem_count = hakem_count + excluded.hakem_count`, + userID, games, wins, losses, kotMade, kotRecv, cuts, hakem) + if err != nil { + // آمار غیرحیاتی است؛ خطا را لاگ‌نشده می‌گذریم تا جریان بازی مختل نشود. + _ = err + } +} + +// RecordHand یک هَندِ پایان‌یافته را برای کاربر ثبت می‌کند. +func (s *Service) RecordHand(ctx context.Context, userID int64, won, kot, asHakem bool) { + var kotMade, kotRecv, hakem int64 + if asHakem { + hakem = 1 + } + if kot { + if won { + kotMade = 1 + } else { + kotRecv = 1 + } + } + s.bumpStats(ctx, userID, 0, 0, 0, kotMade, kotRecv, 0, hakem) +} + +// RecordCut یک بُرِش با حکم را برای کاربر ثبت می‌کند. +func (s *Service) RecordCut(ctx context.Context, userID int64) { + s.bumpStats(ctx, userID, 0, 0, 0, 0, 0, 1, 0) +} + +// RecordGameResult پایانِ یک بازی را برای کاربر ثبت می‌کند (برد/باخت). +func (s *Service) RecordGameResult(ctx context.Context, userID int64, won bool) { + var wins, losses int64 + if won { + wins = 1 + } else { + losses = 1 + } + s.bumpStats(ctx, userID, 1, wins, losses, 0, 0, 0, 0) +} diff --git a/internal/economy/tables.go b/internal/economy/tables.go new file mode 100644 index 0000000..b2182ca --- /dev/null +++ b/internal/economy/tables.go @@ -0,0 +1,44 @@ +package economy + +import ( + "context" + "errors" +) + +// FreePrivateTables سقفِ میزهای خصوصیِ رایگان برای کاربرِ غیر VIP. +const FreePrivateTables = 5 + +// ErrTableLimit وقتی کاربرِ غیر VIP به سقفِ میزهای رایگان رسیده باشد. +var ErrTableLimit = errors.New("free private table limit reached") + +// PrivateTableInfo باقیمانده‌ی میزهای رایگان و نامحدود بودن (VIP) را برمی‌گرداند. +func (s *Service) PrivateTableInfo(ctx context.Context, userID int64) (remaining int, unlimited bool) { + if s.isVIP(ctx, userID) { + return 0, true + } + var used int + _ = s.db.QueryRowContext(ctx, `SELECT free_tables_used FROM users WHERE id = ?`, userID).Scan(&used) + rem := FreePrivateTables - used + if rem < 0 { + rem = 0 + } + return rem, false +} + +// ConsumePrivateTable در صورت مجاز بودن، یک میز خصوصی را برای کاربر مصرف می‌کند. +// برای VIP بدون محدودیت است؛ برای غیر VIP در صورت اتمامِ سقف خطا می‌دهد. +func (s *Service) ConsumePrivateTable(ctx context.Context, userID int64) error { + if s.isVIP(ctx, userID) { + return nil + } + res, err := s.db.ExecContext(ctx, + `UPDATE users SET free_tables_used = free_tables_used + 1 + WHERE id = ? AND free_tables_used < ?`, userID, FreePrivateTables) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrTableLimit + } + return nil +} diff --git a/internal/game/hokm.go b/internal/game/hokm.go index 186d9ea..556aa2e 100644 --- a/internal/game/hokm.go +++ b/internal/game/hokm.go @@ -48,6 +48,7 @@ type HandResult struct { Kot bool `json:"kot"` // کُت: تیم بازنده هیچ دستی نبرد HakemKot bool `json:"hakem_kot"` // حاکم‌کُت: تیمِ حاکم کُت شد (۳ امتیاز) Points int `json:"points"` // امتیاز این هَند (۱ معمولی، ۲ کُت، ۳ حاکم‌کُت) + Hakem int `json:"hakem"` // حاکمِ همین هَند (پیش از چرخش) NextHakem int `json:"next_hakem"` } @@ -289,6 +290,7 @@ func (g *Game) checkHandOver() { Kot: kot, HakemKot: points == 3, Points: points, + Hakem: g.Hakem, // حاکمِ این هَند (هنوز چرخش نکرده) NextHakem: nextHakem, } diff --git a/internal/store/migrations/004_stats.sql b/internal/store/migrations/004_stats.sql new file mode 100644 index 0000000..e480d08 --- /dev/null +++ b/internal/store/migrations/004_stats.sql @@ -0,0 +1,12 @@ +-- آمار کاربر (برای پروفایل): بازی‌ها، برد/باخت، کُت، بریدن، دست حاکم. +CREATE TABLE IF NOT EXISTS user_stats ( + user_id INTEGER PRIMARY KEY, + games INTEGER NOT NULL DEFAULT 0, + wins INTEGER NOT NULL DEFAULT 0, + losses INTEGER NOT NULL DEFAULT 0, + kot_made INTEGER NOT NULL DEFAULT 0, -- دفعاتی که تیمِ کاربر حریف را کُت کرد + kot_received INTEGER NOT NULL DEFAULT 0, -- دفعاتی که تیمِ کاربر کُت شد + cuts INTEGER NOT NULL DEFAULT 0, -- دفعاتی که کاربر با حکم بُرید + hakem_count INTEGER NOT NULL DEFAULT 0, -- دفعاتی که کاربر حاکم شد + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/internal/store/migrations/005_vip.sql b/internal/store/migrations/005_vip.sql new file mode 100644 index 0000000..386108e --- /dev/null +++ b/internal/store/migrations/005_vip.sql @@ -0,0 +1,14 @@ +-- بسته‌های اشتراک VIP (خرید با پول واقعی از طریق بازار/مایکت). +CREATE TABLE IF NOT EXISTS vip_packages ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + months INTEGER NOT NULL, -- مدت اشتراک به ماه + price_toman INTEGER NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1 +); + +INSERT OR IGNORE INTO vip_packages (id, title, months, price_toman, sort) VALUES + ('vip1', 'اشتراک ۱ ماهه', 1, 49000, 1), + ('vip2', 'اشتراک ۳ ماهه', 3, 119000, 2), + ('vip3', 'اشتراک ۶ ماهه', 6, 199000, 3); diff --git a/internal/store/migrations/006_private_tables.sql b/internal/store/migrations/006_private_tables.sql new file mode 100644 index 0000000..c85e97f --- /dev/null +++ b/internal/store/migrations/006_private_tables.sql @@ -0,0 +1,2 @@ +-- شمارنده‌ی میزهای خصوصیِ رایگانِ مصرف‌شده (دورهمی). کاربر غیر VIP سقفِ محدود دارد. +ALTER TABLE users ADD COLUMN free_tables_used INTEGER NOT NULL DEFAULT 0; diff --git a/internal/user/user.go b/internal/user/user.go index 54b280d..4704c36 100644 --- a/internal/user/user.go +++ b/internal/user/user.go @@ -52,6 +52,14 @@ func (r *Repo) Create(ctx context.Context, mobile string) (*User, error) { return r.FindByID(ctx, id) } +// UpdateProfile نام نمایشی و آواتار کاربر را تنظیم می‌کند. +func (r *Repo) UpdateProfile(ctx context.Context, id int64, firstName, avatar string) error { + _, err := r.db.ExecContext(ctx, + `UPDATE users SET first_name = ?, avatar = ? WHERE id = ?`, + firstName, avatar, id) + return err +} + // FindOrCreate اگر کاربر نبود می‌سازد (مطابق فلوی login-otp). func (r *Repo) FindOrCreate(ctx context.Context, mobile string) (*User, error) { u, err := r.FindByMobile(ctx, mobile) diff --git a/internal/ws/client.go b/internal/ws/client.go index 34aabfd..bcf62e3 100644 --- a/internal/ws/client.go +++ b/internal/ws/client.go @@ -31,9 +31,10 @@ type Client struct { closeOnce sync.Once // توسط goroutine هاب ست می‌شوند (تک‌نویسنده) و فقط توسط آن خوانده می‌شوند. - room *Room - seat int - tier string // نوع میزی که در صفش است (برای تسویه) + room *Room + seat int + tier string // نوع میزی که در صفش است (برای تسویه) + tableCode string // کدِ میز خصوصیِ در انتظار (پیش از شروع بازی) } // close اتصال را یک‌بار به‌صورت امن می‌بندد. diff --git a/internal/ws/hub.go b/internal/ws/hub.go index bfdfeec..5e81633 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -3,6 +3,7 @@ package ws import ( "fmt" "log/slog" + "math/rand" "net/http" "time" @@ -39,6 +40,15 @@ type endInfo struct { clients []*Client } +// pendingTable میز خصوصیِ در انتظارِ شروع (دورهمی). +// فقط توسط goroutine هاب خوانده/نوشته می‌شود. +type pendingTable struct { + code string + host int64 + clients []*Client + started bool // پس از فشردن «شروع» توسط میزبان +} + // Hub هماهنگ‌کننده مرکزی: اتصال‌ها، صف matchmaking و مسیریابی پیام‌ها. // تمام state آن فقط توسط goroutine Run تغییر می‌کند (single-writer، بدون قفل). // پس از ساخت یک میز، هاب دیگر به seatInfo دست نمی‌زند (مالک آن goroutine میز است). @@ -47,23 +57,26 @@ type Hub struct { upgrader websocket.Upgrader clients map[*Client]bool - queues map[string][]*Client // صف انتظار به ازای هر tier - locations map[int64]location // userID → محل بازی (برای reconnect) + queues map[string][]*Client // صف انتظار به ازای هر tier + locations map[int64]location // userID → محل بازی (برای reconnect) + tables map[string]*pendingTable // کد میز → میز خصوصیِ در انتظار roomSeq int botSeq int fillPending map[string]bool settler Settler - turnTimeout time.Duration - botDelay time.Duration - matchWait time.Duration - trickHold time.Duration + turnTimeout time.Duration + botDelay time.Duration + matchWait time.Duration + trickHold time.Duration + countdownDelay time.Duration // مهلت شمارش معکوسِ میز خصوصی پیش از شروع register chan *Client unregister chan *Client inbound chan inbound endRoom chan endInfo fill chan string // tier برای پر کردن با بات + startTbl chan string // کد میز خصوصی برای شروع (پس از شمارش معکوس) } func NewHub(auth AuthFunc) *Hub { @@ -78,17 +91,20 @@ func NewHub(auth AuthFunc) *Hub { clients: make(map[*Client]bool), queues: make(map[string][]*Client), locations: make(map[int64]location), + tables: make(map[string]*pendingTable), fillPending: make(map[string]bool), settler: noopSettler{}, - turnTimeout: defaultTurnTimeout, - botDelay: defaultBotDelay, - matchWait: defaultMatchWait, - trickHold: defaultTrickHold, + turnTimeout: defaultTurnTimeout, + botDelay: defaultBotDelay, + matchWait: defaultMatchWait, + trickHold: defaultTrickHold, + countdownDelay: 3 * time.Second, register: make(chan *Client), unregister: make(chan *Client), inbound: make(chan inbound, 64), endRoom: make(chan endInfo), fill: make(chan string, 8), + startTbl: make(chan string, 8), } } @@ -111,6 +127,8 @@ func (h *Hub) Run() { h.closeRoom(e) case tier := <-h.fill: h.onFill(tier) + case code := <-h.startTbl: + h.onStartTable(code) } } } @@ -150,6 +168,171 @@ func (h *Hub) handle(in inbound) { default: } } + case "create_table": + h.createTable(c) + case "join_table": + h.joinTable(c, in.msg.Code) + case "start_table": + h.startTable(c) + case "leave_table": + h.leaveTable(c) + } +} + +// --- میز خصوصی (دورهمی) --- + +// genCode یک کد ۵ رقمیِ یکتا برای میز خصوصی می‌سازد. +func (h *Hub) genCode() string { + for { + code := fmt.Sprintf("%05d", rand.Intn(100000)) + if _, ok := h.tables[code]; !ok { + return code + } + } +} + +// createTable یک میز خصوصی می‌سازد (در صورت مجاز بودنِ سهمیه). +func (h *Hub) createTable(c *Client) { + if c.room != nil || c.tableCode != "" { + return + } + if err := h.settler.ChargePrivateTable(c.UserID); err != nil { + c.trySend(mustJSON(errorMsg{Type: "error", Message: "سهمیه‌ی میزهای رایگان به پایان رسیده؛ برای میز نامحدود VIP بگیرید"})) + return + } + code := h.genCode() + t := &pendingTable{code: code, host: c.UserID, clients: []*Client{c}} + h.tables[code] = t + c.tableCode = code + slog.Info("private table created", "code", code, "host", c.UserID) + h.broadcastLobby(t) +} + +// joinTable کلاینت را به میز خصوصیِ موجود می‌افزاید. +func (h *Hub) joinTable(c *Client, code string) { + if c.room != nil || c.tableCode != "" { + return + } + t := h.tables[code] + if t == nil || t.started { + c.trySend(mustJSON(errorMsg{Type: "error", Message: "میز یافت نشد"})) + return + } + if len(t.clients) >= 4 { + c.trySend(mustJSON(errorMsg{Type: "error", Message: "میز پر است"})) + return + } + t.clients = append(t.clients, c) + c.tableCode = code + h.broadcastLobby(t) +} + +// startTable شمارش معکوس را آغاز و پس از آن بازی را شروع می‌کند (فقط میزبان). +func (h *Hub) startTable(c *Client) { + t := h.tables[c.tableCode] + if t == nil || t.host != c.UserID || t.started { + return + } + t.started = true + cd := mustJSON(countdownMsg{Type: "countdown", Seconds: 3}) + for _, cl := range t.clients { + cl.trySend(cd) + } + code := t.code + time.AfterFunc(h.countdownDelay, func() { + select { + case h.startTbl <- code: + default: + } + }) +} + +// onStartTable پس از شمارش معکوس، میز را از کلاینت‌های متصل می‌سازد و بازی را شروع می‌کند. +func (h *Hub) onStartTable(code string) { + t := h.tables[code] + if t == nil { + return + } + delete(h.tables, code) + + var seats [4]*seatInfo + n := 0 + for _, cl := range t.clients { + if n >= 4 || !h.clients[cl] { + continue // قطع‌شده‌ها نادیده گرفته می‌شوند + } + seats[n] = &seatInfo{client: cl, userID: cl.UserID, name: cl.Name, connected: true} + n++ + } + if n == 0 { + return // همه خارج شدند + } + for i := n; i < 4; i++ { + h.botSeq++ + seats[i] = &seatInfo{isBot: true, name: fmt.Sprintf("ربات %d", h.botSeq)} + } + + h.roomSeq++ + room := newRoom(fmt.Sprintf("p%d", h.roomSeq), seats, h) + room.tier = defaultTier // قواعدِ مبتدی برای دورهمی + room.private = true + for i := 0; i < n; i++ { + cl := seats[i].client + cl.room = room + cl.seat = i + cl.tableCode = "" + h.locations[cl.UserID] = location{room: room, seat: i} + } + go room.run() + slog.Info("private room started", "room", room.ID, "code", code, "humans", n) +} + +// leaveTable خروجِ داوطلبانه از اتاق انتظار. +func (h *Hub) leaveTable(c *Client) { + t := h.tables[c.tableCode] + c.tableCode = "" + if t != nil { + h.removeFromTable(t, c) + } +} + +// removeFromTable کلاینت را از میز حذف می‌کند؛ با خروجِ میزبان یا خالی‌شدن، میز منحل می‌شود. +func (h *Hub) removeFromTable(t *pendingTable, c *Client) { + idx := -1 + for i, cl := range t.clients { + if cl == c { + idx = i + break + } + } + if idx < 0 { + return + } + t.clients = append(t.clients[:idx], t.clients[idx+1:]...) + if c.UserID == t.host || len(t.clients) == 0 { + closed := mustJSON(tableClosedMsg{Type: "table_closed", Reason: "host_left"}) + for _, cl := range t.clients { + cl.tableCode = "" + cl.trySend(closed) + } + delete(h.tables, t.code) + return + } + h.broadcastLobby(t) +} + +// broadcastLobby وضعیت اتاق انتظار را برای همه‌ی اعضای میز ارسال می‌کند. +func (h *Hub) broadcastLobby(t *pendingTable) { + players := make([]lobbyPlayer, len(t.clients)) + for i, cl := range t.clients { + players[i] = lobbyPlayer{Name: cl.Name, Host: cl.UserID == t.host} + } + for _, cl := range t.clients { + remaining, unlimited := h.settler.PrivateTableInfo(cl.UserID) + cl.trySend(mustJSON(tableLobbyMsg{ + Type: "table_lobby", Code: t.code, Players: players, + Host: cl.UserID == t.host, Remaining: remaining, Unlimited: unlimited, + })) } } @@ -248,6 +431,13 @@ func (h *Hub) handleDisconnect(c *Client) { } } } + // اگر در اتاق انتظارِ میز خصوصی بود، از آن حذف شود. + if c.tableCode != "" { + if t := h.tables[c.tableCode]; t != nil { + h.removeFromTable(t, c) + } + c.tableCode = "" + } if c.room != nil { select { case c.room.actions <- roomAction{kind: akDisconnect, seat: c.seat, client: c}: diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 084931a..fdee721 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -4,11 +4,40 @@ import "hakemsho/internal/game" // inboundMsg پیام دریافتی از کلاینت. type inboundMsg struct { - Type string `json:"type"` // join_queue | choose_trump | play_card | leave + Type string `json:"type"` // join_queue | choose_trump | play_card | leave | create_table | join_table | start_table | leave_table Mode string `json:"mode"` // برای join_queue Tier string `json:"tier"` // برای join_queue: نوع میز (beginner/pro/...) Suit string `json:"suit"` // برای choose_trump: hearts|spades|diamonds|clubs Card string `json:"card"` // برای play_card: مثل "AS" + Code string `json:"code"` // برای join_table: شماره میز خصوصی +} + +// lobbyPlayer یک بازیکن در اتاق انتظارِ میز خصوصی. +type lobbyPlayer struct { + Name string `json:"name"` + Host bool `json:"host"` +} + +// tableLobbyMsg وضعیت اتاق انتظارِ میز خصوصی (دورهمی). +type tableLobbyMsg struct { + Type string `json:"type"` // "table_lobby" + Code string `json:"code"` + Players []lobbyPlayer `json:"players"` + Host bool `json:"host"` // آیا گیرنده میزبان است + Remaining int `json:"remaining"` // باقیمانده‌ی میزهای رایگانِ گیرنده + Unlimited bool `json:"unlimited"` +} + +// countdownMsg شمارش معکوس پیش از شروع بازیِ خصوصی. +type countdownMsg struct { + Type string `json:"type"` // "countdown" + Seconds int `json:"seconds"` +} + +// tableClosedMsg انحلالِ میز خصوصی پیش از شروع (میزبان خارج شد). +type tableClosedMsg struct { + Type string `json:"type"` // "table_closed" + Reason string `json:"reason"` } // PlayerInfo اطلاعات عمومی یک بازیکن سر میز. diff --git a/internal/ws/room.go b/internal/ws/room.go index ed04e87..da09841 100644 --- a/internal/ws/room.go +++ b/internal/ws/room.go @@ -47,6 +47,7 @@ type Room struct { ID string hub *Hub tier string + private bool // میز دورهمی: بدون ورودی/جایزه (فقط آمار ثبت می‌شود) settler Settler seats [4]*seatInfo game *game.Game @@ -130,6 +131,7 @@ func (r *Room) react() { switch r.game.Phase { case game.PhaseHandOver: res := r.game.LastResult + r.recordHandStats(res) r.broadcast(mustJSON(handOverMsg{ Type: "hand_over", WinnerTeam: res.WinnerTeam, Kot: res.Kot, HakemKot: res.HakemKot, Points: res.Points, Scores: r.game.Scores, @@ -144,6 +146,7 @@ func (r *Room) react() { r.armTimer() case game.PhaseGameOver: res := r.game.LastResult + r.recordHandStats(res) r.broadcast(mustJSON(gameOverMsg{ Type: "game_over", WinnerTeam: res.WinnerTeam, Scores: r.game.Scores, })) @@ -238,7 +241,7 @@ func (r *Room) handleInput(act roomAction) { r.sendError(act.seat, "invalid card") return } - if err := r.game.PlayCard(act.seat, card); err != nil { + if err := r.playCard(act.seat, card); err != nil { r.sendError(act.seat, err.Error()) return } @@ -307,7 +310,37 @@ func (r *Room) autoPlayCard(seat int) { } } } - _ = r.game.PlayCard(seat, choice) + _ = r.playCard(seat, choice) +} + +// playCard کارت را بازی کرده و در صورت بُرِش با حکم، آن را برای آمار ثبت می‌کند. +func (r *Room) playCard(seat int, card game.Card) error { + wasFirst := len(r.game.Trick) == 0 + if err := r.game.PlayCard(seat, card); err != nil { + return err + } + // بُرِش: کارتِ غیرِاولِ دست، از خالِ حکم، در حالی که خالِ زمینه حکم نبوده + // ⇒ بازیکن خالِ زمینه را نداشته و با حکم بریده است. + if !wasFirst && card.Suit == r.game.Trump && r.game.LeadSuit != r.game.Trump { + if s := r.seats[seat]; !s.isBot && s.userID != 0 { + r.settler.RecordCut(s.userID) + } + } + return nil +} + +// recordHandStats آمار هر بازیکنِ انسان را برای هَندِ پایان‌یافته ثبت می‌کند. +func (r *Room) recordHandStats(res *game.HandResult) { + if res == nil { + return + } + for seat, s := range r.seats { + if s.isBot || s.userID == 0 { + continue + } + won := game.Team(seat) == res.WinnerTeam + r.settler.RecordHand(s.userID, won, res.Kot, seat == res.Hakem) + } } // handleLeave خروج دائمی یک بازیکن؛ جایگاهش به بات تبدیل می‌شود. @@ -383,15 +416,20 @@ func (r *Room) settleGameOver(res *game.HandResult) { if s.isBot || s.userID == 0 { continue } - if game.Team(seat) == res.WinnerTeam { - r.settler.AwardWinner(s.userID, r.tier) + won := game.Team(seat) == res.WinnerTeam + if won && !r.private { + r.settler.AwardWinner(s.userID, r.tier) // میز دورهمی جایزه‌ی سکه ندارد } + r.settler.RecordGameResult(s.userID, won) } r.settler.RecordGame(r.ID, r.playersJSON(res.WinnerTeam), res.WinnerTeam, res.Kot) } // refundAll ورودی را به بازیکنانِ انسان که داوطلبانه خارج نشده‌اند بازمی‌گرداند. func (r *Room) refundAll() { + if r.private { + return // میز دورهمی ورودی نگرفته، پس بازگشتی هم ندارد + } for _, s := range r.seats { if s.isBot || s.left || s.userID == 0 { continue diff --git a/internal/ws/settler.go b/internal/ws/settler.go index 1704592..94a717f 100644 --- a/internal/ws/settler.go +++ b/internal/ws/settler.go @@ -7,6 +7,13 @@ type Settler interface { Refund(userID int64, tier string) // بازگرداندن ورودی در صورت لغو AwardWinner(userID int64, tier string) // جایزه/XP/جام به برنده 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) // پایان بازی (برد/باخت) + // میز خصوصی (دورهمی). + ChargePrivateTable(userID int64) error // مصرفِ یک میز رایگان (خطا اگر سقف پر باشد) + PrivateTableInfo(userID int64) (remaining int, unlimited bool) // TargetHands تعداد هَندِ لازم برای برد بازی در این tier (مثلاً مبتدی=۳). TargetHands(tier string) int } @@ -18,4 +25,9 @@ func (noopSettler) ChargeEntry(int64, string) error { return nil } func (noopSettler) Refund(int64, string) {} func (noopSettler) AwardWinner(int64, string) {} 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) 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 2edebb2..5d816d4 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -27,6 +28,8 @@ type anyMsg struct { LeadSuit string `json:"lead_suit"` TrickDone bool `json:"trick_done"` Players []PlayerInfo `json:"players"` + Code string `json:"code"` // table_lobby + Host bool `json:"host"` // table_lobby } // TestFourPlayersFullGame یک بازی کامل را روی WebSocket واقعی با ۴ ربات اجرا می‌کند. @@ -119,10 +122,117 @@ func testHub() *Hub { hub.botDelay = 8 * time.Millisecond hub.matchWait = 300 * time.Millisecond hub.trickHold = 5 * time.Millisecond + hub.countdownDelay = 20 * time.Millisecond go hub.Run() return hub } +// TestPrivateTableFlow: میزبان میز خصوصی می‌سازد، بازیکن دوم می‌پیوندد، +// میزبان شروع می‌کند و بازی (با بات برای ۲ جای خالی) تا پایان پیش می‌رود. +func TestPrivateTableFlow(t *testing.T) { + hub := testHub() + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + dial := func(tok string) *websocket.Conn { + c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+tok, nil) + if err != nil { + t.Fatalf("dial %s: %v", tok, err) + } + return c + } + + host := dial("1") + defer host.Close() + if err := host.WriteJSON(map[string]string{"type": "create_table"}); err != nil { + t.Fatal(err) + } + + // میزبان باید table_lobby با کد دریافت کند. + code := "" + for code == "" { + _ = host.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, raw, err := host.ReadMessage() + if err != nil { + t.Fatalf("host read: %v", err) + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + if m.Type == "table_lobby" { + if !m.Host { + t.Fatal("host flag must be true for creator") + } + code = m.Code + } + } + if len(code) != 5 { + t.Fatalf("expected 5-digit code, got %q", code) + } + + joiner := dial("2") + defer joiner.Close() + if err := joiner.WriteJSON(map[string]string{"type": "join_table", "code": code}); err != nil { + t.Fatal(err) + } + + // میزبان منتظر می‌ماند تا میز ۲ نفره شود، سپس شروع می‌کند. + var gotCountdown atomic.Bool + play := func(conn *websocket.Conn, m *anyMsg) { + if m.Type == "state" { + act(conn, m) + } + } + + done := make(chan string, 2) + run := func(conn *websocket.Conn, isHost bool) { + started := false + for { + _ = conn.SetReadDeadline(time.Now().Add(6 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + done <- "read err: " + err.Error() + return + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + switch m.Type { + case "table_lobby": + if isHost && !started && len(m.Players) >= 2 { + started = true + _ = conn.WriteJSON(map[string]string{"type": "start_table"}) + } + case "countdown": + gotCountdown.Store(true) + case "state": + play(conn, &m) + case "game_over": + done <- "ok" + return + case "error": + done <- "error: " + string(raw) + return + } + } + } + go run(host, true) + go run(joiner, false) + + for i := 0; i < 2; i++ { + select { + case res := <-done: + if res != "ok" { + t.Fatalf("client finished with: %s", res) + } + case <-time.After(25 * time.Second): + t.Fatal("private game did not finish in time") + } + } + if !gotCountdown.Load() { + t.Fatal("expected countdown message before game start") + } +} + // TestDisconnectMidGameNoPanic: قطع بازیکن وسط بازی باید بدون panic، رویداد // player_disconnected تولید کند و بازی با حرکت خودکار ادامه یابد. func TestDisconnectMidGameNoPanic(t *testing.T) { @@ -323,6 +433,9 @@ type recordingSettler struct { refunds int awards int records int + hands int + cuts int + results int } func (s *recordingSettler) ChargeEntry(int64, string) error { @@ -347,6 +460,25 @@ func (s *recordingSettler) RecordGame(string, string, int, bool) { s.mu.Unlock() } +func (s *recordingSettler) RecordHand(int64, bool, bool, bool) { + s.mu.Lock() + s.hands++ + s.mu.Unlock() +} +func (s *recordingSettler) RecordCut(int64) { + s.mu.Lock() + s.cuts++ + s.mu.Unlock() +} +func (s *recordingSettler) RecordGameResult(int64, bool) { + s.mu.Lock() + s.results++ + s.mu.Unlock() +} + +func (s *recordingSettler) ChargePrivateTable(int64) error { return nil } +func (s *recordingSettler) PrivateTableInfo(int64) (int, bool) { return 5, false } + // برای سرعتِ تست، بازی پس از ۲ هَند تمام می‌شود. func (s *recordingSettler) TargetHands(string) int { return 2 } func (s *recordingSettler) snapshot() (c, r, a, rec int) {