From 390ef96e7e458b4c5507f707d897adfb0ab709ba Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Mon, 15 Jun 2026 16:53:25 +0330 Subject: [PATCH] init --- .env.example | 16 + .gitignore | 5 + Dockerfile | 18 + README.md | 138 +++++++ cmd/server/main.go | 111 ++++++ cmd/server/settler.go | 48 +++ go.mod | 26 ++ go.sum | 55 +++ internal/auth/handler.go | 160 ++++++++ internal/auth/jwt.go | 90 +++++ internal/auth/kavenegar.go | 61 +++ internal/auth/otp.go | 57 +++ internal/config/config.go | 70 ++++ internal/economy/catalog.go | 175 +++++++++ internal/economy/economy.go | 407 +++++++++++++++++++ internal/economy/economy_test.go | 198 ++++++++++ internal/economy/handler.go | 186 +++++++++ internal/economy/verify.go | 139 +++++++ internal/economy/wallet.go | 74 ++++ internal/game/deck.go | 129 ++++++ internal/game/hokm.go | 333 ++++++++++++++++ internal/game/hokm_test.go | 128 ++++++ internal/httpx/httpx.go | 53 +++ internal/store/db.go | 97 +++++ internal/store/db_test.go | 40 ++ internal/store/migrations/001_init.sql | 43 ++ internal/store/migrations/002_economy.sql | 50 +++ internal/user/user.go | 76 ++++ internal/ws/client.go | 112 ++++++ internal/ws/hub.go | 306 +++++++++++++++ internal/ws/protocol.go | 131 ++++++ internal/ws/room.go | 454 +++++++++++++++++++++ internal/ws/settler.go | 18 + internal/ws/ws_test.go | 459 ++++++++++++++++++++++ 34 files changed, 4463 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 cmd/server/settler.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/handler.go create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/kavenegar.go create mode 100644 internal/auth/otp.go create mode 100644 internal/config/config.go create mode 100644 internal/economy/catalog.go create mode 100644 internal/economy/economy.go create mode 100644 internal/economy/economy_test.go create mode 100644 internal/economy/handler.go create mode 100644 internal/economy/verify.go create mode 100644 internal/economy/wallet.go create mode 100644 internal/game/deck.go create mode 100644 internal/game/hokm.go create mode 100644 internal/game/hokm_test.go create mode 100644 internal/httpx/httpx.go create mode 100644 internal/store/db.go create mode 100644 internal/store/db_test.go create mode 100644 internal/store/migrations/001_init.sql create mode 100644 internal/store/migrations/002_economy.sql create mode 100644 internal/user/user.go create mode 100644 internal/ws/client.go create mode 100644 internal/ws/hub.go create mode 100644 internal/ws/protocol.go create mode 100644 internal/ws/room.go create mode 100644 internal/ws/settler.go create mode 100644 internal/ws/ws_test.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7777574 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# آدرس گوش‌دادن سرور +ADDR=:8080 + +# مسیر فایل دیتابیس SQLite +DB_PATH=hakemsho.db + +# کلید امضای JWT — حتماً در پروداکشن عوض شود +JWT_SECRET=change-me-in-production + +# کاوه‌نگار +KAVE_API_KEY= +KAVE_TEMPLATE=loginotp + +# ادمین برای تست بدون SMS (اختیاری) +ADMIN_MOBILE= +ADMIN_OTP= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b606b8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/server +*.db +*.db-shm +*.db-wal +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9aa0b54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# --- مرحله build --- +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# باینری استاتیک (modernc.org/sqlite بدون cgo کار می‌کند) +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server + +# --- مرحله اجرا (کمترین حجم) --- +FROM gcr.io/distroless/static-debian12 +WORKDIR /app +COPY --from=build /out/server /app/server +ENV ADDR=:8080 DB_PATH=/data/hakemsho.db +EXPOSE 8080 +VOLUME ["/data"] +USER nonroot:nonroot +ENTRYPOINT ["/app/server"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a005b43 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# حکم‌شو — بک‌اند (Go) + +سرور بازی حکم. فازهای انجام‌شده: **اسکلت + احراز هویت OTP (کاوه‌نگار) + JWT**، **موتور حکم + لایه WebSocket (matchmaking + اجرای میز به‌صورت goroutine)**، **مقاوم‌سازی realtime (reconnect + timeout نوبت + بات)** و **اقتصاد و فروشگاه (کیف‌پول، اسکین کارت، سکه روزانه، تبلیغ rewarded، خرید IAP، تسویه‌ی داخل بازی)**. + +## اجرا (محلی) + +```bash +cp .env.example .env # و مقادیر را پر کنید +# یا مستقیم با env: +ADMIN_MOBILE=09120000000 ADMIN_OTP=11111 go run ./cmd/server +``` + +سرور روی `:8080` بالا می‌آید. + +> به‌خاطر محدودیت شبکه برای دانلود ماژول‌ها، اگر لازم شد از میرور استفاده کنید: +> `go env -w GOPROXY=https://proxy.golang.org,direct` (همین الان کار کرد) یا یک میرور داخلی معتبر. + +## متغیرهای محیطی + +| متغیر | پیش‌فرض | توضیح | +|-------|---------|-------| +| `ADDR` | `:8080` | آدرس گوش‌دادن | +| `DB_PATH` | `hakemsho.db` | مسیر فایل SQLite | +| `JWT_SECRET` | `change-me…` | کلید امضای JWT (در پروداکشن عوض شود) | +| `KAVE_API_KEY` | — | کلید کاوه‌نگار | +| `KAVE_TEMPLATE` | `loginotp` | تمپلیت verify | +| `ADMIN_MOBILE` / `ADMIN_OTP` | — | ورود تستی بدون SMS | + +## endpointها (فاز ۱) + +``` +GET /health +POST /api/auth/login-otp { mobile, fcm_token? } → { message: "otp sent" } +POST /api/auth/check-otp { mobile, token } → { user, token } +GET /api/me (Bearer JWT) → { user } +``` + +- throttle: حداکثر **۳ بار در دقیقه** روی `login-otp` به ازای هر شماره. +- کد OTP **۵ رقمی**، اعتبار **۱۵ دقیقه**، پس از مصرف حذف می‌شود. +- کاوه‌نگار از `verify/lookup.json` با تمپلیت `loginotp` استفاده می‌کند (مطابق پروژه approagency). + +## WebSocket بازی (فاز ۲) + +اتصال: `GET /ws?token=` (یا هدر `Authorization: Bearer `). + +جریان کلی: اتصال → `join_queue` → وقتی ۴ نفر در صف شد میز ساخته می‌شود → `matched` + `state` → حاکم `choose_trump` → بازیکنان به‌نوبت `play_card` → `hand_over` / `game_over`. + +پیام‌های **کلاینت → سرور** (JSON): +```json +{ "type": "join_queue", "mode": "normal" } +{ "type": "choose_trump", "suit": "hearts" } // فقط حاکم +{ "type": "play_card", "card": "AS" } +{ "type": "leave" } +``` + +پیام‌های **سرور → کلاینت**: +```json +{ "type": "matched", "room": "r1", "seat": 0, "players": [...] } +{ "type": "state", "phase": "playing", "your_seat": 0, "hakem": 0, "turn": 1, + "trump": "hearts", "your_hand": ["AS","10H"], "hand_counts": [13,13,13,13], + "trick": [{"seat":0,"card":"AS"}], "lead_suit": "spades", + "tricks_won": [0,0], "scores": [0,0], "target_score": 7, "players": [...] } +{ "type": "hand_over", "winner_team": 0, "kot": false, "points": 1, "scores": [1,0] } +{ "type": "game_over", "winner_team": 0, "scores": [7,5] } +{ "type": "player_disconnected", "seat": 2 } // قطع موقت؛ جایگاه برای بازگشت باز است +{ "type": "player_reconnected", "seat": 2 } +{ "type": "player_left", "seat": 2 } // خروج دائمی؛ جایگاه به بات تبدیل شد +{ "type": "error", "message": "..." } +``` + +در پیام `state`، هر بازیکن در `players` فیلدهای `bot` و `connected` دارد تا UI وضعیت میز را نشان دهد. + +## مقاوم‌سازی realtime (فاز ۵) + +- **timeout نوبت**: اگر بازیکن در مهلت نوبت (پیش‌فرض ۲۰s) حرکت نکند، سرور یک حرکت مجاز خودکار می‌زند (انتخاب حکمِ پرتعدادترین خال؛ پایین‌ترین کارتِ مجاز). همین مسیر، بات‌ها و بازیکنانِ قطع‌شده را هم اداره می‌کند (با تأخیر کوتاه‌تر). +- **reconnect**: قطع اتصال میز را خاتمه نمی‌دهد؛ جایگاه باز می‌ماند و خودکار بازی می‌شود. کاربر با همان توکن دوباره وصل می‌شود (هاب نگاشت `userID → میز/جایگاه` نگه می‌دارد) و `matched`+`state` می‌گیرد. اگر هیچ انسانِ متصلی در میز نماند، میز بسته می‌شود. +- **بات / پرکردن میز**: اگر تا مهلت matchmaking (پیش‌فرض ۱۲s) چهار انسان جمع نشد، جایگاه‌های خالی با بات پر می‌شوند تا بازی شروع شود. `leave` هم جایگاه را به بات تبدیل می‌کند تا بقیه ادامه دهند. +- **ایمنی همزمانی**: `seatInfo` فقط متعلق به goroutine میز است؛ هاب پس از ساخت میز هرگز به آن دست نمی‌زند و همه‌چیز از طریق action/endInfo رد و بدل می‌شود. تایمرها با شمارنده‌ی نسل (generation) از اجرای کهنه مصون‌اند. همه‌ی تست‌ها با `-race` سبزند. + +نکات معماری: +- هر **میز یک goroutine** است و تنها نویسنده‌ی state بازی (الگوی actor، بدون قفل). +- **هاب** تنها هماهنگ‌کننده است (اتصال‌ها، صف، مسیریابی) و تنها نویسنده‌ی state خودش. +- نمای هر بازیکن فقط **کارت‌های خودش** را دارد؛ از بقیه فقط «تعداد کارت». +- منبع حقیقت سرور است: نوبت، follow-suit و مالکیت کارت سمت سرور اعتبارسنجی می‌شوند. +- تست‌ها: بازی کامل ۴ نفره روی WebSocket واقعی و سناریوی قطع‌اتصال، هر دو با `-race`. + +## اقتصاد و فروشگاه + +endpointها (همه پشت JWT): +``` +GET /api/wallet → سکه، بلیط، XP، سطح، جام، VIP، کارت انتخابی +GET /api/shop → کاتالوگ + کارت‌های متعلق به کاربر + کارت انتخابی +POST /api/shop/buy-card { card_id } خرید اسکین کارت با سکه +POST /api/shop/select-card { card_id } انتخاب اسکین +POST /api/shop/purchase { store, kind, product_id, token } تأیید خرید IAP +POST /api/rewards/daily سکه روزانه (هر ۲۴ ساعت) +POST /api/rewards/ad { token } سکه رایگان پس از تبلیغ rewarded +``` + +مدل اقتصادی (کاتالوگ در [internal/economy/catalog.go](internal/economy/catalog.go)، برگرفته از اپ مرجع): +- **بسته‌های سکه** (پول واقعی، ۶ سطح با بونوس و VIP هدیه)، **بسته‌های بلیط**، **اسکین کارت** (با سکه)، **بوستر XP**، **انواع میز** (ورودی/جایزه/XP/جام). +- **سکه روزانه** و **سکه رایگان ۵۰تایی** فقط با دیدن کامل تبلیغ (تأیید سمت‌سرور؛ ضد تکرار با token یکتا + سقف روزانه). +- **VIP**: ۱۰٪ سکه‌ی بیشتر در هر خرید. + +پرداخت و تبلیغ پشت اینترفیس‌اند (`AdVerifier`, `IAPVerifier` در [internal/economy/verify.go](internal/economy/verify.go)): +- تبلیغ: **تپسل** (آداپتر آماده) · IAP: **کافه‌بازار و مایکت** (آداپتر آماده). +- فعلاً تأییدکننده‌ی **توسعه** (`Dev*Verifier`) وصل است؛ با تنظیم creds به آداپتر واقعی سوییچ می‌شود. + +### تسویه‌ی داخل بازی +`join_queue` یک `tier` می‌گیرد (مثل `beginner`/`pro`). هاب هنگام ورود به صف **ورودی** را کسر می‌کند؛ در پایان بازی **جایزه/XP/جام** به برندگانِ انسان واریز و بازی در `game_history` ثبت می‌شود. اگر بازی پیش از پایان لغو شود، ورودی **بازگردانده** می‌شود (به‌جز کسی که داوطلبانه `leave` کرده). تسویه پشت اینترفیس `ws.Settler` است (لایه ws از سکه بی‌خبر می‌ماند). + +## ساخت باینری / Docker + +```bash +CGO_ENABLED=0 go build -ldflags="-s -w" -o server ./cmd/server # باینری استاتیک +docker build -t hakemsho-backend . # image مینیمال (distroless) +``` + +## ساختار + +``` +cmd/server نقطه ورود و wiring +internal/config خواندن env +internal/store SQLite + migrations (embed) +internal/user مدل و ریپوی کاربر +internal/auth OTP، JWT، کاوه‌نگار، handlerها +internal/httpx پاسخ JSON و throttle +internal/game موتور حکم (کارت، قوانین، state machine) + تست‌ها +internal/ws هاب، میز (room)، کلاینت، پروتکل WebSocket، تسویه + تست‌ها +internal/economy کیف‌پول، کاتالوگ، کارت، روزانه/تبلیغ، IAP، تسویه + تست‌ها +``` + +## قدم بعدی + +- وصل‌کردن آداپتر واقعی **تپسل** و **بازار/مایکت** با creds (الان استاب توسعه است). +- شروع **فرانت Flutter/Flame** (لاگین/OTP → لابی/فروشگاه → میز بازی). +- بهبود هوش بات (فعلاً حرکت مجاز ساده می‌زند). +- به [../BACKEND_PLAN.md](../BACKEND_PLAN.md) رجوع کنید. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..1318753 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "log/slog" + "net/http" + "os" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + "hakemsho/internal/auth" + "hakemsho/internal/config" + "hakemsho/internal/economy" + "hakemsho/internal/httpx" + "hakemsho/internal/store" + "hakemsho/internal/user" + "hakemsho/internal/ws" +) + +func main() { + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) + cfg := config.Load() + + st, err := store.Open(cfg.DBPath) + if err != nil { + slog.Error("open db", "err", err) + os.Exit(1) + } + defer st.Close() + + // لایه‌های دامنه + users := user.NewRepo(st.DB) + otp := auth.NewOTPStore(st.DB, cfg.OTPTTL) + kave := auth.NewKavenegar(cfg.KaveAPIKey, cfg.KaveTemple) + jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL) + authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP) + + // اقتصاد و فروشگاه. تأییدکننده‌ها فعلاً نسخه‌ی توسعه‌اند؛ آداپتر واقعی + // تپسل (تبلیغ) و بازار/مایکت (IAP) با تنظیم creds جایگزین می‌شوند. + eco := economy.New(st.DB, economy.DevAdVerifier{}, economy.DevIAPVerifier{}) + ecoH := economy.NewHandler(eco) + + // هاب WebSocket برای بازی realtime + hub := ws.NewHub(func(token string) (int64, string, error) { + id, err := jwt.Verify(token) + if err != nil { + return 0, "", err + } + name := "بازیکن " + strconv.FormatInt(id, 10) + if u, err := users.FindByID(context.Background(), id); err == nil { + if u.FirstName != nil && *u.FirstName != "" { + name = *u.FirstName + } else { + name = u.Mobile + } + } + return id, name, nil + }) + hub.SetSettler(gameSettler{eco: eco}) + go hub.Run() + + r := chi.NewRouter() + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Recoverer) + r.Use(middleware.Timeout(15 * time.Second)) + + r.Get("/health", func(w http.ResponseWriter, _ *http.Request) { + httpx.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + + // WebSocket بازی (احراز هویت با توکن در ?token= یا هدر Authorization) + r.Get("/ws", hub.ServeWS) + + r.Route("/api", func(r chi.Router) { + r.Route("/auth", func(r chi.Router) { + r.Post("/login-otp", authH.LoginOTP) + r.Post("/check-otp", authH.CheckOTP) + }) + // مسیرهای محافظت‌شده با JWT + r.Group(func(r chi.Router) { + r.Use(jwt.Middleware) + r.Get("/me", authH.Me) + + // کیف‌پول و فروشگاه + r.Get("/wallet", ecoH.Wallet) + r.Get("/shop", ecoH.Shop) + r.Post("/shop/buy-card", ecoH.BuyCard) + r.Post("/shop/select-card", ecoH.SelectCard) + r.Post("/shop/purchase", ecoH.Purchase) + r.Post("/rewards/daily", ecoH.Daily) + r.Post("/rewards/ad", ecoH.AdReward) + }) + }) + + srv := &http.Server{ + Addr: cfg.Addr, + Handler: r, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + slog.Info("server starting", "addr", cfg.Addr) + if err := srv.ListenAndServe(); err != nil { + slog.Error("server", "err", err) + os.Exit(1) + } +} diff --git a/cmd/server/settler.go b/cmd/server/settler.go new file mode 100644 index 0000000..5bcd326 --- /dev/null +++ b/cmd/server/settler.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "log/slog" + + "hakemsho/internal/economy" +) + +// gameSettler اینترفیس ws.Settler را با سرویس اقتصاد پیاده می‌کند. +// tier را به ورودی/جایزه/XP/جام نگاشت کرده و تراکنش سکه را انجام می‌دهد. +type gameSettler struct { + eco *economy.Service +} + +func (g gameSettler) ChargeEntry(userID int64, tier string) error { + t := economy.FindTier(tier) + if t == nil { + return nil // tier ناشناخته ⇒ بدون ورودی + } + return g.eco.ChargeEntry(context.Background(), userID, t.Entry, "tier:"+tier) +} + +func (g gameSettler) Refund(userID int64, tier string) { + t := economy.FindTier(tier) + if t == nil { + return + } + if err := g.eco.Refund(context.Background(), userID, t.Entry, "tier:"+tier); err != nil { + slog.Error("refund", "user", userID, "err", err) + } +} + +func (g gameSettler) AwardWinner(userID int64, tier string) { + t := economy.FindTier(tier) + if t == nil { + return + } + if err := g.eco.AwardWinner(context.Background(), userID, t.Prize, t.XP, t.Trophy, "tier:"+tier); err != nil { + slog.Error("award winner", "user", userID, "err", err) + } +} + +func (g gameSettler) RecordGame(room, playersJSON string, winnerTeam int, kot bool) { + if err := g.eco.RecordGame(context.Background(), room, playersJSON, winnerTeam, kot); err != nil { + slog.Error("record game", "room", room, "err", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..707012e --- /dev/null +++ b/go.mod @@ -0,0 +1,26 @@ +module hakemsho + +go 1.23.6 + +require ( + github.com/go-chi/chi/v5 v5.3.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/gorilla/websocket v1.5.3 + modernc.org/sqlite v1.34.4 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..dafc41e --- /dev/null +++ b/go.sum @@ -0,0 +1,55 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..1411821 --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,160 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "regexp" + "time" + + "hakemsho/internal/httpx" + "hakemsho/internal/user" +) + +// Handler endpointهای احراز هویت را فراهم می‌کند. +type Handler struct { + users *user.Repo + otp *OTPStore + kave *Kavenegar + jwt *JWT + throttle *httpx.Throttle + + adminMobile string + adminOTP string +} + +func NewHandler(users *user.Repo, otp *OTPStore, kave *Kavenegar, jwt *JWT, adminMobile, adminOTP string) *Handler { + return &Handler{ + users: users, + otp: otp, + kave: kave, + jwt: jwt, + throttle: httpx.NewThrottle(3, time.Minute), // معادل throttle:3,1 + adminMobile: adminMobile, + adminOTP: adminOTP, + } +} + +// mobileRe اعتبارسنجی ساده شماره موبایل ایران. +var mobileRe = regexp.MustCompile(`^09\d{9}$`) + +type loginOTPReq struct { + Mobile string `json:"mobile"` + FCMToken string `json:"fcm_token"` +} + +// LoginOTP — POST /api/auth/login-otp +func (h *Handler) LoginOTP(w http.ResponseWriter, r *http.Request) { + var req loginOTPReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + if !mobileRe.MatchString(req.Mobile) { + httpx.Error(w, http.StatusUnprocessableEntity, "invalid mobile") + return + } + // throttle بر اساس موبایل: حداکثر ۳ بار در دقیقه + if !h.throttle.Allow(req.Mobile) { + httpx.Error(w, http.StatusTooManyRequests, "too many requests") + return + } + + ctx := r.Context() + u, err := h.users.FindOrCreate(ctx, req.Mobile) + if err != nil { + slog.Error("find or create user", "err", err) + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + + // شماره ادمین کد ثابت دارد و SMS نمی‌گیرد + if req.Mobile != h.adminMobile { + code := Generate() + if err := h.otp.Create(ctx, u.ID, code); err != nil { + slog.Error("create otp", "err", err) + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + // ارسال SMS به‌صورت fire-and-forget (بدون صف جانبی، مطابق پلن مینیموم) + go func(mobile, code string) { + bg, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := h.kave.SendVerify(bg, mobile, code); err != nil { + slog.Error("kavenegar send", "mobile", mobile, "err", err) + } + }(req.Mobile, code) + } + + httpx.JSON(w, http.StatusOK, map[string]string{"message": "otp sent"}) +} + +type checkOTPReq struct { + Mobile string `json:"mobile"` + Token string `json:"token"` +} + +// CheckOTP — POST /api/auth/check-otp +func (h *Handler) CheckOTP(w http.ResponseWriter, r *http.Request) { + var req checkOTPReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + if req.Mobile == "" || req.Token == "" { + httpx.Error(w, http.StatusUnprocessableEntity, "mobile and token required") + return + } + + ctx := r.Context() + u, err := h.users.FindByMobile(ctx, req.Mobile) + if errors.Is(err, user.ErrNotFound) { + httpx.Error(w, http.StatusNotFound, "user not found") + return + } + if err != nil { + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + + // راه ادمین برای تست بدون SMS + adminBypass := h.adminMobile != "" && req.Mobile == h.adminMobile && req.Token == h.adminOTP + if !adminBypass { + if err := h.otp.Verify(ctx, u.ID, req.Token); err != nil { + httpx.Error(w, http.StatusBadRequest, "token not valid") + return + } + } + + ability := "user" + if u.IsAdmin { + ability = "admin" + } + token, err := h.jwt.Issue(u.ID, ability) + if err != nil { + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + + httpx.JSON(w, http.StatusOK, map[string]any{ + "user": u, + "token": token, + }) +} + +// Me — GET /api/me (نیازمند JWT) +func (h *Handler) Me(w http.ResponseWriter, r *http.Request) { + id, ok := UserID(r.Context()) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + u, err := h.users.FindByID(r.Context(), id) + if err != nil { + httpx.Error(w, http.StatusNotFound, "user not found") + return + } + httpx.JSON(w, http.StatusOK, map[string]any{"user": u}) +} diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..cb0bc9e --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1,90 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type ctxKey string + +const userIDKey ctxKey = "uid" + +// JWT صدور و اعتبارسنجی توکن stateless. +type JWT struct { + secret []byte + ttl time.Duration +} + +func NewJWT(secret string, ttl time.Duration) *JWT { + return &JWT{secret: []byte(secret), ttl: ttl} +} + +// Issue توکن برای کاربر صادر می‌کند. ability مثل "user" یا "admin". +func (j *JWT) Issue(userID int64, ability string) (string, error) { + claims := jwt.MapClaims{ + "sub": strconv.FormatInt(userID, 10), + "abl": ability, + "exp": time.Now().Add(j.ttl).Unix(), + "iat": time.Now().Unix(), + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return tok.SignedString(j.secret) +} + +func (j *JWT) parse(tokenStr string) (int64, error) { + tok, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("unexpected signing method") + } + return j.secret, nil + }) + if err != nil || !tok.Valid { + return 0, errors.New("invalid token") + } + claims, ok := tok.Claims.(jwt.MapClaims) + if !ok { + return 0, errors.New("invalid claims") + } + sub, _ := claims["sub"].(string) + id, err := strconv.ParseInt(sub, 10, 64) + if err != nil { + return 0, errors.New("invalid sub") + } + return id, nil +} + +// Verify توکن را اعتبارسنجی کرده و شناسه کاربر را برمی‌گرداند (برای WebSocket). +func (j *JWT) Verify(token string) (int64, error) { + return j.parse(token) +} + +// Middleware احراز هویت Bearer JWT. +func (j *JWT) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + tokenStr := strings.TrimPrefix(h, "Bearer ") + if tokenStr == h || tokenStr == "" { + http.Error(w, `{"message":"unauthorized"}`, http.StatusUnauthorized) + return + } + id, err := j.parse(tokenStr) + if err != nil { + http.Error(w, `{"message":"unauthorized"}`, http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), userIDKey, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// UserID شناسه کاربر را از context (پس از Middleware) برمی‌گرداند. +func UserID(ctx context.Context) (int64, bool) { + id, ok := ctx.Value(userIDKey).(int64) + return id, ok +} diff --git a/internal/auth/kavenegar.go b/internal/auth/kavenegar.go new file mode 100644 index 0000000..8764ce1 --- /dev/null +++ b/internal/auth/kavenegar.go @@ -0,0 +1,61 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +// Kavenegar کلاینت ارسال OTP از طریق verify/lookup (معادل KavenegarService.php). +type Kavenegar struct { + APIKey string + Template string // مثل "loginotp" + client *http.Client +} + +func NewKavenegar(apiKey, template string) *Kavenegar { + return &Kavenegar{ + APIKey: apiKey, + Template: template, + client: &http.Client{Timeout: 5 * time.Second}, + } +} + +// SendVerify کد را با تمپلیت verify ارسال می‌کند. +func (k *Kavenegar) SendVerify(ctx context.Context, mobile, token string) error { + if k.APIKey == "" { + return fmt.Errorf("KAVE_API_KEY is null") + } + endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/verify/lookup.json", k.APIKey) + q := url.Values{} + q.Set("receptor", mobile) + q.Set("token", token) + q.Set("template", k.Template) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil) + if err != nil { + return err + } + resp, err := k.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + var out struct { + Return struct { + Status int `json:"status"` + Message string `json:"message"` + } `json:"return"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return fmt.Errorf("kavenegar decode: %w", err) + } + if out.Return.Status != 200 { + return fmt.Errorf("kavenegar failed: status=%d msg=%s", out.Return.Status, out.Return.Message) + } + return nil +} diff --git a/internal/auth/otp.go b/internal/auth/otp.go new file mode 100644 index 0000000..2b14fc3 --- /dev/null +++ b/internal/auth/otp.go @@ -0,0 +1,57 @@ +package auth + +import ( + "context" + "crypto/rand" + "database/sql" + "errors" + "math/big" + "time" +) + +// OTPStore مدیریت کدهای OTP در جدول otp_tokens. +type OTPStore struct { + db *sql.DB + ttl time.Duration +} + +func NewOTPStore(db *sql.DB, ttl time.Duration) *OTPStore { + return &OTPStore{db: db, ttl: ttl} +} + +// Generate یک کد ۵ رقمی امن می‌سازد (مطابق rand(10000,99999) در Laravel). +func Generate() string { + n, err := rand.Int(rand.Reader, big.NewInt(90000)) + if err != nil { + return "12345" // عملاً رخ نمی‌دهد + } + return big.NewInt(10000 + n.Int64()).String() +} + +// Create کد را برای کاربر ذخیره می‌کند. +func (s *OTPStore) Create(ctx context.Context, userID int64, token string) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO otp_tokens (user_id, token) VALUES (?, ?)`, userID, token) + return err +} + +var ErrInvalidOTP = errors.New("token not valid") + +// Verify اعتبار کد را در بازه ttl بررسی و پس از مصرف حذف می‌کند. +func (s *OTPStore) Verify(ctx context.Context, userID int64, token string) error { + cutoff := time.Now().Add(-s.ttl).UTC().Format("2006-01-02 15:04:05") + var id int64 + err := s.db.QueryRowContext(ctx, + `SELECT id FROM otp_tokens + WHERE user_id = ? AND token = ? AND created_at > ? + ORDER BY id DESC LIMIT 1`, userID, token, cutoff).Scan(&id) + if errors.Is(err, sql.ErrNoRows) { + return ErrInvalidOTP + } + if err != nil { + return err + } + // مصرف شد ⇒ حذف + _, err = s.db.ExecContext(ctx, `DELETE FROM otp_tokens WHERE id = ?`, id) + return err +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..895ab9a --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,70 @@ +package config + +import ( + "bufio" + "os" + "strings" + "time" +) + +// Config مقادیر محیطی برنامه را نگه می‌دارد. +type Config struct { + Addr string // آدرس گوش‌دادن HTTP مثل :8080 + DBPath string // مسیر فایل SQLite + JWTSecret string // کلید امضای JWT + JWTTTL time.Duration // طول عمر توکن + KaveAPIKey string // کلید کاوه‌نگار + KaveTemple string // نام تمپلیت verify (loginotp) + OTPTTL time.Duration // اعتبار کد OTP + AdminMobile string // شماره ادمین برای تست بدون SMS + AdminOTP string // کد ثابت ادمین +} + +// Load کانفیگ را از env با مقادیر پیش‌فرض معقول می‌خواند. +func Load() Config { + loadDotenv(".env") + return Config{ + Addr: env("ADDR", ":8080"), + DBPath: env("DB_PATH", "hakemsho.db"), + JWTSecret: env("JWT_SECRET", "change-me-in-production"), + JWTTTL: 30 * 24 * time.Hour, + KaveAPIKey: env("KAVE_API_KEY", ""), + KaveTemple: env("KAVE_TEMPLATE", "loginotp"), + OTPTTL: 15 * time.Minute, + AdminMobile: env("ADMIN_MOBILE", ""), + AdminOTP: env("ADMIN_OTP", ""), + } +} + +// loadDotenv فایل .env را در صورت وجود می‌خواند و متغیرهایی را که +// از قبل در محیط ست نشده‌اند مقداردهی می‌کند. بدون وابستگی خارجی. +func loadDotenv(path string) { + f, err := os.Open(path) + if err != nil { + return // .env اختیاری است + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.Trim(strings.TrimSpace(val), `"'`) + if _, exists := os.LookupEnv(key); !exists { + _ = os.Setenv(key, val) + } + } +} + +func env(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/internal/economy/catalog.go b/internal/economy/catalog.go new file mode 100644 index 0000000..3d75f9f --- /dev/null +++ b/internal/economy/catalog.go @@ -0,0 +1,175 @@ +package economy + +// مقادیر اقتصادی پایه (قابل تنظیم بر اساس بیزینس‌پلن). +const ( + DailyReward = 200 // سکه روزانه + AdReward = 50 // سکه به ازای هر تبلیغ rewardedِ کامل + AdDailyCap = 50 // سقف تعداد تبلیغ پاداش‌دار در روز +) + +// CoinPackage بسته‌ی سکه با پول واقعی (IAP) — ممکن است VIP هم بدهد. +type CoinPackage struct { + ID string `json:"id"` + Title string `json:"title"` + Coins int64 `json:"coins"` + VIPDays int `json:"vip_days"` + PriceToman int `json:"price_toman"` + BonusPct int `json:"bonus_pct"` +} + +// TicketPackage بسته‌ی بلیط با پول واقعی (IAP). +type TicketPackage struct { + ID string `json:"id"` + Title string `json:"title"` + Tickets int64 `json:"tickets"` + PriceToman int `json:"price_toman"` +} + +// CardSkin اسکین کارت که با سکه باز می‌شود (قیمت ۰ یعنی پیش‌فرض رایگان). +type CardSkin struct { + ID string `json:"id"` + Title string `json:"title"` + PriceCoins int64 `json:"price_coins"` +} + +// Booster بوستر XP با پول واقعی (IAP). +type Booster struct { + ID string `json:"id"` + Title string `json:"title"` + Multiplier int `json:"multiplier"` + Hours int `json:"hours"` + PriceToman int `json:"price_toman"` +} + +// TableTier نوع میز: تعداد دست، ورودی، جایزه، XP و جام. +type TableTier struct { + ID string `json:"id"` + Title string `json:"title"` + Hands int `json:"hands"` + Entry int64 `json:"entry"` + Prize int64 `json:"prize"` + XP int `json:"xp"` + Trophy int `json:"trophy"` +} + +// Catalog کل کاتالوگ فروشگاه (برای GET /api/shop). +type Catalog struct { + CoinPackages []CoinPackage `json:"coin_packages"` + TicketPackages []TicketPackage `json:"ticket_packages"` + CardSkins []CardSkin `json:"card_skins"` + Boosters []Booster `json:"boosters"` + TableTiers []TableTier `json:"table_tiers"` +} + +// coinPackages — مطابق فروشگاه مرجع. +var coinPackages = []CoinPackage{ + {ID: "small", Title: "بسته کوچک", Coins: 6000, VIPDays: 0, PriceToman: 14950, BonusPct: 0}, + {ID: "medium", Title: "بسته متوسط", Coins: 20000, VIPDays: 3, PriceToman: 37900, BonusPct: 20}, + {ID: "large", Title: "بسته بزرگ", Coins: 80000, VIPDays: 7, PriceToman: 134900, BonusPct: 30}, + {ID: "xlarge", Title: "بسته خیلی بزرگ", Coins: 180000, VIPDays: 15, PriceToman: 279900, BonusPct: 40}, + {ID: "luxury", Title: "بسته لاکچری", Coins: 280000, VIPDays: 20, PriceToman: 379900, BonusPct: 50}, + {ID: "sultans", Title: "بسته سلاطین", Coins: 700000, VIPDays: 30, PriceToman: 999000, BonusPct: 60}, +} + +var ticketPackages = []TicketPackage{ + {ID: "small", Title: "بسته کوچک", Tickets: 10, PriceToman: 6900}, + {ID: "medium", Title: "بسته متوسط", Tickets: 50, PriceToman: 24900}, + {ID: "large", Title: "بسته بزرگ", Tickets: 150, PriceToman: 64900}, + {ID: "xlarge", Title: "بسته خیلی بزرگ", Tickets: 500, PriceToman: 199900}, +} + +var cardSkins = []CardSkin{ + {ID: "simple", Title: "بسته کارت ساده", PriceCoins: 0}, + {ID: "swiss", Title: "بسته کارت سوئیسی", PriceCoins: 6000}, + {ID: "wizard", Title: "بسته کارت جادوگر", PriceCoins: 10000}, + {ID: "western", Title: "بسته کارت وسترن", PriceCoins: 12000}, + {ID: "french", Title: "بسته کارت فرانسوی", PriceCoins: 20000}, + {ID: "epic", Title: "بسته کارت حماسی", PriceCoins: 25000}, +} + +var boosters = []Booster{ + {ID: "xp2", Title: "بسته تجربه دو برابر", Multiplier: 2, Hours: 12, PriceToman: 6900}, + {ID: "xp3", Title: "بسته تجربه سه برابر", Multiplier: 3, Hours: 24, PriceToman: 19900}, +} + +// tableTiers — انواع میز (ورودی/جایزه/XP/جام). +var tableTiers = []TableTier{ + {ID: "beginner", Title: "مبتدی", Hands: 3, Entry: 100, Prize: 250, XP: 15, Trophy: 50}, + {ID: "amateur", Title: "پیوست", Hands: 3, Entry: 2500, Prize: 5000, XP: 25, Trophy: 100}, + {ID: "pro", Title: "حرفه‌ای", Hands: 7, Entry: 2500, Prize: 5000, XP: 25, Trophy: 100}, + {ID: "master", Title: "استادان", Hands: 7, Entry: 20000, Prize: 40000, XP: 40, Trophy: 200}, +} + +// FullCatalog کل کاتالوگ را برمی‌گرداند. +func FullCatalog() Catalog { + return Catalog{ + CoinPackages: coinPackages, + TicketPackages: ticketPackages, + CardSkins: cardSkins, + Boosters: boosters, + TableTiers: tableTiers, + } +} + +func findCoinPackage(id string) *CoinPackage { + for i := range coinPackages { + if coinPackages[i].ID == id { + return &coinPackages[i] + } + } + return nil +} + +func findTicketPackage(id string) *TicketPackage { + for i := range ticketPackages { + if ticketPackages[i].ID == id { + return &ticketPackages[i] + } + } + return nil +} + +func findCardSkin(id string) *CardSkin { + for i := range cardSkins { + if cardSkins[i].ID == id { + return &cardSkins[i] + } + } + return nil +} + +func findBooster(id string) *Booster { + for i := range boosters { + if boosters[i].ID == id { + return &boosters[i] + } + } + return nil +} + +// FindTier نوع میز را برمی‌گرداند (برای لایه بازی). +func FindTier(id string) *TableTier { + for i := range tableTiers { + if tableTiers[i].ID == id { + return &tableTiers[i] + } + } + return nil +} + +// LevelInfo سطح فعلی، XP درون‌سطح و XP لازم برای سطح بعد را از XP کل محاسبه می‌کند. +// نیاز هر سطح: ۵۰ × شماره سطح (مثلاً سطح ۸ ⇒ ۴۰۰). +func LevelInfo(totalXP int64) (level int, into int64, need int64) { + level = 1 + remaining := totalXP + for { + need = int64(50 * level) + if remaining < need { + break + } + remaining -= need + level++ + } + into = remaining + return level, into, need +} diff --git a/internal/economy/economy.go b/internal/economy/economy.go new file mode 100644 index 0000000..ce36d3b --- /dev/null +++ b/internal/economy/economy.go @@ -0,0 +1,407 @@ +package economy + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" +) + +// خطاهای دامنه. +var ( + ErrTooSoon = errors.New("daily reward not ready") + ErrAdNotVerified = errors.New("ad not verified") + ErrAdCap = errors.New("daily ad cap reached") + ErrAdDuplicate = errors.New("ad reward already claimed") + ErrNotFound = errors.New("item not found") + ErrAlreadyOwned = errors.New("already owned") + ErrNotOwned = errors.New("card not owned") + ErrIAPInvalid = errors.New("purchase not valid") +) + +const tsLayout = "2006-01-02 15:04:05" + +func nowUTC() time.Time { return time.Now().UTC() } +func nowStr() string { return nowUTC().Format(tsLayout) } +func parseTS(s string) time.Time { + t, _ := time.Parse(tsLayout, s) + return t +} + +// Service سرویس اقتصاد بازی. +type Service struct { + db *sql.DB + ad AdVerifier + iap IAPVerifier +} + +func New(db *sql.DB, ad AdVerifier, iap IAPVerifier) *Service { + return &Service{db: db, ad: ad, iap: iap} +} + +// Profile وضعیت اقتصادی کاربر. +type Profile struct { + Coins int64 `json:"coins"` + Tickets int64 `json:"tickets"` + XP int64 `json:"xp"` + Trophies int `json:"trophies"` + Level int `json:"level"` + XPInto int64 `json:"xp_into_level"` + XPNeed int64 `json:"xp_for_next"` + VIP bool `json:"vip"` + VIPUntil *string `json:"vip_until"` + SelectedCard string `json:"selected_card"` +} + +// GetProfile وضعیت اقتصادی کاربر را می‌خواند. +func (s *Service) GetProfile(ctx context.Context, userID int64) (*Profile, error) { + var p Profile + var vipUntil sql.NullString + err := s.db.QueryRowContext(ctx, + `SELECT coins, tickets, xp, trophies, vip_until, selected_card FROM users WHERE id = ?`, userID). + Scan(&p.Coins, &p.Tickets, &p.XP, &p.Trophies, &vipUntil, &p.SelectedCard) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + p.Level, p.XPInto, p.XPNeed = LevelInfo(p.XP) + if vipUntil.Valid { + p.VIPUntil = &vipUntil.String + p.VIP = parseTS(vipUntil.String).After(nowUTC()) + } + return &p, nil +} + +func (s *Service) isVIP(ctx context.Context, userID int64) bool { + var vu sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT vip_until FROM users WHERE id = ?`, userID).Scan(&vu); err != nil { + return false + } + return vu.Valid && parseTS(vu.String).After(nowUTC()) +} + +// ClaimDaily سکه روزانه را در صورت گذشت ۲۴ ساعت می‌دهد. +func (s *Service) ClaimDaily(ctx context.Context, userID int64) (int64, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + cutoff := nowUTC().Add(-24 * time.Hour).Format(tsLayout) + res, err := tx.ExecContext(ctx, + `UPDATE users SET last_daily_at = ? + WHERE id = ? AND (last_daily_at IS NULL OR last_daily_at <= ?)`, + nowStr(), userID, cutoff) + if err != nil { + return 0, err + } + if n, _ := res.RowsAffected(); n == 0 { + return 0, ErrTooSoon + } + if err := adjustTx(ctx, tx, userID, CurrencyCoin, DailyReward, "daily_reward", ""); err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return DailyReward, nil +} + +// ClaimAdReward پس از تأیید تبلیغ rewarded، سکه می‌دهد (با ضد تکرار و سقف روزانه). +func (s *Service) ClaimAdReward(ctx context.Context, userID int64, token string) (int64, error) { + ok, err := s.ad.Verify(ctx, userID, token) + if err != nil || !ok { + return 0, ErrAdNotVerified + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + // سقف روزانه + since := nowUTC().Add(-24 * time.Hour).Format(tsLayout) + var cnt int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM ad_rewards WHERE user_id = ? AND created_at > ?`, userID, since).Scan(&cnt); err != nil { + return 0, err + } + if cnt >= AdDailyCap { + return 0, ErrAdCap + } + + // ثبت توکن یکتا (ضد تکرار) + if _, err := tx.ExecContext(ctx, + `INSERT INTO ad_rewards (user_id, token, amount) VALUES (?, ?, ?)`, userID, token, AdReward); err != nil { + if isUnique(err) { + return 0, ErrAdDuplicate + } + return 0, err + } + if err := adjustTx(ctx, tx, userID, CurrencyCoin, AdReward, "ad_reward", token); err != nil { + return 0, err + } + if err := tx.Commit(); err != nil { + return 0, err + } + return AdReward, nil +} + +// BuyCard اسکین کارت را با سکه باز می‌کند. +func (s *Service) BuyCard(ctx context.Context, userID int64, cardID string) error { + skin := findCardSkin(cardID) + if skin == nil { + 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_cards WHERE user_id = ? AND card_id = ?`, userID, cardID).Scan(&exists) + if exists == 1 { + return ErrAlreadyOwned + } + if skin.PriceCoins > 0 { + if err := adjustTx(ctx, tx, userID, CurrencyCoin, -skin.PriceCoins, "buy_card", cardID); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO user_cards (user_id, card_id) VALUES (?, ?)`, userID, cardID); err != nil { + return err + } + return tx.Commit() +} + +// SelectCard کارت انتخابی کاربر را تنظیم می‌کند (باید مالکش باشد یا کارت پیش‌فرض). +func (s *Service) SelectCard(ctx context.Context, userID int64, cardID string) error { + if findCardSkin(cardID) == nil { + return ErrNotFound + } + if cardID != "simple" { + var exists int + _ = s.db.QueryRowContext(ctx, + `SELECT 1 FROM user_cards WHERE user_id = ? AND card_id = ?`, userID, cardID).Scan(&exists) + if exists != 1 { + return ErrNotOwned + } + } + _, err := s.db.ExecContext(ctx, `UPDATE users SET selected_card = ? WHERE id = ?`, cardID, userID) + return err +} + +// OwnedCards فهرست کارت‌های متعلق به کاربر (شامل کارت پیش‌فرض). +func (s *Service) OwnedCards(ctx context.Context, userID int64) ([]string, error) { + rows, err := s.db.QueryContext(ctx, `SELECT card_id FROM user_cards WHERE user_id = ?`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + owned := []string{"simple"} + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, err + } + if id != "simple" { + owned = append(owned, id) + } + } + return owned, rows.Err() +} + +// addXPTx مقدار XP را با اعمال بوستر فعال اضافه می‌کند. +func (s *Service) addXPTx(ctx context.Context, tx *sql.Tx, userID int64, base int) error { + mult := 1 + var boostUntil sql.NullString + var m int + if err := tx.QueryRowContext(ctx, + `SELECT xp_boost_mult, xp_boost_until FROM users WHERE id = ?`, userID).Scan(&m, &boostUntil); err == nil { + if boostUntil.Valid && parseTS(boostUntil.String).After(nowUTC()) && m > 1 { + mult = m + } + } + _, err := tx.ExecContext(ctx, `UPDATE users SET xp = xp + ? WHERE id = ?`, base*mult, userID) + return err +} + +// PurchaseRequest درخواست تأیید خرید IAP. +type PurchaseRequest struct { + Store string `json:"store"` // bazaar | myket + Kind string `json:"kind"` // coin | ticket | booster + ProductID string `json:"product_id"` // شناسه بسته در کاتالوگ + Token string `json:"token"` // purchaseToken +} + +// VerifyPurchase خرید را تأیید و در صورت معتبر بودن اعتبار/بلیط/VIP/بوستر می‌دهد (idempotent). +func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req PurchaseRequest) error { + ok, err := s.iap.Verify(ctx, req.Store, req.ProductID, req.Token) + if err != nil || !ok { + return ErrIAPInvalid + } + + var coins, tickets int64 + var vipDays int + var booster *Booster + switch req.Kind { + case "coin": + p := findCoinPackage(req.ProductID) + if p == nil { + return ErrNotFound + } + coins, vipDays = p.Coins, p.VIPDays + if s.isVIP(ctx, userID) { + coins += coins / 10 // پاداش ۱۰٪ VIP + } + case "ticket": + p := findTicketPackage(req.ProductID) + if p == nil { + return ErrNotFound + } + tickets = p.Tickets + case "booster": + booster = findBooster(req.ProductID) + if booster == nil { + return ErrNotFound + } + default: + return ErrNotFound + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + // ثبت خرید (یکتا بودن store+token ⇒ ضد دوباره‌اعتبارسنجی) + if _, err := tx.ExecContext(ctx, + `INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days) + VALUES (?, ?, ?, ?, 'verified', ?, ?, ?)`, + userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays); err != nil { + if isUnique(err) { + return ErrAlreadyOwned // قبلاً پردازش شده + } + return err + } + if coins > 0 { + if err := adjustTx(ctx, tx, userID, CurrencyCoin, coins, "purchase_coin", req.ProductID); err != nil { + return err + } + } + if tickets > 0 { + if err := adjustTx(ctx, tx, userID, CurrencyTicket, tickets, "purchase_ticket", req.ProductID); err != nil { + return err + } + } + if vipDays > 0 { + if err := extendVIPTx(ctx, tx, userID, vipDays); err != nil { + return err + } + } + if booster != nil { + until := laterOf(nowUTC(), time.Time{}).Add(time.Duration(booster.Hours) * time.Hour).Format(tsLayout) + if _, err := tx.ExecContext(ctx, + `UPDATE users SET xp_boost_mult = ?, xp_boost_until = ? WHERE id = ?`, + booster.Multiplier, until, userID); err != nil { + return err + } + } + return tx.Commit() +} + +// extendVIPTx زمان VIP را تمدید می‌کند (از بیشینهٔ اکنون و پایان فعلی). +func extendVIPTx(ctx context.Context, tx *sql.Tx, userID int64, days int) error { + var cur sql.NullString + if err := tx.QueryRowContext(ctx, `SELECT vip_until FROM users WHERE id = ?`, userID).Scan(&cur); err != nil { + return err + } + base := nowUTC() + if cur.Valid { + if t := parseTS(cur.String); t.After(base) { + base = t + } + } + until := base.Add(time.Duration(days) * 24 * time.Hour).Format(tsLayout) + _, err := tx.ExecContext(ctx, `UPDATE users SET vip_until = ? WHERE id = ?`, until, userID) + return err +} + +func laterOf(a, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} + +// --- تسویه‌ی داخل بازی --- + +// ChargeEntry ورودی میز را از کاربر کسر می‌کند. +func (s *Service) ChargeEntry(ctx context.Context, userID, entry int64, ref string) error { + if entry <= 0 { + return nil + } + return s.Adjust(ctx, userID, CurrencyCoin, -entry, "table_entry", ref) +} + +// Refund ورودی میز را بازمی‌گرداند (در صورت لغو بازی). +func (s *Service) Refund(ctx context.Context, userID, entry int64, ref string) error { + if entry <= 0 { + return nil + } + return s.Adjust(ctx, userID, CurrencyCoin, entry, "table_refund", ref) +} + +// AwardWinner جایزه، XP و جام را به برنده می‌دهد. +func (s *Service) AwardWinner(ctx context.Context, userID, prize int64, xp, trophy int, ref string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if prize > 0 { + if err := adjustTx(ctx, tx, userID, CurrencyCoin, prize, "table_prize", ref); err != nil { + return err + } + } + if xp > 0 { + if err := s.addXPTx(ctx, tx, userID, xp); err != nil { + return err + } + } + if trophy > 0 { + if _, err := tx.ExecContext(ctx, `UPDATE users SET trophies = trophies + ? WHERE id = ?`, trophy, userID); err != nil { + return err + } + } + return tx.Commit() +} + +// RecordGame نتیجه‌ی بازی را در تاریخچه ثبت می‌کند. +func (s *Service) RecordGame(ctx context.Context, room, playersJSON string, winnerTeam int, kot bool) error { + _, err := s.db.ExecContext(ctx, + `INSERT INTO game_history (room, players_json, winner_team, kot) VALUES (?, ?, ?, ?)`, + room, playersJSON, winnerTeam, boolToInt(kot)) + return err +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func isUnique(err error) bool { + return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique") +} diff --git a/internal/economy/economy_test.go b/internal/economy/economy_test.go new file mode 100644 index 0000000..d76075e --- /dev/null +++ b/internal/economy/economy_test.go @@ -0,0 +1,198 @@ +package economy + +import ( + "context" + "path/filepath" + "testing" + + "hakemsho/internal/store" +) + +func setup(t *testing.T) (*Service, int64) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + st, err := store.Open(dbPath) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { st.Close() }) + + res, err := st.DB.Exec(`INSERT INTO users (mobile) VALUES ('09120000000')`) + if err != nil { + t.Fatalf("insert user: %v", err) + } + id, _ := res.LastInsertId() + svc := New(st.DB, DevAdVerifier{}, DevIAPVerifier{}) + return svc, id +} + +func setCoins(t *testing.T, s *Service, userID, coins int64) { + t.Helper() + if _, err := s.db.Exec(`UPDATE users SET coins = ? WHERE id = ?`, coins, userID); err != nil { + t.Fatal(err) + } +} + +func coinsOf(t *testing.T, s *Service, userID int64) int64 { + t.Helper() + p, err := s.GetProfile(context.Background(), userID) + if err != nil { + t.Fatal(err) + } + return p.Coins +} + +func TestLevelInfo(t *testing.T) { + cases := []struct { + xp int64 + level int + into int64 + need int64 + }{ + {0, 1, 0, 50}, + {49, 1, 49, 50}, + {50, 2, 0, 100}, + {150, 3, 0, 150}, + } + for _, c := range cases { + lvl, into, need := LevelInfo(c.xp) + if lvl != c.level || into != c.into || need != c.need { + t.Errorf("LevelInfo(%d) = (%d,%d,%d), want (%d,%d,%d)", + c.xp, lvl, into, need, c.level, c.into, c.need) + } + } +} + +func TestWalletInsufficient(t *testing.T) { + svc, id := setup(t) + ctx := context.Background() + setCoins(t, svc, id, 100) + if err := svc.Adjust(ctx, id, CurrencyCoin, -200, "test", ""); err != ErrInsufficient { + t.Fatalf("want ErrInsufficient, got %v", err) + } + if c := coinsOf(t, svc, id); c != 100 { + t.Fatalf("balance changed on failed debit: %d", c) + } + if err := svc.Adjust(ctx, id, CurrencyCoin, -60, "test", ""); err != nil { + t.Fatal(err) + } + if c := coinsOf(t, svc, id); c != 40 { + t.Fatalf("want 40, got %d", c) + } +} + +func TestDailyCooldown(t *testing.T) { + svc, id := setup(t) + ctx := context.Background() + if amt, err := svc.ClaimDaily(ctx, id); err != nil || amt != DailyReward { + t.Fatalf("first claim: amt=%d err=%v", amt, err) + } + if _, err := svc.ClaimDaily(ctx, id); err != ErrTooSoon { + t.Fatalf("want ErrTooSoon, got %v", err) + } + if c := coinsOf(t, svc, id); c != DailyReward { + t.Fatalf("want %d coins, got %d", DailyReward, c) + } +} + +func TestAdRewardDedupeAndCredit(t *testing.T) { + svc, id := setup(t) + ctx := context.Background() + if amt, err := svc.ClaimAdReward(ctx, id, "tok-1"); err != nil || amt != AdReward { + t.Fatalf("claim: amt=%d err=%v", amt, err) + } + // همان توکن دوباره ⇒ تکراری + if _, err := svc.ClaimAdReward(ctx, id, "tok-1"); err != ErrAdDuplicate { + t.Fatalf("want ErrAdDuplicate, got %v", err) + } + // توکن خالی ⇒ تأیید نشده + if _, err := svc.ClaimAdReward(ctx, id, ""); err != ErrAdNotVerified { + t.Fatalf("want ErrAdNotVerified, got %v", err) + } + if c := coinsOf(t, svc, id); c != AdReward { + t.Fatalf("want %d, got %d", AdReward, c) + } +} + +func TestBuyAndSelectCard(t *testing.T) { + svc, id := setup(t) + ctx := context.Background() + setCoins(t, svc, id, 10000) + + // انتخاب کارتی که مالکش نیست ⇒ خطا + if err := svc.SelectCard(ctx, id, "wizard"); err != ErrNotOwned { + t.Fatalf("want ErrNotOwned, got %v", err) + } + // خرید کارت جادوگر (۱۰۰۰۰) + if err := svc.BuyCard(ctx, id, "wizard"); err != nil { + t.Fatalf("buy: %v", err) + } + if c := coinsOf(t, svc, id); c != 0 { + t.Fatalf("want 0 after buy, got %d", c) + } + // خرید دوباره ⇒ قبلاً مالک است + if err := svc.BuyCard(ctx, id, "wizard"); err != ErrAlreadyOwned { + t.Fatalf("want ErrAlreadyOwned, got %v", err) + } + // حالا قابل انتخاب است + if err := svc.SelectCard(ctx, id, "wizard"); err != nil { + t.Fatalf("select: %v", err) + } + p, _ := svc.GetProfile(ctx, id) + if p.SelectedCard != "wizard" { + t.Fatalf("selected = %s", p.SelectedCard) + } + // خرید بدون سکه کافی ⇒ insufficient + if err := svc.BuyCard(ctx, id, "epic"); err != ErrInsufficient { + t.Fatalf("want ErrInsufficient, got %v", err) + } +} + +func TestPurchaseCreditsAndIdempotent(t *testing.T) { + svc, id := setup(t) + ctx := context.Background() + req := PurchaseRequest{Store: "bazaar", Kind: "coin", ProductID: "small", Token: "pt-1"} + + if err := svc.VerifyPurchase(ctx, id, req); err != nil { + t.Fatalf("purchase: %v", err) + } + pkg := findCoinPackage("small") + if c := coinsOf(t, svc, id); c != pkg.Coins { + t.Fatalf("want %d coins, got %d", pkg.Coins, c) + } + // همان توکن دوباره ⇒ قبلاً پردازش شده، بدون شارژ مجدد + if err := svc.VerifyPurchase(ctx, id, req); err != ErrAlreadyOwned { + t.Fatalf("want ErrAlreadyOwned, got %v", err) + } + if c := coinsOf(t, svc, id); c != pkg.Coins { + t.Fatalf("double credit! got %d", c) + } +} + +func TestGameSettlement(t *testing.T) { + svc, id := setup(t) + ctx := context.Background() + setCoins(t, svc, id, 1000) + + // کسر ورودی + if err := svc.ChargeEntry(ctx, id, 100, "r1"); err != nil { + t.Fatalf("charge: %v", err) + } + if c := coinsOf(t, svc, id); c != 900 { + t.Fatalf("after entry want 900, got %d", c) + } + // جایزه برنده + if err := svc.AwardWinner(ctx, id, 250, 15, 50, "r1"); err != nil { + t.Fatalf("award: %v", err) + } + p, _ := svc.GetProfile(ctx, id) + if p.Coins != 1150 { + t.Fatalf("after prize want 1150, got %d", p.Coins) + } + if p.XP != 15 { + t.Fatalf("want 15 xp, got %d", p.XP) + } + if p.Trophies != 50 { + t.Fatalf("want 50 trophies, got %d", p.Trophies) + } +} diff --git a/internal/economy/handler.go b/internal/economy/handler.go new file mode 100644 index 0000000..8099dd8 --- /dev/null +++ b/internal/economy/handler.go @@ -0,0 +1,186 @@ +package economy + +import ( + "encoding/json" + "errors" + "net/http" + + "hakemsho/internal/auth" + "hakemsho/internal/httpx" +) + +// Handler endpointهای فروشگاه و کیف‌پول (همه پشت JWT). +type Handler struct { + svc *Service +} + +func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } + +func uid(r *http.Request) (int64, bool) { return auth.UserID(r.Context()) } + +// Wallet — GET /api/wallet +func (h *Handler) Wallet(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + p, err := h.svc.GetProfile(r.Context(), id) + if err != nil { + httpx.Error(w, http.StatusNotFound, "user not found") + return + } + httpx.JSON(w, http.StatusOK, p) +} + +// Shop — GET /api/shop +func (h *Handler) Shop(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + owned, err := h.svc.OwnedCards(r.Context(), id) + if err != nil { + httpx.Error(w, http.StatusInternalServerError, "server error") + return + } + p, _ := h.svc.GetProfile(r.Context(), id) + selected := "simple" + if p != nil { + selected = p.SelectedCard + } + httpx.JSON(w, http.StatusOK, map[string]any{ + "catalog": FullCatalog(), + "owned_cards": owned, + "selected_card": selected, + }) +} + +type cardReq struct { + CardID string `json:"card_id"` +} + +// BuyCard — POST /api/shop/buy-card +func (h *Handler) BuyCard(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + var req cardReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + switch err := h.svc.BuyCard(r.Context(), id, req.CardID); { + case err == nil: + httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"}) + case errors.Is(err, ErrNotFound): + httpx.Error(w, http.StatusNotFound, "card not found") + case errors.Is(err, ErrAlreadyOwned): + httpx.Error(w, http.StatusConflict, "already owned") + case errors.Is(err, ErrInsufficient): + httpx.Error(w, http.StatusPaymentRequired, "insufficient coins") + default: + httpx.Error(w, http.StatusInternalServerError, "server error") + } +} + +// SelectCard — POST /api/shop/select-card +func (h *Handler) SelectCard(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + var req cardReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + switch err := h.svc.SelectCard(r.Context(), id, req.CardID); { + case err == nil: + httpx.JSON(w, http.StatusOK, map[string]string{"message": "ok"}) + case errors.Is(err, ErrNotFound): + httpx.Error(w, http.StatusNotFound, "card not found") + case errors.Is(err, ErrNotOwned): + httpx.Error(w, http.StatusForbidden, "card not owned") + default: + httpx.Error(w, http.StatusInternalServerError, "server error") + } +} + +// Purchase — POST /api/shop/purchase (تأیید خرید IAP) +func (h *Handler) Purchase(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + var req PurchaseRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + switch err := h.svc.VerifyPurchase(r.Context(), id, req); { + case err == nil: + p, _ := h.svc.GetProfile(r.Context(), id) + httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "wallet": p}) + case errors.Is(err, ErrAlreadyOwned): + httpx.Error(w, http.StatusConflict, "already processed") + case errors.Is(err, ErrIAPInvalid): + httpx.Error(w, http.StatusBadRequest, "purchase not valid") + case errors.Is(err, ErrNotFound): + httpx.Error(w, http.StatusNotFound, "package not found") + default: + httpx.Error(w, http.StatusInternalServerError, "server error") + } +} + +// Daily — POST /api/rewards/daily +func (h *Handler) Daily(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + switch amount, err := h.svc.ClaimDaily(r.Context(), id); { + case err == nil: + httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "amount": amount}) + case errors.Is(err, ErrTooSoon): + httpx.Error(w, http.StatusTooManyRequests, "already claimed today") + default: + httpx.Error(w, http.StatusInternalServerError, "server error") + } +} + +type adReq struct { + Token string `json:"token"` +} + +// AdReward — POST /api/rewards/ad (سکه رایگان پس از دیدن کامل تبلیغ) +func (h *Handler) AdReward(w http.ResponseWriter, r *http.Request) { + id, ok := uid(r) + if !ok { + httpx.Error(w, http.StatusUnauthorized, "unauthorized") + return + } + var req adReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.Error(w, http.StatusBadRequest, "invalid body") + return + } + switch amount, err := h.svc.ClaimAdReward(r.Context(), id, req.Token); { + case err == nil: + httpx.JSON(w, http.StatusOK, map[string]any{"message": "ok", "amount": amount}) + case errors.Is(err, ErrAdNotVerified): + httpx.Error(w, http.StatusBadRequest, "ad not verified") + case errors.Is(err, ErrAdDuplicate): + httpx.Error(w, http.StatusConflict, "already claimed") + case errors.Is(err, ErrAdCap): + httpx.Error(w, http.StatusTooManyRequests, "daily ad cap reached") + default: + httpx.Error(w, http.StatusInternalServerError, "server error") + } +} diff --git a/internal/economy/verify.go b/internal/economy/verify.go new file mode 100644 index 0000000..1dedcee --- /dev/null +++ b/internal/economy/verify.go @@ -0,0 +1,139 @@ +package economy + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// AdVerifier تأیید می‌کند که کاربر یک تبلیغ rewarded را کامل دیده است. +type AdVerifier interface { + Verify(ctx context.Context, userID int64, token string) (bool, error) +} + +// IAPVerifier تأیید می‌کند که یک خرید درون‌برنامه‌ای معتبر و پرداخت‌شده است. +type IAPVerifier interface { + Verify(ctx context.Context, store, productID, purchaseToken string) (bool, error) +} + +// --- استاب توسعه: هر توکن غیرخالی را معتبر می‌داند (فقط برای محیط dev/تست) --- + +type DevAdVerifier struct{} + +func (DevAdVerifier) Verify(_ context.Context, _ int64, token string) (bool, error) { + return token != "", nil +} + +type DevIAPVerifier struct{} + +func (DevIAPVerifier) Verify(_ context.Context, _ string, _ string, token string) (bool, error) { + return token != "", nil +} + +// --- آداپتر تپسل (تأیید سمت‌سرور تبلیغ rewarded) --- +// +// تپسل پس از پایان کامل تبلیغ، یک توکن/شناسه می‌دهد که سرور باید آن را +// نزد تپسل اعتبارسنجی کند. آدرس و قالب دقیق بسته به پنل تپسل تنظیم می‌شود؛ +// اینجا اسکلت فراخوانی با کلید قرار داده شده است. +type TapsellAdVerifier struct { + APIKey string + BaseURL string // پیش‌فرض اگر خالی باشد ست می‌شود + client *http.Client +} + +func NewTapsellAdVerifier(apiKey string) *TapsellAdVerifier { + return &TapsellAdVerifier{ + APIKey: apiKey, + BaseURL: "https://api.tapsell.ir", + client: &http.Client{Timeout: 5 * time.Second}, + } +} + +func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token string) (bool, error) { + if t.APIKey == "" || token == "" { + return false, fmt.Errorf("tapsell: missing api key or token") + } + // TODO: مسیر دقیق اعتبارسنجی تپسل را مطابق مستندات پنل تنظیم کنید. + url := fmt.Sprintf("%s/rewarded/verify?token=%s", t.BaseURL, token) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + req.Header.Set("Authorization", t.APIKey) + resp, err := t.client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + var out struct { + Valid bool `json:"valid"` + Completed bool `json:"completed"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return false, err + } + return out.Valid && out.Completed, nil +} + +// --- آداپتر IAP کافه‌بازار / مایکت --- +// +// هر دو فروشگاه API شبیه Google Play دارند: با access_token (که از refresh_token +// گرفته می‌شود) وضعیت یک purchaseToken برای یک محصول را اعتبارسنجی می‌کنیم. +type StoreIAPVerifier struct { + // قالب آدرس اعتبارسنجی؛ %s ها به ترتیب: package, product, token + ValidateURLFmt string + PackageName string + AccessToken string // در عمل باید از refresh_token تازه‌سازی شود + client *http.Client +} + +// NewBazaarVerifier آداپتر کافه‌بازار. +func NewBazaarVerifier(pkg, accessToken string) *StoreIAPVerifier { + return &StoreIAPVerifier{ + ValidateURLFmt: "https://pardakht.cafebazaar.ir/devapi/v2/api/validate/%s/inapp/%s/purchases/%s/", + PackageName: pkg, + AccessToken: accessToken, + client: &http.Client{Timeout: 6 * time.Second}, + } +} + +// NewMyketVerifier آداپتر مایکت. +func NewMyketVerifier(pkg, accessToken string) *StoreIAPVerifier { + return &StoreIAPVerifier{ + ValidateURLFmt: "https://developer.myket.ir/api/applications/%s/purchases/products/%s/tokens/%s/", + PackageName: pkg, + AccessToken: accessToken, + client: &http.Client{Timeout: 6 * time.Second}, + } +} + +func (v *StoreIAPVerifier) Verify(ctx context.Context, _ string, productID, purchaseToken string) (bool, error) { + if v.AccessToken == "" || purchaseToken == "" { + return false, fmt.Errorf("iap: missing access token or purchase token") + } + url := fmt.Sprintf(v.ValidateURLFmt, v.PackageName, productID, purchaseToken) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + req.Header.Set("Authorization", "Bearer "+v.AccessToken) + resp, err := v.client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return false, nil + } + var out struct { + // هر دو فروشگاه فیلدی شبیه purchaseState برمی‌گردانند (۰ = خریداری‌شده) + PurchaseState int `json:"purchaseState"` + ConsumptionState int `json:"consumptionState"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return false, err + } + return out.PurchaseState == 0, nil +} diff --git a/internal/economy/wallet.go b/internal/economy/wallet.go new file mode 100644 index 0000000..463e353 --- /dev/null +++ b/internal/economy/wallet.go @@ -0,0 +1,74 @@ +package economy + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// ارزها. +const ( + CurrencyCoin = "coin" + CurrencyTicket = "ticket" +) + +var ( + ErrInsufficient = errors.New("insufficient balance") + ErrBadCurrency = errors.New("invalid currency") +) + +// column نام ستون موجودی را برای یک ارز برمی‌گرداند. +func column(currency string) (string, error) { + switch currency { + case CurrencyCoin: + return "coins", nil + case CurrencyTicket: + return "tickets", nil + } + return "", ErrBadCurrency +} + +// adjustTx موجودی را داخل یک تراکنش تغییر داده و در دفتر ثبت می‌کند. +// برای delta منفی، اگر موجودی کافی نباشد ErrInsufficient برمی‌گرداند (بدون تغییر). +func adjustTx(ctx context.Context, tx *sql.Tx, userID int64, currency string, delta int64, reason, ref string) error { + col, err := column(currency) + if err != nil { + return err + } + var res sql.Result + if delta < 0 { + // فقط در صورت کفایت موجودی کم کن (اتمیک) + res, err = tx.ExecContext(ctx, + fmt.Sprintf("UPDATE users SET %s = %s + ? WHERE id = ? AND %s >= ?", col, col, col), + delta, userID, -delta) + } else { + res, err = tx.ExecContext(ctx, + fmt.Sprintf("UPDATE users SET %s = %s + ? WHERE id = ?", col, col), + delta, userID) + } + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrInsufficient + } + _, err = tx.ExecContext(ctx, + "INSERT INTO wallet_tx (user_id, amount, reason, ref, currency) VALUES (?, ?, ?, ?, ?)", + userID, delta, reason, ref, currency) + return err +} + +// Adjust موجودی را در یک تراکنش مستقل تغییر می‌دهد. +func (s *Service) Adjust(ctx context.Context, userID int64, currency string, delta int64, reason, ref string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if err := adjustTx(ctx, tx, userID, currency, delta, reason, ref); err != nil { + return err + } + return tx.Commit() +} diff --git a/internal/game/deck.go b/internal/game/deck.go new file mode 100644 index 0000000..896dfb4 --- /dev/null +++ b/internal/game/deck.go @@ -0,0 +1,129 @@ +package game + +import ( + "crypto/rand" + "fmt" + "math/big" + "strings" +) + +// Suit خال کارت. +type Suit uint8 + +const ( + Spades Suit = iota + Hearts + Diamonds + Clubs +) + +var suitChar = [4]byte{'S', 'H', 'D', 'C'} +var suitName = [4]string{"spades", "hearts", "diamonds", "clubs"} + +func (s Suit) String() string { return suitName[s] } + +// ParseSuit نام انگلیسی خال را به Suit تبدیل می‌کند. +func ParseSuit(name string) (Suit, error) { + switch strings.ToLower(name) { + case "spades", "s": + return Spades, nil + case "hearts", "h": + return Hearts, nil + case "diamonds", "d": + return Diamonds, nil + case "clubs", "c": + return Clubs, nil + } + return 0, fmt.Errorf("invalid suit: %q", name) +} + +// Rank ارزش کارت؛ 2..10 معمولی، 11=J 12=Q 13=K 14=A. +type Rank uint8 + +const ( + Jack Rank = 11 + Queen Rank = 12 + King Rank = 13 + Ace Rank = 14 +) + +// Card یک کارت بازی. +type Card struct { + Suit Suit + Rank Rank +} + +// String کد کوتاه کارت مثل "AS", "10H", "2C". +func (c Card) String() string { + var r string + switch c.Rank { + case Ace: + r = "A" + case King: + r = "K" + case Queen: + r = "Q" + case Jack: + r = "J" + default: + r = fmt.Sprintf("%d", c.Rank) + } + return r + string(suitChar[c.Suit]) +} + +// ParseCard کد کوتاه کارت را پارس می‌کند (مثلاً "10H", "AS"). +func ParseCard(code string) (Card, error) { + code = strings.ToUpper(strings.TrimSpace(code)) + if len(code) < 2 { + return Card{}, fmt.Errorf("invalid card: %q", code) + } + rankStr := code[:len(code)-1] + suitStr := code[len(code)-1:] + + s, err := ParseSuit(suitStr) + if err != nil { + return Card{}, fmt.Errorf("invalid card %q: %w", code, err) + } + + var r Rank + switch rankStr { + case "A": + r = Ace + case "K": + r = King + case "Q": + r = Queen + case "J": + r = Jack + default: + var n int + if _, err := fmt.Sscanf(rankStr, "%d", &n); err != nil || n < 2 || n > 10 { + return Card{}, fmt.Errorf("invalid rank in card %q", code) + } + r = Rank(n) + } + return Card{Suit: s, Rank: r}, nil +} + +// NewDeck یک دسته ۵۲ کارتی مرتب می‌سازد. +func NewDeck() []Card { + deck := make([]Card, 0, 52) + for s := Suit(0); s < 4; s++ { + for r := Rank(2); r <= Ace; r++ { + deck = append(deck, Card{Suit: s, Rank: r}) + } + } + return deck +} + +// Shuffle دسته را با crypto/rand به‌صورت Fisher-Yates بُر می‌زند. +func Shuffle(deck []Card) { + for i := len(deck) - 1; i > 0; i-- { + jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) + if err != nil { + continue // عملاً رخ نمی‌دهد + } + j := int(jBig.Int64()) + deck[i], deck[j] = deck[j], deck[i] + } +} diff --git a/internal/game/hokm.go b/internal/game/hokm.go new file mode 100644 index 0000000..f765607 --- /dev/null +++ b/internal/game/hokm.go @@ -0,0 +1,333 @@ +package game + +import "errors" + +// Phase مرحله بازی. +type Phase int + +const ( + PhaseChooseTrump Phase = iota // حاکم باید حکم بزند + PhasePlaying // در حال بازی دست‌ها (tricks) + PhaseHandOver // یک هَند تمام شد + PhaseGameOver // بازی تمام شد +) + +func (p Phase) String() string { + switch p { + case PhaseChooseTrump: + return "choose_trump" + case PhasePlaying: + return "playing" + case PhaseHandOver: + return "hand_over" + case PhaseGameOver: + return "game_over" + } + return "unknown" +} + +// خطاهای موتور. +var ( + ErrWrongPhase = errors.New("wrong phase") + ErrNotYourTurn = errors.New("not your turn") + ErrNotHakem = errors.New("only hakem can choose trump") + ErrCardNotHeld = errors.New("card not in hand") + ErrMustFollow = errors.New("must follow lead suit") + ErrInvalidSeat = errors.New("invalid seat") +) + +// TrickCard یک کارت انداخته‌شده روی زمین به‌همراه جایگاه بازیکن. +type TrickCard struct { + Seat int `json:"seat"` + Card Card `json:"card"` +} + +// HandResult نتیجه یک هَند. +type HandResult struct { + WinnerTeam int `json:"winner_team"` + Kot bool `json:"kot"` // کُت: تیم بازنده هیچ دستی نبرد + Points int `json:"points"` // امتیاز این هَند (۱ معمولی، ۲ کُت) + NextHakem int `json:"next_hakem"` +} + +// Team تیم یک جایگاه را برمی‌گرداند (0&2 تیم۰، 1&3 تیم۱). +func Team(seat int) int { return seat % 2 } + +// Game وضعیت کامل یک میز حکم؛ فقط در حافظه نگه‌داری می‌شود. +type Game struct { + Phase Phase + Hakem int // جایگاه حاکم + Trump Suit // خال حکم + TrumpChosen bool // آیا حکم انتخاب شده + + hands [4][]Card // کارت هر بازیکن (خصوصی) + deck []Card // باقیمانده دسته برای پخش پس از انتخاب حکم + + Turn int // جایگاه نوبت فعلی + LeadSuit Suit // خال شروع‌کننده دست جاری + leadSet bool // آیا خال زمینه تعیین شده + Trick []TrickCard // کارت‌های دست جاری + TricksWon [2]int // تعداد دست‌های برده هر تیم در هَند جاری + TrickDone bool // دستِ کامل (۴ کارت) منتظر جمع‌آوری + pendingWinner int // برنده‌ی دستِ کاملِ منتظر + + Scores [2]int // امتیاز کلی (تعداد هَند برده) + TargetScore int // امتیاز لازم برای برد بازی + + LastResult *HandResult // نتیجه آخرین هَند (در PhaseHandOver/GameOver) +} + +// NewGame یک میز با حاکم اولیه مشخص می‌سازد و اولین هَند را شروع می‌کند. +// hakem را می‌توان با FirstHakem از روی یک دسته تعیین کرد. +func NewGame(targetScore, hakem int) *Game { + g := &Game{ + Hakem: hakem, + TargetScore: targetScore, + } + g.startHand() + return g +} + +// FirstHakem با پخش کارت دور میز تا اولین آس، جایگاه حاکم اول را تعیین می‌کند. +func FirstHakem() int { + d := NewDeck() + Shuffle(d) + for i, c := range d { + if c.Rank == Ace { + return i % 4 + } + } + return 0 +} + +// startHand دسته را بُر زده، ۵ کارت اول را به حاکم می‌دهد و منتظر انتخاب حکم می‌ماند. +func (g *Game) startHand() { + g.deck = NewDeck() + Shuffle(g.deck) + + for i := range g.hands { + g.hands[i] = nil + } + // حاکم ۵ کارت اول را می‌گیرد و بر اساس آن حکم را انتخاب می‌کند. + g.hands[g.Hakem] = append(g.hands[g.Hakem], g.deck[:5]...) + + g.Phase = PhaseChooseTrump + g.Trump = 0 + g.TrumpChosen = false + g.Turn = g.Hakem + g.LeadSuit = 0 + g.leadSet = false + g.Trick = nil + g.TricksWon = [2]int{} + g.TrickDone = false +} + +// ChooseTrump حکم را توسط حاکم تعیین کرده و بقیه کارت‌ها را پخش می‌کند. +func (g *Game) ChooseTrump(seat int, s Suit) error { + if g.Phase != PhaseChooseTrump { + return ErrWrongPhase + } + if seat != g.Hakem { + return ErrNotHakem + } + g.Trump = s + g.TrumpChosen = true + g.dealRemaining() + + g.Phase = PhasePlaying + g.Turn = g.Hakem // حاکم دست اول را شروع می‌کند + return nil +} + +// dealRemaining باقی کارت‌ها را پخش می‌کند تا هر بازیکن ۱۳ کارت داشته باشد. +func (g *Game) dealRemaining() { + idx := 5 // ۵ کارت اول قبلاً به حاکم داده شد + deal := func(seat int) { + for len(g.hands[seat]) < 13 { + g.hands[seat] = append(g.hands[seat], g.deck[idx]) + idx++ + } + } + deal(g.Hakem) + for s := 0; s < 4; s++ { + if s != g.Hakem { + deal(s) + } + } + g.deck = nil +} + +// PlayCard یک کارت را برای بازیکن seat بازی می‌کند. +func (g *Game) PlayCard(seat int, c Card) error { + if g.Phase != PhasePlaying { + return ErrWrongPhase + } + if g.TrickDone { + return ErrWrongPhase // دستِ کامل هنوز جمع نشده (CollectTrick) + } + if seat < 0 || seat > 3 { + return ErrInvalidSeat + } + if seat != g.Turn { + return ErrNotYourTurn + } + idx := indexOf(g.hands[seat], c) + if idx < 0 { + return ErrCardNotHeld + } + // قانون follow: اگر خال زمینه تعیین شده و بازیکن آن خال را دارد، باید همان را بازی کند. + if g.leadSet && c.Suit != g.LeadSuit && hasSuit(g.hands[seat], g.LeadSuit) { + return ErrMustFollow + } + + // حذف کارت از دست + g.hands[seat] = append(g.hands[seat][:idx], g.hands[seat][idx+1:]...) + + if !g.leadSet { + g.LeadSuit = c.Suit + g.leadSet = true + } + g.Trick = append(g.Trick, TrickCard{Seat: seat, Card: c}) + + if len(g.Trick) < 4 { + g.Turn = (g.Turn + 1) % 4 + return nil + } + + // دست کامل شد ⇒ برنده تعیین می‌شود ولی جمع‌آوری به CollectTrick موکول می‌شود + // تا کلاینت‌ها فرصت دیدن هر ۴ کارت روی زمین را داشته باشند. + g.pendingWinner = g.trickWinner() + g.TrickDone = true + return nil +} + +// CollectTrick دستِ کامل را جمع کرده، برنده را ثبت و نوبت بعد را تنظیم می‌کند. +// پس از PhasePlaying و وقتی TrickDone باشد فراخوانی می‌شود. +func (g *Game) CollectTrick() error { + if !g.TrickDone { + return ErrWrongPhase + } + winner := g.pendingWinner + g.TricksWon[Team(winner)]++ + g.Trick = nil + g.leadSet = false + g.Turn = winner + g.TrickDone = false + g.checkHandOver() + return nil +} + +// trickWinner برنده دست جاری را تعیین می‌کند. +func (g *Game) trickWinner() int { + best := g.Trick[0] + for _, tc := range g.Trick[1:] { + if beats(tc.Card, best.Card, g.Trump, g.LeadSuit) { + best = tc + } + } + return best.Seat +} + +// beats مشخص می‌کند آیا a کارت b را می‌برد (با توجه به حکم و خال زمینه). +func beats(a, b Card, trump, lead Suit) bool { + aTrump := a.Suit == trump + bTrump := b.Suit == trump + switch { + case aTrump && !bTrump: + return true + case !aTrump && bTrump: + return false + case aTrump && bTrump: + return a.Rank > b.Rank + default: + // هیچ‌کدام آتو نیستند: فقط کارت هم‌خالِ زمینه می‌تواند ببرد + if a.Suit != lead { + return false + } + if b.Suit != lead { + return true + } + return a.Rank > b.Rank + } +} + +// checkHandOver بررسی می‌کند آیا تیمی به ۷ دست رسیده و هَند را تسویه می‌کند. +func (g *Game) checkHandOver() { + const tricksToWin = 7 + var winnerTeam = -1 + if g.TricksWon[0] >= tricksToWin { + winnerTeam = 0 + } else if g.TricksWon[1] >= tricksToWin { + winnerTeam = 1 + } + if winnerTeam == -1 { + return + } + + loserTeam := 1 - winnerTeam + kot := g.TricksWon[loserTeam] == 0 + points := 1 + if kot { + points = 2 + } + g.Scores[winnerTeam] += points + + // چرخش حاکمی: اگر تیم حاکم برد، حاکم می‌ماند؛ وگرنه به نفر بعد می‌رسد. + nextHakem := g.Hakem + if Team(g.Hakem) != winnerTeam { + nextHakem = (g.Hakem + 1) % 4 + } + + g.LastResult = &HandResult{ + WinnerTeam: winnerTeam, + Kot: kot, + Points: points, + NextHakem: nextHakem, + } + + if g.Scores[winnerTeam] >= g.TargetScore { + g.Phase = PhaseGameOver + } else { + g.Phase = PhaseHandOver + g.Hakem = nextHakem + } +} + +// NextHand هَند بعدی را شروع می‌کند (پس از PhaseHandOver). +func (g *Game) NextHand() error { + if g.Phase != PhaseHandOver { + return ErrWrongPhase + } + g.startHand() + return nil +} + +// Hand کپی کارت‌های یک بازیکن را برمی‌گرداند. +func (g *Game) Hand(seat int) []Card { + out := make([]Card, len(g.hands[seat])) + copy(out, g.hands[seat]) + return out +} + +// HandCount تعداد کارت‌های هر بازیکن. +func (g *Game) HandCount(seat int) int { return len(g.hands[seat]) } + +// --- کمکی‌ها --- + +func indexOf(hand []Card, c Card) int { + for i, x := range hand { + if x == c { + return i + } + } + return -1 +} + +func hasSuit(hand []Card, s Suit) bool { + for _, c := range hand { + if c.Suit == s { + return true + } + } + return false +} diff --git a/internal/game/hokm_test.go b/internal/game/hokm_test.go new file mode 100644 index 0000000..1cc76dd --- /dev/null +++ b/internal/game/hokm_test.go @@ -0,0 +1,128 @@ +package game + +import "testing" + +func TestParseCardRoundTrip(t *testing.T) { + cases := []string{"AS", "10H", "2C", "KD", "JS", "QH"} + for _, code := range cases { + c, err := ParseCard(code) + if err != nil { + t.Fatalf("parse %q: %v", code, err) + } + if c.String() != code { + t.Errorf("roundtrip %q -> %q", code, c.String()) + } + } +} + +func TestBeats(t *testing.T) { + trump := Hearts + lead := Spades + // آتو بر غیرآتو می‌برد + if !beats(Card{Hearts, 2}, Card{Spades, Ace}, trump, lead) { + t.Error("trump 2 should beat non-trump Ace") + } + // بالاترین خال زمینه بر کارت خال دیگر (غیرآتو) می‌برد + if !beats(Card{Spades, 5}, Card{Diamonds, Ace}, trump, lead) { + t.Error("lead-suit card should beat off-suit card") + } + // کارت خارج از خال زمینه و غیرآتو نمی‌برد + if beats(Card{Diamonds, Ace}, Card{Spades, 2}, trump, lead) { + t.Error("off-suit non-trump should not beat lead suit") + } +} + +func TestMustFollowSuit(t *testing.T) { + g := NewGame(7, 0) + if err := g.ChooseTrump(0, Hearts); err != nil { + t.Fatal(err) + } + // حاکم (seat 0) شروع می‌کند. یک کارت از دستش بازی می‌کنیم. + lead := g.hands[0][0] + if err := g.PlayCard(0, lead); err != nil { + t.Fatal(err) + } + // بازیکن بعدی اگر کارت هم‌خال دارد باید همان را بزند. + next := g.Turn + if hasSuit(g.hands[next], lead.Suit) { + // یک کارت با خال متفاوت پیدا کن + for _, c := range g.hands[next] { + if c.Suit != lead.Suit { + if err := g.PlayCard(next, c); err != ErrMustFollow { + t.Fatalf("expected ErrMustFollow, got %v", err) + } + return + } + } + } +} + +// playFullGame یک بازی کامل را با یک ربات ساده تا پایان پیش می‌برد و +// تمام نامتغیرها (invariants) را بررسی می‌کند. +func TestFullGamePlaysOut(t *testing.T) { + g := NewGame(7, FirstHakem()) + + for hand := 0; g.Phase != PhaseGameOver; hand++ { + if hand > 100 { + t.Fatal("too many hands; engine likely stuck") + } + // انتخاب حکم توسط حاکم + if g.Phase != PhaseChooseTrump { + t.Fatalf("expected choose_trump, got %s", g.Phase) + } + if err := g.ChooseTrump(g.Hakem, g.hands[g.Hakem][0].Suit); err != nil { + t.Fatal(err) + } + // بررسی: هر بازیکن دقیقاً ۱۳ کارت دارد + for s := 0; s < 4; s++ { + if g.HandCount(s) != 13 { + t.Fatalf("seat %d has %d cards after deal", s, g.HandCount(s)) + } + } + + // بازی دست‌ها تا پایان هَند + for g.Phase == PhasePlaying { + if g.TrickDone { + if err := g.CollectTrick(); err != nil { + t.Fatalf("collect trick: %v", err) + } + continue + } + seat := g.Turn + card := legalMove(g, seat) + if err := g.PlayCard(seat, card); err != nil { + t.Fatalf("play %s by seat %d: %v", card, seat, err) + } + } + + // بعد از هَند: یا تمام شده یا hand_over + if g.Phase == PhaseHandOver { + r := g.LastResult + if r.Points < 1 || r.Points > 2 { + t.Fatalf("unexpected points %d", r.Points) + } + if err := g.NextHand(); err != nil { + t.Fatal(err) + } + } + } + + // در پایان یک تیم باید به امتیاز هدف رسیده باشد + if g.Scores[0] < g.TargetScore && g.Scores[1] < g.TargetScore { + t.Fatalf("game over but no team reached target: %v", g.Scores) + } + t.Logf("final scores: %v", g.Scores) +} + +// legalMove یک حرکت مجاز برای ربات انتخاب می‌کند (follow suit در صورت امکان). +func legalMove(g *Game, seat int) Card { + hand := g.hands[seat] + if g.leadSet { + for _, c := range hand { + if c.Suit == g.LeadSuit { + return c + } + } + } + return hand[0] +} diff --git a/internal/httpx/httpx.go b/internal/httpx/httpx.go new file mode 100644 index 0000000..eaab7d8 --- /dev/null +++ b/internal/httpx/httpx.go @@ -0,0 +1,53 @@ +package httpx + +import ( + "encoding/json" + "net/http" + "sync" + "time" +) + +// JSON پاسخ JSON با کد وضعیت می‌نویسد. +func JSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// Error پاسخ خطای استاندارد { "message": ... }. +func Error(w http.ResponseWriter, status int, msg string) { + JSON(w, status, map[string]string{"message": msg}) +} + +// Throttle محدودکننده ساده in-memory بر اساس کلید (مثل IP یا موبایل). +// معادل throttle:max,perMinutes در Laravel — بدون نیاز به Redis. +type Throttle struct { + mu sync.Mutex + hits map[string][]time.Time + max int + window time.Duration +} + +func NewThrottle(max int, window time.Duration) *Throttle { + return &Throttle{hits: make(map[string][]time.Time), max: max, window: window} +} + +// Allow بررسی می‌کند آیا کلید در پنجره زمانی مجاز است. +func (t *Throttle) Allow(key string) bool { + t.mu.Lock() + defer t.mu.Unlock() + now := time.Now() + cutoff := now.Add(-t.window) + kept := t.hits[key][:0] + for _, ts := range t.hits[key] { + if ts.After(cutoff) { + kept = append(kept, ts) + } + } + if len(kept) >= t.max { + t.hits[key] = kept + return false + } + t.hits[key] = append(kept, now) + return true +} diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..8b3171e --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,97 @@ +package store + +import ( + "database/sql" + "embed" + "fmt" + "log/slog" + "strings" + + _ "modernc.org/sqlite" // درایور SQLite خالص Go (بدون cgo) +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Store دسترسی به دیتابیس را کپسوله می‌کند. +type Store struct { + DB *sql.DB +} + +// Open اتصال SQLite را باز کرده و migrations را اجرا می‌کند. +func Open(path string) (*Store, error) { + // pragmaها برای کارایی و یکپارچگی روی سرور کوچک + dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)", path) + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, err + } + // SQLite تک‌نویسنده است؛ pool کوچک نگه می‌داریم. + db.SetMaxOpenConns(1) + if err := db.Ping(); err != nil { + return nil, err + } + s := &Store{DB: db} + if err := s.migrate(); err != nil { + return nil, err + } + return s, nil +} + +func (s *Store) migrate() error { + // جدول ردیابی تا هر migration فقط یک‌بار اجرا شود. + if _, err := s.DB.Exec( + `CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations: %w", err) + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + + var applied int + if err := s.DB.QueryRow( + `SELECT COUNT(*) FROM schema_migrations WHERE name = ?`, name).Scan(&applied); err != nil { + return err + } + if applied > 0 { + continue + } + + b, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return err + } + if _, err := s.DB.Exec(string(b)); err != nil { + // دیتابیس توسعه‌ای که قبل از افزودن ردیابی، این migration را اعمال کرده + // (ستون/جدول از قبل هست) ⇒ به‌عنوان اعمال‌شده علامت می‌زنیم. + if alreadyApplied(err) { + slog.Warn("migration schema already present; marking applied", "name", name) + } else { + return fmt.Errorf("apply %s: %w", name, err) + } + } + if _, err := s.DB.Exec( + `INSERT INTO schema_migrations (name) VALUES (?)`, name); err != nil { + return fmt.Errorf("record %s: %w", name, err) + } + } + return nil +} + +// alreadyApplied تشخیص می‌دهد خطا ناشی از وجود قبلی همان تغییر است. +func alreadyApplied(err error) bool { + m := strings.ToLower(err.Error()) + return strings.Contains(m, "duplicate column") || strings.Contains(m, "already exists") +} + +func (s *Store) Close() error { return s.DB.Close() } diff --git a/internal/store/db_test.go b/internal/store/db_test.go new file mode 100644 index 0000000..66bd53e --- /dev/null +++ b/internal/store/db_test.go @@ -0,0 +1,40 @@ +package store + +import ( + "path/filepath" + "testing" +) + +// TestMigrateIdempotent بررسی می‌کند باز کردن مکرر یک دیتابیس (مثل ری‌استارت سرور) +// migration‌ها را دوباره اجرا نکرده و خطای «duplicate column» ندهد. +func TestMigrateIdempotent(t *testing.T) { + path := filepath.Join(t.TempDir(), "mig.db") + for i := 1; i <= 3; i++ { + s, err := Open(path) + if err != nil { + t.Fatalf("open #%d: %v", i, err) + } + s.Close() + } +} + +// TestMigrateLegacyDB سناریوی دیتابیسِ از قبل migrate‌شده ولی بدون جدول ردیابی +// (مثل DB فعلیِ توسعه) را شبیه‌سازی می‌کند: نباید خطای duplicate column بدهد. +func TestMigrateLegacyDB(t *testing.T) { + path := filepath.Join(t.TempDir(), "legacy.db") + s, err := Open(path) + if err != nil { + t.Fatalf("first open: %v", err) + } + // حذف جدول ردیابی ⇒ سرور فکر می‌کند هیچ migration اجرا نشده، در حالی که ستون‌ها هستند. + if _, err := s.DB.Exec(`DROP TABLE schema_migrations`); err != nil { + t.Fatal(err) + } + s.Close() + + s2, err := Open(path) + if err != nil { + t.Fatalf("reopen legacy db: %v", err) + } + s2.Close() +} diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql new file mode 100644 index 0000000..778edda --- /dev/null +++ b/internal/store/migrations/001_init.sql @@ -0,0 +1,43 @@ +-- اسکیمای اولیه حکم‌شو (SQLite) + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + mobile TEXT NOT NULL UNIQUE, + first_name TEXT, + last_name TEXT, + avatar TEXT, + coins INTEGER NOT NULL DEFAULT 0, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS otp_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_otp_user ON otp_tokens(user_id); + +CREATE TABLE IF NOT EXISTS wallet_tx ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + amount INTEGER NOT NULL, -- مثبت=واریز، منفی=برداشت + reason TEXT NOT NULL, -- مثل: game_win, kot, purchase + ref TEXT, -- شناسه میز/تراکنش مرجع + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_wallet_user ON wallet_tx(user_id); + +CREATE TABLE IF NOT EXISTS game_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room TEXT NOT NULL, + players_json TEXT NOT NULL, + winner_team INTEGER NOT NULL, + kot INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/internal/store/migrations/002_economy.sql b/internal/store/migrations/002_economy.sql new file mode 100644 index 0000000..c594df9 --- /dev/null +++ b/internal/store/migrations/002_economy.sql @@ -0,0 +1,50 @@ +-- اقتصاد: کیف‌پول، بلیط، XP/سطح، VIP، اسکین کارت، پاداش تبلیغ، خریدهای IAP + +ALTER TABLE users ADD COLUMN tickets INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN xp INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN trophies INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN vip_until TEXT; -- زمان پایان VIP (UTC) یا NULL +ALTER TABLE users ADD COLUMN selected_card TEXT NOT NULL DEFAULT 'simple'; +ALTER TABLE users ADD COLUMN last_daily_at TEXT; -- آخرین دریافت سکه روزانه +ALTER TABLE users ADD COLUMN xp_boost_mult INTEGER NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN xp_boost_until TEXT; -- زمان پایان بوستر XP + +-- ستون نوع ارز در دفتر تراکنش (coin | ticket) +ALTER TABLE wallet_tx ADD COLUMN currency TEXT NOT NULL DEFAULT 'coin'; + +-- اسکین کارت‌های متعلق به کاربر (کارت ساده پیش‌فرض و رایگان است) +CREATE TABLE IF NOT EXISTS user_cards ( + user_id INTEGER NOT NULL, + card_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, card_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +-- پاداش تبلیغ rewarded؛ token یکتا برای جلوگیری از دریافت تکراری +CREATE TABLE IF NOT EXISTS ad_rewards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + token TEXT NOT NULL UNIQUE, + amount INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_ad_rewards_user ON ad_rewards(user_id, created_at); + +-- خریدهای درون‌برنامه‌ای (Cafe Bazaar / Myket)؛ purchase_token یکتا به ازای فروشگاه +CREATE TABLE IF NOT EXISTS purchases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + store TEXT NOT NULL, -- bazaar | myket + product_id TEXT NOT NULL, -- SKU بسته + purchase_token TEXT NOT NULL, + status TEXT NOT NULL, -- pending | verified | failed + coins INTEGER NOT NULL DEFAULT 0, + tickets INTEGER NOT NULL DEFAULT 0, + vip_days INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (store, purchase_token), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/internal/user/user.go b/internal/user/user.go new file mode 100644 index 0000000..54b280d --- /dev/null +++ b/internal/user/user.go @@ -0,0 +1,76 @@ +package user + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// User مدل کاربر. +type User struct { + ID int64 `json:"id"` + Mobile string `json:"mobile"` + FirstName *string `json:"first_name"` + LastName *string `json:"last_name"` + Avatar *string `json:"avatar"` + Coins int64 `json:"coins"` + IsAdmin bool `json:"is_admin"` + CreatedAt time.Time `json:"created_at"` +} + +var ErrNotFound = errors.New("user not found") + +// Repo دسترسی به جدول users. +type Repo struct{ db *sql.DB } + +func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} } + +// FindByMobile کاربر را با شماره موبایل پیدا می‌کند. +func (r *Repo) FindByMobile(ctx context.Context, mobile string) (*User, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id, mobile, first_name, last_name, avatar, coins, is_admin, created_at + FROM users WHERE mobile = ?`, mobile) + return scan(row) +} + +// FindByID کاربر را با شناسه پیدا می‌کند. +func (r *Repo) FindByID(ctx context.Context, id int64) (*User, error) { + row := r.db.QueryRowContext(ctx, + `SELECT id, mobile, first_name, last_name, avatar, coins, is_admin, created_at + FROM users WHERE id = ?`, id) + return scan(row) +} + +// Create کاربر جدید می‌سازد. +func (r *Repo) Create(ctx context.Context, mobile string) (*User, error) { + res, err := r.db.ExecContext(ctx, `INSERT INTO users (mobile) VALUES (?)`, mobile) + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return r.FindByID(ctx, id) +} + +// FindOrCreate اگر کاربر نبود می‌سازد (مطابق فلوی login-otp). +func (r *Repo) FindOrCreate(ctx context.Context, mobile string) (*User, error) { + u, err := r.FindByMobile(ctx, mobile) + if errors.Is(err, ErrNotFound) { + return r.Create(ctx, mobile) + } + return u, err +} + +func scan(row *sql.Row) (*User, error) { + var u User + var created string + err := row.Scan(&u.ID, &u.Mobile, &u.FirstName, &u.LastName, &u.Avatar, &u.Coins, &u.IsAdmin, &created) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + u.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", created) + return &u, nil +} diff --git a/internal/ws/client.go b/internal/ws/client.go new file mode 100644 index 0000000..34aabfd --- /dev/null +++ b/internal/ws/client.go @@ -0,0 +1,112 @@ +package ws + +import ( + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 4096 + sendBuffer = 32 +) + +// Client یک اتصال WebSocket یک بازیکن. +type Client struct { + hub *Hub + conn *websocket.Conn + send chan []byte + UserID int64 + Name string + + // done هنگام قطع اتصال بسته می‌شود تا writePump خارج شده و + // trySend دیگر تلاش به ارسال نکند (send هیچ‌گاه close نمی‌شود تا panic رخ ندهد). + done chan struct{} + closeOnce sync.Once + + // توسط goroutine هاب ست می‌شوند (تک‌نویسنده) و فقط توسط آن خوانده می‌شوند. + room *Room + seat int + tier string // نوع میزی که در صفش است (برای تسویه) +} + +// close اتصال را یک‌بار به‌صورت امن می‌بندد. +func (c *Client) close() { + c.closeOnce.Do(func() { + close(c.done) + c.conn.Close() + }) +} + +// readPump پیام‌های ورودی را خوانده و به هاب می‌فرستد. +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.close() + }() + c.conn.SetReadLimit(maxMessageSize) + _ = c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetPongHandler(func(string) error { + return c.conn.SetReadDeadline(time.Now().Add(pongWait)) + }) + + for { + _, raw, err := c.conn.ReadMessage() + if err != nil { + return + } + var msg inboundMsg + if err := json.Unmarshal(raw, &msg); err != nil { + c.trySend(mustJSON(errorMsg{Type: "error", Message: "invalid message"})) + continue + } + c.hub.inbound <- inbound{client: c, msg: msg} + } +} + +// writePump پیام‌های خروجی و ping را به سوکت می‌نویسد. +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.close() + }() + for { + select { + case <-c.done: + return + case msg := <-c.send: + _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ticker.C: + _ = c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +// trySend ارسال غیرمسدودکننده؛ اگر اتصال بسته یا بافر پر بود پیام را می‌اندازد. +// send هیچ‌گاه close نمی‌شود؛ پس از done صرفاً پیام دور ریخته می‌شود (بدون panic). +func (c *Client) trySend(b []byte) { + select { + case <-c.done: + case c.send <- b: + default: + slog.Warn("client send buffer full, dropping message", "user", c.UserID) + } +} + +func mustJSON(v any) []byte { + b, _ := json.Marshal(v) + return b +} diff --git a/internal/ws/hub.go b/internal/ws/hub.go new file mode 100644 index 0000000..bfdfeec --- /dev/null +++ b/internal/ws/hub.go @@ -0,0 +1,306 @@ +package ws + +import ( + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/gorilla/websocket" +) + +// مقادیر پیش‌فرض مقاوم‌سازی (قابل بازنویسی در تست). +const ( + defaultTurnTimeout = 20 * time.Second // مهلت نوبت بازیکنِ انسان + defaultBotDelay = 800 * time.Millisecond // تأخیر حرکت بات/قطع‌شده (حس طبیعی + فرصت بازگشت) + defaultMatchWait = 12 * time.Second // مهلت پر شدن میز با انسان پیش از افزودن بات + defaultTrickHold = 1200 * time.Millisecond // مدت نمایش دستِ کامل پیش از جمع‌آوری +) + +// AuthFunc توکن را تأیید کرده و (شناسه کاربر، نام نمایشی) را برمی‌گرداند. +type AuthFunc func(token string) (userID int64, name string, err error) + +// inbound پیام ورودی یک کلاینت برای پردازش در هاب. +type inbound struct { + client *Client + msg inboundMsg +} + +// location محل بازیکن در حال بازی (برای reconnect). +type location struct { + room *Room + seat int +} + +// endInfo اطلاعاتی که میز هنگام بسته‌شدن به هاب می‌دهد تا نگاشت‌ها پاک شوند. +type endInfo struct { + room *Room + humanIDs []int64 + clients []*Client +} + +// Hub هماهنگ‌کننده مرکزی: اتصال‌ها، صف matchmaking و مسیریابی پیام‌ها. +// تمام state آن فقط توسط goroutine Run تغییر می‌کند (single-writer، بدون قفل). +// پس از ساخت یک میز، هاب دیگر به seatInfo دست نمی‌زند (مالک آن goroutine میز است). +type Hub struct { + auth AuthFunc + upgrader websocket.Upgrader + + clients map[*Client]bool + queues map[string][]*Client // صف انتظار به ازای هر tier + locations map[int64]location // userID → محل بازی (برای reconnect) + roomSeq int + botSeq int + fillPending map[string]bool + settler Settler + + turnTimeout time.Duration + botDelay time.Duration + matchWait time.Duration + trickHold time.Duration + + register chan *Client + unregister chan *Client + inbound chan inbound + endRoom chan endInfo + fill chan string // tier برای پر کردن با بات +} + +func NewHub(auth AuthFunc) *Hub { + return &Hub{ + auth: auth, + upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + // در پروداکشن مبدأ را محدود کنید؛ فعلاً برای توسعه باز است. + CheckOrigin: func(r *http.Request) bool { return true }, + }, + clients: make(map[*Client]bool), + queues: make(map[string][]*Client), + locations: make(map[int64]location), + fillPending: make(map[string]bool), + settler: noopSettler{}, + turnTimeout: defaultTurnTimeout, + botDelay: defaultBotDelay, + matchWait: defaultMatchWait, + trickHold: defaultTrickHold, + register: make(chan *Client), + unregister: make(chan *Client), + inbound: make(chan inbound, 64), + endRoom: make(chan endInfo), + fill: make(chan string, 8), + } +} + +// SetSettler تسویه‌گر اقتصادی را تنظیم می‌کند (پیش از Run). +func (h *Hub) SetSettler(s Settler) { h.settler = s } + +const defaultTier = "beginner" + +// Run حلقه اصلی هاب (در یک goroutine اجرا شود). +func (h *Hub) Run() { + for { + select { + case c := <-h.register: + h.handleRegister(c) + case c := <-h.unregister: + h.handleDisconnect(c) + case in := <-h.inbound: + h.handle(in) + case e := <-h.endRoom: + h.closeRoom(e) + case tier := <-h.fill: + h.onFill(tier) + } + } +} + +// handleRegister اتصال جدید را ثبت می‌کند؛ اگر کاربر در حال بازی بود، reconnect می‌شود. +func (h *Hub) handleRegister(c *Client) { + h.clients[c] = true + if loc, ok := h.locations[c.UserID]; ok { + c.room = loc.room + c.seat = loc.seat + select { + case loc.room.actions <- roomAction{kind: akReconnect, seat: loc.seat, client: c}: + default: + } + slog.Info("player reconnecting", "user", c.UserID, "room", loc.room.ID) + } +} + +func (h *Hub) handle(in inbound) { + c := in.client + switch in.msg.Type { + case "join_queue": + if c.room == nil { + tier := in.msg.Tier + if tier == "" { + tier = defaultTier + } + h.enqueue(c, tier) + } + case "choose_trump", "play_card", "leave": + if c.room != nil { + if in.msg.Type == "leave" { + delete(h.locations, c.UserID) // پس از خروج دائمی، reconnect نشود + } + select { + case c.room.actions <- roomAction{kind: akInput, seat: c.seat, client: c, msg: in.msg}: + default: + } + } + } +} + +// enqueue کلاینت را پس از کسر ورودی به صفِ tier افزوده و میزهای کامل را می‌سازد. +func (h *Hub) enqueue(c *Client, tier string) { + if c.room != nil { + return + } + for _, q := range h.queues[tier] { + if q == c { + return + } + } + // کسر ورودی میز؛ اگر سکه کافی نبود، اجازه ورود به صف داده نمی‌شود. + if err := h.settler.ChargeEntry(c.UserID, tier); err != nil { + c.trySend(mustJSON(errorMsg{Type: "error", Message: "insufficient coins"})) + return + } + c.tier = tier + h.queues[tier] = append(h.queues[tier], c) + for len(h.queues[tier]) >= 4 { + h.formTable(tier, 4) + } + h.maybeArmFill(tier) +} + +// onFill با سررسید مهلت، میز ناقصِ یک tier را با بات کامل می‌کند. +func (h *Hub) onFill(tier string) { + h.fillPending[tier] = false + n := len(h.queues[tier]) + if n == 0 { + return + } + if n > 4 { + n = 4 + } + h.formTable(tier, n) + h.maybeArmFill(tier) +} + +// maybeArmFill در صورت وجود بازیکن در صفِ tier، تایمر پر کردن با بات را مسلح می‌کند. +func (h *Hub) maybeArmFill(tier string) { + if len(h.queues[tier]) > 0 && !h.fillPending[tier] { + h.fillPending[tier] = true + time.AfterFunc(h.matchWait, func() { + select { + case h.fill <- tier: + default: + } + }) + } +} + +// formTable یک میزِ tier با nHumans بازیکن از ابتدای صف و بات برای بقیه می‌سازد. +func (h *Hub) formTable(tier string, nHumans int) { + var seats [4]*seatInfo + for i := 0; i < nHumans; i++ { + c := h.queues[tier][i] + seats[i] = &seatInfo{client: c, userID: c.UserID, name: c.Name, connected: true} + } + h.queues[tier] = h.queues[tier][nHumans:] + for i := nHumans; i < 4; i++ { + h.botSeq++ + seats[i] = &seatInfo{isBot: true, name: fmt.Sprintf("ربات %d", h.botSeq)} + } + + h.roomSeq++ + room := newRoom(fmt.Sprintf("r%d", h.roomSeq), seats, h) + room.tier = tier + // ست‌کردن اشاره‌گرها و نگاشت reconnect پیش از شروع goroutine میز (happens-before). + for i := 0; i < nHumans; i++ { + c := seats[i].client + c.room = room + c.seat = i + h.locations[c.UserID] = location{room: room, seat: i} + } + go room.run() + slog.Info("room created", "room", room.ID, "tier", tier, "humans", nHumans, "bots", 4-nHumans) +} + +// handleDisconnect قطع اتصال یک کلاینت را مدیریت می‌کند (نگاشت reconnect حفظ می‌شود). +func (h *Hub) handleDisconnect(c *Client) { + if !h.clients[c] { + return + } + delete(h.clients, c) + c.close() // writePump را خاتمه می‌دهد؛ send بسته نمی‌شود تا room بدون panic بتواند trySend کند + + // اگر در صف بود، حذف و ورودی بازگردانده شود (هنوز بازی شروع نشده). + if q := h.queues[c.tier]; len(q) > 0 { + for i, x := range q { + if x == c { + h.queues[c.tier] = append(q[:i], q[i+1:]...) + h.settler.Refund(c.UserID, c.tier) + break + } + } + } + if c.room != nil { + select { + case c.room.actions <- roomAction{kind: akDisconnect, seat: c.seat, client: c}: + default: + } + } +} + +// closeRoom پس از پایان بازی، نگاشت‌ها و اشاره‌گرهای میز را پاک می‌کند. +func (h *Hub) closeRoom(e endInfo) { + for _, id := range e.humanIDs { + if loc, ok := h.locations[id]; ok && loc.room == e.room { + delete(h.locations, id) + } + } + for _, c := range e.clients { + if c.room == e.room { + c.room = nil + c.seat = 0 + } + } + slog.Info("room closed", "room", e.room.ID) +} + +// ServeWS اتصال WebSocket را پس از تأیید توکن برقرار می‌کند. +// توکن از پارامتر کوئری ?token= یا هدر Authorization خوانده می‌شود. +func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + if token == "" { + if a := r.Header.Get("Authorization"); len(a) > 7 && a[:7] == "Bearer " { + token = a[7:] + } + } + userID, name, err := h.auth(token) + if err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + conn, err := h.upgrader.Upgrade(w, r, nil) + if err != nil { + return // upgrader خودش پاسخ خطا را نوشته + } + + c := &Client{ + hub: h, + conn: conn, + send: make(chan []byte, sendBuffer), + done: make(chan struct{}), + UserID: userID, + Name: name, + } + h.register <- c + go c.writePump() + go c.readPump() +} diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go new file mode 100644 index 0000000..54e66c6 --- /dev/null +++ b/internal/ws/protocol.go @@ -0,0 +1,131 @@ +package ws + +import "hakemsho/internal/game" + +// inboundMsg پیام دریافتی از کلاینت. +type inboundMsg struct { + Type string `json:"type"` // join_queue | choose_trump | play_card | leave + Mode string `json:"mode"` // برای join_queue + Tier string `json:"tier"` // برای join_queue: نوع میز (beginner/pro/...) + Suit string `json:"suit"` // برای choose_trump: hearts|spades|diamonds|clubs + Card string `json:"card"` // برای play_card: مثل "AS" +} + +// PlayerInfo اطلاعات عمومی یک بازیکن سر میز. +type PlayerInfo struct { + Seat int `json:"seat"` + Name string `json:"name"` + Bot bool `json:"bot"` + Connected bool `json:"connected"` +} + +// matchedMsg به کلاینت پس از تشکیل میز. +type matchedMsg struct { + Type string `json:"type"` // "matched" + Room string `json:"room"` + Seat int `json:"seat"` + Players []PlayerInfo `json:"players"` +} + +// trickView یک کارت روی زمین. +type trickView struct { + Seat int `json:"seat"` + Card string `json:"card"` +} + +// stateMsg نمای مجاز بازی برای یک بازیکن خاص. +type stateMsg struct { + Type string `json:"type"` // "state" + Room string `json:"room"` + Phase string `json:"phase"` + YourSeat int `json:"your_seat"` + Hakem int `json:"hakem"` + Turn int `json:"turn"` + Trump string `json:"trump,omitempty"` // فقط پس از انتخاب حکم + TrickDone bool `json:"trick_done"` // دستِ کامل در حال نمایش (بازی ممنوع) + YourHand []string `json:"your_hand"` + HandCounts [4]int `json:"hand_counts"` + Trick []trickView `json:"trick"` + LeadSuit string `json:"lead_suit,omitempty"` + TricksWon [2]int `json:"tricks_won"` + Scores [2]int `json:"scores"` + TargetScore int `json:"target_score"` + Players []PlayerInfo `json:"players"` +} + +// handOverMsg نتیجه یک هَند. +type handOverMsg struct { + Type string `json:"type"` // "hand_over" + WinnerTeam int `json:"winner_team"` + Kot bool `json:"kot"` + Points int `json:"points"` + Scores [2]int `json:"scores"` +} + +// gameOverMsg پایان بازی. +type gameOverMsg struct { + Type string `json:"type"` // "game_over" + WinnerTeam int `json:"winner_team"` + Scores [2]int `json:"scores"` +} + +// errorMsg خطا برای کلاینت. +type errorMsg struct { + Type string `json:"type"` // "error" + Message string `json:"message"` +} + +// playerLeftMsg خروج دائمی یک بازیکن (تبدیل به بات). +type playerLeftMsg struct { + Type string `json:"type"` // "player_left" + Seat int `json:"seat"` +} + +// playerStatusMsg تغییر وضعیت اتصال یک بازیکن (موقت). +type playerStatusMsg struct { + Type string `json:"type"` // "player_disconnected" | "player_reconnected" + Seat int `json:"seat"` +} + +// buildState نمای بازی را برای جایگاه seat می‌سازد (کارت بقیه مخفی می‌ماند). +func buildState(g *game.Game, roomID string, seat int, players []PlayerInfo) stateMsg { + hand := g.Hand(seat) + handStr := make([]string, len(hand)) + for i, c := range hand { + handStr[i] = c.String() + } + + var counts [4]int + for s := 0; s < 4; s++ { + counts[s] = g.HandCount(s) + } + + trick := make([]trickView, 0, len(g.Trick)) + for _, tc := range g.Trick { + trick = append(trick, trickView{Seat: tc.Seat, Card: tc.Card.String()}) + } + + st := stateMsg{ + Type: "state", + Room: roomID, + Phase: g.Phase.String(), + TrickDone: g.TrickDone, + YourSeat: seat, + Hakem: g.Hakem, + Turn: g.Turn, + YourHand: handStr, + HandCounts: counts, + Trick: trick, + TricksWon: g.TricksWon, + Scores: g.Scores, + TargetScore: g.TargetScore, + Players: players, + } + if g.TrumpChosen { + st.Trump = g.Trump.String() + } + if len(g.Trick) > 0 { + st.LeadSuit = g.LeadSuit.String() + } + return st +} diff --git a/internal/ws/room.go b/internal/ws/room.go new file mode 100644 index 0000000..5ed64ea --- /dev/null +++ b/internal/ws/room.go @@ -0,0 +1,454 @@ +package ws + +import ( + "log/slog" + "time" + + "hakemsho/internal/game" +) + +const targetScore = 7 // امتیاز لازم برای برد بازی + +// actionKind نوع پیام ورودی به goroutine میز. +type actionKind int + +const ( + akInput actionKind = iota // ورودی بازیکن (choose_trump/play_card/leave) + akTimeout // سررسید تایمر نوبت + akDisconnect // قطع اتصال یک بازیکن + akReconnect // اتصال مجدد یک بازیکن + akCollect // جمع‌آوری دستِ کامل پس از نمایش +) + +// roomAction یک رویداد برای پردازش در goroutine میز. +type roomAction struct { + kind actionKind + seat int + client *Client // برای akReconnect و تأیید فرستنده در akInput/akDisconnect + gen int // برای akTimeout: نسل تایمر (تشخیص تایمر کهنه) + msg inboundMsg // برای akInput +} + +// seatInfo وضعیت یک جایگاه؛ فقط توسط goroutine میز خوانده/نوشته می‌شود (single-writer). +type seatInfo struct { + client *Client + isBot bool + userID int64 + name string + connected bool + left bool // خروج دائمی داوطلبانه (در تسویه از بازگشت ورودی محروم) +} + +// auto مشخص می‌کند جایگاه باید خودکار بازی شود (بات یا قطع‌شده). +func (s *seatInfo) auto() bool { return s.isBot || !s.connected } + +// Room یک میز بازی؛ تنها goroutine خودش روی state بازی و seatها می‌نویسد. +type Room struct { + ID string + hub *Hub + tier string + settler Settler + seats [4]*seatInfo + game *game.Game + actions chan roomAction + ended bool + turnGen int + timer *time.Timer // تایمر فعال (نوبت یا جمع‌آوری)؛ فقط goroutine میز به آن دست می‌زند + turnTimeout time.Duration + botDelay time.Duration + trickHold time.Duration +} + +func newRoom(id string, seats [4]*seatInfo, hub *Hub) *Room { + return &Room{ + ID: id, + hub: hub, + settler: hub.settler, + seats: seats, + actions: make(chan roomAction, 64), + turnTimeout: hub.turnTimeout, + botDelay: hub.botDelay, + trickHold: hub.trickHold, + } +} + +// run حلقه اصلی میز. +func (r *Room) run() { + r.start() + for act := range r.actions { + if r.ended { + continue + } + switch act.kind { + case akInput: + r.handleInput(act) + case akTimeout: + if act.gen == r.turnGen { + r.autoMove(act.seat) + } + case akDisconnect: + r.handleDisconnect(act.seat, act.client) + case akReconnect: + r.handleReconnect(act.seat, act.client) + case akCollect: + r.collect() + } + if r.ended { + return // بازی تمام شد ⇒ خروج از goroutine + } + } +} + +// start بازی را ساخته، matched را برای انسان‌ها فرستاده و وضعیت اولیه را پخش می‌کند. +func (r *Room) start() { + r.game = game.NewGame(targetScore, game.FirstHakem()) + for seat, s := range r.seats { + if s.client != nil { + s.client.trySend(mustJSON(r.matchedFor(seat))) + } + } + r.react() +} + +func (r *Room) matchedFor(seat int) matchedMsg { + return matchedMsg{Type: "matched", Room: r.ID, Seat: seat, Players: r.playersInfo()} +} + +// react پس از هر تغییر state: نتیجه را اعلام و تایمر بازیگر بعدی را مسلح می‌کند. +func (r *Room) react() { + // دستِ کامل (۴ کارت) را نشان بده، کمی نگه دار، سپس جمع کن. + if r.game.TrickDone { + r.broadcastState() + r.scheduleCollect() + return + } + switch r.game.Phase { + case game.PhaseHandOver: + res := r.game.LastResult + r.broadcast(mustJSON(handOverMsg{ + Type: "hand_over", WinnerTeam: res.WinnerTeam, Kot: res.Kot, + Points: res.Points, Scores: r.game.Scores, + })) + if err := r.game.NextHand(); err != nil { + slog.Error("next hand", "room", r.ID, "err", err) + r.refundAll() + r.endRoom() + return + } + r.broadcastState() + r.armTimer() + case game.PhaseGameOver: + res := r.game.LastResult + r.broadcast(mustJSON(gameOverMsg{ + Type: "game_over", WinnerTeam: res.WinnerTeam, Scores: r.game.Scores, + })) + r.settleGameOver(res) + r.endRoom() + default: // choose_trump یا playing + r.broadcastState() + r.armTimer() + } +} + +// stopTimer تایمر فعال را لغو می‌کند تا پیام‌های کهنه کانال را پر نکنند. +func (r *Room) stopTimer() { + if r.timer != nil { + r.timer.Stop() + r.timer = nil + } +} + +// scheduleCollect پس از نمایش دستِ کامل، جمع‌آوری را زمان‌بندی می‌کند. +func (r *Room) scheduleCollect() { + r.stopTimer() + r.turnGen++ // هر تایمر نوبتِ کهنه را بی‌اثر کن + r.timer = time.AfterFunc(r.trickHold, func() { + select { + case r.actions <- roomAction{kind: akCollect}: + default: + } + }) +} + +// collect دستِ کامل را جمع کرده و به وضعیت بعدی می‌رود. +func (r *Room) collect() { + if err := r.game.CollectTrick(); err != nil { + return // قبلاً جمع شده یا نامعتبر + } + r.react() +} + +// armTimer برای بازیگر فعلی (حاکم در انتخاب حکم، یا نوبت‌دار در بازی) تایمر می‌گذارد. +func (r *Room) armTimer() { + r.stopTimer() + if r.game.TrickDone { + return // در زمان نمایش دستِ کامل تایمر نوبت نگذار + } + var actor int + switch r.game.Phase { + case game.PhaseChooseTrump: + actor = r.game.Hakem + case game.PhasePlaying: + actor = r.game.Turn + default: + return + } + r.turnGen++ + gen := r.turnGen + delay := r.turnTimeout + if r.seats[actor].auto() { + delay = r.botDelay + } + r.timer = time.AfterFunc(delay, func() { + select { + case r.actions <- roomAction{kind: akTimeout, seat: actor, gen: gen}: + default: + } + }) +} + +// handleInput یک ورودی معتبر از بازیکن را اعمال می‌کند. +func (r *Room) handleInput(act roomAction) { + s := r.seats[act.seat] + if s.client == nil || s.client != act.client { + return // فرستنده دیگر مالک این جایگاه نیست (کهنه) + } + switch act.msg.Type { + case "leave": + r.handleLeave(act.seat) + case "choose_trump": + suit, err := game.ParseSuit(act.msg.Suit) + if err != nil { + r.sendError(act.seat, "invalid suit") + return + } + if err := r.game.ChooseTrump(act.seat, suit); err != nil { + r.sendError(act.seat, err.Error()) + return + } + r.react() + case "play_card": + card, err := game.ParseCard(act.msg.Card) + if err != nil { + r.sendError(act.seat, "invalid card") + return + } + if err := r.game.PlayCard(act.seat, card); err != nil { + r.sendError(act.seat, err.Error()) + return + } + r.react() + } +} + +// autoMove حرکت خودکار برای جایگاه (بات/قطع‌شده/AFK) را انجام می‌دهد. +func (r *Room) autoMove(seat int) { + switch r.game.Phase { + case game.PhaseChooseTrump: + if seat != r.game.Hakem { + return + } + r.autoChooseTrump(seat) + r.react() + case game.PhasePlaying: + if seat != r.game.Turn { + return + } + r.autoPlayCard(seat) + r.react() + } +} + +// autoChooseTrump خالی را انتخاب می‌کند که بازیکن بیشترین کارت از آن را دارد. +func (r *Room) autoChooseTrump(seat int) { + hand := r.game.Hand(seat) + if len(hand) == 0 { + return + } + var counts [4]int + for _, c := range hand { + counts[c.Suit]++ + } + best, bestN := hand[0].Suit, -1 + for s := game.Suit(0); s < 4; s++ { + if counts[s] > bestN { + bestN, best = counts[s], s + } + } + _ = r.game.ChooseTrump(seat, best) +} + +// autoPlayCard یک کارت مجاز (پایین‌ترین کارتِ خالِ زمینه، وگرنه پایین‌ترین کارت) بازی می‌کند. +func (r *Room) autoPlayCard(seat int) { + hand := r.game.Hand(seat) + if len(hand) == 0 { + return + } + var choice game.Card + found := false + if len(r.game.Trick) > 0 { // خالِ زمینه تعیین شده + lead := r.game.LeadSuit + for _, c := range hand { + if c.Suit == lead && (!found || c.Rank < choice.Rank) { + choice, found = c, true + } + } + } + if !found { + choice = hand[0] + for _, c := range hand { + if c.Rank < choice.Rank { + choice = c + } + } + } + _ = r.game.PlayCard(seat, choice) +} + +// handleLeave خروج دائمی یک بازیکن؛ جایگاهش به بات تبدیل می‌شود. +func (r *Room) handleLeave(seat int) { + s := r.seats[seat] + s.isBot = true + s.connected = false + s.left = true + s.client = nil + r.broadcast(mustJSON(playerLeftMsg{Type: "player_left", Seat: seat})) + if r.connectedHumans() == 0 { + r.refundAll() + r.endRoom() + return + } + r.broadcastState() + r.armTimer() +} + +// handleDisconnect قطع موقت یک بازیکن؛ جایگاه برای بازگشت باز می‌ماند و خودکار بازی می‌شود. +func (r *Room) handleDisconnect(seat int, client *Client) { + s := r.seats[seat] + if s.client != client { + return // قبلاً با کلاینت جدید جایگزین شده (کهنه) + } + s.connected = false + s.client = nil + r.broadcast(mustJSON(playerStatusMsg{Type: "player_disconnected", Seat: seat})) + if r.connectedHumans() == 0 { + r.refundAll() + r.endRoom() + return + } + r.broadcastState() + r.armTimer() +} + +// handleReconnect اتصال مجدد یک بازیکن به جایگاه قبلی. +func (r *Room) handleReconnect(seat int, client *Client) { + s := r.seats[seat] + s.client = client + s.connected = true + r.broadcast(mustJSON(playerStatusMsg{Type: "player_reconnected", Seat: seat})) + // وضعیت کامل را برای بازیکن بازگشته بفرست + client.trySend(mustJSON(r.matchedFor(seat))) + client.trySend(mustJSON(buildState(r.game, r.ID, seat, r.playersInfo()))) + r.armTimer() // مهلت نوبت برای انسانِ بازگشته دوباره بلند می‌شود +} + +// endRoom میز را بسته و فهرست انسان‌ها را برای پاک‌سازی به هاب می‌دهد. +func (r *Room) endRoom() { + if r.ended { + return + } + r.ended = true + var ids []int64 + var clients []*Client + for _, s := range r.seats { + if s.isBot { + continue + } + ids = append(ids, s.userID) + if s.client != nil { + clients = append(clients, s.client) + } + } + r.hub.endRoom <- endInfo{room: r, humanIDs: ids, clients: clients} +} + +// settleGameOver جایزه را به برندگانِ انسان می‌دهد و بازی را ثبت می‌کند. +func (r *Room) settleGameOver(res *game.HandResult) { + for seat, s := range r.seats { + if s.isBot || s.userID == 0 { + continue + } + if game.Team(seat) == res.WinnerTeam { + r.settler.AwardWinner(s.userID, r.tier) + } + } + r.settler.RecordGame(r.ID, r.playersJSON(res.WinnerTeam), res.WinnerTeam, res.Kot) +} + +// refundAll ورودی را به بازیکنانِ انسان که داوطلبانه خارج نشده‌اند بازمی‌گرداند. +func (r *Room) refundAll() { + for _, s := range r.seats { + if s.isBot || s.left || s.userID == 0 { + continue + } + r.settler.Refund(s.userID, r.tier) + } +} + +// playersJSON خلاصه‌ی بازیکنان را برای ثبت تاریخچه می‌سازد. +func (r *Room) playersJSON(winnerTeam int) string { + type rec struct { + Seat int `json:"seat"` + Name string `json:"name"` + Bot bool `json:"bot"` + Won bool `json:"won"` + } + out := make([]rec, 4) + for i, s := range r.seats { + out[i] = rec{Seat: i, Name: s.name, Bot: s.isBot, Won: game.Team(i) == winnerTeam} + } + return string(mustJSON(out)) +} + +func (r *Room) connectedHumans() int { + n := 0 + for _, s := range r.seats { + if !s.isBot && s.connected && s.client != nil { + n++ + } + } + return n +} + +func (r *Room) playersInfo() []PlayerInfo { + out := make([]PlayerInfo, 4) + for i, s := range r.seats { + out[i] = PlayerInfo{Seat: i, Name: s.name, Bot: s.isBot, Connected: s.isBot || s.connected} + } + return out +} + +// broadcastState نمای اختصاصی هر بازیکنِ انسانِ متصل را برای او ارسال می‌کند. +func (r *Room) broadcastState() { + players := r.playersInfo() + for seat, s := range r.seats { + if s.client != nil { + s.client.trySend(mustJSON(buildState(r.game, r.ID, seat, players))) + } + } +} + +// broadcast یک پیام یکسان را برای همه بازیکنانِ انسانِ متصل ارسال می‌کند. +func (r *Room) broadcast(b []byte) { + for _, s := range r.seats { + if s.client != nil { + s.client.trySend(b) + } + } +} + +func (r *Room) sendError(seat int, msg string) { + if c := r.seats[seat].client; c != nil { + c.trySend(mustJSON(errorMsg{Type: "error", Message: msg})) + } +} diff --git a/internal/ws/settler.go b/internal/ws/settler.go new file mode 100644 index 0000000..d928980 --- /dev/null +++ b/internal/ws/settler.go @@ -0,0 +1,18 @@ +package ws + +// Settler تسویه‌ی اقتصادی میز را انجام می‌دهد (پیاده‌سازی در لایه economy). +// لایه ws از جزئیات سکه/جایزه بی‌خبر است و فقط tier را پاس می‌دهد. +type Settler interface { + ChargeEntry(userID int64, tier string) error // کسر ورودی هنگام ورود به صف + Refund(userID int64, tier string) // بازگرداندن ورودی در صورت لغو + AwardWinner(userID int64, tier string) // جایزه/XP/جام به برنده + RecordGame(room, playersJSON string, winnerTeam int, kot bool) +} + +// noopSettler پیش‌فرض (برای تست‌ها و حالت بدون اقتصاد). +type noopSettler struct{} + +func (noopSettler) ChargeEntry(int64, string) error { return nil } +func (noopSettler) Refund(int64, string) {} +func (noopSettler) AwardWinner(int64, string) {} +func (noopSettler) RecordGame(string, string, int, bool) {} diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go new file mode 100644 index 0000000..9bb9d20 --- /dev/null +++ b/internal/ws/ws_test.go @@ -0,0 +1,459 @@ +package ws + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + + "hakemsho/internal/game" +) + +// anyMsg تمام فیلدهای ممکن پیام‌های سرور را برای تست جمع می‌کند. +type anyMsg struct { + Type string `json:"type"` + Seat int `json:"seat"` + YourSeat int `json:"your_seat"` + Phase string `json:"phase"` + Hakem int `json:"hakem"` + Turn int `json:"turn"` + YourHand []string `json:"your_hand"` + LeadSuit string `json:"lead_suit"` + TrickDone bool `json:"trick_done"` + Players []PlayerInfo `json:"players"` +} + +// TestFourPlayersFullGame یک بازی کامل را روی WebSocket واقعی با ۴ ربات اجرا می‌کند. +func TestFourPlayersFullGame(t *testing.T) { + hub := testHub() // مهلت‌های کوتاه (شامل trickHold) برای سرعت تست + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + var wg sync.WaitGroup + + for i := 0; i < 4; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx), nil) + if err != nil { + t.Errorf("dial %d: %v", idx, err) + return + } + defer conn.Close() + + // ورود به صف + if err := conn.WriteJSON(map[string]string{"type": "join_queue", "mode": "normal"}); err != nil { + t.Errorf("join %d: %v", idx, err) + return + } + + seat := -1 + for { + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + t.Errorf("read %d (seat %d): %v", idx, seat, err) + return + } + var m anyMsg + if err := json.Unmarshal(raw, &m); err != nil { + t.Errorf("unmarshal %d: %v", idx, err) + return + } + switch m.Type { + case "matched": + seat = m.Seat + case "state": + act(conn, &m) + case "game_over": + return + case "error": + t.Errorf("client %d got error: %s", idx, raw) + } + } + }(i) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(25 * time.Second): + t.Fatal("game did not finish in time") + } +} + +// act ربات سمت‌کلاینت: حکم می‌زند یا کارت مجاز بازی می‌کند. +// در فاز انتخاب حکم، حاکم دقیقاً یک state دریافت می‌کند پس یک‌بار حکم می‌زند (هر هَند). +func act(conn *websocket.Conn, m *anyMsg) { + switch m.Phase { + case "choose_trump": + if m.YourSeat == m.Hakem && len(m.YourHand) > 0 { + c, _ := game.ParseCard(m.YourHand[0]) + _ = conn.WriteJSON(map[string]string{"type": "choose_trump", "suit": c.Suit.String()}) + } + case "playing": + if m.TrickDone || m.Turn != m.YourSeat { + return + } + card := legalCard(m.YourHand, m.LeadSuit) + _ = conn.WriteJSON(map[string]string{"type": "play_card", "card": card}) + } +} + +// testHub یک هاب با مهلت‌های کوتاه برای تست‌ها می‌سازد. +func testHub() *Hub { + hub := NewHub(func(token string) (int64, string, error) { + id, err := strconv.ParseInt(token, 10, 64) + return id, "p" + token, err + }) + hub.turnTimeout = 8 * time.Millisecond + hub.botDelay = 8 * time.Millisecond + hub.matchWait = 300 * time.Millisecond + hub.trickHold = 5 * time.Millisecond + go hub.Run() + return hub +} + +// TestDisconnectMidGameNoPanic: قطع بازیکن وسط بازی باید بدون panic، رویداد +// player_disconnected تولید کند و بازی با حرکت خودکار ادامه یابد. +func TestDisconnectMidGameNoPanic(t *testing.T) { + hub := testHub() + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + conns := make([]*websocket.Conn, 4) + for i := 0; i < 4; i++ { + c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i), nil) + if err != nil { + t.Fatalf("dial %d: %v", i, err) + } + conns[i] = c + if err := c.WriteJSON(map[string]string{"type": "join_queue"}); err != nil { + t.Fatalf("join %d: %v", i, err) + } + } + time.Sleep(200 * time.Millisecond) + conns[0].Close() + + gotDisconnect := false + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + _ = conns[1].SetReadDeadline(time.Now().Add(2 * time.Second)) + _, raw, err := conns[1].ReadMessage() + if err != nil { + break + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + if m.Type == "player_disconnected" { + gotDisconnect = true + break + } + } + for _, c := range conns[1:] { + c.Close() + } + if !gotDisconnect { + t.Fatal("expected player_disconnected after a player disconnected") + } +} + +// TestBotFill: اگر کمتر از ۴ انسان در صف بماند، میز با بات کامل و بازی شروع می‌شود. +func TestBotFill(t *testing.T) { + hub := testHub() + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + // فقط ۲ انسان وصل می‌شوند. + conns := make([]*websocket.Conn, 2) + for i := 0; i < 2; i++ { + c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i), nil) + if err != nil { + t.Fatalf("dial %d: %v", i, err) + } + defer c.Close() + conns[i] = c + _ = c.WriteJSON(map[string]string{"type": "join_queue"}) + } + + // بازیکن ۰ باید matched با ۴ بازیکن (۲ بات) دریافت کند. + _ = conns[0].SetReadDeadline(time.Now().Add(3 * time.Second)) + bots := -1 + for { + _, raw, err := conns[0].ReadMessage() + if err != nil { + t.Fatalf("read matched: %v", err) + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + if m.Type == "matched" { + if len(m.Players) != 4 { + t.Fatalf("expected 4 players, got %d", len(m.Players)) + } + bots = 0 + for _, p := range m.Players { + if p.Bot { + bots++ + } + } + break + } + } + if bots != 2 { + t.Fatalf("expected 2 bots, got %d", bots) + } +} + +// TestReconnect: بازیکن قطع‌شده می‌تواند با همان توکن به جایگاه قبلی بازگردد. +func TestReconnect(t *testing.T) { + hub := testHub() + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + conns := make([]*websocket.Conn, 4) + for i := 0; i < 4; i++ { + c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i), nil) + if err != nil { + t.Fatalf("dial %d: %v", i, err) + } + conns[i] = c + _ = c.WriteJSON(map[string]string{"type": "join_queue"}) + } + time.Sleep(150 * time.Millisecond) + + // بازیکن ۰ قطع و سپس با همان توکن دوباره وصل می‌شود. + conns[0].Close() + time.Sleep(100 * time.Millisecond) + rc, _, err := websocket.DefaultDialer.Dial(wsURL+"?token=0", nil) + if err != nil { + t.Fatalf("reconnect dial: %v", err) + } + defer rc.Close() + + // باید matched با seat=0 و سپس state دریافت کند. + gotMatched, gotState := false, false + _ = rc.SetReadDeadline(time.Now().Add(3 * time.Second)) + for !gotState { + _, raw, err := rc.ReadMessage() + if err != nil { + t.Fatalf("read after reconnect: %v", err) + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + switch m.Type { + case "matched": + if m.Seat != 0 { + t.Fatalf("reconnect to wrong seat: %d", m.Seat) + } + gotMatched = true + case "state": + gotState = true + } + } + if !gotMatched || !gotState { + t.Fatal("reconnected client did not receive matched+state") + } + for _, c := range conns[1:] { + c.Close() + } +} + +// TestTurnTimeoutAutoPlays: حتی اگر هیچ بازیکنی حرکت نکند، تایمر نوبت کل بازی +// را خودکار پیش می‌برد تا game_over (مسیر مشترک با حرکت بات/قطع‌شده). +func TestTurnTimeoutAutoPlays(t *testing.T) { + hub := testHub() + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx), nil) + if err != nil { + t.Errorf("dial %d: %v", idx, err) + return + } + defer conn.Close() + _ = conn.WriteJSON(map[string]string{"type": "join_queue"}) + // عمداً هیچ حرکتی نمی‌زنیم؛ فقط منتظر game_over می‌مانیم. + for { + _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + t.Errorf("client %d read: %v", idx, err) + return + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + if m.Type == "game_over" { + return + } + } + }(i) + } + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(20 * time.Second): + t.Fatal("turn-timeout auto-play did not finish the game") + } +} + +// recordingSettler فراخوانی‌های تسویه را برای بررسی در تست می‌شمارد. +type recordingSettler struct { + mu sync.Mutex + charges int + refunds int + awards int + records int +} + +func (s *recordingSettler) ChargeEntry(int64, string) error { + s.mu.Lock() + s.charges++ + s.mu.Unlock() + return nil +} +func (s *recordingSettler) Refund(int64, string) { + s.mu.Lock() + s.refunds++ + s.mu.Unlock() +} +func (s *recordingSettler) AwardWinner(int64, string) { + s.mu.Lock() + s.awards++ + s.mu.Unlock() +} +func (s *recordingSettler) RecordGame(string, string, int, bool) { + s.mu.Lock() + s.records++ + s.mu.Unlock() +} +func (s *recordingSettler) snapshot() (c, r, a, rec int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.charges, s.refunds, s.awards, s.records +} + +// TestSettlementOnGameOver: ۴ انسانِ بیکار ⇒ ۴ کسر ورودی، بازی خودکار تا پایان، +// ۲ جایزه‌ی برنده و یک ثبت بازی، بدون بازگشت. +func TestSettlementOnGameOver(t *testing.T) { + hub := testHub() + settler := &recordingSettler{} + hub.SetSettler(settler) + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx+1), nil) + if err != nil { + t.Errorf("dial %d: %v", idx, err) + return + } + defer conn.Close() + _ = conn.WriteJSON(map[string]string{"type": "join_queue", "tier": "beginner"}) + for { + _ = conn.SetReadDeadline(time.Now().Add(6 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + return + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + if m.Type == "game_over" { + return + } + } + }(i) + } + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(20 * time.Second): + t.Fatal("game did not finish") + } + time.Sleep(100 * time.Millisecond) // فرصت پردازش settle در goroutine میز + + c, r, a, rec := settler.snapshot() + if c != 4 { + t.Errorf("charges = %d, want 4", c) + } + if a != 2 { + t.Errorf("awards = %d, want 2 (winning team humans)", a) + } + if rec != 1 { + t.Errorf("records = %d, want 1", rec) + } + if r != 0 { + t.Errorf("refunds = %d, want 0 on normal finish", r) + } +} + +// TestRefundOnAbort: اگر همه‌ی انسان‌ها وسط بازی قطع شوند، ورودی بازگردانده می‌شود. +func TestRefundOnAbort(t *testing.T) { + hub := testHub() + settler := &recordingSettler{} + hub.SetSettler(settler) + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + conns := make([]*websocket.Conn, 4) + for i := 0; i < 4; i++ { + c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i+1), nil) + if err != nil { + t.Fatalf("dial %d: %v", i, err) + } + conns[i] = c + _ = c.WriteJSON(map[string]string{"type": "join_queue", "tier": "beginner"}) + } + time.Sleep(150 * time.Millisecond) // میز تشکیل شود + for _, c := range conns { + c.Close() + } + time.Sleep(400 * time.Millisecond) // قطع همه ⇒ abort + refund + + charges, refunds, _, _ := settler.snapshot() + if charges != 4 { + t.Errorf("charges = %d, want 4", charges) + } + if refunds != 4 { + t.Errorf("refunds = %d, want 4 on abort", refunds) + } +} + +// legalCard یک کارت مجاز انتخاب می‌کند (follow suit در صورت امکان). +func legalCard(hand []string, leadSuit string) string { + if leadSuit != "" { + for _, cs := range hand { + c, err := game.ParseCard(cs) + if err == nil && c.Suit.String() == leadSuit { + return cs + } + } + } + return hand[0] +}