10 Commits

Author SHA1 Message Date
f1e85a72dd feat(rules): add game-specific metadata attachment and unify match metadata initialization
Added AttachGameMetadata to GameRules interface

Implemented metadata setup for Battleship (phase + readiness flags)

Implemented no-op metadata hook for TicTacToe

Moved generic phase/ready metadata out of MatchInit

Added game/mode metadata to match state

Fixed json:"metadata" tag in MatchState
2025-12-03 22:02:24 +05:30
0562d1e0c9 feat(battleship): add full placement/battle phases + metadata support + action-based moves
- Added Battleship Fleet definition (carrier, battleship, cruiser, submarine, destroyer)
- Implemented action-based MovePayload (`action: "place" | "shoot"`)
- Added placement and shot validation (ValidatePlacementMove, ValidateShotMove)
- Added ApplyPlacement and ApplyShot with correct ship placement + hit/miss logic
- Added pX_placed, pX_ready tracking and phase switching (placement → battle)
- Added Metadata field to MatchState (for phase/ready tracking)
- Updated MatchInit to initialize placement phase and readiness flags
- Updated MatchLoop to enforce turn order only during battle phase
- Added debug logging for state broadcasts
- Fixed protobuf dependency marking as indirect
2025-12-03 21:00:38 +05:30
10c7933aca refactor to tictactoe for standardisation of game names 2025-12-03 18:44:09 +05:30
d75dcd3c74 feat(battleship,tictactoe,engine): add multi-board support and keepTurn logic
### Core Engine
- Updated `GameRules.ApplyMove` to return `(changed, gameOver, winnerIdx, keepTurn)`
- Added keepTurn handling in `MatchLoop` to support Battleship mode B (classic rules)
- Removed old single-board handling from MatchState and MatchInit
- Cleaned go.mod by marking protobuf dependency as indirect

### Battleship
- Implemented board-based state tracking using MatchState.Boards:
  - `p0_ships`, `p0_shots`, `p1_ships`, `p1_shots`
- Removed legacy metadata-based ship/shot board encoding
- Rewrote ValidateMove to use structured boards
- Rewrote ApplyMove for classic Battleship rules (mode B):
  - Hits allow the attacker to keep their turn
  - Miss switches turn
  - Destroyed ship sections marked `X`
- Improved CheckGameOver using structured boards

### TicTacToe
- Updated ApplyMove signature to match new interface
- Ensured TicTacToe always returns `keepTurn = false`
- Updated code paths to use MatchState.Boards instead of Board

### Summary
This commit completes the migration from a single-board architecture to a
multi-board architecture across the engine, TicTacToe, and Battleship, enabling
support for more complex games and multiple modes such as Battleship Mode B.
2025-12-03 18:09:49 +05:30
bcdc5faea5 feat(core): migrate to multi-board architecture and implement per-game InitBoards
### Major changes
- Replace single-board MatchState (`Board`) with multi-board map (`Boards`)
- Update GenericMatch to initialize empty Boards and populate them via GameRules.InitBoards
- Remove legacy `newEmptyBoard` helper from match.go
- Update .gitignore to include *BKP* patterns

### GameRules interface
- Add InitBoards(players, cfg) to allow games to construct their own board sets
- Add detailed documentation explaining method responsibilities and usage
- Improve MovePayload comment clarity

### TicTacToe updates
- Implement InitBoards to produce a single `"tictactoe"` board
- Replace all old references to `state.Board` with `state.Boards["tictactoe"]`
- Make CheckGameOver, ValidateMove, and ApplyMove multi-board compatible

### Battleship updates
- Implement InitBoards generating per-player ships + shots boards:
  - p0_ships, p0_shots
  - p1_ships, p1_shots

### Match flow updates
- Boards are now created only when all players have joined
- Initial state broadcast now includes `boards` instead of `board`

This completes the backend migration for multi-board games and prepares the architecture
for Battleship and other complex board-based games.
2025-12-03 17:52:27 +05:30
1c31c489c7 fixes
All checks were successful
continuous-integration/drone/tag Build is passing
2025-12-01 18:27:19 +05:30
3eadb49a72 feat(games): unify ApplyMove signatures + remove unused player conversion
Updated TicTacToeRules and BattleshipRules to implement ApplyMove(state, playerIdx, payload) (bool, bool, int) as required by GameRules.

Added win/draw resolution logic directly inside each game’s ApplyMove return.

Removed obsolete convertToGamePlayers helper.

Updated GenericMatch to call AssignPlayerSymbols with []*structs.Player directly.

