init
This commit is contained in:
@@ -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}))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user