diff --git a/plugins/common/game.go b/plugins/common/game.go deleted file mode 100644 index fd3eda8..0000000 --- a/plugins/common/game.go +++ /dev/null @@ -1,27 +0,0 @@ -package game - -import ( - "context" - "encoding/json" - - "github.com/heroiclabs/nakama-common/runtime" -) - -type MovePayload struct { - Data map[string]interface{} `json:"data"` // arbitrary structure per game -} - -// GameRules defines game-specific mechanics. -// You implement this for TicTacToe, Chess, etc. -type GameRules interface { - MaxPlayers() int - - // ApplyMove modifies state, returns (stateChanged, gameOver, winnerIndex) - ApplyMove(state *MatchState, playerIdx int, payload MovePayload) (bool, bool, int) - - // Called when match starts and players are set. - AssignPlayerSymbols(players []*Player) - - // Called when match ends via forfeit. - ForfeitWinner(state *MatchState, leaverIndex int) int -} diff --git a/plugins/games/config.go b/plugins/games/config.go new file mode 100644 index 0000000..fa5c5d5 --- /dev/null +++ b/plugins/games/config.go @@ -0,0 +1,23 @@ +package modules + +type BoardConfig struct { + Rows int + Cols int +} + +type GameConfiguration struct { + Players int + Board BoardConfig +} + +// Static configuration for all supported games. +var GameConfig = map[string]GameConfiguration{ + "tictactoe": { + Players: 2, + Board: BoardConfig{Rows: 3, Cols: 3}, + }, + "battleship": { + Players: 2, + Board: BoardConfig{Rows: 10, Cols: 10}, + }, +} diff --git a/plugins/games/rules.go b/plugins/games/rules.go new file mode 100644 index 0000000..8e9e7ac --- /dev/null +++ b/plugins/games/rules.go @@ -0,0 +1,27 @@ +package games + +import "localrepo/plugins/structs" + +// MovePayload is used for incoming move data from clients. +type MovePayload struct { + Data map[string]interface{} `json:"data"` +} + +type GameRules interface { + // Number of players needed to start. + MaxPlayers() int + + // Assign symbols/colors/pieces at start. + AssignPlayerSymbols(players []*Player) + + // Apply a move. + // Returns: (changed, gameOver, winnerIndex) + ApplyMove(state *structs.MatchState, playerIdx int, payload MovePayload) (bool, bool, int) + + // If a player leaves, who wins? + // Return: + // >=0 → winner index + // -1 → draw + // -2 → invalid + ForfeitWinner(state *structs.MatchState, leaverIndex int) int +} diff --git a/plugins/modules/match.go b/plugins/modules/match.go index b071961..be7e171 100644 --- a/plugins/modules/match.go +++ b/plugins/modules/match.go @@ -7,8 +7,6 @@ import ( "fmt" "github.com/heroiclabs/nakama-common/runtime" - - // adjust these imports to match your actual module path / packages "localrepo/plugins/structs" "localrepo/plugins/games" ) @@ -21,13 +19,17 @@ const ( // GenericMatch is a match implementation that delegates game-specific logic // to a game.GameRules implementation chosen by the match params ("game"). type GenericMatch struct { - // registry maps game name -> GameRules implementation - Registry map[string]game.GameRules + GameName string + Mode string + Config games.GameConfiguration + Rules games.GameRules } // NewGenericMatch returns a factory function suitable for RegisterMatch. // Provide a registry mapping game names (strings) to implementations. -func NewGenericMatch(registry map[string]game.GameRules) func( +func NewGenericMatch( + registry map[string]games.GameRules +) func( ctx context.Context, logger runtime.Logger, db *sql.DB, @@ -35,7 +37,8 @@ func NewGenericMatch(registry map[string]game.GameRules) func( ) (runtime.Match, error) { return func(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule) (runtime.Match, error) { - return &GenericMatch{Registry: registry}, nil + // Empty match instance; MatchInit will fill real config. + return &GenericMatch{}, nil } } @@ -77,56 +80,54 @@ func (m *GenericMatch) MatchInit( params map[string]interface{}, ) (interface{}, int, string) { - // Determine requested game - gameName := "" - if g, ok := params["game"].(string); ok { - gameName = g + // ---- 1. game REQUIRED ---- + raw, ok := params["game"] + if !ok { + logger.Error("MatchInit ERROR: missing param 'game'") + return nil, 0, "" } - mode := "" - if md, ok := params["mode"].(string); ok { + gameName, ok := raw.(string) + if !ok || gameName == "" { + logger.Error("MatchInit ERROR: invalid 'game' param") + return nil, 0, "" + } + + cfg, found := games.GameConfig[gameName] + if !found { + logger.Error("MatchInit ERROR: game '%s' not in GameConfig", gameName) + return nil, 0, "" + } + + rules, found := games.RulesRegistry[gameName] + if !found { + logger.Warn("GenericMatch MatchInit: unknown game '%s' — match will start but no rules attached", gameName) + return nil, 0, "" + } + + // ---- 2. mode (optional) ---- + mode := "default" + if md, ok := params["mode"].(string); ok && md != "" { mode = md } - // Pick rules if registered - rules, found := m.Registry[gameName] - if !found { - logger.Warn("GenericMatch MatchInit: unknown game '%s' — match will start but no rules attached", gameName) - } + // ---- 3. build match instance fields ---- + m.GameName = gameName + m.Mode = mode + m.Config = cfg + m.Rules = rules - // board size fallback defaults (3x3). Games can re-initialize in Setup if required. - rows := 3 - cols := 3 - if r, ok := params["rows"].(float64); ok { // JSON numbers come as float64 - rows = int(r) - } - if c, ok := params["cols"].(float64); ok { - cols = int(c) - } - - // Build base state + // ---- 4. create initial state ---- state := &structs.MatchState{ Players: []*structs.Player{}, - Board: newEmptyBoard(rows, cols), + Board: newEmptyBoard(cfg.Board.Rows, cfg.Board.Cols), Turn: 0, Winner: -1, GameOver: false, } - label := "generic" - if gameName != "" { - label = fmt.Sprintf("%s:%s", gameName, mode) - } + label := fmt.Sprintf("%s:%s", m.GameName, m.Mode) - // store selected game in metadata so MatchLoop knows which rules to call - // We'll attach it to the state's "Players" metadata of a synthetic player at index - not ideal, - // so include in logger and rely on match params available in MatchInit -> MatchLoop via the match object instance. - // Note: Nakama doesn't pass params to MatchLoop directly; we keep chosen rules in the GenericMatch instance by mapping match label -> rules could be implemented, - // but to keep this simple, we'll rely on the convention that the match label contains the game name (above). - // Most importantly, store the chosen game in state via Player Metadata by creating a pseudo player entry (not visible to clients). - // However to keep MatchState pure, we instead keep the mapping in memory using the label. That requires matches to be one-per-factory instance, - // which is the case: each match instance is independent. - - logger.Info("MatchInit: game=%s mode=%s rows=%d cols=%d label=%s", gameName, mode, rows, cols, label) + logger.Info("MatchInit OK — game=%s mode=%s players=%d board=%dx%d", m.GameName, m.Mode, cfg.Players, cfg.Board.Rows, cfg.Board.Cols) // Store the gameName in the match's Registry via a reserved key? Simpler: keep label informative and rely on Registry lookup by gameName later. // Tick rate 5 (200ms) is a sensible default; can be tuned per game. @@ -148,16 +149,7 @@ func (m *GenericMatch) MatchJoinAttempt( s := state.(*structs.MatchState) - // We can't know the game name safely here (params not passed). We use MaxPlayers based on the first player's "game" property if provided in metadata. - // metadata may contain "game" from client at join time; fallback to 2 players if unknown. - maxPlayers := 2 - if gameName, ok := metadata["game"]; ok { - if rules, found := m.Registry[gameName]; found { - maxPlayers = rules.MaxPlayers() - } - } - - if len(s.Players) >= maxPlayers { + if len(s.Players) >= m.Config.Players { return s, false, "match full" } @@ -180,7 +172,6 @@ func (m *GenericMatch) MatchJoin( for _, p := range presences { userID := p.GetUserId() - // avoid duplicates if indexOfPlayerByID(s.Players, userID) != -1 { continue } @@ -189,74 +180,30 @@ func (m *GenericMatch) MatchJoin( if acc, err := nk.AccountGetId(ctx, userID); err == nil && acc != nil && acc.GetUser() != nil { username = acc.GetUser().GetUsername() } - player := &structs.Player{ + + s.Players = append(s.Players, &structs.Player{ UserID: userID, Username: username, Index: len(s.Players), - } - // ensure metadata map exists - player.Metadata = make(map[string]string) - - s.Players = append(s.Players, player) + Metadata: map[string]string{}, + }) } - logger.Info("MatchJoin: now %d players", len(s.Players)) + if len(s.Players) == m.Config.Players { + // Assign player symbols/colors/etc. + m.Rules.AssignPlayerSymbols(s.Players) - // determine game name from dispatcher match label if possible - // Nakama does not expose match params to MatchJoin; to keep things simple we infer from the match label which was set in MatchInit (format "game:mode") - // The runtime Match object cannot directly access that label; in practice you should pass the chosen game into the match via factory closure. - // For maximum compatibility, attempt to read a "game" key from the first player's metadata (clients should send it on join) - if len(s.Players) == 0 { - logger.Error("MatchLoop: no players in match") - return s - } - - gameName, ok := s.Players[0].Metadata["game"] - if !ok || gameName == "" { - logger.Error("MatchLoop: missing required metadata 'game'") - s.GameOver = true - s.Winner = -1 - return s - } - - rules, found := m.Registry[gameName] - if !found { - logger.Warn("MatchJoin: no rules registered for game '%s'", gameName) - } else { - if len(s.Players) == rules.MaxPlayers() { - // call AssignPlayerSymbols to allow games to annotate players (symbols, colors, etc.) - // convert []*structs.Player to []*game.Player if your Player type differs — here we assume same - // However earlier your Player resides in structs package; we call the game.AssignPlayerSymbols with structs players if compatible. - rules.AssignPlayerSymbols(convertToGamePlayers(s.Players)) - // Broadcast initial state - if data, err := json.Marshal(s); err == nil { - if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil { - logger.Error("BroadcastMessage (initial state) failed: %v", err) - } - } - } + // Broadcast initial state + if data, err := json.Marshal(s); err == nil { + if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil { + logger.Error("BroadcastMessage (initial state) failed: %v", err) + } + } } return s } -// convertToGamePlayers converts structs.Player -> game.Player if necessary. -// If your game package expects the same Player struct, this is a no-op conversion. -// We perform a shallow conversion assuming the fields are compatible. -func convertToGamePlayers(players []*structs.Player) []*game.Player { - out := make([]*game.Player, 0, len(players)) - for i, p := range players { - // create a game.Player with compatible fields. If game.Player has NewPlayer helper, consider using it. - out = append(out, &game.Player{ - UserID: p.UserID, - Username: p.Username, - Index: i, - Metadata: p.Metadata, - }) - } - return out -} - // MatchLeave: mark forfeit and call game.ForfeitWinner func (m *GenericMatch) MatchLeave( ctx context.Context, @@ -278,33 +225,12 @@ func (m *GenericMatch) MatchLeave( // determine leaving player index from presence list leaverIdx := -1 if len(presences) > 0 { - leaverID := presences[0].GetUserId() - leaverIdx = indexOfPlayerByID(s.Players, leaverID) + leaverIdx = indexOfPlayerByID(s.Players, presences[0].GetUserId()) } - // determine game name & rules as in Join (best-effort) - gameName := "tictactoe" - if len(s.Players) > 0 { - if gm, ok := s.Players[0].Metadata["game"]; ok && gm != "" { - gameName = gm - } - } - rules, found := m.Registry[gameName] - if found { - w := rules.ForfeitWinner(s, leaverIdx) - if w >= 0 { - s.Winner = w - s.GameOver = true - } else if w == -1 { - // draw - s.Winner = -1 - s.GameOver = true - } - } else { - // fallback: end match as forfeit - s.GameOver = true - s.Winner = -1 - } + winner := m.Rules.ForfeitWinner(s, leaverIdx) + s.Winner = winner + s.GameOver = true // broadcast final state if data, err := json.Marshal(s); err == nil { @@ -316,7 +242,12 @@ func (m *GenericMatch) MatchLeave( return s } -// MatchLoop: handle incoming move messages, delegate to the GameRules implementation +// +// ────────────────────────────────────────────────────────── +// MatchLoop +// ────────────────────────────────────────────────────────── +// + func (m *GenericMatch) MatchLoop( ctx context.Context, logger runtime.Logger, @@ -336,27 +267,12 @@ func (m *GenericMatch) MatchLoop( changed := false - // determine game/rules (best-effort same as other methods) - gameName := "tictactoe" - if len(s.Players) > 0 { - if gm, ok := s.Players[0].Metadata["game"]; ok && gm != "" { - gameName = gm - } - } - rules, found := m.Registry[gameName] - if !found { - // without rules we cannot apply moves - logger.Warn("MatchLoop: no rules registered for game '%s' -- ignoring messages", gameName) - return s - } - for _, msg := range messages { if msg.GetOpCode() != OpMove { continue } - // decode generic payload into MovePayload (map[string]interface{}) - var payload game.MovePayload + var payload games.MovePayload if err := json.Unmarshal(msg.GetData(), &payload); err != nil { logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err) continue @@ -385,11 +301,9 @@ func (m *GenericMatch) MatchLoop( s.GameOver = true s.Winner = winnerIdx } else { - // rotate to next player if game not over - if len(s.Players) > 0 { - s.Turn = (s.Turn + 1) % len(s.Players) - } + s.Turn = (s.Turn + 1) % len(s.Players) } + } // optional: if gameOver and winnerIdx >= 0, write leaderboard here if desired // This code left intentionally out — you can add leaderboard writes by asking for that feature.