Ensured all rule implementations now fully satisfy the GameRules interface.
2025-12-01 17:02:57 +05:30
3c81a8bf29 fixed package 2025-12-01 16:18:22 +05:30
d9c3ecb252 refactor(server): move shared game logic into games/ and introduce GameConfig system
- Moved common/game.go → plugins/games/rules.go
  - Contains GameRules interface, MovePayload, Player abstraction
  - Centralizes all reusable game rule contract logic

- Added plugins/games/config.go
  - Introduces GameConfiguration struct (players, board size, etc.)
  - Added global GameConfig and RulesRegistry maps
  - Each game (tictactoe, battleship, etc.) now registers its config + rules

- Updated generic_match.go to use:
  - GameConfig for board/players initialization
  - RulesRegistry for rule lookup during MatchInit
  - Removed hardcoded TicTacToe behavior
  - Clean error returns when game param missing or invalid

- Updated folder structure:
    /plugins/
        /games
            rules.go       (formerly common/game.go)
            config.go
            tictactoe.go
            battleship.go
        /structs
        /modules
        main.go

- Ensures GenericMatch pulls:
    m.GameName, m.Mode, m.Config, m.Rules
  directly from config/registry at MatchInit

- Removes old duplicated logic and simplifies how games are registered
2025-12-01 16:05:53 +05:30
eeb0a8175f 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.
2025-12-01 15:28:54 +05:30
13 changed files with 1241 additions and 509 deletions

4
go.mod
View File

@@ -1,7 +1,7 @@
module git.aetoskia.com/lila-games/tic-tac-toe
module localrepo
go 1.21
require github.com/heroiclabs/nakama-common v1.31.0
require google.golang.org/protobuf v1.31.0 // indirect
require google.golang.org/protobuf v1.31.0

354
plugins/games/battleship.go Normal file
View File

