feat: add private and profile

This commit is contained in:
2026-06-17 11:32:51 +03:30
parent 02060a4dc0
commit 0827858752
23 changed files with 972 additions and 25 deletions
+200 -10
View File
@@ -3,6 +3,7 @@ package ws
import (
"fmt"
"log/slog"
"math/rand"
"net/http"
"time"
@@ -39,6 +40,15 @@ type endInfo struct {
clients []*Client
}
// pendingTable میز خصوصیِ در انتظارِ شروع (دورهمی).
// فقط توسط goroutine هاب خوانده/نوشته می‌شود.
type pendingTable struct {
code string
host int64
clients []*Client
started bool // پس از فشردن «شروع» توسط میزبان
}
// Hub هماهنگ‌کننده مرکزی: اتصال‌ها، صف matchmaking و مسیریابی پیام‌ها.
// تمام state آن فقط توسط goroutine Run تغییر می‌کند (single-writer، بدون قفل).
// پس از ساخت یک میز، هاب دیگر به seatInfo دست نمی‌زند (مالک آن goroutine میز است).
@@ -47,23 +57,26 @@ type Hub struct {
upgrader websocket.Upgrader
clients map[*Client]bool
queues map[string][]*Client // صف انتظار به ازای هر tier
locations map[int64]location // userID → محل بازی (برای reconnect)
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
turnTimeout time.Duration
botDelay time.Duration
matchWait time.Duration
trickHold 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 {
@@ -78,17 +91,20 @@ func NewHub(auth AuthFunc) *Hub {
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,
turnTimeout: defaultTurnTimeout,
botDelay: defaultBotDelay,
matchWait: defaultMatchWait,
trickHold: defaultTrickHold,
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),
}
}
@@ -111,6 +127,8 @@ func (h *Hub) Run() {
h.closeRoom(e)
case tier := <-h.fill:
h.onFill(tier)
case code := <-h.startTbl:
h.onStartTable(code)
}
}
}
@@ -150,6 +168,171 @@ func (h *Hub) handle(in inbound) {
default:
}
}
case "create_table":
h.createTable(c)
case "join_table":
h.joinTable(c, in.msg.Code)
case "start_table":
h.startTable(c)
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) {
t := h.tables[c.tableCode]
if t == nil || t.host != c.UserID || t.started {
return
}
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
n := 0
for _, cl := range t.clients {
if n >= 4 || !h.clients[cl] {
continue // قطع‌شده‌ها نادیده گرفته می‌شوند
}
seats[n] = &seatInfo{client: cl, userID: cl.UserID, name: cl.Name, connected: true}
n++
}
if n == 0 {
return // همه خارج شدند
}
for i := n; i < 4; i++ {
h.botSeq++
seats[i] = &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
for i := 0; i < n; i++ {
cl := seats[i].client
cl.room = room
cl.seat = i
cl.tableCode = ""
h.locations[cl.UserID] = location{room: room, seat: i}
}
go room.run()
slog.Info("private room started", "room", room.ID, "code", code, "humans", n)
}
// 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}
}
for _, 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, Remaining: remaining, Unlimited: unlimited,
}))
}
}
@@ -248,6 +431,13 @@ func (h *Hub) handleDisconnect(c *Client) {
}
}
}
// اگر در اتاق انتظارِ میز خصوصی بود، از آن حذف شود.
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}: