Files
back-hokm/internal/ws/room.go
T
2026-07-07 23:23:12 +03:30

602 lines
20 KiB
Go

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 // خروج دائمی داوطلبانه (در تسویه از بازگشت ورودی محروم)
coins int64 // برای نمایش سرِ میز
rankTier string // نشانِ رتبه برای نمایش سرِ میز
avatar string // seed آواتارِ انتخابیِ کاربر (برای نمایش سرِ میز)
}
// 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 // میز دورهمی: بدون ورودی/جایزه‌ی tier (فقط آمار و شرطِ سکه)
handsOverride int // تعداد دستِ سفارشیِ میز خصوصی (۰ یعنی از tier)
stake int64 // شرطِ سکه‌ایِ هر بازیکن در میز خصوصی (۰ یعنی رایگان)
pot int64 // مجموعِ سکه‌های جمع‌آوری‌شده برای برنده
paid [4]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 // پنجره‌ی بُر زدن+پخشِ ۵ کارت (ابتدای هَند)
dealDelay time.Duration // پنجره‌ی پخشِ بقیه‌ی کارت‌ها (پس از انتخابِ حکم)
// graceUntil: تا این لحظه هیچ حرکتِ خودکاری انجام نمی‌شود (هماهنگ با انیمیشن‌های کلاینت).
graceUntil time.Time
chatAt [4]time.Time // آخرین زمانِ چتِ هر جایگاه (ضدِ اسپم)
// seen: حافظه‌ی کارت‌های بازی‌شده در هَندِ جاری (برای هوشِ باتِ کارت‌شمار).
// بیتِ (rank-2) از seen[suit] یعنی آن کارت رو شده. هر هَند ریست می‌شود.
seen [4]uint16
}
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,
dealDelay: hub.dealDelay,
}
}
// 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 r.handsOverride > 0 {
target = r.handsOverride // میز خصوصی: تعداد دستِ انتخابیِ میزبان
}
if target <= 0 {
target = targetScore
}
r.game = game.NewGame(target, game.FirstHakem())
r.graceUntil = time.Now().Add(r.firstHandDelay) // بُر زدن + پخشِ ۵ کارت
// شرطِ سکه‌ایِ میزِ خصوصی: از هر بازیکنِ انسان کسر و به pot اضافه می‌شود
// (هر که موجودی نداشت، رایگان بازی می‌کند و به pot اضافه نمی‌شود).
if r.stake > 0 {
for seat, s := range r.seats {
if s.isBot || s.userID == 0 {
continue
}
if err := r.settler.ChargeCoins(s.userID, r.stake, r.ID); err == nil {
r.pot += r.stake
r.paid[seat] = true
}
}
}
// اطلاعاتِ نمایشیِ بازیکنان (سکه و نشانِ رتبه) را یک‌بار هنگام شروع می‌گیریم.
for _, s := range r.seats {
if s.isBot || s.userID == 0 {
continue
}
s.coins, s.rankTier, s.avatar = r.settler.PlayerProfile(s.userID)
}
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.graceUntil = time.Now().Add(r.firstHandDelay) // هَندِ جدید ⇒ بُر زدن + پخشِ ۵ کارت
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 := time.Until(r.graceUntil); 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 "chat":
r.handleChat(act.seat, act.msg.Text, act.msg.Emoji)
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.seen = [4]uint16{} // شروعِ فازِ بازیِ هَند ⇒ ریستِ حافظه‌ی کارت‌ها
r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیه‌ی کارت‌ها
r.react()
case "reshuffle":
// بُرِ مجدد با مصرفِ یک بلیت: فقط حاکم و فقط پیش از انتخابِ حکم.
if r.game.Phase != game.PhaseChooseTrump || act.seat != r.game.Hakem {
r.sendError(act.seat, "بُر مجدد مجاز نیست")
return
}
if s.isBot || s.userID == 0 {
return
}
if err := r.settler.ChargeTicket(s.userID); err != nil {
r.sendError(act.seat, "بلیت کافی ندارید")
return
}
if err := r.game.Reshuffle(act.seat); err != nil {
r.sendError(act.seat, err.Error())
return
}
r.graceUntil = time.Now().Add(r.firstHandDelay) // انیمیشنِ بُر زدنِ مجدد
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
}
_ = r.game.ChooseTrump(seat, botChooseTrump(hand))
r.seen = [4]uint16{} // شروعِ فازِ بازیِ هَند ⇒ ریستِ حافظه‌ی کارت‌ها
r.graceUntil = time.Now().Add(r.dealDelay) // انیمیشنِ پخشِ بقیه‌ی کارت‌ها
}
// autoPlayCard با هوشِ باتِ اکتشافی یک کارتِ مجاز و راهبردی بازی می‌کند.
func (r *Room) autoPlayCard(seat int) {
hand := r.game.Hand(seat)
if len(hand) == 0 {
return
}
choice := botPlay(hand, r.game.Trick, r.game.Trump, r.game.LeadSuit, r.seen)
_ = 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
}
r.seen[card.Suit] |= 1 << (card.Rank - 2) // ثبت در حافظه‌ی کارت‌شمارِ بات
// بُرِش: کارتِ غیرِاولِ دست، از خالِ حکم، در حالی که خالِ زمینه حکم نبوده
// ⇒ بازیکن خالِ زمینه را نداشته و با حکم بریده است.
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) {
// برندگانِ انسان (برای تقسیمِ potِ میزِ خصوصی).
var winners []int64
for seat, s := range r.seats {
if s.isBot || s.userID == 0 {
continue
}
won := game.Team(seat) == res.WinnerTeam
if won {
winners = append(winners, s.userID)
if !r.private {
r.settler.AwardWinner(s.userID, r.tier)
}
}
if !r.private {
r.settler.AwardRank(s.userID, r.tier, won) // امتیازِ رتبه (اگر میزِ رتبه‌بندی باشد)
}
r.settler.RecordGameResult(s.userID, won)
}
// تقسیمِ potِ شرطِ خصوصی بینِ برندگانِ انسان (باقی‌مانده به نفرِ اول).
if r.private && r.pot > 0 && len(winners) > 0 {
share := r.pot / int64(len(winners))
rem := r.pot - share*int64(len(winners))
for i, id := range winners {
amt := share
if i == 0 {
amt += rem
}
r.settler.AwardCoins(id, amt, r.ID)
}
r.pot = 0
}
r.settler.RecordGame(r.ID, r.playersJSON(res.WinnerTeam), res.WinnerTeam, res.Kot)
}
// refundAll ورودی را به بازیکنانِ انسان که داوطلبانه خارج نشده‌اند بازمی‌گرداند.
func (r *Room) refundAll() {
if r.private {
// میزِ خصوصی: فقط شرطِ سکه‌ایِ پرداخت‌شده بازمی‌گردد (اگر شرطی بوده).
if r.stake > 0 {
for seat, s := range r.seats {
if s.isBot || s.left || s.userID == 0 || !r.paid[seat] {
continue
}
r.settler.AwardCoins(s.userID, r.stake, r.ID+":refund")
r.paid[seat] = false
}
r.pot = 0
}
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,
Coins: s.coins, RankTier: s.rankTier, Avatar: s.avatar,
}
}
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)
}
}
}
// handleChat یک پیام/شکلکِ بازیکن را با کنترلِ نرخ (ضدِ اسپم) و طولِ مجاز برای
// همه‌ی میز پخش می‌کند تا کنارِ آواتارِ فرستنده نمایش داده شود.
func (r *Room) handleChat(seat int, text, emoji string) {
now := time.Now()
if now.Sub(r.chatAt[seat]) < 1200*time.Millisecond {
return // فاصله‌ی کمتر از ~۱.۲ ثانیه ⇒ نادیده (ضدِ اسپم)
}
clip := func(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
text, emoji = clip(text, 60), clip(emoji, 16)
if text == "" && emoji == "" {
return
}
r.chatAt[seat] = now
r.broadcast(mustJSON(chatMsg{Type: "chat", Seat: seat, Text: text, Emoji: emoji}))
}
func (r *Room) sendError(seat int, msg string) {
if c := r.seats[seat].client; c != nil {
c.trySend(mustJSON(errorMsg{Type: "error", Message: msg}))
}
}