This commit is contained in:
2026-06-15 16:53:25 +03:30
commit 390ef96e7e
34 changed files with 4463 additions and 0 deletions
+306
View File
@@ -0,0 +1,306 @@
package ws
import (
"fmt"
"log/slog"
"net/http"
"time"
"github.com/gorilla/websocket"
)
// مقادیر پیش‌فرض مقاوم‌سازی (قابل بازنویسی در تست).
const (
defaultTurnTimeout = 20 * time.Second // مهلت نوبت بازیکنِ انسان
defaultBotDelay = 800 * time.Millisecond // تأخیر حرکت بات/قطع‌شده (حس طبیعی + فرصت بازگشت)
defaultMatchWait = 12 * time.Second // مهلت پر شدن میز با انسان پیش از افزودن بات
defaultTrickHold = 1200 * time.Millisecond // مدت نمایش دستِ کامل پیش از جمع‌آوری
)
// AuthFunc توکن را تأیید کرده و (شناسه کاربر، نام نمایشی) را برمی‌گرداند.
type AuthFunc func(token string) (userID int64, name string, err error)
// inbound پیام ورودی یک کلاینت برای پردازش در هاب.
type inbound struct {
client *Client
msg inboundMsg
}
// location محل بازیکن در حال بازی (برای reconnect).
type location struct {
room *Room
seat int
}
// endInfo اطلاعاتی که میز هنگام بسته‌شدن به هاب می‌دهد تا نگاشت‌ها پاک شوند.
type endInfo struct {
room *Room
humanIDs []int64
clients []*Client
}
// Hub هماهنگ‌کننده مرکزی: اتصال‌ها، صف matchmaking و مسیریابی پیام‌ها.
// تمام state آن فقط توسط goroutine Run تغییر می‌کند (single-writer، بدون قفل).
// پس از ساخت یک میز، هاب دیگر به seatInfo دست نمی‌زند (مالک آن goroutine میز است).
type Hub struct {
auth AuthFunc
upgrader websocket.Upgrader
clients map[*Client]bool
queues map[string][]*Client // صف انتظار به ازای هر tier
locations map[int64]location // userID → محل بازی (برای reconnect)
roomSeq int
botSeq int
fillPending map[string]bool
settler Settler
turnTimeout time.Duration
botDelay time.Duration
matchWait time.Duration
trickHold time.Duration
register chan *Client
unregister chan *Client
inbound chan inbound
endRoom chan endInfo
fill chan string // tier برای پر کردن با بات
}
func NewHub(auth AuthFunc) *Hub {
return &Hub{
auth: auth,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// در پروداکشن مبدأ را محدود کنید؛ فعلاً برای توسعه باز است.
CheckOrigin: func(r *http.Request) bool { return true },
},
clients: make(map[*Client]bool),
queues: make(map[string][]*Client),
locations: make(map[int64]location),
fillPending: make(map[string]bool),
settler: noopSettler{},
turnTimeout: defaultTurnTimeout,
botDelay: defaultBotDelay,
matchWait: defaultMatchWait,
trickHold: defaultTrickHold,
register: make(chan *Client),
unregister: make(chan *Client),
inbound: make(chan inbound, 64),
endRoom: make(chan endInfo),
fill: make(chan string, 8),
}
}
// SetSettler تسویه‌گر اقتصادی را تنظیم می‌کند (پیش از Run).
func (h *Hub) SetSettler(s Settler) { h.settler = s }
const defaultTier = "beginner"
// Run حلقه اصلی هاب (در یک goroutine اجرا شود).
func (h *Hub) Run() {
for {
select {
case c := <-h.register:
h.handleRegister(c)
case c := <-h.unregister:
h.handleDisconnect(c)
case in := <-h.inbound:
h.handle(in)
case e := <-h.endRoom:
h.closeRoom(e)
case tier := <-h.fill:
h.onFill(tier)
}
}
}
// handleRegister اتصال جدید را ثبت می‌کند؛ اگر کاربر در حال بازی بود، reconnect می‌شود.
func (h *Hub) handleRegister(c *Client) {
h.clients[c] = true
if loc, ok := h.locations[c.UserID]; ok {
c.room = loc.room
c.seat = loc.seat
select {
case loc.room.actions <- roomAction{kind: akReconnect, seat: loc.seat, client: c}:
default:
}
slog.Info("player reconnecting", "user", c.UserID, "room", loc.room.ID)
}
}
func (h *Hub) handle(in inbound) {
c := in.client
switch in.msg.Type {
case "join_queue":
if c.room == nil {
tier := in.msg.Tier
if tier == "" {
tier = defaultTier
}
h.enqueue(c, tier)
}
case "choose_trump", "play_card", "leave":
if c.room != nil {
if in.msg.Type == "leave" {
delete(h.locations, c.UserID) // پس از خروج دائمی، reconnect نشود
}
select {
case c.room.actions <- roomAction{kind: akInput, seat: c.seat, client: c, msg: in.msg}:
default:
}
}
}
}
// enqueue کلاینت را پس از کسر ورودی به صفِ tier افزوده و میزهای کامل را می‌سازد.
func (h *Hub) enqueue(c *Client, tier string) {
if c.room != nil {
return
}
for _, q := range h.queues[tier] {
if q == c {
return
}
}
// کسر ورودی میز؛ اگر سکه کافی نبود، اجازه ورود به صف داده نمی‌شود.
if err := h.settler.ChargeEntry(c.UserID, tier); err != nil {
c.trySend(mustJSON(errorMsg{Type: "error", Message: "insufficient coins"}))
return
}
c.tier = tier
h.queues[tier] = append(h.queues[tier], c)
for len(h.queues[tier]) >= 4 {
h.formTable(tier, 4)
}
h.maybeArmFill(tier)
}
// onFill با سررسید مهلت، میز ناقصِ یک tier را با بات کامل می‌کند.
func (h *Hub) onFill(tier string) {
h.fillPending[tier] = false
n := len(h.queues[tier])
if n == 0 {
return
}
if n > 4 {
n = 4
}
h.formTable(tier, n)
h.maybeArmFill(tier)
}
// maybeArmFill در صورت وجود بازیکن در صفِ tier، تایمر پر کردن با بات را مسلح می‌کند.
func (h *Hub) maybeArmFill(tier string) {
if len(h.queues[tier]) > 0 && !h.fillPending[tier] {
h.fillPending[tier] = true
time.AfterFunc(h.matchWait, func() {
select {
case h.fill <- tier:
default:
}
})
}
}
// formTable یک میزِ tier با nHumans بازیکن از ابتدای صف و بات برای بقیه می‌سازد.
func (h *Hub) formTable(tier string, nHumans int) {
var seats [4]*seatInfo
for i := 0; i < nHumans; i++ {
c := h.queues[tier][i]
seats[i] = &seatInfo{client: c, userID: c.UserID, name: c.Name, connected: true}
}
h.queues[tier] = h.queues[tier][nHumans:]
for i := nHumans; i < 4; i++ {
h.botSeq++
seats[i] = &seatInfo{isBot: true, name: fmt.Sprintf("ربات %d", h.botSeq)}
}
h.roomSeq++
room := newRoom(fmt.Sprintf("r%d", h.roomSeq), seats, h)
room.tier = tier
// ست‌کردن اشاره‌گرها و نگاشت reconnect پیش از شروع goroutine میز (happens-before).
for i := 0; i < nHumans; i++ {
c := seats[i].client
c.room = room
c.seat = i
h.locations[c.UserID] = location{room: room, seat: i}
}
go room.run()
slog.Info("room created", "room", room.ID, "tier", tier, "humans", nHumans, "bots", 4-nHumans)
}
// handleDisconnect قطع اتصال یک کلاینت را مدیریت می‌کند (نگاشت reconnect حفظ می‌شود).
func (h *Hub) handleDisconnect(c *Client) {
if !h.clients[c] {
return
}
delete(h.clients, c)
c.close() // writePump را خاتمه می‌دهد؛ send بسته نمی‌شود تا room بدون panic بتواند trySend کند
// اگر در صف بود، حذف و ورودی بازگردانده شود (هنوز بازی شروع نشده).
if q := h.queues[c.tier]; len(q) > 0 {
for i, x := range q {
if x == c {
h.queues[c.tier] = append(q[:i], q[i+1:]...)
h.settler.Refund(c.UserID, c.tier)
break
}
}
}
if c.room != nil {
select {
case c.room.actions <- roomAction{kind: akDisconnect, seat: c.seat, client: c}:
default:
}
}
}
// closeRoom پس از پایان بازی، نگاشت‌ها و اشاره‌گرهای میز را پاک می‌کند.
func (h *Hub) closeRoom(e endInfo) {
for _, id := range e.humanIDs {
if loc, ok := h.locations[id]; ok && loc.room == e.room {
delete(h.locations, id)
}
}
for _, c := range e.clients {
if c.room == e.room {
c.room = nil
c.seat = 0
}
}
slog.Info("room closed", "room", e.room.ID)
}
// ServeWS اتصال WebSocket را پس از تأیید توکن برقرار می‌کند.
// توکن از پارامتر کوئری ?token= یا هدر Authorization خوانده می‌شود.
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
if token == "" {
if a := r.Header.Get("Authorization"); len(a) > 7 && a[:7] == "Bearer " {
token = a[7:]
}
}
userID, name, err := h.auth(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
conn, err := h.upgrader.Upgrade(w, r, nil)
if err != nil {
return // upgrader خودش پاسخ خطا را نوشته
}
c := &Client{
hub: h,
conn: conn,
send: make(chan []byte, sendBuffer),
done: make(chan struct{}),
UserID: userID,
Name: name,
}
h.register <- c
go c.writePump()
go c.readPump()
}