feat: add private and profile
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user