This commit is contained in:
2026-06-15 16:53:25 +03:30
commit 390ef96e7e
34 changed files with 4463 additions and 0 deletions
+16
View File
@@ -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=
+5
View File
@@ -0,0 +1,5 @@
/server
*.db
*.db-shm
*.db-wal
.env
+18
View File
@@ -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"]
+138
View File
@@ -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=<JWT>` (یا هدر `Authorization: Bearer <JWT>`).
جریان کلی: اتصال → `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) رجوع کنید.
+111
View File
@@ -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)
}
}
+48
View File
@@ -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)
}
}
+26
View File
@@ -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
)
+55
View File
@@ -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=
+160
View File
@@ -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})
}
+90
View File
@@ -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
}
+61
View File
@@ -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
}
+57
View File
@@ -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
}
+70
View File
@@ -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
}
+175
View File
@@ -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
}
+407
View File
@@ -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")
}
+198
View File
@@ -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)
}
}
+186
View File
@@ -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")
}
}
+139
View File
@@ -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
}
+74
View File
@@ -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()
}
+129
View File
@@ -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]
}
}
+333
View File
@@ -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
}
+128
View File
@@ -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]
}
+53
View File
@@ -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
}
+97
View File
@@ -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() }
+40
View File
@@ -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()
}
+43
View File
@@ -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'))
);
+50
View File
@@ -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
);
+76
View File
@@ -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
}
+112
View File
@@ -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
}
+306
View File
@@ -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()
}
+131
View File
@@ -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
}
+454
View File
@@ -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}))
}
}
+18
View File
@@ -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) {}
+459
View File
@@ -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]
}