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}} + + + + + + + + + + + + {{end}} +
#عنوانورودیجوایزوضعیتبازیکنانشروع (تهران)پایان (تهران)
{{.ID}}{{.Title}}{{.EntryFee}}{{range .Prizes}}{{.}} {{end}}{{if eq .Status "active"}}فعال{{else if eq .Status "upcoming"}}به‌زودی{{else}}پایان‌یافته{{end}}{{.Players}}{{.StartsLocal}}{{.EndsLocal}} +
+ + +
+
+ +

افزودنِ تورنومنت

+
+ + + + + + + +
+
+ + 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';