package ws import ( "fmt" "log/slog" "math/rand" "net/http" "time" "github.com/gorilla/websocket" ) // مقادیر پیش‌فرض مقاوم‌سازی (قابل بازنویسی در تست). const ( defaultTurnTimeout = 30 * time.Second // مهلت نوبت بازیکنِ انسان defaultBotDelay = 800 * time.Millisecond // تأخیر حرکت بات/قطع‌شده (حس طبیعی + فرصت بازگشت) defaultMatchWait = 6 * time.Second // مهلت پر شدن میز با انسان پیش از افزودن بات defaultTrickHold = 1200 * time.Millisecond // مدت نمایش دستِ کامل پیش از جمع‌آوری defaultFirstHandDelay = 3000 * time.Millisecond // تأخیر اولین حرکتِ بات تا پایانِ اینترو+بُر زدنِ کلاینت defaultDealDelay = 1600 * 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 } // pendingTable میز خصوصیِ در انتظارِ شروع (دورهمی). // فقط توسط goroutine هاب خوانده/نوشته می‌شود. type pendingTable struct { code string host int64 clients []*Client hands int // تعداد دستِ انتخاب‌شده توسط میزبان (۳/۵/۷) rot int // چرخشِ جایگاه‌های غیرِمیزبان (۰..۲) started bool // پس از فشردن «شروع» توسط میزبان } // nonHostSeat جایگاهِ مطلقِ j-اُمین بازیکنِ غیرِمیزبان را با چرخشِ rot برمی‌گرداند. // میزبان همیشه جایگاه ۰ است؛ سه جایگاهِ دیگر {۱،۲،۳} با چرخش جابه‌جا می‌شوند. func nonHostSeat(j, rot int) int { seats := [3]int{1, 2, 3} return seats[((j+rot)%3+3)%3] } // 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) tables map[string]*pendingTable // کد میز → میز خصوصیِ در انتظار roomSeq int botSeq int fillPending map[string]bool settler Settler turnTimeout time.Duration botDelay time.Duration matchWait time.Duration trickHold time.Duration firstHandDelay time.Duration // تأخیر اولین حرکتِ بات (هماهنگ با انیمیشنِ بُر زدنِ کلاینت) dealDelay time.Duration // تأخیر اولین بازی پس از انتخابِ حکم (انیمیشنِ پخشِ کارت‌ها) countdownDelay time.Duration // مهلت شمارش معکوسِ میز خصوصی پیش از شروع register chan *Client unregister chan *Client inbound chan inbound endRoom chan endInfo fill chan string // tier برای پر کردن با بات startTbl chan string // کد میز خصوصی برای شروع (پس از شمارش معکوس) } 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), tables: make(map[string]*pendingTable), fillPending: make(map[string]bool), settler: noopSettler{}, turnTimeout: defaultTurnTimeout, botDelay: defaultBotDelay, matchWait: defaultMatchWait, trickHold: defaultTrickHold, firstHandDelay: defaultFirstHandDelay, dealDelay: defaultDealDelay, countdownDelay: 3 * time.Second, register: make(chan *Client), unregister: make(chan *Client), inbound: make(chan inbound, 64), endRoom: make(chan endInfo), fill: make(chan string, 8), startTbl: 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) case code := <-h.startTbl: h.onStartTable(code) } } } // 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", "reshuffle", "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: } } case "create_table": h.createTable(c) case "join_table": h.joinTable(c, in.msg.Code) case "start_table": h.startTable(c, in.msg.Hands) case "rotate_table": h.rotateTable(c, in.msg.Dir) case "leave_table": h.leaveTable(c) } } // --- میز خصوصی (دورهمی) --- // genCode یک کد ۵ رقمیِ یکتا برای میز خصوصی می‌سازد. func (h *Hub) genCode() string { for { code := fmt.Sprintf("%05d", rand.Intn(100000)) if _, ok := h.tables[code]; !ok { return code } } } // createTable یک میز خصوصی می‌سازد (در صورت مجاز بودنِ سهمیه). func (h *Hub) createTable(c *Client) { if c.room != nil || c.tableCode != "" { return } if err := h.settler.ChargePrivateTable(c.UserID); err != nil { c.trySend(mustJSON(errorMsg{Type: "error", Message: "سهمیه‌ی میزهای رایگان به پایان رسیده؛ برای میز نامحدود VIP بگیرید"})) return } code := h.genCode() t := &pendingTable{code: code, host: c.UserID, clients: []*Client{c}} h.tables[code] = t c.tableCode = code slog.Info("private table created", "code", code, "host", c.UserID) h.broadcastLobby(t) } // joinTable کلاینت را به میز خصوصیِ موجود می‌افزاید. func (h *Hub) joinTable(c *Client, code string) { if c.room != nil || c.tableCode != "" { return } t := h.tables[code] if t == nil || t.started { c.trySend(mustJSON(errorMsg{Type: "error", Message: "میز یافت نشد"})) return } if len(t.clients) >= 4 { c.trySend(mustJSON(errorMsg{Type: "error", Message: "میز پر است"})) return } t.clients = append(t.clients, c) c.tableCode = code h.broadcastLobby(t) } // startTable شمارش معکوس را آغاز و پس از آن بازی را شروع می‌کند (فقط میزبان). func (h *Hub) startTable(c *Client, hands int) { t := h.tables[c.tableCode] if t == nil || t.host != c.UserID || t.started { return } t.hands = clampHands(hands) // تعداد دستِ انتخابیِ میزبان (۳/۵/۷) t.started = true cd := mustJSON(countdownMsg{Type: "countdown", Seconds: 3}) for _, cl := range t.clients { cl.trySend(cd) } code := t.code time.AfterFunc(h.countdownDelay, func() { select { case h.startTbl <- code: default: } }) } // onStartTable پس از شمارش معکوس، میز را از کلاینت‌های متصل می‌سازد و بازی را شروع می‌کند. func (h *Hub) onStartTable(code string) { t := h.tables[code] if t == nil { return } delete(h.tables, code) var seats [4]*seatInfo humans := 0 for i, cl := range t.clients { if i >= 4 || !h.clients[cl] { continue // قطع‌شده‌ها نادیده گرفته می‌شوند (جایشان را بات می‌گیرد) } seat := t.seatOf(i) seats[seat] = &seatInfo{client: cl, userID: cl.UserID, name: cl.Name, connected: true} humans++ } if humans == 0 { return // همه خارج شدند } for s := 0; s < 4; s++ { if seats[s] == nil { h.botSeq++ seats[s] = &seatInfo{isBot: true, name: fmt.Sprintf("ربات %d", h.botSeq)} } } h.roomSeq++ room := newRoom(fmt.Sprintf("p%d", h.roomSeq), seats, h) room.tier = defaultTier // برای جایزه/ورودی بی‌اثر است (میز خصوصی) room.private = true room.handsOverride = clampHands(t.hands) // تعداد دستِ انتخابیِ میزبان for s := 0; s < 4; s++ { if seats[s].isBot { continue } cl := seats[s].client cl.room = room cl.seat = s cl.tableCode = "" h.locations[cl.UserID] = location{room: room, seat: s} } go room.run() slog.Info("private room started", "room", room.ID, "code", code, "humans", humans) } // rotateTable جایگاهِ بازیکنانِ غیرِمیزبان را دورِ میز می‌چرخاند (فقط میزبان). // میزبان همیشه جایگاهِ اولش را حفظ می‌کند؛ چرخش تیم‌بندی را تغییر می‌دهد. func (h *Hub) rotateTable(c *Client, dir int) { t := h.tables[c.tableCode] if t == nil || t.host != c.UserID || t.started { return } if len(t.clients) < 2 { return // جز میزبان کسی نیست ⇒ چیزی برای چرخش نمانده } d := 1 if dir < 0 { d = -1 } t.rot = ((t.rot+d)%3 + 3) % 3 h.broadcastLobby(t) } // leaveTable خروجِ داوطلبانه از اتاق انتظار. func (h *Hub) leaveTable(c *Client) { t := h.tables[c.tableCode] c.tableCode = "" if t != nil { h.removeFromTable(t, c) } } // removeFromTable کلاینت را از میز حذف می‌کند؛ با خروجِ میزبان یا خالی‌شدن، میز منحل می‌شود. func (h *Hub) removeFromTable(t *pendingTable, c *Client) { idx := -1 for i, cl := range t.clients { if cl == c { idx = i break } } if idx < 0 { return } t.clients = append(t.clients[:idx], t.clients[idx+1:]...) if c.UserID == t.host || len(t.clients) == 0 { closed := mustJSON(tableClosedMsg{Type: "table_closed", Reason: "host_left"}) for _, cl := range t.clients { cl.tableCode = "" cl.trySend(closed) } delete(h.tables, t.code) return } h.broadcastLobby(t) } // broadcastLobby وضعیت اتاق انتظار را برای همه‌ی اعضای میز ارسال می‌کند. func (h *Hub) broadcastLobby(t *pendingTable) { players := make([]lobbyPlayer, len(t.clients)) for i, cl := range t.clients { players[i] = lobbyPlayer{ Name: cl.Name, Host: cl.UserID == t.host, Seat: t.seatOf(i), } } for i, cl := range t.clients { remaining, unlimited := h.settler.PrivateTableInfo(cl.UserID) cl.trySend(mustJSON(tableLobbyMsg{ Type: "table_lobby", Code: t.code, Players: players, Host: cl.UserID == t.host, YouSeat: t.seatOf(i), Remaining: remaining, Unlimited: unlimited, })) } } // seatOf جایگاهِ مطلقِ کلاینتِ شماره i در فهرستِ میز را برمی‌گرداند (میزبان=۰). func (t *pendingTable) seatOf(i int) int { if i == 0 { return 0 } return nonHostSeat(i-1, t.rot) } // clampHands تعداد دست را به یکی از مقادیرِ مجاز (۳/۵/۷) محدود می‌کند. func clampHands(h int) int { switch h { case 5: return 5 case 7: return 7 default: return 3 } } // 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.tableCode != "" { if t := h.tables[c.tableCode]; t != nil { h.removeFromTable(t, c) } c.tableCode = "" } 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() }