package handlers import ( "context" "database/sql" "encoding/json" "fmt" "math/rand" "time" "wuziqi-server/characters" "wuziqi-server/config" "wuziqi-server/core" "wuziqi-server/items" "wuziqi-server/logic" "github.com/heroiclabs/nakama-common/runtime" ) type MatchLabel struct { Open bool `json:"open"` Started bool `json:"started"` PlayerCount int `json:"player_count"` MaxPlayers int `json:"max_players"` Label string `json:"label"` GameType string `json:"game_type"` // 游戏类型标识(minesweeper/minesweeper_free) } type MatchHandler struct{} func (m *MatchHandler) MatchInit(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}) (interface{}, int, string) { logger.Info("MatchInit (Refactored) called") // 从 params 中提取 game_type gameType := "minesweeper" // 默认值 if params != nil { if gt, ok := params["game_type"]; ok { if gtStr, ok := gt.(string); ok && gtStr != "" { gameType = gtStr } } } logger.Info("MatchInit: GameType = %s", gameType) // 1. 加载配置 apiConfig := config.GetMinesweeperConfig(logger) // 默认值 gridSizeSide := 10 bombCount := 30 itemMin := 5 itemMax := 10 hpInit := 4 matchPlayerCount := core.MaxPlayers enabledItems := make(map[string]bool) itemWeights := make(map[string]int) charHPConfig := make(map[string]int) turnDuration := 15 if apiConfig != nil { if apiConfig.GridSize > 0 { gridSizeSide = apiConfig.GridSize } if apiConfig.BombCount > 0 { bombCount = apiConfig.BombCount } if apiConfig.ItemMin >= 0 { itemMin = apiConfig.ItemMin } if apiConfig.ItemMax >= itemMin { itemMax = apiConfig.ItemMax } if apiConfig.HPInit > 0 { hpInit = apiConfig.HPInit } if apiConfig.MatchPlayerCount >= 2 { matchPlayerCount = apiConfig.MatchPlayerCount } enabledItems = apiConfig.EnabledItems itemWeights = apiConfig.ItemWeights charHPConfig = apiConfig.CharacterHP if apiConfig.TurnDuration > 0 { turnDuration = apiConfig.TurnDuration } } // 强制最小值检查 (防止配置为0导致死循环) if turnDuration < 5 { logger.Warn("TurnDuration too small (%d), resetting to 15", turnDuration) turnDuration = 15 } // 2. 初始化管理器 charMgr := characters.NewCharacterManager(charHPConfig) itemMgr := items.NewItemManager() // 3. 生成网格 // 如果池需要回退,我们需要将所有道具类型传递给 GenerateGrid // 道具已在 ItemManager 中注册,但我们需要ID字符串列表 // 我们在这里硬编码列表或从 items 包中公开它。 // 目前,硬编码以匹配以前的逻辑。 allItemTypes := []string{ "medkit", "bomb_timer", "poison", "shield", "skip", "magnifier", "knife", "revive", "lightning", "chest", "curse", } grid := logic.GenerateGrid(gridSizeSide, bombCount, itemMin, itemMax, enabledItems, itemWeights, allItemTypes) // 4. 创建初始状态 gameState := &core.GameState{ Players: make(map[string]*core.Player), Grid: grid, GridSize: gridSizeSide, TurnOrder: make([]string, 0), CurrentTurnIndex: 0, Round: 1, WinnerID: "", GameStarted: false, TurnDuration: turnDuration, } logger.Info("MatchInit: Configured TurnDuration: %d (API Config: %v)", turnDuration, apiConfig != nil) // 5. 创建比赛状态 // 我们需要一个持有引擎的简化 MatchState 吗? // 或者应该在 MatchLoop 中创建 logic.GameEngine? // Nakama 在调用之间传递 'state' interface{}。 // 我们应该存储 GameState + 引擎依赖项。 // 初始化角色池 charTypes := []string{"elephant", "cat", "dog", "monkey", "chicken", "sloth", "hippo", "tiger"} shuffledChars := make([]string, len(charTypes)) copy(shuffledChars, charTypes) rand.Shuffle(len(shuffledChars), func(i, j int) { shuffledChars[i], shuffledChars[j] = shuffledChars[j], shuffledChars[i] }) matchState := &MatchState{ State: gameState, HPInit: hpInit, MatchPlayerCount: matchPlayerCount, ValidatedPlayers: make(map[string]*config.GameTokenInfo), DisconnectedPlayers: make(map[string]*core.Player), CharacterPool: shuffledChars, CharacterIndex: 0, Spectators: make(map[string]bool), Presences: make(map[string]runtime.Presence), CharManager: charMgr, ItemManager: itemMgr, GameType: gameType, // 存储房间的游戏类型 CreatedAt: time.Now().Unix(), // 记录房间创建时间,用于兜底销毁 } tickRate := 10 label := "Animal Minesweeper" // 初始化 Label initialLabel := MatchLabel{ Open: true, Started: false, PlayerCount: 0, MaxPlayers: matchPlayerCount, Label: label, GameType: gameType, // 添加游戏类型到标签 } labelBytes, _ := json.Marshal(initialLabel) return matchState, tickRate, string(labelBytes) } type MatchState struct { State *core.GameState HPInit int MatchPlayerCount int ValidatedPlayers map[string]*config.GameTokenInfo DisconnectedPlayers map[string]*core.Player CharacterPool []string CharacterIndex int Spectators map[string]bool Presences map[string]runtime.Presence GameType string // 房间的游戏类型(minesweeper/minesweeper_free) CreatedAt int64 // 房间创建时间(Unix 秒),用于兜底销毁 // 用于构建引擎的管理器 CharManager *characters.CharacterManager ItemManager *items.ItemManager } // ... MatchJoinAttempt, MatchJoin, MatchLoop ... func (m *MatchHandler) MatchJoinAttempt(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presence runtime.Presence, metadata map[string]string) (interface{}, bool, string) { ms := state.(*MatchState) // Token 验证逻辑 gameToken, ok := metadata["game_token"] if !ok || gameToken == "" { logger.Warn("MatchJoinAttempt: No game_token provided for user %s (SID: %s). Metadata: %v", presence.GetUserId(), presence.GetSessionId(), metadata) return ms, false, "Game token required" } logger.Info("MatchJoinAttempt: Validating token for user %s", presence.GetUserId()) tokenInfo := config.ValidateGameToken(logger, gameToken) if tokenInfo == nil { logger.Error("MatchJoinAttempt: Invalid game token for user %s", presence.GetUserId()) return ms, false, "Invalid game token" } // 校验玩家的游戏类型是否与房间匹配 if tokenInfo.GameType != ms.GameType { logger.Error("MatchJoinAttempt: GameType MISMATCH for user %s. Player: %s, Room: %s. This indicates matchmaker query filtering may not be working correctly.", presence.GetUserId(), tokenInfo.GameType, ms.GameType) return ms, false, fmt.Sprintf("Game type mismatch (player: %s, room: %s)", tokenInfo.GameType, ms.GameType) } ms.ValidatedPlayers[presence.GetUserId()] = tokenInfo if ms.State.GameStarted { if _, ok := ms.DisconnectedPlayers[presence.GetUserId()]; ok { return ms, true, "" // 重连 } ms.Spectators[presence.GetUserId()] = true return ms, true, "" // 观众 } if len(ms.State.Players) >= ms.MatchPlayerCount { return ms, false, "Match full" } return ms, true, "" } func (m *MatchHandler) MatchJoin(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} { ms := state.(*MatchState) // 仅在需要时构建引擎?还是只使用它的组件? // 如果需要,我们可以在本地构建它以使用其逻辑,但 Join 逻辑主要是设置。 matchID, _ := ctx.Value(runtime.RUNTIME_CTX_MATCH_ID).(string) for _, presence := range presences { userID := presence.GetUserId() ms.Presences[userID] = presence // 统一追踪 Presence,确保消息路由正确 // 记录活跃对局到存储,用于全设备重连查找 nk.StorageWrite(ctx, []*runtime.StorageWrite{ { Collection: "game_data", Key: "active_match", UserID: userID, Value: fmt.Sprintf(`{"match_id":"%s"}`, matchID), PermissionRead: 2, // Owner Read PermissionWrite: 0, // Server Only }, }) // 观众 if ms.Spectators[userID] { publicState := core.CreatePublicGameState(ms.State) data, _ := json.Marshal(publicState) dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{presence}, nil, true) continue } // 重连 if p, ok := ms.DisconnectedPlayers[userID]; ok { p.SessionID = presence.GetSessionId() ms.State.Players[userID] = p delete(ms.DisconnectedPlayers, userID) sanitized := ms.State.SanitizeForUser(userID) data, err := json.Marshal(sanitized) if err != nil { continue } dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{presence}, nil, true) continue } // 新玩家 if _, exists := ms.State.Players[userID]; exists { continue } tokenInfo := ms.ValidatedPlayers[userID] if tokenInfo == nil { continue } // 分配角色 char := "dog" // 回退 if ms.CharacterIndex < len(ms.CharacterPool) { char = ms.CharacterPool[ms.CharacterIndex] ms.CharacterIndex++ } maxHP := ms.CharManager.GetInitialHP(char, ms.HPInit) avatar := ms.CharManager.GetAvatar(char) username := tokenInfo.Username if username == "" { username = presence.GetUsername() } player := &core.Player{ UserID: userID, RealUserID: tokenInfo.UserID, SessionID: presence.GetSessionId(), Username: username, Avatar: avatar, HP: maxHP, MaxHP: maxHP, Character: char, Ticket: tokenInfo.Ticket, GameType: tokenInfo.GameType, // 从 token 中获取游戏类型(免费/付费) Status: []string{}, RevealedCells: make(map[int]string), } ms.State.Players[userID] = player ms.State.TurnOrder = append(ms.State.TurnOrder, userID) ms.Presences[userID] = presence // 追踪 presence } // 更新 Label ms.updateLabel(dispatcher) // 开始游戏检查 if len(ms.State.Players) >= ms.MatchPlayerCount && !ms.State.GameStarted { ms.State.GameStarted = true ms.State.LastMoveTimestamp = time.Now().Unix() // 打乱回合顺序 rand.Shuffle(len(ms.State.TurnOrder), func(i, j int) { ms.State.TurnOrder[i], ms.State.TurnOrder[j] = ms.State.TurnOrder[j], ms.State.TurnOrder[i] }) // 记录并消耗入场券 // ... 入场券消耗逻辑(由于是处理器保持简单)... for _, p := range ms.State.Players { go consumeTicket(p.RealUserID, p.Ticket, p.GameType, logger) } // 广播开始 broadcastUpdate(dispatcher, ms.State, core.OpCodeGameStart) ms.updateLabel(dispatcher) } else { // 更新大厅 broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState) } return ms } func (m *MatchHandler) MatchLoop(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData) interface{} { ms := state.(*MatchState) // 兜底销毁:房间存活超过 10 分钟强制销毁,防止异常房间泄漏 const maxRoomLifetimeSecs = 10 * 60 if time.Now().Unix()-ms.CreatedAt > maxRoomLifetimeSecs { logger.Warn("MatchLoop: Room exceeded max lifetime (%ds), force destroying. CreatedAt=%d", maxRoomLifetimeSecs, ms.CreatedAt) return nil } // 为此循环 tick 创建引擎实例 loopMatchID, _ := ctx.Value(runtime.RUNTIME_CTX_MATCH_ID).(string) engine := logic.NewGameEngine(logger, dispatcher, ms.CharManager, ms.ItemManager, ms.Presences, ms.DisconnectedPlayers, loopMatchID) for _, message := range messages { userID := message.GetUserId() opCode := message.GetOpCode() logger.Debug("MatchLoop: Received msg from UserID=%s, OpCode=%d, DataLen=%d", userID, opCode, len(message.GetData())) if ms.Spectators[userID] { continue } switch opCode { case core.OpCodeMove: if !ms.State.GameStarted { logger.Warn("Move ignored: game not started") continue } var move core.MoveMessage if err := json.Unmarshal(message.GetData(), &move); err != nil { logger.Error("Failed to unmarshal move: %v", err) continue } // 委托给引擎 engine.HandleMove(ms.State, userID, move.Index) case core.OpCodeGetState: sanitized := ms.State.SanitizeForUser(message.GetUserId()) data, _ := json.Marshal(sanitized) targetPresence := ms.Presences[message.GetUserId()] if targetPresence == nil { // Presence 可能还未更新(重连时序问题),记录警告 logger.Warn("OpCodeGetState: Presence not found for user %s, possible timing issue", message.GetUserId()) continue } dispatcher.BroadcastMessage(core.OpCodeUpdateState, data, []runtime.Presence{targetPresence}, nil, true) } } // 超时检查 if ms.State.GameStarted && ms.State.WinnerID == "" { now := time.Now().Unix() // Debug logging for timeout logic if now-ms.State.LastMoveTimestamp >= int64(ms.State.TurnDuration) { logger.Info("MatchLoop: Turn Timeout Triggered. Now: %d, Last: %d, Diff: %d, Duration: %d", now, ms.State.LastMoveTimestamp, now-ms.State.LastMoveTimestamp, ms.State.TurnDuration) currentUID := ms.State.TurnOrder[ms.State.CurrentTurnIndex] if _, disconnected := ms.DisconnectedPlayers[currentUID]; disconnected { // 跳过回合 engine.BroadcastEvent(core.GameEvent{Type: "status", Message: "⏰ 玩家断线,跳过回合"}) engine.AdvanceTurn(ms.State) } else { // 扣除HP并跳过 player := ms.State.Players[currentUID] if player != nil { engine.ApplyDamage(ms.State, player, 1, false) // 1点HP惩罚,非道具效果 engine.AdvanceTurn(ms.State) } } ms.State.LastMoveTimestamp = now // 检查游戏结束 if engine.CheckGameOver(ms.State) { return ms } // 广播更新 broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState) } } // 检查游戏是否应该结束并销毁房间 if !ms.State.GameStarted && ms.State.WinnerID != "" { logger.Info("Match %s is over (Winner: %s), destroying room", ms.State.WinnerID, ms.State.WinnerID) // 游戏结束,清理所有参与者的对局存储 for uid := range ms.State.Players { nk.StorageDelete(ctx, []*runtime.StorageDelete{ { Collection: "game_data", Key: "active_match", UserID: uid, }, }) } for uid := range ms.DisconnectedPlayers { nk.StorageDelete(ctx, []*runtime.StorageDelete{ { Collection: "game_data", Key: "active_match", UserID: uid, }, }) } return nil } return ms } func (m *MatchHandler) MatchLeave(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence) interface{} { ms := state.(*MatchState) for _, presence := range presences { userID := presence.GetUserId() if ms.Spectators[userID] { delete(ms.Spectators, userID) delete(ms.Presences, userID) continue } if ms.State.GameStarted { // 将玩家标记为断开连接 if p, ok := ms.State.Players[userID]; ok { ms.DisconnectedPlayers[userID] = p p.DisconnectTime = time.Now().Unix() delete(ms.State.Players, userID) // 从活跃玩家中移除 broadcastUpdate(dispatcher, ms.State, core.OpCodeUpdateState) } } else { delete(ms.State.Players, userID) // 退出大厅时,清理对局存储 nk.StorageDelete(ctx, []*runtime.StorageDelete{ { Collection: "game_data", Key: "active_match", UserID: userID, }, }) // 从回合顺序中移除...(如果我们支持离开大厅则需要逻辑) // 找到并移除 userID for i, id := range ms.State.TurnOrder { if id == userID { ms.State.TurnOrder = append(ms.State.TurnOrder[:i], ms.State.TurnOrder[i+1:]...) break } } } delete(ms.Presences, userID) // 移除 presence } ms.updateLabel(dispatcher) return ms } func (ms *MatchState) updateLabel(dispatcher runtime.MatchDispatcher) { label := MatchLabel{ Open: !ms.State.GameStarted && len(ms.State.Players) < ms.MatchPlayerCount, Started: ms.State.GameStarted, PlayerCount: len(ms.State.Players), MaxPlayers: ms.MatchPlayerCount, Label: "Animal Minesweeper", GameType: ms.GameType, // 包含游戏类型 } labelBytes, _ := json.Marshal(label) _ = dispatcher.MatchLabelUpdate(string(labelBytes)) } func (m *MatchHandler) MatchTerminate(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int) interface{} { return state } func (m *MatchHandler) MatchSignal(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string) (interface{}, string) { return state, data } // 辅助函数 func broadcastUpdate(dispatcher runtime.MatchDispatcher, state *core.GameState, opCode int64) { // 原始逻辑:向每个玩家发送已脱敏的状态 // 我们需要遍历已连接的 presence 吗? // 或者只是广播已脱敏的通用状态? // 原始 main.go 循环: // for _, presence := range presences ... SanitizeForUser ... // 由于我们在全局函数中无法轻松获取 presence,我们要依赖玩家映射的键吗? // 但是 Dispatcher 需要 presence 作为目标。 // BroadcastMessage 使用 nil presence 会发送给所有人。 // 但是我们需要针对每个用户的脱敏来处理隐藏格子。 // Sanitize() 方法(无参数)清除所有已揭示的格子。这对公开广播是安全的。 // 并且它更新所有人。 // 但是正确的扫雷游戏需要用户看到他们自己揭示的格子。 // 所以我们现在应该广播安全的通用版本,除非我们追踪 presence。 // 简化:广播通用的已脱敏状态(如果不是公开的,每个人都看到揭示的格子是隐藏的?) // 等等,Sanitize() 清除了 Player 中的 RevealedCells 映射。 // SanitizeForUser(uid) 保留该用户的格子。 // 如果我们使用 dispatcher.BroadcastMessage(..., nil, ...) 它会发送给所有人。 // 没有 Presence 列表,我们无法在这里实现每用户的视图。 // MatchState 通常不存储 presence 列表,Nakama 会管理它。 // 如果我们需要完美的实现,可能需要重新考虑这一点。 // 然而,原始 main.go 的 broadcastStateToAllPlayers 需要传递 presence。 // 在 MatchLoop 中,我们没有在消息中传递所有已连接用户的 presence 列表。 // 原始代码 MatchJoin 使用 dispatcher.BroadcastMessage(..., nil) 广播,这会向所有人发送一条消息。 // 而且 Sanitize() 清除了所有私有信息。 // 逻辑 handleMove 也广播了 Sanitize()。 // 所以原始代码实际上并没有发送私有信息? // 让我们重读 main.go。 // 第 319 行:Sanitize() 清除所有 revealedCells。 // 第 413 行:broadcastStateToAllPlayers 循环 presence... // 但是 MatchLoop 调用 handleMove(第 1158 行)调用了 dispatcher.BroadcastMessage(..., state.Sanitize()...)! // 所以在原始代码中,RevealedCells 从未通过广播更新显示给客户端? // 放大镜事件发送 CellIndex 和 CellType。客户端使用该事件在本地更新地图? // 是的:broadcastEvent 发送 CellIndex 和 CellType。 // 所以如果事件携带私有信息,状态更新本身不需要携带它。 // 所以 Sanitize() 就足够了。 data, _ := json.Marshal(state.Sanitize()) dispatcher.BroadcastMessage(opCode, data, nil, nil, true) } func consumeTicket(realUserID int64, ticket string, gameType string, logger runtime.Logger) { if realUserID <= 0 { return } reqBody, _ := json.Marshal(map[string]string{ "user_id": fmt.Sprintf("%d", realUserID), "game_code": gameType, // 使用实际的游戏类型,免费模式后端会跳过扣减 "ticket": ticket, }) url := config.BackendBaseURL + "/game/consume-ticket" resp, err := config.MakeInternalRequest("POST", url, reqBody) if err != nil { logger.Error("Failed to call backend consume-ticket API for user %d: %v", realUserID, err) return } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Backend consume-ticket returned status %d for user %d", resp.StatusCode, realUserID) } else { logger.Info("Ticket consumed successfully for user %d", realUserID) } }