@@ -0,0 +1,354 @@
package games
import (
"fmt"
"encoding/json"
"localrepo/plugins/structs"
)
var Fleet = map[string]int{
"carrier": 5,
"battleship": 4,
"cruiser": 3,
"submarine": 3,
"destroyer": 2,
}
//
// BATTLESHIP RULES IMPLEMENTATION
//
// NOTES:
// - 2 players
// - Each player has 2 boards:
// 1. Their own ship board (state.Board is not reused here)
// 2. Their "shots" board (hits/misses on opponent)
// - We store boards in Player.Metadata as JSON strings
// (simplest method without changing your structs).
//
// ShipBoard and ShotBoard are encoded inside Metadata:
//
// Metadata["ship_board"] = JSON string of [][]string
// Metadata["shot_board"] = JSON string of [][]string
//
// ------------------------------
// Helpers: encode/decode
// ------------------------------
func encodeBoard(b [][]string) string {
out := "["
for i, row := range b {
out += "["
for j, col := range row {
out += fmt.Sprintf("%q", col)
if j < len(row)-1 {
out += ","
}
}
out += "]"
if i < len(b)-1 {
out += ","
}
}
out += "]"
return out
}
func decodeBoard(s string) [][]string {
var out [][]string
// should never fail; safe fallback
_ = json.Unmarshal([]byte(s), &out)
return out
}
// ------------------------------
// BattleshipRules
// ------------------------------
type BattleshipRules struct{}
func (b *BattleshipRules) MaxPlayers() int { return 2 }
func (b *BattleshipRules) InitBoards(players []*structs.Player, cfg GameConfiguration) map[string]*structs.Board {
boards := make(map[string]*structs.Board)
// One ships board and one shots board per player
for _, p := range players {
pid := fmt.Sprintf("p%d", p.Index)
// Player's fleet board (ships placement)
boards[pid+"_ships"] = structs.NewBoard(cfg.Board.Rows, cfg.Board.Cols)
// Player's attack tracking board (shots fired at opponent)
boards[pid+"_shots"] = structs.NewBoard(cfg.Board.Rows, cfg.Board.Cols)
}
return boards
}
// ------------------------------
// Assign player boards
// ------------------------------
func (b *BattleshipRules) AssignPlayerSymbols(players []*structs.Player) {
// nothing needed for battleship
}
// ------------------------------
// Attach Game Metadata
// ------------------------------
func (b *BattleshipRules) AttachGameMetadata(state *structs.MatchState) {
state.Metadata["phase"] = "placement"
state.Metadata["p0_ready"] = false
state.Metadata["p1_ready"] = false
}
// ------------------------------
// ValidateMove
// payload.data = { "row": int, "col": int }
// ------------------------------
func (b *BattleshipRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
switch payload.Action {
case "place":
return b.ValidatePlacementMove(state, playerIdx, payload)
case "shoot":
return b.ValidateShotMove(state, playerIdx, payload)
default:
return false
}
}
func (b *BattleshipRules) ValidatePlacementMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
// Allow placement until player placed all ships
if state.Metadata["phase"] != "placement" {
return false
}
key := fmt.Sprintf("p%d_placed", playerIdx)
placed := 0
if state.Metadata[key] != nil {
placed = state.Metadata[key].(int)
}
return placed < len(Fleet)
}
func (b *BattleshipRules) ValidateShotMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
if state.Metadata["phase"] != "battle" {
return false
}
rf, ok1 := payload.Data["row"].(float64)
cf, ok2 := payload.Data["col"].(float64)
if !ok1 || !ok2 {
return false
}
r := int(rf)
c := int(cf)
shotKey := fmt.Sprintf("p%d_shots", playerIdx)
shotBoard := state.Boards[shotKey]
if shotBoard == nil {
return false
}
if !shotBoard.InBounds(r, c) {
return false
}
if !shotBoard.IsEmpty(r, c) { // already shot
return false
}
return true
}
// -----------------------------
// APPLY MOVE (MODE B — CLASSIC)
// -----------------------------
func (b *BattleshipRules) ApplyShot(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int, bool) {
if !b.bothPlayersReady(state) {
return false, false, -1, false
}
r := int(payload.Data["row"].(float64))
c := int(payload.Data["col"].(float64))
shotKey := fmt.Sprintf("p%d_shots", playerIdx)
shipKey := fmt.Sprintf("p%d_ships", 1-playerIdx)
shots := state.Boards[shotKey]
ships := state.Boards[shipKey]
hit := false
if ships.Get(r, c) == "S" {
// hit
hit = true
shots.Set(r, c, "H")
ships.Set(r, c, "X") // mark destroyed section
} else {
shots.Set(r, c, "M")
}
// check game over
over, winner := b.CheckGameOver(state)
// keepTurn = hit (classic rule)
return true, over, winner, hit
}
func (b *BattleshipRules) ApplyMove(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int, bool) {
switch payload.Action {
case "place":
return b.ApplyPlacement(state, playerIdx, payload)
case "shoot":
return b.ApplyShot(state, playerIdx, payload)
default:
return false, false, -1, false
}
}
func (b *BattleshipRules) ApplyPlacement(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int, bool) {
shipName, _ := payload.Data["ship"].(string)
rf, _ := payload.Data["row"].(float64)
cf, _ := payload.Data["col"].(float64)
dir, _ := payload.Data["dir"].(string)
r := int(rf)
c := int(cf)
size, ok := Fleet[shipName]
if !ok {
return false, false, -1, false // invalid ship name
}
shipKey := fmt.Sprintf("p%d_ships", playerIdx)
shipBoard := state.Boards[shipKey]
// Validate placement
if !b.validatePlacement(shipBoard, r, c, size, dir) {
return false, false, -1, false
}
// Place the ship
if dir == "h" {
for i := 0; i < size; i++ {
shipBoard.Set(r, c+i, "S")
}
} else { // vertical
for i := 0; i < size; i++ {
shipBoard.Set(r+i, c, "S")
}
}
// Track ships placed by player
placedCountKey := fmt.Sprintf("p%d_placed", playerIdx)
count := state.Metadata[placedCountKey]
if count == nil {
state.Metadata[placedCountKey] = 1
} else {
state.Metadata[placedCountKey] = count.(int) + 1
}
// If all 5 ships placed → ready
if state.Metadata[placedCountKey].(int) == len(Fleet) {
readyKey := fmt.Sprintf("p%d_ready", playerIdx)
state.Metadata[readyKey] = true
}
// Check if both players are ready
if b.bothPlayersReady(state) {
state.Metadata["phase"] = "battle"
}
return true, false, -1, false
}
func (b *BattleshipRules) validatePlacement(board *structs.Board, r, c, size int, dir string) bool {
rows, cols := board.Rows, board.Cols
if dir == "h" {
if c+size > cols {
return false
}
for i := 0; i < size; i++ {
if board.Get(r, c+i) != "" {
return false
}
}
} else {
if r+size > rows {
return false
}
for i := 0; i < size; i++ {
if board.Get(r+i, c) != "" {
return false
}
}
}
return true
}
func (b *BattleshipRules) bothPlayersReady(state *structs.MatchState) bool {
r0 := state.Metadata["p0_ready"]
r1 := state.Metadata["p1_ready"]
return r0 == true && r1 == true
}
// ------------------------------
// CheckGameOver
// ------------------------------
func (b *BattleshipRules) CheckGameOver(state *structs.MatchState) (bool, int) {
for i := range state.Players {
shipKey := fmt.Sprintf("p%d_ships", i)
ships := state.Boards[shipKey]
alive := false
for r := 0; r < ships.Rows; r++ {
for c := 0; c < ships.Cols; c++ {
if ships.Get(r, c) == "S" {
alive = true
break
}
}
if alive {
break
}
}
if !alive {
// this player has no ships left → opponent wins
return true, 1 - i
}
}
return false, -1
}
// ------------------------------
// Forfeit Winner
// ------------------------------
func (b *BattleshipRules) ForfeitWinner(state *structs.MatchState, leaverIndex int) int {
// If player leaves, opponent automatically wins.
if leaverIndex == 0 {
return 1
}
if leaverIndex == 1 {
return 0
}
return -1
}

