init
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
writeWait = 10 * time.Second
|
||||
pongWait = 60 * time.Second
|
||||
pingPeriod = (pongWait * 9) / 10
|
||||
maxMessageSize = 4096
|
||||
sendBuffer = 32
|
||||
)
|
||||
|
||||
// Client یک اتصال WebSocket یک بازیکن.
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
UserID int64
|
||||
Name string
|
||||
|
||||
// done هنگام قطع اتصال بسته میشود تا writePump خارج شده و
|
||||
// trySend دیگر تلاش به ارسال نکند (send هیچگاه close نمیشود تا panic رخ ندهد).
|
||||
done chan struct{}
|
||||
closeOnce sync.Once
|
||||
|
||||
// توسط goroutine هاب ست میشوند (تکنویسنده) و فقط توسط آن خوانده میشوند.
|
||||
room *Room
|
||||
seat int
|
||||
tier string // نوع میزی که در صفش است (برای تسویه)
|
||||
}
|
||||
|
||||
// close اتصال را یکبار بهصورت امن میبندد.
|
||||
func (c *Client) close() {
|
||||
c.closeOnce.Do(func() {
|
||||
close(c.done)
|
||||
c.conn.Close()
|
||||
})
|
||||
}
|
||||
|
||||
// readPump پیامهای ورودی را خوانده و به هاب میفرستد.
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
c.close()
|
||||
}()
|
||||
c.conn.SetReadLimit(maxMessageSize)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||
})
|
||||
|
||||
for {
|
||||
_, raw, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var msg inboundMsg
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
c.trySend(mustJSON(errorMsg{Type: "error", Message: "invalid message"}))
|
||||
continue
|
||||
}
|
||||
c.hub.inbound <- inbound{client: c, msg: msg}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump پیامهای خروجی و ping را به سوکت مینویسد.
|
||||
func (c *Client) writePump() {
|
||||
ticker := time.NewTicker(pingPeriod)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case <-c.done:
|
||||
return
|
||||
case msg := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trySend ارسال غیرمسدودکننده؛ اگر اتصال بسته یا بافر پر بود پیام را میاندازد.
|
||||
// send هیچگاه close نمیشود؛ پس از done صرفاً پیام دور ریخته میشود (بدون panic).
|
||||
func (c *Client) trySend(b []byte) {
|
||||
select {
|
||||
case <-c.done:
|
||||
case c.send <- b:
|
||||
default:
|
||||
slog.Warn("client send buffer full, dropping message", "user", c.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package ws
|
||||
|
||||
import "hakemsho/internal/game"
|
||||
|
||||
// inboundMsg پیام دریافتی از کلاینت.
|
||||
type inboundMsg struct {
|
||||
Type string `json:"type"` // join_queue | choose_trump | play_card | leave
|
||||
Mode string `json:"mode"` // برای join_queue
|
||||
Tier string `json:"tier"` // برای join_queue: نوع میز (beginner/pro/...)
|
||||
Suit string `json:"suit"` // برای choose_trump: hearts|spades|diamonds|clubs
|
||||
Card string `json:"card"` // برای play_card: مثل "AS"
|
||||
}
|
||||
|
||||
// PlayerInfo اطلاعات عمومی یک بازیکن سر میز.
|
||||
type PlayerInfo struct {
|
||||
Seat int `json:"seat"`
|
||||
Name string `json:"name"`
|
||||
Bot bool `json:"bot"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
// matchedMsg به کلاینت پس از تشکیل میز.
|
||||
type matchedMsg struct {
|
||||
Type string `json:"type"` // "matched"
|
||||
Room string `json:"room"`
|
||||
Seat int `json:"seat"`
|
||||
Players []PlayerInfo `json:"players"`
|
||||
}
|
||||
|
||||
// trickView یک کارت روی زمین.
|
||||
type trickView struct {
|
||||
Seat int `json:"seat"`
|
||||
Card string `json:"card"`
|
||||
}
|
||||
|
||||
// stateMsg نمای مجاز بازی برای یک بازیکن خاص.
|
||||
type stateMsg struct {
|
||||
Type string `json:"type"` // "state"
|
||||
Room string `json:"room"`
|
||||
Phase string `json:"phase"`
|
||||
YourSeat int `json:"your_seat"`
|
||||
Hakem int `json:"hakem"`
|
||||
Turn int `json:"turn"`
|
||||
Trump string `json:"trump,omitempty"` // فقط پس از انتخاب حکم
|
||||
TrickDone bool `json:"trick_done"` // دستِ کامل در حال نمایش (بازی ممنوع)
|
||||
YourHand []string `json:"your_hand"`
|
||||
HandCounts [4]int `json:"hand_counts"`
|
||||
Trick []trickView `json:"trick"`
|
||||
LeadSuit string `json:"lead_suit,omitempty"`
|
||||
TricksWon [2]int `json:"tricks_won"`
|
||||
Scores [2]int `json:"scores"`
|
||||
TargetScore int `json:"target_score"`
|
||||
Players []PlayerInfo `json:"players"`
|
||||
}
|
||||
|
||||
// handOverMsg نتیجه یک هَند.
|
||||
type handOverMsg struct {
|
||||
Type string `json:"type"` // "hand_over"
|
||||
WinnerTeam int `json:"winner_team"`
|
||||
Kot bool `json:"kot"`
|
||||
Points int `json:"points"`
|
||||
Scores [2]int `json:"scores"`
|
||||
}
|
||||
|
||||
// gameOverMsg پایان بازی.
|
||||
type gameOverMsg struct {
|
||||
Type string `json:"type"` // "game_over"
|
||||
WinnerTeam int `json:"winner_team"`
|
||||
Scores [2]int `json:"scores"`
|
||||
}
|
||||
|
||||
// errorMsg خطا برای کلاینت.
|
||||
type errorMsg struct {
|
||||
Type string `json:"type"` // "error"
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// playerLeftMsg خروج دائمی یک بازیکن (تبدیل به بات).
|
||||
type playerLeftMsg struct {
|
||||
Type string `json:"type"` // "player_left"
|
||||
Seat int `json:"seat"`
|
||||
}
|
||||
|
||||
// playerStatusMsg تغییر وضعیت اتصال یک بازیکن (موقت).
|
||||
type playerStatusMsg struct {
|
||||
Type string `json:"type"` // "player_disconnected" | "player_reconnected"
|
||||
Seat int `json:"seat"`
|
||||
}
|
||||
|
||||
// buildState نمای بازی را برای جایگاه seat میسازد (کارت بقیه مخفی میماند).
|
||||
func buildState(g *game.Game, roomID string, seat int, players []PlayerInfo) stateMsg {
|
||||
hand := g.Hand(seat)
|
||||
handStr := make([]string, len(hand))
|
||||
for i, c := range hand {
|
||||
handStr[i] = c.String()
|
||||
}
|
||||
|
||||
var counts [4]int
|
||||
for s := 0; s < 4; s++ {
|
||||
counts[s] = g.HandCount(s)
|
||||
}
|
||||
|
||||
trick := make([]trickView, 0, len(g.Trick))
|
||||
for _, tc := range g.Trick {
|
||||
trick = append(trick, trickView{Seat: tc.Seat, Card: tc.Card.String()})
|
||||
}
|
||||
|
||||
st := stateMsg{
|
||||
Type: "state",
|
||||
Room: roomID,
|
||||
Phase: g.Phase.String(),
|
||||
TrickDone: g.TrickDone,
|
||||
YourSeat: seat,
|
||||
Hakem: g.Hakem,
|
||||
Turn: g.Turn,
|
||||
YourHand: handStr,
|
||||
HandCounts: counts,
|
||||
Trick: trick,
|
||||
TricksWon: g.TricksWon,
|
||||
Scores: g.Scores,
|
||||
TargetScore: g.TargetScore,
|
||||
Players: players,
|
||||
}
|
||||
if g.TrumpChosen {
|
||||
st.Trump = g.Trump.String()
|
||||
}
|
||||
if len(g.Trick) > 0 {
|
||||
st.LeadSuit = g.LeadSuit.String()
|
||||
}
|
||||
return st
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"hakemsho/internal/game"
|
||||
)
|
||||
|
||||
const targetScore = 7 // امتیاز لازم برای برد بازی
|
||||
|
||||
// actionKind نوع پیام ورودی به goroutine میز.
|
||||
type actionKind int
|
||||
|
||||
const (
|
||||
akInput actionKind = iota // ورودی بازیکن (choose_trump/play_card/leave)
|
||||
akTimeout // سررسید تایمر نوبت
|
||||
akDisconnect // قطع اتصال یک بازیکن
|
||||
akReconnect // اتصال مجدد یک بازیکن
|
||||
akCollect // جمعآوری دستِ کامل پس از نمایش
|
||||
)
|
||||
|
||||
// roomAction یک رویداد برای پردازش در goroutine میز.
|
||||
type roomAction struct {
|
||||
kind actionKind
|
||||
seat int
|
||||
client *Client // برای akReconnect و تأیید فرستنده در akInput/akDisconnect
|
||||
gen int // برای akTimeout: نسل تایمر (تشخیص تایمر کهنه)
|
||||
msg inboundMsg // برای akInput
|
||||
}
|
||||
|
||||
// seatInfo وضعیت یک جایگاه؛ فقط توسط goroutine میز خوانده/نوشته میشود (single-writer).
|
||||
type seatInfo struct {
|
||||
client *Client
|
||||
isBot bool
|
||||
userID int64
|
||||
name string
|
||||
connected bool
|
||||
left bool // خروج دائمی داوطلبانه (در تسویه از بازگشت ورودی محروم)
|
||||
}
|
||||
|
||||
// auto مشخص میکند جایگاه باید خودکار بازی شود (بات یا قطعشده).
|
||||
func (s *seatInfo) auto() bool { return s.isBot || !s.connected }
|
||||
|
||||
// Room یک میز بازی؛ تنها goroutine خودش روی state بازی و seatها مینویسد.
|
||||
type Room struct {
|
||||
ID string
|
||||
hub *Hub
|
||||
tier string
|
||||
settler Settler
|
||||
seats [4]*seatInfo
|
||||
game *game.Game
|
||||
actions chan roomAction
|
||||
ended bool
|
||||
turnGen int
|
||||
timer *time.Timer // تایمر فعال (نوبت یا جمعآوری)؛ فقط goroutine میز به آن دست میزند
|
||||
turnTimeout time.Duration
|
||||
botDelay time.Duration
|
||||
trickHold time.Duration
|
||||
}
|
||||
|
||||
func newRoom(id string, seats [4]*seatInfo, hub *Hub) *Room {
|
||||
return &Room{
|
||||
ID: id,
|
||||
hub: hub,
|
||||
settler: hub.settler,
|
||||
seats: seats,
|
||||
actions: make(chan roomAction, 64),
|
||||
turnTimeout: hub.turnTimeout,
|
||||
botDelay: hub.botDelay,
|
||||
trickHold: hub.trickHold,
|
||||
}
|
||||
}
|
||||
|
||||
// run حلقه اصلی میز.
|
||||
func (r *Room) run() {
|
||||
r.start()
|
||||
for act := range r.actions {
|
||||
if r.ended {
|
||||
continue
|
||||
}
|
||||
switch act.kind {
|
||||
case akInput:
|
||||
r.handleInput(act)
|
||||
case akTimeout:
|
||||
if act.gen == r.turnGen {
|
||||
r.autoMove(act.seat)
|
||||
}
|
||||
case akDisconnect:
|
||||
r.handleDisconnect(act.seat, act.client)
|
||||
case akReconnect:
|
||||
r.handleReconnect(act.seat, act.client)
|
||||
case akCollect:
|
||||
r.collect()
|
||||
}
|
||||
if r.ended {
|
||||
return // بازی تمام شد ⇒ خروج از goroutine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// start بازی را ساخته، matched را برای انسانها فرستاده و وضعیت اولیه را پخش میکند.
|
||||
func (r *Room) start() {
|
||||
r.game = game.NewGame(targetScore, game.FirstHakem())
|
||||
for seat, s := range r.seats {
|
||||
if s.client != nil {
|
||||
s.client.trySend(mustJSON(r.matchedFor(seat)))
|
||||
}
|
||||
}
|
||||
r.react()
|
||||
}
|
||||
|
||||
func (r *Room) matchedFor(seat int) matchedMsg {
|
||||
return matchedMsg{Type: "matched", Room: r.ID, Seat: seat, Players: r.playersInfo()}
|
||||
}
|
||||
|
||||
// react پس از هر تغییر state: نتیجه را اعلام و تایمر بازیگر بعدی را مسلح میکند.
|
||||
func (r *Room) react() {
|
||||
// دستِ کامل (۴ کارت) را نشان بده، کمی نگه دار، سپس جمع کن.
|
||||
if r.game.TrickDone {
|
||||
r.broadcastState()
|
||||
r.scheduleCollect()
|
||||
return
|
||||
}
|
||||
switch r.game.Phase {
|
||||
case game.PhaseHandOver:
|
||||
res := r.game.LastResult
|
||||
r.broadcast(mustJSON(handOverMsg{
|
||||
Type: "hand_over", WinnerTeam: res.WinnerTeam, Kot: res.Kot,
|
||||
Points: res.Points, Scores: r.game.Scores,
|
||||
}))
|
||||
if err := r.game.NextHand(); err != nil {
|
||||
slog.Error("next hand", "room", r.ID, "err", err)
|
||||
r.refundAll()
|
||||
r.endRoom()
|
||||
return
|
||||
}
|
||||
r.broadcastState()
|
||||
r.armTimer()
|
||||
case game.PhaseGameOver:
|
||||
res := r.game.LastResult
|
||||
r.broadcast(mustJSON(gameOverMsg{
|
||||
Type: "game_over", WinnerTeam: res.WinnerTeam, Scores: r.game.Scores,
|
||||
}))
|
||||
r.settleGameOver(res)
|
||||
r.endRoom()
|
||||
default: // choose_trump یا playing
|
||||
r.broadcastState()
|
||||
r.armTimer()
|
||||
}
|
||||
}
|
||||
|
||||
// stopTimer تایمر فعال را لغو میکند تا پیامهای کهنه کانال را پر نکنند.
|
||||
func (r *Room) stopTimer() {
|
||||
if r.timer != nil {
|
||||
r.timer.Stop()
|
||||
r.timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// scheduleCollect پس از نمایش دستِ کامل، جمعآوری را زمانبندی میکند.
|
||||
func (r *Room) scheduleCollect() {
|
||||
r.stopTimer()
|
||||
r.turnGen++ // هر تایمر نوبتِ کهنه را بیاثر کن
|
||||
r.timer = time.AfterFunc(r.trickHold, func() {
|
||||
select {
|
||||
case r.actions <- roomAction{kind: akCollect}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// collect دستِ کامل را جمع کرده و به وضعیت بعدی میرود.
|
||||
func (r *Room) collect() {
|
||||
if err := r.game.CollectTrick(); err != nil {
|
||||
return // قبلاً جمع شده یا نامعتبر
|
||||
}
|
||||
r.react()
|
||||
}
|
||||
|
||||
// armTimer برای بازیگر فعلی (حاکم در انتخاب حکم، یا نوبتدار در بازی) تایمر میگذارد.
|
||||
func (r *Room) armTimer() {
|
||||
r.stopTimer()
|
||||
if r.game.TrickDone {
|
||||
return // در زمان نمایش دستِ کامل تایمر نوبت نگذار
|
||||
}
|
||||
var actor int
|
||||
switch r.game.Phase {
|
||||
case game.PhaseChooseTrump:
|
||||
actor = r.game.Hakem
|
||||
case game.PhasePlaying:
|
||||
actor = r.game.Turn
|
||||
default:
|
||||
return
|
||||
}
|
||||
r.turnGen++
|
||||
gen := r.turnGen
|
||||
delay := r.turnTimeout
|
||||
if r.seats[actor].auto() {
|
||||
delay = r.botDelay
|
||||
}
|
||||
r.timer = time.AfterFunc(delay, func() {
|
||||
select {
|
||||
case r.actions <- roomAction{kind: akTimeout, seat: actor, gen: gen}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// handleInput یک ورودی معتبر از بازیکن را اعمال میکند.
|
||||
func (r *Room) handleInput(act roomAction) {
|
||||
s := r.seats[act.seat]
|
||||
if s.client == nil || s.client != act.client {
|
||||
return // فرستنده دیگر مالک این جایگاه نیست (کهنه)
|
||||
}
|
||||
switch act.msg.Type {
|
||||
case "leave":
|
||||
r.handleLeave(act.seat)
|
||||
case "choose_trump":
|
||||
suit, err := game.ParseSuit(act.msg.Suit)
|
||||
if err != nil {
|
||||
r.sendError(act.seat, "invalid suit")
|
||||
return
|
||||
}
|
||||
if err := r.game.ChooseTrump(act.seat, suit); err != nil {
|
||||
r.sendError(act.seat, err.Error())
|
||||
return
|
||||
}
|
||||
r.react()
|
||||
case "play_card":
|
||||
card, err := game.ParseCard(act.msg.Card)
|
||||
if err != nil {
|
||||
r.sendError(act.seat, "invalid card")
|
||||
return
|
||||
}
|
||||
if err := r.game.PlayCard(act.seat, card); err != nil {
|
||||
r.sendError(act.seat, err.Error())
|
||||
return
|
||||
}
|
||||
r.react()
|
||||
}
|
||||
}
|
||||
|
||||
// autoMove حرکت خودکار برای جایگاه (بات/قطعشده/AFK) را انجام میدهد.
|
||||
func (r *Room) autoMove(seat int) {
|
||||
switch r.game.Phase {
|
||||
case game.PhaseChooseTrump:
|
||||
if seat != r.game.Hakem {
|
||||
return
|
||||
}
|
||||
r.autoChooseTrump(seat)
|
||||
r.react()
|
||||
case game.PhasePlaying:
|
||||
if seat != r.game.Turn {
|
||||
return
|
||||
}
|
||||
r.autoPlayCard(seat)
|
||||
r.react()
|
||||
}
|
||||
}
|
||||
|
||||
// autoChooseTrump خالی را انتخاب میکند که بازیکن بیشترین کارت از آن را دارد.
|
||||
func (r *Room) autoChooseTrump(seat int) {
|
||||
hand := r.game.Hand(seat)
|
||||
if len(hand) == 0 {
|
||||
return
|
||||
}
|
||||
var counts [4]int
|
||||
for _, c := range hand {
|
||||
counts[c.Suit]++
|
||||
}
|
||||
best, bestN := hand[0].Suit, -1
|
||||
for s := game.Suit(0); s < 4; s++ {
|
||||
if counts[s] > bestN {
|
||||
bestN, best = counts[s], s
|
||||
}
|
||||
}
|
||||
_ = r.game.ChooseTrump(seat, best)
|
||||
}
|
||||
|
||||
// autoPlayCard یک کارت مجاز (پایینترین کارتِ خالِ زمینه، وگرنه پایینترین کارت) بازی میکند.
|
||||
func (r *Room) autoPlayCard(seat int) {
|
||||
hand := r.game.Hand(seat)
|
||||
if len(hand) == 0 {
|
||||
return
|
||||
}
|
||||
var choice game.Card
|
||||
found := false
|
||||
if len(r.game.Trick) > 0 { // خالِ زمینه تعیین شده
|
||||
lead := r.game.LeadSuit
|
||||
for _, c := range hand {
|
||||
if c.Suit == lead && (!found || c.Rank < choice.Rank) {
|
||||
choice, found = c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
choice = hand[0]
|
||||
for _, c := range hand {
|
||||
if c.Rank < choice.Rank {
|
||||
choice = c
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = r.game.PlayCard(seat, choice)
|
||||
}
|
||||
|
||||
// handleLeave خروج دائمی یک بازیکن؛ جایگاهش به بات تبدیل میشود.
|
||||
func (r *Room) handleLeave(seat int) {
|
||||
s := r.seats[seat]
|
||||
s.isBot = true
|
||||
s.connected = false
|
||||
s.left = true
|
||||
s.client = nil
|
||||
r.broadcast(mustJSON(playerLeftMsg{Type: "player_left", Seat: seat}))
|
||||
if r.connectedHumans() == 0 {
|
||||
r.refundAll()
|
||||
r.endRoom()
|
||||
return
|
||||
}
|
||||
r.broadcastState()
|
||||
r.armTimer()
|
||||
}
|
||||
|
||||
// handleDisconnect قطع موقت یک بازیکن؛ جایگاه برای بازگشت باز میماند و خودکار بازی میشود.
|
||||
func (r *Room) handleDisconnect(seat int, client *Client) {
|
||||
s := r.seats[seat]
|
||||
if s.client != client {
|
||||
return // قبلاً با کلاینت جدید جایگزین شده (کهنه)
|
||||
}
|
||||
s.connected = false
|
||||
s.client = nil
|
||||
r.broadcast(mustJSON(playerStatusMsg{Type: "player_disconnected", Seat: seat}))
|
||||
if r.connectedHumans() == 0 {
|
||||
r.refundAll()
|
||||
r.endRoom()
|
||||
return
|
||||
}
|
||||
r.broadcastState()
|
||||
r.armTimer()
|
||||
}
|
||||
|
||||
// handleReconnect اتصال مجدد یک بازیکن به جایگاه قبلی.
|
||||
func (r *Room) handleReconnect(seat int, client *Client) {
|
||||
s := r.seats[seat]
|
||||
s.client = client
|
||||
s.connected = true
|
||||
r.broadcast(mustJSON(playerStatusMsg{Type: "player_reconnected", Seat: seat}))
|
||||
// وضعیت کامل را برای بازیکن بازگشته بفرست
|
||||
client.trySend(mustJSON(r.matchedFor(seat)))
|
||||
client.trySend(mustJSON(buildState(r.game, r.ID, seat, r.playersInfo())))
|
||||
r.armTimer() // مهلت نوبت برای انسانِ بازگشته دوباره بلند میشود
|
||||
}
|
||||
|
||||
// endRoom میز را بسته و فهرست انسانها را برای پاکسازی به هاب میدهد.
|
||||
func (r *Room) endRoom() {
|
||||
if r.ended {
|
||||
return
|
||||
}
|
||||
r.ended = true
|
||||
var ids []int64
|
||||
var clients []*Client
|
||||
for _, s := range r.seats {
|
||||
if s.isBot {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, s.userID)
|
||||
if s.client != nil {
|
||||
clients = append(clients, s.client)
|
||||
}
|
||||
}
|
||||
r.hub.endRoom <- endInfo{room: r, humanIDs: ids, clients: clients}
|
||||
}
|
||||
|
||||
// settleGameOver جایزه را به برندگانِ انسان میدهد و بازی را ثبت میکند.
|
||||
func (r *Room) settleGameOver(res *game.HandResult) {
|
||||
for seat, s := range r.seats {
|
||||
if s.isBot || s.userID == 0 {
|
||||
continue
|
||||
}
|
||||
if game.Team(seat) == res.WinnerTeam {
|
||||
r.settler.AwardWinner(s.userID, r.tier)
|
||||
}
|
||||
}
|
||||
r.settler.RecordGame(r.ID, r.playersJSON(res.WinnerTeam), res.WinnerTeam, res.Kot)
|
||||
}
|
||||
|
||||
// refundAll ورودی را به بازیکنانِ انسان که داوطلبانه خارج نشدهاند بازمیگرداند.
|
||||
func (r *Room) refundAll() {
|
||||
for _, s := range r.seats {
|
||||
if s.isBot || s.left || s.userID == 0 {
|
||||
continue
|
||||
}
|
||||
r.settler.Refund(s.userID, r.tier)
|
||||
}
|
||||
}
|
||||
|
||||
// playersJSON خلاصهی بازیکنان را برای ثبت تاریخچه میسازد.
|
||||
func (r *Room) playersJSON(winnerTeam int) string {
|
||||
type rec struct {
|
||||
Seat int `json:"seat"`
|
||||
Name string `json:"name"`
|
||||
Bot bool `json:"bot"`
|
||||
Won bool `json:"won"`
|
||||
}
|
||||
out := make([]rec, 4)
|
||||
for i, s := range r.seats {
|
||||
out[i] = rec{Seat: i, Name: s.name, Bot: s.isBot, Won: game.Team(i) == winnerTeam}
|
||||
}
|
||||
return string(mustJSON(out))
|
||||
}
|
||||
|
||||
func (r *Room) connectedHumans() int {
|
||||
n := 0
|
||||
for _, s := range r.seats {
|
||||
if !s.isBot && s.connected && s.client != nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (r *Room) playersInfo() []PlayerInfo {
|
||||
out := make([]PlayerInfo, 4)
|
||||
for i, s := range r.seats {
|
||||
out[i] = PlayerInfo{Seat: i, Name: s.name, Bot: s.isBot, Connected: s.isBot || s.connected}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// broadcastState نمای اختصاصی هر بازیکنِ انسانِ متصل را برای او ارسال میکند.
|
||||
func (r *Room) broadcastState() {
|
||||
players := r.playersInfo()
|
||||
for seat, s := range r.seats {
|
||||
if s.client != nil {
|
||||
s.client.trySend(mustJSON(buildState(r.game, r.ID, seat, players)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcast یک پیام یکسان را برای همه بازیکنانِ انسانِ متصل ارسال میکند.
|
||||
func (r *Room) broadcast(b []byte) {
|
||||
for _, s := range r.seats {
|
||||
if s.client != nil {
|
||||
s.client.trySend(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Room) sendError(seat int, msg string) {
|
||||
if c := r.seats[seat].client; c != nil {
|
||||
c.trySend(mustJSON(errorMsg{Type: "error", Message: msg}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package ws
|
||||
|
||||
// Settler تسویهی اقتصادی میز را انجام میدهد (پیادهسازی در لایه economy).
|
||||
// لایه ws از جزئیات سکه/جایزه بیخبر است و فقط tier را پاس میدهد.
|
||||
type Settler interface {
|
||||
ChargeEntry(userID int64, tier string) error // کسر ورودی هنگام ورود به صف
|
||||
Refund(userID int64, tier string) // بازگرداندن ورودی در صورت لغو
|
||||
AwardWinner(userID int64, tier string) // جایزه/XP/جام به برنده
|
||||
RecordGame(room, playersJSON string, winnerTeam int, kot bool)
|
||||
}
|
||||
|
||||
// noopSettler پیشفرض (برای تستها و حالت بدون اقتصاد).
|
||||
type noopSettler struct{}
|
||||
|
||||
func (noopSettler) ChargeEntry(int64, string) error { return nil }
|
||||
func (noopSettler) Refund(int64, string) {}
|
||||
func (noopSettler) AwardWinner(int64, string) {}
|
||||
func (noopSettler) RecordGame(string, string, int, bool) {}
|
||||
@@ -0,0 +1,459 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"hakemsho/internal/game"
|
||||
)
|
||||
|
||||
// anyMsg تمام فیلدهای ممکن پیامهای سرور را برای تست جمع میکند.
|
||||
type anyMsg struct {
|
||||
Type string `json:"type"`
|
||||
Seat int `json:"seat"`
|
||||
YourSeat int `json:"your_seat"`
|
||||
Phase string `json:"phase"`
|
||||
Hakem int `json:"hakem"`
|
||||
Turn int `json:"turn"`
|
||||
YourHand []string `json:"your_hand"`
|
||||
LeadSuit string `json:"lead_suit"`
|
||||
TrickDone bool `json:"trick_done"`
|
||||
Players []PlayerInfo `json:"players"`
|
||||
}
|
||||
|
||||
// TestFourPlayersFullGame یک بازی کامل را روی WebSocket واقعی با ۴ ربات اجرا میکند.
|
||||
func TestFourPlayersFullGame(t *testing.T) {
|
||||
hub := testHub() // مهلتهای کوتاه (شامل trickHold) برای سرعت تست
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx), nil)
|
||||
if err != nil {
|
||||
t.Errorf("dial %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// ورود به صف
|
||||
if err := conn.WriteJSON(map[string]string{"type": "join_queue", "mode": "normal"}); err != nil {
|
||||
t.Errorf("join %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
|
||||
seat := -1
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("read %d (seat %d): %v", idx, seat, err)
|
||||
return
|
||||
}
|
||||
var m anyMsg
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Errorf("unmarshal %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
switch m.Type {
|
||||
case "matched":
|
||||
seat = m.Seat
|
||||
case "state":
|
||||
act(conn, &m)
|
||||
case "game_over":
|
||||
return
|
||||
case "error":
|
||||
t.Errorf("client %d got error: %s", idx, raw)
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(25 * time.Second):
|
||||
t.Fatal("game did not finish in time")
|
||||
}
|
||||
}
|
||||
|
||||
// act ربات سمتکلاینت: حکم میزند یا کارت مجاز بازی میکند.
|
||||
// در فاز انتخاب حکم، حاکم دقیقاً یک state دریافت میکند پس یکبار حکم میزند (هر هَند).
|
||||
func act(conn *websocket.Conn, m *anyMsg) {
|
||||
switch m.Phase {
|
||||
case "choose_trump":
|
||||
if m.YourSeat == m.Hakem && len(m.YourHand) > 0 {
|
||||
c, _ := game.ParseCard(m.YourHand[0])
|
||||
_ = conn.WriteJSON(map[string]string{"type": "choose_trump", "suit": c.Suit.String()})
|
||||
}
|
||||
case "playing":
|
||||
if m.TrickDone || m.Turn != m.YourSeat {
|
||||
return
|
||||
}
|
||||
card := legalCard(m.YourHand, m.LeadSuit)
|
||||
_ = conn.WriteJSON(map[string]string{"type": "play_card", "card": card})
|
||||
}
|
||||
}
|
||||
|
||||
// testHub یک هاب با مهلتهای کوتاه برای تستها میسازد.
|
||||
func testHub() *Hub {
|
||||
hub := NewHub(func(token string) (int64, string, error) {
|
||||
id, err := strconv.ParseInt(token, 10, 64)
|
||||
return id, "p" + token, err
|
||||
})
|
||||
hub.turnTimeout = 8 * time.Millisecond
|
||||
hub.botDelay = 8 * time.Millisecond
|
||||
hub.matchWait = 300 * time.Millisecond
|
||||
hub.trickHold = 5 * time.Millisecond
|
||||
go hub.Run()
|
||||
return hub
|
||||
}
|
||||
|
||||
// TestDisconnectMidGameNoPanic: قطع بازیکن وسط بازی باید بدون panic، رویداد
|
||||
// player_disconnected تولید کند و بازی با حرکت خودکار ادامه یابد.
|
||||
func TestDisconnectMidGameNoPanic(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)
|
||||
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)
|
||||
}
|
||||
conns[i] = c
|
||||
if err := c.WriteJSON(map[string]string{"type": "join_queue"}); err != nil {
|
||||
t.Fatalf("join %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
conns[0].Close()
|
||||
|
||||
gotDisconnect := false
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
_ = conns[1].SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, raw, err := conns[1].ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if m.Type == "player_disconnected" {
|
||||
gotDisconnect = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, c := range conns[1:] {
|
||||
c.Close()
|
||||
}
|
||||
if !gotDisconnect {
|
||||
t.Fatal("expected player_disconnected after a player disconnected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBotFill: اگر کمتر از ۴ انسان در صف بماند، میز با بات کامل و بازی شروع میشود.
|
||||
func TestBotFill(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, 2)
|
||||
for i := 0; i < 2; 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 با ۴ بازیکن (۲ بات) دریافت کند.
|
||||
_ = conns[0].SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
bots := -1
|
||||
for {
|
||||
_, raw, err := conns[0].ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read matched: %v", err)
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if m.Type == "matched" {
|
||||
if len(m.Players) != 4 {
|
||||
t.Fatalf("expected 4 players, got %d", len(m.Players))
|
||||
}
|
||||
bots = 0
|
||||
for _, p := range m.Players {
|
||||
if p.Bot {
|
||||
bots++
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if bots != 2 {
|
||||
t.Fatalf("expected 2 bots, got %d", bots)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconnect: بازیکن قطعشده میتواند با همان توکن به جایگاه قبلی بازگردد.
|
||||
func TestReconnect(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)
|
||||
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)
|
||||
}
|
||||
conns[i] = c
|
||||
_ = c.WriteJSON(map[string]string{"type": "join_queue"})
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
// بازیکن ۰ قطع و سپس با همان توکن دوباره وصل میشود.
|
||||
conns[0].Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
rc, _, err := websocket.DefaultDialer.Dial(wsURL+"?token=0", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("reconnect dial: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
// باید matched با seat=0 و سپس state دریافت کند.
|
||||
gotMatched, gotState := false, false
|
||||
_ = rc.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
for !gotState {
|
||||
_, raw, err := rc.ReadMessage()
|
||||
if err != nil {
|
||||
t.Fatalf("read after reconnect: %v", err)
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
switch m.Type {
|
||||
case "matched":
|
||||
if m.Seat != 0 {
|
||||
t.Fatalf("reconnect to wrong seat: %d", m.Seat)
|
||||
}
|
||||
gotMatched = true
|
||||
case "state":
|
||||
gotState = true
|
||||
}
|
||||
}
|
||||
if !gotMatched || !gotState {
|
||||
t.Fatal("reconnected client did not receive matched+state")
|
||||
}
|
||||
for _, c := range conns[1:] {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// TestTurnTimeoutAutoPlays: حتی اگر هیچ بازیکنی حرکت نکند، تایمر نوبت کل بازی
|
||||
// را خودکار پیش میبرد تا game_over (مسیر مشترک با حرکت بات/قطعشده).
|
||||
func TestTurnTimeoutAutoPlays(t *testing.T) {
|
||||
hub := testHub()
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx), nil)
|
||||
if err != nil {
|
||||
t.Errorf("dial %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.WriteJSON(map[string]string{"type": "join_queue"})
|
||||
// عمداً هیچ حرکتی نمیزنیم؛ فقط منتظر game_over میمانیم.
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
t.Errorf("client %d read: %v", idx, err)
|
||||
return
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if m.Type == "game_over" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("turn-timeout auto-play did not finish the game")
|
||||
}
|
||||
}
|
||||
|
||||
// recordingSettler فراخوانیهای تسویه را برای بررسی در تست میشمارد.
|
||||
type recordingSettler struct {
|
||||
mu sync.Mutex
|
||||
charges int
|
||||
refunds int
|
||||
awards int
|
||||
records int
|
||||
}
|
||||
|
||||
func (s *recordingSettler) ChargeEntry(int64, string) error {
|
||||
s.mu.Lock()
|
||||
s.charges++
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (s *recordingSettler) Refund(int64, string) {
|
||||
s.mu.Lock()
|
||||
s.refunds++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *recordingSettler) AwardWinner(int64, string) {
|
||||
s.mu.Lock()
|
||||
s.awards++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *recordingSettler) RecordGame(string, string, int, bool) {
|
||||
s.mu.Lock()
|
||||
s.records++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
func (s *recordingSettler) snapshot() (c, r, a, rec int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.charges, s.refunds, s.awards, s.records
|
||||
}
|
||||
|
||||
// TestSettlementOnGameOver: ۴ انسانِ بیکار ⇒ ۴ کسر ورودی، بازی خودکار تا پایان،
|
||||
// ۲ جایزهی برنده و یک ثبت بازی، بدون بازگشت.
|
||||
func TestSettlementOnGameOver(t *testing.T) {
|
||||
hub := testHub()
|
||||
settler := &recordingSettler{}
|
||||
hub.SetSettler(settler)
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(idx+1), nil)
|
||||
if err != nil {
|
||||
t.Errorf("dial %d: %v", idx, err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.WriteJSON(map[string]string{"type": "join_queue", "tier": "beginner"})
|
||||
for {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(6 * time.Second))
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m anyMsg
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
if m.Type == "game_over" {
|
||||
return
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("game did not finish")
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond) // فرصت پردازش settle در goroutine میز
|
||||
|
||||
c, r, a, rec := settler.snapshot()
|
||||
if c != 4 {
|
||||
t.Errorf("charges = %d, want 4", c)
|
||||
}
|
||||
if a != 2 {
|
||||
t.Errorf("awards = %d, want 2 (winning team humans)", a)
|
||||
}
|
||||
if rec != 1 {
|
||||
t.Errorf("records = %d, want 1", rec)
|
||||
}
|
||||
if r != 0 {
|
||||
t.Errorf("refunds = %d, want 0 on normal finish", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefundOnAbort: اگر همهی انسانها وسط بازی قطع شوند، ورودی بازگردانده میشود.
|
||||
func TestRefundOnAbort(t *testing.T) {
|
||||
hub := testHub()
|
||||
settler := &recordingSettler{}
|
||||
hub.SetSettler(settler)
|
||||
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
|
||||
defer srv.Close()
|
||||
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
conns := make([]*websocket.Conn, 4)
|
||||
for i := 0; i < 4; i++ {
|
||||
c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+strconv.Itoa(i+1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial %d: %v", i, err)
|
||||
}
|
||||
conns[i] = c
|
||||
_ = c.WriteJSON(map[string]string{"type": "join_queue", "tier": "beginner"})
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond) // میز تشکیل شود
|
||||
for _, c := range conns {
|
||||
c.Close()
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond) // قطع همه ⇒ abort + refund
|
||||
|
||||
charges, refunds, _, _ := settler.snapshot()
|
||||
if charges != 4 {
|
||||
t.Errorf("charges = %d, want 4", charges)
|
||||
}
|
||||
if refunds != 4 {
|
||||
t.Errorf("refunds = %d, want 4 on abort", refunds)
|
||||
}
|
||||
}
|
||||
|
||||
// legalCard یک کارت مجاز انتخاب میکند (follow suit در صورت امکان).
|
||||
func legalCard(hand []string, leadSuit string) string {
|
||||
if leadSuit != "" {
|
||||
for _, cs := range hand {
|
||||
c, err := game.ParseCard(cs)
|
||||
if err == nil && c.Suit.String() == leadSuit {
|
||||
return cs
|
||||
}
|
||||
}
|
||||
}
|
||||
return hand[0]
|
||||
}
|
||||
Reference in New Issue
Block a user