package modules import ( "context" "database/sql" "encoding/json" "fmt" "github.com/heroiclabs/nakama-common/runtime" // adjust these imports to match your actual module path / packages "localrepo/plugins/structs" "localrepo/plugins/games" ) const ( OpMove int64 = 1 OpState int64 = 2 ) // 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 } // 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( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, ) (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 } } // ------------------------- // Helpers // ------------------------- func indexOfPlayerByID(players []*structs.Player, userID string) int { for i, p := range players { if p.UserID == userID { return i } } return -1 } func newEmptyBoard(rows, cols int) *structs.Board { b := &structs.Board{ Rows: rows, Cols: cols, Grid: make([][]string, rows), } for r := 0; r < rows; r++ { b.Grid[r] = make([]string, cols) } return b } // ------------------------- // Match interface methods // ------------------------- // MatchInit: create initial state. Expects params to include "game" (string) and optionally "mode" and board size. func (m *GenericMatch) MatchInit( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}, ) (interface{}, int, string) { // Determine requested game gameName := "" if g, ok := params["game"].(string); ok { gameName = g } mode := "" if md, ok := params["mode"].(string); ok { 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) } // 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 state := &structs.MatchState{ Players: []*structs.Player{}, Board: newEmptyBoard(rows, cols), Turn: 0, Winner: -1, GameOver: false, } label := "generic" if gameName != "" { label = fmt.Sprintf("%s:%s", gameName, 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) // 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. return state, 5, label } // MatchJoinAttempt: basic capacity check using rules.MaxPlayers() func (m *GenericMatch) 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) { 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 { return s, false, "match full" } return s, true, "" } // MatchJoin: add players, fetch usernames, assign indices; when full, call rules.AssignPlayerSymbols func (m *GenericMatch) MatchJoin( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence, ) interface{} { s := state.(*structs.MatchState) for _, p := range presences { userID := p.GetUserId() // avoid duplicates if indexOfPlayerByID(s.Players, userID) != -1 { continue } username := "" if acc, err := nk.AccountGetId(ctx, userID); err == nil && acc != nil && acc.GetUser() != nil { username = acc.GetUser().GetUsername() } player := &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) } logger.Info("MatchJoin: now %d players", len(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) } } } } 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, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, presences []runtime.Presence, ) interface{} { s := state.(*structs.MatchState) if s.GameOver { return s } // determine leaving player index from presence list leaverIdx := -1 if len(presences) > 0 { leaverID := presences[0].GetUserId() leaverIdx = indexOfPlayerByID(s.Players, leaverID) } // 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 } // broadcast final state if data, err := json.Marshal(s); err == nil { if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil { logger.Error("BroadcastMessage (forfeit) failed: %v", err) } } return s } // MatchLoop: handle incoming move messages, delegate to the GameRules implementation func (m *GenericMatch) MatchLoop( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, messages []runtime.MatchData, ) interface{} { s := state.(*structs.MatchState) if s.GameOver { return s } 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 if err := json.Unmarshal(msg.GetData(), &payload); err != nil { logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err) continue } playerID := msg.GetUserId() playerIdx := indexOfPlayerByID(s.Players, playerID) if playerIdx == -1 { logger.Warn("Move rejected: unknown player %s", playerID) continue } if playerIdx != s.Turn { logger.Warn("Move rejected: not player's turn (idx=%d turn=%d)", playerIdx, s.Turn) continue } // Delegate to rules.ApplyMove which returns (changed, gameOver, winnerIndex) stateChanged, gameOver, winnerIdx := rules.ApplyMove(s, playerIdx, payload) if stateChanged { changed = true } if gameOver { 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) } } // 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. } if changed { if data, err := json.Marshal(s); err == nil { if err := dispatcher.BroadcastMessage(OpState, data, nil, nil, true); err != nil { logger.Error("BroadcastMessage failed: %v", err) } } else { logger.Error("Failed to marshal state: %v", err) } } return s } // MatchTerminate func (m *GenericMatch) MatchTerminate( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, graceSeconds int, ) interface{} { logger.Info("MatchTerminate: grace=%d", graceSeconds) return state } // MatchSignal func (m *GenericMatch) MatchSignal( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, dispatcher runtime.MatchDispatcher, tick int64, state interface{}, data string, ) (interface{}, string) { logger.Debug("MatchSignal: %s", data) return state, "" }