23
plugins/games/config.go Normal file
View File

@@ -0,0 +1,23 @@
package games
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},
},
}

53
plugins/games/rules.go Normal file
View File

@@ -0,0 +1,53 @@
package games
import "localrepo/plugins/structs"
// MovePayload is the decoded payload sent from clients.
// It is intentionally untyped (map[string]interface{}) so each game
// can define its own move structure (e.g., row/col, coordinate, action type, etc.)
type MovePayload struct {
Action string `json:"action"` // "place" or "shoot"
Data map[string]interface{} `json:"data"`
}
// GameRules defines a generic interface for match logic.
//
// Each game (TicTacToe, Battleship, Chess, etc.) must implement this interface.
// The Nakama match handler delegates all game-specific behavior to these methods.
type GameRules interface {
// Number of players needed to start.
MaxPlayers() int
// Assign symbols/colors/pieces at start.
AssignPlayerSymbols(players []*structs.Player)
// Attach Game Metadata
AttachGameMetadata(state *structs.MatchState)
// Apply a move.
// Returns: (changed, gameOver, winnerIndex)
ApplyMove(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (changed bool, gameOver bool, winnerIdx int, keepTurn bool)
// If a player leaves, who wins?
// Return:
// >=0 → winner index
// -1 → draw
// -2 → invalid
ForfeitWinner(state *structs.MatchState, leaverIndex int) int
// InitBoards initializes all the boards required for the game.
//
// This is called AFTER all players have joined the match.
//
// Examples:
// - TicTacToe → 1 board shared by both players: {"tictactoe": 3x3}
// - Battleship → 2 boards per player:
// {"p0_ships":10x10, "p0_shots":10x10, "p1_ships":..., "p1_shots":...}
//
// The returned map is stored in MatchState.Boards.
InitBoards(players []*structs.Player, cfg GameConfiguration) map[string]*structs.Board
}

176
plugins/games/tictactoe.go Normal file
View File

@@ -0,0 +1,176 @@
package games
import (
"localrepo/plugins/structs"
)
// TicTacToeRules implements GameRules for 3x3 Tic Tac Toe.
type TicTacToeRules struct{}
// -------------------------------
// GameRules Implementation
// -------------------------------
func (t *TicTacToeRules) MaxPlayers() int {
return 2
}
func (t *TicTacToeRules) InitBoards(players []*structs.Player, cfg GameConfiguration) map[string]*structs.Board {
return map[string]*structs.Board{
"tictactoe": structs.NewBoard(cfg.Board.Rows, cfg.Board.Cols),
}
}
// Assign player symbols: X and O
func (t *TicTacToeRules) AssignPlayerSymbols(players []*structs.Player) {
if len(players) < 2 {
return
}
players[0].Metadata["symbol"] = "X"
players[1].Metadata["symbol"] = "O"
}
// ------------------------------
// Attach Game Metadata
// ------------------------------
func (b *TicTacToeRules) AttachGameMetadata(state *structs.MatchState) {
// nothing needed for tictactoe
}
// ValidateMove checks bounds and empty cell.
func (t *TicTacToeRules) ValidateMove(state *structs.MatchState, playerIdx int, payload MovePayload) bool {
rowVal, ok1 := payload.Data["row"]
colVal, ok2 := payload.Data["col"]
if !ok1 || !ok2 {
return false
}
row, ok3 := rowVal.(float64)
col, ok4 := colVal.(float64)
if !ok3 || !ok4 {
return false
}
r := int(row)
c := int(col)
b := state.Boards["tictactoe"]
if b == nil {
return false
}
// bounds
if !b.InBounds(r, c) {
return false
}
// empty?
return b.IsEmpty(r, c)
}
// ApplyMove writes X or O to the board.
func (t *TicTacToeRules) ApplyMove(
state *structs.MatchState,
playerIdx int,
payload MovePayload,
) (bool, bool, int, bool) {
b := state.Boards["tictactoe"]
if b == nil {
return false, false, -1, false
}
symbol := state.Players[playerIdx].Metadata["symbol"]
r := int(payload.Data["row"].(float64))
c := int(payload.Data["col"].(float64))
b.Set(r, c, symbol)
over, winner := t.CheckGameOver(state)
return true, over, winner, false
}
// CheckGameOver determines win/draw state.
func (t *TicTacToeRules) CheckGameOver(state *structs.MatchState) (bool, int) {
b := state.Boards["tictactoe"]
if b == nil {
return true, -1 // fallback safety
}
winnerSymbol := t.findWinner(b)
if winnerSymbol != "" {
// find the player with this symbol
for _, p := range state.Players {
if p.Metadata["symbol"] == winnerSymbol {
return true, p.Index
}
}
return true, -1
}
if b.Full() {
return true, -1 // draw
}
return false, -1
}
// OnForfeit: whoever leaves loses instantly
func (t *TicTacToeRules) ForfeitWinner(state *structs.MatchState, leaverIndex int) int {
// If player 0 leaves, player 1 wins.
if leaverIndex == 0 && len(state.Players) > 1 {
return 1
}
// If player 1 leaves, player 0 wins.
if leaverIndex == 1 && len(state.Players) > 0 {
return 0
}
// Otherwise draw.
return -1
}
// -------------------------------
// Helper: winner detection
// -------------------------------
func (t *TicTacToeRules) findWinner(b *structs.Board) string {
lines := [][][2]int{
// rows
{{0, 0}, {0, 1}, {0, 2}},
{{1, 0}, {1, 1}, {1, 2}},
{{2, 0}, {2, 1}, {2, 2}},
// cols
{{0, 0}, {1, 0}, {2, 0}},
{{0, 1}, {1, 1}, {2, 1}},
{{0, 2}, {1, 2}, {2, 2}},
// diagonals
{{0, 0}, {1, 1}, {2, 2}},
{{0, 2}, {1, 1}, {2, 0}},
}
for _, line := range lines {
r1, c1 := line[0][0], line[0][1]
r2, c2 := line[1][0], line[1][1]
r3, c3 := line[2][0], line[2][1]
v1 := b.Get(r1, c1)
if v1 != "" &&
v1 == b.Get(r2, c2) &&
v1 == b.Get(r3, c3) {
return v1
}
}
return ""
}

View File

@@ -5,21 +5,12 @@ import (
"database/sql"
"github.com/heroiclabs/nakama-common/runtime"
// Project modules
"localrepo/plugins/modules"
"localrepo/plugins/games"
)
// Example RPC
func HelloWorld(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
payload string,
) (string, error) {
logger.Info("HelloWorld RPC called — payload: %s", payload)
return `{"message": "Hello from Go RPC!"}`, nil
}
// Required module initializer
func InitModule(
ctx context.Context,
logger runtime.Logger,
@@ -27,39 +18,70 @@ func InitModule(
nk runtime.NakamaModule,
initializer runtime.Initializer,
) error {
if err := initializer.RegisterRpc("hello_world", HelloWorld); err != nil {
logger.Error("Failed to register RPC: %v", err)
return err
}
if err := initializer.RegisterMatch("tictactoe", NewMatch); err != nil {
logger.Error("Failed to register RPC: %v", err)
return err
}
// Match making
if err := initializer.RegisterRpc("leave_matchmaking", rpcLeaveMatchmaking); err != nil {
logger.Error("RegisterRpc leave_matchmaking failed: %v", err)
return err
}
if err := initializer.RegisterMatchmakerMatched(MatchmakerMatched); err != nil {
logger.Error("RegisterMatchmakerMatched failed: %v", err)
//--------------------------------------------------------
// 1. Register RPCs
//--------------------------------------------------------
if err := initializer.RegisterRpc("leave_matchmaking", modules.RpcLeaveMatchmaking); err != nil {
logger.Error("Failed to register RPC leave_matchmaking: %v", err)
return err
}
//--------------------------------------------------------
// 2. Register Matchmaker Handler
//--------------------------------------------------------
if err := initializer.RegisterMatchmakerMatched(modules.MatchmakerMatched); err != nil {
logger.Error("Failed to register MatchmakerMatched: %v", err)
return err
}
//--------------------------------------------------------
// 3. Register ALL game rules for GenericMatch
//--------------------------------------------------------
registry := map[string]games.GameRules{
"tictactoe": &games.TicTacToeRules{},
"battleship": &games.BattleshipRules{},
}
if err := initializer.RegisterMatch(
"generic",
modules.NewGenericMatch(registry),
); err != nil {
logger.Error("Failed to register generic match: %v", err)
return err
}
//--------------------------------------------------------
// 4. Create Leaderboards
//--------------------------------------------------------
leaderboards := []string{
"tictactoe_classic",
"tictactoe_ranked",
"battleship_classic",
"battleship_ranked",
}
for _, lb := range leaderboards {
err := nk.LeaderboardCreate(
ctx,
"tictactoe", // id
lb,
true, // authoritative
"desc", // sortOrder
"desc", // sort order
"incr", // operator
"", // resetSchedule
"", // reset schedule
map[string]interface{}{}, // metadata
)
if err != nil && err.Error() != "Leaderboard ID already exists" {
logger.Error("Failed to create leaderboard %s: %v", lb, err)
return err
}
logger.Info("Leaderboard tictactoe ready")
logger.Info("Leaderboard ready: %s", lb)
}
logger.Info("Go module loaded successfully!")
return nil
}

View File

@@ -1,378 +0,0 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"github.com/heroiclabs/nakama-common/runtime"
)
const (
OpMove int64 = 1
OpState int64 = 2
)
// Server-side game state
type MatchState struct {
Board [3][3]string `json:"board"`
Players []string `json:"players"`
Turn int `json:"turn"` // index in Players
Winner string `json:"winner"` // "X", "O", "draw", "forfeit"
GameOver bool `json:"game_over"` // true when finished
}
// Struct that implements runtime.Match
type TicTacToeMatch struct{}
// Factory for RegisterMatch
func NewMatch(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
) (runtime.Match, error) {
logger.Info("TicTacToe NewMatch factory called")
return &TicTacToeMatch{}, nil
}
// ---- MatchInit ----
// Return initial state, tick rate (ticks/sec), and label
func (m *TicTacToeMatch) MatchInit(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
params map[string]interface{},
) (interface{}, int, string) {
state := &MatchState{
Board: [3][3]string{},
Players: []string{},
Turn: 0,
Winner: "",
GameOver: false,
}
tickRate := 5 // 5 ticks per second (~200ms)
label := "tictactoe"
logger.Info("TicTacToe MatchInit: tickRate=%v label=%s", tickRate, label)
return state, tickRate, label
}
// ---- MatchJoinAttempt ----
func (m *TicTacToeMatch) 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.(*MatchState)
if len(s.Players) >= 2 {
return s, false, "match full"
}
return s, true, ""
}
// ---- MatchJoin ----
func (m *TicTacToeMatch) 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.(*MatchState)
for _, p := range presences {
userID := p.GetUserId()
// avoid duplicates
if indexOf(s.Players, userID) == -1 {
s.Players = append(s.Players, userID)
}
}
logger.Info("MatchJoin: now %d players", len(s.Players))
// If we have enough players to start, broadcast initial state immediately
if len(s.Players) == 2 {
stateJSON, err := json.Marshal(s)
if err != nil {
logger.Error("Failed to marshal state on join: %v", err)
} else {
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
logger.Error("BroadcastMessage (initial state) failed: %v", err)
} else {
logger.Info("Broadcasted initial state to players")
}
}
}
return s
}
// ---- MatchLeave ----
func (m *TicTacToeMatch) 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.(*MatchState)
// End the game if anyone leaves
if !s.GameOver {
s.GameOver = true
s.Winner = "forfeit"
logger.Info("MatchLeave: game ended by forfeit")
// broadcast final state so clients see the forfeit
stateJSON, err := json.Marshal(s)
if err != nil {
logger.Error("Failed to marshal state on leave: %v", err)
} else {
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
logger.Error("BroadcastMessage (forfeit) failed: %v", err)
} else {
logger.Info("Broadcasted forfeit state to remaining players")
}
}
}
return s
}
// ---- MatchLoop ----
func (m *TicTacToeMatch) 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.(*MatchState)
if s.GameOver {
return s
}
changed := false
for _, msg := range messages {
if msg.GetOpCode() != OpMove {
logger.Debug("Ignoring non-move opcode: %d", msg.GetOpCode())
continue
}
var move struct {
Row int `json:"row"`
Col int `json:"col"`
}
if err := json.Unmarshal(msg.GetData(), &move); err != nil {
logger.Warn("Invalid move payload from %s: %v", msg.GetUserId(), err)
continue
}
playerID := msg.GetUserId()
playerIdx := indexOf(s.Players, playerID)
logger.Info("Received move from %s (playerIdx=%d): row=%d col=%d", playerID, playerIdx, move.Row, move.Col)
if playerIdx == -1 {
logger.Warn("Move rejected: player %s not in player list", playerID)
continue
}
if playerIdx != s.Turn {
logger.Warn("Move rejected: not player's turn (playerIdx=%d turn=%d)", playerIdx, s.Turn)
continue
}
if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 {
logger.Warn("Move rejected: out of bounds (%d,%d)", move.Row, move.Col)
continue
}
if s.Board[move.Row][move.Col] != "" {
logger.Warn("Move rejected: cell already occupied (%d,%d)", move.Row, move.Col)
continue
}
symbols := []string{"X", "O"}
if playerIdx < 0 || playerIdx >= len(symbols) {
logger.Warn("Move rejected: invalid player index %d", playerIdx)
continue
}
// Apply move
s.Board[move.Row][move.Col] = symbols[playerIdx]
changed = true
logger.Info("Move applied for player %s -> %s at (%d,%d)", playerID, symbols[playerIdx], move.Row, move.Col)
// Check win/draw
if winner := checkWinner(s.Board); winner != "" {
s.Winner = winner
s.GameOver = true
logger.Info("Game over! Winner: %s", winner)
} else if fullBoard(s.Board) {
s.Winner = "draw"
s.GameOver = true
logger.Info("Game over! Draw")
} else {
s.Turn = 1 - s.Turn
logger.Info("Turn advanced to %d", s.Turn)
}
if s.GameOver {
if s.Winner != "" && s.Winner != "draw" && s.Winner != "forfeit" {
// winner = "X" or "O"
winningIndex := 0
if s.Winner == "O" {
winningIndex = 1
}
winnerUserId := s.Players[winningIndex]
account, acc_err := nk.AccountGetId(ctx, winnerUserId)
winnerUsername := ""
if acc_err != nil {
logger.Error("Failed to fetch username for winner %s: %v", winnerUserId, acc_err)
} else {
winnerUsername = account.GetUser().GetUsername()
}
logger.Info("Winner username=%s userId=%s", winnerUsername, winnerUserId)
// Write +1 win
_, err := nk.LeaderboardRecordWrite(
ctx,
"tictactoe", // leaderboard ID
winnerUserId, // owner ID
winnerUsername, // username
int64(1), // score
int64(0), // subscore
map[string]interface{}{"result": "win"},
nil, // overrideOperator
)
if err != nil {
logger.Error("Failed to write leaderboard win: %v", err)
} else {
logger.Info("Leaderboard updated for: %s", winnerUserId)
}
}
}
}
// If anything changed (or periodically if you want), broadcast updated state to everyone
if changed {
stateJSON, err := json.Marshal(s)
if err != nil {
logger.Error("Failed to marshal state: %v", err)
} else {
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
logger.Error("BroadcastMessage failed: %v", err)
} else {
logger.Info("Broadcasted updated state to players")
}
}
}
return s
}
// ---- MatchTerminate ----
func (m *TicTacToeMatch) 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 (not used, but required) ----
func (m *TicTacToeMatch) 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.Info("MatchSignal: %s", data)
// no-op; just echo back
return state, ""
}
// ---- Helpers ----
func indexOf(arr []string, v string) int {
for i, s := range arr {
if s == v {
return i
}
}
return -1
}
func checkWinner(b [3][3]string) string {
lines := [][][2]int{
{{0, 0}, {0, 1}, {0, 2}},
{{1, 0}, {1, 1}, {1, 2}},
{{2, 0}, {2, 1}, {2, 2}},
{{0, 0}, {1, 0}, {2, 0}},
{{0, 1}, {1, 1}, {2, 1}},
{{0, 2}, {1, 2}, {2, 2}},
{{0, 0}, {1, 1}, {2, 2}},
{{0, 2}, {1, 1}, {2, 0}},
}
for _, l := range lines {
a, b2, c := l[0], l[1], l[2]
if b[a[0]][a[1]] != "" &&
b[a[0]][a[1]] == b[b2[0]][b2[1]] &&
b[a[0]][a[1]] == b[c[0]][c[1]] {
return b[a[0]][a[1]]
}
}
return ""
}
func fullBoard(b [3][3]string) bool {
for _, row := range b {
for _, v := range row {
if v == "" {
return false
}
}
}
return true
}

