diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 1f5c8ce..1e1f153 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -173,7 +173,7 @@ func (h *Hub) handle(in inbound) { } h.enqueue(c, tier) } - case "choose_trump", "play_card", "reshuffle", "leave": + case "choose_trump", "play_card", "reshuffle", "chat", "leave": if c.room != nil { if in.msg.Type == "leave" { delete(h.locations, c.UserID) // پس از خروج دائمی، reconnect نشود diff --git a/internal/ws/protocol.go b/internal/ws/protocol.go index 9ac9a24..13d4209 100644 --- a/internal/ws/protocol.go +++ b/internal/ws/protocol.go @@ -12,6 +12,16 @@ type inboundMsg struct { Code string `json:"code"` // برای join_table: شماره میز خصوصی Hands int `json:"hands"` // برای start_table: تعداد دستِ بازیِ خصوصی (۳/۵/۷) Dir int `json:"dir"` // برای rotate_table: جهتِ چرخشِ جایگاه‌ها (+۱/−۱) + Text string `json:"text"` // برای chat: متنِ پیامِ آماده + Emoji string `json:"emoji"` // برای chat: شناسه/کاراکترِ شکلک +} + +// chatMsg پیامِ چت/شکلکِ یک بازیکن که برای همه‌ی میز پخش می‌شود. +type chatMsg struct { + Type string `json:"type"` // "chat" + Seat int `json:"seat"` + Text string `json:"text,omitempty"` + Emoji string `json:"emoji,omitempty"` } // lobbyPlayer یک بازیکن در اتاق انتظارِ میز خصوصی. diff --git a/internal/ws/room.go b/internal/ws/room.go index 8ac66d0..c6a1375 100644 --- a/internal/ws/room.go +++ b/internal/ws/room.go @@ -65,6 +65,7 @@ type Room struct { dealDelay time.Duration // پنجره‌ی پخشِ بقیه‌ی کارت‌ها (پس از انتخابِ حکم) // graceUntil: تا این لحظه هیچ حرکتِ خودکاری انجام نمی‌شود (هماهنگ با انیمیشن‌های کلاینت). graceUntil time.Time + chatAt [4]time.Time // آخرین زمانِ چتِ هر جایگاه (ضدِ اسپم) } func newRoom(id string, seats [4]*seatInfo, hub *Hub) *Room { @@ -250,6 +251,8 @@ func (r *Room) handleInput(act roomAction) { switch act.msg.Type { case "leave": r.handleLeave(act.seat) + case "chat": + r.handleChat(act.seat, act.msg.Text, act.msg.Emoji) case "choose_trump": suit, err := game.ParseSuit(act.msg.Suit) if err != nil { @@ -544,6 +547,28 @@ func (r *Room) broadcast(b []byte) { } } +// handleChat یک پیام/شکلکِ بازیکن را با کنترلِ نرخ (ضدِ اسپم) و طولِ مجاز برای +// همه‌ی میز پخش می‌کند تا کنارِ آواتارِ فرستنده نمایش داده شود. +func (r *Room) handleChat(seat int, text, emoji string) { + now := time.Now() + if now.Sub(r.chatAt[seat]) < 1200*time.Millisecond { + return // فاصله‌ی کمتر از ~۱.۲ ثانیه ⇒ نادیده (ضدِ اسپم) + } + clip := func(s string, n int) string { + r := []rune(s) + if len(r) > n { + return string(r[:n]) + } + return s + } + text, emoji = clip(text, 60), clip(emoji, 16) + if text == "" && emoji == "" { + return + } + r.chatAt[seat] = now + r.broadcast(mustJSON(chatMsg{Type: "chat", Seat: seat, Text: text, Emoji: emoji})) +} + func (r *Room) sendError(seat int, msg string) { if c := r.seats[seat].client; c != nil { c.trySend(mustJSON(errorMsg{Type: "error", Message: msg})) diff --git a/internal/ws/ws_test.go b/internal/ws/ws_test.go index a75ea90..2a057da 100644 --- a/internal/ws/ws_test.go +++ b/internal/ws/ws_test.go @@ -30,6 +30,8 @@ type anyMsg struct { Players []PlayerInfo `json:"players"` Code string `json:"code"` // table_lobby Host bool `json:"host"` // table_lobby + Text string `json:"text"` // chat + Emoji string `json:"emoji"` // chat } // TestFourPlayersFullGame یک بازی کامل را روی WebSocket واقعی با ۴ ربات اجرا می‌کند. @@ -585,6 +587,68 @@ func TestRefundOnAbort(t *testing.T) { } } +// TestChatBroadcast: پیامِ چتِ یک بازیکن باید برای همه‌ی میز (شاملِ گیرنده‌ی دیگر) +// با جایگاهِ فرستنده و متن پخش شود. +func TestChatBroadcast(t *testing.T) { + hub := testHub() + srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS)) + defer srv.Close() + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + + conns := make([]*websocket.Conn, 4) + seats := make([]int, 4) + for i := 0; i < 4; i++ { + c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i), nil) + if err != nil { + t.Fatalf("dial %d: %v", i, err) + } + defer c.Close() + conns[i] = c + _ = c.WriteJSON(map[string]string{"type": "join_queue"}) + } + + // منتظرِ matched برای دانستنِ جایگاهِ فرستنده. + for i := 0; i < 4; i++ { + _ = conns[i].SetReadDeadline(time.Now().Add(3 * time.Second)) + for { + _, raw, err := conns[i].ReadMessage() + if err != nil { + t.Fatalf("read matched %d: %v", i, err) + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + if m.Type == "matched" { + seats[i] = m.Seat + break + } + } + } + + // فرستنده: بازیکن ۰ یک پیام می‌فرستد. + _ = conns[0].WriteJSON(map[string]string{"type": "chat", "text": "سلام"}) + + // گیرنده: بازیکن ۱ باید پیامِ chat با جایگاهِ بازیکن ۰ و متنِ درست بگیرد. + _ = conns[1].SetReadDeadline(time.Now().Add(3 * time.Second)) + got := false + for !got { + _, raw, err := conns[1].ReadMessage() + if err != nil { + t.Fatalf("read chat: %v", err) + } + var m anyMsg + _ = json.Unmarshal(raw, &m) + if m.Type == "chat" { + if m.Seat != seats[0] { + t.Fatalf("chat seat = %d, want %d", m.Seat, seats[0]) + } + if m.Text != "سلام" { + t.Fatalf("chat text = %q, want سلام", m.Text) + } + got = true + } + } +} + // legalCard یک کارت مجاز انتخاب می‌کند (follow suit در صورت امکان). func legalCard(hand []string, leadSuit string) string { if leadSuit != "" {