feat: add messages feature

This commit is contained in:
2026-06-25 01:11:13 +03:30
parent 27c20b1d34
commit 9a86d2bd4d
4 changed files with 100 additions and 1 deletions
+1 -1
View File
@@ -173,7 +173,7 @@ func (h *Hub) handle(in inbound) {
} }
h.enqueue(c, tier) h.enqueue(c, tier)
} }
case "choose_trump", "play_card", "reshuffle", "leave": case "choose_trump", "play_card", "reshuffle", "chat", "leave":
if c.room != nil { if c.room != nil {
if in.msg.Type == "leave" { if in.msg.Type == "leave" {
delete(h.locations, c.UserID) // پس از خروج دائمی، reconnect نشود delete(h.locations, c.UserID) // پس از خروج دائمی، reconnect نشود
+10
View File
@@ -12,6 +12,16 @@ type inboundMsg struct {
Code string `json:"code"` // برای join_table: شماره میز خصوصی Code string `json:"code"` // برای join_table: شماره میز خصوصی
Hands int `json:"hands"` // برای start_table: تعداد دستِ بازیِ خصوصی (۳/۵/۷) Hands int `json:"hands"` // برای start_table: تعداد دستِ بازیِ خصوصی (۳/۵/۷)
Dir int `json:"dir"` // برای rotate_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 یک بازیکن در اتاق انتظارِ میز خصوصی. // lobbyPlayer یک بازیکن در اتاق انتظارِ میز خصوصی.
+25
View File
@@ -65,6 +65,7 @@ type Room struct {
dealDelay time.Duration // پنجره‌ی پخشِ بقیه‌ی کارت‌ها (پس از انتخابِ حکم) dealDelay time.Duration // پنجره‌ی پخشِ بقیه‌ی کارت‌ها (پس از انتخابِ حکم)
// graceUntil: تا این لحظه هیچ حرکتِ خودکاری انجام نمی‌شود (هماهنگ با انیمیشن‌های کلاینت). // graceUntil: تا این لحظه هیچ حرکتِ خودکاری انجام نمی‌شود (هماهنگ با انیمیشن‌های کلاینت).
graceUntil time.Time graceUntil time.Time
chatAt [4]time.Time // آخرین زمانِ چتِ هر جایگاه (ضدِ اسپم)
} }
func newRoom(id string, seats [4]*seatInfo, hub *Hub) *Room { func newRoom(id string, seats [4]*seatInfo, hub *Hub) *Room {
@@ -250,6 +251,8 @@ func (r *Room) handleInput(act roomAction) {
switch act.msg.Type { switch act.msg.Type {
case "leave": case "leave":
r.handleLeave(act.seat) r.handleLeave(act.seat)
case "chat":
r.handleChat(act.seat, act.msg.Text, act.msg.Emoji)
case "choose_trump": case "choose_trump":
suit, err := game.ParseSuit(act.msg.Suit) suit, err := game.ParseSuit(act.msg.Suit)
if err != nil { 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) { func (r *Room) sendError(seat int, msg string) {
if c := r.seats[seat].client; c != nil { if c := r.seats[seat].client; c != nil {
c.trySend(mustJSON(errorMsg{Type: "error", Message: msg})) c.trySend(mustJSON(errorMsg{Type: "error", Message: msg}))
+64
View File
@@ -30,6 +30,8 @@ type anyMsg struct {
Players []PlayerInfo `json:"players"` Players []PlayerInfo `json:"players"`
Code string `json:"code"` // table_lobby Code string `json:"code"` // table_lobby
Host bool `json:"host"` // table_lobby Host bool `json:"host"` // table_lobby
Text string `json:"text"` // chat
Emoji string `json:"emoji"` // chat
} }
// TestFourPlayersFullGame یک بازی کامل را روی WebSocket واقعی با ۴ ربات اجرا می‌کند. // 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 در صورت امکان). // legalCard یک کارت مجاز انتخاب می‌کند (follow suit در صورت امکان).
func legalCard(hand []string, leadSuit string) string { func legalCard(hand []string, leadSuit string) string {
if leadSuit != "" { if leadSuit != "" {