game/server/main.go
2026-01-01 02:21:09 +08:00

1194 lines
37 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}