init
This commit is contained in:
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user