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 { 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) { // Empty match instance; MatchInit will fill real config. return &GenericMatch{}, 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) { // ---- 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, "" } 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 } // ---- 3. build match instance fields ---- m.GameName = gameName m.Mode = mode m.Config = cfg m.Rules = rules // ---- 4. create initial state ---- 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) // 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) 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{}, }) } if len(s.Players) == m.Config.Players { // Assign player symbols/colors/etc. 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) } } } 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()) } winner := m.Rules.ForfeitWinner(s, leaverIdx) s.Winner = winner s.GameOver = true // 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 // ────────────────────────────────────────────────────────── // 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 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 } 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 { 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, "" }