Files
back-hokm/internal/ws/hub.go
T
2026-06-25 01:11:13 +03:30

565 lines
17 KiB
Go

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", "chat", "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()
}