package main import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "io/ioutil" "math/rand" "net/http" "os" "strings" "time" "github.com/heroiclabs/nakama-common/runtime" ) // --- Constants & Enums --- const ( OpCodeGameStart = 1 OpCodeUpdateState = 2 OpCodeMove = 3 OpCodeGameEvent = 5 // Special game events (item use, character ability) OpCodeGameOver = 6 OpCodeGetState = 100 // New opcode for requesting current state MaxPlayers = 2 // Default value, will be overridden by config ) var ( BackendBaseURL = "http://host.docker.internal:9991/api/internal" // Default InternalAPIKey = "bindbox-internal-secret-2024" // Must match backend ) var httpClient = &http.Client{ Timeout: 5 * time.Second, } // Helper function to make authenticated internal API requests func makeInternalRequest(method, url string, body []byte) (*http.Response, error) { req, err := http.NewRequest(method, url, bytes.NewBuffer(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Internal-Key", InternalAPIKey) return httpClient.Do(req) } type MinesweeperConfig struct { GridSize int `json:"grid_size"` BombCount int `json:"bomb_count"` ItemMin int `json:"item_min"` ItemMax int `json:"item_max"` HPInit int `json:"hp_init"` MatchPlayerCount int `json:"match_player_count"` // Required players to start match EnabledItems map[string]bool `json:"enabled_items"` ItemWeights map[string]int `json:"item_weights"` } func getMinesweeperConfig(logger runtime.Logger) *MinesweeperConfig { url := BackendBaseURL + "/game/minesweeper/config" logger.Info("Fetching minesweeper config from: %s", url) resp, err := makeInternalRequest("GET", url, nil) if err != nil { logger.Error("Network error fetching config (check BackendBaseURL): %v", err) return nil } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Backend config API returned status: %d (URL: %s)", resp.StatusCode, url) return nil } body, err := ioutil.ReadAll(resp.Body) if err != nil { logger.Error("Failed to read config response body: %v", err) return nil } var config MinesweeperConfig if err := json.Unmarshal(body, &config); err != nil { logger.Error("Failed to parse minesweeper config: %v. Raw body: %s", err, string(body)) return nil } return &config } type VerifyTicketResponse struct { Valid bool `json:"valid"` UserID string `json:"user_id"` RemainingTimes int `json:"remaining_times"` } type SettleGameResponse struct { Success bool `json:"success"` Reward string `json:"reward"` } // GameTokenInfo contains validated token information from backend type GameTokenInfo struct { Valid bool `json:"valid"` UserID int64 `json:"user_id"` Username string `json:"username"` Avatar string `json:"avatar"` GameType string `json:"game_type"` Ticket string `json:"ticket"` Error string `json:"error"` } // validateGameToken validates a game token with the backend and returns user info func validateGameToken(logger runtime.Logger, gameToken string) *GameTokenInfo { logger.Info("Validating game token with backend") reqBody, _ := json.Marshal(map[string]string{ "game_token": gameToken, }) resp, err := makeInternalRequest("POST", BackendBaseURL+"/game/validate-token", reqBody) if err != nil { logger.Error("Failed to call backend validate-token API: %v", err) return nil } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Backend validate-token returned non-200 status: %d", resp.StatusCode) return nil } body, _ := ioutil.ReadAll(resp.Body) var result GameTokenInfo if err := json.Unmarshal(body, &result); err != nil { logger.Error("Failed to parse validate-token response: %v", err) return nil } if !result.Valid { logger.Warn("Game token invalid: %s", result.Error) return nil } return &result } func verifyTicketWithBackend(logger runtime.Logger, userID string, ticket string) bool { logger.Info("Verifying ticket with backend: %s for user %s", ticket, userID) reqBody, _ := json.Marshal(map[string]string{ "user_id": userID, "ticket": ticket, }) resp, err := makeInternalRequest("POST", BackendBaseURL+"/game/verify", reqBody) if err != nil { logger.Error("Failed to call backend verify API: %v", err) return false // Fail safe } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Backend returned non-200 status: %d", resp.StatusCode) return false } body, _ := ioutil.ReadAll(resp.Body) var result VerifyTicketResponse if err := json.Unmarshal(body, &result); err != nil { logger.Error("Failed to parse backend response: %v", err) return false } return result.Valid } // settleGameWithBackend settles the game with the backend using the real user ID func settleGameWithBackend(logger runtime.Logger, realUserID int64, ticket, matchID string, win bool, score int) { logger.Info("Settling game with backend for realUserID %d (Win: %v)", realUserID, win) reqBody, _ := json.Marshal(map[string]interface{}{ "user_id": fmt.Sprintf("%d", realUserID), // Backend expects string "ticket": ticket, "match_id": matchID, "win": win, "score": score, }) // Async call to not block game loop too much, or use goroutine go func() { resp, err := makeInternalRequest("POST", BackendBaseURL+"/game/settle", reqBody) if err != nil { logger.Error("Failed to call backend settle API: %v", err) return } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Backend settle returned non-200: %d", resp.StatusCode) } else { logger.Info("Game settled successfully with backend") } }() } var ( ItemTypes = []string{ "medkit", "bomb_timer", "poison", "shield", "skip", "magnifier", "knife", "revive", "lightning", "chest", "curse", } CharacterTypes = []string{ "elephant", "cat", "dog", "monkey", "chicken", "sloth", "hippo", "tiger", } CharacterData = map[string]struct { MaxHP int Avatar string Desc string }{ "elephant": {MaxHP: 5, Avatar: "🐘", Desc: "High HP, can't use some items"}, "cat": {MaxHP: 3, Avatar: "🐱", Desc: "Max dmg taken is 1"}, "dog": {MaxHP: 4, Avatar: "🐶", Desc: "Periodic magnifier"}, "monkey": {MaxHP: 4, Avatar: "🐒", Desc: "Get banana (heal) chance"}, "chicken": {MaxHP: 4, Avatar: "🐔", Desc: "Get item on dmg chance"}, "sloth": {MaxHP: 4, Avatar: "🦥", Desc: "Immune poison, bomb dmg reduced"}, "hippo": {MaxHP: 4, Avatar: "🦛", Desc: "Cant pick items, resist death chance"}, "tiger": {MaxHP: 4, Avatar: "🐯", Desc: "Stronger knife"}, } ) // --- Structs --- type GridCell struct { Type string `json:"type"` // "empty" | "bomb" | "item" ItemID string `json:"itemId,omitempty"` Revealed bool `json:"revealed"` NeighborBombs int `json:"neighborBombs"` // Count of adjacent bombs } type Player struct { UserID string `json:"userId"` // Nakama user ID RealUserID int64 `json:"realUserId"` // Backend user ID (for settlements) SessionID string `json:"sessionId"` Username string `json:"username"` Avatar string `json:"avatar"` HP int `json:"hp"` MaxHP int `json:"maxHp"` Status []string `json:"status"` // Visual status tags Character string `json:"character"` Ticket string `json:"ticket"` // Store the ticket used to join // Status Flags Shield bool `json:"shield"` SkipTurn bool `json:"skipTurn"` Poisoned bool `json:"poisoned"` PoisonSteps int `json:"poisonSteps"` // Steps taken since poisoned Revive bool `json:"revive"` Curse bool `json:"curse"` TimeBombTurns int `json:"timeBombTurns"` // Countdown for time bomb (0 = no bomb) // Character ability usage counters (for limits) MonkeyBananaCount int `json:"monkeyBananaCount"` // Max 2 per game ChickenItemCount int `json:"chickenItemCount"` // Max 2 per game HippoDeathImmune bool `json:"hippoDeathImmune"` // Used once = true // Magnifier reveals (cell index -> cell type) RevealedCells map[int]string `json:"revealedCells"` } type GameState struct { Players map[string]*Player `json:"players"` Grid []*GridCell `json:"grid"` GridSize int `json:"gridSize"` // Side length of the square grid TurnOrder []string `json:"turnOrder"` CurrentTurnIndex int `json:"currentTurnIndex"` Round int `json:"round"` GlobalTurnCount int `json:"globalTurnCount"` // Total turns taken (for Dog ability) WinnerID string `json:"winnerId"` GameStarted bool `json:"gameStarted"` LastMoveTimestamp int64 `json:"lastMoveTimestamp"` // Unix timestamp in seconds } type MoveMessage struct { Index int `json:"index"` } type GetStateMessage struct { Action string `json:"action"` } type MatchState struct { State *GameState HPInit int MatchPlayerCount int // Dynamic player count for matching ValidatedPlayers map[string]*GameTokenInfo // Cache: NakamaUserID -> validated token info } // GameEvent represents a special event to be displayed in client logs type GameEvent struct { Type string `json:"type"` // "ability", "item", "damage", "heal", "status" PlayerID string `json:"playerId"` PlayerName string `json:"playerName"` TargetID string `json:"targetId,omitempty"` TargetName string `json:"targetName,omitempty"` ItemID string `json:"itemId,omitempty"` Value int `json:"value,omitempty"` Message string `json:"message"` } func broadcastEvent(dispatcher runtime.MatchDispatcher, event GameEvent) { data, _ := json.Marshal(event) dispatcher.BroadcastMessage(OpCodeGameEvent, data, nil, nil, true) } // --- Match Handler Methods --- 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.Error("MatchInit called (Go) - Dynamic Config Version") // Fetch dynamic config apiConfig := getMinesweeperConfig(logger) // Set defaults if fetch fails gridSizeSide := 10 totalCells := 100 bombCount := 30 itemCountMin := 5 itemCountMax := 10 hpInit := 4 if apiConfig != nil { if apiConfig.GridSize > 0 && apiConfig.GridSize <= 30 { gridSizeSide = apiConfig.GridSize } totalCells = gridSizeSide * gridSizeSide if apiConfig.BombCount > 0 && apiConfig.BombCount < totalCells { bombCount = apiConfig.BombCount } if apiConfig.ItemMax >= apiConfig.ItemMin && apiConfig.ItemMin >= 0 { itemCountMin = apiConfig.ItemMin itemCountMax = apiConfig.ItemMax } if apiConfig.HPInit > 0 { hpInit = apiConfig.HPInit } logger.Info("Using dynamic config: %+v (Final: Grid=%d, Bombs=%d, HP=%d)", apiConfig, gridSizeSide, bombCount, hpInit) } // Dynamic match player count matchPlayerCount := MaxPlayers // Default if apiConfig != nil && apiConfig.MatchPlayerCount >= 2 && apiConfig.MatchPlayerCount <= 10 { matchPlayerCount = apiConfig.MatchPlayerCount logger.Info("Using dynamic match_player_count: %d", matchPlayerCount) } // Generate Grid grid := make([]*GridCell, totalCells) for i := 0; i < totalCells; i++ { grid[i] = &GridCell{Type: "empty", Revealed: false} } // Place Bombs bombsPlaced := 0 for bombsPlaced < bombCount && bombsPlaced < totalCells { idx := rand.Intn(totalCells) if grid[idx].Type == "empty" { grid[idx].Type = "bomb" bombsPlaced++ } } // Filter enabled items and calculate weights var pool []string if apiConfig != nil && len(apiConfig.EnabledItems) > 0 { for _, it := range ItemTypes { if enabled, ok := apiConfig.EnabledItems[it]; ok && enabled { weight := 10 if w, ok := apiConfig.ItemWeights[it]; ok && w > 0 { weight = w } for i := 0; i < weight; i++ { pool = append(pool, it) } } } } // Fallback pool if empty if len(pool) == 0 { pool = ItemTypes } // Place Items itemCount := rand.Intn(itemCountMax-itemCountMin+1) + itemCountMin itemsPlaced := 0 for itemsPlaced < itemCount && (bombsPlaced+itemsPlaced) < totalCells { idx := rand.Intn(totalCells) if grid[idx].Type == "empty" { grid[idx].Type = "item" grid[idx].ItemID = pool[rand.Intn(len(pool))] itemsPlaced++ } } // Calculate Neighbor Bombs for all cells for i := 0; i < totalCells; i++ { if grid[i].Type == "bomb" { continue } count := 0 row := i / gridSizeSide col := i % gridSizeSide // Check all 8 neighbors for r := row - 1; r <= row+1; r++ { for c := col - 1; c <= col+1; c++ { // Skip valid check if r >= 0 && r < gridSizeSide && c >= 0 && c < gridSizeSide { // Skip self if r == row && c == col { continue } neighborIdx := r*gridSizeSide + c if grid[neighborIdx].Type == "bomb" { count++ } } } } grid[i].NeighborBombs = count } state := &GameState{ Players: make(map[string]*Player), Grid: grid, GridSize: gridSizeSide, TurnOrder: make([]string, 0), CurrentTurnIndex: 0, Round: 1, WinnerID: "", GameStarted: false, } // Store hpInit in params or just use local, but MatchJoin needs it // We can store it in MatchState for future joins tickRate := 10 label := "Animal Minesweeper" return &MatchState{ State: state, HPInit: hpInit, MatchPlayerCount: matchPlayerCount, ValidatedPlayers: make(map[string]*GameTokenInfo), }, tickRate, label } 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) { matchState := state.(*MatchState) gameState := matchState.State // 1. Validate GameToken (REQUIRED - prevents free play and spoofing) gameToken, ok := metadata["game_token"] if !ok || gameToken == "" { logger.Warn("MatchJoinAttempt: No game_token provided for user %s, rejecting", presence.GetUserId()) return state, false, "Game token required to join" } // Validate token with backend and get real user info tokenInfo := validateGameToken(logger, gameToken) if tokenInfo == nil { logger.Warn("MatchJoinAttempt: Invalid game_token for user %s", presence.GetUserId()) return state, false, "Invalid or expired game token" } // Cache the validated info for use in MatchJoin matchState.ValidatedPlayers[presence.GetUserId()] = tokenInfo logger.Info("MatchJoinAttempt: Validated user %s (RealUserID: %d, Username: %s)", presence.GetUserId(), tokenInfo.UserID, tokenInfo.Username) if gameState.GameStarted { return state, false, "Game already started" } if len(gameState.Players) >= matchState.MatchPlayerCount { return state, false, "Match full" } return state, 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{} { matchState := state.(*MatchState) gameState := matchState.State for _, presence := range presences { if _, exists := gameState.Players[presence.GetUserId()]; exists { continue } // Get validated token info from cache tokenInfo := matchState.ValidatedPlayers[presence.GetUserId()] if tokenInfo == nil { // This should not happen if MatchJoinAttempt worked correctly logger.Error("MatchJoin: No cached token info for user %s, rejecting", presence.GetUserId()) continue } character := CharacterTypes[rand.Intn(len(CharacterTypes))] charData := CharacterData[character] initialHP := charData.MaxHP if matchState.HPInit > 0 { initialHP = matchState.HPInit } // Use real user info from validated token username := tokenInfo.Username if username == "" { username = presence.GetUsername() // Fallback to Nakama username } player := &Player{ UserID: presence.GetUserId(), RealUserID: tokenInfo.UserID, // Backend user ID for settlements SessionID: presence.GetSessionId(), Username: username, Avatar: charData.Avatar, HP: initialHP, MaxHP: initialHP, Status: make([]string, 0), Character: character, RevealedCells: make(map[int]string), Ticket: tokenInfo.Ticket, } gameState.Players[presence.GetUserId()] = player gameState.TurnOrder = append(gameState.TurnOrder, presence.GetUserId()) logger.Info("Player joined: %s (RealUserID: %d, Username: %s)", presence.GetUserId(), tokenInfo.UserID, username) } // Check if full to start game if len(gameState.Players) >= matchState.MatchPlayerCount && !gameState.GameStarted { gameState.GameStarted = true gameState.LastMoveTimestamp = time.Now().Unix() logger.Info("Game Started! TurnOrder: %v, First player: %s", gameState.TurnOrder, gameState.TurnOrder[0]) // Consume game tickets for all players on match success for _, player := range gameState.Players { go func(p *Player) { consumeReqBody, _ := json.Marshal(map[string]string{ "user_id": fmt.Sprintf("%d", p.RealUserID), "game_code": "minesweeper", "ticket": p.Ticket, }) resp, err := makeInternalRequest("POST", BackendBaseURL+"/game/consume-ticket", consumeReqBody) if err != nil { logger.Error("Failed to consume ticket for user %d: %v", p.RealUserID, err) return } defer resp.Body.Close() if resp.StatusCode != 200 { logger.Error("Backend consume-ticket returned non-200 for user %d: %d", p.RealUserID, resp.StatusCode) } else { logger.Info("Successfully consumed ticket for user %d on match success", p.RealUserID) } }(player) } data, _ := json.Marshal(gameState) dispatcher.BroadcastMessage(OpCodeGameStart, data, nil, nil, true) } else { logger.Info("Player count: %d. Broadcasting UPDATE_STATE", len(gameState.Players)) data, _ := json.Marshal(gameState) dispatcher.BroadcastMessage(OpCodeUpdateState, data, nil, nil, true) } return matchState } 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{} { matchState := state.(*MatchState) gameState := matchState.State for _, presence := range presences { delete(gameState.Players, presence.GetUserId()) // Remove from turn order for i, uid := range gameState.TurnOrder { if uid == presence.GetUserId() { // Remove element gameState.TurnOrder = append(gameState.TurnOrder[:i], gameState.TurnOrder[i+1:]...) // Adjust turn index if i < gameState.CurrentTurnIndex { gameState.CurrentTurnIndex-- } if len(gameState.TurnOrder) > 0 && gameState.CurrentTurnIndex >= len(gameState.TurnOrder) { gameState.CurrentTurnIndex = 0 } break } } } // Broadcast update if game is running if gameState.GameStarted && len(gameState.Players) > 0 { data, _ := json.Marshal(gameState) dispatcher.BroadcastMessage(OpCodeUpdateState, data, nil, nil, true) } // End game if less than 2 players (always need at least 2 to continue) if gameState.GameStarted && len(gameState.Players) < 2 { winnerID := "" if len(gameState.Players) == 1 { for uid := range gameState.Players { winnerID = uid break } } gameState.WinnerID = winnerID endData, _ := json.Marshal(map[string]string{"winnerId": winnerID}) dispatcher.BroadcastMessage(OpCodeGameOver, endData, nil, nil, true) return nil // End match } return matchState } 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{} { matchState := state.(*MatchState) gameState := matchState.State // Only log when there are messages to process if len(messages) > 0 { logger.Info("MatchLoop processing %d messages", len(messages)) } for _, message := range messages { logger.Info("Processing message with op_code: %d, sender: %s", message.GetOpCode(), message.GetUserId()) switch message.GetOpCode() { case OpCodeMove: if !gameState.GameStarted { logger.Info("MatchLoop: Game not started ignoring move. GameStarted=%v", gameState.GameStarted) continue // Skip move messages if game hasn't started } logger.Info("MatchLoop: Processing OpCodeMove") var move MoveMessage if err := json.Unmarshal(message.GetData(), &move); err != nil { logger.Error("Failed to parse move data: %v", err) continue } handleMove(gameState, message.GetUserId(), move.Index, logger, dispatcher) case OpCodeGetState: // Handle get state request - send current state to requesting player logger.Debug("Sending current game state to player: %s", message.GetUserId()) data, _ := json.Marshal(gameState) dispatcher.BroadcastMessage(OpCodeUpdateState, data, []runtime.Presence{message}, nil, true) } } // Inactivity Timeout Check (15 seconds) if gameState.GameStarted && gameState.WinnerID == "" { now := time.Now().Unix() if now-gameState.LastMoveTimestamp >= 15 { currentUserID := gameState.TurnOrder[gameState.CurrentTurnIndex] logger.Info("Inactivity timeout for player %s (15s). Deducting 1 HP and advancing turn.", currentUserID) player := gameState.Players[currentUserID] if player != nil { // Deduct 1 HP applyDamage(gameState, player, 1) // Advance Turn advanceTurn(gameState, logger) // Update last move timestamp gameState.LastMoveTimestamp = time.Now().Unix() // Check Game Over if checkGameOver(gameState, dispatcher, logger) { return matchState } // Broadcast update data, _ := json.Marshal(gameState) dispatcher.BroadcastMessage(OpCodeUpdateState, data, nil, nil, true) } } } return matchState } 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 } // --- Helper Functions --- func handleMove(state *GameState, userID string, cellIndex int, logger runtime.Logger, dispatcher runtime.MatchDispatcher) { logger.Info("handleMove: userID=%s, cellIndex=%d", userID, cellIndex) if len(state.TurnOrder) == 0 { return } currentUserID := state.TurnOrder[state.CurrentTurnIndex] if userID != currentUserID { logger.Info("handleMove: Not your turn. Expected=%s, Got=%s", currentUserID, userID) return } if cellIndex < 0 || cellIndex >= len(state.Grid) { return } cell := state.Grid[cellIndex] if cell.Revealed { return } // Reveal cell.Revealed = true player := state.Players[userID] // Increment global turn counter state.GlobalTurnCount++ // 狗狗天赋: 定期触发放大镜效果 // 4人局每6回合触发一次,6人局每9回合触发一次,自动透视一个随机未翻开的格子 if player.Character == "dog" { interval := 6 if len(state.Players) >= 6 { interval = 9 } if state.GlobalTurnCount%interval == 0 { // 随机选择一个未翻开的格子透视给玩家 for i := 0; i < 100; i++ { idx := rand.Intn(len(state.Grid)) if !state.Grid[idx].Revealed { logger.Info("Dog %s activates magnifier! Cell %d is %s", player.UserID, idx, state.Grid[idx].Type) broadcastEvent(dispatcher, GameEvent{ Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, Message: "🐶 狗狗触发嗅觉天赋,获得了一个格子的信息!", }) break } } } } // 猴子天赋: 香蕉概率回复 // 每次行动有15%概率获得香蕉(回复1点HP),每局游戏最多生效2次 if player.Character == "monkey" && player.MonkeyBananaCount < 2 && rand.Float32() < 0.15 { healPlayer(player, 1) player.MonkeyBananaCount++ logger.Info("Monkey %s found a banana! (%d/2)", player.UserID, player.MonkeyBananaCount) broadcastEvent(dispatcher, GameEvent{ Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, Value: 1, Message: "🍌 猴子发现了香蕉,回复1点血量!", }) } // Handle Cell Content if cell.Type == "bomb" { dmg := 2 if player.Character == "sloth" { dmg = 1 } logger.Info("Player %s stepped on bomb (dmg=%d)", player.UserID, dmg) broadcastEvent(dispatcher, GameEvent{ Type: "damage", PlayerID: player.UserID, PlayerName: player.Username, Value: dmg, Message: fmt.Sprintf("💣 踩到炸弹,受到%d点伤害!", dmg), }) applyDamage(state, player, dmg) } else if cell.Type == "item" { if player.Character == "hippo" { logger.Info("Hippo %s cannot pick up items", player.UserID) broadcastEvent(dispatcher, GameEvent{ Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, ItemID: cell.ItemID, Message: "🦛 河马无法拾取道具!", }) } else { resolveItem(state, player, cell.ItemID, logger, dispatcher) } } // Check Game Over if checkGameOver(state, dispatcher, logger) { return } // Advance Turn advanceTurn(state, logger) // Update last move timestamp state.LastMoveTimestamp = time.Now().Unix() // Double check Game Over (poison might have killed someone) if checkGameOver(state, dispatcher, logger) { return } // Broadcast data, _ := json.Marshal(state) dispatcher.BroadcastMessage(OpCodeUpdateState, data, nil, nil, true) } // resolveItem 处理道具效果 // 道具被拾取后自动使用,根据不同道具类型触发对应效果 func resolveItem(state *GameState, player *Player, item string, logger runtime.Logger, dispatcher runtime.MatchDispatcher) { logger.Info("Player %s used item: %s", player.UserID, item) // 大象角色限制: 无法使用医疗包、好人卡、复活甲 if player.Character == "elephant" && (item == "medkit" || item == "skip" || item == "revive") { logger.Info("Elephant refused item %s", item) broadcastEvent(dispatcher, GameEvent{ Type: "ability", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Message: "🐘 大象无法使用该道具!", }) return } switch item { case "medkit": player.Poisoned = false player.PoisonSteps = 0 healPlayer(player, 1) broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Value: 1, Message: "💊 使用医疗包,回复1血并解除中毒!", }) case "bomb_timer": player.TimeBombTurns = 3 logger.Info("Player %s has a time bomb! Explodes in 3 turns", player.UserID) broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Value: 3, Message: "⏰ 定时炸弹启动,3回合后爆炸!", }) case "poison": target := getRandomAliveTarget(state, player.UserID) if target != nil { if target.Character == "sloth" { logger.Info("Sloth resisted poison") broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, TargetID: target.UserID, TargetName: target.Username, ItemID: item, Message: "🦥 树懒免疫了毒药!", }) } else { target.Poisoned = true broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, TargetID: target.UserID, TargetName: target.Username, ItemID: item, Message: fmt.Sprintf("☠️ %s 中毒了!", target.Username), }) } } case "shield": player.Shield = true broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Message: "🛡️ 获得护盾,可抵挡一次伤害!", }) case "skip": player.SkipTurn = true player.Shield = true broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Message: "⏭️ 好人卡:跳过回合并获得护盾!", }) case "magnifier": for i := 0; i < 100; i++ { idx := rand.Intn(len(state.Grid)) if !state.Grid[idx].Revealed { cellType := state.Grid[idx].Type if state.Grid[idx].Type == "item" { cellType = state.Grid[idx].ItemID } player.RevealedCells[idx] = cellType logger.Info("Magnifier: Player %s can now see cell %d (%s)", player.UserID, idx, cellType) broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Message: "🔍 放大镜:透视了一个隐藏格子!", }) break } } case "knife": dmg := 1 isAOE := false if player.Character == "tiger" { dmg = 2 isAOE = true } if isAOE { broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Value: dmg, Message: fmt.Sprintf("🐯🔪 老虎的飞刀对所有敌人造成%d点伤害!", dmg), }) for _, p := range state.Players { if p.UserID != player.UserID && p.HP > 0 { applyDamage(state, p, dmg) } } } else { target := getRandomAliveTarget(state, player.UserID) if target != nil { broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, TargetID: target.UserID, TargetName: target.Username, ItemID: item, Value: dmg, Message: fmt.Sprintf("🔪 飞刀命中 %s,造成%d点伤害!", target.Username, dmg), }) applyDamage(state, target, dmg) } } case "revive": player.Revive = true broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Message: "💖 获得复活甲,可免疫一次死亡!", }) case "lightning": broadcastEvent(dispatcher, GameEvent{ Type: "item", PlayerID: player.UserID, PlayerName: player.Username, ItemID: item, Value: 1, Message: "⚡ 闪电对所有玩家造成1点伤害!", }) for _, p := range state.Players { applyDamage(state, p, 1) } case "chest": // Meta reward, logic ignored case "curse": player.Curse = true } } func applyDamage(state *GameState, target *Player, amount int) { if target.HP <= 0 { return } // 护盾优先抵挡伤害(消耗护盾,不受伤) if target.Shield { target.Shield = false return // 完全格挡 } // 猫咪天赋: 所有伤害强制为1点(诅咒加成也无效) if target.Character == "cat" { amount = 1 target.Curse = false // 诅咒被消耗但不生效 } else if target.Curse { // 非猫咪角色: 诅咒使伤害翻倍 amount *= 2 target.Curse = false } // Apply damage target.HP -= amount // 坤坤天赋: 受伤时有概率获得道具 // 8%概率获得好人卡/护盾/放大镜之一,每局最多触发2次 if target.Character == "chicken" && target.HP > 0 && target.ChickenItemCount < 2 { if rand.Float32() < 0.08 { target.ChickenItemCount++ // 随机获得: 好人卡(skip), 护盾(shield), 放大镜(magnifier) items := []string{"skip", "shield", "magnifier"} item := items[rand.Intn(len(items))] switch item { case "skip": target.SkipTurn = true target.Shield = true // 好人卡附带护盾效果 case "shield": target.Shield = true case "magnifier": // 放大镜效果在其他地方处理 } } } // Death Check if target.HP <= 0 { if target.Revive { target.Revive = false target.HP = 1 } else if target.Character == "hippo" && !target.HippoDeathImmune { // 55% chance to survive death (once per game) if rand.Float32() < 0.55 { target.HP = 1 target.HippoDeathImmune = true // Mark as used } } } if target.HP < 0 { target.HP = 0 } } func healPlayer(p *Player, amount int) { if p.HP < p.MaxHP { p.HP += amount if p.HP > p.MaxHP { p.HP = p.MaxHP } } } func getRandomAliveTarget(state *GameState, excludeID string) *Player { candidates := []*Player{} for _, p := range state.Players { if p.UserID != excludeID && p.HP > 0 { candidates = append(candidates, p) } } if len(candidates) == 0 { return nil } return candidates[rand.Intn(len(candidates))] } func advanceTurn(state *GameState, logger runtime.Logger) { scanCount := 0 for { state.CurrentTurnIndex = (state.CurrentTurnIndex + 1) % len(state.TurnOrder) scanCount++ // Prevent infinite loop if everyone skips/is dead if scanCount > len(state.TurnOrder)*2 { break } nextUID := state.TurnOrder[state.CurrentTurnIndex] nextPlayer := state.Players[nextUID] if nextPlayer.HP <= 0 { continue } // Handle Time Bomb countdown if nextPlayer.TimeBombTurns > 0 { nextPlayer.TimeBombTurns-- if nextPlayer.TimeBombTurns == 0 { // BOOM! Time bomb explodes dmg := 2 if nextPlayer.Character == "sloth" { dmg = 1 // Sloth takes reduced bomb damage } logger.Info("Time bomb exploded on player %s! Taking %d damage", nextPlayer.UserID, dmg) applyDamage(state, nextPlayer, dmg) if nextPlayer.HP <= 0 { continue // Died from bomb, skip turn } } } // Handle Poison if nextPlayer.Poisoned { nextPlayer.PoisonSteps++ if nextPlayer.PoisonSteps%2 == 0 { applyDamage(state, nextPlayer, 1) if nextPlayer.HP <= 0 { continue // Died from poison, skip turn } } } // Handle Skip if nextPlayer.SkipTurn { nextPlayer.SkipTurn = false logger.Info("Player %s skipped turn", nextPlayer.UserID) continue } // Found valid player break } // Game Over check should happen outside } func checkGameOver(state *GameState, dispatcher runtime.MatchDispatcher, logger runtime.Logger) bool { alive := []string{} for _, p := range state.Players { if p.HP > 0 { alive = append(alive, p.UserID) } } if len(alive) <= 1 { winnerID := "" if len(alive) == 1 { winnerID = alive[0] } state.WinnerID = winnerID state.GameStarted = false // 2. Settle Game with Backend using RealUserID if winnerID != "" { winnerPlayer := state.Players[winnerID] if winnerPlayer != nil && winnerPlayer.RealUserID > 0 { settleGameWithBackend(logger, winnerPlayer.RealUserID, winnerPlayer.Ticket, "", true, 100) } else { logger.Error("Winner player has no RealUserID, cannot settle") } } endData, _ := json.Marshal(map[string]interface{}{ "winnerId": winnerID, "gameState": state, }) dispatcher.BroadcastMessage(OpCodeGameOver, endData, nil, nil, true) return true } return false } // Presence helper for broadcast type UserIDPresence struct { UserID string SessionID string Username string } func (p *UserIDPresence) GetUserId() string { return p.UserID } func (p *UserIDPresence) GetSessionId() string { return p.SessionID } func (p *UserIDPresence) GetNodeId() string { return "" } func (p *UserIDPresence) GetHidden() bool { return false } func (p *UserIDPresence) GetPersistence() bool { return false } func (p *UserIDPresence) GetUsername() string { return p.Username } func (p *UserIDPresence) GetStatus() string { return "" } func (p *UserIDPresence) GetReason() runtime.PresenceReason { return runtime.PresenceReasonUnknown } // --- Init Module --- func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error { logger.Error("Nakama Go Module Loaded - Dynamic Config Version") // Seed random rand.Seed(time.Now().UnixNano()) // Initialize Backend URL from Env if exists envURL := os.Getenv("MINESWEEPER_BACKEND_URL") if envURL != "" { BackendBaseURL = strings.TrimSuffix(envURL, "/") logger.Info("Setting BackendBaseURL from environment: %s", BackendBaseURL) } if err := initializer.RegisterMatch("animal_minesweeper", func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule) (runtime.Match, error) { logger.Error("Creating new MatchHandler") return &MatchHandler{}, nil }); err != nil { logger.Error("Unable to register match: %v", err) return err } // Register matchmaker matched hook - when 4 players are matched, create an authoritative match if err := initializer.RegisterMatchmakerMatched(func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, entries []runtime.MatchmakerEntry) (string, error) { logger.Info("Matchmaker matched! Creating authoritative match for %d players", len(entries)) // Create an authoritative match using our custom handler matchId, err := nk.MatchCreate(ctx, "animal_minesweeper", nil) if err != nil { logger.Error("Failed to create match: %v", err) return "", err } logger.Info("Created authoritative match: %s", matchId) return matchId, nil }); err != nil { logger.Error("Unable to register matchmaker matched hook: %v", err) return err } logger.Error("Match registration completed successfully") return nil }