View File

@@ -1,86 +0,0 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"github.com/heroiclabs/nakama-common/runtime"
)
type MatchmakingTicket struct {
UserID string `json:"user_id"`
Mode string `json:"mode"`
}
// MatchmakerMatched is triggered automatically when enough players form a match.
func MatchmakerMatched(
ctx context.Context,
logger runtime.Logger,
db *sql.DB,
nk runtime.NakamaModule,
entries []runtime.MatchmakerEntry,
) (string, error) {
if len(entries) != 2 {
logger.Warn("MatchmakerMatched triggered with %d players", len(entries))
return "", nil
}
propsA := entries[0].GetProperties()
propsB := entries[1].GetProperties()
validModes := map[string]bool{"classic": true, "blitz": true}
modeA, okA := propsA["mode"].(string)
modeB, okB := propsB["mode"].(string)
if !okA || !okB || !validModes[modeA] || !validModes[modeB] {
logger.Warn("MatchmakerMatched missing mode property — ignoring")
return "", nil
}
// ✅ If modes dont match, let Nakama find another pairing
if modeA != modeB {
logger.Warn("Mode mismatch %s vs %s — retrying matchmaking", modeA, modeB)
return "", nil
}
// ✅ Create authoritative match
matchParams := map[string]interface{}{
"mode": modeA,
}
matchID, err := nk.MatchCreate(ctx, "tictactoe", matchParams)
if err != nil {
logger.Error("MatchCreate failed: %v", err)
return "", runtime.NewError("failed to create match", 13)
}
logger.Info("✅ Match created %s — mode=%s", matchID, modeA)
return matchID, nil
}
// RPC to leave matchmaking queue
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)
}
logger.Info("✅ Matchmaking ticket removed: %s", input.Ticket)
return "{}", nil
}

