package room import ( "encoding/json" "fmt" "log" "math/rand" "sync" "time" "nl-game-api-gin/internal/ai" "nl-game-api-gin/internal/database" "nl-game-api-gin/internal/gamecore/billiards" "nl-game-api-gin/internal/gamecore/ddz" "nl-game-api-gin/internal/gamecore/ludo" "nl-game-api-gin/internal/gamecore/monopoly" "nl-game-api-gin/internal/gamecore/xiangqi" "nl-game-api-gin/internal/model" "nl-game-api-gin/internal/service" ) // 回合时限(超时自动托管行动) const ( bidTimeout = 20 * time.Second // 斗地主叫牌时限 playTimeout = 30 * time.Second // 斗地主出牌时限 chessTimeout = 45 * time.Second // 象棋走子时限 monoTimeout = 30 * time.Second // 大富翁掷骰/购买时限 ludoTimeout = 30 * time.Second // 飞行棋掷骰/选棋时限 billTimeout = 45 * time.Second // 台球瞄准击球时限 ) // Seat 房间内的一个座位 type Seat struct { Index int // 座位号(斗地主0-2,象棋0红1黑) UserID int // 真人用户ID(AI 座位为 0) Name string // 显示昵称 Avatar string // 头像 Skin string // 皮肤编码(饥荒组队联机:更衣室选择的皮肤) IsAI bool // 是否 AI 座位 Ready bool // 是否已准备(AI 恒为 true) Online bool // 是否在线(断线重连用) Auto bool // 真人主动托管中(每回合由规则 AI 代打,结算后自动取消) client *Client // 关联的连接(AI 为 nil) offlineTimer *time.Timer // 饥荒等待中断线保护:30秒后仍未重连才清座 } // chessState 象棋对局状态 type chessState struct { board xiangqi.Board // 棋盘 turnSide int // 轮到哪方走(1红 -1黑) lastMove *xiangqi.Move // 上一步着法(前端高亮) moveCount int // 总步数(超过上限判和) } // Room 一个对战房间(斗地主/象棋/大富翁/飞行棋/台球) type Room struct { Code string // 6位邀请码 Game string // doudizhu / chess / monopoly / ludo / billiards Mode string // pvp / ai AIProvider string // AI 提供方(spark/deepseek/rule) AIDifficulty string // AI 难度(easy/medium/hard) mu sync.Mutex // 保护以下全部状态 hub *Hub // 反向引用(结算时清理映射) seats []*Seat // 座位列表 hostUserID int // 房主用户ID status string // waiting / playing ddzGame *ddz.Game // 斗地主对局(仅斗地主房间) ddzMarks [3]string // 斗地主叫/抢表态气泡("叫地主!"/"不叫"/"抢地主!"/"不抢",重发时清空) chessGame *chessState // 象棋对局(仅象棋房间) monoGame *monopoly.Game // 大富翁对局(仅大富翁房间) monoEvents []monopoly.Event // 大富翁最近一次动作的动画事件(随状态广播) monoSeq int // 大富翁动作序号(前端凭连续序号判断是否播放动画) ludoGame *ludo.Game // 飞行棋对局(仅飞行棋房间) ludoEvents []ludo.Event // 飞行棋最近一次动作的动画事件(随状态广播) ludoSeq int // 飞行棋动作序号 billGame *billiards.Game // 台球对局(仅台球房间) animMs int // 最近动作的前端动画时长(毫秒,调度下一回合时预留观看时间) gameAI ai.DdzAI // 斗地主 AI 实例(按房间配置构造) chessAI ai.ChessAI // 象棋 AI 实例 escrowDdz ai.DdzAI // 托管用规则 AI(超时/离线代打) escrowChs ai.ChessAI // 托管用规则象棋 AI turnSeq int // 回合序号(使过期的定时器与 AI 任务失效) turnDeadln int64 // 当前回合截止时间戳 turnTimer *time.Timer // 回合超时定时器 starveMs int64 // 饥荒:上次转发主机快照的毫秒时间戳(节流保护) startedAt int64 // 本局开始时间戳 createdAt int64 // 房间创建时间戳 lastActive int64 // 最近活跃时间戳(清理判定) lastResult map[string]any // 上一局结算信息(房间内展示) inviteAt map[int]int64 // 对好友最近一次邀请时间(userID → unix),冷却 60 秒 closed bool // 房间是否已关闭 } // 同一好友再次邀请的最短间隔 const inviteCooldown = 60 * time.Second const starveOfflineGrace = 30 * time.Second // 饥荒等待中断线保护:30秒内重连不散房 // newRoom 构造房间:按游戏类型确定座位数,按配置构造 AI 实例 func newRoom(code, game, mode, aiProvider, aiDifficulty string) *Room { if aiProvider == "" { aiProvider = ai.ProviderRule } if aiDifficulty == "" { aiDifficulty = ai.DiffMedium } // 座位数按游戏类型:斗地主 3、象棋/台球 2、大富翁/飞行棋/饥荒 4 seatCount := 3 switch game { case "chess", "billiards": seatCount = 2 case "monopoly", "ludo", "starve": seatCount = 4 } seats := make([]*Seat, seatCount) for i := range seats { seats[i] = &Seat{Index: i} } now := time.Now().Unix() return &Room{ Code: code, Game: game, Mode: mode, AIProvider: aiProvider, AIDifficulty: aiDifficulty, seats: seats, status: "waiting", gameAI: ai.NewDdzAI(aiProvider, aiDifficulty), chessAI: ai.NewChessAI(aiProvider, aiDifficulty), escrowDdz: ai.NewDdzAI(ai.ProviderRule, ai.DiffMedium), escrowChs: ai.NewChessAI(ai.ProviderRule, ai.DiffEasy), createdAt: now, lastActive: now, } } // --------------------------------------------------------------------- // 座位与成员管理 // --------------------------------------------------------------------- // join 加入房间:isHost 表示建房者(AI 模式建房时立即用 AI 填满其余座位) func (r *Room) join(c *Client, isHost bool) error { r.mu.Lock() defer r.mu.Unlock() if r.closed { return fmt.Errorf("房间已解散") } if r.status == "playing" { return fmt.Errorf("对局进行中,无法加入") } // 找空位 var seat *Seat for _, s := range r.seats { if s.UserID == 0 && !s.IsAI { seat = s break } } if seat == nil { return fmt.Errorf("房间已满") } seat.UserID = c.userID seat.Name = c.name seat.Avatar = c.avatar seat.Online = true seat.Ready = isHost // 房主默认准备 seat.client = c if isHost { r.hostUserID = c.userID r.hub = c.hub // AI 模式:其余座位立即补 AI if r.Mode == "ai" { r.fillAISeatsLocked() } } r.touch() r.broadcastStateLocked() r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 进入了房间", c.name), false) return nil } // fillAISeatsLocked 把所有空位补成 AI 座位(需持锁) func (r *Room) fillAISeatsLocked() { // 大富翁/飞行棋/台球使用本地规则 AI,名称固定 aiName := "电脑AI" switch r.Game { case "doudizhu": aiName = r.gameAI.Name() case "chess": aiName = r.chessAI.Name() } idx := 1 for _, s := range r.seats { if s.UserID == 0 && !s.IsAI { s.IsAI = true s.Name = fmt.Sprintf("%s·%d号", aiName, idx) s.Avatar = "🤖" s.Ready = true s.Online = true idx++ } } } // reattach 断线重连:把新连接绑回原座位并推送最新状态 func (r *Room) reattach(c *Client) { r.mu.Lock() defer r.mu.Unlock() for _, s := range r.seats { if s.UserID == c.userID { if s.offlineTimer != nil { s.offlineTimer.Stop() s.offlineTimer = nil } s.client = c s.Online = true r.touch() r.broadcastStateLocked() r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 重新连接", c.name), false) return } } } // onOffline 连接断开:等待中直接移出房间(返回 true),对局中标记离线等待重连 func (r *Room) onOffline(c *Client) (leftRoom bool) { r.mu.Lock() defer r.mu.Unlock() seat := r.seatByUser(c.userID) if seat == nil || seat.client != c { return false } seat.client = nil seat.Online = false if r.status == "waiting" { if r.Game == "starve" { // 饥荒等待中断线保护:保留座位 30 秒,刷新/断线重连成功后取消清理 if seat.offlineTimer != nil { seat.offlineTimer.Stop() } offlineUID, offlineSeat := seat.UserID, seat.Index seat.offlineTimer = time.AfterFunc(starveOfflineGrace, func() { c.hub.onStarveOfflineTimeout(r, offlineUID, offlineSeat) }) r.broadcastStateLocked() return false } r.clearSeatLocked(seat) r.broadcastStateLocked() return true } // 饥荒组队:主机断线对局无法继续,直接结束通知各自结算;队友断线保留座位等待重连 if r.Game == "starve" { if seat.UserID == r.hostUserID { r.broadcastChatLocked(-1, "系统", "", "主机掉线,冒险结束", false) r.endStarveLocked("主机掉线,冒险结束") } else { r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 掉线了", seat.Name), false) } r.broadcastStateLocked() return false } // 对局中:保留座位等待重连,超时后由托管 AI 代打 r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 掉线了,回合超时将自动托管", seat.Name), false) r.broadcastStateLocked() return false } // endStarveLocked 结束饥荒联机对局:房间回等待状态并广播事件,客户端各自走单机结算(需持锁) func (r *Room) endStarveLocked(reason string) { if r.status != "playing" { return } r.status = "waiting" for _, s := range r.seats { if s.UserID > 0 { s.Ready = s.UserID == r.hostUserID } } for _, s := range r.seats { if s.client != nil { s.client.push("starve_end", map[string]any{"reason": reason}) } } } // leave 主动退出:对局中视为认输(大富翁/飞行棋按退赛处理,其余玩家继续) func (r *Room) leave(c *Client) { r.mu.Lock() defer r.mu.Unlock() seat := r.seatByUser(c.userID) if seat == nil { return } name := seat.Name if r.status == "playing" { switch { case r.Game == "starve": // 饥荒组队:房主(主机)退出则对局无法继续,通知全员各自结算;队友退出对局继续 r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 离开了世界", name), false) if seat.UserID == r.hostUserID { r.endStarveLocked("主机离开,冒险结束") } case r.Game == "monopoly" && r.monoGame != nil: // 多人棋盘游戏:退出者按破产退赛处理,对局继续 r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 中途退赛", name), false) r.monoGame.Eliminate(seat.Index) r.afterMonoActionLocked() case r.Game == "ludo" && r.ludoGame != nil: r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 中途退赛", name), false) r.ludoGame.Eliminate(seat.Index) r.afterLudoActionLocked() default: // 双方/三方对局:退出即认输结算 r.settleLocked(r.winnersWithout(seat.Index), fmt.Sprintf("%s 中途退出,对局结束", name)) } } r.clearSeatLocked(seat) r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 离开了房间", name), false) r.broadcastStateLocked() } // clearSeatLocked 清空座位;房主离开时移交房主(需持锁) func (r *Room) clearSeatLocked(seat *Seat) { uid := seat.UserID if seat.offlineTimer != nil { seat.offlineTimer.Stop() seat.offlineTimer = nil } *seat = Seat{Index: seat.Index} if uid == r.hostUserID { r.hostUserID = 0 for _, s := range r.seats { if s.UserID > 0 { r.hostUserID = s.UserID s.Ready = true break } } } } // winnersWithout 认输结算辅助:返回除指定座位所在方之外的获胜座位(需持锁) func (r *Room) winnersWithout(loserSeat int) []int { winners := []int{} switch r.Game { case "chess", "billiards": winners = append(winners, 1-loserSeat) return winners case "monopoly", "ludo": // 多人棋盘游戏:其余所有已占用座位获胜 for _, s := range r.seats { if s.Index != loserSeat && (s.UserID > 0 || s.IsAI) { winners = append(winners, s.Index) } } return winners } // 斗地主:退出者是地主 → 两农民胜;退出者是农民 → 地主胜 g := r.ddzGame if g == nil || g.Landlord < 0 { // 还没确定地主就退出:其余两家算胜 for i := 0; i < 3; i++ { if i != loserSeat { winners = append(winners, i) } } return winners } if loserSeat == g.Landlord { for i := 0; i < 3; i++ { if i != g.Landlord { winners = append(winners, i) } } } else { winners = append(winners, g.Landlord) } return winners } // seatByUser 按用户ID找座位(需持锁) func (r *Room) seatByUser(uid int) *Seat { for _, s := range r.seats { if s.UserID == uid && uid > 0 { return s } } return nil } // empty 房间内是否已无真人 func (r *Room) empty() bool { r.mu.Lock() defer r.mu.Unlock() for _, s := range r.seats { if s.UserID > 0 { return false } } return true } // humanUserIDs 房间内全部真人用户ID func (r *Room) humanUserIDs() []int { r.mu.Lock() defer r.mu.Unlock() ids := []int{} for _, s := range r.seats { if s.UserID > 0 { ids = append(ids, s.UserID) } } return ids } // expired 房间是否应被清理:无人在线超10分钟或创建超6小时 func (r *Room) expired() bool { r.mu.Lock() defer r.mu.Unlock() if r.closed { return true } now := time.Now().Unix() anyOnline := false for _, s := range r.seats { if s.UserID > 0 && s.Online { anyOnline = true } } if !anyOnline && now-r.lastActive > 600 { return true } return now-r.createdAt > 6*3600 } // close 解散房间并通知所有人 func (r *Room) close(reason string) { r.mu.Lock() defer r.mu.Unlock() r.closed = true if r.turnTimer != nil { r.turnTimer.Stop() } for _, s := range r.seats { if s.client != nil { s.client.push("room_closed", map[string]string{"reason": reason}) } } } // touch 更新活跃时间(需持锁) func (r *Room) touch() { r.lastActive = time.Now().Unix() } // --------------------------------------------------------------------- // 消息处理与广播 // --------------------------------------------------------------------- // handleMessage 处理房间内消息(准备/开始/聊天/游戏动作) func (r *Room) handleMessage(c *Client, msgType string, raw json.RawMessage) { r.mu.Lock() defer r.mu.Unlock() seat := r.seatByUser(c.userID) if seat == nil { c.pushError("你不在该房间的座位上") return } r.touch() switch msgType { case "get_state": r.pushStateLocked(seat) case "ready": var req struct { Ready bool `json:"ready"` } json.Unmarshal(raw, &req) if r.status != "waiting" { return } seat.Ready = req.Ready r.broadcastStateLocked() case "start": r.handleStartLocked(c, seat) case "skin": // 饥荒更衣室:等待阶段设置座位皮肤并广播(对局中不可换装) var req struct { Skin string `json:"skin"` } json.Unmarshal(raw, &req) if r.status != "waiting" || len(req.Skin) > 32 { return } seat.Skin = req.Skin r.broadcastStateLocked() case "starve_input": // 饥荒队友输入:解析后附上权威座位号转发给房主(主机权威模拟) if r.Game != "starve" || r.status != "playing" { return } host := r.seatByUser(r.hostUserID) if host == nil || host.client == nil || seat.UserID == r.hostUserID { return } var payload map[string]any if err := json.Unmarshal(raw, &payload); err != nil || payload == nil { return } payload["seat"] = seat.Index // 以服务端座位为准,防伪造 host.client.push("starve_input", payload) case "starve_state": // 饥荒主机快照:仅房主可发,原样广播给其余座位 // 全量世界(full:1)不限流;增量快照间隔 <80ms 丢弃防刷 if r.Game != "starve" || r.status != "playing" || seat.UserID != r.hostUserID { return } isFull := false var peek struct { Full int `json:"full"` } if json.Unmarshal(raw, &peek) == nil && peek.Full != 0 { isFull = true } now := time.Now().UnixMilli() if !isFull && now-r.starveMs < 80 { return } r.starveMs = now for _, s := range r.seats { if s.client != nil && s.UserID != r.hostUserID { s.client.push("starve_state", raw) } } case "starve_over": // 饥荒全灭:房主宣告对局结束,房间回等待状态(各端自行走单机结算) if r.Game != "starve" || seat.UserID != r.hostUserID { return } r.broadcastChatLocked(-1, "系统", "", "全员阵亡,冒险结束", false) r.endStarveLocked("全员阵亡,冒险结束") r.broadcastStateLocked() case "invite": // 邀请好友进房:仅房主可发,目标必须是自己的已通过好友且不在房内 // 好友在线则实时推弹窗邀请;离线落一条私聊兜底(上线可见) var req struct { UserID int `json:"user_id"` } json.Unmarshal(raw, &req) if r.status != "waiting" { c.pushError("对局进行中,无法邀请") return } if seat.UserID != r.hostUserID { c.pushError("只有房主可以邀请好友") return } if req.UserID <= 0 || req.UserID == c.userID { c.pushError("参数有误") return } // 必须是已通过的好友关系 var cnt int64 database.DB.Model(&model.Friend{}). Where("(user_id = ? AND friend_id = ? OR user_id = ? AND friend_id = ?) AND status = ?", c.userID, req.UserID, req.UserID, c.userID, model.FriendStatusAccepted). Count(&cnt) if cnt == 0 { c.pushError("只能邀请好友") return } // 不能已在房内 for _, s := range r.seats { if s.UserID == req.UserID { c.pushError("对方已在房间内") return } } // 同一好友 60 秒内不可重复邀请 now := time.Now().Unix() if r.inviteAt == nil { r.inviteAt = map[int]int64{} } if last, ok := r.inviteAt[req.UserID]; ok { left := int64(inviteCooldown.Seconds()) - (now - last) if left > 0 { c.pushError(fmt.Sprintf("请 %d 秒后再邀请该好友", left)) return } } gameName := r.Game var g model.Game if err := database.DB.Where("code = ?", r.Game).First(&g).Error; err == nil && g.Name != "" { gameName = g.Name } inviteData := map[string]any{ "code": r.Code, "game": r.Game, "game_name": gameName, "host_id": c.userID, "host_name": seat.Name, "host_avatar": seat.Avatar, } // 在线实时弹窗;离线降级为私聊消息兜底(下次上线可见) if !PushToUser(req.UserID, "room_invite", inviteData) { var peer model.User if err := database.DB.First(&peer, req.UserID).Error; err == nil { database.DB.Create(&model.ChatMessage{ FromID: c.userID, ToID: req.UserID, Content: fmt.Sprintf("邀请你加入房间【%s】,邀请码 %s", gameName, r.Code), }) } } r.inviteAt[req.UserID] = now c.push("invite_sent", map[string]any{"user_id": req.UserID, "cooldown": int(inviteCooldown.Seconds())}) case "chat": var req struct { Text string `json:"text"` } json.Unmarshal(raw, &req) if text := trimChat(req.Text); text != "" { r.broadcastChatLocked(seat.Index, seat.Name, seat.Avatar, text, false) } case "ddz_bid": var req struct { Call bool `json:"call"` } json.Unmarshal(raw, &req) r.handleBidLocked(seat.Index, req.Call, c) case "ddz_play": var req struct { Cards []int `json:"cards"` } json.Unmarshal(raw, &req) r.handlePlayLocked(seat.Index, req.Cards, c) case "ddz_rob": var req struct { Rob bool `json:"rob"` } json.Unmarshal(raw, &req) r.handleRobLocked(seat.Index, req.Rob, c) case "ddz_pass": r.handlePassLocked(seat.Index, c) case "ddz_hint": var req struct { Seq int `json:"seq"` } json.Unmarshal(raw, &req) r.handleDdzHintLocked(seat.Index, req.Seq, c) case "chess_move": var m xiangqi.Move json.Unmarshal(raw, &m) r.handleChessMoveLocked(seat.Index, m, c) case "chess_hints": // 走法提示:返回指定己方棋子的合法落点(新手高亮) var req struct { FromR int `json:"from_r"` FromC int `json:"from_c"` } json.Unmarshal(raw, &req) r.handleChessHintsLocked(seat.Index, req.FromR, req.FromC, c) case "mono_roll": r.handleMonoRollLocked(seat.Index, c) case "mono_buy": var req struct { Buy bool `json:"buy"` } json.Unmarshal(raw, &req) r.handleMonoBuyLocked(seat.Index, req.Buy, c) case "mono_upgrade": var req struct { Up bool `json:"up"` } json.Unmarshal(raw, &req) r.handleMonoUpgradeLocked(seat.Index, req.Up, c) case "ludo_roll": r.handleLudoRollLocked(seat.Index, c) case "ludo_move": var req struct { Plane int `json:"plane"` } json.Unmarshal(raw, &req) r.handleLudoMoveLocked(seat.Index, req.Plane, c) case "bill_shot": var req struct { Angle float64 `json:"angle"` Power float64 `json:"power"` SpinX float64 `json:"spin_x"` // 左右塞 -1~1(右塞为正) SpinY float64 `json:"spin_y"` // 高低杆 -1~1(高杆为正) } json.Unmarshal(raw, &req) r.handleBillShotLocked(seat.Index, req.Angle, req.Power, req.SpinX, req.SpinY, c) case "escrow": // 主动托管开关:开启后每回合由托管规则 AI 代打 var req struct { On bool `json:"on"` } json.Unmarshal(raw, &req) r.handleEscrowLocked(seat, req.On) case "resign": // 认输:双人局直接判负结算;大富翁/飞行棋按退赛处理(留座观战) if r.status != "playing" { return } switch { case r.Game == "monopoly" && r.monoGame != nil: r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 认输退赛", seat.Name), false) r.monoGame.Eliminate(seat.Index) r.afterMonoActionLocked() case r.Game == "ludo" && r.ludoGame != nil: r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 认输退赛", seat.Name), false) r.ludoGame.Eliminate(seat.Index) r.afterLudoActionLocked() default: r.settleLocked(r.winnersWithout(seat.Index), fmt.Sprintf("%s 认输,对局结束", seat.Name)) r.broadcastStateLocked() } default: c.pushError("未知的消息类型") } } // handleEscrowLocked 真人切换托管状态(需持锁): // 开启后每回合由托管规则 AI 自动代打,结算时自动取消 func (r *Room) handleEscrowLocked(seat *Seat, on bool) { if seat.IsAI || seat.Auto == on { return } seat.Auto = on if on { r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 开启托管,由系统代打", seat.Name), false) } else { r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 取消托管,恢复亲自操作", seat.Name), false) } r.broadcastStateLocked() // 开启时恰好轮到自己:立即安排一次代打 if on && r.status == "playing" { if cur := r.currentSeatLocked(); cur == seat { seq := r.turnSeq go func() { time.Sleep(600 * time.Millisecond) r.aiActWith(seq, true) }() } } } // trimChat 聊天内容清洗:去空格并限制长度 func trimChat(text string) string { runes := []rune(text) if len(runes) > 100 { runes = runes[:100] } return string(runes) } // handleStartLocked 房主开始游戏:空位自动补 AI,人满且都准备后开局(需持锁) func (r *Room) handleStartLocked(c *Client, seat *Seat) { if r.status != "waiting" { c.pushError("对局已在进行中") return } if c.userID != r.hostUserID { c.pushError("只有房主可以开始游戏") return } // 除房主外所有真人必须已准备 for _, s := range r.seats { if s.UserID > 0 && s.UserID != r.hostUserID && !s.Ready { c.pushError(fmt.Sprintf("%s 还没准备", s.Name)) return } } // 空位补 AI(饥荒组队为 1~4 人弹性开局,空位保持空置) if r.Game != "starve" { r.fillAISeatsLocked() } r.startGameLocked() } // broadcastChatLocked 广播聊天消息(seat=-1 表示系统消息,需持锁) func (r *Room) broadcastChatLocked(seatIdx int, name, avatar, text string, isAI bool) { msg := map[string]any{ "seat": seatIdx, "name": name, "avatar": avatar, "text": text, "is_ai": isAI, "ts": time.Now().Unix(), } for _, s := range r.seats { if s.client != nil { s.client.push("chat", msg) } } } // broadcastStateLocked 向所有真人座位推送各自视角的房间状态(需持锁) func (r *Room) broadcastStateLocked() { for _, s := range r.seats { if s.client != nil { r.pushStateLocked(s) } } } // pushStateLocked 向单个座位推送其视角的状态快照(需持锁) func (r *Room) pushStateLocked(seat *Seat) { if seat.client == nil { return } seat.client.push("room_state", r.buildStateLocked(seat.Index)) } // buildStateLocked 构建指定座位视角的状态快照(隐藏他人手牌,需持锁) func (r *Room) buildStateLocked(viewSeat int) map[string]any { seats := make([]map[string]any, len(r.seats)) for i, s := range r.seats { info := map[string]any{ "index": i, "user_id": s.UserID, "name": s.Name, "avatar": s.Avatar, "is_ai": s.IsAI, "ready": s.Ready, "online": s.Online, "occupied": s.UserID > 0 || s.IsAI, "auto": s.Auto, "skin": s.Skin, } if r.ddzGame != nil { info["cards_count"] = len(r.ddzGame.Hands[i]) info["is_landlord"] = r.ddzGame.Landlord == i } seats[i] = info } state := map[string]any{ "code": r.Code, "game": r.Game, "mode": r.Mode, "ai_provider": r.AIProvider, "ai_difficulty": r.AIDifficulty, "status": r.status, "host_id": r.hostUserID, "my_seat": viewSeat, "seats": seats, "deadline": r.turnDeadln, "last_result": r.lastResult, } // 斗地主视角数据 if r.Game == "doudizhu" && r.ddzGame != nil { g := r.ddzGame dd := map[string]any{ "phase": string(g.Phase), "turn": g.Turn, "my_hand": g.Hands[viewSeat], "bombs": g.Bombs, "landlord": g.Landlord, // 当前倍数(叫抢×炸弹,春天在结算时另乘) "multiplier": g.CurrentMultiplier(), // 叫/抢表态气泡(前端在叫抢阶段显示在座位旁) "marks": r.ddzMarks[:], } // 底牌只在地主确定后公开 if g.Landlord >= 0 { dd["bottom"] = g.Bottom } // 最近一手牌 if g.LastCombo != nil { dd["last_seat"] = g.LastSeat dd["last_cards"] = g.LastCombo.Cards dd["last_desc"] = g.LastCombo.Desc() } else { dd["last_seat"] = -1 } // 当前玩家能否过牌 dd["can_pass"] = g.LastCombo != nil && g.LastSeat != g.Turn // 出牌历史尾部(最多10条) tail := g.History if len(tail) > 10 { tail = tail[len(tail)-10:] } dd["history"] = tail state["ddz"] = dd } // 象棋视角数据 if r.Game == "chess" && r.chessGame != nil { cs := r.chessGame mySide := xiangqi.Red if viewSeat == 1 { mySide = xiangqi.Black } state["chess"] = map[string]any{ "board": cs.board, "turn_side": cs.turnSide, "my_side": mySide, "last_move": cs.lastMove, "in_check": cs.board.InCheck(cs.turnSide), "move_count": cs.moveCount, } } // 大富翁/飞行棋/台球数据(信息全公开) if r.Game == "monopoly" && r.monoGame != nil { state["mono"] = r.monoStateLocked() } if r.Game == "ludo" && r.ludoGame != nil { state["ludo"] = r.ludoStateLocked() } if r.Game == "billiards" && r.billGame != nil { state["bill"] = r.billStateLocked(viewSeat) } return state } // --------------------------------------------------------------------- // 对局流程(开局 → 回合调度 → 动作处理 → 结算) // --------------------------------------------------------------------- // startGameLocked 初始化对局并开始第一回合(需持锁) func (r *Room) startGameLocked() { r.status = "playing" r.startedAt = time.Now().Unix() r.lastResult = nil switch r.Game { case "doudizhu": r.ddzGame = ddz.NewGame() r.ddzMarks = [3]string{} case "chess": r.chessGame = &chessState{board: xiangqi.Initial(), turnSide: xiangqi.Red} case "monopoly": r.monoGame = monopoly.NewGame(len(r.seats)) r.monoEvents, r.monoSeq = nil, 0 case "ludo": r.ludoGame = ludo.NewGame() r.ludoEvents, r.ludoSeq = nil, 0 case "billiards": r.billGame = billiards.NewGame() case "starve": // 饥荒:主机权威模拟在房主端进行,服务端只做转发,无引擎与回合调度 r.starveMs = 0 r.broadcastChatLocked(-1, "系统", "", "冒险开始!一起活下去!", false) r.broadcastStateLocked() return } r.animMs = 0 r.broadcastChatLocked(-1, "系统", "", "对局开始!", false) r.scheduleTurnLocked() r.broadcastStateLocked() } // currentSeatLocked 当前该行动的座位(需持锁) func (r *Room) currentSeatLocked() *Seat { if r.status != "playing" { return nil } switch r.Game { case "doudizhu": if r.ddzGame != nil { return r.seats[r.ddzGame.Turn] } case "chess": if r.chessGame != nil { if r.chessGame.turnSide == xiangqi.Red { return r.seats[0] } return r.seats[1] } case "monopoly": if r.monoGame != nil { return r.seats[r.monoGame.Turn] } case "ludo": if r.ludoGame != nil { return r.seats[r.ludoGame.Turn] } case "billiards": if r.billGame != nil { return r.seats[r.billGame.Turn] } } return nil } // turnDuration 当前回合时限(需持锁) func (r *Room) turnDuration() time.Duration { switch r.Game { case "chess": return chessTimeout case "monopoly": return monoTimeout case "ludo": return ludoTimeout case "billiards": return billTimeout } if r.ddzGame != nil && (r.ddzGame.Phase == ddz.PhaseBidding || r.ddzGame.Phase == ddz.PhaseRobbing) { return bidTimeout } return playTimeout } // scheduleTurnLocked 开启新回合:重置超时定时器,AI 座位安排延迟行动(需持锁) func (r *Room) scheduleTurnLocked() { r.turnSeq++ seq := r.turnSeq dur := r.turnDuration() // 上一动作的前端动画还在播放(台球回放/大富翁走格/飞行棋飞行),超时与 AI 行动都预留观看时间 extra := time.Duration(0) if r.animMs > 0 { extra = time.Duration(r.animMs) * time.Millisecond r.animMs = 0 } dur += extra r.turnDeadln = time.Now().Add(dur).Unix() if r.turnTimer != nil { r.turnTimer.Stop() } r.turnTimer = time.AfterFunc(dur, func() { r.onTimeout(seq) }) // AI 座位:1.2~2.7 秒后行动(模拟思考) seat := r.currentSeatLocked() if seat != nil && seat.IsAI { delay := time.Duration(1200+rand.Intn(1500))*time.Millisecond + extra go func() { time.Sleep(delay) r.aiAct(seq) }() } // 真人开启托管:短暂延迟后由托管规则 AI 代打 if seat != nil && !seat.IsAI && seat.Auto { delay := time.Duration(900+rand.Intn(600))*time.Millisecond + extra go func() { time.Sleep(delay) r.aiActWith(seq, true) }() } } // onTimeout 回合超时:由托管规则 AI 代替当前座位行动 func (r *Room) onTimeout(seq int) { r.mu.Lock() if r.closed || r.status != "playing" || seq != r.turnSeq { r.mu.Unlock() return } seat := r.currentSeatLocked() if seat == nil { r.mu.Unlock() return } if !seat.IsAI { r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 超时,本回合自动托管", seat.Name), false) } r.mu.Unlock() // 超时托管与 AI 行动共用一条路径(用托管规则 AI) r.aiActWith(seq, true) } // aiAct AI 座位的正常行动(用房间配置的 AI 实例) func (r *Room) aiAct(seq int) { r.aiActWith(seq, false) } // aiActWith AI 决策并执行:escrow=true 时使用托管规则 AI // 决策阶段不持锁(LLM 可能耗时数秒),执行前重新校验回合有效性 func (r *Room) aiActWith(seq int, escrow bool) { // 第一步:持锁快照当前局面 r.mu.Lock() if r.closed || r.status != "playing" || seq != r.turnSeq { r.mu.Unlock() return } seat := r.currentSeatLocked() if seat == nil { r.mu.Unlock() return } seatIdx := seat.Index game := r.Game // 大富翁/飞行棋/台球:本地规则 AI 决策瞬时完成,直接持锁执行后返回 switch game { case "monopoly": r.aiMonoActLocked(seatIdx, escrow) r.mu.Unlock() return case "ludo": r.aiLudoActLocked(seatIdx, escrow) r.mu.Unlock() return case "billiards": r.aiBillActLocked(seatIdx, escrow) r.mu.Unlock() return } var ( ddzHand []int ddzLast *ddz.Combo ddzPhase ddz.Phase ddzCtx ai.PlayContext chessBoard xiangqi.Board chessSide int ) if game == "doudizhu" { g := r.ddzGame ddzHand = append([]int{}, g.Hands[seatIdx]...) ddzLast = g.LastCombo ddzPhase = g.Phase ddzCtx = r.ddzPlayCtxLocked(seatIdx) } else { cs := r.chessGame chessBoard = cs.board chessSide = cs.turnSide } // 选择决策器:正常 AI 或托管规则 AI ddzBrain := r.gameAI chessBrain := r.chessAI if escrow { ddzBrain = r.escrowDdz chessBrain = r.escrowChs } r.mu.Unlock() // 第二步:锁外决策(LLM 最长十余秒) var ( bidCall bool robCall bool playCards []int say string chessMv xiangqi.Move ) if game == "doudizhu" { switch ddzPhase { case ddz.PhaseBidding: bidCall, say = ddzBrain.DecideBid(ddzHand) case ddz.PhaseRobbing: robCall, say = ddzBrain.DecideRob(ddzHand) default: playCards, say = ddzBrain.DecidePlay(ddzHand, ddzLast, ddzCtx) } } else { chessMv, say = chessBrain.DecideMove(&chessBoard, chessSide) } // 第三步:持锁校验回合仍有效后执行 r.mu.Lock() defer r.mu.Unlock() if r.closed || r.status != "playing" || seq != r.turnSeq { return } seat = r.seats[seatIdx] // AI 台词随聊天下发 if say != "" { r.broadcastChatLocked(seatIdx, seat.Name, seat.Avatar, say, true) } if game == "doudizhu" { if ddzPhase == ddz.PhaseBidding { r.applyBidLocked(seatIdx, bidCall) } else if ddzPhase == ddz.PhaseRobbing { r.applyRobLocked(seatIdx, robCall) } else if playCards == nil { // 托管/AI 选择过牌;不能过时强制出最小牌 if err := r.ddzGame.Pass(seatIdx); err != nil { if moves := ddz.GenMoves(r.ddzGame.Hands[seatIdx], r.ddzGame.LastCombo); len(moves) > 0 { r.applyPlayLocked(seatIdx, moves[0]) return } } else { r.afterDdzActionLocked(seatIdx, nil) } } else { // AI 决策基于快照,执行前再校验一次合法性,失败则出最小牌兜底 if _, err := r.tryPlayLocked(seatIdx, playCards); err != nil { if moves := ddz.GenMoves(r.ddzGame.Hands[seatIdx], r.ddzGame.LastCombo); len(moves) > 0 { r.applyPlayLocked(seatIdx, moves[0]) } else if r.ddzGame.LastCombo != nil { r.ddzGame.Pass(seatIdx) r.afterDdzActionLocked(seatIdx, nil) } } } } else { // 象棋:校验合法性,非法则规则 AI 重算 if !r.chessGame.board.IsLegal(chessMv, chessSide) { chessMv, _ = r.escrowChs.DecideMove(&r.chessGame.board, chessSide) } r.applyChessMoveLocked(seatIdx, chessMv) } } // --------------------------------------------------------------------- // 斗地主动作 // --------------------------------------------------------------------- // handleBidLocked 真人叫地主(需持锁) func (r *Room) handleBidLocked(seatIdx int, call bool, c *Client) { if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhaseBidding { c.pushError("当前不在叫地主阶段") return } if r.ddzGame.Turn != seatIdx { c.pushError("还没轮到你表态") return } r.applyBidLocked(seatIdx, call) } // applyBidLocked 执行叫地主表态并推进流程(需持锁) func (r *Room) applyBidLocked(seatIdx int, call bool) { seat := r.seats[seatIdx] redeal, err := r.ddzGame.Bid(seatIdx, call) if err != nil { return } action := "不叫" if call { action = "叫地主!" } r.ddzMarks[seatIdx] = action r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s:%s", seat.Name, action), false) if redeal { // 三家都不叫:重新发牌重新叫,表态气泡一并清空 r.broadcastChatLocked(-1, "系统", "", "三家都不叫,重新发牌", false) r.ddzGame = ddz.NewGame() r.ddzMarks = [3]string{} } else if r.ddzGame.Phase == ddz.PhaseRobbing { r.broadcastChatLocked(-1, "系统", "", "进入抢地主环节:其余两家可以抢,每抢一次倍数×2", false) } r.scheduleTurnLocked() r.broadcastStateLocked() } // handleRobLocked 真人抢地主表态(需持锁) func (r *Room) handleRobLocked(seatIdx int, rob bool, c *Client) { if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhaseRobbing { c.pushError("当前不在抢地主阶段") return } if r.ddzGame.Turn != seatIdx { c.pushError("还没轮到你表态") return } r.applyRobLocked(seatIdx, rob) } // applyRobLocked 执行抢地主表态并推进流程(需持锁) func (r *Room) applyRobLocked(seatIdx int, rob bool) { seat := r.seats[seatIdx] if err := r.ddzGame.Rob(seatIdx, rob); err != nil { return } if rob { r.ddzMarks[seatIdx] = "抢地主!" r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s:抢地主!倍数翻倍(当前 %d 倍)", seat.Name, r.ddzGame.Multiplier), false) } else { r.ddzMarks[seatIdx] = "不抢" r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s:不抢", seat.Name), false) } if r.ddzGame.Phase == ddz.PhasePlaying { r.broadcastChatLocked(-1, "系统", "", fmt.Sprintf("%s 成为地主,亮出底牌", r.seats[r.ddzGame.Landlord].Name), false) } r.scheduleTurnLocked() r.broadcastStateLocked() } // handlePlayLocked 真人出牌(需持锁) func (r *Room) handlePlayLocked(seatIdx int, cards []int, c *Client) { if _, err := r.tryPlayLocked(seatIdx, cards); err != nil { c.pushError(err.Error()) } } // tryPlayLocked 尝试出牌,成功后推进流程(需持锁) func (r *Room) tryPlayLocked(seatIdx int, cards []int) (*ddz.Combo, error) { if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhasePlaying { return nil, fmt.Errorf("当前不在出牌阶段") } combo, err := r.ddzGame.Play(seatIdx, cards) if err != nil { return nil, err } r.afterDdzActionLocked(seatIdx, combo) return combo, nil } // applyPlayLocked 直接出牌(AI 兜底路径,忽略错误,需持锁) func (r *Room) applyPlayLocked(seatIdx int, cards []int) { if combo, err := r.ddzGame.Play(seatIdx, cards); err == nil { r.afterDdzActionLocked(seatIdx, combo) } } // handlePassLocked 真人过牌(需持锁) func (r *Room) handlePassLocked(seatIdx int, c *Client) { if r.status != "playing" || r.ddzGame == nil || r.ddzGame.Phase != ddz.PhasePlaying { c.pushError("当前不在出牌阶段") return } if err := r.ddzGame.Pass(seatIdx); err != nil { c.pushError(err.Error()) return } r.afterDdzActionLocked(seatIdx, nil) } // ddzPlayCtxLocked 组装斗地主出牌决策上下文(需持锁):身份与各家剩牌 func (r *Room) ddzPlayCtxLocked(seatIdx int) ai.PlayContext { g := r.ddzGame pctx := ai.PlayContext{} if g == nil || g.Landlord < 0 { return pctx } lord := g.Landlord pctx.IsLandlord = seatIdx == lord pctx.LandlordCards = len(g.Hands[lord]) if pctx.IsLandlord { // 地主视角:敌方是两位平民,取剩牌最少者 for i := 0; i < 3; i++ { if i == seatIdx { continue } if n := len(g.Hands[i]); pctx.OppMin == 0 || n < pctx.OppMin { pctx.OppMin = n } } } else { // 平民视角:队友是另一位平民(三座位号之和为 3),敌方只有地主 partner := 3 - seatIdx - lord pctx.PartnerCards = len(g.Hands[partner]) pctx.OppMin = len(g.Hands[lord]) pctx.LastIsPartner = g.LastCombo != nil && g.LastSeat == partner } return pctx } // handleDdzHintLocked 出牌提示(需持锁):seq=0 返回配合策略推荐的一手, // 连续点击(seq 递增)时按牌力从小到大轮换全部可压候选 func (r *Room) handleDdzHintLocked(seatIdx, seq int, c *Client) { g := r.ddzGame if g == nil || r.status != "playing" || g.Phase != ddz.PhasePlaying { c.pushError("当前不在出牌阶段") return } if g.Turn != seatIdx { c.pushError("还没轮到你出牌") return } moves := ddz.GenMoves(g.Hands[seatIdx], g.LastCombo) if len(moves) == 0 { // 没有任何能压的牌:告知只能过 c.push("ddz_hint", map[string]any{"none": true}) return } if seq <= 0 { // 首次提示:走托管规则 AI(带平民配合),可能建议过牌 hand := append([]int{}, g.Hands[seatIdx]...) pick, _ := r.escrowDdz.DecidePlay(hand, g.LastCombo, r.ddzPlayCtxLocked(seatIdx)) if pick == nil && g.LastCombo != nil { c.push("ddz_hint", map[string]any{"suggest_pass": true}) return } if pick != nil { c.push("ddz_hint", map[string]any{"cards": pick}) return } } idx := seq if idx < 0 { idx = 0 } c.push("ddz_hint", map[string]any{"cards": moves[idx%len(moves)]}) } // afterDdzActionLocked 斗地主动作后的统一收尾:结束判定或进入下一回合(需持锁) func (r *Room) afterDdzActionLocked(seatIdx int, combo *ddz.Combo) { g := r.ddzGame if g.Phase == ddz.PhaseOver { // 结算:地主胜或农民胜,描述带上春天与总倍数 winners := []int{} desc := "" if g.LandlordWon() { winners = append(winners, g.Landlord) desc = fmt.Sprintf("地主 %s 获胜!", r.seats[g.Landlord].Name) if g.SpringMultiplier() == 2 { desc += "春天!农民一张牌都没出!" } } else { for i := 0; i < 3; i++ { if i != g.Landlord { winners = append(winners, i) } } desc = "农民配合获胜!" if g.SpringMultiplier() == 2 { desc += "反春!地主只出了一手牌!" } } if m := g.TotalMultiplier(); m > 1 { desc += fmt.Sprintf("(%d 倍结算:叫抢 ×%d、炸弹 ×%d、春天 ×%d)", m, g.Multiplier, 1<= 300 { r.settleLocked(nil, "步数达到上限,双方战平") r.broadcastStateLocked() return } r.scheduleTurnLocked() r.broadcastStateLocked() } // --------------------------------------------------------------------- // 结算 // --------------------------------------------------------------------- // settleLocked 对局结算:发积分、写对战记录、重置房间为等待状态(需持锁) // winners 为获胜座位列表(nil=平局) func (r *Room) settleLocked(winners []int, desc string) { if r.status != "playing" { return } r.status = "waiting" if r.turnTimer != nil { r.turnTimer.Stop() } r.turnSeq++ // 使遗留的定时器与 AI 任务全部失效 r.turnDeadln = 0 duration := int(time.Now().Unix() - r.startedAt) winSet := map[int]bool{} for _, w := range winners { winSet[w] = true } // 查游戏ID与奖励配置 var game model.Game database.DB.Where("code = ?", r.Game).First(&game) winPoints := service.GetConfigInt(model.ConfKeyBattleWinPoints, 30) // 斗地主按倍数放大胜利积分(叫抢 × 炸弹 × 春天,牌局核心已封顶 64 倍) if r.Game == "doudizhu" && r.ddzGame != nil { winPoints *= r.ddzGame.TotalMultiplier() } mode := model.BattleModePVP aiProvider, aiDiff := "", "" if r.Mode == "ai" { mode = model.BattleModeAI aiProvider, aiDiff = r.AIProvider, r.AIDifficulty } rewards := map[int]int{} // 给每个真人座位写对战记录与积分 for _, s := range r.seats { if s.UserID <= 0 { continue } result := model.BattleResultLose points := 0 if winners == nil { result = model.BattleResultDraw } else if winSet[s.Index] { result = model.BattleResultWin points = winPoints } if points > 0 { // 发放失败时不能让结算面板与对战记录显示"已获奖"的假象 if _, err := service.ChangePoints(nil, s.UserID, points, model.PointTypeBattle, 0, fmt.Sprintf("「%s」对战获胜奖励", game.Name)); err != nil { log.Printf("[结算] 用户 %d 对战奖励 %d 积分发放失败: %v", s.UserID, points, err) points = 0 } else { rewards[s.Index] = points } } database.DB.Create(&model.BattleRecord{ UserID: s.UserID, GameID: game.ID, RoomCode: r.Code, Mode: mode, AIProvider: aiProvider, AIDifficulty: aiDiff, Result: result, PointsChange: points, Duration: duration, Detail: desc, }) } // 组装结算信息(房间内展示)+ 重置准备状态 winnerNames := []string{} for _, w := range winners { winnerNames = append(winnerNames, r.seats[w].Name) } r.lastResult = map[string]any{ "desc": desc, "winners": winners, "winner_names": winnerNames, "rewards": rewards, "duration": duration, } for _, s := range r.seats { if s.UserID > 0 { s.Ready = s.UserID == r.hostUserID // 房主保持准备 } s.Auto = false // 托管随对局结束自动取消 } r.broadcastChatLocked(-1, "系统", "", desc, false) // 广播结算事件(前端弹结算面板) for _, s := range r.seats { if s.client != nil { s.client.push("battle_end", r.lastResult) } } }