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 private bool // میز دورهمی: بدون ورودی/جایزه (فقط آمار ثبت می‌شود) 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 firstHandDelay time.Duration handStart time.Time // زمانِ شروعِ هَندِ جاری (برای پنجره‌ی بُر زدن) } 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, firstHandDelay: hub.firstHandDelay, } } // 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() { // تعداد هَندِ لازم برای برد بازی از روی نوعِ میز (tier) تعیین می‌شود. target := r.settler.TargetHands(r.tier) if target <= 0 { target = targetScore } r.game = game.NewGame(target, game.FirstHakem()) r.handStart = time.Now() 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.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, })) if err := r.game.NextHand(); err != nil { slog.Error("next hand", "room", r.ID, "err", err) r.refundAll() r.endRoom() return } r.handStart = time.Now() // شروعِ هَندِ جدید ⇒ پنجره‌ی بُر زدن r.broadcastState() 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, })) 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 // در پنجره‌ی ابتدای هَند (انیمیشنِ بُر زدنِ کلاینت)، حرکتِ خودکار را عقب // می‌اندازیم تا کارت‌ها وسطِ بُر زدن روی میز نیفتند. نوبتِ انسان دست‌نخورده می‌ماند. if grace := r.firstHandDelay - time.Since(r.handStart); grace > delay { delay = grace } } 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.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.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 خروج دائمی یک بازیکن؛ جایگاهش به بات تبدیل می‌شود. 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(), int(r.turnTimeout.Milliseconds())))) 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 } 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 } 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, int(r.turnTimeout.Milliseconds())))) } } } // 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})) } }