init
This commit is contained in:
@@ -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=
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/server
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
+18
@@ -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"]
|
||||||
@@ -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) رجوع کنید.
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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'))
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user