This commit is contained in:
2026-06-15 16:53:25 +03:30
commit 390ef96e7e
34 changed files with 4463 additions and 0 deletions
+112
View File
@@ -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
}
+306
View File
@@ -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()
}
+131
View File
@@ -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
}
+454
View File
@@ -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}))
}
}
+18
View File
@@ -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) {}
+459
View File
@@ -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]
}