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() }