feat: refactor Nakama plugin into generic multi-game match engine
### Highlights
- Introduced generic match engine (`generic_match.go`) implementing dynamic GameRules-based runtime.
- Added modular structure under `/plugins`:
- /plugins/game → GameRules interface + TicTacToe + Battleship rule sets
- /plugins/structs → Board, Player, MatchState generic structs
- /plugins/modules → matchmaking + RPC handlers + match engine
- Migrated TicTacToe logic into reusable rule implementation.
- Added Battleship game support using same engine.
- Updated matchmaking to accept { game, mode } for multi-game routing.
- Updated UI contract: clients must send `game` (and optional `mode`) when joining matchmaking.
- Removed hardcoded TicTacToe match registration.
- Registered a single “generic” authoritative match with ruleset registry.
- Normalized imports under local dev module path.
- Ensured MatchState and Board are now generic and reusable across games.
- Added strict requirement for `game` metadata in match flow (error if missing).
- Cleaned initial state creation into MatchInit with flexible board dimensions.
- Improved MatchLeave for proper forfeit handling through GameRules.
### Result
The server now supports an unlimited number of turn-based board games
via swappable rulesets while keeping a single authoritative Nakama match loop.
This commit is contained in:
101
plugins/modules/matchmaking.go
Normal file
101
plugins/modules/matchmaking.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package modules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/heroiclabs/nakama-common/runtime"
|
||||
)
|
||||
|
||||
type MatchmakingTicket struct {
|
||||
Game string `json:"game"` // e.g. "tictactoe", "battleship"
|
||||
Mode string `json:"mode"` // e.g. "classic", "ranked", "blitz"
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// GENERIC MATCHMAKER — Supports ALL Games & Modes
|
||||
// --------------------------------------------------
|
||||
|
||||
func MatchmakerMatched(
|
||||
ctx context.Context,
|
||||
logger runtime.Logger,
|
||||
db *sql.DB,
|
||||
nk runtime.NakamaModule,
|
||||
entries []runtime.MatchmakerEntry,
|
||||
) (string, error) {
|
||||
|
||||
if len(entries) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Extract the first player's desired properties
|
||||
props0 := entries[0].GetProperties()
|
||||
|
||||
game, okGame := props0["game"].(string)
|
||||
mode, okMode := props0["mode"].(string)
|
||||
|
||||
if !okGame || !okMode {
|
||||
logger.Warn("MatchmakerMatched: Missing 'game' or 'mode' properties.")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// Ensure ALL players match game + mode
|
||||
for _, e := range entries {
|
||||
p := e.GetProperties()
|
||||
|
||||
g, okG := p["game"].(string)
|
||||
m, okM := p["mode"].(string)
|
||||
|
||||
if !okG || !okM || g != game || m != mode {
|
||||
logger.Warn("MatchmakerMatched: Player properties do not match — retrying matchmaking.")
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create the correct authoritative match handler.
|
||||
// This depends on how "game" was registered in main.go.
|
||||
// Example: initializer.RegisterMatch("tictactoe", NewGenericMatch(TicTacToeRules))
|
||||
matchParams := map[string]interface{}{
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
matchID, err := nk.MatchCreate(ctx, game, matchParams)
|
||||
if err != nil {
|
||||
logger.Error("MatchmakerMatched: MatchCreate failed: %v", err)
|
||||
return "", runtime.NewError("failed to create match", 13)
|
||||
}
|
||||
|
||||
logger.Info("✔ Match created game=%s mode=%s id=%s", game, mode, matchID)
|
||||
return matchID, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------
|
||||
// RPC: Leave matchmaking (generic cancel API)
|
||||
// --------------------------------------------------
|
||||
|
||||
func RpcLeaveMatchmaking(
|
||||
ctx context.Context,
|
||||
logger runtime.Logger,
|
||||
db *sql.DB,
|
||||
nk runtime.NakamaModule,
|
||||
payload string,
|
||||
) (string, error) {
|
||||
|
||||
var input struct {
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(payload), &input); err != nil {
|
||||
return "", runtime.NewError("invalid JSON", 3)
|
||||
}
|
||||
|
||||
if input.Ticket == "" {
|
||||
return "", runtime.NewError("missing ticket", 3)
|
||||
}
|
||||
|
||||
// Client removes ticket locally — server doesn't need to do anything
|
||||
logger.Info("✔ Player left matchmaking: ticket=%s", input.Ticket)
|
||||
|
||||
return "{}", nil
|
||||
}
|
||||
Reference in New Issue
Block a user