diff --git a/cmd/server/main.go b/cmd/server/main.go
index 96411ea..aa198e0 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -172,9 +172,23 @@ func main() {
r.Post("/shop/purchase", ecoH.Purchase)
r.Post("/rewards/daily", ecoH.Daily)
r.Post("/rewards/ad", ecoH.AdReward)
+
+ // تورنومنتها
+ r.Get("/tournaments", ecoH.Tournaments)
+ r.Get("/tournaments/standings", ecoH.TournamentStandings)
+ r.Post("/tournaments/join", ecoH.JoinTournament)
})
})
+ // تسویهی خودکارِ تورنومنتهای پایانیافته (هر دقیقه).
+ go func() {
+ t := time.NewTicker(time.Minute)
+ defer t.Stop()
+ for range t.C {
+ eco.SettleTournaments(context.Background())
+ }
+ }()
+
srv := &http.Server{
Addr: cfg.Addr,
Handler: r,
diff --git a/cmd/server/settler.go b/cmd/server/settler.go
index 45946cb..5c08966 100644
--- a/cmd/server/settler.go
+++ b/cmd/server/settler.go
@@ -68,9 +68,11 @@ func (g gameSettler) RecordCut(userID int64) {
g.eco.RecordCut(context.Background(), userID)
}
-// RecordGameResult آمار پایان بازی (برد/باخت) را ثبت میکند.
+// RecordGameResult آمار پایان بازی (برد/باخت) را ثبت میکند و امتیازِ تورنومنت
+// را (برای تورنومنتهای فعالی که کاربر عضوشان است) بهروزرسانی میکند.
func (g gameSettler) RecordGameResult(userID int64, won bool) {
g.eco.RecordGameResult(context.Background(), userID, won)
+ g.eco.AddTournamentPoints(context.Background(), userID, won)
}
// PlayerProfile سکه، نشانِ رتبه و آواتارِ بازیکن را برای نمایش سرِ میز برمیگرداند.
diff --git a/internal/admin/admin.go b/internal/admin/admin.go
index 5265be5..e18936d 100644
--- a/internal/admin/admin.go
+++ b/internal/admin/admin.go
@@ -64,6 +64,9 @@ func (h *Handler) Routes(user, pass string) http.Handler {
r.Post("/chat/message/add", h.chatMessageAdd)
r.Post("/chat/message", h.chatMessageUpdate)
r.Post("/chat/message/delete", h.chatMessageDelete)
+ r.Get("/tournaments", h.tournamentsAdmin)
+ r.Post("/tournaments/add", h.tournamentAdd)
+ r.Post("/tournaments/delete", h.tournamentDelete)
return r
}
@@ -681,3 +684,63 @@ func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[
}
return out
}
+
+// --- مدیریتِ تورنومنتها ---
+
+func (h *Handler) tournamentsAdmin(w http.ResponseWriter, r *http.Request) {
+ list, _ := h.eco.AdminListTournaments(r.Context())
+ h.render(w, "tournaments.html", map[string]any{
+ "Nav": "tournaments",
+ "Tournaments": list,
+ "Saved": r.URL.Query().Get("saved") == "1",
+ })
+}
+
+func (h *Handler) tournamentAdd(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ num := func(k string) int64 { v, _ := strconv.ParseInt(r.FormValue(k), 10, 64); return v }
+ // جوایز: مقادیرِ سکه با کاما جدا شده ("۵۰۰۰,۳۰۰۰,۱۰۰۰").
+ var prizes []int64
+ for _, p := range strings.Split(r.FormValue("prizes"), ",") {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ continue
+ }
+ if v, err := strconv.ParseInt(p, 10, 64); err == nil {
+ prizes = append(prizes, v)
+ }
+ }
+ // ورودیِ datetime-local به وقتِ تهران (UTC+3:30) تفسیر و به UTC ذخیره میشود.
+ iran := time.FixedZone("IRST", 12600)
+ parseDT := func(k string) time.Time {
+ t, err := time.ParseInLocation("2006-01-02T15:04", r.FormValue(k), iran)
+ if err != nil {
+ return time.Now().UTC()
+ }
+ return t.UTC()
+ }
+ if r.FormValue("title") == "" {
+ http.Error(w, "عنوان لازم است", http.StatusBadRequest)
+ return
+ }
+ _, err := h.eco.CreateTournament(r.Context(), economy.Tournament{
+ Title: r.FormValue("title"),
+ Description: r.FormValue("description"),
+ EntryFee: num("entry_fee"),
+ Prizes: prizes,
+ StartsAt: parseDT("starts_at"),
+ EndsAt: parseDT("ends_at"),
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/admin/tournaments?saved=1", http.StatusSeeOther)
+}
+
+func (h *Handler) tournamentDelete(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64)
+ _ = h.eco.DeleteTournament(r.Context(), id)
+ http.Redirect(w, r, "/admin/tournaments?saved=1", http.StatusSeeOther)
+}
diff --git a/internal/admin/templates/layout.html b/internal/admin/templates/layout.html
index 6583c0c..c3ea9cc 100644
--- a/internal/admin/templates/layout.html
+++ b/internal/admin/templates/layout.html
@@ -35,6 +35,7 @@
فروشگاه
فرشها
چت
+ تورنومنت
کاربران
تلسکوپ
diff --git a/internal/admin/templates/tournaments.html b/internal/admin/templates/tournaments.html
new file mode 100644
index 0000000..787d734
--- /dev/null
+++ b/internal/admin/templates/tournaments.html
@@ -0,0 +1,52 @@
+
+
+{{template "head" .}}
+
+{{template "nav" .}}
+
+
تورنومنتها
+ {{if .Saved}}
انجام شد ✓
{{end}}
+
+
+ تورنومنتِ امتیازی: بازیکنان با پرداختِ ورودی ثبتنام میکنند و در بازهی زمانی با
+ بردنِ بازی امتیاز میگیرند (برد ۱۰۰، شرکت ۲۵). در پایان، نفراتِ برتر بهترتیبِ رتبه
+ جایزهی سکه میگیرند. زمانها به وقتِ تهران است (برای «فعال»شدنِ فوری،
+ زمانِ شروع را کمی قبل از الان بگذارید). جوایز را با کاما جدا کنید
+ (نفرِ اول، دوم، …)، مثلاً 5000,3000,1000.
+
+
+
+ | # | عنوان | ورودی | جوایز | وضعیت | بازیکنان | شروع (تهران) | پایان (تهران) | |
+ {{range .Tournaments}}
+
+ | {{.ID}} |
+ {{.Title}} |
+ {{.EntryFee}} |
+ {{range .Prizes}}{{.}} {{end}} |
+ {{if eq .Status "active"}}فعال{{else if eq .Status "upcoming"}}بهزودی{{else}}پایانیافته{{end}} |
+ {{.Players}} |
+ {{.StartsLocal}} |
+ {{.EndsLocal}} |
+
+
+ |
+
+ {{end}}
+
+
+
افزودنِ تورنومنت
+
+
+
+
diff --git a/internal/economy/handler.go b/internal/economy/handler.go
index 287c228..8e0f8f2 100644
--- a/internal/economy/handler.go
+++ b/internal/economy/handler.go
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
+ "strconv"
"hakemsho/internal/auth"
"hakemsho/internal/httpx"
@@ -197,6 +198,70 @@ func (h *Handler) BuyChatPack(w http.ResponseWriter, r *http.Request) {
}
}
+// Tournaments — GET /api/tournaments (فهرست با وضعیتِ عضویتِ کاربر)
+func (h *Handler) Tournaments(w http.ResponseWriter, r *http.Request) {
+ id, ok := uid(r)
+ if !ok {
+ httpx.Error(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ list, err := h.svc.ListTournaments(r.Context(), id)
+ if err != nil {
+ httpx.Error(w, http.StatusInternalServerError, "server error")
+ return
+ }
+ httpx.JSON(w, http.StatusOK, map[string]any{"tournaments": list})
+}
+
+// TournamentStandings — GET /api/tournaments/standings?id=X (ردهبندی)
+func (h *Handler) TournamentStandings(w http.ResponseWriter, r *http.Request) {
+ if _, ok := uid(r); !ok {
+ httpx.Error(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ tid, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
+ if tid == 0 {
+ httpx.Error(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ rows, err := h.svc.TournamentStandings(r.Context(), tid, 50)
+ if err != nil {
+ httpx.Error(w, http.StatusInternalServerError, "server error")
+ return
+ }
+ httpx.JSON(w, http.StatusOK, map[string]any{"standings": rows})
+}
+
+// JoinTournament — POST /api/tournaments/join {id}
+func (h *Handler) JoinTournament(w http.ResponseWriter, r *http.Request) {
+ id, ok := uid(r)
+ if !ok {
+ httpx.Error(w, http.StatusUnauthorized, "unauthorized")
+ return
+ }
+ var req struct {
+ ID int64 `json:"id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ httpx.Error(w, http.StatusBadRequest, "invalid body")
+ return
+ }
+ switch err := h.svc.JoinTournament(r.Context(), id, req.ID); {
+ case err == nil:
+ httpx.JSON(w, http.StatusOK, map[string]string{"message": "ثبتنام انجام شد"})
+ case errors.Is(err, ErrTournamentNotFound):
+ httpx.Error(w, http.StatusNotFound, "تورنومنت یافت نشد")
+ case errors.Is(err, ErrTournamentClosed):
+ httpx.Error(w, http.StatusConflict, "مهلتِ ثبتنام تمام شده")
+ case errors.Is(err, ErrAlreadyJoined):
+ httpx.Error(w, http.StatusConflict, "قبلاً ثبتنام کردهاید")
+ case errors.Is(err, ErrInsufficient):
+ httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
+ default:
+ httpx.Error(w, http.StatusInternalServerError, "server error")
+ }
+}
+
// SelectCard — POST /api/shop/select-card
func (h *Handler) SelectCard(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
diff --git a/internal/economy/tournament.go b/internal/economy/tournament.go
new file mode 100644
index 0000000..083d52c
--- /dev/null
+++ b/internal/economy/tournament.go
@@ -0,0 +1,366 @@
+package economy
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "time"
+)
+
+// امتیازِ هر رویداد در تورنومنت.
+const (
+ tourPointsWin = 100 // بردِ یک بازی
+ tourPointsLoss = 25 // شرکت در یک بازی (باخت)
+)
+
+var (
+ ErrTournamentNotFound = errors.New("tournament not found")
+ ErrTournamentClosed = errors.New("tournament is closed")
+ ErrAlreadyJoined = errors.New("already joined")
+)
+
+// Tournament یک تورنومنتِ امتیازی.
+type Tournament struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ EntryFee int64 `json:"entry_fee"`
+ Prizes []int64 `json:"prizes"`
+ StartsAt time.Time `json:"-"`
+ EndsAt time.Time `json:"-"`
+ Settled bool `json:"-"`
+}
+
+// TournamentView نمای عمومیِ تورنومنت برای کلاینت (وضعیت، جایزهی کل، عضویتِ کاربر).
+type TournamentView struct {
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ EntryFee int64 `json:"entry_fee"`
+ Prizes []int64 `json:"prizes"`
+ PrizePool int64 `json:"prize_pool"` // مجموعِ جوایز
+ Status string `json:"status"` // upcoming | active | ended
+ StartsAt string `json:"starts_at"` // RFC3339 UTC (ماشینی)
+ EndsAt string `json:"ends_at"`
+ StartsLocal string `json:"starts_local"` // به وقتِ تهران (نمایشی)
+ EndsLocal string `json:"ends_local"`
+ Players int `json:"players"`
+ Joined bool `json:"joined"`
+ MyPoints int `json:"my_points"`
+ MyRank int `json:"my_rank"` // ۰ یعنی رتبهای ندارد
+}
+
+// TournamentStanding یک ردیفِ جدولِ ردهبندی.
+type TournamentStanding struct {
+ Rank int `json:"rank"`
+ UserID int64 `json:"user_id"`
+ Name string `json:"name"`
+ Avatar string `json:"avatar"`
+ Points int `json:"points"`
+ Wins int `json:"wins"`
+ Games int `json:"games"`
+ Prize int64 `json:"prize"`
+}
+
+// iranZone وقتِ ایران (UTC+3:30، بدونِ DST از ۲۰۲۲). ثابت است تا به tzdata
+// روی سرور وابسته نباشیم (باینریِ CGO-off).
+var iranZone = time.FixedZone("IRST", 12600)
+
+func tournamentStatus(now, starts, ends time.Time) string {
+ switch {
+ case now.Before(starts):
+ return "upcoming"
+ case now.After(ends):
+ return "ended"
+ default:
+ return "active"
+ }
+}
+
+func sumPrizes(p []int64) int64 {
+ var s int64
+ for _, x := range p {
+ s += x
+ }
+ return s
+}
+
+// scanTournament یک ردیفِ tournaments را میخواند.
+func scanTournament(sc interface{ Scan(...any) error }) (*Tournament, error) {
+ var t Tournament
+ var prizes, starts, ends string
+ var settled int
+ if err := sc.Scan(&t.ID, &t.Title, &t.Description, &t.EntryFee, &prizes, &starts, &ends, &settled); err != nil {
+ return nil, err
+ }
+ _ = json.Unmarshal([]byte(prizes), &t.Prizes)
+ t.StartsAt = parseTS(starts)
+ t.EndsAt = parseTS(ends)
+ t.Settled = settled != 0
+ return &t, nil
+}
+
+const tournamentCols = `id, title, description, entry_fee, prizes, starts_at, ends_at, settled`
+
+// ListTournaments تورنومنتها را (بههمراه وضعیتِ عضویتِ کاربر) برمیگرداند.
+// پیش از خواندن، تورنومنتهای پایانیافتهی تسویهنشده را تسویه میکند.
+//
+// نکتهی مهم: چون pool فقط یک اتصال دارد (SetMaxOpenConns(1))، نباید حین باز بودنِ
+// یک rows کوئریِ دیگری زد (self-deadlock). پس ابتدا همهی ردیفها را میخوانیم و
+// rows را میبندیم، سپس زیرکوئریهای هر تورنومنت را اجرا میکنیم.
+func (s *Service) ListTournaments(ctx context.Context, userID int64) ([]TournamentView, error) {
+ s.SettleTournaments(ctx)
+
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT `+tournamentCols+` FROM tournaments ORDER BY (ends_at < strftime('%Y-%m-%d %H:%M:%S','now')), starts_at`)
+ if err != nil {
+ return nil, err
+ }
+ var ts []*Tournament
+ for rows.Next() {
+ t, err := scanTournament(rows)
+ if err != nil {
+ rows.Close()
+ return nil, err
+ }
+ ts = append(ts, t)
+ }
+ rows.Close()
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ now := nowUTC()
+ out := make([]TournamentView, 0, len(ts))
+ for _, t := range ts {
+ v := TournamentView{
+ ID: t.ID, Title: t.Title, Description: t.Description,
+ EntryFee: t.EntryFee, Prizes: t.Prizes, PrizePool: sumPrizes(t.Prizes),
+ Status: tournamentStatus(now, t.StartsAt, t.EndsAt),
+ StartsAt: t.StartsAt.Format(time.RFC3339),
+ EndsAt: t.EndsAt.Format(time.RFC3339),
+ StartsLocal: t.StartsAt.In(iranZone).Format("2006-01-02 15:04"),
+ EndsLocal: t.EndsAt.In(iranZone).Format("2006-01-02 15:04"),
+ }
+ _ = s.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM tournament_entries WHERE tournament_id = ?`, t.ID).Scan(&v.Players)
+ if userID != 0 {
+ var pts sql.NullInt64
+ if err := s.db.QueryRowContext(ctx,
+ `SELECT points FROM tournament_entries WHERE tournament_id = ? AND user_id = ?`,
+ t.ID, userID).Scan(&pts); err == nil {
+ v.Joined = true
+ v.MyPoints = int(pts.Int64)
+ _ = s.db.QueryRowContext(ctx,
+ `SELECT COUNT(*)+1 FROM tournament_entries WHERE tournament_id = ? AND points > ?`,
+ t.ID, pts.Int64).Scan(&v.MyRank)
+ }
+ }
+ out = append(out, v)
+ }
+ return out, nil
+}
+
+// TournamentStandings ردهبندیِ یک تورنومنت (تا limit نفرِ برتر).
+func (s *Service) TournamentStandings(ctx context.Context, id int64, limit int) ([]TournamentStanding, error) {
+ if limit <= 0 {
+ limit = 50
+ }
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT e.user_id, COALESCE(u.first_name, u.mobile), COALESCE(u.avatar,''),
+ e.points, e.wins, e.games, e.prize
+ FROM tournament_entries e JOIN users u ON u.id = e.user_id
+ WHERE e.tournament_id = ?
+ ORDER BY e.points DESC, e.wins DESC, e.joined_at ASC
+ LIMIT ?`, id, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []TournamentStanding
+ rank := 0
+ for rows.Next() {
+ rank++
+ var st TournamentStanding
+ var name sql.NullString
+ if err := rows.Scan(&st.UserID, &name, &st.Avatar, &st.Points, &st.Wins, &st.Games, &st.Prize); err != nil {
+ return nil, err
+ }
+ st.Rank = rank
+ st.Name = name.String
+ out = append(out, st)
+ }
+ return out, rows.Err()
+}
+
+// JoinTournament کاربر را ثبتنام کرده و ورودی را از سکه کسر میکند.
+func (s *Service) JoinTournament(ctx context.Context, userID, id int64) error {
+ row := s.db.QueryRowContext(ctx, `SELECT `+tournamentCols+` FROM tournaments WHERE id = ?`, id)
+ t, err := scanTournament(row)
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrTournamentNotFound
+ }
+ if err != nil {
+ return err
+ }
+ // ثبتنام تا پیش از پایانِ تورنومنت مجاز است.
+ if nowUTC().After(t.EndsAt) || t.Settled {
+ return ErrTournamentClosed
+ }
+ // آیا قبلاً عضو شده؟
+ var exists int
+ _ = s.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM tournament_entries WHERE tournament_id = ? AND user_id = ?`,
+ id, userID).Scan(&exists)
+ if exists > 0 {
+ return ErrAlreadyJoined
+ }
+
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ if t.EntryFee > 0 {
+ if err := adjustTx(ctx, tx, userID, CurrencyCoin, -t.EntryFee, "tournament_join", tournamentRef(id)); err != nil {
+ return err // ErrInsufficient در صورتِ کمبود
+ }
+ }
+ if _, err := tx.ExecContext(ctx,
+ `INSERT INTO tournament_entries (tournament_id, user_id) VALUES (?, ?)`, id, userID); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// AddTournamentPoints امتیازِ کاربر را در همهی تورنومنتهای فعالی که در آنها عضو
+// است افزایش میدهد (هنگامِ پایانِ هر بازی). fire-and-forget؛ خطاها لاگ نمیشوند.
+func (s *Service) AddTournamentPoints(ctx context.Context, userID int64, won bool) {
+ pts := tourPointsLoss
+ winInc := 0
+ if won {
+ pts = tourPointsWin
+ winInc = 1
+ }
+ now := nowUTC().Format(tsLayout)
+ _, _ = s.db.ExecContext(ctx,
+ `UPDATE tournament_entries
+ SET points = points + ?, games = games + 1, wins = wins + ?
+ WHERE user_id = ?
+ AND tournament_id IN (
+ SELECT id FROM tournaments
+ WHERE settled = 0 AND starts_at <= ? AND ends_at >= ?
+ )`,
+ pts, winInc, userID, now, now)
+}
+
+// SettleTournaments تورنومنتهای پایانیافتهی تسویهنشده را تسویه و جوایز را پرداخت میکند.
+func (s *Service) SettleTournaments(ctx context.Context) {
+ now := nowUTC().Format(tsLayout)
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, prizes FROM tournaments WHERE settled = 0 AND ends_at < ?`, now)
+ if err != nil {
+ return
+ }
+ var ids []int64
+ prizeMap := map[int64][]int64{}
+ for rows.Next() {
+ var id int64
+ var prizes string
+ if err := rows.Scan(&id, &prizes); err != nil {
+ continue
+ }
+ var p []int64
+ _ = json.Unmarshal([]byte(prizes), &p)
+ ids = append(ids, id)
+ prizeMap[id] = p
+ }
+ rows.Close()
+
+ for _, id := range ids {
+ s.settleOne(ctx, id, prizeMap[id])
+ }
+}
+
+// settleOne یک تورنومنت را در یک تراکنش تسویه میکند.
+func (s *Service) settleOne(ctx context.Context, id int64, prizes []int64) {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return
+ }
+ defer tx.Rollback()
+
+ if len(prizes) > 0 {
+ rows, err := tx.QueryContext(ctx,
+ `SELECT user_id FROM tournament_entries WHERE tournament_id = ?
+ ORDER BY points DESC, wins DESC, joined_at ASC LIMIT ?`, id, len(prizes))
+ if err != nil {
+ return
+ }
+ var winners []int64
+ for rows.Next() {
+ var uid int64
+ if err := rows.Scan(&uid); err != nil {
+ rows.Close()
+ return
+ }
+ winners = append(winners, uid)
+ }
+ rows.Close()
+
+ for i, uid := range winners {
+ amt := prizes[i]
+ if amt <= 0 {
+ continue
+ }
+ if err := adjustTx(ctx, tx, uid, CurrencyCoin, amt, "tournament_prize", tournamentRef(id)); err != nil {
+ return
+ }
+ if _, err := tx.ExecContext(ctx,
+ `UPDATE tournament_entries SET prize = ? WHERE tournament_id = ? AND user_id = ?`,
+ amt, id, uid); err != nil {
+ return
+ }
+ }
+ }
+ if _, err := tx.ExecContext(ctx, `UPDATE tournaments SET settled = 1 WHERE id = ?`, id); err != nil {
+ return
+ }
+ _ = tx.Commit()
+}
+
+func tournamentRef(id int64) string {
+ return "tournament:" + strconv.FormatInt(id, 10)
+}
+
+// --- ادمین: CRUD تورنومنت ---
+
+// CreateTournament یک تورنومنت جدید میسازد و شناسهاش را برمیگرداند.
+func (s *Service) CreateTournament(ctx context.Context, t Tournament) (int64, error) {
+ prizes, _ := json.Marshal(t.Prizes)
+ res, err := s.db.ExecContext(ctx,
+ `INSERT INTO tournaments (title, description, entry_fee, prizes, starts_at, ends_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ t.Title, t.Description, t.EntryFee, string(prizes),
+ t.StartsAt.UTC().Format(tsLayout), t.EndsAt.UTC().Format(tsLayout))
+ if err != nil {
+ return 0, err
+ }
+ return res.LastInsertId()
+}
+
+// DeleteTournament یک تورنومنت و ثبتنامهایش را حذف میکند.
+func (s *Service) DeleteTournament(ctx context.Context, id int64) error {
+ if _, err := s.db.ExecContext(ctx, `DELETE FROM tournament_entries WHERE tournament_id = ?`, id); err != nil {
+ return err
+ }
+ _, err := s.db.ExecContext(ctx, `DELETE FROM tournaments WHERE id = ?`, id)
+ return err
+}
+
+// AdminListTournaments همهی تورنومنتها را برای پنلِ ادمین برمیگرداند.
+func (s *Service) AdminListTournaments(ctx context.Context) ([]TournamentView, error) {
+ return s.ListTournaments(ctx, 0)
+}
diff --git a/internal/economy/tournament_test.go b/internal/economy/tournament_test.go
new file mode 100644
index 0000000..f6f490d
--- /dev/null
+++ b/internal/economy/tournament_test.go
@@ -0,0 +1,140 @@
+package economy
+
+import (
+ "context"
+ "testing"
+ "time"
+)
+
+// makeUser یک کاربرِ آزمایشی با شمارهی دلخواه میسازد.
+func makeUser(t *testing.T, s *Service, mobile string) int64 {
+ t.Helper()
+ res, err := s.db.Exec(`INSERT INTO users (mobile) VALUES (?)`, mobile)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, _ := res.LastInsertId()
+ return id
+}
+
+func TestTournamentJoinPointsSettle(t *testing.T) {
+ ctx := context.Background()
+ svc, u1 := setup(t)
+ u2 := makeUser(t, svc, "09120000002")
+ setCoins(t, svc, u1, 1000)
+ setCoins(t, svc, u2, 1000)
+
+ // تورنومنتی که همین حالا فعال است.
+ id, err := svc.CreateTournament(ctx, Tournament{
+ Title: "شبانه",
+ EntryFee: 200,
+ Prizes: []int64{500, 200}, // رتبه۱=۵۰۰، رتبه۲=۲۰۰
+ StartsAt: nowUTC().Add(-time.Minute),
+ EndsAt: nowUTC().Add(time.Hour),
+ })
+ if err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ // هر دو ثبتنام میکنند ⇒ ۲۰۰ سکه کسر میشود.
+ if err := svc.JoinTournament(ctx, u1, id); err != nil {
+ t.Fatalf("join u1: %v", err)
+ }
+ if err := svc.JoinTournament(ctx, u2, id); err != nil {
+ t.Fatalf("join u2: %v", err)
+ }
+ if c := coinsOf(t, svc, u1); c != 800 {
+ t.Errorf("entry fee not charged: coins=%d", c)
+ }
+ // ثبتنامِ دوباره باید خطا دهد.
+ if err := svc.JoinTournament(ctx, u1, id); err != ErrAlreadyJoined {
+ t.Errorf("expected ErrAlreadyJoined, got %v", err)
+ }
+
+ // u1 دو برد، u2 یک برد ⇒ u1 رتبهی اول.
+ svc.AddTournamentPoints(ctx, u1, true)
+ svc.AddTournamentPoints(ctx, u1, true)
+ svc.AddTournamentPoints(ctx, u2, true)
+ svc.AddTournamentPoints(ctx, u2, false)
+
+ st, err := svc.TournamentStandings(ctx, id, 10)
+ if err != nil {
+ t.Fatalf("standings: %v", err)
+ }
+ if len(st) != 2 || st[0].UserID != u1 || st[0].Points != 200 {
+ t.Fatalf("bad standings: %+v", st)
+ }
+ if st[1].UserID != u2 || st[1].Points != 125 {
+ t.Errorf("u2 points wrong: %+v", st[1])
+ }
+
+ // پایانِ تورنومنت را به گذشته میبریم؛ تسویه جوایز را پرداخت میکند.
+ if _, err := svc.db.Exec(`UPDATE tournaments SET ends_at = ? WHERE id = ?`,
+ nowUTC().Add(-time.Minute).Format(tsLayout), id); err != nil {
+ t.Fatal(err)
+ }
+ svc.SettleTournaments(ctx)
+
+ if c := coinsOf(t, svc, u1); c != 800+500 {
+ t.Errorf("u1 prize not paid: coins=%d (want %d)", c, 800+500)
+ }
+ if c := coinsOf(t, svc, u2); c != 800+200 {
+ t.Errorf("u2 prize not paid: coins=%d (want %d)", c, 800+200)
+ }
+
+ // تسویهی دوباره نباید دوباره پرداخت کند.
+ svc.SettleTournaments(ctx)
+ if c := coinsOf(t, svc, u1); c != 1300 {
+ t.Errorf("double settle paid again: coins=%d", c)
+ }
+}
+
+func TestListTournamentsWithRows(t *testing.T) {
+ // رگرسیون: با MaxOpenConns(1)، ListTournaments نباید حین باز بودنِ rows
+ // زیرکوئری بزند (self-deadlock). این تست با تورنومنتِ واقعی آن مسیر را میپیماید.
+ ctx := context.Background()
+ svc, u1 := setup(t)
+ setCoins(t, svc, u1, 1000)
+ id, err := svc.CreateTournament(ctx, Tournament{
+ Title: "لیست", EntryFee: 100, Prizes: []int64{500},
+ StartsAt: nowUTC().Add(-time.Minute), EndsAt: nowUTC().Add(time.Hour),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := svc.JoinTournament(ctx, u1, id); err != nil {
+ t.Fatal(err)
+ }
+ svc.AddTournamentPoints(ctx, u1, true)
+
+ done := make(chan []TournamentView, 1)
+ go func() {
+ v, e := svc.ListTournaments(ctx, u1)
+ if e != nil {
+ t.Errorf("list: %v", e)
+ }
+ done <- v
+ }()
+ select {
+ case v := <-done:
+ if len(v) != 1 || !v[0].Joined || v[0].MyPoints != 100 ||
+ v[0].Players != 1 || v[0].MyRank != 1 {
+ t.Errorf("bad view: %+v", v)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("ListTournaments deadlocked (nested query on single conn)")
+ }
+}
+
+func TestTournamentJoinInsufficient(t *testing.T) {
+ ctx := context.Background()
+ svc, u1 := setup(t)
+ setCoins(t, svc, u1, 50)
+ id, _ := svc.CreateTournament(ctx, Tournament{
+ Title: "گران", EntryFee: 500, Prizes: []int64{100},
+ StartsAt: nowUTC().Add(-time.Minute), EndsAt: nowUTC().Add(time.Hour),
+ })
+ if err := svc.JoinTournament(ctx, u1, id); err != ErrInsufficient {
+ t.Errorf("expected ErrInsufficient, got %v", err)
+ }
+}
diff --git a/internal/store/migrations/011_tournaments.sql b/internal/store/migrations/011_tournaments.sql
new file mode 100644
index 0000000..877f230
--- /dev/null
+++ b/internal/store/migrations/011_tournaments.sql
@@ -0,0 +1,29 @@
+-- تورنومنتِ امتیازی: بازیکنان با پرداختِ ورودی ثبتنام میکنند و در بازهی
+-- تورنومنت با بردنِ بازیها امتیاز میگیرند؛ در پایان، نفراتِ برتر جایزه میگیرند.
+
+CREATE TABLE IF NOT EXISTS tournaments (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ entry_fee INTEGER NOT NULL DEFAULT 0, -- ورودی به سکه
+ prizes TEXT NOT NULL DEFAULT '[]', -- JSON: سکهی جایزه بهترتیبِ رتبه [رتبه۱, رتبه۲, ...]
+ starts_at TEXT NOT NULL, -- زمانِ شروع (UTC)
+ ends_at TEXT NOT NULL, -- زمانِ پایان (UTC)
+ settled INTEGER NOT NULL DEFAULT 0, -- آیا جوایز پرداخت شده
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now'))
+);
+
+CREATE TABLE IF NOT EXISTS tournament_entries (
+ tournament_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ points INTEGER NOT NULL DEFAULT 0,
+ games INTEGER NOT NULL DEFAULT 0,
+ wins INTEGER NOT NULL DEFAULT 0,
+ prize INTEGER NOT NULL DEFAULT 0, -- سکهی جایزهی دریافتی (پس از تسویه)
+ joined_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now')),
+ PRIMARY KEY (tournament_id, user_id),
+ FOREIGN KEY (tournament_id) REFERENCES tournaments(id) ON DELETE CASCADE
+);
+
+CREATE INDEX IF NOT EXISTS idx_tournament_entries_user ON tournament_entries(user_id);
+CREATE INDEX IF NOT EXISTS idx_tournaments_window ON tournaments(ends_at, settled);
diff --git a/internal/store/migrations/012_rank_from_public.sql b/internal/store/migrations/012_rank_from_public.sql
new file mode 100644
index 0000000..cf9c782
--- /dev/null
+++ b/internal/store/migrations/012_rank_from_public.sql
@@ -0,0 +1,11 @@
+-- رتبه از بازیِ عمومی: بهجای یک میزِ «رتبهبندی»ِ جدا (که کاربر را گیج میکرد)،
+-- هر میزِ عمومی به برنده/بازنده امتیازِ رتبه میدهد (متناسب با سطحِ میز). میزِ
+-- تورنومنت هم عمومی است و همین امتیاز را میدهد. میزِ اختصاصیِ «رتبهبندی» حذف میشود.
+
+UPDATE table_tiers SET rank_reward = 10 WHERE id = 'beginner';
+UPDATE table_tiers SET rank_reward = 20 WHERE id = 'amateur';
+UPDATE table_tiers SET rank_reward = 25 WHERE id = 'pro';
+UPDATE table_tiers SET rank_reward = 40 WHERE id = 'master';
+
+-- حذفِ میزِ رتبهبندیِ اختصاصی از انتخابِ میز (سیستمِ رتبه و جدولِ قهرمانان میماند).
+UPDATE table_tiers SET enabled = 0 WHERE id = 'ranked';