feat: add private and profile

This commit is contained in:
2026-06-17 11:32:51 +03:30
parent 02060a4dc0
commit 0827858752
23 changed files with 972 additions and 25 deletions
+3
View File
@@ -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)
+25
View File
@@ -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)
+120
View File
@@ -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)
+87 -5
View File
@@ -6,10 +6,11 @@
<div class="wrap">
<h1>فروشگاه</h1>
{{if .Saved}}<div class="saved">تغییرات ذخیره شد ✓</div>{{end}}
<p style="color:#888">برای ویرایش مقدارها «ذخیره»، برای حذف «حذف» و در ردیف آخر هر جدول محصول جدید «افزودن».</p>
<h2>بسته‌های سکه</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>سکه</th><th>VIP (روز)</th><th>قیمت (تومان)</th><th>بونوس٪</th><th>ترتیب</th><th>فعال</th><th></th></tr>
<tr><th>شناسه</th><th>عنوان</th><th>سکه</th><th>VIP (روز)</th><th>قیمت (تومان)</th><th>بونوس٪</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
{{range .Coins}}
<tr><form method="post" action="/admin/shop/coin">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
@@ -21,13 +22,25 @@
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
<td><button formaction="/admin/shop/coin/delete" formnovalidate class="del">حذف</button></td>
</form></tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/shop/coin/add">
<td><input name="id" placeholder="شناسه" required></td>
<td><input name="title" placeholder="عنوان"></td>
<td><input name="coins" value="0"></td>
<td><input name="vip_days" value="0"></td>
<td><input name="price_toman" value="0"></td>
<td><input name="bonus_pct" value="0"></td>
<td><input name="sort" value="0"></td>
<td><input type="checkbox" name="enabled" checked></td>
<td colspan="2"><button>افزودن</button></td>
</form></tr>
</table>
<h2>بسته‌های بلیط</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>بلیط</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
<tr><th>شناسه</th><th>عنوان</th><th>بلیط</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
{{range .Tickets}}
<tr><form method="post" action="/admin/shop/ticket">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
@@ -37,13 +50,23 @@
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
<td><button formaction="/admin/shop/ticket/delete" formnovalidate class="del">حذف</button></td>
</form></tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/shop/ticket/add">
<td><input name="id" placeholder="شناسه" required></td>
<td><input name="title" placeholder="عنوان"></td>
<td><input name="tickets" value="0"></td>
<td><input name="price_toman" value="0"></td>
<td><input name="sort" value="0"></td>
<td><input type="checkbox" name="enabled" checked></td>
<td colspan="2"><button>افزودن</button></td>
</form></tr>
</table>
<h2>اسکین کارت‌ها</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
<tr><th>شناسه</th><th>عنوان</th><th>قیمت (سکه)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
{{range .Cards}}
<tr><form method="post" action="/admin/shop/card">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
@@ -52,13 +75,22 @@
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
<td><button formaction="/admin/shop/card/delete" formnovalidate class="del">حذف</button></td>
</form></tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/shop/card/add">
<td><input name="id" placeholder="شناسه" required></td>
<td><input name="title" placeholder="عنوان"></td>
<td><input name="price_coins" value="0"></td>
<td><input name="sort" value="0"></td>
<td><input type="checkbox" name="enabled" checked></td>
<td colspan="2"><button>افزودن</button></td>
</form></tr>
</table>
<h2>بوسترها</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>ضریب</th><th>ساعت</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th></th></tr>
<tr><th>شناسه</th><th>عنوان</th><th>ضریب</th><th>ساعت</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
{{range .Boosters}}
<tr><form method="post" action="/admin/shop/booster">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
@@ -69,13 +101,50 @@
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
<td><button formaction="/admin/shop/booster/delete" formnovalidate class="del">حذف</button></td>
</form></tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/shop/booster/add">
<td><input name="id" placeholder="شناسه" required></td>
<td><input name="title" placeholder="عنوان"></td>
<td><input name="multiplier" value="2"></td>
<td><input name="hours" value="24"></td>
<td><input name="price_toman" value="0"></td>
<td><input name="sort" value="0"></td>
<td><input type="checkbox" name="enabled" checked></td>
<td colspan="2"><button>افزودن</button></td>
</form></tr>
</table>
<h2>اشتراک‌های VIP</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>ماه</th><th>قیمت (تومان)</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
{{range .VIP}}
<tr><form method="post" action="/admin/shop/vip">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
<td><input type="text" name="title" value="{{.title}}"></td>
<td><input name="months" value="{{.months}}"></td>
<td><input name="price_toman" value="{{.price_toman}}"></td>
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
<td><button formaction="/admin/shop/vip/delete" formnovalidate class="del">حذف</button></td>
</form></tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/shop/vip/add">
<td><input name="id" placeholder="شناسه" required></td>
<td><input name="title" placeholder="عنوان"></td>
<td><input name="months" value="1"></td>
<td><input name="price_toman" value="0"></td>
<td><input name="sort" value="0"></td>
<td><input type="checkbox" name="enabled" checked></td>
<td colspan="2"><button>افزودن</button></td>
</form></tr>
</table>
<h2>انواع میز</h2>
<table>
<tr><th>شناسه</th><th>عنوان</th><th>دست</th><th>ورودی</th><th>جایزه</th><th>XP</th><th>جام</th><th>ترتیب</th><th>فعال</th><th></th></tr>
<tr><th>شناسه</th><th>عنوان</th><th>دست</th><th>ورودی</th><th>جایزه</th><th>XP</th><th>جام</th><th>ترتیب</th><th>فعال</th><th colspan="2"></th></tr>
{{range .Tiers}}
<tr><form method="post" action="/admin/shop/tier">
<td>{{.id}}<input type="hidden" name="id" value="{{.id}}"></td>
@@ -88,8 +157,21 @@
<td><input name="sort" value="{{.sort}}"></td>
<td><input type="checkbox" name="enabled" {{if .enabled}}checked{{end}}></td>
<td><button>ذخیره</button></td>
<td><button formaction="/admin/shop/tier/delete" formnovalidate class="del">حذف</button></td>
</form></tr>
{{end}}
<tr class="addrow"><form method="post" action="/admin/shop/tier/add">
<td><input name="id" placeholder="شناسه" required></td>
<td><input name="title" placeholder="عنوان"></td>
<td><input name="hands" value="7"></td>
<td><input name="entry" value="0"></td>
<td><input name="prize" value="0"></td>
<td><input name="xp" value="0"></td>
<td><input name="trophy" value="0"></td>
<td><input name="sort" value="0"></td>
<td><input type="checkbox" name="enabled" checked></td>
<td colspan="2"><button>افزودن</button></td>
</form></tr>
</table>
</div>
</body>
+14 -1
View File
@@ -13,7 +13,7 @@
</form>
<table>
<tr><th>#</th><th>موبایل</th><th>سکه</th><th>بلیط</th><th>سطح</th><th>جام</th><th>ادمین</th><th>تغییر سکه (+/-)</th></tr>
<tr><th>#</th><th>موبایل</th><th>سکه</th><th>بلیط</th><th>سطح</th><th>جام</th><th>ادمین</th><th>تغییر سکه (+/-)</th><th>اعطای محصول</th></tr>
{{range .Users}}
<tr>
<td>{{.ID}}</td>
@@ -30,6 +30,19 @@
<button>اعمال</button>
</form>
</td>
<td>
<form method="post" action="/admin/users/grant" style="display:flex;gap:6px">
<input type="hidden" name="user_id" value="{{.ID}}">
<select name="kind">
<option value="coins">سکه</option>
<option value="tickets">بلیط</option>
<option value="vip">VIP (روز)</option>
<option value="card">کارت (شناسه)</option>
</select>
<input name="value" placeholder="مقدار / شناسه">
<button>اعطا</button>
</form>
</td>
</tr>
{{end}}
</table>
+31
View File
@@ -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})
}
+9
View File
@@ -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"`
}
+27
View File
@@ -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()
+30 -1
View File
@@ -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
+38
View File
@@ -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)
+86
View File
@@ -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)
}
+44
View File
@@ -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
}
+2
View File
@@ -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,
}
+12
View File
@@ -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
);
+14
View File
@@ -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);
@@ -0,0 +1,2 @@
-- شمارنده‌ی میزهای خصوصیِ رایگانِ مصرف‌شده (دورهمی). کاربر غیر VIP سقفِ محدود دارد.
ALTER TABLE users ADD COLUMN free_tables_used INTEGER NOT NULL DEFAULT 0;
+8
View File
@@ -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)
+4 -3
View File
@@ -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 اتصال را یک‌بار به‌صورت امن می‌بندد.
+200 -10
View File
@@ -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}:
+30 -1
View File
@@ -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 اطلاعات عمومی یک بازیکن سر میز.
+42 -4
View File
@@ -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
+12
View File
@@ -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 }
+132
View File
@@ -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) {