package modules import ( "context" "database/sql" "encoding/json" "fmt" "github.com/heroiclabs/nakama-common/runtime" "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 provided when creating the match factory. Keeps available rules. Registry map[string]games.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]games.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) { // The factory stores the registry on each match instance so MatchInit can use it. 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". func (m *GenericMatch) MatchInit( ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, params map[string]interface{}, ) (interface{}, int, string) { // ---- 1. game REQUIRED ---- raw, ok := params["game"] if !ok { logger.Error("MatchInit ERROR: missing param 'game'") return nil, 0, "" } gameName, ok := raw.(string) if !ok || gameName == "" { logger.Error("MatchInit ERROR: invalid 'game' param") return nil, 0, "" } // ---- 2. config lookup ---- cfg, found := games.GameConfig[gameName] if !found { logger.Error("MatchInit ERROR: game '%s' not in GameConfig", gameName) return nil, 0, "" } // ---- 3. rules lookup from registry (factory-provided) ---- var rules games.GameRules if m.Registry != nil { if r, ok := m.Registry[gameName]; ok { rules = r } } if rules == nil { // no rules — abort match creation logger.Error("MatchInit ERROR: no rules registered for game '%s'", gameName) return nil, 0, "" } // ---- 4. mode (optional) ---- mode := "default" if md, ok := params["mode"].(string); ok && md != "" { mode = md } // ---- 5. build match instance fields ---- m.GameName = gameName m.Mode = mode m.Config = cfg m.Rules = rules // ---- 6. create initial state (board from config) ---- state := &structs.MatchState{ Players: []*structs.Player{}, Board: newEmptyBoard(cfg.Board.Rows, cfg.Board.Cols), Turn: 0, Winner: -1, GameOver: false, } label := fmt.Sprintf("%s:%s", m.GameName, m.Mode) 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) // Tick rate 5 (200ms) is a sensible default; can be tuned per game. return state, 5, label } // MatchJoinAttempt: basic capacity check using config.Players 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) if m.Config.Players <= 0 { // defensive: require init to have populated config logger.Error("MatchJoinAttempt ERROR: match config not initialized") return s, false, "server error" } if len(s.Players) >= m.Config.Players { 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() 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() } s.Players = append(s.Players, &structs.Player{ UserID: userID, Username: username, Index: len(s.Players), Metadata: map[string]string{}, }) } logger.Info("MatchJoin: now %d players (need %d)", len(s.Players), m.Config.Players) if m.Rules != nil && len(s.Players) == m.Config.Players { // Assign player symbols/colors/etc. Pass structs.Player directly. m.Rules.AssignPlayerSymbols(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) } } else { logger.Error("Failed to marshal initial state: %v", err) } } return s } // 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 { leaverIdx = indexOfPlayerByID(s.Players, presences[0].GetUserId()) } if m.Rules != nil { winner := m.Rules.ForfeitWinner(s, leaverIdx) s.Winner = winner 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) } } else { logger.Error("Failed to marshal forfeit state: %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 if m.Rules == nil { logger.Warn("MatchLoop: no rules present for game '%s' -- ignoring messages", m.GameName) return s } for _, msg := range messages { if msg.GetOpCode() != OpMove { continue } 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 } playerID := msg.GetUserId() playerIdx := indexOfPlayerByID(s.Players, playerID) if playerIdx == -1 { logger.Warn("Move rejected: unknown player %s", playerID) continue } // Turn enforcement — keep this here for turn-based games. If you want per-game control, // move this check into the game's ApplyMove implementation or toggle via config. 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 := m.Rules.ApplyMove(s, playerIdx, payload) if stateChanged { changed = true } if gameOver { s.GameOver = true s.Winner = winnerIdx } else { if len(s.Players) > 0 { s.Turn = (s.Turn + 1) % len(s.Players) } } } // Optional: handle leaderboard logic here if needed 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, "" }