386
plugins/modules/match.go Normal file
View File

@@ -0,0 +1,386 @@
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
}
// -------------------------
// 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{},
Boards: map[string]*structs.Board{}, // empty, will be filled later
Turn: 0,
Winner: -1,
GameOver: false,
Metadata: map[string]interface{}{},
}
state.Metadata["game"] = m.GameName
state.Metadata["mode"] = m.Mode
m.Rules.AttachGameMetadata(state)
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)
// Initialize boards using game rules
s.Boards = m.Rules.InitBoards(s.Players, m.Config)
// 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.
phase := s.Metadata["phase"]
if phase == "battle" && 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, keepTurn := m.Rules.ApplyMove(s, playerIdx, payload)
if stateChanged {
changed = true
}
if gameOver {
s.GameOver = true
s.Winner = winnerIdx
} else {
if !keepTurn && 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 {
logger.Info("Broadcasting state update (op=%d): %v", OpState, data)
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, ""
}

View 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.Error("MatchmakerMatched: Missing 'game' or 'mode' properties.")
return "", runtime.NewError("missing matchmaking properties", 3)
}
// 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{}{
"game": game,
"mode": mode,
}
matchID, err := nk.MatchCreate(ctx, "generic", 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
}

51
plugins/structs/board.go Normal file
View File

