feat: add tournoment
This commit is contained in:
@@ -172,9 +172,23 @@ func main() {
|
|||||||
r.Post("/shop/purchase", ecoH.Purchase)
|
r.Post("/shop/purchase", ecoH.Purchase)
|
||||||
r.Post("/rewards/daily", ecoH.Daily)
|
r.Post("/rewards/daily", ecoH.Daily)
|
||||||
r.Post("/rewards/ad", ecoH.AdReward)
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.Addr,
|
Addr: cfg.Addr,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
|
|||||||
@@ -68,9 +68,11 @@ func (g gameSettler) RecordCut(userID int64) {
|
|||||||
g.eco.RecordCut(context.Background(), userID)
|
g.eco.RecordCut(context.Background(), userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordGameResult آمار پایان بازی (برد/باخت) را ثبت میکند.
|
// RecordGameResult آمار پایان بازی (برد/باخت) را ثبت میکند و امتیازِ تورنومنت
|
||||||
|
// را (برای تورنومنتهای فعالی که کاربر عضوشان است) بهروزرسانی میکند.
|
||||||
func (g gameSettler) RecordGameResult(userID int64, won bool) {
|
func (g gameSettler) RecordGameResult(userID int64, won bool) {
|
||||||
g.eco.RecordGameResult(context.Background(), userID, won)
|
g.eco.RecordGameResult(context.Background(), userID, won)
|
||||||
|
g.eco.AddTournamentPoints(context.Background(), userID, won)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlayerProfile سکه، نشانِ رتبه و آواتارِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
// PlayerProfile سکه، نشانِ رتبه و آواتارِ بازیکن را برای نمایش سرِ میز برمیگرداند.
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ func (h *Handler) Routes(user, pass string) http.Handler {
|
|||||||
r.Post("/chat/message/add", h.chatMessageAdd)
|
r.Post("/chat/message/add", h.chatMessageAdd)
|
||||||
r.Post("/chat/message", h.chatMessageUpdate)
|
r.Post("/chat/message", h.chatMessageUpdate)
|
||||||
r.Post("/chat/message/delete", h.chatMessageDelete)
|
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
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,3 +684,63 @@ func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[
|
|||||||
}
|
}
|
||||||
return out
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<a href="/admin/shop" class="{{if eq .Nav "shop"}}active{{end}}">فروشگاه</a>
|
<a href="/admin/shop" class="{{if eq .Nav "shop"}}active{{end}}">فروشگاه</a>
|
||||||
<a href="/admin/carpets" class="{{if eq .Nav "carpets"}}active{{end}}">فرشها</a>
|
<a href="/admin/carpets" class="{{if eq .Nav "carpets"}}active{{end}}">فرشها</a>
|
||||||
<a href="/admin/chat" class="{{if eq .Nav "chat"}}active{{end}}">چت</a>
|
<a href="/admin/chat" class="{{if eq .Nav "chat"}}active{{end}}">چت</a>
|
||||||
|
<a href="/admin/tournaments" class="{{if eq .Nav "tournaments"}}active{{end}}">تورنومنت</a>
|
||||||
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
|
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
|
||||||
<a href="/admin/telescope">تلسکوپ</a>
|
<a href="/admin/telescope">تلسکوپ</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html dir="rtl" lang="fa">
|
||||||
|
{{template "head" .}}
|
||||||
|
<body>
|
||||||
|
{{template "nav" .}}
|
||||||
|
<div class="wrap">
|
||||||
|
<h1>تورنومنتها</h1>
|
||||||
|
{{if .Saved}}<div class="saved">انجام شد ✓</div>{{end}}
|
||||||
|
|
||||||
|
<p style="color:#9aa;font-size:13px">
|
||||||
|
تورنومنتِ امتیازی: بازیکنان با پرداختِ ورودی ثبتنام میکنند و در بازهی زمانی با
|
||||||
|
بردنِ بازی امتیاز میگیرند (برد ۱۰۰، شرکت ۲۵). در پایان، نفراتِ برتر بهترتیبِ رتبه
|
||||||
|
جایزهی سکه میگیرند. زمانها به وقتِ <b>تهران</b> است (برای «فعال»شدنِ فوری،
|
||||||
|
زمانِ شروع را کمی قبل از الان بگذارید). جوایز را با کاما جدا کنید
|
||||||
|
(نفرِ اول، دوم، …)، مثلاً <code>5000,3000,1000</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr><th>#</th><th>عنوان</th><th>ورودی</th><th>جوایز</th><th>وضعیت</th><th>بازیکنان</th><th>شروع (تهران)</th><th>پایان (تهران)</th><th></th></tr>
|
||||||
|
{{range .Tournaments}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>{{.Title}}</td>
|
||||||
|
<td>{{.EntryFee}}</td>
|
||||||
|
<td>{{range .Prizes}}{{.}} {{end}}</td>
|
||||||
|
<td>{{if eq .Status "active"}}<span style="color:#4ade80">فعال</span>{{else if eq .Status "upcoming"}}<span style="color:#fbbf24">بهزودی</span>{{else}}<span style="color:#888">پایانیافته</span>{{end}}</td>
|
||||||
|
<td>{{.Players}}</td>
|
||||||
|
<td style="direction:ltr;font-size:12px">{{.StartsLocal}}</td>
|
||||||
|
<td style="direction:ltr;font-size:12px">{{.EndsLocal}}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action="/admin/tournaments/delete" onsubmit="return confirm('حذفِ تورنومنت؟')">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
|
<button class="del">حذف</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 style="margin-top:24px">افزودنِ تورنومنت</h2>
|
||||||
|
<form method="post" action="/admin/tournaments/add" style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
|
||||||
|
<label>عنوان<input name="title" required></label>
|
||||||
|
<label>ورودی (سکه)<input name="entry_fee" value="0"></label>
|
||||||
|
<label style="grid-column:1/3">توضیح<input name="description" placeholder="مثلاً: تورنومنتِ شبانه"></label>
|
||||||
|
<label style="grid-column:1/3">جوایز (سکه، با کاما)<input name="prizes" placeholder="5000,3000,1000"></label>
|
||||||
|
<label>شروع (تهران)<input type="datetime-local" name="starts_at" required></label>
|
||||||
|
<label>پایان (تهران)<input type="datetime-local" name="ends_at" required></label>
|
||||||
|
<button style="grid-column:1/3">ساختِ تورنومنت</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"hakemsho/internal/auth"
|
"hakemsho/internal/auth"
|
||||||
"hakemsho/internal/httpx"
|
"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
|
// SelectCard — POST /api/shop/select-card
|
||||||
func (h *Handler) SelectCard(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) SelectCard(w http.ResponseWriter, r *http.Request) {
|
||||||
id, ok := uid(r)
|
id, ok := uid(r)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user