Compare commits
70 Commits
v0.0.1
...
multi-game
| Author | SHA1 | Date | |
|---|---|---|---|
| f1e85a72dd | |||
| 0562d1e0c9 | |||
| 10c7933aca | |||
| d75dcd3c74 | |||
| bcdc5faea5 | |||
| 1c31c489c7 | |||
| 3eadb49a72 | |||
| 3c81a8bf29 | |||
| d9c3ecb252 | |||
| eeb0a8175f | |||
| 70669fc856 | |||
| 1752bfaed4 | |||
| 74e75de577 | |||
| 7983be56e4 | |||
| e9eb37f665 | |||
| 7a7c6adc47 | |||
| ae5628f370 | |||
| e8e2419537 | |||
| 4ee6027612 | |||
| 389e77e2ae | |||
| 7ce860db96 | |||
| 92f307d33d | |||
| 6e7d2d9f14 | |||
| c91dba475c | |||
| 04d988c584 | |||
| 27abc56a00 | |||
| 333b48ad60 | |||
| 4dee0bfb0a | |||
| 1e91825808 | |||
| c5cb1047ae | |||
| 18f9eed71d | |||
| 02de328bcd | |||
| 31f1a66fed | |||
| 5065145c6f | |||
| 8e0475c8a7 | |||
| 1359a75214 | |||
| 6786547950 | |||
| 17a2caea49 | |||
| 8ff199ca10 | |||
| 62c1f3920b | |||
| 73e3f0a7ac | |||
| 21f6698b89 | |||
| 06904143d6 | |||
| ba05aa7c76 | |||
| bfcf977e13 | |||
| 6eca090046 | |||
| 07cb519f55 | |||
| 840d7701ca | |||
| f1f40f5799 | |||
| 0d8d3785fc | |||
| 65b5ef4660 | |||
| 95381c2a56 | |||
| ead7ad2c35 | |||
| 4a833dc258 | |||
| 37c090cf64 | |||
| 7bcdc76594 | |||
| 087616a67e | |||
| 10058710fb | |||
| eb35ccd180 | |||
| bd376123b3 | |||
| ea1a70b212 | |||
| 908eebefdd | |||
| 0fb448dd45 | |||
| fa4d4d00be | |||
| 22993d6d37 | |||
| de4bfb6c07 | |||
| d260c9a1ef | |||
| 1b4e7a5ee0 | |||
| cb23d3c516 | |||
| 37b20c6c36 |
163
.drone.yml
Normal file
163
.drone.yml
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: nakama-server
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
workspace:
|
||||||
|
path: /drone/src
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
host:
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 1. Fetch latest Tags
|
||||||
|
# -----------------------------------------------------
|
||||||
|
- name: fetch-tags
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git
|
||||||
|
- git fetch --tags
|
||||||
|
- |
|
||||||
|
# Get latest Git tag and trim newline
|
||||||
|
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
|
||||||
|
echo "Latest Git tag fetched: $LATEST_TAG"
|
||||||
|
|
||||||
|
# Save to file for downstream steps
|
||||||
|
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
|
||||||
|
|
||||||
|
# Read back for verification
|
||||||
|
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
echo "Image tag read from file: $IMAGE_TAG"
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if [ -z "$IMAGE_TAG" ]; then
|
||||||
|
echo "❌ No git tags found! Cannot continue."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 2. Check existing Nakama Docker image
|
||||||
|
# -----------------------------------------------------
|
||||||
|
- name: check-remote-image
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
|
- echo "Checking if lila-games/nakama-server:$IMAGE_TAG exists on remote Docker..."
|
||||||
|
- echo "Existing Docker tags for lila-games/nakama-server:"
|
||||||
|
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^lila-games/nakama-server" || echo "(none)"
|
||||||
|
- |
|
||||||
|
if docker image inspect lila-games/nakama-server:$IMAGE_TAG > /dev/null 2>&1; then
|
||||||
|
echo "✅ Docker image lila-games/nakama-server:$IMAGE_TAG already exists — skipping build"
|
||||||
|
exit 78
|
||||||
|
else
|
||||||
|
echo "⚙️ Docker image lila-games/nakama-server:$IMAGE_TAG not found — proceeding to build..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 3. Build Nakama Docker image
|
||||||
|
# -----------------------------------------------------
|
||||||
|
- name: build-image
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
|
- echo "🔨 Building Nakama image lila-games/nakama-server:latest"
|
||||||
|
- |
|
||||||
|
docker build --network=host \
|
||||||
|
-t lila-games/nakama-server:$IMAGE_TAG \
|
||||||
|
-t lila-games/nakama-server:latest \
|
||||||
|
/drone/src
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 4. Push Nakama Image
|
||||||
|
# -----------------------------------------------------
|
||||||
|
- name: push-image
|
||||||
|
image: docker:24
|
||||||
|
environment:
|
||||||
|
REGISTRY_HOST:
|
||||||
|
from_secret: REGISTRY_HOST
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: REGISTRY_USER
|
||||||
|
REGISTRY_PASS:
|
||||||
|
from_secret: REGISTRY_PASS
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
|
- echo "🔑 Logging into registry $REGISTRY_HOST ..."
|
||||||
|
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
|
||||||
|
- echo "🏷️ Tagging images with registry prefix..."
|
||||||
|
- docker tag lila-games/nakama-server:$IMAGE_TAG $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG
|
||||||
|
- docker tag lila-games/nakama-server:$IMAGE_TAG $REGISTRY_HOST/lila-games/nakama-server:latest
|
||||||
|
- echo "📤 Pushing lila-games/nakama-server:$IMAGE_TAG ..."
|
||||||
|
- docker push $REGISTRY_HOST/lila-games/nakama-server:$IMAGE_TAG
|
||||||
|
- echo "📤 Pushing lila-games/nakama-server:latest ..."
|
||||||
|
- docker push $REGISTRY_HOST/lila-games/nakama-server:latest
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 5. Stop old Nakama container
|
||||||
|
# -----------------------------------------------------
|
||||||
|
- name: stop-old
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- echo "🛑 Stopping old Nakama container..."
|
||||||
|
- docker rm -f nakama-server || true
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 6. Run new Nakama container
|
||||||
|
# -----------------------------------------------------
|
||||||
|
- name: run-container
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
environment:
|
||||||
|
DB_ADDR:
|
||||||
|
from_secret: POSTGRES_ADDR
|
||||||
|
SERVER_KEY:
|
||||||
|
from_secret: SERVER_KEY
|
||||||
|
REGISTRY_HOST:
|
||||||
|
from_secret: REGISTRY_HOST
|
||||||
|
commands:
|
||||||
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
- echo "🚀 Starting Nakama server..."
|
||||||
|
- |
|
||||||
|
docker run -d \
|
||||||
|
--name nakama-server \
|
||||||
|
-p 7350:7350 \
|
||||||
|
-p 7351:7351 \
|
||||||
|
-p 7349:7349 \
|
||||||
|
--restart always \
|
||||||
|
--add-host private-pi:192.168.1.111 \
|
||||||
|
-e DB_ADDR="$DB_ADDR" \
|
||||||
|
-e SERVER_KEY="$SERVER_KEY" \
|
||||||
|
lila-games/nakama-server:latest
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# Pipeline trigger
|
||||||
|
# -----------------------------------------------------
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,4 +2,5 @@
|
|||||||
.run
|
.run
|
||||||
.venv
|
.venv
|
||||||
/vendor/
|
/vendor/
|
||||||
/build/
|
/build/
|
||||||
|
.env
|
||||||
|
|||||||
35
Dockerfile
Normal file
35
Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# -----------------------------------------------------
|
||||||
|
# 1. Build the Nakama plugin
|
||||||
|
# -----------------------------------------------------
|
||||||
|
FROM --platform=linux/arm64 golang:1.21.6 AS plugin_builder
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
build-essential \
|
||||||
|
git
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p build && \
|
||||||
|
CGO_ENABLED=1 go build \
|
||||||
|
--trimpath \
|
||||||
|
--buildmode=plugin \
|
||||||
|
-o build/main.so \
|
||||||
|
./plugins
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------
|
||||||
|
# 2. Build final Nakama image
|
||||||
|
# -----------------------------------------------------
|
||||||
|
FROM heroiclabs/nakama:3.21.0-arm
|
||||||
|
|
||||||
|
# Copy plugin from builder stage
|
||||||
|
COPY --from=plugin_builder /workspace/build/main.so /nakama/data/modules/main.so
|
||||||
|
#COPY --from=plugin_builder local.yml /nakama/data/config.yml
|
||||||
|
|
||||||
|
# Default Nakama startup (runs migrations + server)
|
||||||
|
ENTRYPOINT exec /bin/sh -ecx "/nakama/nakama migrate up --database.address \"$DB_ADDR\" && exec /nakama/nakama --database.address \"$DB_ADDR\" --socket.server_key=\"$SERVER_KEY\""
|
||||||
318
README.md
318
README.md
@@ -1,190 +1,272 @@
|
|||||||
# ✅ Project Status Report — Multiplayer Tic-Tac-Toe Platform
|
# tic-tac-toe — Authoritative Multiplayer Game Server (Nakama + Go)
|
||||||
|
|
||||||
**To:** CTO & Technical Interview Panel
|
A production-grade, server-authoritative multiplayer backend built using **Nakama 3**, **Go plugins**, and **PostgreSQL**, deployed via **Drone CI/CD** and exposed securely through **Traefik**.
|
||||||
|
|
||||||
**Date:** November 25, 2025
|
|
||||||
|
|
||||||
**Version:** v0.0.1
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **1. Objective**
|
## 🚀 Overview
|
||||||
|
|
||||||
Design and implement a lightweight, scalable, server-authoritative multiplayer game system using **Nakama + Go plugins**, supporting authentication, matchmaking, real-time gameplay, and leaderboards — deployable to Google Cloud for demonstration.
|
This repository contains a fully authoritative Tic-Tac-Toe backend built on **Nakama 3.21.0 ARM**, using a custom **Go plugin** to implement game logic, matchmaking, validation, leaderboards, and session management.
|
||||||
|
|
||||||
|
The server ensures:
|
||||||
|
|
||||||
|
* **Zero client trust**
|
||||||
|
* **Deterministic gameplay**
|
||||||
|
* **Secure matchmaking**
|
||||||
|
* **Validated moves**
|
||||||
|
* **Authoritative outcomes**
|
||||||
|
|
||||||
|
Designed for scalability, extensibility, and cloud deployment (GCP, AWS, or ARM homelab environments).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **2. Current Completion Summary**
|
## ⭐ Key Features
|
||||||
|
|
||||||
| Requirement | Status |
|
* **Authoritative Go match handler** (`tictactoe`)
|
||||||
| ---------------------------------------- | -------------------------------- |
|
* Device-ID authentication + JWT session
|
||||||
| Install Nakama + PostgreSQL | ✅ Completed |
|
* Matchmaking queue support
|
||||||
| Custom Go server plugins | ✅ Completed |
|
* Deterministic game validation
|
||||||
| Server-authoritative Tic-Tac-Toe | ✅ Completed |
|
* Match lifecycle handling
|
||||||
| Real-time WebSocket communication | ✅ Completed |
|
* Disconnect handling & forfeit detection
|
||||||
| Device-based authentication | ✅ Completed |
|
* Leaderboards creation + scoring
|
||||||
| JWT-based session management | ✅ Completed |
|
* Production-grade CI/CD with Drone
|
||||||
| Match creation & joining | ✅ Completed |
|
* Automated ARM Docker builds
|
||||||
| Matchmaking queue support | 🟡 Not Started |
|
* Full Traefik routing (HTTP + WebSocket)
|
||||||
| Game state validation & turn enforcement | 🟡 Not Started |
|
* Compatible with x86/ARM/GCP
|
||||||
| Leaderboard/tracking foundation | 🟡 Not Started |
|
|
||||||
| UI Game Client | 🟡 Not Started |
|
|
||||||
| Google Cloud deployment | 🟡 Not Started |
|
|
||||||
|
|
||||||
✅ **Core backend functionality is complete and stable**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **3. Core Technical Architecture**
|
## 🧩 Architecture
|
||||||
|
|
||||||
* **Backend Framework:** Nakama 3.x
|
### High-Level System Diagram
|
||||||
* **Business Logic:** Custom Go runtime module
|
|
||||||
* **Database:** PostgreSQL 14
|
```mermaid
|
||||||
* **Transport:** WebSockets (real-time)
|
flowchart LR
|
||||||
* **Authentication:** Device-ID based auth → JWT session returned
|
UI[(React + Vite)] --> Traefik
|
||||||
* **State Management:** Server-authoritative, deterministic
|
Traefik --> Nakama[Nakama Server]
|
||||||
* **Protocol:** Nakama RT JSON envelopes
|
Nakama --> Plugin[Go Plugin]
|
||||||
* **Build & Deployment:** Docker-based
|
Nakama --> Postgres[(PostgreSQL)]
|
||||||
|
Drone[Drone CI/CD] --> Registry[Private Docker Registry]
|
||||||
|
Registry --> Nakama
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **4. Authentication**
|
## 🛠 Tech Stack
|
||||||
|
|
||||||
Implemented **Nakama device authentication flow**:
|
**Backend**
|
||||||
|
|
||||||
1. Client provides device UUID
|
* Nakama 3.21.0 ARM
|
||||||
2. Nakama validates & creates account if needed
|
* Go 1.21.6 (plugin ABI compatible)
|
||||||
3. Server responds with **JWT session token**
|
* PostgreSQL 16
|
||||||
4. Client uses JWT for all WebSocket connections
|
* Docker / multi-stage builds
|
||||||
|
* Drone CI/CD
|
||||||
|
* Traefik reverse proxy
|
||||||
|
|
||||||
|
**Cloud/Infra**
|
||||||
|
|
||||||
|
* GCP-ready
|
||||||
|
* ARM homelab deployments
|
||||||
|
* Private registry support
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **5. Game Server Logic**
|
## 📦 Repository Structure
|
||||||
|
|
||||||
Implemented as Go match module:
|
```
|
||||||
|
tic-tac-toe/
|
||||||
* Turn-based validation
|
│── plugins/
|
||||||
* Board occupancy checks
|
│ ├── main.go
|
||||||
* Win/draw/forfeit detection
|
│ ├── match.go
|
||||||
* Automatic broadcast of updated state
|
│ └── matchmaking.go
|
||||||
* Graceful match termination
|
│
|
||||||
* Prevents cheating & client-side manipulation
|
│── Dockerfile
|
||||||
|
│── go.mod
|
||||||
Result:
|
│── go.sum
|
||||||
✅ Entire game lifecycle enforced server-side.
|
│── README.md
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **6. Real-Time Networking**
|
## 🔌 Registered Server Components
|
||||||
|
|
||||||
Clients communicate via:
|
### 📌 Match Handler
|
||||||
|
|
||||||
* `match_create`
|
Name: **`tictactoe`**
|
||||||
* `match_join`
|
|
||||||
* `match_data_send` (OpCode 1) → moves
|
|
||||||
* Broadcast state updates (OpCode 2)
|
|
||||||
|
|
||||||
Python WebSocket simulation confirms:
|
Handles:
|
||||||
✅ Move sequencing
|
|
||||||
✅ Session isolation
|
* Turn validation
|
||||||
✅ Messaging reliability
|
* State updates
|
||||||
✅ Auto-cleanup on disconnect
|
* Win/loss/draw
|
||||||
|
* Disconnect forfeit
|
||||||
|
* Broadcasting authoritative state
|
||||||
|
|
||||||
|
### 📌 RPCs
|
||||||
|
|
||||||
|
| RPC Name | Purpose |
|
||||||
|
| ----------------------- | ---------------------------------------- |
|
||||||
|
| **`leave_matchmaking`** | Allows clients to exit matchmaking queue |
|
||||||
|
|
||||||
|
### 📌 Matchmaker Hook
|
||||||
|
|
||||||
|
| Hook | Purpose |
|
||||||
|
| ----------------------- | --------------------------------------------------------- |
|
||||||
|
| **`MatchmakerMatched`** | Validates matchmaker tickets and instantiates a new match |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **7. Matchmaking**
|
## 🎮 Gameplay Protocol
|
||||||
|
|
||||||
**NOT STARTED**
|
### OpCodes
|
||||||
|
|
||||||
|
| OpCode | Direction | Meaning |
|
||||||
|
| ------ | --------------- | ----------------------------- |
|
||||||
|
| **1** | Client → Server | Submit move `{x, y}` |
|
||||||
|
| **2** | Server → Client | Full authoritative game state |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **8. Testing & Validation**
|
## 🧠 Authoritative Flow Diagram
|
||||||
|
|
||||||
Performed using:
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C1 as Client 1
|
||||||
|
participant C2 as Client 2
|
||||||
|
participant S as Nakama + Go Plugin
|
||||||
|
|
||||||
* Automated two-client WebSocket game flow - Happy path
|
C1->>S: Matchmaker Join
|
||||||
|
C2->>S: Matchmaker Join
|
||||||
**PENDING STRESS AND EDGE CASE TESTING**
|
S->>S: MatchmakerMatched()
|
||||||
|
S-->>C1: Matched
|
||||||
|
S-->>C2: Matched
|
||||||
|
|
||||||
|
C1->>S: OpCode 1 (Move)
|
||||||
|
S->>S: Validate Move
|
||||||
|
S-->>C1: OpCode 2 (Updated State)
|
||||||
|
S-->>C2: OpCode 2 (Updated State)
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **9. UI Status**
|
## ⚙️ Build & Development
|
||||||
|
|
||||||
Not implemented yet — intentionally deferred.
|
### Build Go Plugin
|
||||||
|
|
||||||
Planned:
|
```
|
||||||
|
CGO_ENABLED=1 go build \
|
||||||
|
--trimpath \
|
||||||
|
--buildmode=plugin \
|
||||||
|
-o build/main.so \
|
||||||
|
./plugins
|
||||||
|
```
|
||||||
|
|
||||||
* Simple browser/mobile client
|
### Run Nakama Locally
|
||||||
* Display board, turns, win state
|
|
||||||
* WebSocket integration
|
|
||||||
* Leaderboard screen
|
|
||||||
|
|
||||||
Estimated time: **6–10 hours**
|
```
|
||||||
|
nakama migrate up --database.address "$DB_ADDR"
|
||||||
|
nakama \
|
||||||
|
--database.address "$DB_ADDR" \
|
||||||
|
--socket.server_key="$SERVER_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **10. Leaderboard System**
|
## 🐳 Docker Build (Multi-Stage)
|
||||||
|
|
||||||
Backend-ready but not finalized:
|
```dockerfile
|
||||||
|
FROM --platform=linux/arm64 golang:1.21.6 AS plugin_builder
|
||||||
|
|
||||||
✅ Database & Nakama leaderboard APIs available
|
RUN go mod download
|
||||||
✅ Game result reporting planned
|
COPY . .
|
||||||
🟡 Ranking, ELO, win streak logic pending
|
RUN CGO_ENABLED=1 go build --buildmode=plugin -o build/main.so ./plugins
|
||||||
|
|
||||||
Estimated time: **4–6 hours**
|
FROM heroiclabs/nakama:3.21.0-arm
|
||||||
|
COPY --from=plugin_builder /workspace/build/main.so /nakama/data/modules/main.so
|
||||||
|
ENTRYPOINT ...
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **11. Google Cloud Deployment (Interview-Scope)**
|
## 🤖 CI/CD — Drone Pipeline
|
||||||
|
|
||||||
Goal: **Simple, affordable, demo-ready deployment**
|
Drone performs:
|
||||||
|
|
||||||
### Planned architecture:
|
1. Fetch latest Git tag
|
||||||
|
2. Check if Docker image already exists
|
||||||
|
3. Build Nakama + plugin
|
||||||
|
4. Push to private registry
|
||||||
|
5. Stop old container
|
||||||
|
6. Deploy new Nakama server automatically
|
||||||
|
|
||||||
* Highly subjective as of now
|
Full pipeline included in repository (`.drone.yml`).
|
||||||
|
|
||||||
| Component | GCP Service |
|
|
||||||
| ------------- | ----------------------------- |
|
|
||||||
| Nakama server | Compute Engine VM (Docker) |
|
|
||||||
| PostgreSQL | Cloud SQL (shared tier) |
|
|
||||||
| Game UI | Cloud Run or Firebase Hosting |
|
|
||||||
| Networking | Static IP + HTTPS |
|
|
||||||
| Auth secrets | Secret Manager (optional) |
|
|
||||||
|
|
||||||
Estimated setup time: **6–8 hours**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **12. Risks & Considerations**
|
## 🌐 Traefik Routing
|
||||||
|
|
||||||
| Risk | Mitigation |
|
### HTTPS
|
||||||
| ------------------------ | ------------------------- |
|
|
||||||
| No UI yet | Prioritized next |
|
|
||||||
| Only happy path tested | In parallel with UI work |
|
|
||||||
| Matchmaking incomplete | Clear implementation plan |
|
|
||||||
| Leaderboard incomplete | Clear implementation plan |
|
|
||||||
|
|
||||||
None block demonstration or evaluation.
|
```
|
||||||
|
nakama.aetoskia.com
|
||||||
|
/ → HTTP API
|
||||||
|
/ws → WebSocket (real-time)
|
||||||
|
```
|
||||||
|
|
||||||
|
Includes:
|
||||||
|
|
||||||
|
* CORS rules
|
||||||
|
* WebSocket upgrade headers
|
||||||
|
* Certificate resolver
|
||||||
|
* Secure default middlewares
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **13. Next Steps**
|
## 🔧 Environment Variables
|
||||||
|
|
||||||
1. Implement browser/mobile UI
|
| Variable | Description |
|
||||||
2. Stress, load and Edge case testing
|
| --------------- | ---------------------------- |
|
||||||
3. Complete match making, leaderboard scoring
|
| `DB_ADDR` | PostgreSQL connection string |
|
||||||
4. Deploy to Google Cloud for public access
|
| `SERVER_KEY` | Nakama server key |
|
||||||
5. Record demo video + documentation
|
| `REGISTRY_HOST` | Private registry |
|
||||||
|
|
||||||
Estimated remaining effort: **1.5–2.5 days**
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## **Executive Summary**
|
## 📊 Leaderboards
|
||||||
|
|
||||||
The foundational backend for the multiplayer Tic-Tac-Toe platform is fully implemented, stable, and validated over real-time WebSocket communication. Core features—authentication, session management, game state handling, and authoritative gameplay—are complete and functioning reliably.
|
* Created during server start
|
||||||
|
* Score: `+1` on win
|
||||||
|
* Metadata logged (mode, player IDs)
|
||||||
|
|
||||||
Remaining deliverables, including UI development, matchmaking, extended test coverage, leaderboard logic, and Google Cloud deployment, are intentionally pending to align effort with interview scope and timelines. These are well-defined, low-risk, and can be completed within the estimated timeframe.
|
---
|
||||||
|
|
||||||
**The project is technically strong, progressing as planned, and positioned for successful final delivery and demonstration.**
|
## 🧪 Testing
|
||||||
|
|
||||||
|
* Win/loss/draw simulation
|
||||||
|
* Invalid move rejection
|
||||||
|
* Disconnect → forfeit
|
||||||
|
* Load testing matchmaking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Production Deployment
|
||||||
|
|
||||||
|
Supported on:
|
||||||
|
|
||||||
|
* ARM homelabs (Raspberry Pi)
|
||||||
|
* Google Cloud (Compute Engine + Cloud SQL)
|
||||||
|
* AWS EC2
|
||||||
|
* Kubernetes (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork repo
|
||||||
|
2. Create feature branch
|
||||||
|
3. Write tests
|
||||||
|
4. Submit PR
|
||||||
|
|
||||||
|
Coding style: Go fmt + idiomatic Go; follow Nakama plugin constraints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all
|
command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all
|
||||||
@@ -44,8 +43,8 @@ services:
|
|||||||
- "/bin/sh"
|
- "/bin/sh"
|
||||||
- "-ecx"
|
- "-ecx"
|
||||||
- >
|
- >
|
||||||
/nakama/nakama migrate up --database.address postgres:localdb@postgres:5432/nakama?sslmode=disable &&
|
/nakama/nakama migrate up --database.address "$DB_ADDR" &&
|
||||||
exec /nakama/nakama --config /nakama/data/local.yml --database.address postgres:localdb@postgres:5432/nakama?sslmode=disable
|
exec /nakama/nakama --database.address "$DB_ADDR" --socket.server_key="$SERVER_KEY"
|
||||||
volumes:
|
volumes:
|
||||||
- ./local.yml:/nakama/data/local.yml
|
- ./local.yml:/nakama/data/local.yml
|
||||||
- ./build:/nakama/data/modules
|
- ./build:/nakama/data/modules
|
||||||
|
|||||||
426
game_flow.py
426
game_flow.py
@@ -1,6 +1,8 @@
|
|||||||
|
import random
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import websockets
|
import websockets
|
||||||
@@ -18,140 +20,338 @@ def print_board(board):
|
|||||||
print("\n" + separator.join(rows) + "\n")
|
print("\n" + separator.join(rows) + "\n")
|
||||||
|
|
||||||
|
|
||||||
# ---------- Auth ----------
|
class WebSocketHandler(object):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
custom_id: str,
|
||||||
|
label: str,
|
||||||
|
):
|
||||||
|
self.custom_id = custom_id
|
||||||
|
self.label = label
|
||||||
|
|
||||||
def login(custom_id: str) -> str:
|
self.token = None
|
||||||
"""Authenticate via custom ID and return Nakama session token (JWT)."""
|
self.ws = None
|
||||||
basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode()
|
self.listener_task = None
|
||||||
r = requests.post(
|
|
||||||
f"{HOST}/v2/account/authenticate/custom?create=true",
|
async def on_message(self, msg: dict):
|
||||||
headers={"Authorization": f"Basic {basic}"},
|
raise NotImplementedError("Override me!")
|
||||||
json={"id": custom_id},
|
|
||||||
)
|
# ---------- Auth & Connect ----------
|
||||||
r.raise_for_status()
|
def login(self):
|
||||||
body = r.json()
|
"""Authenticate via custom ID and store token."""
|
||||||
return body["token"]
|
basic = base64.b64encode(f"{SERVER_KEY}:".encode()).decode()
|
||||||
|
r = requests.post(
|
||||||
|
f"{HOST}/v2/account/authenticate/custom?create=true",
|
||||||
|
headers={"Authorization": f"Basic {basic}"},
|
||||||
|
json={"id": self.custom_id},
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
self.token = r.json()["token"]
|
||||||
|
return self.token
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Open Nakama WebSocket using stored auth token."""
|
||||||
|
if not self.token:
|
||||||
|
self.login()
|
||||||
|
|
||||||
|
url = f"{WS}/ws?token={self.token}"
|
||||||
|
self.ws = await websockets.connect(url)
|
||||||
|
|
||||||
|
# ---------- Listener ----------
|
||||||
|
async def listener(self):
|
||||||
|
"""Continuously receive events + decode match data."""
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
raw = await self.ws.recv()
|
||||||
|
msg = json.loads(raw)
|
||||||
|
await self.on_message(msg) # ✅ handoff
|
||||||
|
except websockets.exceptions.ConnectionClosedOK:
|
||||||
|
print(f"[{self.label}] WebSocket closed gracefully")
|
||||||
|
except websockets.exceptions.ConnectionClosedError as e:
|
||||||
|
print(f"[{self.label}] WebSocket closed with error: {e}")
|
||||||
|
|
||||||
|
def start_listener(self):
|
||||||
|
"""Spawn background task for async message stream."""
|
||||||
|
if self.ws:
|
||||||
|
self.listener_task = asyncio.create_task(self.listener())
|
||||||
|
|
||||||
|
# ---------- Cleanup ----------
|
||||||
|
async def close(self):
|
||||||
|
if self.listener_task:
|
||||||
|
self.listener_task.cancel()
|
||||||
|
if self.ws:
|
||||||
|
await self.ws.close()
|
||||||
|
print(f"[{self.label}] Connection closed")
|
||||||
|
|
||||||
|
|
||||||
# ---------- WebSocket helpers ----------
|
class PlayerWebSocketHandler(WebSocketHandler):
|
||||||
|
@classmethod
|
||||||
|
async def setup_player(
|
||||||
|
cls,
|
||||||
|
name: str
|
||||||
|
) -> 'PlayerWebSocketHandler':
|
||||||
|
"""Create WS handler, login, connect, start listener."""
|
||||||
|
handler = cls(name, name)
|
||||||
|
handler.login()
|
||||||
|
await handler.connect()
|
||||||
|
return handler
|
||||||
|
|
||||||
async def connect(token: str) -> websockets.ClientConnection:
|
def __init__(self, custom_id: str, label: str):
|
||||||
url = f"{WS}/ws?token={token}"
|
super().__init__(custom_id, label)
|
||||||
ws = await websockets.connect(url)
|
self.ticket = None
|
||||||
return ws
|
self.match_id = None
|
||||||
|
|
||||||
|
async def on_message(self, msg):
|
||||||
|
if "matchmaker_matched" in msg:
|
||||||
|
match_id = msg["matchmaker_matched"]["match_id"]
|
||||||
|
print(f"[{self.label}] ✅ Match found: {match_id}")
|
||||||
|
await self.join_match(match_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "matchmaker_ticket" in msg:
|
||||||
|
self.ticket = msg["matchmaker_ticket"]["ticket"]
|
||||||
|
print(f"[{self.label}] ✅ Received ticket: {self.ticket}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if "match_data" not in msg:
|
||||||
|
print(f"[{self.label}] {msg}")
|
||||||
|
return
|
||||||
|
|
||||||
|
md = msg["match_data"]
|
||||||
|
op = int(md.get("op_code", 0))
|
||||||
|
data = md.get("data")
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
payload = json.loads(base64.b64decode(data).decode())
|
||||||
|
|
||||||
|
if op == 1:
|
||||||
|
print(f"[{self.label}] MOVE -> {payload}")
|
||||||
|
elif op == 2:
|
||||||
|
print(f"\n[{self.label}] GAME STATE")
|
||||||
|
# print_board(payload["board"])
|
||||||
|
print(f"Turn={payload['turn']} Winner={payload['winner']}\n")
|
||||||
|
else:
|
||||||
|
print(f"[{self.label}] UNKNOWN OPCODE {op}: {payload}")
|
||||||
|
|
||||||
|
# ---------- Match Helpers ----------
|
||||||
|
async def join_matchmaking(self, mode: str = "classic"):
|
||||||
|
"""Queue into Nakama matchmaker."""
|
||||||
|
await self.ws.send(json.dumps({
|
||||||
|
"matchmaker_add": {
|
||||||
|
"min_count": 2,
|
||||||
|
"max_count": 2,
|
||||||
|
"string_properties": {"mode": mode}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
print(f"[{self.label}] Searching match for mode={mode}...")
|
||||||
|
|
||||||
|
async def leave_matchmaking(self, ticket: str):
|
||||||
|
"""Remove player from Nakama matchmaking queue."""
|
||||||
|
await self.ws.send(json.dumps({
|
||||||
|
"matchmaker_remove": {
|
||||||
|
"ticket": ticket
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
print(f"[{self.label}] Left matchmaking queue")
|
||||||
|
|
||||||
|
async def create_match(self) -> str:
|
||||||
|
await self.ws.send(json.dumps({"match_create": {}}))
|
||||||
|
msg = json.loads(await self.ws.recv())
|
||||||
|
|
||||||
|
match_id = msg["match"]["match_id"]
|
||||||
|
print(f"[{self.label}] Created match: {match_id}")
|
||||||
|
return match_id
|
||||||
|
|
||||||
|
async def join_match(self, match_id: str):
|
||||||
|
await self.ws.send(json.dumps({"match_join": {"match_id": match_id}}))
|
||||||
|
print(f"[{self.label}] Joined match: {match_id}")
|
||||||
|
self.match_id = match_id
|
||||||
|
|
||||||
|
# ---------- Gameplay ----------
|
||||||
|
async def send_move(self, match_id: str, row: int, col: int):
|
||||||
|
payload = {"row": row, "col": col}
|
||||||
|
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
|
||||||
|
|
||||||
|
await self.ws.send(json.dumps({
|
||||||
|
"match_data_send": {
|
||||||
|
"match_id": match_id,
|
||||||
|
"op_code": 1,
|
||||||
|
"data": encoded,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
print(f"[{self.label}] Sent move: {payload}")
|
||||||
|
|
||||||
|
|
||||||
async def listener(ws, label: str):
|
async def happy_path(
|
||||||
"""Log all messages for a given socket."""
|
match_id: str,
|
||||||
try:
|
p1: PlayerWebSocketHandler,
|
||||||
while True:
|
p2: PlayerWebSocketHandler
|
||||||
raw = await ws.recv()
|
):
|
||||||
data = json.loads(raw)
|
# Play moves
|
||||||
if "match_data" not in data:
|
await p1.send_move(match_id, 0, 0)
|
||||||
print(f"[{label}] {data}")
|
await asyncio.sleep(0.3)
|
||||||
continue
|
await p2.send_move(match_id, 1, 1)
|
||||||
md = data["match_data"]
|
await asyncio.sleep(0.3)
|
||||||
parse_match_data(md, label)
|
await p1.send_move(match_id, 0, 1)
|
||||||
except websockets.exceptions.ConnectionClosedOK:
|
await asyncio.sleep(0.3)
|
||||||
print(f"[{label}] WebSocket closed cleanly")
|
await p2.send_move(match_id, 2, 2)
|
||||||
return
|
await asyncio.sleep(0.3)
|
||||||
except websockets.exceptions.ConnectionClosedError as e:
|
await p1.send_move(match_id, 0, 2)
|
||||||
print(f"[{label}] WebSocket closed with error: {e}")
|
|
||||||
return
|
|
||||||
|
|
||||||
def parse_match_data(md, label: str):
|
|
||||||
op = int(md.get("op_code", 0))
|
|
||||||
|
|
||||||
# decode base64 payload
|
|
||||||
payload_json = base64.b64decode(md["data"]).decode()
|
|
||||||
payload = json.loads(payload_json)
|
|
||||||
|
|
||||||
# OpMove → just log move
|
|
||||||
if op == 1:
|
|
||||||
print(f"[{label}] MOVE -> {payload}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# OpState → pretty-print board
|
|
||||||
if op == 2:
|
|
||||||
print(f"\n[{label}] GAME STATE")
|
|
||||||
print_board(payload["board"])
|
|
||||||
print(f"Turn={payload['turn']} Winner={payload['winner']}\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# other opcodes
|
|
||||||
raise RuntimeError(f"[{label}] UNKNOWN OPCODE {op}: {payload}")
|
|
||||||
|
|
||||||
|
|
||||||
async def send_move(ws, match_id: str, row: int, col: int):
|
async def p2_wins_diagonal(
|
||||||
"""Send a TicTacToe move using OpMove = 1 with base64-encoded data."""
|
match_id: str,
|
||||||
payload = {"row": row, "col": col}
|
p1: PlayerWebSocketHandler,
|
||||||
# Nakama expects `data` as bytes -> base64 string in JSON
|
p2: PlayerWebSocketHandler
|
||||||
data_bytes = json.dumps(payload).encode("utf-8")
|
):
|
||||||
data_b64 = base64.b64encode(data_bytes).decode("ascii")
|
await p1.send_move(match_id, 0, 0)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
msg = {
|
await p2.send_move(match_id, 0, 2)
|
||||||
"match_data_send": {
|
await asyncio.sleep(0.3)
|
||||||
"match_id": match_id,
|
await p1.send_move(match_id, 1, 0)
|
||||||
"op_code": 1, # OpMove
|
await asyncio.sleep(0.3)
|
||||||
"data": data_b64,
|
await p2.send_move(match_id, 1, 1)
|
||||||
}
|
await asyncio.sleep(0.3)
|
||||||
}
|
await p1.send_move(match_id, 2, 1)
|
||||||
await ws.send(json.dumps(msg))
|
await asyncio.sleep(0.3)
|
||||||
|
await p2.send_move(match_id, 2, 0) # P2 wins
|
||||||
|
|
||||||
|
|
||||||
# ---------- Main flow ----------
|
async def draw_game(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
moves = [
|
||||||
|
(p1, 0, 0), (p2, 0, 1),
|
||||||
|
(p1, 0, 2), (p2, 1, 0),
|
||||||
|
(p1, 1, 2), (p2, 1, 1),
|
||||||
|
(p1, 2, 1), (p2, 2, 2),
|
||||||
|
(p1, 2, 0),
|
||||||
|
]
|
||||||
|
for player, r, c in moves:
|
||||||
|
await player.send_move(match_id, r, c)
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
|
||||||
|
async def illegal_occupied_cell(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
await p1.send_move(match_id, 0, 0)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# P2 tries same spot → should trigger server rejection
|
||||||
|
await p2.send_move(match_id, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def illegal_out_of_turn(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
await p1.send_move(match_id, 2, 2)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# P1 tries again before P2
|
||||||
|
await p1.send_move(match_id, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
async def illegal_out_of_bounds(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
await p1.send_move(match_id, 3, 3) # Invalid indices
|
||||||
|
|
||||||
|
|
||||||
|
async def midgame_disconnect(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
await p1.send_move(match_id, 0, 0)
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await p2.close() # simulate rage quit
|
||||||
|
|
||||||
|
|
||||||
|
async def abandoned_lobby(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
# No p2.join_match
|
||||||
|
await asyncio.sleep(5) # test timeout, cleanup, match state
|
||||||
|
|
||||||
|
|
||||||
|
async def spam_moves(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
for _ in range(10):
|
||||||
|
await p1.send_move(match_id, 0, 0)
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
|
||||||
|
async def random_game(
|
||||||
|
match_id: str,
|
||||||
|
p1: PlayerWebSocketHandler,
|
||||||
|
p2: PlayerWebSocketHandler
|
||||||
|
):
|
||||||
|
board = {(r, c) for r in range(3) for c in range(3)}
|
||||||
|
players = [p1, p2]
|
||||||
|
|
||||||
|
for i in range(9):
|
||||||
|
player = players[i % 2]
|
||||||
|
print(f"[{player.label}] Playing move...")
|
||||||
|
r, c = random.choice(list(board))
|
||||||
|
board.remove((r, c))
|
||||||
|
await player.send_move(match_id, r, c)
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_SCENARIOS = [
|
||||||
|
happy_path,
|
||||||
|
p2_wins_diagonal,
|
||||||
|
draw_game,
|
||||||
|
illegal_occupied_cell,
|
||||||
|
illegal_out_of_turn,
|
||||||
|
illegal_out_of_bounds,
|
||||||
|
midgame_disconnect,
|
||||||
|
abandoned_lobby,
|
||||||
|
spam_moves,
|
||||||
|
random_game,
|
||||||
|
]
|
||||||
|
|
||||||
async def main():
|
async def main():
|
||||||
# 1) Login 2 players
|
# Initialize players (login + connect + start listener)
|
||||||
token1 = login("player_one_123456")
|
p1 = await PlayerWebSocketHandler.setup_player("player_one_123456")
|
||||||
token2 = login("player_two_123456")
|
p2 = await PlayerWebSocketHandler.setup_player("player_two_123456")
|
||||||
|
|
||||||
# 2) Connect sockets
|
# Start listeners
|
||||||
ws1 = await connect(token1)
|
p1.start_listener()
|
||||||
ws2 = await connect(token2)
|
p2.start_listener()
|
||||||
|
|
||||||
# 3) Create a match from P1
|
# Match create + join
|
||||||
await ws1.send(json.dumps({"match_create": {}}))
|
match_id = await p1.create_match()
|
||||||
raw = await ws1.recv()
|
await p2.join_match(match_id)
|
||||||
msg = json.loads(raw)
|
|
||||||
match_id = msg["match"]["match_id"]
|
|
||||||
print("Match:", match_id)
|
|
||||||
|
|
||||||
# 4) Only P2 explicitly joins (creator is auto-joined)
|
print(f"\n✅ Match ready: {match_id}\n")
|
||||||
await ws2.send(json.dumps({"match_join": {"match_id": match_id}}))
|
|
||||||
|
|
||||||
# 5) Start listeners
|
|
||||||
asyncio.create_task(listener(ws1, "P1"))
|
|
||||||
asyncio.create_task(listener(ws2, "P2"))
|
|
||||||
|
|
||||||
# Give server time to process joins and initial state
|
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
# 6) Play a quick winning game for P1 (X)
|
for test_scenario in TEST_SCENARIOS:
|
||||||
# P1: (0,0)
|
print(f"\n🚀 Running '{test_scenario.__name__}'...\n")
|
||||||
await send_move(ws1, match_id, 0, 0)
|
await test_scenario(match_id, p1, p2)
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
# P2: (1,1)
|
await asyncio.sleep(1.0)
|
||||||
await send_move(ws2, match_id, 1, 1)
|
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
# P1: (0,1)
|
print("\n✅ All scenarios executed.\n")
|
||||||
await send_move(ws1, match_id, 0, 1)
|
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
# P2: (2,2)
|
await p1.close()
|
||||||
await send_move(ws2, match_id, 2, 2)
|
await p2.close()
|
||||||
await asyncio.sleep(0.3)
|
|
||||||
|
|
||||||
# P1: (0,2) -> X wins by top row
|
|
||||||
await send_move(ws1, match_id, 0, 2)
|
|
||||||
|
|
||||||
# Wait to receive the final state broadcast (OpState = 2)
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
await ws1.close()
|
|
||||||
await ws2.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -1,7 +1,7 @@
|
|||||||
module git.aetoskia.com/lila-games/tic-tac-toe
|
module localrepo
|
||||||
|
|
||||||
go 1.21
|
go 1.21
|
||||||
|
|
||||||
require github.com/heroiclabs/nakama-common v1.31.0
|
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
|
||||||
|
|||||||
@@ -7,3 +7,10 @@ session:
|
|||||||
socket:
|
socket:
|
||||||
max_message_size_bytes: 4096 # reserved buffer
|
max_message_size_bytes: 4096 # reserved buffer
|
||||||
max_request_size_bytes: 131072
|
max_request_size_bytes: 131072
|
||||||
|
matchmaker:
|
||||||
|
max_tickets: 10000
|
||||||
|
interval_ms: 100
|
||||||
|
query:
|
||||||
|
properties:
|
||||||
|
string:
|
||||||
|
mode: true
|
||||||
|
|||||||
101
match_making_flow.py
Normal file
101
match_making_flow.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
from game_flow import PlayerWebSocketHandler, TEST_SCENARIOS
|
||||||
|
|
||||||
|
|
||||||
|
async def simulate_matchmaking(num_players: int = 6):
|
||||||
|
print(f"\n🎮 Spawning {num_players} players...\n")
|
||||||
|
|
||||||
|
# 1) Login + connect
|
||||||
|
players = await asyncio.gather(*[
|
||||||
|
PlayerWebSocketHandler.setup_player(f"player_{i}")
|
||||||
|
for i in range(num_players)
|
||||||
|
])
|
||||||
|
|
||||||
|
print("\n✅ All players authenticated + connected\n")
|
||||||
|
|
||||||
|
# 2) Start listeners BEFORE matchmaking
|
||||||
|
for p in players:
|
||||||
|
p.start_listener()
|
||||||
|
|
||||||
|
print("\n👂 WebSocket listeners active\n")
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
# ✅ 3) Split evenly between classic & blitz
|
||||||
|
half = num_players // 2
|
||||||
|
assignments = ["classic"] * half + ["blitz"] * (num_players - half)
|
||||||
|
random.shuffle(assignments)
|
||||||
|
|
||||||
|
print("\n🎯 Queuing players:")
|
||||||
|
for p, mode in zip(players, assignments):
|
||||||
|
print(f" - {p.label} -> {mode}")
|
||||||
|
|
||||||
|
await asyncio.gather(*[
|
||||||
|
p.join_matchmaking(mode)
|
||||||
|
for p, mode in zip(players, assignments)
|
||||||
|
])
|
||||||
|
|
||||||
|
print("\n✅ All players queued — waiting for matches...\n")
|
||||||
|
|
||||||
|
# ✅ 4) Collect matches
|
||||||
|
matches = {}
|
||||||
|
timeout = 15
|
||||||
|
start = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
|
matches = dict()
|
||||||
|
while asyncio.get_event_loop().time() - start < timeout:
|
||||||
|
for p in players:
|
||||||
|
# print(f'player = {p.label} for match_id = {p.match_id}')
|
||||||
|
# print(f'players = {len(matches.get(p.match_id, list()))} for match = {p.match_id}')
|
||||||
|
if p.match_id:
|
||||||
|
# matches.setdefault(p.match_id, []).append(p)
|
||||||
|
if p.match_id not in matches:
|
||||||
|
matches[p.match_id] = [p]
|
||||||
|
elif p not in matches[p.match_id]:
|
||||||
|
matches[p.match_id].append(p)
|
||||||
|
# print(f'player = {p.label} for match = {p.match_id}')
|
||||||
|
# print(f'players = {len(matches[p.match_id])} for match = {p.match_id}')
|
||||||
|
|
||||||
|
# stop early if all assigned
|
||||||
|
if sum(len(v) for v in matches.values()) >= num_players:
|
||||||
|
break
|
||||||
|
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
|
||||||
|
print(f"\n✅ Matchmaking complete — {len(matches)} matches formed\n")
|
||||||
|
|
||||||
|
# ✅ 5) Assign random scenarios per match & run
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
for match_id, grouped_players in matches.items():
|
||||||
|
if len(grouped_players) != 2:
|
||||||
|
print(f"⚠️ Skipping match {match_id} — not 1v1")
|
||||||
|
continue
|
||||||
|
|
||||||
|
p1, p2 = grouped_players
|
||||||
|
scenario = random.choice(TEST_SCENARIOS)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"🎭 Running scenario '{scenario.__name__}' — "
|
||||||
|
f"{p1.label} vs {p2.label} | match={match_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
tasks.append(asyncio.create_task(
|
||||||
|
scenario(match_id, p1, p2)
|
||||||
|
))
|
||||||
|
|
||||||
|
# ✅ 6) Wait for all mock games to finish
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
|
else:
|
||||||
|
print("⚠️ No playable matches found")
|
||||||
|
|
||||||
|
# ✅ 7) Cleanup connections
|
||||||
|
print("\n🧹 Closing player connections...\n")
|
||||||
|
await asyncio.gather(*[p.close() for p in players])
|
||||||
|
|
||||||
|
print("\n🏁 Matchmaking test run complete ✅\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(simulate_matchmaking(6))
|
||||||
354
plugins/games/battleship.go
Normal file
354
plugins/games/battleship.go
Normal 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
23
plugins/games/config.go
Normal 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
53
plugins/games/rules.go
Normal 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
176
plugins/games/tictactoe.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -5,36 +5,83 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"github.com/heroiclabs/nakama-common/runtime"
|
"github.com/heroiclabs/nakama-common/runtime"
|
||||||
|
|
||||||
|
// Project modules
|
||||||
|
"localrepo/plugins/modules"
|
||||||
|
"localrepo/plugins/games"
|
||||||
)
|
)
|
||||||
|
|
||||||
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(
|
func InitModule(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
logger runtime.Logger,
|
logger runtime.Logger,
|
||||||
db *sql.DB,
|
db *sql.DB,
|
||||||
nk runtime.NakamaModule,
|
nk runtime.NakamaModule,
|
||||||
initializer runtime.Initializer,
|
initializer runtime.Initializer,
|
||||||
) error {
|
) error {
|
||||||
if err := initializer.RegisterRpc("hello_world", HelloWorld); err != nil {
|
|
||||||
logger.Error("Failed to register RPC: %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
|
return err
|
||||||
}
|
}
|
||||||
if err := initializer.RegisterMatch("tictactoe", NewMatch); err != nil {
|
|
||||||
logger.Error("Failed to register RPC: %v", err)
|
//--------------------------------------------------------
|
||||||
|
// 2. Register Matchmaker Handler
|
||||||
|
//--------------------------------------------------------
|
||||||
|
if err := initializer.RegisterMatchmakerMatched(modules.MatchmakerMatched); err != nil {
|
||||||
|
logger.Error("Failed to register MatchmakerMatched: %v", err)
|
||||||
return 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,
|
||||||
|
lb,
|
||||||
|
true, // authoritative
|
||||||
|
"desc", // sort order
|
||||||
|
"incr", // operator
|
||||||
|
"", // 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 ready: %s", lb)
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("Go module loaded successfully!")
|
logger.Info("Go module loaded successfully!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
286
plugins/match.go
286
plugins/match.go
@@ -1,286 +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))
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, msg := range messages {
|
|
||||||
if msg.GetOpCode() != OpMove {
|
|
||||||
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: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
playerID := msg.GetUserId()
|
|
||||||
playerIdx := indexOf(s.Players, playerID)
|
|
||||||
if playerIdx != s.Turn {
|
|
||||||
// not your turn
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if move.Row < 0 || move.Row > 2 || move.Col < 0 || move.Col > 2 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.Board[move.Row][move.Col] != "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
symbols := []string{"X", "O"}
|
|
||||||
if playerIdx < 0 || playerIdx >= len(symbols) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
s.Board[move.Row][move.Col] = symbols[playerIdx]
|
|
||||||
|
|
||||||
if winner := checkWinner(s.Board); winner != "" {
|
|
||||||
s.Winner = winner
|
|
||||||
s.GameOver = true
|
|
||||||
} else if fullBoard(s.Board) {
|
|
||||||
s.Winner = "draw"
|
|
||||||
s.GameOver = true
|
|
||||||
} else {
|
|
||||||
s.Turn = 1 - s.Turn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast updated state to everyone
|
|
||||||
stateJSON, _ := json.Marshal(s)
|
|
||||||
if err := dispatcher.BroadcastMessage(OpState, stateJSON, nil, nil, true); err != nil {
|
|
||||||
logger.Error("BroadcastMessage failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
386
plugins/modules/match.go
Normal file
386
plugins/modules/match.go
Normal 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, ""
|
||||||
|
}
|
||||||
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.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
51
plugins/structs/board.go
Normal 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
|
||||||
|
}
|
||||||
11
plugins/structs/match_state.go
Normal file
11
plugins/structs/match_state.go
Normal 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
19
plugins/structs/player.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user