mirror of
https://github.com/tiennm99/gomoku.git
synced 2026-10-03 07:13:00 +00:00
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:
1 parent
cdcc3e0623
commit
5ccd4e7ce2
15 files changed
+652
-164
No files matched your search
+19
-30
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user