feat: add private and profile

This commit is contained in:
2026-06-17 11:32:51 +03:30
parent 02060a4dc0
commit 0827858752
23 changed files with 972 additions and 25 deletions
+4 -3
View File
@@ -31,9 +31,10 @@ type Client struct {
closeOnce sync.Once
// توسط goroutine هاب ست می‌شوند (تک‌نویسنده) و فقط توسط آن خوانده می‌شوند.
room *Room
seat int
tier string // نوع میزی که در صفش است (برای تسویه)
room *Room
seat int
tier string // نوع میزی که در صفش است (برای تسویه)
tableCode string // کدِ میز خصوصیِ در انتظار (پیش از شروع بازی)
}
// close اتصال را یک‌بار به‌صورت امن می‌بندد.
+200 -10
View File
@@ -3,6 +3,7 @@ package ws
import (
"fmt"
"log/slog"
"math/rand"
"net/http"
"time"
@@ -39,6 +40,15 @@ type endInfo struct {
clients []*Client
}
// pendingTable میز خصوصیِ در انتظارِ شروع (دورهمی).
// فقط توسط goroutine هاب خوانده/نوشته می‌شود.
type pendingTable struct {
code string
host int64
clients []*Client
started bool // پس از فشردن «شروع» توسط میزبان
}
// Hub هماهنگ‌کننده مرکزی: اتصال‌ها، صف matchmaking و مسیریابی پیام‌ها.
// تمام state آن فقط توسط goroutine Run تغییر می‌کند (single-writer، بدون قفل).
// پس از ساخت یک میز، هاب دیگر به seatInfo دست نمی‌زند (مالک آن goroutine میز است).
@@ -47,23 +57,26 @@ type Hub struct {
upgrader websocket.Upgrader
clients map[*Client]bool
queues map[string][]*Client // صف انتظار به ازای هر tier
locations map[int64]location // userID → محل بازی (برای reconnect)
queues map[string][]*Client // صف انتظار به ازای هر tier
locations map[int64]location // userID → محل بازی (برای reconnect)
tables map[string]*pendingTable // کد میز → میز خصوصیِ در انتظار
roomSeq int
botSeq int
fillPending map[string]bool
settler Settler
turnTimeout time.Duration
botDelay time.Duration
matchWait time.Duration
trickHold time.Duration
turnTimeout time.Duration
botDelay time.Duration
matchWait time.Duration
trickHold time.Duration
countdownDelay time.Duration // مهلت شمارش معکوسِ میز خصوصی پیش از شروع
register chan *Client
unregister chan *Client
inbound chan inbound
endRoom chan endInfo
fill chan string // tier برای پر کردن با بات
startTbl chan string // کد میز خصوصی برای شروع (پس از شمارش معکوس)
}
func NewHub(auth AuthFunc) *Hub {
@@ -78,17 +91,20 @@ func NewHub(auth AuthFunc) *Hub {
clients: make(map[*Client]bool),
queues: make(map[string][]*Client),
locations: make(map[int64]location),
tables: make(map[string]*pendingTable),
fillPending: make(map[string]bool),
settler: noopSettler{},
turnTimeout: defaultTurnTimeout,
botDelay: defaultBotDelay,
matchWait: defaultMatchWait,
trickHold: defaultTrickHold,
turnTimeout: defaultTurnTimeout,
botDelay: defaultBotDelay,
matchWait: defaultMatchWait,
trickHold: defaultTrickHold,
countdownDelay: 3 * time.Second,
register: make(chan *Client),
unregister: make(chan *Client),
inbound: make(chan inbound, 64),
endRoom: make(chan endInfo),
fill: make(chan string, 8),
startTbl: make(chan string, 8),
}
}
@@ -111,6 +127,8 @@ func (h *Hub) Run() {
h.closeRoom(e)
case tier := <-h.fill:
h.onFill(tier)
case code := <-h.startTbl:
h.onStartTable(code)
}
}
}
@@ -150,6 +168,171 @@ func (h *Hub) handle(in inbound) {
default:
}
}
case "create_table":
h.createTable(c)
case "join_table":
h.joinTable(c, in.msg.Code)
case "start_table":
h.startTable(c)
case "leave_table":
h.leaveTable(c)
}
}
// --- میز خصوصی (دورهمی) ---
// genCode یک کد ۵ رقمیِ یکتا برای میز خصوصی می‌سازد.
func (h *Hub) genCode() string {
for {
code := fmt.Sprintf("%05d", rand.Intn(100000))
if _, ok := h.tables[code]; !ok {
return code
}
}
}
// createTable یک میز خصوصی می‌سازد (در صورت مجاز بودنِ سهمیه).
func (h *Hub) createTable(c *Client) {
if c.room != nil || c.tableCode != "" {
return
}
if err := h.settler.ChargePrivateTable(c.UserID); err != nil {
c.trySend(mustJSON(errorMsg{Type: "error", Message: "سهمیه‌ی میزهای رایگان به پایان رسیده؛ برای میز نامحدود VIP بگیرید"}))
return
}
code := h.genCode()
t := &pendingTable{code: code, host: c.UserID, clients: []*Client{c}}
h.tables[code] = t
c.tableCode = code
slog.Info("private table created", "code", code, "host", c.UserID)
h.broadcastLobby(t)
}
// joinTable کلاینت را به میز خصوصیِ موجود می‌افزاید.
func (h *Hub) joinTable(c *Client, code string) {
if c.room != nil || c.tableCode != "" {
return
}
t := h.tables[code]
if t == nil || t.started {
c.trySend(mustJSON(errorMsg{Type: "error", Message: "میز یافت نشد"}))
return
}
if len(t.clients) >= 4 {
c.trySend(mustJSON(errorMsg{Type: "error", Message: "میز پر است"}))
return
}
t.clients = append(t.clients, c)
c.tableCode = code
h.broadcastLobby(t)
}
// startTable شمارش معکوس را آغاز و پس از آن بازی را شروع می‌کند (فقط میزبان).
func (h *Hub) startTable(c *Client) {
t := h.tables[c.tableCode]
if t == nil || t.host != c.UserID || t.started {
return
}
t.started = true
cd := mustJSON(countdownMsg{Type: "countdown", Seconds: 3})
for _, cl := range t.clients {
cl.trySend(cd)
}
code := t.code
time.AfterFunc(h.countdownDelay, func() {
select {
case h.startTbl <- code:
default:
}
})
}
// onStartTable پس از شمارش معکوس، میز را از کلاینت‌های متصل می‌سازد و بازی را شروع می‌کند.
func (h *Hub) onStartTable(code string) {
t := h.tables[code]
if t == nil {
return
}
delete(h.tables, code)
var seats [4]*seatInfo
n := 0
for _, cl := range t.clients {
if n >= 4 || !h.clients[cl] {
continue // قطع‌شده‌ها نادیده گرفته می‌شوند
}
seats[n] = &seatInfo{client: cl, userID: cl.UserID, name: cl.Name, connected: true}
n++
}
if n == 0 {
return // همه خارج شدند
}
for i := n; i < 4; i++ {
h.botSeq++
seats[i] = &seatInfo{isBot: true, name: fmt.Sprintf("ربات %d", h.botSeq)}
}
h.roomSeq++
room := newRoom(fmt.Sprintf("p%d", h.roomSeq), seats, h)
room.tier = defaultTier // قواعدِ مبتدی برای دورهمی
room.private = true
for i := 0; i < n; i++ {
cl := seats[i].client
cl.room = room
cl.seat = i
cl.tableCode = ""
h.locations[cl.UserID] = location{room: room, seat: i}
}
go room.run()
slog.Info("private room started", "room", room.ID, "code", code, "humans", n)
}
// leaveTable خروجِ داوطلبانه از اتاق انتظار.
func (h *Hub) leaveTable(c *Client) {
t := h.tables[c.tableCode]
c.tableCode = ""
if t != nil {
h.removeFromTable(t, c)
}
}
// removeFromTable کلاینت را از میز حذف می‌کند؛ با خروجِ میزبان یا خالی‌شدن، میز منحل می‌شود.
func (h *Hub) removeFromTable(t *pendingTable, c *Client) {
idx := -1
for i, cl := range t.clients {
if cl == c {
idx = i
break
}
}
if idx < 0 {
return
}
t.clients = append(t.clients[:idx], t.clients[idx+1:]...)
if c.UserID == t.host || len(t.clients) == 0 {
closed := mustJSON(tableClosedMsg{Type: "table_closed", Reason: "host_left"})
for _, cl := range t.clients {
cl.tableCode = ""
cl.trySend(closed)
}
delete(h.tables, t.code)
return
}
h.broadcastLobby(t)
}
// broadcastLobby وضعیت اتاق انتظار را برای همه‌ی اعضای میز ارسال می‌کند.
func (h *Hub) broadcastLobby(t *pendingTable) {
players := make([]lobbyPlayer, len(t.clients))
for i, cl := range t.clients {
players[i] = lobbyPlayer{Name: cl.Name, Host: cl.UserID == t.host}
}
for _, cl := range t.clients {
remaining, unlimited := h.settler.PrivateTableInfo(cl.UserID)
cl.trySend(mustJSON(tableLobbyMsg{
Type: "table_lobby", Code: t.code, Players: players,
Host: cl.UserID == t.host, Remaining: remaining, Unlimited: unlimited,
}))
}
}
@@ -248,6 +431,13 @@ func (h *Hub) handleDisconnect(c *Client) {
}
}
}
// اگر در اتاق انتظارِ میز خصوصی بود، از آن حذف شود.
if c.tableCode != "" {
if t := h.tables[c.tableCode]; t != nil {
h.removeFromTable(t, c)
}
c.tableCode = ""
}
if c.room != nil {
select {
case c.room.actions <- roomAction{kind: akDisconnect, seat: c.seat, client: c}:
+30 -1
View File
@@ -4,11 +4,40 @@ import "hakemsho/internal/game"
// inboundMsg پیام دریافتی از کلاینت.
type inboundMsg struct {
Type string `json:"type"` // join_queue | choose_trump | play_card | leave
Type string `json:"type"` // join_queue | choose_trump | play_card | leave | create_table | join_table | start_table | leave_table
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"
Code string `json:"code"` // برای join_table: شماره میز خصوصی
}
// lobbyPlayer یک بازیکن در اتاق انتظارِ میز خصوصی.
type lobbyPlayer struct {
Name string `json:"name"`
Host bool `json:"host"`
}
// tableLobbyMsg وضعیت اتاق انتظارِ میز خصوصی (دورهمی).
type tableLobbyMsg struct {
Type string `json:"type"` // "table_lobby"
Code string `json:"code"`
Players []lobbyPlayer `json:"players"`
Host bool `json:"host"` // آیا گیرنده میزبان است
Remaining int `json:"remaining"` // باقیمانده‌ی میزهای رایگانِ گیرنده
Unlimited bool `json:"unlimited"`
}
// countdownMsg شمارش معکوس پیش از شروع بازیِ خصوصی.
type countdownMsg struct {
Type string `json:"type"` // "countdown"
Seconds int `json:"seconds"`
}
// tableClosedMsg انحلالِ میز خصوصی پیش از شروع (میزبان خارج شد).
type tableClosedMsg struct {
Type string `json:"type"` // "table_closed"
Reason string `json:"reason"`
}
// PlayerInfo اطلاعات عمومی یک بازیکن سر میز.
+42 -4
View File
@@ -47,6 +47,7 @@ type Room struct {
ID string
hub *Hub
tier string
private bool // میز دورهمی: بدون ورودی/جایزه (فقط آمار ثبت می‌شود)
settler Settler
seats [4]*seatInfo
game *game.Game
@@ -130,6 +131,7 @@ func (r *Room) react() {
switch r.game.Phase {
case game.PhaseHandOver:
res := r.game.LastResult
r.recordHandStats(res)
r.broadcast(mustJSON(handOverMsg{
Type: "hand_over", WinnerTeam: res.WinnerTeam, Kot: res.Kot,
HakemKot: res.HakemKot, Points: res.Points, Scores: r.game.Scores,
@@ -144,6 +146,7 @@ func (r *Room) react() {
r.armTimer()
case game.PhaseGameOver:
res := r.game.LastResult
r.recordHandStats(res)
r.broadcast(mustJSON(gameOverMsg{
Type: "game_over", WinnerTeam: res.WinnerTeam, Scores: r.game.Scores,
}))
@@ -238,7 +241,7 @@ func (r *Room) handleInput(act roomAction) {
r.sendError(act.seat, "invalid card")
return
}
if err := r.game.PlayCard(act.seat, card); err != nil {
if err := r.playCard(act.seat, card); err != nil {
r.sendError(act.seat, err.Error())
return
}
@@ -307,7 +310,37 @@ func (r *Room) autoPlayCard(seat int) {
}
}
}
_ = r.game.PlayCard(seat, choice)
_ = r.playCard(seat, choice)
}
// playCard کارت را بازی کرده و در صورت بُرِش با حکم، آن را برای آمار ثبت می‌کند.
func (r *Room) playCard(seat int, card game.Card) error {
wasFirst := len(r.game.Trick) == 0
if err := r.game.PlayCard(seat, card); err != nil {
return err
}
// بُرِش: کارتِ غیرِاولِ دست، از خالِ حکم، در حالی که خالِ زمینه حکم نبوده
// ⇒ بازیکن خالِ زمینه را نداشته و با حکم بریده است.
if !wasFirst && card.Suit == r.game.Trump && r.game.LeadSuit != r.game.Trump {
if s := r.seats[seat]; !s.isBot && s.userID != 0 {
r.settler.RecordCut(s.userID)
}
}
return nil
}
// recordHandStats آمار هر بازیکنِ انسان را برای هَندِ پایان‌یافته ثبت می‌کند.
func (r *Room) recordHandStats(res *game.HandResult) {
if res == nil {
return
}
for seat, s := range r.seats {
if s.isBot || s.userID == 0 {
continue
}
won := game.Team(seat) == res.WinnerTeam
r.settler.RecordHand(s.userID, won, res.Kot, seat == res.Hakem)
}
}
// handleLeave خروج دائمی یک بازیکن؛ جایگاهش به بات تبدیل می‌شود.
@@ -383,15 +416,20 @@ func (r *Room) settleGameOver(res *game.HandResult) {
if s.isBot || s.userID == 0 {
continue
}
if game.Team(seat) == res.WinnerTeam {
r.settler.AwardWinner(s.userID, r.tier)
won := game.Team(seat) == res.WinnerTeam
if won && !r.private {
r.settler.AwardWinner(s.userID, r.tier) // میز دورهمی جایزه‌ی سکه ندارد
}
r.settler.RecordGameResult(s.userID, won)
}
r.settler.RecordGame(r.ID, r.playersJSON(res.WinnerTeam), res.WinnerTeam, res.Kot)
}
// refundAll ورودی را به بازیکنانِ انسان که داوطلبانه خارج نشده‌اند بازمی‌گرداند.
func (r *Room) refundAll() {
if r.private {
return // میز دورهمی ورودی نگرفته، پس بازگشتی هم ندارد
}
for _, s := range r.seats {
if s.isBot || s.left || s.userID == 0 {
continue
+12
View File
@@ -7,6 +7,13 @@ type Settler interface {
Refund(userID int64, tier string) // بازگرداندن ورودی در صورت لغو
AwardWinner(userID int64, tier string) // جایزه/XP/جام به برنده
RecordGame(room, playersJSON string, winnerTeam int, kot bool)
// آمار پروفایل (همگی غیرحیاتی و fire-and-forget).
RecordHand(userID int64, won, kot, asHakem bool) // پایان یک هَند
RecordCut(userID int64) // بُرِش با حکم
RecordGameResult(userID int64, won bool) // پایان بازی (برد/باخت)
// میز خصوصی (دورهمی).
ChargePrivateTable(userID int64) error // مصرفِ یک میز رایگان (خطا اگر سقف پر باشد)
PrivateTableInfo(userID int64) (remaining int, unlimited bool)
// TargetHands تعداد هَندِ لازم برای برد بازی در این tier (مثلاً مبتدی=۳).
TargetHands(tier string) int
}
@@ -18,4 +25,9 @@ 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) {}
func (noopSettler) RecordHand(int64, bool, bool, bool) {}
func (noopSettler) RecordCut(int64) {}
func (noopSettler) RecordGameResult(int64, bool) {}
func (noopSettler) ChargePrivateTable(int64) error { return nil }
func (noopSettler) PrivateTableInfo(int64) (int, bool) { return 0, true }
func (noopSettler) TargetHands(string) int { return 7 }
+132
View File
@@ -7,6 +7,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
@@ -27,6 +28,8 @@ type anyMsg struct {
LeadSuit string `json:"lead_suit"`
TrickDone bool `json:"trick_done"`
Players []PlayerInfo `json:"players"`
Code string `json:"code"` // table_lobby
Host bool `json:"host"` // table_lobby
}
// TestFourPlayersFullGame یک بازی کامل را روی WebSocket واقعی با ۴ ربات اجرا می‌کند.
@@ -119,10 +122,117 @@ func testHub() *Hub {
hub.botDelay = 8 * time.Millisecond
hub.matchWait = 300 * time.Millisecond
hub.trickHold = 5 * time.Millisecond
hub.countdownDelay = 20 * time.Millisecond
go hub.Run()
return hub
}
// TestPrivateTableFlow: میزبان میز خصوصی می‌سازد، بازیکن دوم می‌پیوندد،
// میزبان شروع می‌کند و بازی (با بات برای ۲ جای خالی) تا پایان پیش می‌رود.
func TestPrivateTableFlow(t *testing.T) {
hub := testHub()
srv := httptest.NewServer(http.HandlerFunc(hub.ServeWS))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
dial := func(tok string) *websocket.Conn {
c, _, err := websocket.DefaultDialer.Dial(wsURL+"?token="+tok, nil)
if err != nil {
t.Fatalf("dial %s: %v", tok, err)
}
return c
}
host := dial("1")
defer host.Close()
if err := host.WriteJSON(map[string]string{"type": "create_table"}); err != nil {
t.Fatal(err)
}
// میزبان باید table_lobby با کد دریافت کند.
code := ""
for code == "" {
_ = host.SetReadDeadline(time.Now().Add(2 * time.Second))
_, raw, err := host.ReadMessage()
if err != nil {
t.Fatalf("host read: %v", err)
}
var m anyMsg
_ = json.Unmarshal(raw, &m)
if m.Type == "table_lobby" {
if !m.Host {
t.Fatal("host flag must be true for creator")
}
code = m.Code
}
}
if len(code) != 5 {
t.Fatalf("expected 5-digit code, got %q", code)
}
joiner := dial("2")
defer joiner.Close()
if err := joiner.WriteJSON(map[string]string{"type": "join_table", "code": code}); err != nil {
t.Fatal(err)
}
// میزبان منتظر می‌ماند تا میز ۲ نفره شود، سپس شروع می‌کند.
var gotCountdown atomic.Bool
play := func(conn *websocket.Conn, m *anyMsg) {
if m.Type == "state" {
act(conn, m)
}
}
done := make(chan string, 2)
run := func(conn *websocket.Conn, isHost bool) {
started := false
for {
_ = conn.SetReadDeadline(time.Now().Add(6 * time.Second))
_, raw, err := conn.ReadMessage()
if err != nil {
done <- "read err: " + err.Error()
return
}
var m anyMsg
_ = json.Unmarshal(raw, &m)
switch m.Type {
case "table_lobby":
if isHost && !started && len(m.Players) >= 2 {
started = true
_ = conn.WriteJSON(map[string]string{"type": "start_table"})
}
case "countdown":
gotCountdown.Store(true)
case "state":
play(conn, &m)
case "game_over":
done <- "ok"
return
case "error":
done <- "error: " + string(raw)
return
}
}
}
go run(host, true)
go run(joiner, false)
for i := 0; i < 2; i++ {
select {
case res := <-done:
if res != "ok" {
t.Fatalf("client finished with: %s", res)
}
case <-time.After(25 * time.Second):
t.Fatal("private game did not finish in time")
}
}
if !gotCountdown.Load() {
t.Fatal("expected countdown message before game start")
}
}
// TestDisconnectMidGameNoPanic: قطع بازیکن وسط بازی باید بدون panic، رویداد
// player_disconnected تولید کند و بازی با حرکت خودکار ادامه یابد.
func TestDisconnectMidGameNoPanic(t *testing.T) {
@@ -323,6 +433,9 @@ type recordingSettler struct {
refunds int
awards int
records int
hands int
cuts int
results int
}
func (s *recordingSettler) ChargeEntry(int64, string) error {
@@ -347,6 +460,25 @@ func (s *recordingSettler) RecordGame(string, string, int, bool) {
s.mu.Unlock()
}
func (s *recordingSettler) RecordHand(int64, bool, bool, bool) {
s.mu.Lock()
s.hands++
s.mu.Unlock()
}
func (s *recordingSettler) RecordCut(int64) {
s.mu.Lock()
s.cuts++
s.mu.Unlock()
}
func (s *recordingSettler) RecordGameResult(int64, bool) {
s.mu.Lock()
s.results++
s.mu.Unlock()
}
func (s *recordingSettler) ChargePrivateTable(int64) error { return nil }
func (s *recordingSettler) PrivateTableInfo(int64) (int, bool) { return 5, false }
// برای سرعتِ تست، بازی پس از ۲ هَند تمام می‌شود.
func (s *recordingSettler) TargetHands(string) int { return 2 }
func (s *recordingSettler) snapshot() (c, r, a, rec int) {