Compare commits

..
11 Commits
Author SHA1 Message Date
Amirmahdi ba660f156c feat: add table for purchase 2026-08-07 08:33:09 +03:30
Amirmahdi 3883d1b471 feat: WAL concurrency change is now running 2026-07-23 13:10:19 +03:30
Amirmahdi 64bdf5fc3d fix: delay 2026-07-11 22:43:59 +03:30
Amirmahdi ceb1c68174 feat: slected after buy 2026-07-10 01:27:24 +03:30
Amirmahdi fe0b63083c feat: add characters 2026-07-10 00:56:03 +03:30
Amirmahdi da6df0328f fix: add frame 2026-07-08 13:47:50 +03:30
Amirmahdi b5420bac21 feat: add frame 2026-07-08 12:34:21 +03:30
Amirmahdi 525cd554ef feat: smarter bot 2026-07-07 23:23:12 +03:30
Amirmahdi e7b2e05428 feat: add streak 2026-07-07 21:10:01 +03:30
Amirmahdi e8396db45c feat: add admin routes 2026-07-07 17:21:08 +03:30
Amirmahdi e58c50d6e5 feat: add tournoment 2026-07-07 15:17:33 +03:30
41 changed files with 2999 additions and 110 deletions
+192
View File
@@ -0,0 +1,192 @@
// Command loadtest یک استرس‌تستِ واقعیِ WebSocket برای سرورِ حکم است: N بازیکنِ
// مصنوعی را می‌سازد، هرکدام join_queue می‌کند و بازیِ کاملِ حکم را به‌صورتِ خودکار
// (انتخابِ حکم + بازیِ کارتِ قانونی) بازی می‌کند تا بارِ واقعیِ پخشِ وضعیت تولید شود.
//
// توکن‌ها با همان JWT_SECRET سرور ساخته می‌شوند (بدونِ نیاز به OTP/SMS). کاربرها
// باید در DB وجود داشته باشند و برای tierِ انتخابی سکه‌ی کافی داشته باشند.
//
// مثال (روی نمونه‌ی محلی/استیجینگ — نه روی پروداکشنِ واقعی):
//
// go run ./cmd/loadtest -url ws://localhost:8080 -n 200 -tier beginner \
// -secret "$JWT_SECRET" -startid 1000 -ramp 50ms
//
// خروجی هر ثانیه: اتصال‌های فعال، پیام‌های وضعیت/ثانیه، حرکت‌ها/ثانیه، خطاها.
package main
import (
"flag"
"fmt"
"log"
"math/rand"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/gorilla/websocket"
)
var (
connected int64
stateMsgs int64
moves int64
errs int64
)
type state struct {
Type string `json:"type"`
Phase string `json:"phase"`
YourSeat int `json:"your_seat"`
Hakem int `json:"hakem"`
Turn int `json:"turn"`
TrickDone bool `json:"trick_done"`
YourHand []string `json:"your_hand"`
LeadSuit string `json:"lead_suit"`
}
var suitWord = map[byte]string{'H': "hearts", 'S': "spades", 'D': "diamonds", 'C': "clubs"}
var wordSuit = map[string]byte{"hearts": 'H', "spades": 'S', "diamonds": 'D', "clubs": 'C'}
func mintToken(secret string, uid int64, ttl time.Duration) string {
claims := jwt.MapClaims{
"sub": strconv.FormatInt(uid, 10),
"abl": "play",
"exp": time.Now().Add(ttl).Unix(),
"iat": time.Now().Unix(),
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
s, _ := t.SignedString([]byte(secret))
return s
}
// pickCard یک کارتِ قانونی برمی‌گرداند (پیرویِ خال در صورتِ داشتن).
func pickCard(hand []string, lead string) string {
if lead != "" {
want := wordSuit[lead]
var same []string
for _, c := range hand {
if c[len(c)-1] == want {
same = append(same, c)
}
}
if len(same) > 0 {
return same[rand.Intn(len(same))]
}
}
return hand[rand.Intn(len(hand))]
}
func runClient(wsURL, secret, tier string, uid int64, playDelay time.Duration, wg *sync.WaitGroup) {
defer wg.Done()
tok := mintToken(secret, uid, time.Hour)
u := wsURL + "/ws?token=" + tok
c, _, err := websocket.DefaultDialer.Dial(u, http.Header{})
if err != nil {
atomic.AddInt64(&errs, 1)
return
}
atomic.AddInt64(&connected, 1)
defer func() { atomic.AddInt64(&connected, -1); c.Close() }()
_ = c.WriteJSON(map[string]any{"type": "join_queue", "tier": tier})
for {
var raw map[string]any
if err := c.ReadJSON(&raw); err != nil {
atomic.AddInt64(&errs, 1)
return
}
if raw["type"] != "state" {
continue
}
atomic.AddInt64(&stateMsgs, 1)
// دوباره به‌صورتِ typed decode کن.
var s state
if b, ok := raw["your_hand"].([]any); ok {
for _, x := range b {
s.YourHand = append(s.YourHand, x.(string))
}
}
s.Phase, _ = raw["phase"].(string)
s.LeadSuit, _ = raw["lead_suit"].(string)
s.YourSeat = intOf(raw["your_seat"])
s.Hakem = intOf(raw["hakem"])
s.Turn = intOf(raw["turn"])
s.TrickDone, _ = raw["trick_done"].(bool)
if s.Turn != s.YourSeat || s.TrickDone {
continue
}
time.Sleep(playDelay) // شبیه‌سازیِ زمانِ فکرِ انسان
switch s.Phase {
case "choose_trump":
if s.Hakem == s.YourSeat {
_ = c.WriteJSON(map[string]any{"type": "choose_trump", "suit": bestSuit(s.YourHand)})
atomic.AddInt64(&moves, 1)
}
case "playing":
if len(s.YourHand) > 0 {
_ = c.WriteJSON(map[string]any{"type": "play_card", "card": pickCard(s.YourHand, s.LeadSuit)})
atomic.AddInt64(&moves, 1)
}
}
}
}
func bestSuit(hand []string) string {
cnt := map[byte]int{}
for _, c := range hand {
cnt[c[len(c)-1]]++
}
best, n := byte('S'), -1
for s, k := range cnt {
if k > n {
best, n = s, k
}
}
return suitWord[best]
}
func intOf(v any) int {
if f, ok := v.(float64); ok {
return int(f)
}
return 0
}
func main() {
url := flag.String("url", "ws://localhost:8080", "ws base url (no /ws)")
n := flag.Int("n", 100, "concurrent players")
tier := flag.String("tier", "beginner", "table tier id")
secret := flag.String("secret", "", "JWT_SECRET of the target server")
startID := flag.Int64("startid", 1000, "first synthetic user id (must exist in DB w/ coins)")
ramp := flag.Duration("ramp", 30*time.Millisecond, "delay between client starts")
play := flag.Duration("play", 300*time.Millisecond, "think time before each move")
flag.Parse()
if *secret == "" {
log.Fatal("-secret (JWT_SECRET) is required")
}
*url = strings.TrimRight(*url, "/")
var wg sync.WaitGroup
go func() {
for range time.Tick(time.Second) {
fmt.Printf("conns=%d state/s=%d moves/s=%d errs=%d\n",
atomic.LoadInt64(&connected),
atomic.SwapInt64(&stateMsgs, 0),
atomic.SwapInt64(&moves, 0),
atomic.LoadInt64(&errs))
}
}()
for i := 0; i < *n; i++ {
wg.Add(1)
go runClient(*url, *secret, *tier, *startID+int64(i), *play, &wg)
time.Sleep(*ramp)
}
fmt.Printf("launched %d clients; Ctrl-C to stop\n", *n)
wg.Wait()
}
+26 -1
View File
@@ -90,6 +90,9 @@ func main() {
slog.Info("IAP bazaar (RSA) configured") slog.Info("IAP bazaar (RSA) configured")
} }
eco := economy.New(st.DB, economy.DevAdVerifier{}, iap) eco := economy.New(st.DB, economy.DevAdVerifier{}, iap)
eco.SetPublicURL(cfg.PublicURL) // برای ساختِ URL کاملِ تصاویر (آواتار/فرش)
// آواتارهای «svgِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن (یک‌بار، idempotent).
admin.ConvertRasterAvatars(context.Background(), st.DB, cfg.AvatarsDir)
if err := eco.LoadCatalog(context.Background()); err != nil { if err := eco.LoadCatalog(context.Background()); err != nil {
slog.Error("load catalog", "err", err) slog.Error("load catalog", "err", err)
os.Exit(1) os.Exit(1)
@@ -134,9 +137,11 @@ func main() {
r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir)) r.Handle("/cards/*", static.CardsHandler(cfg.CardsDir))
// تصاویرِ فرش (به‌جای میز) — از پنل ادمین آپلود می‌شوند. // تصاویرِ فرش (به‌جای میز) — از پنل ادمین آپلود می‌شوند.
r.Handle("/carpets/*", static.CarpetsHandler(cfg.CarpetsDir)) r.Handle("/carpets/*", static.CarpetsHandler(cfg.CarpetsDir))
// تصاویرِ شخصیتِ آواتار — از پنل ادمین آپلود می‌شوند.
r.Handle("/avatars/*", static.AvatarsHandler(cfg.AvatarsDir))
// پنل ادمین (HTML سرور‌ساید، پشت Basic Auth) // پنل ادمین (HTML سرور‌ساید، پشت Basic Auth)
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass)) r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir, cfg.AvatarsDir).Routes(cfg.AdminUser, cfg.AdminPass))
// بازرسِ درخواست‌ها (Telescope-lite) — پشتِ همان Basic Auth ادمین. // بازرسِ درخواست‌ها (Telescope-lite) — پشتِ همان Basic Auth ادمین.
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
@@ -169,12 +174,32 @@ func main() {
r.Get("/carpets", ecoH.Carpets) r.Get("/carpets", ecoH.Carpets)
r.Post("/shop/buy-carpet", ecoH.BuyCarpet) r.Post("/shop/buy-carpet", ecoH.BuyCarpet)
r.Post("/shop/select-carpet", ecoH.SelectCarpet) r.Post("/shop/select-carpet", ecoH.SelectCarpet)
r.Get("/frames", ecoH.Frames)
r.Post("/shop/buy-frame", ecoH.BuyFrame)
r.Post("/shop/select-frame", ecoH.SelectFrame)
r.Get("/avatars", ecoH.Avatars)
r.Post("/shop/buy-avatar", ecoH.BuyAvatar)
r.Post("/shop/select-avatar", ecoH.SelectAvatar)
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,
+8 -5
View File
@@ -68,18 +68,21 @@ 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 سکه، نشانِ رتبه، آواتار، قاب و شخصیتِ آواتارِ بازیکن را برای نمایشِ
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string) { // سرِ میز برمی‌گرداند.
func (g gameSettler) PlayerProfile(userID int64) (int64, string, string, string, string) {
p, err := g.eco.GetProfile(context.Background(), userID) p, err := g.eco.GetProfile(context.Background(), userID)
if err != nil || p == nil { if err != nil || p == nil {
return 0, "", "" return 0, "", "", "", ""
} }
return p.Coins, p.RankTier, p.Avatar return p.Coins, p.RankTier, p.Avatar, p.SelectedFrame, p.SelectedAvatarImg
} }
// ChargeTicket یک بلیت برای بُرِ مجدد مصرف می‌کند (خطا اگر بلیت کافی نباشد). // ChargeTicket یک بلیت برای بُرِ مجدد مصرف می‌کند (خطا اگر بلیت کافی نباشد).
+1 -1
View File
@@ -3,7 +3,7 @@
# بدونِ رمز (کلیدِ SSH). اجرا: ./deploy.sh # بدونِ رمز (کلیدِ SSH). اجرا: ./deploy.sh
set -euo pipefail set -euo pipefail
HOST="${HAKEM_HOST:-ubuntu@95.38.179.159}" HOST="${HAKEM_HOST:-ubuntu@37.32.20.103}"
KEY="${HAKEM_KEY:-$HOME/.ssh/hakem_deploy}" KEY="${HAKEM_KEY:-$HOME/.ssh/hakem_deploy}"
REMOTE_DIR="/home/ubuntu/hakemsho" REMOTE_DIR="/home/ubuntu/hakemsho"
+129 -3
View File
@@ -33,11 +33,12 @@ type Handler struct {
tpl *template.Template tpl *template.Template
cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند) cardsDir string // پوشه‌ی دیسکیِ آپلودِ اسکین‌ها (همان مسیری که static از آن سرو می‌کند)
carpetsDir string // پوشه‌ی دیسکیِ آپلودِ تصاویرِ فرش carpetsDir string // پوشه‌ی دیسکیِ آپلودِ تصاویرِ فرش
avatarsDir string // پوشه‌ی دیسکیِ آپلودِ تصاویرِ شخصیتِ آواتار
} }
func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir string) *Handler { func New(db *sql.DB, eco *economy.Service, cardsDir, carpetsDir, avatarsDir string) *Handler {
tpl := template.Must(template.ParseFS(tplFS, "templates/*.html")) tpl := template.Must(template.ParseFS(tplFS, "templates/*.html"))
return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir} return &Handler{db: db, eco: eco, tpl: tpl, cardsDir: cardsDir, carpetsDir: carpetsDir, avatarsDir: avatarsDir}
} }
// Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند. // Routes زیرروترِ /admin را با Basic Auth برمی‌گرداند.
@@ -64,6 +65,11 @@ 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)
// APIِ JSON برای پنلِ Next.js (پشتِ همان Basic Auth).
r.Mount("/api", h.APIRoutes())
return r return r
} }
@@ -532,6 +538,61 @@ func (h *Handler) carpetUpload(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther) http.Redirect(w, r, "/admin/carpets?saved=1", http.StatusSeeOther)
} }
// avatarUpload یک تصویرِ png یا svg را برای یک شخصیتِ آواتار آپلود و با نامِ
// <id>.<ext> ذخیره می‌کند و img_ext را در دیتابیس به‌روزرسانی می‌کند. نوعِ فایل از
// پسوندِ نامِ آپلودشده تشخیص داده می‌شود (svg → svg، وگرنه png). png برای شفافیت.
func (h *Handler) avatarUpload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" || strings.ContainsAny(id, "/\\.") {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if h.avatarsDir == "" {
http.Error(w, "AVATARS_DIR تنظیم نشده است", http.StatusInternalServerError)
return
}
if err := r.ParseMultipartForm(16 << 20); err != nil {
http.Error(w, "bad upload", http.StatusBadRequest)
return
}
file, hdr, err := r.FormFile("image")
if err != nil {
http.Error(w, "image لازم است", http.StatusBadRequest)
return
}
defer file.Close()
buf, err := io.ReadAll(io.LimitReader(file, 16<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
ext := "png"
if strings.HasSuffix(strings.ToLower(hdr.Filename), ".svg") {
ext = "svg"
// «SVGِ رستری» (PNG داخلِ svg) را به PNG واقعی تبدیل کن تا اپ رندرش کند.
if raw, rext, ok := extractEmbeddedRaster(buf); ok {
buf, ext = raw, rext
}
}
if err := os.MkdirAll(h.avatarsDir, 0o755); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// نسخه‌های قبلی با پسوندِ دیگر را پاک کن تا چند نسخه نماند.
for _, e := range []string{"png", "jpg", "svg"} {
if e != ext {
_ = os.Remove(filepath.Join(h.avatarsDir, id+"."+e))
}
}
if err := os.WriteFile(filepath.Join(h.avatarsDir, id+"."+ext), buf, 0o644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
_, _ = h.db.ExecContext(r.Context(), `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}
// --- مدیریتِ بسته‌های چت و پیام‌ها --- // --- مدیریتِ بسته‌های چت و پیام‌ها ---
func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) { func (h *Handler) chatAdmin(w http.ResponseWriter, r *http.Request) {
@@ -658,7 +719,12 @@ func newUUID() string {
} }
func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any { func (h *Handler) rows(ctx context.Context, query string, cols ...string) []map[string]any {
rs, err := h.db.QueryContext(ctx, query) return h.rowsArgs(ctx, query, nil, cols...)
}
// rowsArgs مثلِ rows اما با پارامترهای کوئری.
func (h *Handler) rowsArgs(ctx context.Context, query string, args []any, cols ...string) []map[string]any {
rs, err := h.db.QueryContext(ctx, query, args...)
if err != nil { if err != nil {
return nil return nil
} }
@@ -681,3 +747,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)
}
+638
View File
@@ -0,0 +1,638 @@
package admin
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"hakemsho/internal/economy"
"hakemsho/internal/httpx"
)
// APIRoutes زیرروترِ JSON پنلِ ادمینِ Next.js را برمی‌گرداند (پشتِ همان Basic Auth).
// سمتِ کلاینت، هدرِ Authorization: Basic را ذخیره و در هر درخواست می‌فرستد.
func (h *Handler) APIRoutes() http.Handler {
r := chi.NewRouter()
r.Get("/me", h.apiMe)
r.Get("/dashboard", h.apiDashboard)
r.Get("/catalog", h.apiCatalog)
r.Post("/catalog/{kind}", h.apiCatalogUpsert)
r.Delete("/catalog/{kind}", h.apiCatalogDelete)
r.Post("/catalog/card/{id}/upload", h.uploadDeck) // multipart (اشتراکی با HTML)
r.Get("/users", h.apiUsers)
r.Post("/users/coins", h.apiUserCoins)
r.Post("/users/grant", h.apiUserGrant)
r.Get("/purchases", h.apiPurchases)
r.Get("/carpets", h.apiCarpets)
r.Post("/carpets", h.apiCarpetUpsert)
r.Delete("/carpets", h.apiCarpetDelete)
r.Post("/carpets/{id}/upload", h.carpetUpload) // multipart (اشتراکی)
r.Get("/frames", h.apiFrames)
r.Post("/frames", h.apiFrameUpsert)
r.Delete("/frames", h.apiFrameDelete)
r.Get("/avatars", h.apiAvatars)
r.Post("/avatars", h.apiAvatarUpsert)
r.Delete("/avatars", h.apiAvatarDelete)
r.Post("/avatars/{id}/upload", h.avatarUpload) // multipart (تصویرِ png)
r.Get("/rank-tiers", h.apiRankTiers)
r.Post("/rank-tiers", h.apiRankTierUpsert)
r.Get("/chat", h.apiChat)
r.Post("/chat/pack", h.apiChatPackUpsert)
r.Delete("/chat/pack", h.apiChatPackDelete)
r.Post("/chat/message", h.apiChatMessageAdd)
r.Delete("/chat/message", h.apiChatMessageDelete)
r.Get("/tournaments", h.apiTournaments)
r.Post("/tournaments", h.apiTournamentAdd)
r.Put("/tournaments", h.apiTournamentUpdate)
r.Delete("/tournaments", h.apiTournamentDelete)
r.Post("/season/reset", h.apiSeasonReset)
return r
}
func (h *Handler) apiMe(w http.ResponseWriter, _ *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
count := func(q string) int64 {
var n int64
_ = h.db.QueryRowContext(ctx, q).Scan(&n)
return n
}
stats := h.purchasesStats(ctx)
httpx.JSON(w, http.StatusOK, map[string]any{
"users": count(`SELECT COUNT(*) FROM users`),
"purchases": count(`SELECT COUNT(*) FROM purchases WHERE status='verified'`),
"games": count(`SELECT COUNT(*) FROM game_history`),
"coins": count(`SELECT COALESCE(SUM(coins),0) FROM users`),
"tournaments": count(`SELECT COUNT(*) FROM tournaments`),
"season": h.eco.Season(ctx),
"revenue": stats["total_revenue"],
"revenue_today": stats["today"],
"revenue_14d": stats["last_14_days"],
"recent": h.purchaseRows(ctx, ` WHERE p.status = 'verified'`, nil, 8, 0),
})
}
// --- کاتالوگِ فروشگاه ---
var catalogTable = map[string]string{
"coin": "coin_packages", "ticket": "ticket_packages", "card": "card_skins",
"booster": "boosters", "vip": "vip_packages", "tier": "table_tiers",
}
// catalogCols ستون‌های قابلِ ویرایشِ هر نوع (به‌جز sku که خودکار است).
var catalogCols = map[string][]string{
"coin": {"id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sort", "enabled"},
"ticket": {"id", "title", "tickets", "price_toman", "sort", "enabled"},
"card": {"id", "title", "price_coins", "sort", "enabled"},
"booster": {"id", "title", "multiplier", "hours", "price_toman", "sort", "enabled"},
"vip": {"id", "title", "months", "price_toman", "sort", "enabled"},
"tier": {"id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"},
}
var catalogHasSKU = map[string]bool{"coin": true, "ticket": true, "booster": true, "vip": true}
func (h *Handler) apiCatalog(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
httpx.JSON(w, http.StatusOK, map[string]any{
"coin": h.rows(ctx, `SELECT id,title,coins,vip_days,price_toman,bonus_pct,sku,sort,enabled FROM coin_packages ORDER BY sort`,
"id", "title", "coins", "vip_days", "price_toman", "bonus_pct", "sku", "sort", "enabled"),
"ticket": h.rows(ctx, `SELECT id,title,tickets,price_toman,sku,sort,enabled FROM ticket_packages ORDER BY sort`,
"id", "title", "tickets", "price_toman", "sku", "sort", "enabled"),
"card": h.rows(ctx, `SELECT id,title,price_coins,sort,enabled FROM card_skins ORDER BY sort`,
"id", "title", "price_coins", "sort", "enabled"),
"booster": h.rows(ctx, `SELECT id,title,multiplier,hours,price_toman,sku,sort,enabled FROM boosters ORDER BY sort`,
"id", "title", "multiplier", "hours", "price_toman", "sku", "sort", "enabled"),
"vip": h.rows(ctx, `SELECT id,title,months,price_toman,sku,sort,enabled FROM vip_packages ORDER BY sort`,
"id", "title", "months", "price_toman", "sku", "sort", "enabled"),
"tier": h.rows(ctx, `SELECT id,title,hands,entry,prize,xp,trophy,rank_reward,sort,enabled FROM table_tiers ORDER BY sort`,
"id", "title", "hands", "entry", "prize", "xp", "trophy", "rank_reward", "sort", "enabled"),
})
}
// apiCatalogUpsert یک آیتم را می‌سازد یا به‌روزرسانی می‌کند (INSERT OR REPLACE).
func (h *Handler) apiCatalogUpsert(w http.ResponseWriter, r *http.Request) {
kind := chi.URLParam(r, "kind")
table, ok := catalogTable[kind]
if !ok {
httpx.Error(w, http.StatusBadRequest, "unknown kind")
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
id, _ := body["id"].(string)
if strings.TrimSpace(id) == "" {
httpx.Error(w, http.StatusBadRequest, "id required")
return
}
cols := append([]string{}, catalogCols[kind]...)
vals := make([]any, 0, len(cols)+1)
for _, c := range cols {
vals = append(vals, coerceCol(c, body[c]))
}
// SKU: در ساخت خودکار تولید و در ویرایش حفظ می‌شود.
if catalogHasSKU[kind] {
var sku string
_ = h.db.QueryRowContext(r.Context(),
fmt.Sprintf(`SELECT sku FROM %s WHERE id=?`, table), id).Scan(&sku)
if sku == "" {
sku = newUUID()
}
cols = append(cols, "sku")
vals = append(vals, sku)
}
ph := strings.TrimRight(strings.Repeat("?,", len(cols)), ",")
q := fmt.Sprintf(`INSERT OR REPLACE INTO %s (%s) VALUES (%s)`, table, strings.Join(cols, ","), ph)
if _, err := h.db.ExecContext(r.Context(), q, vals...); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
_ = h.eco.LoadCatalog(r.Context())
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiCatalogDelete(w http.ResponseWriter, r *http.Request) {
kind := chi.URLParam(r, "kind")
table, ok := catalogTable[kind]
if !ok {
httpx.Error(w, http.StatusBadRequest, "unknown kind")
return
}
id := r.URL.Query().Get("id")
if _, err := h.db.ExecContext(r.Context(),
fmt.Sprintf(`DELETE FROM %s WHERE id=?`, table), id); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
_ = h.eco.LoadCatalog(r.Context())
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- کاربران ---
func (h *Handler) apiUsers(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
ctx := r.Context()
var rows []map[string]any
if q == "" {
rows = h.rows(ctx,
`SELECT id,mobile,first_name,coins,rank_points,is_admin FROM users ORDER BY id DESC LIMIT 50`,
"id", "mobile", "first_name", "coins", "rank_points", "is_admin")
} else {
like := "%" + q + "%"
rows = h.rowsArgs(ctx,
`SELECT id,mobile,first_name,coins,rank_points,is_admin FROM users
WHERE mobile LIKE ? OR first_name LIKE ? OR CAST(id AS TEXT)=? ORDER BY id DESC LIMIT 50`,
[]any{like, like, q},
"id", "mobile", "first_name", "coins", "rank_points", "is_admin")
}
httpx.JSON(w, http.StatusOK, map[string]any{"users": rows})
}
func (h *Handler) apiUserCoins(w http.ResponseWriter, r *http.Request) {
var b struct {
UserID flexInt `json:"user_id"`
Amount flexInt `json:"amount"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر")
return
}
if err := h.eco.Adjust(r.Context(), b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_adjust", "panel"); err != nil {
httpx.Error(w, http.StatusBadRequest, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiUserGrant(w http.ResponseWriter, r *http.Request) {
var b struct {
UserID flexInt `json:"user_id"`
Kind string `json:"kind"` // coin | ticket | vip
Amount flexInt `json:"amount"`
VIPDays flexInt `json:"vip_days"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.UserID == 0 {
httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر")
return
}
ctx := r.Context()
var err error
switch b.Kind {
case "coin":
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyCoin, b.Amount.i(), "admin_grant", "panel")
case "ticket":
err = h.eco.Adjust(ctx, b.UserID.i(), economy.CurrencyTicket, b.Amount.i(), "admin_grant", "panel")
case "vip":
_, err = h.db.ExecContext(ctx,
`UPDATE users SET vip_until = datetime('now', ?) WHERE id = ?`,
fmt.Sprintf("+%d days", b.VIPDays.i()), b.UserID.i())
default:
httpx.Error(w, http.StatusBadRequest, "unknown kind")
return
}
if err != nil {
httpx.Error(w, http.StatusBadRequest, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- فرش‌ها ---
func (h *Handler) apiCarpets(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{
"carpets": h.rows(r.Context(),
`SELECT id,title,price_coins,vip,sort,enabled FROM carpets ORDER BY sort`,
"id", "title", "price_coins", "vip", "sort", "enabled"),
})
}
func (h *Handler) apiCarpetUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins flexInt `json:"price_coins"`
VIP flexBool `json:"vip"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر")
return
}
if strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
if strings.ContainsAny(b.ID, `/\.`) {
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
return
}
vip := 0
if b.VIP {
vip = 1
}
if _, err := h.db.ExecContext(r.Context(),
`INSERT OR REPLACE INTO carpets (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)`,
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiCarpetDelete(w http.ResponseWriter, r *http.Request) {
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM carpets WHERE id=?`, r.URL.Query().Get("id"))
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- شخصیت‌های آواتار ---
func (h *Handler) apiAvatars(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{
"avatars": h.rows(r.Context(),
`SELECT id,title,price_coins,vip,sort,enabled,img_ext FROM avatars ORDER BY sort`,
"id", "title", "price_coins", "vip", "sort", "enabled", "img_ext"),
})
}
func (h *Handler) apiAvatarUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins flexInt `json:"price_coins"`
VIP flexBool `json:"vip"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
httpx.Error(w, http.StatusBadRequest, "بدنه‌ی نامعتبر")
return
}
if strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
if strings.ContainsAny(b.ID, `/\.`) {
httpx.Error(w, http.StatusBadRequest, "شناسه نباید شاملِ نقطه، / یا \\ باشد")
return
}
vip := 0
if b.VIP {
vip = 1
}
// ON CONFLICT به‌جای REPLACE تا img_ext (نوعِ فایلِ آپلودشده) هنگامِ ویرایش پاک نشود.
if _, err := h.db.ExecContext(r.Context(),
`INSERT INTO avatars (id,title,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,1)
ON CONFLICT(id) DO UPDATE SET title=excluded.title, price_coins=excluded.price_coins,
vip=excluded.vip, sort=excluded.sort, enabled=1`,
b.ID, b.Title, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiAvatarDelete(w http.ResponseWriter, r *http.Request) {
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatars WHERE id=?`, r.URL.Query().Get("id"))
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- قاب‌های آواتار ---
func (h *Handler) apiFrames(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{
"frames": h.rows(r.Context(),
`SELECT id,title,price_coins,vip,c1,c2,sort,enabled FROM avatar_frames ORDER BY sort`,
"id", "title", "price_coins", "vip", "c1", "c2", "sort", "enabled"),
})
}
func (h *Handler) apiFrameUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins flexInt `json:"price_coins"`
VIP flexBool `json:"vip"`
C1 string `json:"c1"`
C2 string `json:"c2"`
Sort flexInt `json:"sort"`
Enabled *flexBool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
vip := 0
if b.VIP {
vip = 1
}
enabled := 1
if b.Enabled != nil && !*b.Enabled {
enabled = 0
}
// رنگ‌ها را نرمال کن (حذفِ # و فاصله).
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
if _, err := h.db.ExecContext(r.Context(),
`INSERT OR REPLACE INTO avatar_frames (id,title,price_coins,vip,c1,c2,sort,enabled) VALUES (?,?,?,?,?,?,?,?)`,
b.ID, b.Title, b.PriceCoins.i(), vip, clean(b.C1), clean(b.C2), b.Sort.i(), enabled); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiFrameDelete(w http.ResponseWriter, r *http.Request) {
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM avatar_frames WHERE id=?`, r.URL.Query().Get("id"))
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- رتبه‌ها (رنگ/برچسب؛ مجموعه ثابت است، فقط ویرایش) ---
func (h *Handler) apiRankTiers(w http.ResponseWriter, r *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]any{
"rank_tiers": h.rows(r.Context(),
`SELECT id,label,c1,c2,sort FROM rank_tiers ORDER BY sort`,
"id", "label", "c1", "c2", "sort"),
})
}
func (h *Handler) apiRankTierUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Label string `json:"label"`
C1 string `json:"c1"`
C2 string `json:"c2"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
clean := func(s string) string { return strings.TrimPrefix(strings.TrimSpace(s), "#") }
if _, err := h.db.ExecContext(r.Context(),
`INSERT OR REPLACE INTO rank_tiers (id,label,c1,c2,sort) VALUES (?,?,?,?,?)`,
b.ID, b.Label, clean(b.C1), clean(b.C2), b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- چت ---
func (h *Handler) apiChat(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
httpx.JSON(w, http.StatusOK, map[string]any{
"packs": h.rows(ctx,
`SELECT id,title,kind,price_coins,vip,sort,enabled FROM chat_packs ORDER BY sort`,
"id", "title", "kind", "price_coins", "vip", "sort", "enabled"),
"messages": h.rows(ctx,
`SELECT id,pack_id,body,sort FROM chat_messages ORDER BY pack_id,sort`,
"id", "pack_id", "body", "sort"),
})
}
func (h *Handler) apiChatPackUpsert(w http.ResponseWriter, r *http.Request) {
var b struct {
ID string `json:"id"`
Title string `json:"title"`
Kind string `json:"kind"`
PriceCoins flexInt `json:"price_coins"`
VIP flexBool `json:"vip"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.ID) == "" {
httpx.Error(w, http.StatusBadRequest, "شناسه (id) لازم است")
return
}
if b.Kind != "emoji" {
b.Kind = "text"
}
vip := 0
if b.VIP {
vip = 1
}
if _, err := h.db.ExecContext(r.Context(),
`INSERT OR REPLACE INTO chat_packs (id,title,kind,price_coins,vip,sort,enabled) VALUES (?,?,?,?,?,?,1)`,
b.ID, b.Title, b.Kind, b.PriceCoins.i(), vip, b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiChatPackDelete(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE pack_id=?`, id)
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_packs WHERE id=?`, id)
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiChatMessageAdd(w http.ResponseWriter, r *http.Request) {
var b struct {
PackID string `json:"pack_id"`
Body string `json:"body"`
Sort flexInt `json:"sort"`
}
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.PackID == "" || b.Body == "" {
httpx.Error(w, http.StatusBadRequest, "بسته و متن لازم است")
return
}
if _, err := h.db.ExecContext(r.Context(),
`INSERT INTO chat_messages (pack_id,body,sort) VALUES (?,?,?)`, b.PackID, b.Body, b.Sort.i()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiChatMessageDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
_, _ = h.db.ExecContext(r.Context(), `DELETE FROM chat_messages WHERE id=?`, id)
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// --- تورنومنت‌ها ---
func (h *Handler) apiTournaments(w http.ResponseWriter, r *http.Request) {
list, _ := h.eco.AdminListTournaments(r.Context())
httpx.JSON(w, http.StatusOK, map[string]any{"tournaments": list})
}
type tournamentBody struct {
ID flexInt `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
EntryFee flexInt `json:"entry_fee"`
Prizes []flexInt `json:"prizes"`
StartsAt string `json:"starts_at"` // datetime-local به وقتِ تهران
EndsAt string `json:"ends_at"`
}
// tournamentFromBody بدنه را به economy.Tournament (با زمانِ UTC) تبدیل می‌کند.
func tournamentFromBody(b tournamentBody) economy.Tournament {
iran := time.FixedZone("IRST", 12600)
parse := func(s string) time.Time {
t, err := time.ParseInLocation("2006-01-02T15:04", s, iran)
if err != nil {
return time.Now().UTC()
}
return t.UTC()
}
prizes := make([]int64, len(b.Prizes))
for i, p := range b.Prizes {
prizes[i] = p.i()
}
return economy.Tournament{
Title: b.Title, Description: b.Description, EntryFee: b.EntryFee.i(),
Prizes: prizes, StartsAt: parse(b.StartsAt), EndsAt: parse(b.EndsAt),
}
}
func (h *Handler) apiTournamentAdd(w http.ResponseWriter, r *http.Request) {
var b tournamentBody
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || strings.TrimSpace(b.Title) == "" {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
if _, err := h.eco.CreateTournament(r.Context(), tournamentFromBody(b)); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiTournamentUpdate(w http.ResponseWriter, r *http.Request) {
var b tournamentBody
if err := json.NewDecoder(r.Body).Decode(&b); err != nil || b.ID == 0 || strings.TrimSpace(b.Title) == "" {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
if err := h.eco.UpdateTournament(r.Context(), b.ID.i(), tournamentFromBody(b)); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiTournamentDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
_ = h.eco.DeleteTournament(r.Context(), id)
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (h *Handler) apiSeasonReset(w http.ResponseWriter, r *http.Request) {
if err := h.eco.ResetSeason(r.Context()); err != nil {
httpx.Error(w, http.StatusInternalServerError, err.Error())
return
}
httpx.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
// coerceCol مقدارِ JSON را برای ستونِ کاتالوگ به نوعِ درست تبدیل می‌کند.
func coerceCol(col string, v any) any {
switch col {
case "id", "title":
s, _ := v.(string)
return s
case "enabled":
return boolToInt(v)
default: // اعداد
return toInt(v)
}
}
func toInt(v any) int64 {
switch n := v.(type) {
case float64:
return int64(n)
case int64:
return n
case int:
return int64(n)
case string:
i, _ := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
return i
}
return 0
}
func boolToInt(v any) int {
switch b := v.(type) {
case bool:
if b {
return 1
}
case float64:
if b != 0 {
return 1
}
case string:
if b == "true" || b == "1" || b == "on" {
return 1
}
}
return 0
}
+78
View File
@@ -0,0 +1,78 @@
package admin
import (
"context"
"database/sql"
"encoding/base64"
"os"
"path/filepath"
"regexp"
"strings"
)
// برخی ابزارها (مثلِ خروجیِ AI) یک PNG را داخلِ یک <svg> با base64 قرار می‌دهند.
// flutter_svg نمی‌تواند این «SVGِ رستری» را رندر کند، پس آن را به PNG واقعی تبدیل
// می‌کنیم.
var rasterInSVG = regexp.MustCompile(`data:image/(png|jpe?g);base64,([A-Za-z0-9+/=\s]+)`)
// extractEmbeddedRaster اگر محتوای SVG صرفاً یک تصویرِ رستریِ base64 باشد، آن را
// استخراج می‌کند (بایت‌های خام + پسوند). برای SVGهای واقعیِ برداری false برمی‌گرداند.
func extractEmbeddedRaster(svg []byte) (data []byte, ext string, ok bool) {
m := rasterInSVG.FindSubmatch(svg)
if m == nil {
return nil, "", false
}
ext = "png"
if strings.HasPrefix(string(m[1]), "jp") {
ext = "jpg"
}
b64 := regexp.MustCompile(`\s`).ReplaceAll(m[2], nil)
raw, err := base64.StdEncoding.DecodeString(string(b64))
if err != nil || len(raw) == 0 {
return nil, "", false
}
return raw, ext, true
}
// convertRasterAvatars فایل‌های آواتارِ svg را که در واقع رستر هستند به png/jpg
// تبدیل می‌کند و img_ext را در DB به‌روزرسانی می‌کند. یک‌بار در startup اجرا می‌شود
// (idempotent). برای رفعِ آواتارهایی که قبلاً به‌صورتِ «svgِ رستری» آپلود شده‌اند.
func convertRasterAvatars(ctx context.Context, db *sql.DB, dir string) {
if dir == "" {
return
}
rows, err := db.QueryContext(ctx, `SELECT id FROM avatars WHERE img_ext = 'svg'`)
if err != nil {
return
}
var ids []string
for rows.Next() {
var id string
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
rows.Close()
for _, id := range ids {
svgPath := filepath.Join(dir, id+".svg")
content, err := os.ReadFile(svgPath)
if err != nil {
continue
}
raw, ext, ok := extractEmbeddedRaster(content)
if !ok {
continue // SVGِ واقعیِ برداری — دست نزن.
}
if err := os.WriteFile(filepath.Join(dir, id+"."+ext), raw, 0o644); err != nil {
continue
}
_ = os.Remove(svgPath)
_, _ = db.ExecContext(ctx, `UPDATE avatars SET img_ext = ? WHERE id = ?`, ext, id)
}
}
// ConvertRasterAvatars نسخه‌ی عمومی برای فراخوانی از main در startup.
func ConvertRasterAvatars(ctx context.Context, db *sql.DB, dir string) {
convertRasterAvatars(ctx, db, dir)
}
+37
View File
@@ -0,0 +1,37 @@
package admin
import (
"strconv"
"strings"
)
// flexBool یک bool است که هنگامِ decode از JSON، مقادیرِ عددی/رشته‌ای را هم می‌پذیرد
// (true/false، 0/1، "1"/"true"). دلیل: SQLite بولین‌ها را int برمی‌گرداند و پنل
// همان مقدار را دوباره می‌فرستد؛ decodeِ سخت‌گیرِ Go عدد را در bool نمی‌پذیرد → 400.
type flexBool bool
func (f *flexBool) UnmarshalJSON(b []byte) error {
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
*f = s == "1" || s == "true" || s == "1.0"
return nil
}
// flexInt یک int64 است که از عدد، رشته‌ی عددی، خالی یا null هم decode می‌شود
// (تا ورودی‌های متنیِ عددی مثلِ "2000" باعثِ 400 نشوند).
type flexInt int64
func (f *flexInt) UnmarshalJSON(b []byte) error {
s := strings.Trim(strings.TrimSpace(string(b)), `"`)
if s == "" || s == "null" {
*f = 0
return nil
}
v, err := strconv.ParseFloat(s, 64) // "2000" و "2000.0" هر دو
if err != nil {
return err
}
*f = flexInt(int64(v))
return nil
}
func (f flexInt) i() int64 { return int64(f) }
+236
View File
@@ -0,0 +1,236 @@
package admin
import (
"context"
"net/http"
"strconv"
"strings"
"time"
"hakemsho/internal/httpx"
)
// iranTZ منطقه‌ی زمانیِ ایران (UTC+3:30؛ بدون DST از ۲۰۲۲). همین الگو در admin.go
// و api.go به‌صورت inline استفاده شده؛ اینجا در سطح پکیج برای چند نقطهِ مشترک.
var iranTZ = time.FixedZone("IRST", 12600)
const tsLayout = "2006-01-02 15:04:05"
// apiPurchases فهرستِ تراکنش‌های خرید را به‌همراه آمارهای کلی برمی‌گرداند.
// پارامترها: q (موبایل/نام/شناسه کاربر)، store، kind، status، user_id،
// page (پیش‌فرض ۱) و page_size (پیش‌فرض ۵۰، سقف ۲۰۰).
func (h *Handler) apiPurchases(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := strings.TrimSpace(r.URL.Query().Get("q"))
store := strings.TrimSpace(r.URL.Query().Get("store"))
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
status := strings.TrimSpace(r.URL.Query().Get("status"))
userID, _ := strconv.ParseInt(r.URL.Query().Get("user_id"), 10, 64)
page := atoiDefault(r.URL.Query().Get("page"), 1)
if page < 1 {
page = 1
}
pageSize := atoiDefault(r.URL.Query().Get("page_size"), 50)
if pageSize < 1 {
pageSize = 50
}
if pageSize > 200 {
pageSize = 200
}
var where []string
var args []any
if q != "" {
like := "%" + q + "%"
where = append(where, `(u.mobile LIKE ? OR u.first_name LIKE ? OR CAST(p.user_id AS TEXT) = ?)`)
args = append(args, like, like, q)
}
if store != "" {
where = append(where, `p.store = ?`)
args = append(args, store)
}
if kind != "" {
where = append(where, `p.kind = ?`)
args = append(args, kind)
}
if status != "" {
where = append(where, `p.status = ?`)
args = append(args, status)
}
if userID > 0 {
where = append(where, `p.user_id = ?`)
args = append(args, userID)
}
whereSQL := ""
if len(where) > 0 {
whereSQL = " WHERE " + strings.Join(where, " AND ")
}
var total int64
_ = h.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM purchases p LEFT JOIN users u ON u.id = p.user_id`+whereSQL, args...).Scan(&total)
list := h.purchaseRows(ctx, whereSQL, args, pageSize, (page-1)*pageSize)
pages := int64(1)
if pageSize > 0 {
pages = (total + int64(pageSize) - 1) / int64(pageSize)
}
if pages < 1 {
pages = 1
}
httpx.JSON(w, http.StatusOK, map[string]any{
"purchases": list,
"stats": h.purchasesStats(ctx),
"total": total,
"page": page,
"page_size": pageSize,
"pages": pages,
})
}
// purchaseRows فهرستِ خریدها را با مشخصاتِ کاربر برمی‌گرداند و روی هر ردیف
// «عنوانِ محصول» و «زمانِ تهران» را اضافه می‌کند.
func (h *Handler) purchaseRows(ctx context.Context, whereSQL string, args []any, limit, offset int) []map[string]any {
q := `SELECT p.id, p.user_id, COALESCE(u.mobile,''), COALESCE(u.first_name,''), p.store, p.kind, p.product_id,
p.coins, p.tickets, p.vip_days, p.price_toman, p.status, p.created_at
FROM purchases p LEFT JOIN users u ON u.id = p.user_id` +
whereSQL + ` ORDER BY p.id DESC LIMIT ? OFFSET ?`
qArgs := append(append([]any{}, args...), limit, offset)
rows := h.rowsArgs(ctx, q, qArgs,
"id", "user_id", "mobile", "name", "store", "kind", "product_id",
"coins", "tickets", "vip_days", "price_toman", "status", "created_at")
titles := h.productTitles()
for _, row := range rows {
kind, _ := row["kind"].(string)
pid, _ := row["product_id"].(string)
row["product_title"] = titles[kind+":"+pid]
if s, _ := row["product_title"].(string); s == "" {
row["product_title"] = pid
}
if raw, _ := row["created_at"].(string); raw != "" {
row["created_local"] = toTehran(raw)
} else {
row["created_local"] = ""
}
}
return rows
}
// productTitles نگاشتِ «نوع:شناسه» → عنوانِ محصول را از کاتالوگِ کش‌شده می‌سازد.
func (h *Handler) productTitles() map[string]string {
c := h.eco.Catalog()
m := make(map[string]string)
for _, p := range c.CoinPackages {
m["coin:"+p.ID] = p.Title
}
for _, p := range c.TicketPackages {
m["ticket:"+p.ID] = p.Title
}
for _, p := range c.Boosters {
m["booster:"+p.ID] = p.Title
}
for _, p := range c.VIPPackages {
m["vip:"+p.ID] = p.Title
}
return m
}
// purchasesStats آمارِ کلیِ خریدهای تأییدشده (برای کارت‌های بالای پنلِ تراکنش‌ها).
// بازه‌های «امروز/این ماه/۱۴ روز» با نیمه‌شبِ تهران در Go محاسبه و با رشته‌ی UTC
// مقایسه می‌شوند تا به تفاوتِ منطقه‌ی زمانیِ SQLite وابسته نباشیم.
func (h *Handler) purchasesStats(ctx context.Context) map[string]any {
now := time.Now()
tehranNow := now.In(iranTZ)
dayStart := tehranNow.UTC().Truncate(24 * time.Hour).Format(tsLayout)
monthStart := time.Date(tehranNow.Year(), tehranNow.Month(), 1, 0, 0, 0, 0, iranTZ).UTC().Format(tsLayout)
weekAgo := now.UTC().AddDate(0, 0, -13).Format(tsLayout)
agg := func(since string) (int64, int64) {
var n, sum int64
q := `SELECT COUNT(*), COALESCE(SUM(price_toman),0) FROM purchases WHERE status='verified'`
var args []any
if since != "" {
q += ` AND created_at >= ?`
args = append(args, since)
}
_ = h.db.QueryRowContext(ctx, q, args...).Scan(&n, &sum)
return n, sum
}
stat := func(since string) map[string]any {
n, sum := agg(since)
return map[string]any{"count": n, "revenue": sum}
}
totalN, totalSum := agg("")
var avg int64
if totalN > 0 {
avg = totalSum / totalN
}
var buyers int64
_ = h.db.QueryRowContext(ctx,
`SELECT COUNT(DISTINCT user_id) FROM purchases WHERE status='verified'`).Scan(&buyers)
// ۱۴ روزِ گذشته (تهران): یک کوئریِ محدود و جمع‌بندی در Go.
days := make(map[string]int64, 14)
counts := make(map[string]int64, 14)
rs, err := h.db.QueryContext(ctx,
`SELECT created_at, price_toman FROM purchases WHERE status='verified' AND created_at >= ?`, weekAgo)
if err == nil {
defer rs.Close()
for rs.Next() {
var raw string
var price int64
if rs.Scan(&raw, &price) != nil {
continue
}
d := "?"
if t, err := time.Parse(tsLayout, raw); err == nil {
d = t.In(iranTZ).Format("2006-01-02")
}
days[d] += price
counts[d]++
}
}
var last14 []map[string]any
for i := 13; i >= 0; i-- {
d := tehranNow.AddDate(0, 0, -i).Format("2006-01-02")
last14 = append(last14, map[string]any{
"date": d,
"revenue": days[d],
"count": counts[d],
})
}
return map[string]any{
"total_revenue": totalSum,
"verified_count": totalN,
"unique_buyers": buyers,
"avg_revenue": avg,
"today": stat(dayStart),
"month": stat(monthStart),
"last_14_days": last14,
}
}
// toTehran رشته‌ی زمانیِ UTC را به «زمانِ تهران» با همان قالب تبدیل می‌کند.
func toTehran(raw string) string {
t, err := time.Parse(tsLayout, raw)
if err != nil {
return raw
}
return t.In(iranTZ).Format(tsLayout)
}
func atoiDefault(s string, def int) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return def
}
return n
}
+1
View File
@@ -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>
+52
View File
@@ -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
View File
@@ -23,6 +23,8 @@ type Config struct {
AdminPass string // پسوردِ پنل ادمین (Basic Auth) AdminPass string // پسوردِ پنل ادمین (Basic Auth)
CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار) CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار)
CarpetsDir string // پوشه‌ی دیسکیِ تصاویرِ فرش (روی volume ماندگار) CarpetsDir string // پوشه‌ی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
AvatarsDir string // پوشه‌ی دیسکیِ تصاویرِ شخصیتِ آواتار (روی volume ماندگار)
PublicURL string // مبدأِ عمومیِ سرور (برای ساختِ URL کاملِ تصاویر مثلِ آواتار)
TelescopeSize int // تعدادِ درخواستی که بازرسِ حافظه نگه می‌دارد TelescopeSize int // تعدادِ درخواستی که بازرسِ حافظه نگه می‌دارد
// خرید درون‌برنامه‌ای (IAP) // خرید درون‌برنامه‌ای (IAP)
IAPPackage string // نامِ بسته‌ی اپ (برای مایکت/کافه) IAPPackage string // نامِ بسته‌ی اپ (برای مایکت/کافه)
@@ -48,6 +50,8 @@ func Load() Config {
AdminPass: env("ADMIN_PANEL_PASS", "admin"), AdminPass: env("ADMIN_PANEL_PASS", "admin"),
CardsDir: env("CARDS_DIR", "carddata"), CardsDir: env("CARDS_DIR", "carddata"),
CarpetsDir: env("CARPETS_DIR", "carpetdata"), CarpetsDir: env("CARPETS_DIR", "carpetdata"),
AvatarsDir: env("AVATARS_DIR", "avatardata"),
PublicURL: strings.TrimRight(env("PUBLIC_URL", "https://hakem.approagency.ir"), "/"),
TelescopeSize: envInt("TELESCOPE_SIZE", 200), TelescopeSize: envInt("TELESCOPE_SIZE", 200),
IAPPackage: env("IAP_PACKAGE", ""), IAPPackage: env("IAP_PACKAGE", ""),
MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""), MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""),
+133
View File
@@ -0,0 +1,133 @@
package economy
import "context"
// Avatar یک شخصیتِ آواتار (کازمتیک تصویری) که در کلِ بازی دیده می‌شود. تصویرِ آن
// از /avatars/<id>.png سرو می‌شود. Owned یعنی قابلِ انتخاب.
type Avatar struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins int64 `json:"price_coins"`
VIP bool `json:"vip"`
Img string `json:"img"` // URL کاملِ تصویرِ شخصیت (URL-safe)
Owned bool `json:"owned"`
}
// Avatars آواتارهای فعال را با وضعیتِ مالکیتِ کاربر برمی‌گرداند.
func (s *Service) Avatars(ctx context.Context, userID int64) ([]Avatar, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, title, price_coins, vip, img_ext FROM avatars WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return nil, err
}
defer rows.Close()
var avatars []Avatar
for rows.Next() {
var a Avatar
var vip int
var ext string
if err := rows.Scan(&a.ID, &a.Title, &a.PriceCoins, &vip, &ext); err != nil {
return nil, err
}
a.VIP = vip == 1
a.Img = s.imgURL("avatars", a.ID+"."+ext) // URL کاملِ URL-safe
avatars = append(avatars, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
owned, err := s.ownedAvatars(ctx, userID)
if err != nil {
return nil, err
}
isVIP := s.isVIP(ctx, userID)
for i := range avatars {
a := &avatars[i]
a.Owned = (a.PriceCoins == 0 && !a.VIP) || (a.VIP && isVIP) || owned[a.ID]
}
return avatars, nil
}
func (s *Service) ownedAvatars(ctx context.Context, userID int64) (map[string]bool, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT avatar_id FROM user_avatars WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
owned := map[string]bool{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
owned[id] = true
}
return owned, rows.Err()
}
// BuyAvatar یک شخصیتِ آواتار را با سکه باز می‌کند.
func (s *Service) BuyAvatar(ctx context.Context, userID int64, avatarID string) error {
var price int64
var vip, enabled int
err := s.db.QueryRowContext(ctx,
`SELECT price_coins, vip, enabled FROM avatars WHERE id = ?`, avatarID).
Scan(&price, &vip, &enabled)
if err != nil || enabled != 1 {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var exists int
_ = tx.QueryRowContext(ctx,
`SELECT 1 FROM user_avatars WHERE user_id = ? AND avatar_id = ?`, userID, avatarID).Scan(&exists)
if exists == 1 {
return ErrAlreadyOwned
}
if price > 0 {
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_avatar", avatarID); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO user_avatars (user_id, avatar_id) VALUES (?, ?)`, userID, avatarID); err != nil {
return err
}
// خرید = مالکیت + انتخابِ خودکار.
if _, err := tx.ExecContext(ctx,
`UPDATE users SET selected_avatar = ? WHERE id = ?`, avatarID, userID); err != nil {
return err
}
return tx.Commit()
}
// SelectAvatar شخصیتِ انتخابیِ کاربر را تنظیم می‌کند (خالی ⇒ بازگشت به آواتارِ
// تصادفی؛ وگرنه باید مالکش باشد یا رایگان/VIP).
func (s *Service) SelectAvatar(ctx context.Context, userID int64, avatarID string) error {
if avatarID == "" {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET selected_avatar = '' WHERE id = ?`, userID)
return err
}
avatars, err := s.Avatars(ctx, userID)
if err != nil {
return err
}
for _, a := range avatars {
if a.ID == avatarID {
if !a.Owned {
return ErrNotOwned
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET selected_avatar = ? WHERE id = ?`, avatarID, userID)
return err
}
}
return ErrNotFound
}
+9
View File
@@ -8,6 +8,7 @@ type Carpet struct {
Title string `json:"title"` Title string `json:"title"`
PriceCoins int64 `json:"price_coins"` PriceCoins int64 `json:"price_coins"`
VIP bool `json:"vip"` VIP bool `json:"vip"`
Img string `json:"img"` // URL کاملِ تصویرِ فرش (خالی برای classic که تصویری ندارد)
Owned bool `json:"owned"` Owned bool `json:"owned"`
} }
@@ -28,6 +29,9 @@ func (s *Service) Carpets(ctx context.Context, userID int64) ([]Carpet, error) {
return nil, err return nil, err
} }
c.VIP = vip == 1 c.VIP = vip == 1
if c.ID != "classic" {
c.Img = s.imgURL("carpets", c.ID+".jpg")
}
carpets = append(carpets, c) carpets = append(carpets, c)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -95,6 +99,11 @@ func (s *Service) BuyCarpet(ctx context.Context, userID int64, carpetID string)
`INSERT INTO user_carpets (user_id, carpet_id) VALUES (?, ?)`, userID, carpetID); err != nil { `INSERT INTO user_carpets (user_id, carpet_id) VALUES (?, ?)`, userID, carpetID); err != nil {
return err return err
} }
// خرید = مالکیت + انتخابِ خودکار.
if _, err := tx.ExecContext(ctx,
`UPDATE users SET selected_carpet = ? WHERE id = ?`, carpetID, userID); err != nil {
return err
}
return tx.Commit() return tx.Commit()
} }
+117 -28
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"database/sql" "database/sql"
"errors" "errors"
"net/url"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -36,6 +37,8 @@ type Service struct {
ad AdVerifier ad AdVerifier
iap IAPVerifier iap IAPVerifier
publicURL string // مبدأِ عمومیِ سرور برای ساختِ URL کاملِ تصاویر
mu sync.RWMutex // محافظ کشِ کاتالوگ mu sync.RWMutex // محافظ کشِ کاتالوگ
cat Catalog // کشِ کاتالوگ خوانده‌شده از DB cat Catalog // کشِ کاتالوگ خوانده‌شده از DB
} }
@@ -44,6 +47,18 @@ func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service {
return &Service{db: db, ad: ad, iap: iap} return &Service{db: db, ad: ad, iap: iap}
} }
// SetPublicURL مبدأِ عمومیِ سرور را تنظیم می‌کند (برای URLهای کاملِ تصاویر).
func (s *Service) SetPublicURL(u string) { s.publicURL = strings.TrimRight(u, "/") }
// imgURL یک URLِ کاملِ URL-safe برای فایلی در پوشه‌ی [dir] می‌سازد ("" اگر نام خالی
// باشد). نامِ فایل encode می‌شود تا فاصله/کاراکترِ خاص مشکلی ایجاد نکند.
func (s *Service) imgURL(dir, name string) string {
if name == "" {
return ""
}
return s.publicURL + "/" + dir + "/" + url.PathEscape(name)
}
// Profile وضعیت اقتصادی کاربر. // Profile وضعیت اقتصادی کاربر.
type Profile struct { type Profile struct {
Coins int64 `json:"coins"` Coins int64 `json:"coins"`
@@ -57,19 +72,29 @@ type Profile struct {
VIPUntil *string `json:"vip_until"` VIPUntil *string `json:"vip_until"`
SelectedCard string `json:"selected_card"` SelectedCard string `json:"selected_card"`
SelectedCarpet string `json:"selected_carpet"` SelectedCarpet string `json:"selected_carpet"`
SelectedFrame string `json:"selected_frame"`
SelectedAvatar string `json:"selected_avatar"` // شناسه‌ی شخصیتِ انتخابی (برای وضعیتِ انتخاب در فروشگاه)
SelectedAvatarImg string `json:"selected_avatar_img"` // URL کاملِ تصویرِ شخصیت — خالی ⇒ آواتارِ تصادفی
SelectedCarpetImg string `json:"selected_carpet_img"` // URL کاملِ تصویرِ فرش — خالی ⇒ میزِ کلاسیک
RankPoints int64 `json:"rank_points"` RankPoints int64 `json:"rank_points"`
RankTier string `json:"rank_tier"` // bronze..king RankTier string `json:"rank_tier"` // bronze..king
RankIndex int `json:"rank_index"` // ۰..۴ RankIndex int `json:"rank_index"` // ۰..۴
Avatar string `json:"avatar"` // seed آواتار (برای نمایش سرِ میز) Avatar string `json:"avatar"` // seed آواتار (برای نمایش سرِ میز)
DailyStreak int `json:"daily_streak"`
BestStreak int `json:"best_streak"`
DailyReady bool `json:"daily_ready"` // آیا امروز سکه‌ی روزانه قابلِ دریافت است
NextDaily int64 `json:"next_daily"` // جایزه‌ی روزانه‌ی بعدی (پیش‌نمایش)
} }
// GetProfile وضعیت اقتصادی کاربر را می‌خواند. // GetProfile وضعیت اقتصادی کاربر را می‌خواند.
func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) { func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) {
var p Profile var p Profile
var vipUntil, avatar sql.NullString var vipUntil, avatar, lastDaily, avatarImg sql.NullString
err := s.db.QueryRowContext(ctx, err := s.db.QueryRowContext(ctx,
`SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, rank_points, avatar FROM users WHERE id = ?`, userID). `SELECT coins, tickets, xp, trophies, vip_until, selected_card, selected_carpet, selected_frame, selected_avatar,
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.RankPoints, &avatar) (SELECT id || '.' || img_ext FROM avatars WHERE id = users.selected_avatar AND enabled = 1),
rank_points, avatar, daily_streak, best_streak, last_daily_at FROM users WHERE id = ?`, userID).
Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard, &p.SelectedCarpet, &p.SelectedFrame, &p.SelectedAvatar, &avatarImg, &p.RankPoints, &avatar, &p.DailyStreak, &p.BestStreak, &lastDaily)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound return nil, ErrNotFound
} }
@@ -77,12 +102,32 @@ func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error
return nil, err return nil, err
} }
p.Avatar = avatar.String p.Avatar = avatar.String
p.SelectedAvatarImg = s.imgURL("avatars", avatarImg.String)
// فرش: 'classic' تصویری ندارد (میزِ ترسیمی)؛ بقیه از /carpets/<id>.jpg.
if p.SelectedCarpet != "" && p.SelectedCarpet != "classic" {
p.SelectedCarpetImg = s.imgURL("carpets", p.SelectedCarpet+".jpg")
}
p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP) p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP)
p.RankTier, p.RankIndex = RankTier(p.RankPoints) p.RankTier, p.RankIndex = RankTier(p.RankPoints)
if vipUntil.Valid { if vipUntil.Valid {
p.VIPUntil = &vipUntil.String p.VIPUntil = &vipUntil.String
p.VIP = parseTS(vipUntil.String).After(nowUTC()) p.VIP = parseTS(vipUntil.String).After(nowUTC())
} }
// وضعیتِ سکه‌ی روزانه: اگر امروز (تهران) نگرفته، قابلِ دریافت است. اگر یک روز
// جا افتاده باشد، استریکِ بعدی از ۱ شروع می‌شود.
today := tehranDate(nowUTC())
nextStreak := p.DailyStreak + 1
if lastDaily.Valid {
lastDate := tehranDate(parseTS(lastDaily.String))
p.DailyReady = lastDate != today
if lastDate != tehranDate(nowUTC().Add(-24*time.Hour)) && lastDate != today {
nextStreak = 1 // روزی جا افتاده ⇒ ریست
}
} else {
p.DailyReady = true
nextStreak = 1
}
p.NextDaily = streakReward(nextStreak)
return &p, nil return &p, nil
} }
@@ -94,32 +139,70 @@ func (s *Service) isVIP(ctx context.Context, userID int64) bool {
return vu.Valid && parseTS(vu.String).After(nowUTC()) return vu.Valid && parseTS(vu.String).After(nowUTC())
} }
// ClaimDaily سکه روزانه را در صورت گذشت ۲۴ ساعت می‌دهد. // tehranDate تاریخِ تقویمیِ تهران (YYYY-MM-DD) را برمی‌گرداند. مرزِ «روز» برای
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (int64, error) { // استریک، نیمه‌شبِ تهران است تا با حسِ کاربر بخواند.
func tehranDate(t time.Time) string { return t.In(iranZone).Format("2006-01-02") }
// streakReward جایزه‌ی روزِ n اُمِ استریک: پایه + پله (سقفِ روز ۷) + پاداشِ هفتگی.
func streakReward(streak int) int64 {
n := streak
if n > 7 {
n = 7
}
r := int64(DailyReward) + int64(n-1)*50 // روز۱=۲۰۰ … روز۷=۵۰۰
if streak%7 == 0 { // هر ۷ روز پیاپی، پاداشِ ویژه
r += 500
}
return r
}
// ClaimDaily سکه‌ی روزانه را (یک‌بار در هر روزِ تقویمیِ تهران) می‌دهد و استریک را
// به‌روزرسانی می‌کند. برمی‌گرداند: جایزه، استریکِ جدید، بهترین استریک.
func (s *Service) ClaimDaily(ctx context.Context, userID int64) (reward int64, streak int, best int, err error) {
tx, err := s.db.BeginTx(ctx, nil) tx, err := s.db.BeginTx(ctx, nil)
if err != nil { if err != nil {
return 0, err return 0, 0, 0, err
} }
defer tx.Rollback() defer tx.Rollback()
cutoff := nowUTC().Add(-24 * time.Hour).Format(tsLayout) var last sql.NullString
res, err := tx.ExecContext(ctx, if err = tx.QueryRowContext(ctx,
`UPDATE users SET last_daily_at = ? `SELECT last_daily_at, daily_streak, best_streak FROM users WHERE id = ?`, userID).
WHERE id = ? AND (last_daily_at IS NULL OR last_daily_at <= ?)`, Scan(&last, &streak, &best); err != nil {
nowStr(), userID, cutoff) return 0, 0, 0, err
if err != nil {
return 0, err
} }
if n, _ := res.RowsAffected(); n == 0 {
return 0, ErrTooSoon today := tehranDate(nowUTC())
if last.Valid {
lastDate := tehranDate(parseTS(last.String))
if lastDate == today {
return 0, 0, 0, ErrTooSoon // امروز قبلاً گرفته
} }
if err := adjustTx(ctx, tx, userID, CurrencyCoin, DailyReward, "daily_reward", ""); err != nil { if lastDate == tehranDate(nowUTC().Add(-24*time.Hour)) {
return 0, err streak++ // دیروز گرفته ⇒ ادامه‌ی استریک
} else {
streak = 1 // یک روز جا افتاده ⇒ ریست
} }
if err := tx.Commit(); err != nil { } else {
return 0, err streak = 1
} }
return DailyReward, nil if streak > best {
best = streak
}
reward = streakReward(streak)
if _, err = tx.ExecContext(ctx,
`UPDATE users SET last_daily_at = ?, daily_streak = ?, best_streak = ? WHERE id = ?`,
nowStr(), streak, best, userID); err != nil {
return 0, 0, 0, err
}
if err = adjustTx(ctx, tx, userID, CurrencyCoin, reward, "daily_reward", ""); err != nil {
return 0, 0, 0, err
}
if err = tx.Commit(); err != nil {
return 0, 0, 0, err
}
return reward, streak, best, nil
} }
// ClaimAdReward پس از تأیید تبلیغ rewarded، سکه می‌دهد (با ضد تکرار و سقف روزانه). // ClaimAdReward پس از تأیید تبلیغ rewarded، سکه می‌دهد (با ضد تکرار و سقف روزانه).
@@ -191,6 +274,11 @@ func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) erro
`INSERT INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID); err != nil { `INSERT INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID); err != nil {
return err return err
} }
// خرید = مالکیت + انتخابِ خودکار (به‌عنوانِ پیش‌فرض فعال شود).
if _, err := tx.ExecContext(ctx,
`UPDATE users SET selected_card = ? WHERE id = ?`, cardID, userID); err != nil {
return err
}
return tx.Commit() return tx.Commit()
} }
@@ -263,6 +351,7 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
// را به‌دست آوریم؛ سپس با همان SKU خرید را تأیید می‌کنیم. // را به‌دست آوریم؛ سپس با همان SKU خرید را تأیید می‌کنیم.
var coins, tickets int64 var coins, tickets int64
var vipDays int var vipDays int
var priceToman int
var booster *Booster var booster *Booster
var sku string var sku string
switch req.Kind { switch req.Kind {
@@ -271,28 +360,28 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
if p == nil { if p == nil {
return ErrNotFound return ErrNotFound
} }
coins, vipDays, sku = p.Coins, p.VIPDays, p.SKU coins, vipDays, priceToman, sku = p.Coins, p.VIPDays, p.PriceToman, p.SKU
if s.isVIP(ctx, userID) { if s.isVIP(ctx, userID) {
coins += coins / 10 // پاداش ۱۰٪ VIP coins += coins / 10 // پاداش ۱۰٪ VIP (قیمت را تغییر نمی‌دهد)
} }
case "ticket": case "ticket":
p := s.findTicketPackage(req.ProductID) p := s.findTicketPackage(req.ProductID)
if p == nil { if p == nil {
return ErrNotFound return ErrNotFound
} }
tickets, sku = p.Tickets, p.SKU tickets, priceToman, sku = p.Tickets, p.PriceToman, p.SKU
case "booster": case "booster":
booster = s.findBooster(req.ProductID) booster = s.findBooster(req.ProductID)
if booster == nil { if booster == nil {
return ErrNotFound return ErrNotFound
} }
sku = booster.SKU priceToman, sku = booster.PriceToman, booster.SKU
case "vip": case "vip":
p := s.findVIPPackage(req.ProductID) p := s.findVIPPackage(req.ProductID)
if p == nil { if p == nil {
return ErrNotFound return ErrNotFound
} }
vipDays, sku = p.Months*30, p.SKU // هر ماه = ۳۰ روز vipDays, priceToman, sku = p.Months*30, p.PriceToman, p.SKU // هر ماه = ۳۰ روز
default: default:
return ErrNotFound return ErrNotFound
} }
@@ -319,9 +408,9 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
// ثبت خرید (یکتا بودن store+token ⇒ ضد دوباره‌اعتبارسنجی) // ثبت خرید (یکتا بودن store+token ⇒ ضد دوباره‌اعتبارسنجی)
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days) `INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days, kind, price_toman)
VALUES (?, ?, ?, ?, 'verified', ?, ?, ?)`, VALUES (?, ?, ?, ?, 'verified', ?, ?, ?, ?, ?)`,
userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays); err != nil { userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays, req.Kind, priceToman); err != nil {
if isUnique(err) { if isUnique(err) {
return ErrAlreadyOwned // قبلاً پردازش شده return ErrAlreadyOwned // قبلاً پردازش شده
} }
+42 -3
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"hakemsho/internal/store" "hakemsho/internal/store"
) )
@@ -87,10 +88,11 @@ func TestWalletInsufficient(t *testing.T) {
func TestDailyCooldown(t *testing.T) { func TestDailyCooldown(t *testing.T) {
svc, id := setup(t) svc, id := setup(t)
ctx := context.Background() ctx := context.Background()
if amt, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward { // اولین دریافت: استریک ۱، جایزه = DailyReward.
t.Fatalf("first claim: amt=%d err=%v", amt, err) if amt, streak, _, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward || streak != 1 {
t.Fatalf("first claim: amt=%d streak=%d err=%v", amt, streak, err)
} }
if _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon { if _, _, _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon {
t.Fatalf("want ErrTooSoon, got %v", err) t.Fatalf("want ErrTooSoon, got %v", err)
} }
if c := coinsOf(t, svc, id); c != DailyReward { if c := coinsOf(t, svc, id); c != DailyReward {
@@ -98,6 +100,33 @@ func TestDailyCooldown(t *testing.T) {
} }
} }
func TestDailyStreakContinuesAndResets(t *testing.T) {
svc, id := setup(t)
ctx := context.Background()
// دیروز گرفته ⇒ امروز استریک به ۲ می‌رسد و جایزه بیشتر می‌شود.
ySet := func(daysAgo int) {
ts := nowUTC().Add(time.Duration(-daysAgo) * 24 * time.Hour).Format(tsLayout)
if _, err := svc.db.Exec(`UPDATE users SET last_daily_at=?, daily_streak=1 WHERE id=?`, ts, id); err != nil {
t.Fatal(err)
}
}
ySet(1)
amt, streak, _, err := svc.ClaimDaily(ctx, id)
if err != nil || streak != 2 || amt != streakReward(2) {
t.Fatalf("continue: amt=%d streak=%d err=%v", amt, streak, err)
}
// دو روز فاصله ⇒ استریک ریست به ۱.
if _, err := svc.db.Exec(
`UPDATE users SET last_daily_at=?, daily_streak=5 WHERE id=?`,
nowUTC().Add(-48*time.Hour).Format(tsLayout), id); err != nil {
t.Fatal(err)
}
_, streak, _, err = svc.ClaimDaily(ctx, id)
if err != nil || streak != 1 {
t.Fatalf("reset: streak=%d err=%v", streak, err)
}
}
func TestAdRewardDedupeAndCredit(t *testing.T) { func TestAdRewardDedupeAndCredit(t *testing.T) {
svc, id := setup(t) svc, id := setup(t)
ctx := context.Background() ctx := context.Background()
@@ -163,6 +192,16 @@ func TestPurchaseCreditsAndIdempotent(t *testing.T) {
if c := coinsOf(t, svc, id); c != pkg.Coins { if c := coinsOf(t, svc, id); c != pkg.Coins {
t.Fatalf("want %d coins, got %d", pkg.Coins, c) t.Fatalf("want %d coins, got %d", pkg.Coins, c)
} }
// نوع و قیمتِ خرید باید ثبت شده باشند (پنلِ تراکنش‌ها).
var kind string
var priceToman int
if err := svc.db.QueryRow(`SELECT kind, price_toman FROM purchases WHERE user_id=?`, id).
Scan(&kind, &priceToman); err != nil {
t.Fatalf("read purchase: %v", err)
}
if kind != "coin" || priceToman != pkg.PriceToman {
t.Fatalf("want kind=coin price=%d, got kind=%q price=%d", pkg.PriceToman, kind, priceToman)
}
// همان توکن دوباره ⇒ قبلاً پردازش شده، بدون شارژ مجدد // همان توکن دوباره ⇒ قبلاً پردازش شده، بدون شارژ مجدد
if err := svc.VerifyPurchase(ctx, id, req); err != ErrAlreadyOwned { if err := svc.VerifyPurchase(ctx, id, req); err != ErrAlreadyOwned {
t.Fatalf("want ErrAlreadyOwned, got %v", err) t.Fatalf("want ErrAlreadyOwned, got %v", err)
+152
View File
@@ -0,0 +1,152 @@
package economy
import "context"
// Frame یک قابِ آواتار (کازمتیک). Owned یعنی قابلِ انتخاب. سبکِ رنگی سمتِ کلاینت است.
type Frame struct {
ID string `json:"id"`
Title string `json:"title"`
PriceCoins int64 `json:"price_coins"`
VIP bool `json:"vip"`
C1 string `json:"c1"` // رنگِ گرادیان (hex بدونِ #)؛ خالی ⇒ قابِ رتبه
C2 string `json:"c2"`
Owned bool `json:"owned"`
}
// RankTierStyle رنگ و برچسبِ یک رتبه (برنز..پادشاه). سمتِ کلاینت hardcode نیست.
type RankTierStyle struct {
ID string `json:"id"`
Label string `json:"label"`
C1 string `json:"c1"`
C2 string `json:"c2"`
}
// RankTiers رنگ/برچسبِ همه‌ی رتبه‌ها را برمی‌گرداند (برای کشِ کلاینت).
func (s *Service) RankTiers(ctx context.Context) ([]RankTierStyle, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, label, c1, c2 FROM rank_tiers ORDER BY sort`)
if err != nil {
return nil, err
}
defer rows.Close()
var tiers []RankTierStyle
for rows.Next() {
var t RankTierStyle
if err := rows.Scan(&t.ID, &t.Label, &t.C1, &t.C2); err != nil {
return nil, err
}
tiers = append(tiers, t)
}
return tiers, rows.Err()
}
// Frames قاب‌های فعال را با وضعیتِ مالکیتِ کاربر برمی‌گرداند.
func (s *Service) Frames(ctx context.Context, userID int64) ([]Frame, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, title, price_coins, vip, c1, c2 FROM avatar_frames WHERE enabled = 1 ORDER BY sort`)
if err != nil {
return nil, err
}
defer rows.Close()
var frames []Frame
for rows.Next() {
var f Frame
var vip int
if err := rows.Scan(&f.ID, &f.Title, &f.PriceCoins, &vip, &f.C1, &f.C2); err != nil {
return nil, err
}
f.VIP = vip == 1
frames = append(frames, f)
}
if err := rows.Err(); err != nil {
return nil, err
}
owned, err := s.ownedFrames(ctx, userID)
if err != nil {
return nil, err
}
isVIP := s.isVIP(ctx, userID)
for i := range frames {
f := &frames[i]
f.Owned = (f.PriceCoins == 0 && !f.VIP) || (f.VIP && isVIP) || owned[f.ID]
}
return frames, nil
}
func (s *Service) ownedFrames(ctx context.Context, userID int64) (map[string]bool, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT frame_id FROM user_frames WHERE user_id = ?`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
owned := map[string]bool{}
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
owned[id] = true
}
return owned, rows.Err()
}
// BuyFrame یک قاب را با سکه باز می‌کند.
func (s *Service) BuyFrame(ctx context.Context, userID int64, frameID string) error {
var price int64
var vip, enabled int
err := s.db.QueryRowContext(ctx,
`SELECT price_coins, vip, enabled FROM avatar_frames WHERE id = ?`, frameID).
Scan(&price, &vip, &enabled)
if err != nil || enabled != 1 {
return ErrNotFound
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var exists int
_ = tx.QueryRowContext(ctx,
`SELECT 1 FROM user_frames WHERE user_id = ? AND frame_id = ?`, userID, frameID).Scan(&exists)
if exists == 1 {
return ErrAlreadyOwned
}
if price > 0 {
if err := adjustTx(ctx, tx, userID, CurrencyCoin, -price, "buy_frame", frameID); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO user_frames (user_id, frame_id) VALUES (?, ?)`, userID, frameID); err != nil {
return err
}
// خرید = مالکیت + انتخابِ خودکار.
if _, err := tx.ExecContext(ctx,
`UPDATE users SET selected_frame = ? WHERE id = ?`, frameID, userID); err != nil {
return err
}
return tx.Commit()
}
// SelectFrame قابِ انتخابیِ کاربر را تنظیم می‌کند (باید مالکش باشد یا رایگان/VIP).
func (s *Service) SelectFrame(ctx context.Context, userID int64, frameID string) error {
frames, err := s.Frames(ctx, userID)
if err != nil {
return err
}
for _, f := range frames {
if f.ID == frameID {
if !f.Owned {
return ErrNotOwned
}
_, err := s.db.ExecContext(ctx,
`UPDATE users SET selected_frame = ? WHERE id = ?`, frameID, userID)
return err
}
}
return ErrNotFound
}
+205 -2
View File
@@ -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"
@@ -154,6 +155,142 @@ func (h *Handler) carpetAction(w http.ResponseWriter, r *http.Request, sel bool)
} }
} }
// Frames — GET /api/frames (قاب‌های آواتار + مالکیت + انتخابِ کاربر)
func (h *Handler) Frames(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
frames, err := h.svc.Frames(r.Context(), id)
if err != nil {
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
selected := ""
if p, _ := h.svc.GetProfile(r.Context(), id); p != nil {
selected = p.SelectedFrame
}
tiers, _ := h.svc.RankTiers(r.Context())
httpx.JSON(w, http.StatusOK, map[string]any{
"frames": frames,
"selected": selected,
"rank_tiers": tiers,
})
}
// BuyFrame — POST /api/shop/buy-frame
// Avatars — GET /api/avatars: کاتالوگِ شخصیت‌ها + انتخابیِ کاربر.
func (h *Handler) Avatars(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
avatars, err := h.svc.Avatars(r.Context(), id)
if err != nil {
httpx.Error(w, http.StatusInternalServerError, "server error")
return
}
selected := ""
if p, _ := h.svc.GetProfile(r.Context(), id); p != nil {
selected = p.SelectedAvatar
}
httpx.JSON(w, http.StatusOK, map[string]any{
"avatars": avatars,
"selected": selected,
})
}
// BuyAvatar — POST /api/shop/buy-avatar
func (h *Handler) BuyAvatar(w http.ResponseWriter, r *http.Request) {
h.avatarAction(w, r, false)
}
// SelectAvatar — POST /api/shop/select-avatar (avatar_id خالی ⇒ آواتارِ پیش‌فرض)
func (h *Handler) SelectAvatar(w http.ResponseWriter, r *http.Request) {
h.avatarAction(w, r, true)
}
func (h *Handler) avatarAction(w http.ResponseWriter, r *http.Request, sel bool) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var req struct {
AvatarID string `json:"avatar_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
var err error
if sel {
err = h.svc.SelectAvatar(r.Context(), id, req.AvatarID)
} else {
err = h.svc.BuyAvatar(r.Context(), id, req.AvatarID)
}
switch {
case err == nil:
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
case errors.Is(err, ErrNotFound):
httpx.Error(w, http.StatusNotFound, "avatar not found")
case errors.Is(err, ErrAlreadyOwned):
httpx.Error(w, http.StatusConflict, "already owned")
case errors.Is(err, ErrNotOwned):
httpx.Error(w, http.StatusForbidden, "avatar not owned")
case errors.Is(err, ErrInsufficient):
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
default:
httpx.Error(w, http.StatusInternalServerError, "server error")
}
}
func (h *Handler) BuyFrame(w http.ResponseWriter, r *http.Request) {
h.frameAction(w, r, false)
}
// SelectFrame — POST /api/shop/select-frame
func (h *Handler) SelectFrame(w http.ResponseWriter, r *http.Request) {
h.frameAction(w, r, true)
}
func (h *Handler) frameAction(w http.ResponseWriter, r *http.Request, sel bool) {
id, ok := uid(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return
}
var req struct {
FrameID string `json:"frame_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, http.StatusBadRequest, "invalid body")
return
}
var err error
if sel {
err = h.svc.SelectFrame(r.Context(), id, req.FrameID)
} else {
err = h.svc.BuyFrame(r.Context(), id, req.FrameID)
}
switch {
case err == nil:
httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"})
case errors.Is(err, ErrNotFound):
httpx.Error(w, http.StatusNotFound, "frame not found")
case errors.Is(err, ErrAlreadyOwned):
httpx.Error(w, http.StatusConflict, "already owned")
case errors.Is(err, ErrNotOwned):
httpx.Error(w, http.StatusForbidden, "frame not owned")
case errors.Is(err, ErrInsufficient):
httpx.Error(w, http.StatusPaymentRequired, "insufficient coins")
default:
httpx.Error(w, http.StatusInternalServerError, "server error")
}
}
// ChatPacks — GET /api/chat-packs (بسته‌های پیام/شکلک + مالکیتِ کاربر) // ChatPacks — GET /api/chat-packs (بسته‌های پیام/شکلک + مالکیتِ کاربر)
func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) { func (h *Handler) ChatPacks(w http.ResponseWriter, r *http.Request) {
id, ok := uid(r) id, ok := uid(r)
@@ -197,6 +334,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)
@@ -310,9 +511,11 @@ func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) {
httpx.Error(w, http.StatusUnauthorized, "unauthorized") httpx.Error(w, http.StatusUnauthorized, "unauthorized")
return return
} }
switch amount, err := h.svc.ClaimDaily(r.Context(), id); { switch amount, streak, best, err := h.svc.ClaimDaily(r.Context(), id); {
case err == nil: case err == nil:
httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "amount": amount}) httpx.JSON(w, http.StatusOK, map[string]any{
"message": "ok", "amount": amount, "streak": streak, "best": best,
})
case errors.Is(err, ErrTooSoon): case errors.Is(err, ErrTooSoon):
httpx.Error(w, http.StatusTooManyRequests, "already claimed today") httpx.Error(w, http.StatusTooManyRequests, "already claimed today")
default: default:
+377
View File
@@ -0,0 +1,377 @@
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()
}
// UpdateTournament فیلدهای یک تورنومنت را ویرایش می‌کند (ثبت‌نام‌ها دست‌نخورده).
func (s *Service) UpdateTournament(ctx context.Context, id int64, t Tournament) error {
prizes, _ := json.Marshal(t.Prizes)
_, err := s.db.ExecContext(ctx,
`UPDATE tournaments SET title=?, description=?, entry_fee=?, prizes=?, starts_at=?, ends_at=?
WHERE id=?`,
t.Title, t.Description, t.EntryFee, string(prizes),
t.StartsAt.UTC().Format(tsLayout), t.EndsAt.UTC().Format(tsLayout), id)
return err
}
// 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)
}
+140
View File
@@ -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)
}
}
+23
View File
@@ -49,6 +49,29 @@ func CardsHandler(diskDir string) http.Handler {
}) })
} }
// AvatarsHandler تصاویرِ شخصیتِ آواتار را سرو می‌کند: GET /avatars/<id>.png
// فقط از پوشه‌ی دیسکیِ آپلودِ ادمین (بدونِ embed)؛ اگر نبود ۴۰۴ (اپ به آواتارِ
// تصادفیِ پیش‌فرض برمی‌گردد).
func AvatarsHandler(diskDir string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Cache-Control", "public, max-age=604800")
if diskDir != "" {
rel := filepath.Clean("/" + strings.TrimPrefix(r.URL.Path, "/avatars/"))
full := filepath.Join(diskDir, rel)
if st, err := os.Stat(full); err == nil && !st.IsDir() {
// SVG را با content-type درست بده تا مرورگر/پنل هم آن را نمایش دهد.
if strings.HasSuffix(strings.ToLower(full), ".svg") {
w.Header().Set("Content-Type", "image/svg+xml")
}
http.ServeFile(w, r, full)
return
}
}
http.NotFound(w, r)
})
}
// CarpetsHandler تصاویرِ فرش را سرو می‌کند: GET /carpets/<id>.jpg // CarpetsHandler تصاویرِ فرش را سرو می‌کند: GET /carpets/<id>.jpg
// ابتدا از پوشه‌ی دیسکی (آپلودِ ادمین)، سپس از فرش‌های embed‌شده‌ی پیش‌فرض. // ابتدا از پوشه‌ی دیسکی (آپلودِ ادمین)، سپس از فرش‌های embed‌شده‌ی پیش‌فرض.
// اگر هیچ‌کدام نبود ۴۰۴ (اپ به میزِ ترسیمیِ classic برمی‌گردد). // اگر هیچ‌کدام نبود ۴۰۴ (اپ به میزِ ترسیمیِ classic برمی‌گردد).
+55
View File
@@ -0,0 +1,55 @@
package store
import (
"sync"
"sync/atomic"
"testing"
)
// TestConcurrentReadWrite تأیید می‌کند که با pool چند-اتصالی + WAL + busy_timeout،
// خواندن و نوشتنِ هم‌زمان بدونِ خطای «database is locked» انجام می‌شود.
func TestConcurrentReadWrite(t *testing.T) {
s, err := Open(t.TempDir() + "/c.db")
if err != nil {
t.Fatal(err)
}
defer s.Close()
if _, err := s.DB.Exec(`CREATE TABLE t(id INTEGER PRIMARY KEY, v INTEGER)`); err != nil {
t.Fatal(err)
}
const workers = 40 // ۲۰ نویسنده + ۲۰ خواننده هم‌زمان
const iters = 50
var wg sync.WaitGroup
var writeErr, readErr int64
for w := 0; w < workers; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < iters; i++ {
if w%2 == 0 {
if _, err := s.DB.Exec(`INSERT INTO t(v) VALUES(?)`, i); err != nil {
atomic.AddInt64(&writeErr, 1)
t.Logf("write err: %v", err)
}
} else {
var n int
if err := s.DB.QueryRow(`SELECT COUNT(*) FROM t`).Scan(&n); err != nil {
atomic.AddInt64(&readErr, 1)
t.Logf("read err: %v", err)
}
}
}
}(w)
}
wg.Wait()
if writeErr != 0 || readErr != 0 {
t.Fatalf("concurrency errors: writes=%d reads=%d (expected 0)", writeErr, readErr)
}
var total int
_ = s.DB.QueryRow(`SELECT COUNT(*) FROM t`).Scan(&total)
if want := (workers / 2) * iters; total != want {
t.Fatalf("expected %d rows written, got %d", want, total)
}
}
+10 -4
View File
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
"time"
_ "modernc.org/sqlite" // درایور SQLite خالص Go (بدون cgo) _ "modernc.org/sqlite" // درایور SQLite خالص Go (بدون cgo)
) )
@@ -20,14 +21,19 @@ type Store struct {
// Open اتصال SQLite را باز کرده و migrations را اجرا می‌کند. // Open اتصال SQLite را باز کرده و migrations را اجرا می‌کند.
func Open(path string) (*Store, error) { func Open(path string) (*Store, error) {
// pragmaها برای کارایی و یکپارچگی روی سرور کوچک // pragmaها برای کارایی و یکپارچگی روی سرور کوچک. busy_timeout بالاتر تا نویسنده‌ی
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path) // دوم به‌جای خطای «database is locked» چند لحظه منتظر بماند.
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(10000)&_pragma=foreign_keys(ON)", path)
db, err := sql.Open("sqlite", dsn) db, err := sql.Open("sqlite", dsn)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// SQLite تک‌نویسنده است؛ pool کوچک نگه می‌داریم. // WAL اجازه می‌دهد چند «خواننده» هم‌زمان باشند و فقط «نویسنده» تک بماند. با
db.SetMaxOpenConns(1) // SetMaxOpenConns(1) این مزیت هدر می‌رفت (همه، حتی خواندن‌ها، در صفِ تک‌اتصالی).
// pool بزرگ‌تر ⇒ خواندن‌های هم‌زمان بدونِ صف؛ نوشتن‌ها با busy_timeout سریالی می‌مانند.
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
db.SetConnMaxIdleTime(5 * time.Minute)
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, err return nil, 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';
+4
View File
@@ -0,0 +1,4 @@
-- استریکِ روزانه (مثلِ دولینگو): تعدادِ روزهای پیاپی که کاربر سکه‌ی روزانه گرفته.
-- با از دست دادنِ یک روز صفر می‌شود؛ جایزه با طولِ استریک زیاد می‌شود.
ALTER TABLE users ADD COLUMN daily_streak INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN best_streak INTEGER NOT NULL DEFAULT 0;
+28
View File
@@ -0,0 +1,28 @@
-- قاب‌های آواتار (کازمتیکِ قابلِ‌خرید). سبکِ هر قاب (رنگ‌ها) سمتِ کلاینت است؛
-- اینجا فقط شناسه/قیمت/فعال‌بودن نگه‌داری می‌شود. 'none' یعنی قابِ پیش‌فرضِ رتبه.
CREATE TABLE IF NOT EXISTS avatar_frames (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
price_coins INTEGER NOT NULL DEFAULT 0, -- ۰ یعنی رایگان (مگر vip)
vip INTEGER NOT NULL DEFAULT 0, -- ۱ یعنی برای VIP رایگان
sort INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE IF NOT EXISTS user_frames (
user_id INTEGER NOT NULL,
frame_id TEXT NOT NULL,
PRIMARY KEY (user_id, frame_id)
);
-- قابِ انتخابیِ کاربر؛ خالی/'none' یعنی قابِ پیش‌فرضِ رتبه‌ای.
ALTER TABLE users ADD COLUMN selected_frame TEXT NOT NULL DEFAULT '';
INSERT OR IGNORE INTO avatar_frames (id, title, price_coins, vip, sort) VALUES
('none', 'پیش‌فرض (رتبه)', 0, 0, 1),
('gold', 'قابِ طلایی', 5000, 0, 2),
('fire', 'قابِ آتش', 12000, 0, 3),
('emerald', 'قابِ زمرد', 12000, 0, 4),
('ocean', 'قابِ اقیانوس', 15000, 0, 5),
('rose', 'قابِ گل‌سرخ', 15000, 0, 6),
('royal', 'قابِ سلطنتی', 0, 1, 7);
@@ -0,0 +1,11 @@
-- رنگِ گرادیانِ هر قاب سمتِ سرور تعریف می‌شود (hex بدونِ #)، تا ادمین بتواند قابِ
-- جدید با رنگِ دلخواه بسازد بدونِ به‌روزرسانیِ اپ. خالی ⇒ قابِ رتبه.
ALTER TABLE avatar_frames ADD COLUMN c1 TEXT NOT NULL DEFAULT '';
ALTER TABLE avatar_frames ADD COLUMN c2 TEXT NOT NULL DEFAULT '';
UPDATE avatar_frames SET c1='F3D27A', c2='B8860B' WHERE id='gold';
UPDATE avatar_frames SET c1='FF9A3C', c2='B81D2A' WHERE id='fire';
UPDATE avatar_frames SET c1='7BEAA5', c2='1B7A4A' WHERE id='emerald';
UPDATE avatar_frames SET c1='8FD3F0', c2='1E5FA8' WHERE id='ocean';
UPDATE avatar_frames SET c1='F7A8C4', c2='A83060' WHERE id='rose';
UPDATE avatar_frames SET c1='CE9BEA', c2='6A2FA0' WHERE id='royal';
@@ -0,0 +1,15 @@
-- رنگ و برچسبِ رتبه‌ها از سرور می‌آید (تا هیچ رنگی سمتِ کلاینت hardcode نباشد).
CREATE TABLE IF NOT EXISTS rank_tiers (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
c1 TEXT NOT NULL,
c2 TEXT NOT NULL,
sort INTEGER NOT NULL DEFAULT 0
);
INSERT INTO rank_tiers (id, label, c1, c2, sort) VALUES
('bronze', 'برنز', 'CD7F32', '8C5A2B', 1),
('silver', 'نقره', 'BFC6CC', '8A949B', 2),
('gold', 'طلا', 'FFD54F', 'B8860B', 3),
('diamond', 'الماس', '5BE0E6', '1E8A99', 4),
('king', 'پادشاه', 'B388FF', '6A1B9A', 5);
+20
View File
@@ -0,0 +1,20 @@
-- شخصیت‌های آواتار (کازمتیک تصویری): کاربر می‌خرد و در کلِ بازی نمایش داده می‌شود.
-- تصویرِ هر آواتار از پنل ادمین آپلود و در /avatars/<id>.png سرو می‌شود.
CREATE TABLE IF NOT EXISTS avatars (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
price_coins INTEGER NOT NULL DEFAULT 0,
vip INTEGER NOT NULL DEFAULT 0,
sort INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
img_ext TEXT NOT NULL DEFAULT 'png' -- png یا svg (بر اساسِ فایلِ آپلودشده)
);
CREATE TABLE IF NOT EXISTS user_avatars (
user_id INTEGER NOT NULL,
avatar_id TEXT NOT NULL,
PRIMARY KEY (user_id, avatar_id)
);
-- شخصیتِ انتخابیِ کاربر؛ خالی ⇒ آواتارِ تصادفیِ پیش‌فرض (بر اساسِ seed).
ALTER TABLE users ADD COLUMN selected_avatar TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,32 @@
-- تراکنش‌ها: ثبتِ نوع محصول (kind) و قیمتِ پرداخت‌شده (price_toman) در خریدها.
-- تا پیش از این، خرید فقط بسته‌ی دریافتی (سکه/بلیط/VIP) را ثبت می‌کرد؛ نه نوع و نه
-- مبلغ را. این دو ستون برای پنلِ «تراکنش‌ها» لازم است و از این پس هنگامِ ثبتِ خرید
-- پر می‌شوند. ردیف‌های قدیمی با توجه به کاتالوگِ فعلی backfill می‌شوند.
ALTER TABLE purchases ADD COLUMN kind TEXT NOT NULL DEFAULT '';
ALTER TABLE purchases ADD COLUMN price_toman INTEGER NOT NULL DEFAULT 0;
-- Backfill نوع: اولویت سکه → بلیط → VIP → بوستر. ردیف‌های غیرِ verified هیچ
-- بسته‌ای نگرفته‌اند پس نوعشان نامعلوم می‌ماند ('' نه 'booster' تا دوپاره نشود).
UPDATE purchases SET kind = CASE
WHEN status != 'verified' THEN ''
WHEN coins > 0 THEN 'coin'
WHEN tickets > 0 THEN 'ticket'
WHEN vip_days > 0 THEN 'vip'
ELSE 'booster'
END
WHERE kind = '';
-- Backfill قیمت؛ با توجه به نوع، چون شناسه‌ی محصول بین جدول‌های کاتالوگ مشترک است
-- (مثلاً 'small' هم در coin_packages هست و هم در ticket_packages). محصولِ حذف‌شده → ۰.
UPDATE purchases SET price_toman = CASE kind
WHEN 'coin' THEN COALESCE((SELECT price_toman FROM coin_packages WHERE id = purchases.product_id), 0)
WHEN 'ticket' THEN COALESCE((SELECT price_toman FROM ticket_packages WHERE id = purchases.product_id), 0)
WHEN 'booster' THEN COALESCE((SELECT price_toman FROM boosters WHERE id = purchases.product_id), 0)
WHEN 'vip' THEN COALESCE((SELECT price_toman FROM vip_packages WHERE id = purchases.product_id), 0)
ELSE 0
END
WHERE price_toman = 0;
-- ایندکس برای فیلترِ وضعیت + آمارِ روزانه/ماهانه در پنلِ تراکنش‌ها.
CREATE INDEX IF NOT EXISTS idx_purchases_status_created ON purchases(status, created_at);
+18 -3
View File
@@ -19,8 +19,11 @@ import (
) )
const ( const (
maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست (فقط برای نمایش)
maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ
// سقفِ خواندنِ بدنه‌ی غیرِ multipart (JSON): کامل بازگردانده می‌شود تا هندلر
// همه را ببیند؛ فقط برای امنیت در برابرِ بدنه‌ی بسیار بزرگ محدود شده است.
maxCaptureBody = 4 << 20 // ۴ مگابایت
) )
// Entry یک درخواستِ ثبت‌شده را نگه می‌دارد. // Entry یک درخواستِ ثبت‌شده را نگه می‌دارد.
@@ -177,9 +180,21 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
var reqBody string var reqBody string
if req.Body != nil && req.Method != http.MethodGet { if req.Body != nil && req.Method != http.MethodGet {
b, _ := io.ReadAll(io.LimitReader(req.Body, maxReqBody)) // آپلودِ فایل (multipart) را دست نزن؛ بدنه بزرگ است و باید کامل به
// هندلر برسد (وگرنه ParseMultipartForm شکست می‌خورد ⇒ «bad upload»).
if strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data") {
reqBody = "[multipart upload]"
} else {
// بدنه را کامل بخوان و کامل بازگردان (تا هندلر همه‌ی آن را ببیند)،
// اما فقط بخشِ ابتدایی را برای نمایش در تلسکوپ نگه‌دار.
b, _ := io.ReadAll(io.LimitReader(req.Body, maxCaptureBody))
req.Body = io.NopCloser(bytes.NewReader(b)) req.Body = io.NopCloser(bytes.NewReader(b))
reqBody = maskSecrets(string(b)) n := len(b)
if n > maxReqBody {
n = maxReqBody
}
reqBody = maskSecrets(string(b[:n]))
}
} }
var uid int64 var uid int64
+2 -2
View File
@@ -42,9 +42,9 @@ func (r *Repo) FindByID(ctx context.Context, id int64) (*User, error) {
return scan(row) return scan(row)
} }
// Create کاربر جدید می‌سازد. // Create کاربر جدید می‌سازد (با هدیه‌ی خوش‌آمدِ ۱۰۰ سکه).
func (r *Repo) Create(ctx context.Context, mobile string) (*User, error) { func (r *Repo) Create(ctx context.Context, mobile string) (*User, error) {
res, err := r.db.ExecContext(ctx, `INSERT INTO users (mobile) VALUES (?)`, mobile) res, err := r.db.ExecContext(ctx, `INSERT INTO users (mobile, coins) VALUES (?, 100)`, mobile)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+85 -28
View File
@@ -36,10 +36,11 @@ func botChooseTrump(hand []game.Card) game.Suit {
return best return best
} }
// botPlay کارتی که بات باید بازی کند را برمی‌گرداند. // botPlay کارتی که بات باید بازی کند را برمی‌گرداند. seen حافظه‌ی کارت‌های
func botPlay(hand []game.Card, trick []game.TrickCard, trump, lead game.Suit) game.Card { // بازی‌شده‌ی این هَند است (کارت‌شماری برای تصمیمِ بهتر).
func botPlay(hand []game.Card, trick []game.TrickCard, trump, lead game.Suit, seen [4]uint16) game.Card {
if len(trick) == 0 { if len(trick) == 0 {
return botLead(hand, trump) return botLead(hand, trump, seen)
} }
best := trickBest(trick, trump, lead) best := trickBest(trick, trump, lead)
@@ -72,41 +73,97 @@ func botPlay(hand []game.Card, trick []game.TrickCard, trump, lead game.Suit) ga
return lowestDiscard(hand, trump) // حکمت هم نمی‌برد ⇒ دورریز return lowestDiscard(hand, trump) // حکمت هم نمی‌برد ⇒ دورریز
} }
// botLead راهبردِ رهبریِ دست: آسِ غیرِحکم را نقد کن؛ وگرنه از بلندترین خالِ // botLead راهبردِ رهبریِ دست با کارت‌شماری:
// غیرِحکم کارتِ کم بریز؛ اگر فقط حکم داری، بالاترین حکم را بزن تا حکم‌ها را بکشی. // ۱. کارتِ «برنده‌ی قطعی» (بالاترین کارتِ باقی‌مانده‌ی یک خالِ غیرِحکم) را نقد کن.
func botLead(hand []game.Card, trump game.Suit) game.Card { // ۲. اگر کنترلِ حکم داری (بالاترین حکمِ باقی‌مانده + ۳ حکم یا بیشتر) حکم بکِش.
// آسِ غیرِحکم (برنده‌ی تقریباً قطعی). // ۳. وگرنه از کوتاه‌ترین خالِ غیرِحکم کارتِ کم بریز تا زود void شوی و بعداً بِبُری
var ace *game.Card // (اگر حکم داری)؛ در نبودِ حکم از بلندترین خال کارتِ کم.
for i := range hand { func botLead(hand []game.Card, trump game.Suit, seen [4]uint16) game.Card {
if hand[i].Suit != trump && hand[i].Rank == game.Ace { counts := suitCounts(hand)
c := hand[i]
ace = &c
break
}
}
if ace != nil {
return *ace
}
// بلندترین خالِ غیرِحکم. // ۱. برنده‌ی قطعی: کارتی از خالِ غیرِحکم که هیچ کارتِ بالاتری از آن بیرون نمانده.
var counts [4]int var boss *game.Card
for _, c := range hand { for i := range hand {
counts[c.Suit]++ c := hand[i]
}
bestSuit, bestLen := game.Suit(255), -1
for _, c := range hand {
if c.Suit == trump { if c.Suit == trump {
continue continue
} }
if counts[c.Suit] > bestLen { if higherOut(seen, hand, c.Suit, c.Rank) == 0 {
bestLen, bestSuit = counts[c.Suit], c.Suit if boss == nil || counts[c.Suit] > counts[boss.Suit] ||
(counts[c.Suit] == counts[boss.Suit] && c.Rank > boss.Rank) {
cc := c
boss = &cc
}
}
}
if boss != nil {
return *boss
}
// ۲. کشیدنِ حکم: اگر بالاترین حکمِ باقی‌مانده دستِ ماست و حکمِ زیاد داریم.
trumps := cardsOfSuit(hand, trump)
if len(trumps) >= 3 {
top := highest(trumps)
if higherOut(seen, hand, trump, top.Rank) == 0 {
return top
}
}
// ۳. کوتاه‌ترین خالِ غیرِحکم (برای void شدن) اگر حکم داریم؛ وگرنه بلندترین.
haveTrump := len(trumps) > 0
bestSuit, bestLen := game.Suit(255), -1
for s := game.Suit(0); s < 4; s++ {
if s == trump || counts[s] == 0 {
continue
}
better := false
if bestLen < 0 {
better = true
} else if haveTrump {
better = counts[s] < bestLen // کوتاه‌ترین
} else {
better = counts[s] > bestLen // بلندترین
}
if better {
bestLen, bestSuit = counts[s], s
} }
} }
if bestSuit != game.Suit(255) { if bestSuit != game.Suit(255) {
return lowest(cardsOfSuit(hand, bestSuit)) return lowest(cardsOfSuit(hand, bestSuit))
} }
// فقط حکم مانده ⇒ بالاترین حکم را بزن. // فقط حکم مانده ⇒ بالاترین حکم را بزن.
return highest(cardsOfSuit(hand, trump)) return highest(trumps)
}
// suitCounts تعدادِ کارتِ هر خال در دست.
func suitCounts(hand []game.Card) [4]int {
var c [4]int
for _, x := range hand {
c[x.Suit]++
}
return c
}
// higherOut تعدادِ کارت‌های هم‌خالِ بالاتر از rank که هنوز بازی نشده‌اند و در دستِ
// خودِ بات هم نیستند (یعنی دستِ حریف/شریک‌اند). ۰ یعنی این کارت «برنده‌ی خال» است.
func higherOut(seen [4]uint16, hand []game.Card, suit game.Suit, rank game.Rank) int {
n := 0
for r := rank + 1; r <= game.Ace; r++ {
if seen[suit]&(1<<(r-2)) != 0 {
continue // بازی شده
}
held := false
for _, c := range hand {
if c.Suit == suit && c.Rank == r {
held = true
break
}
}
if !held {
n++
}
}
return n
} }
// --- کمکی‌ها --- // --- کمکی‌ها ---
+18 -6
View File
@@ -12,7 +12,7 @@ func TestBotWinsWhenOpponentLeads(t *testing.T) {
// حریف با شاهِ پیک رهبری کرده؛ بات باید با آسِ پیک (کمترین کارتِ برنده) ببرد. // حریف با شاهِ پیک رهبری کرده؛ بات باید با آسِ پیک (کمترین کارتِ برنده) ببرد.
hand := []game.Card{card(game.Spades, game.Ace), card(game.Spades, 3), card(game.Clubs, 5)} hand := []game.Card{card(game.Spades, game.Ace), card(game.Spades, 3), card(game.Clubs, 5)}
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.King)}} trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.King)}}
got := botPlay(hand, trick, game.Hearts, game.Spades) got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
if got != card(game.Spades, game.Ace) { if got != card(game.Spades, game.Ace) {
t.Errorf("expected S-A to win, got %v", got) t.Errorf("expected S-A to win, got %v", got)
} }
@@ -25,7 +25,7 @@ func TestBotSavesCardWhenPartnerWins(t *testing.T) {
{Seat: 0, Card: card(game.Spades, game.Ace)}, {Seat: 0, Card: card(game.Spades, game.Ace)},
{Seat: 1, Card: card(game.Spades, 2)}, {Seat: 1, Card: card(game.Spades, 2)},
} }
got := botPlay(hand, trick, game.Hearts, game.Spades) got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
if got != card(game.Spades, 5) { if got != card(game.Spades, 5) {
t.Errorf("expected low S-5 (save the King), got %v", got) t.Errorf("expected low S-5 (save the King), got %v", got)
} }
@@ -35,7 +35,7 @@ func TestBotTrumpsOpponent(t *testing.T) {
// بات پیک ندارد و حریف با آسِ پیک می‌برد ⇒ باید با کمترین حکم بِبُرد. // بات پیک ندارد و حریف با آسِ پیک می‌برد ⇒ باید با کمترین حکم بِبُرد.
hand := []game.Card{card(game.Hearts, 2), card(game.Hearts, game.King), card(game.Clubs, 9)} hand := []game.Card{card(game.Hearts, 2), card(game.Hearts, game.King), card(game.Clubs, 9)}
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.Ace)}} trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.Ace)}}
got := botPlay(hand, trick, game.Hearts, game.Spades) got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
if got != card(game.Hearts, 2) { if got != card(game.Hearts, 2) {
t.Errorf("expected lowest trump H-2, got %v", got) t.Errorf("expected lowest trump H-2, got %v", got)
} }
@@ -48,7 +48,7 @@ func TestBotDiscardsWhenPartnerWinsAndVoid(t *testing.T) {
{Seat: 0, Card: card(game.Spades, game.Ace)}, {Seat: 0, Card: card(game.Spades, game.Ace)},
{Seat: 1, Card: card(game.Spades, 2)}, {Seat: 1, Card: card(game.Spades, 2)},
} }
got := botPlay(hand, trick, game.Hearts, game.Spades) got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
if got != card(game.Clubs, 4) { if got != card(game.Clubs, 4) {
t.Errorf("expected low discard C-4 (keep trump), got %v", got) t.Errorf("expected low discard C-4 (keep trump), got %v", got)
} }
@@ -57,17 +57,29 @@ func TestBotDiscardsWhenPartnerWinsAndVoid(t *testing.T) {
func TestBotLeadsNonTrumpAce(t *testing.T) { func TestBotLeadsNonTrumpAce(t *testing.T) {
// در رهبری، آسِ غیرِحکم را نقد کن. // در رهبری، آسِ غیرِحکم را نقد کن.
hand := []game.Card{card(game.Clubs, game.Ace), card(game.Diamonds, 7), card(game.Hearts, game.King)} hand := []game.Card{card(game.Clubs, game.Ace), card(game.Diamonds, 7), card(game.Hearts, game.King)}
got := botPlay(hand, nil, game.Hearts, 0) got := botPlay(hand, nil, game.Hearts, 0, [4]uint16{})
if got != card(game.Clubs, game.Ace) { if got != card(game.Clubs, game.Ace) {
t.Errorf("expected to lead C-A, got %v", got) t.Errorf("expected to lead C-A, got %v", got)
} }
} }
func TestBotLeadsBossWhenAceGone(t *testing.T) {
// آسِ گشنیز قبلاً بازی شده ⇒ شاهِ گشنیز حالا برنده‌ی قطعی است؛ باید آن را نقد کند
// (نه کارتِ کم). این رفتار فقط با کارت‌شماری ممکن است.
hand := []game.Card{card(game.Clubs, game.King), card(game.Clubs, 4), card(game.Diamonds, 2)}
var seen [4]uint16
seen[game.Clubs] |= 1 << (game.Ace - 2) // آسِ گشنیز رو شده
got := botPlay(hand, nil, game.Hearts, 0, seen)
if got != card(game.Clubs, game.King) {
t.Errorf("expected to cash boss C-K (Ace gone), got %v", got)
}
}
func TestBotCannotBeatKeepsLow(t *testing.T) { func TestBotCannotBeatKeepsLow(t *testing.T) {
// حریف با آسِ پیک می‌برد و بات فقط پیکِ پایین دارد ⇒ کمترین را بریزد. // حریف با آسِ پیک می‌برد و بات فقط پیکِ پایین دارد ⇒ کمترین را بریزد.
hand := []game.Card{card(game.Spades, 4), card(game.Spades, 9)} hand := []game.Card{card(game.Spades, 4), card(game.Spades, 9)}
trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.Ace)}} trick := []game.TrickCard{{Seat: 0, Card: card(game.Spades, game.Ace)}}
got := botPlay(hand, trick, game.Hearts, game.Spades) got := botPlay(hand, trick, game.Hearts, game.Spades, [4]uint16{})
if got != card(game.Spades, 4) { if got != card(game.Spades, 4) {
t.Errorf("expected low S-4, got %v", got) t.Errorf("expected low S-4, got %v", got)
} }
+20 -3
View File
@@ -14,7 +14,9 @@ const (
pongWait = 60 * time.Second pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10 pingPeriod = (pongWait * 9) / 10
maxMessageSize = 4096 maxMessageSize = 4096
sendBuffer = 32 // بافرِ بزرگ‌تر تا در سرورِ تک‌هسته‌ای، هجومِ کوتاهِ وضعیت‌ها (پخشِ کارت + چند حرکتِ
// بات) روی کلاینتِ کند باعثِ دورریختنِ پیام نشود.
sendBuffer = 256
) )
// Client یک اتصال WebSocket یک بازیکن. // Client یک اتصال WebSocket یک بازیکن.
@@ -96,14 +98,29 @@ func (c *Client) writePump() {
} }
} }
// trySend ارسال غیرمسدودکننده؛ اگر اتصال بسته یا بافر پر بود پیام را می‌اندازد. // trySend ارسالِ غیرمسدودکننده. اگر بافر پر باشد، به‌جای دورریختنِ پیامِ جدید،
// «قدیمی‌ترین» پیام دور ریخته می‌شود تا کلاینت روی جدیدترین وضعیت همگرا بماند
// (هر وضعیتِ کامل، وضعیتِ قبلی را باطل می‌کند). قبلاً جدیدترین دور ریخته می‌شد و
// کلاینت روی حالتِ کهنه گیر می‌کرد: کارت‌های دست دیده نمی‌شد ولی بات‌ها بازی می‌کردند.
// send هیچ‌گاه close نمی‌شود؛ پس از done صرفاً پیام دور ریخته می‌شود (بدون panic). // send هیچ‌گاه close نمی‌شود؛ پس از done صرفاً پیام دور ریخته می‌شود (بدون panic).
func (c *Client) trySend(b []byte) { func (c *Client) trySend(b []byte) {
for {
select { select {
case <-c.done: case <-c.done:
return
case c.send <- b: case c.send <- b:
return
default: default:
slog.Warn("client send buffer full, dropping message", "user", c.UserID) // بافر پر است: یک پیامِ قدیمی را خالی کن و دوباره تلاش کن.
select {
case <-c.send:
slog.Warn("client send buffer full, dropping oldest", "user", c.UserID)
case <-c.done:
return
default:
// بافر بین دو تلاش خالی شد (تولیدکننده‌ی دیگر آن را خالی کرد)؛ ادامه بده.
}
}
} }
} }
+3 -1
View File
@@ -391,10 +391,12 @@ func (h *Hub) removeFromTable(t *pendingTable, c *Client) {
func (h *Hub) broadcastLobby(t *pendingTable) { func (h *Hub) broadcastLobby(t *pendingTable) {
players := make([]lobbyPlayer, len(t.clients)) players := make([]lobbyPlayer, len(t.clients))
for i, cl := range t.clients { for i, cl := range t.clients {
_, _, avatar := h.settler.PlayerProfile(cl.UserID) _, _, avatar, frame, avatarImg := h.settler.PlayerProfile(cl.UserID)
players[i] = lobbyPlayer{ players[i] = lobbyPlayer{
Name: cl.Name, Name: cl.Name,
Avatar: avatar, Avatar: avatar,
Frame: frame,
AvatarImg: avatarImg,
Host: cl.UserID == t.host, Host: cl.UserID == t.host,
Seat: t.seatOf(i), Seat: t.seatOf(i),
} }
+4
View File
@@ -29,6 +29,8 @@ type chatMsg struct {
type lobbyPlayer struct { type lobbyPlayer struct {
Name string `json:"name"` Name string `json:"name"`
Avatar string `json:"avatar"` // seed آواتارِ کاربر Avatar string `json:"avatar"` // seed آواتارِ کاربر
Frame string `json:"frame"` // قابِ آواتارِ انتخابی
AvatarImg string `json:"avatar_img"` // شخصیتِ آواتارِ انتخابی (خالی ⇒ تصادفی)
Host bool `json:"host"` Host bool `json:"host"`
Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳) Seat int `json:"seat"` // جایگاهِ مطلق (۰..۳)
} }
@@ -66,6 +68,8 @@ type PlayerInfo struct {
Coins int64 `json:"coins"` Coins int64 `json:"coins"`
RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه) RankTier string `json:"rank_tier"` // bronze..king (برای نشانِ رتبه)
Avatar string `json:"avatar"` // seed آواتارِ کاربر Avatar string `json:"avatar"` // seed آواتارِ کاربر
Frame string `json:"frame"` // قابِ آواتارِ انتخابی (کازمتیک)
AvatarImg string `json:"avatar_img"` // شخصیتِ آواتارِ انتخابی (خالی ⇒ تصادفی)
} }
// matchedMsg به کلاینت پس از تشکیل میز. // matchedMsg به کلاینت پس از تشکیل میز.
+12 -3
View File
@@ -40,6 +40,8 @@ type seatInfo struct {
coins int64 // برای نمایش سرِ میز coins int64 // برای نمایش سرِ میز
rankTier string // نشانِ رتبه برای نمایش سرِ میز rankTier string // نشانِ رتبه برای نمایش سرِ میز
avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز) avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز)
frame string // قابِ آواتارِ انتخابیِ کاربر (کازمتیک)
avatarImg string // شناسه‌ی شخصیتِ آواتارِ انتخابی (خالی ⇒ آواتارِ تصادفی)
} }
// auto مشخص می‌کند جایگاه باید خودکار بازی شود (بات یا قطع‌شده). // auto مشخص می‌کند جایگاه باید خودکار بازی شود (بات یا قطع‌شده).
@@ -70,6 +72,9 @@ type Room struct {
// graceUntil: تا این لحظه هیچ حرکتِ خودکاری انجام نمی‌شود (هماهنگ با انیمیشن‌های کلاینت). // graceUntil: تا این لحظه هیچ حرکتِ خودکاری انجام نمی‌شود (هماهنگ با انیمیشن‌های کلاینت).
graceUntil time.Time graceUntil time.Time
chatAt [4]time.Time // آخرین زمانِ چتِ هر جایگاه (ضدِ اسپم) chatAt [4]time.Time // آخرین زمانِ چتِ هر جایگاه (ضدِ اسپم)
// seen: حافظه‌ی کارت‌های بازی‌شده در هَندِ جاری (برای هوشِ باتِ کارت‌شمار).
// بیتِ (rank-2) از seen[suit] یعنی آن کارت رو شده. هر هَند ریست می‌شود.
seen [4]uint16
} }
func newRoom(id string, seats [4]*seatInfo, hub *Hub) *Room { func newRoom(id string, seats [4]*seatInfo, hub *Hub) *Room {
@@ -144,7 +149,7 @@ func (r *Room) start() {
if s.isBot || s.userID == 0 { if s.isBot || s.userID == 0 {
continue continue
} }
s.coins, s.rankTier, s.avatar = r.settler.PlayerProfile(s.userID) s.coins, s.rankTier, s.avatar, s.frame, s.avatarImg = r.settler.PlayerProfile(s.userID)
} }
for seat, s := range r.seats { for seat, s := range r.seats {
if s.client != nil { if s.client != nil {
@@ -280,6 +285,7 @@ func (r *Room) handleInput(act roomAction) {
r.sendError(act.seat, err.Error()) r.sendError(act.seat, err.Error())
return return
} }
r.seen = [4]uint16{} // شروعِ فازِ بازیِ هَند ⇒ ریستِ حافظه‌ی کارت‌ها
r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیه‌ی کارت‌ها r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیه‌ی کارت‌ها
r.react() r.react()
case "reshuffle": case "reshuffle":
@@ -340,6 +346,7 @@ func (r *Room) autoChooseTrump(seat int) {
return return
} }
_ = r.game.ChooseTrump(seat, botChooseTrump(hand)) _ = r.game.ChooseTrump(seat, botChooseTrump(hand))
r.seen = [4]uint16{} // شروعِ فازِ بازیِ هَند ⇒ ریستِ حافظه‌ی کارت‌ها
r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیه‌ی کارت‌ها r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیه‌ی کارت‌ها
} }
@@ -349,7 +356,7 @@ func (r *Room) autoPlayCard(seat int) {
if len(hand) == 0 { if len(hand) == 0 {
return return
} }
choice := botPlay(hand, r.game.Trick, r.game.Trump, r.game.LeadSuit) choice := botPlay(hand, r.game.Trick, r.game.Trump, r.game.LeadSuit, r.seen)
_ = r.playCard(seat, choice) _ = r.playCard(seat, choice)
} }
@@ -359,6 +366,7 @@ func (r *Room) playCard(seat int, card game.Card) error {
if err := r.game.PlayCard(seat, card); err != nil { if err := r.game.PlayCard(seat, card); err != nil {
return err return err
} }
r.seen[card.Suit] |= 1 << (card.Rank - 2) // ثبت در حافظه‌ی کارت‌شمارِ بات
// بُرِش: کارتِ غیرِاولِ دست، از خالِ حکم، در حالی که خالِ زمینه حکم نبوده // بُرِش: کارتِ غیرِاولِ دست، از خالِ حکم، در حالی که خالِ زمینه حکم نبوده
// ⇒ بازیکن خالِ زمینه را نداشته و با حکم بریده است. // ⇒ بازیکن خالِ زمینه را نداشته و با حکم بریده است.
if !wasFirst && card.Suit == r.game.Trump && r.game.LeadSuit != r.game.Trump { if !wasFirst && card.Suit == r.game.Trump && r.game.LeadSuit != r.game.Trump {
@@ -541,7 +549,8 @@ func (r *Room) playersInfo() []PlayerInfo {
out[i] = PlayerInfo{ out[i] = PlayerInfo{
Seat: i, Name: s.name, Bot: s.isBot, Seat: i, Name: s.name, Bot: s.isBot,
Connected: s.isBot || s.connected, Connected: s.isBot || s.connected,
Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar, Frame: s.frame,
AvatarImg: s.avatarImg,
} }
} }
return out return out
+6 -3
View File
@@ -12,8 +12,9 @@ type Settler interface {
RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند
RecordCut(userID int64) // بُرِش با حکم RecordCut(userID int64) // بُرِش با حکم
RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت) RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت)
// PlayerProfile سکه، نشانِ رتبه و seedِ آواتارِ بازیکن را برای نمایش سرِ میز برمی‌گرداند. // PlayerProfile سکه، نشانِ رتبه، آواتار، قاب و شخصیتِ آواتارِ انتخابیِ بازیکن را
PlayerProfile(userID int64) (coins int64, rankTier string, avatar string) // برای نمایش سرِ میز برمی‌گرداند (avatarImg خالی ⇒ آواتارِ تصادفیِ پیش‌فرض).
PlayerProfile(userID int64) (coins int64, rankTier, avatar, frame, avatarImg string)
// ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف می‌کند. // ChargeTicket یک بلیت برای بُرِ مجدد در فازِ انتخابِ حکم مصرف می‌کند.
ChargeTicket(userID int64) error ChargeTicket(userID int64) error
// ChargeCoins/AwardCoins برای شرطِ سکه‌ایِ میزِ خصوصی (pot). Charge در صورتِ // ChargeCoins/AwardCoins برای شرطِ سکه‌ایِ میزِ خصوصی (pot). Charge در صورتِ
@@ -38,7 +39,9 @@ func (noopSettler) RecordGame(string, string, int, bool) {}
func (noopSettler) RecordHand(int64, bool, bool, bool) {} func (noopSettler) RecordHand(int64, bool, bool, bool) {}
func (noopSettler) RecordCut(int64) {} func (noopSettler) RecordCut(int64) {}
func (noopSettler) RecordGameResult(int64, bool) {} func (noopSettler) RecordGameResult(int64, bool) {}
func (noopSettler) PlayerProfile(int64) (int64, string, string) { return 0, "", "" } func (noopSettler) PlayerProfile(int64) (int64, string, string, string, string) {
return 0, "", "", "", ""
}
func (noopSettler) ChargeTicket(int64) error { return nil } func (noopSettler) ChargeTicket(int64) error { return nil }
func (noopSettler) ChargeCoins(int64, int64, string) error { return nil } func (noopSettler) ChargeCoins(int64, int64, string) error { return nil }
func (noopSettler) AwardCoins(int64, int64, string) {} func (noopSettler) AwardCoins(int64, int64, string) {}
+3 -1
View File
@@ -481,7 +481,9 @@ func (s *recordingSettler) RecordGameResult(int64, bool) {
s.mu.Unlock() s.mu.Unlock()
} }
func (s *recordingSettler) PlayerProfile(int64) (int64, string, string) { return 0, "", "" } func (s *recordingSettler) PlayerProfile(int64) (int64, string, string, string, string) {
return 0, "", "", "", ""
}
func (s *recordingSettler) ChargeTicket(int64) error { return nil } func (s *recordingSettler) ChargeTicket(int64) error { return nil }
func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil } func (s *recordingSettler) ChargeCoins(int64, int64, string) error { return nil }
func (s *recordingSettler) AwardCoins(int64, int64, string) {} func (s *recordingSettler) AwardCoins(int64, int64, string) {}