package room import ( "encoding/json" "math/rand" "net/http" "sync" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "nl-game-api-gin/internal/ai" "nl-game-api-gin/internal/database" "nl-game-api-gin/internal/model" "nl-game-api-gin/pkg/jwtutil" ) // upgrader WebSocket 升级器(开发环境放行所有来源) var upgrader = websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { return true }, } // Hub 房间管理器:维护全部活跃房间、用户所在房间以及全部在线连接的映射 type Hub struct { mu sync.Mutex // 保护 rooms 与 userRoom rooms map[string]*Room // 邀请码 → 房间 userRoom map[int]*Room // 用户ID → 所在房间(断线重连用) connMu sync.Mutex // 保护 userClients userClients map[int]map[*Client]bool // 用户ID → 该用户的全部在线连接(私聊推送/在线状态用) } // defaultHub 进程内唯一的 Hub 实例(供 handler 包做私聊推送与在线查询) var defaultHub *Hub // NewHub 创建房间管理器并启动过期房间清理协程 func NewHub() *Hub { h := &Hub{ rooms: map[string]*Room{}, userRoom: map[int]*Room{}, userClients: map[int]map[*Client]bool{}, } defaultHub = h go h.janitor() return h } // registerClient 连接建立后登记到在线表 func (h *Hub) registerClient(c *Client) { h.connMu.Lock() defer h.connMu.Unlock() if h.userClients[c.userID] == nil { h.userClients[c.userID] = map[*Client]bool{} } h.userClients[c.userID][c] = true } // unregisterClient 连接断开后从在线表移除 func (h *Hub) unregisterClient(c *Client) { h.connMu.Lock() defer h.connMu.Unlock() if set, ok := h.userClients[c.userID]; ok { delete(set, c) if len(set) == 0 { delete(h.userClients, c.userID) } } } // PushToUser 向指定用户的全部在线连接推送一条消息(离线返回 false,调用方靠数据库兜底) func PushToUser(userID int, msgType string, data any) bool { if defaultHub == nil { return false } defaultHub.connMu.Lock() defer defaultHub.connMu.Unlock() set, ok := defaultHub.userClients[userID] if !ok || len(set) == 0 { return false } for c := range set { c.push(msgType, data) } return true } // OnlineCount 当前在线用户数(后台数据概览用) func OnlineCount() int { if defaultHub == nil { return 0 } defaultHub.connMu.Lock() defer defaultHub.connMu.Unlock() return len(defaultHub.userClients) } // OnlineMap 批量查询用户在线状态(好友列表/会话列表展示用) func OnlineMap(userIDs []int) map[int]bool { out := map[int]bool{} if defaultHub == nil { return out } defaultHub.connMu.Lock() defer defaultHub.connMu.Unlock() for _, id := range userIDs { out[id] = len(defaultHub.userClients[id]) > 0 } return out } // janitor 定时清理:每分钟检查一次,移除已解散或长时间无人在线的房间 func (h *Hub) janitor() { for range time.Tick(time.Minute) { h.mu.Lock() for code, r := range h.rooms { if r.expired() { r.close("房间长时间无人活动,已解散") for _, uid := range r.humanUserIDs() { delete(h.userRoom, uid) } delete(h.rooms, code) } } h.mu.Unlock() } } // HandleWS WebSocket 入口:?token= 鉴权成功后启动读写泵 // 若用户已在房间中(断线重连),自动重新挂载并推送最新状态 func (h *Hub) HandleWS(c *gin.Context) { claims, err := jwtutil.Parse(c.Query("token")) if err != nil { c.JSON(401, gin.H{"code": 401, "msg": "凭证无效"}) return } var user model.User if err := database.DB.First(&user, claims.UserID).Error; err != nil || user.Status != model.UserStatusNormal { c.JSON(401, gin.H{"code": 401, "msg": "用户状态异常"}) return } conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { return } client := &Client{ hub: h, conn: conn, send: make(chan []byte, 64), userID: user.ID, name: user.Nickname, avatar: user.Avatar, } go client.writePump() // 登记在线连接(私聊推送/好友在线状态) h.registerClient(client) // 连接成功先下发身份确认 client.push("hello", gin.H{"user_id": user.ID}) // 断线重连:若用户已在某房间,重新绑定连接并同步状态 h.mu.Lock() if r, ok := h.userRoom[user.ID]; ok { h.mu.Unlock() r.reattach(client) } else { h.mu.Unlock() } client.readPump() } // onDisconnect 连接断开:注销在线登记,标记座位离线(等待重连),等待中的房间直接移除该玩家 func (h *Hub) onDisconnect(c *Client) { h.unregisterClient(c) h.mu.Lock() r, ok := h.userRoom[c.userID] h.mu.Unlock() if !ok { return } if left := r.onOffline(c); left { // 等待中离开即退出房间 h.mu.Lock() delete(h.userRoom, c.userID) if r.empty() { delete(h.rooms, r.Code) } h.mu.Unlock() } } // dispatch 消息路由:按类型分发到建房/加入/游戏动作等处理函数 func (h *Hub) dispatch(c *Client, msgType string, raw json.RawMessage) { switch msgType { case "create_room": h.createRoom(c, raw) case "join_room": h.joinRoom(c, raw) case "leave_room": h.leaveRoom(c) default: // 其余消息都要求已在房间内 h.mu.Lock() r, ok := h.userRoom[c.userID] h.mu.Unlock() if !ok { c.pushError("你不在任何房间中") return } r.handleMessage(c, msgType, raw) } } // roomCodeChars 邀请码字符集(去掉易混淆的 0/O/1/I) const roomCodeChars = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" // genRoomCode 生成 6 位不重复邀请码(调用方需持有 h.mu) func (h *Hub) genRoomCode() string { for { code := make([]byte, 6) for i := range code { code[i] = roomCodeChars[rand.Intn(len(roomCodeChars))] } if _, exists := h.rooms[string(code)]; !exists { return string(code) } } } // createRoomReq 建房请求参数(AI 提供方不由前台指定,取后台全站统一配置) type createRoomReq struct { Game string `json:"game"` // doudizhu / chess / monopoly / ludo / billiards Mode string `json:"mode"` // pvp=好友联机 ai=人机对战 AIDifficulty string `json:"ai_difficulty"` // easy / medium / hard } // validGames 支持联机对战的游戏编码(starve=饥荒组队联机,主机权威模拟) var validGames = map[string]bool{ "doudizhu": true, "chess": true, "monopoly": true, "ludo": true, "billiards": true, "starve": true, } // createRoom 创建房间:AI 模式立即用 AI 填满空位 func (h *Hub) createRoom(c *Client, raw json.RawMessage) { var req createRoomReq json.Unmarshal(raw, &req) if !validGames[req.Game] { c.pushError("未知的游戏类型") return } if req.Mode != "pvp" && req.Mode != "ai" { req.Mode = "pvp" } h.mu.Lock() // 已在房间中不允许重复建房 if _, ok := h.userRoom[c.userID]; ok { h.mu.Unlock() c.pushError("你已在房间中,请先退出") return } code := h.genRoomCode() // AI 模型全站统一:由后台「AI 设置」配置,房间创建时读取当前值 r := newRoom(code, req.Game, req.Mode, ai.GlobalProvider(), req.AIDifficulty) h.rooms[code] = r h.userRoom[c.userID] = r h.mu.Unlock() r.join(c, true) } // joinRoomReq 加入房间请求参数 type joinRoomReq struct { Code string `json:"code"` // 6位邀请码 } // joinRoom 凭邀请码加入房间 func (h *Hub) joinRoom(c *Client, raw json.RawMessage) { var req joinRoomReq json.Unmarshal(raw, &req) h.mu.Lock() if _, ok := h.userRoom[c.userID]; ok { h.mu.Unlock() c.pushError("你已在房间中,请先退出") return } r, ok := h.rooms[req.Code] if !ok { h.mu.Unlock() c.pushError("房间不存在或已解散") return } h.userRoom[c.userID] = r h.mu.Unlock() if err := r.join(c, false); err != nil { h.mu.Lock() delete(h.userRoom, c.userID) h.mu.Unlock() c.pushError(err.Error()) } } // leaveRoom 主动退出房间:对局中退出按认输处理 func (h *Hub) leaveRoom(c *Client) { h.mu.Lock() r, ok := h.userRoom[c.userID] if ok { delete(h.userRoom, c.userID) } h.mu.Unlock() if !ok { return } r.leave(c) h.mu.Lock() if r.empty() { delete(h.rooms, r.Code) } h.mu.Unlock() } // removeUserRoom 房间内部结算/解散时同步清理映射(房间锁外调用) func (h *Hub) removeUserRoom(uid int) { h.mu.Lock() delete(h.userRoom, uid) h.mu.Unlock() } // onStarveOfflineTimeout 饥荒等待中断线保护超时:30秒内未重连才清座;若房间无真人则解散 func (h *Hub) onStarveOfflineTimeout(r *Room, userID, seatIndex int) { h.mu.Lock() defer h.mu.Unlock() r.mu.Lock() defer r.mu.Unlock() seat := r.seatByUser(userID) if seat == nil || seat.Index != seatIndex || seat.Online || seat.client != nil || r.closed { return } r.clearSeatLocked(seat) r.broadcastStateLocked() delete(h.userRoom, userID) empty := true for _, s := range r.seats { if s.UserID > 0 { empty = false break } } if empty { delete(h.rooms, r.Code) } }