1194 lines
37 KiB
Go
1194 lines
37 KiB
Go
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
|
||
}
|