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:
tiennm99 committed 2026-04-09 23:35:46 +07:00
1 parent cdcc3e0623
commit 5ccd4e7ce2
15 files changed
+652 -164

No files matched your search

+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) {