@@ -0,0 +1,51 @@
package structs
// Board is a generic 2D grid for turn-based games.
// Cell data is stored as strings, but can represent anything (piece, move, state).
type Board struct {
Rows int `json:"rows"`
Cols int `json:"cols"`
Grid [][]string `json:"grid"`
}
// NewBoard creates a grid of empty strings.
func NewBoard(rows, cols int) *Board {
b := &Board{
Rows: rows,
Cols: cols,
Grid: make([][]string, rows),
}
for r := 0; r < rows; r++ {
b.Grid[r] = make([]string, cols)
}
return b
}
func (b *Board) InBounds(row, col int) bool {
return row >= 0 && row < b.Rows && col >= 0 && col < b.Cols
}
func (b *Board) Get(row, col int) string {
return b.Grid[row][col]
}
func (b *Board) Set(row, col int, value string) {
b.Grid[row][col] = value
}
func (b *Board) IsEmpty(row, col int) bool {
return b.Grid[row][col] == ""
}
func (b *Board) Full() bool {
for r := 0; r < b.Rows; r++ {
for c := 0; c < b.Cols; c++ {
if b.Grid[r][c] == "" {
return false
}
}
}
return true
}

View File

@@ -0,0 +1,11 @@
package structs
// MatchState holds the full game session state.
type MatchState struct {
Players []*Player `json:"players"`
Boards map[string]*Board `json:"boards"` // Multiple named boards:
Turn int `json:"turn"` // index in Players[]
Winner int `json:"winner"` // -1 = none, >=0 = winner index
GameOver bool `json:"game_over"` // true when the match ends
Metadata map[string]interface{} `json:"metadata"` // metadata
}

19
plugins/structs/player.go Normal file
View File

@@ -0,0 +1,19 @@
package structs
// Player represents a participant in the match.
type Player struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Index int `json:"index"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// NewPlayer creates a new player object.
func NewPlayer(userID, username string, index int) *Player {
return &Player{
UserID: userID,
Username: username,
Index: index,
Metadata: make(map[string]string),
}
}