feat: add documentation, code comments, and update Docker config

Rewrite README with usage guide, deployment instructions, and protocol
docs. Update CLAUDE.md to reflect gomoku-only architecture. Add English
doc comments to all key server Go files, replacing Chinese comments.

Create docs/system-architecture.md (state machine, protocol, database
schema) and docs/deployment-guide.md (local dev, Docker, production
nginx, resource requirements).

Update Dockerfile to Go 1.22 with repo-root build context to include
web client. Update docker-compose to match.
This commit is contained in:
2026-04-09 23:35:46 +07:00
parent cdcc3e0623
commit 5ccd4e7ce2
15 changed files with 652 additions and 164 deletions
+59 -57
View File
@@ -4,112 +4,114 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
Monorepo for a multiplayer gomoku (and other board/card games) platform, forked from [ratel-online](https://github.com/ratel-online). Four independent modules: three Go modules and one Java/Spring Boot API.
Multiplayer Gomoku (Five-in-a-Row) platform. Go game server with WebSocket/TCP support, vanilla JS web client with HTML5 Canvas board, and a shared Go core library. Forked from [ratel-online](https://github.com/ratel-online) with card games removed — gomoku is the only game type.
## Architecture
```
client (Go CLI) ──TCP/WS──► server (Go game server)
├── state machine (per-player)
├── in-memory database (hashmaps)
└── protocol abstraction (TCP + WebSocket)
web/ (browser) ──WS──► server/ (:9998, serves static files + /ws endpoint)
client/ (CLI) ──TCP──► server/ (:9999)
├── state machine (per-player goroutine)
│ welcome → home → join/create → waiting → game
├── in-memory database (hashmaps, no persistence)
├── gomoku engine (15x15 board, win detection)
└── protocol abstraction (TCP + WebSocket)
core (Go shared lib) ◄── imported by both server and client
api (Java/Spring Boot) ── standalone REST API (user auth, MySQL, Redis)
core/ (Go shared lib) ◄── imported by server and client
api/ (Java/Spring Boot) ── standalone REST API (optional, not needed for play)
```
**Key design patterns:**
- **State machine per player** (`server/state/`): each state implements `Next(player) -> (nextStateID, error)`. States registered in `state.go` via `Register()`.
- **Protocol abstraction** (`core/protocol/`): `ReadWriteCloser` interface wraps both TCP (4-byte length-prefixed binary) and WebSocket (JSON). Server and client share same interface.
- **Protocol abstraction** (`core/protocol/`): `ReadWriteCloser` interface wraps both TCP (4-byte length-prefixed binary) and WebSocket (JSON `{"data":"<base64>"}` packets).
- **In-memory game store** (`server/database/`): volatile hashmaps for players, rooms, spectators. No persistence — restart loses all state.
- **Channel-based game sync**: game states communicated via `States[playerID] chan int`, not locks.
**Dependency flow:** `server → core`, `client → core`, `api` is isolated (Java).
- **Channel-based game sync**: game turns communicated via `States[playerID] chan int`, not locks. Mutex protects board reads/writes.
- **Server-authoritative**: all move validation and win detection server-side. Web client never modifies board locally.
## Build & Run
### Server (Go)
```bash
cd server
go build -o ratel-server main.go # build
go run main.go -w 9998 -t 9999 # run (WS + TCP)
go build -o gomoku-server main.go
go run main.go -w 9998 -t 9999 -s ../web
# Web UI at http://localhost:9998, WebSocket at ws://localhost:9998/ws
```
Server flags: `-w` (WebSocket port, default 9998), `-t` (TCP port, default 9999), `-bot`, `-bot-token`, `-bot-group` (QQ bot).
Server flags: `-w` (WebSocket port, default 9998), `-t` (TCP port, default 9999), `-s` (static files dir, default `../web`), `-bot`, `-bot-token`, `-bot-group` (QQ bot).
### Client (Go)
### Client (Go CLI)
```bash
cd client
go run main.go -h 127.0.0.1 -p 9999 # TCP
go run main.go -h 127.0.0.1 -p 9998 # WebSocket (auto-detected by port)
```
Client flags: `-h` (host), `-p` (port), `-n` (player name, auto-generated if empty).
### Cross-compilation
```bash
cd server && bash build.sh # Linux/macOS — outputs to target/
cd server && powershell ./build.ps1 # Windows — outputs to target/
go run main.go -h 127.0.0.1 -p 9998 # WebSocket
```
### Docker
```bash
cd server
make build && make run # dev: ports 9998 + 9999
make prod-build && make prod-up # prod: adds nginx on port 80
make logs # view logs
make stop # stop
make build && make run # dev: ports 9998 + 9999
make logs # view logs
make stop # stop
```
### API (Java)
```bash
cd api
mvn spring-boot:run # requires MySQL + Redis configured
```
Config in `api/src/main/resources/application.properties`. Database schema in `api/ratel.sql`.
### Tests
```bash
cd server && go test ./...
cd core && go test ./...
cd client && go test ./...
```
## Module Details
### server/
- `main.go`: entry point, starts TCP + WS listeners concurrently
- `state/`: state machine — `welcome → home → join/create → waiting → game`
- `state/game/`: game implementations (dou dizhu, texas, mahjong, uno, liar, runfast)
- `network/`: TCP (`tcp.go`) and WebSocket (`wss.go`) listeners, both call shared `handle()`
- `database/`: in-memory player/room store, game-specific record types
- `consts/`: state IDs, game types, timeouts (20s rob, 40s play, 60s bet), room properties
- `rule/`: game rule validation
- `render/`: terminal UI rendering
- `skill/`: special ability system for skill-mode games
- `consts/const.go`: state IDs (`StateWelcome` through `StateGomokuGame`), `GameTypeGomoku=1`, board size (15), timeouts, error constants
- `state/state.go`: state machine runner — registers states, loops `Next()` calls per player
- `state/welcome.go`, `home.go`, `join.go`, `create.go`: menu navigation states
- `state/waiting.go`: room lobby — start game, kick players, set room props
- `state/game/gomoku.go`: gomoku game loop — turn handling, move validation, win detection (4 directions), board broadcast
- `database/model.go`: `Player`, `Room`, `RoomGame` interface — core domain types
- `database/gomoku.go`: `Gomoku` struct — board state, player IDs, turn tracking, channel-based sync
- `database/database.go`: in-memory store — room CRUD, player management, broadcast helpers
- `network/network.go`: connection handler — auth, state machine bootstrap
- `network/wss.go`: WebSocket server + static file serving
- `network/tcp.go`: TCP server
- `bot/bot.go`: QQ bot integration (optional)
### core/
- `protocol/`: `Packet` struct + `ReadWriteCloser` interface (TCP and WS implementations)
- `model/`: shared types — `AuthInfo`, `Player`, `Room`, `GameEvent`
- `protocol/protocol.go`: `Packet` struct + `ReadWriteCloser` interface
- `protocol/websocket.go`: WebSocket implementation (JSON `{"data":"<base64>"}`)
- `protocol/tcp.go`: TCP implementation (4-byte length-prefixed binary)
- `model/model.go`: shared types — `AuthInfo`, `Player`, `Room`
- `network/`: `Conn` wrapper with auto-assigned IDs and handler loop
- `util/poker/`: card evaluation, hand ranking for multiple game types
- `pkg/holdem/`: Texas hold'em hand evaluation
### web/
- `index.html`: HTML shell with panels (connect, home, create, waiting, game, log sidebar)
- `js/ws-client.js`: WebSocket client — base64 encode/decode, auth, transaction markers
- `js/state-machine.js`: detects server state from message patterns, routes UI panels
- `js/board-renderer.js`: Canvas 15x15 board — gradient stones, click handling, hover ghost, game-over overlay
- `js/game-controller.js`: bridges WS client + board renderer — handles turn/board/gameover messages
- `js/app.js`: entry point — wires all modules, DOM event bindings
### client/
- `main.go`: CLI entry, connects via TCP or WS based on port
- `ctx/`: connection context, auth flow, packet listener with start/stop markers
- `ctx/`: connection context, auth flow, packet listener
- `shell/`: wraps context, manages player session
- `api/`: login HTTP calls to the Java API (port 9088)
### api/ (Java)
- Spring Boot 2.3.1, MySQL + Redis + MyBatis
- Controllers: `AuthController`, `UserController`, `MailController`
- Single `user` table (see `ratel.sql`)
- Spring Boot 2.3.1, MySQL + Redis + MyBatis (optional, not required for gameplay)
## Networking Protocol
Auth flow: client sends `AuthInfo` JSON within 3 seconds → server creates player → state machine starts.
**Auth flow**: client sends `AuthInfo{id, name, score}` JSON within 3 seconds → server creates player → state machine starts.
Packet format (TCP): `[4-byte big-endian length][JSON payload]`. WebSocket uses native JSON messages.
**Packet format**: TCP uses `[4-byte big-endian length][payload]`. WebSocket uses JSON `{"data":"<base64-encoded-payload>"}`.
**Game messages** (server → client, JSON strings):
- `{"type":"info","color":1}` — player color assignment (1=black, 2=white)
- `{"type":"board","board":[[...]],"last":[r,c],"turn":1}` — full board state
- `{"type":"turn","color":1}` — it's your turn
- `{"type":"gameover","winner":"Name"}` or `{"type":"gameover","draw":true}`
**Player input** (client → server): plain strings — `"1"` for join, `"2"` for new, `"7,7"` for moves.
+194 -20
View File
@@ -1,28 +1,202 @@
# gomoku
# Gomoku Online
A simple Gomoku game with networking. Based on [Ratel](https://github.com/ratel-online/).
A multiplayer Gomoku (Five-in-a-Row) game with a Go server and web client. Players connect via WebSocket, create or join rooms, and play on a 15x15 board rendered on HTML5 Canvas.
Based on [Ratel Online](https://github.com/ratel-online/).
## Quick Start
```bash
# Build and run the server
cd server
go build -o gomoku-server main.go
./gomoku-server -w 9998 -t 9999 -s ../web
# Open http://localhost:9998 in your browser
# Open a second tab to play against yourself
```
## Project Structure
This is a monorepo containing the following components:
```
gomoku/
server/ Go game server (WebSocket + TCP, state machine, in-memory DB)
core/ Shared Go library (protocol, models, networking)
client/ Go CLI client (terminal-based)
web/ Browser client (vanilla JS, HTML5 Canvas)
api/ Java REST API (user auth, MySQL — optional, not required for play)
```
| Directory | Description | Language | Source |
|-----------|-------------|----------|--------|
| `server/` | Game server with multiplayer support | Go | [ratel-online/server](https://github.com/ratel-online/server) |
| `core/` | Shared core logic, models, and networking | Go | [ratel-online/core](https://github.com/ratel-online/core) |
| `client/` | CLI game client | Go | [ratel-online/client](https://github.com/ratel-online/client) |
| `api/` | API definitions | Java | [ratel-online/api](https://github.com/ratel-online/api) |
## Architecture
```
Browser (web/) ──WebSocket──> server/ (:9998)
|
CLI (client/) ──TCP/WS─────> |── state machine (per player)
|── in-memory database (rooms, players)
|── gomoku game engine (15x15, win detection)
|
|── serves web/ as static files at /
|── WebSocket endpoint at /ws
core/ ── shared protocol, models, networking (imported by server + client)
```
**Key design decisions:**
- **Server-authoritative**: All move validation and win detection happens server-side. The web client never updates the board locally — it waits for the server's broadcast.
- **State machine per player**: Each connected player runs through states: `welcome -> home -> join/create -> waiting -> game`. States are registered in `server/state/state.go`.
- **In-memory store**: All game state lives in memory. Server restart loses all rooms and games. This is intentional — gomoku games are short-lived.
- **No build step for web client**: Vanilla JS, no bundler, no npm. Just serve the files.
## How to Play
### Web Client (recommended)
1. Start the server (see Quick Start above)
2. Open `http://localhost:9998` in your browser
3. Enter a player name and click **Connect**
4. Click **New Room** to create a gomoku room
5. Select **Gomoku** from the game type list
6. Share the room with another player (they click **Join Room** and select the room ID)
7. Room owner clicks **Start Game** when both players are in
8. Black plays first — click on a board intersection to place a stone
9. First player to get 5 in a row (horizontal, vertical, or diagonal) wins
### CLI Client
```bash
cd client
go run main.go -h 127.0.0.1 -p 9999 # TCP connection
go run main.go -h 127.0.0.1 -p 9998 # WebSocket connection
```
In the CLI, type numbers to navigate menus and `row,col` to place stones (e.g., `7,7`).
### Room Commands (in waiting state)
| Command | Description |
|---------|-------------|
| `start` or `s` | Start the game (owner only, needs 2 players) |
| `ls` or `v` | View room players and settings |
| `kicking <id>` or `k <id>` | Kick a player by ID (owner only) |
| `set pwd <password>` | Set room password |
| `set ip on/off` | Show/hide player IPs |
| `exit` or `e` | Leave the room |
## Server Configuration
```bash
./gomoku-server [flags]
```
| Flag | Default | Description |
|------|---------|-------------|
| `-w` | `9998` | WebSocket port (also serves web client) |
| `-t` | `9999` | TCP port (for CLI client) |
| `-s` | `../web` | Path to web client static files directory |
| `-bot` | (empty) | QQ bot connection address (optional) |
| `-bot-token` | (empty) | QQ bot authentication token |
| `-bot-group` | `0` | QQ bot group ID for notifications |
## Deployment
### Docker
```bash
cd server
# Development
make build && make run
# -> WebSocket + Web UI at http://localhost:9998
# -> TCP at localhost:9999
# View logs
make logs
# Stop
make stop
# Clean up
make clean
```
### Docker Compose (manual)
```bash
cd server
docker compose up -d
```
This exposes:
- Port `9998`: WebSocket server + web client UI
- Port `9999`: TCP server for CLI clients
### Production
For production deployment behind a reverse proxy:
1. Build the Docker image: `docker build -t gomoku-server:latest server/`
2. Run with your orchestrator (Docker Compose, Kubernetes, etc.)
3. Point a reverse proxy (nginx, Caddy) at port `9998` with WebSocket upgrade support
4. Ensure the `/ws` path proxies WebSocket connections correctly
Example nginx config:
```nginx
server {
listen 80;
server_name gomoku.example.com;
location / {
proxy_pass http://localhost:9998;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}
```
### Cross-compilation
```bash
cd server
bash build.sh # Linux/macOS — outputs to target/
powershell ./build.ps1 # Windows — outputs to target/
```
## Development
### Prerequisites
- Go 1.22+
- Modern browser (Chrome, Firefox, Edge) for web client
- Docker + Docker Compose (optional, for containerized deployment)
### Build
```bash
cd server && go build ./...
cd core && go build ./...
cd client && go build ./...
```
### Tests
```bash
cd server && go test ./...
cd core && go test ./...
```
### Protocol
The server uses a JSON-over-WebSocket protocol:
- **Wire format**: `{"data":"<base64-encoded-payload>"}` — Go's `[]byte` serializes as base64 in JSON
- **Auth flow**: Client sends `{"id":0, "name":"PlayerName", "score":0}` within 3 seconds of connecting
- **State signals**: `INTERACTIVE_SIGNAL_START` / `INTERACTIVE_SIGNAL_STOP` control when the client can send input
- **Game messages** (JSON): `{"type":"board",...}`, `{"type":"turn",...}`, `{"type":"gameover",...}`
- **Text messages**: Plain strings for menus, errors, and chat
## Credits
This project is built upon the work of the [Ratel Online](https://github.com/ratel-online) organization. The original repositories are licensed under the **MIT License**.
### Original Authors & Contributors
- [ainilili](https://github.com/ainilili) — Primary author
- [feel-easy](https://github.com/feel-easy)
- [mlmdflr](https://github.com/mlmdflr)
- [mikodream](https://github.com/mikodream)
- [EldersJavas](https://github.com/EldersJavas)
See the `LICENSE` file in each subdirectory for the original MIT license terms.
Built upon [Ratel Online](https://github.com/ratel-online) by [ainilili](https://github.com/ainilili) and contributors. Licensed under MIT — see `LICENSE` files in each subdirectory.
+165
View File
@@ -0,0 +1,165 @@
# Deployment Guide
## Prerequisites
| Requirement | Version | Purpose |
|-------------|---------|---------|
| Go | 1.22+ | Build server and CLI client |
| Docker | 20+ | Containerized deployment |
| Docker Compose | v2+ | Orchestration |
| Modern browser | Chrome/Firefox/Edge | Web client |
## Local Development
### 1. Build and Run Server
```bash
cd server
go build -o gomoku-server main.go
./gomoku-server -w 9998 -t 9999 -s ../web
```
This starts:
- **Port 9998**: WebSocket server + web client UI at `http://localhost:9998`
- **Port 9999**: TCP server for CLI clients
### 2. Open Web Client
Open `http://localhost:9998` in two browser tabs. Enter names, connect, create a room, join it, and start playing.
### 3. CLI Client (optional)
```bash
cd client
go run main.go -h 127.0.0.1 -p 9999
```
### Server Flags
| Flag | Default | Description |
|------|---------|-------------|
| `-w` | `9998` | WebSocket port (serves web UI + `/ws` endpoint) |
| `-t` | `9999` | TCP port (CLI client) |
| `-s` | `../web` | Static files directory for web client |
| `-bot` | *(empty)* | QQ bot address (optional) |
| `-bot-token` | *(empty)* | QQ bot token |
| `-bot-group` | `0` | QQ group ID for notifications |
## Docker Deployment
### Development
```bash
cd server
make build # builds Docker image from repo root context
make run # starts container, maps ports 9998 + 9999
make logs # tail container logs
make stop # stop container
make clean # remove container and image
```
### Manual Docker Compose
```bash
cd server
docker compose up -d
```
The `docker-compose.yaml` sets build context to the repo root (`..`) so the Dockerfile can access both `server/` and `web/`.
### Standalone Docker Build
From the **repo root**:
```bash
docker build -t gomoku-server:latest -f server/Dockerfile .
docker run -d -p 9998:9998 -p 9999:9999 --name gomoku gomoku-server:latest
```
## Production Deployment
### Reverse Proxy (nginx)
The server handles both HTTP (static files) and WebSocket (`/ws`) on the same port. Your reverse proxy must support WebSocket upgrade:
```nginx
server {
listen 80;
server_name gomoku.example.com;
location / {
proxy_pass http://gomoku-server:9998;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
### HTTPS / TLS
Add TLS termination at the reverse proxy layer. The Go server itself does not handle TLS. Clients will connect via `wss://` automatically when the page is served over HTTPS.
### Resource Requirements
The server is lightweight — all state is in-memory:
| Metric | Estimate |
|--------|----------|
| Memory | ~50MB base + ~1KB per active player |
| CPU | Minimal — one goroutine per player |
| Disk | None (no persistence) |
| Network | ~1KB per move (JSON board broadcast) |
### Container Limits (docker-compose defaults)
- CPU: 2 cores max, 0.5 reserved
- Memory: 512MB max, 128MB reserved
- Log rotation: 10MB per file, 3 files max
### Health Checks
The Docker container includes a health check that tests TCP connectivity to port 9998. For external monitoring:
```bash
# WebSocket port
curl -s -o /dev/null -w "%{http_code}" http://localhost:9998
# Should return 200 (serves index.html)
# TCP port
nc -z localhost 9999
```
## Cross-Compilation
Build server binaries for multiple platforms:
```bash
cd server
# Linux/macOS
bash build.sh # outputs to target/
# Windows
powershell ./build.ps1 # outputs to target/
```
## Important Notes
### No Persistence
All game state (rooms, players, active games) is stored in memory. A server restart clears everything. This is by design — gomoku games are short-lived sessions, not long-running persistent state.
### No Authentication Required
The web client sends `id: 0` during auth — the server assigns a real ID from its connection counter. No database, no passwords, no sessions. The optional Java `api/` module provides user auth via MySQL if needed, but it is not required for gameplay.
### Single-Process Architecture
The server runs as a single Go process. There is no clustering or horizontal scaling — a single instance handles all connections. For most use cases (dozens of concurrent games), this is sufficient.
### WebSocket Path
The WebSocket endpoint is always at `/ws`. The static file server handles all other paths. Do not configure your reverse proxy to intercept `/ws` — it must pass through to the Go server.
+138
View File
@@ -0,0 +1,138 @@
# System Architecture
## Overview
Gomoku Online is a multiplayer game platform with a Go server, vanilla JS web client, and optional CLI client. The server manages all game state in memory and communicates with clients over WebSocket (web) and TCP (CLI).
## Component Diagram
```
+-------------------+ WebSocket (:9998) +-------------------+
| Web Client | ◄──────────────────────► | Game Server |
| (browser) | /ws endpoint | (Go) |
| | | |
| ws-client.js | HTTP (:9998) | main.go |
| state-machine.js | ◄────── static files ──── | network/wss.go |
| board-renderer.js| / endpoint | network/tcp.go |
| game-controller.js | network/network.go
| app.js | | |
+-------------------+ | state/ |
| state.go |
+-------------------+ TCP (:9999) | welcome.go |
| CLI Client | ◄──────────────────────► | home.go |
| (Go terminal) | binary protocol | join.go |
+-------------------+ | create.go |
| waiting.go |
| game/gomoku.go |
| |
| database/ |
| model.go |
| database.go |
| gomoku.go |
| |
| consts/const.go |
+-------------------+
```
## State Machine
Every connected player runs a per-goroutine state machine. States are registered in `state/state.go` and each implements the `State` interface:
```go
type State interface {
Next(player *database.Player) (consts.StateID, error)
Exit(player *database.Player) consts.StateID
}
```
### State Flow
```
Welcome ──► Home ──► Join ──► Waiting ──► GomokuGame ──► Waiting
└── Create ──► Waiting (game ends)
└── Home (player exits room)
```
| State | ID | Description |
|-------|-----|-------------|
| Welcome | 1 | Sends greeting, auto-transitions to Home |
| Home | 2 | Menu: "1.Join" or "2.New" |
| Join | 3 | Lists rooms, player picks one by ID |
| Create | 4 | Lists game types, creates room, auto-joins |
| Waiting | 5 | Room lobby: owner starts game, players chat/wait |
| GomokuGame | 6 | Active gomoku game loop |
## Networking Protocol
### Wire Formats
**WebSocket** (`/ws` on port 9998):
- JSON packets: `{"data":"<base64-encoded-payload>"}`
- Go's `[]byte` serializes as base64 in JSON, so the web client must `btoa()`/`atob()`
**TCP** (port 9999):
- Binary: `[4-byte big-endian length][payload bytes]`
- Same logical `Packet{Body}` struct, different encoding
### Auth Flow
1. Client connects (WS or TCP)
2. Server waits up to 3 seconds for an `AuthInfo` JSON packet
3. `AuthInfo` fields: `id` (0 for web clients, server assigns real ID), `name`, `score`
4. Server creates a `Player`, starts the state machine goroutine
5. Connection enters the read loop (`Player.Listening()`)
### Transaction Markers
The server uses `INTERACTIVE_SIGNAL_START` and `INTERACTIVE_SIGNAL_STOP` to tell the client when input is expected. The web client enables/disables interactivity based on these signals.
### Gomoku Game Messages
Server sends JSON strings (not wrapped in the usual `Packet` format — they're the `Body` content):
| Message | Direction | Description |
|---------|-----------|-------------|
| `{"type":"info","color":1}` | server→client | Your color assignment (1=black, 2=white) |
| `{"type":"board","board":[[...]],"last":[r,c],"turn":1}` | server→client | Full 15x15 board state after each move |
| `{"type":"turn","color":1}` | server→client | It's your turn to play |
| `{"type":"gameover","winner":"Name"}` | server→client | Game ended with a winner |
| `{"type":"gameover","draw":true}` | server→client | Game ended in a draw |
| `"7,7"` | client→server | Place stone at row 7, column 7 |
## In-Memory Database
All state lives in concurrent hashmaps (no persistence):
| Store | Key | Value | Purpose |
|-------|-----|-------|---------|
| `players` | player ID | `*Player` | All players ever connected |
| `connPlayers` | player ID | `*Player` | Currently connected players |
| `rooms` | room ID | `*Room` | Active game rooms |
| `roomPlayers` | room ID | `map[playerID]bool` | Players in each room |
| `roomSpectators` | room ID | `map[playerID]int` | Spectators (with join order) |
Rooms are auto-cleaned after 24 hours of inactivity or when all players disconnect.
## Gomoku Game Engine
- **Board**: 15x15 integer array (0=empty, 1=black, 2=white)
- **Turn sync**: channel-based — `States[playerID] chan int` with buffer size 2
- **Move validation**: bounds check, cell empty, correct turn (all server-side)
- **Win detection**: checks 4 directions (horizontal, vertical, 2 diagonals) from last placed stone, counting consecutive same-color stones in both directions
- **Draw**: board full (225 moves) with no winner
- **Concurrency**: `sync.Mutex` on the `Gomoku` struct protects board reads/writes
## Web Client Architecture
Vanilla JavaScript, no framework, no build step. Five modules loaded via `<script>` tags:
| Module | Responsibility |
|--------|---------------|
| `ws-client.js` | WebSocket connection, base64 encode/decode, auth, transaction markers |
| `state-machine.js` | Detects server state from message text patterns, shows/hides UI panels |
| `board-renderer.js` | Canvas 15x15 board, gradient stones, click-to-place, hover ghost, game-over overlay |
| `game-controller.js` | Bridges WS client and board renderer — routes game JSON messages |
| `app.js` | Entry point — wires modules, binds DOM events |
The web client is fully server-authoritative: clicking the board sends `"row,col"` to the server and waits for the server's board broadcast before rendering.
+19 -30
View File
@@ -1,56 +1,45 @@
# 构建阶段
FROM golang:1.17-alpine AS builder
# Build stage
FROM golang:1.22-alpine AS builder
# 设置工作目录
WORKDIR /app
# 安装必要的构建工具
RUN apk add --no-cache git
# 复制 go.mod 和 go.sum 文件
COPY go.mod go.sum ./
# Copy server source and build
COPY server/go.mod server/go.sum ./server/
RUN cd server && go mod download
# 下载依赖
RUN go mod download
COPY server/ ./server/
RUN cd server && CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o /app/gomoku-server main.go
# 复制源代码
COPY . .
# Copy web client
COPY web/ ./web/
# 构建应用
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o ratel-server main.go
# 运行阶段
# Runtime stage
FROM alpine:latest
# 安装必要的运行时依赖
RUN apk --no-cache add ca-certificates tzdata
# 设置时区为上海
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 创建非root用户
RUN addgroup -g 1000 -S ratel && \
adduser -u 1000 -S ratel -G ratel
RUN addgroup -g 1000 -S gomoku && \
adduser -u 1000 -S gomoku -G gomoku
# 设置工作目录
WORKDIR /app
# 从构建阶段复制二进制文件
COPY --from=builder /app/ratel-server .
COPY --from=builder /app/gomoku-server .
COPY --from=builder /app/web ./web
# 更改文件所有权
RUN chown -R ratel:ratel /app
RUN chown -R gomoku:gomoku /app
# 切换到非root用户
USER ratel
USER gomoku
# 暴露端口
# WebSocket + web UI on 9998, TCP on 9999
EXPOSE 9998 9999
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD nc -z localhost 9998 && nc -z localhost 9999 || exit 1
# 启动应用
CMD ["./ratel-server"]
# -s ./web serves web client at http://host:9998/
CMD ["./gomoku-server", "-w", "9998", "-t", "9999", "-s", "./web"]
+12 -10
View File
@@ -1,3 +1,5 @@
// Package bot provides optional QQ group messaging via the Milky SDK.
// Used to send game notifications to a QQ chat group. Not required for gameplay.
package bot
import (
@@ -6,13 +8,13 @@ import (
"github.com/ratel-online/core/log"
)
// Session 全局机器人会话
// Session is the global bot connection. Nil when bot is not configured.
var Session *Milky_go_sdk.Session
// GroupID 群ID
// GroupID is the QQ group to send messages to.
var GroupID int64
// Logger 实现 Milky_go_sdk 的 Logger 接口
// Logger adapts the core logger to the Milky SDK's Logger interface.
type Logger struct{}
func (l *Logger) Infof(format string, args ...interface{}) {
@@ -48,7 +50,7 @@ func (l *Logger) Warn(args ...interface{}) {
log.Info(fmt.Sprint(args...))
}
// SendGroupMessage 发送群消息
// SendGroupMessage sends a text message to the configured QQ group.
func SendGroupMessage(groupID int64, content string) error {
if Session == nil {
return fmt.Errorf("bot not connected")
@@ -59,25 +61,25 @@ func SendGroupMessage(groupID int64, content string) error {
return err
}
// Connect 连接机器人
// Connect establishes a WebSocket connection to the Milky bot service.
func Connect(addr, token string, groupID int64) error {
GroupID = groupID
m, err := Milky_go_sdk.New("ws://"+addr+"/event", "http://"+addr+"/api", token, &Logger{})
if err != nil {
return fmt.Errorf("创建Bot会话失败: %v", err)
return fmt.Errorf("failed to create bot session: %v", err)
}
err = m.Open()
if err != nil {
return fmt.Errorf("连接Bot失败: %v", err)
return fmt.Errorf("failed to connect bot: %v", err)
}
Session = m
log.Infof("Bot已连接: %s", addr)
log.Infof("Bot connected: %s", addr)
return nil
}
// Close 关闭机器人连接
// Close gracefully shuts down the bot connection.
func Close() {
if Session != nil {
Session.Close()
}
}
}
+14 -16
View File
@@ -16,13 +16,14 @@ import (
"github.com/ratel-online/server/consts"
)
// In-memory data store. All state is volatile — server restart clears everything.
var roomIds int64 = 0
var players = hashmap.New() // 存储连接过服务器的全部用户
var connPlayers = hashmap.New()
var rooms = hashmap.New()
var roomPlayers = hashmap.New()
var roomSpectators = hashmap.New()
var roomKickedPlayers = hashmap.New()
var players = hashmap.New() // all players ever connected (by ID)
var connPlayers = hashmap.New() // currently connected players
var rooms = hashmap.New() // active rooms (by room ID)
var roomPlayers = hashmap.New() // map[roomID] -> map[playerID]bool
var roomSpectators = hashmap.New() // map[roomID] -> map[playerID]int (join order)
var roomKickedPlayers = hashmap.New() // map[roomID] -> map[playerID]bool
var roomPropsSetter = map[string]func(r *Room, v string){
consts.RoomPropsPassword: func(r *Room, v string) {
if v == "off" {
@@ -59,9 +60,9 @@ func Connected(conn *network.Conn, info *modelx.AuthInfo) *Player {
Name: strings.Desensitize(info.Name),
Amount: 2000,
}
player.Conn(conn) // 初始化play对象
players.Set(conn.ID(), player) // 写入用户池
connPlayers.Set(conn.ID(), player) // 写入连接用户池
player.Conn(conn)
players.Set(conn.ID(), player)
connPlayers.Set(conn.ID(), player)
return player
}
@@ -122,12 +123,11 @@ func getPlayer(playerId int64) *Player {
}
func SetRoomProps(room *Room, k, v string) {
// 根据房间类型限制可设置的属性
// Only allow properties appropriate for the game type
allowedProps := getAllowedPropsByGameType(room.Type)
// 检查属性是否允许设置
if !allowedProps[k] {
return // 不允许的属性直接返回,不执行设置
return
}
if setter, ok := roomPropsSetter[k]; ok {
@@ -181,9 +181,9 @@ func IsValidPlayer(roomId, playerId int64) bool {
return false
}
// 加入房间
// JoinRoom adds a player to a room. If the room is full or running, the player
// becomes a spectator instead. Returns an error if the player was previously kicked.
func JoinRoom(roomId, playerId int64) error {
// 资源检查
player := getPlayer(playerId)
if player == nil {
return consts.ErrorsExist
@@ -193,7 +193,6 @@ func JoinRoom(roomId, playerId int64) error {
return consts.ErrorsRoomInvalid
}
// 加锁防止并发异常
room.Lock()
defer room.Unlock()
@@ -203,7 +202,6 @@ func JoinRoom(roomId, playerId int64) error {
room.ActiveTime = time.Now()
//房间人数及状态检查
if room.Players >= room.MaxPlayers || room.State == consts.RoomStateRunning {
spectatorsIds := getRoomSpectators(roomId)
spectatorsIds[playerId] = len(spectatorsIds)
+26 -6
View File
@@ -14,6 +14,7 @@ import (
"github.com/ratel-online/server/consts"
)
// Role represents a player's role within a room.
type Role string
const (
@@ -22,6 +23,12 @@ const (
RoleSpectator Role = "spectator"
)
// Player represents a connected user. Public fields are serializable state;
// private fields manage the network connection and state machine lifecycle.
//
// The `data` channel receives packets from the client when `read` is true
// (between StartTransaction/StopTransaction). The state machine goroutine
// reads from this channel via AskFor* methods.
type Player struct {
ID int64 `json:"id"`
IP string `json:"ip"`
@@ -32,11 +39,11 @@ type Player struct {
RoomID int64 `json:"roomId"`
Role Role `json:"role"`
conn *network.Conn
data chan *protocol.Packet
read bool
state consts.StateID
online bool
conn *network.Conn // underlying network connection
data chan *protocol.Packet // buffered channel for client input
read bool // true when accepting input (inside a transaction)
state consts.StateID // current state machine state
online bool // false after disconnect
}
func (p *Player) Write(bytes []byte) error {
@@ -49,6 +56,8 @@ func (p *Player) IsOnline() bool {
return p.online
}
// Offline marks the player as disconnected, closes the connection, and
// cleans up room membership. Called when the network read loop exits.
func (p *Player) Offline() {
p.online = false
_ = p.conn.Close()
@@ -65,6 +74,9 @@ func (p *Player) Offline() {
}
}
// Listening is the main read loop. It reads packets from the network connection
// and forwards them to the data channel when the state machine is accepting input.
// Blocks until the connection is closed or errors.
func (p *Player) Listening() error {
loopCount := 0
for {
@@ -83,7 +95,8 @@ func (p *Player) Listening() error {
}
}
// 向客户端发生消息
// WriteString sends a text message to the client. The 30ms sleep prevents
// message flooding when the server sends multiple messages in rapid succession.
func (p *Player) WriteString(data string) error {
time.Sleep(30 * time.Millisecond)
return p.conn.Write(protocol.Packet{
@@ -165,11 +178,14 @@ func (p *Player) AskForStringWithoutTransaction(timeout ...time.Duration) (strin
return packet.String(), nil
}
// StartTransaction enables input acceptance and sends the INTERACTIVE_SIGNAL_START
// marker to the client. The web client uses this to know when to enable the input field.
func (p *Player) StartTransaction() {
p.read = true
_ = p.WriteString(consts.IsStart)
}
// StopTransaction disables input acceptance and sends INTERACTIVE_SIGNAL_STOP.
func (p *Player) StopTransaction() {
p.read = false
_ = p.WriteString(consts.IsStop)
@@ -200,10 +216,14 @@ func (p Player) String() string {
return fmt.Sprintf("%s[%d]", p.Name, p.ID)
}
// RoomGame is implemented by each game type's state struct (e.g., Gomoku).
// Clean() is called when the room is deleted to close channels and free resources.
type RoomGame interface {
Clean()
}
// Room represents a game room. Players join a room, wait for enough players,
// then the owner starts the game. The Game field holds the active game state.
type Room struct {
sync.Mutex
+11 -16
View File
@@ -1,24 +1,22 @@
version: '3.8'
services:
ratel-server:
gomoku-server:
build:
context: .
dockerfile: Dockerfile
image: ratel-server:latest
container_name: ratel-server
# Build from repo root so Dockerfile can access both server/ and web/
context: ..
dockerfile: server/Dockerfile
image: gomoku-server:latest
container_name: gomoku-server
restart: unless-stopped
ports:
- "9998:9998" # WebSocket端口
- "9999:9999" # TCP端口
- "9998:9998" # WebSocket + web UI
- "9999:9999" # TCP (CLI client)
environment:
- TZ=Asia/Shanghai
# volumes:
# # 如果需要持久化日志,可以取消下面的注释
# - ./logs:/app/logs
networks:
- ratel-network
command: ["./ratel-server", "-w", "9998", "-t", "9999"]
- gomoku-network
command: ["./gomoku-server", "-w", "9998", "-t", "9999", "-s", "./web"]
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "9998"]
interval: 30s
@@ -40,8 +38,5 @@ services:
memory: 128M
networks:
ratel-network:
gomoku-network:
driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16
+2 -2
View File
@@ -29,13 +29,13 @@ func main() {
flag.Int64Var(&BotGroup, "bot-group", 0, "Bot group ID")
flag.Parse()
// 连接机器人
// Connect QQ bot if configured
if BotAddr != "" && BotToken != "" && BotGroup != 0 {
err := bot.Connect(BotAddr, BotToken, BotGroup)
if err != nil {
log.Panic(fmt.Sprintf("连接Bot失败: %v", err))
}
// 发送测试消息到 BotGroup
// Send test message to the bot group
err = bot.SendGroupMessage(BotGroup, "Server started!")
if err != nil {
log.Errorf("发送群消息失败: %v", err)
+4 -2
View File
@@ -17,8 +17,9 @@ type Network interface {
Serve() error
}
// handle processes a new connection: wraps it, authenticates within 3 seconds,
// creates a Player, starts the state machine goroutine, and blocks on Listening.
func handle(rwc protocol.ReadWriteCloser) error {
// 给新进入的用户分配资源
c := network.Wrapper(rwc)
defer func() {
err := c.Close()
@@ -39,7 +40,8 @@ func handle(rwc protocol.ReadWriteCloser) error {
return player.Listening()
}
// 登陆验签
// loginAuth reads an AuthInfo JSON packet from the connection within 3 seconds.
// If the client doesn't authenticate in time, it returns ErrorsAuthFail.
func loginAuth(c *network.Conn) (*model.AuthInfo, error) {
authChan := make(chan *model.AuthInfo)
defer close(authChan)
+3 -3
View File
@@ -9,6 +9,8 @@ import (
"github.com/ratel-online/server/database"
)
// create handles room creation. Prompts for game type, creates the room, and
// automatically joins the creator. Transitions to waiting state.
type create struct{}
func (*create) Next(player *database.Player) (consts.StateID, error) {
@@ -16,7 +18,6 @@ func (*create) Next(player *database.Player) (consts.StateID, error) {
if err != nil {
return 0, err
}
// 创建房间
room := database.CreateRoom(player.ID, gameType)
err = player.WriteString(fmt.Sprintf("Create room successful, id : %d\n", room.ID))
if err != nil {
@@ -33,7 +34,7 @@ func (*create) Exit(_ *database.Player) consts.StateID {
return consts.StateHome
}
// 询问游戏类型
// askForGameType displays the list of available game types and waits for the player's selection.
func askForGameType(player *database.Player) (gameType int, err error) {
buf := bytes.Buffer{}
buf.WriteString("Please select game type\n")
@@ -52,7 +53,6 @@ func askForGameType(player *database.Player) (gameType int, err error) {
_ = player.WriteError(consts.ErrorsGameTypeInvalid)
return 0, consts.ErrorsGameTypeInvalid
}
// check game type.
if _, ok := consts.GameTypes[gameType]; !ok {
_ = player.WriteError(consts.ErrorsGameTypeInvalid)
return 0, consts.ErrorsGameTypeInvalid
+1
View File
@@ -6,6 +6,7 @@ import (
"github.com/ratel-online/server/database"
)
// home is the main menu. Player chooses to join an existing room or create a new one.
type home struct{}
func (*home) Next(player *database.Player) (consts.StateID, error) {
+3 -2
View File
@@ -8,6 +8,8 @@ import (
"strconv"
)
// join displays the room list and lets the player pick one to join.
// If the room has a password, the player must enter it before joining.
type join struct{}
func (s *join) Next(player *database.Player) (consts.StateID, error) {
@@ -44,7 +46,6 @@ func (s *join) Next(player *database.Player) (consts.StateID, error) {
return 0, player.WriteError(consts.ErrorsRoomInvalid)
}
//房间存在密码,要求输入密码
pwd := room.Password
if pwd != "" {
err = verifyPassword(player, pwd)
@@ -68,7 +69,7 @@ func (*join) Exit(player *database.Player) consts.StateID {
return consts.StateHome
}
// 校验密码
// verifyPassword prompts the player for the room password and validates it.
func verifyPassword(player *database.Player, pwd string) error {
err := player.WriteString("Please input room password: \n")
if err != nil {
+1
View File
@@ -7,6 +7,7 @@ import (
"github.com/ratel-online/server/database"
)
// welcome is the initial state. Sends a greeting and transitions to home.
type welcome struct{}
func (*welcome) Next(player *database.Player) (consts.StateID, error) {