first commit

This commit is contained in:
2026-02-26 17:52:48 +09:00
commit dabf1f3ba9
49 changed files with 14883 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
package network
import (
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"google.golang.org/protobuf/proto"
"a301_game_server/pkg/logger"
)
// ConnState represents the lifecycle state of a connection.
type ConnState int32
const (
ConnStateActive ConnState = iota
ConnStateClosed
)
// Connection wraps a WebSocket connection with send buffering and lifecycle management.
type Connection struct {
id uint64
ws *websocket.Conn
sendCh chan []byte
handler PacketHandler
state atomic.Int32
closeOnce sync.Once
maxMessageSize int64
heartbeatInterval time.Duration
heartbeatTimeout time.Duration
}
// PacketHandler processes incoming packets from a connection.
type PacketHandler interface {
OnPacket(conn *Connection, pkt *Packet)
OnDisconnect(conn *Connection)
}
// NewConnection creates a new Connection wrapping the given WebSocket.
func NewConnection(id uint64, ws *websocket.Conn, handler PacketHandler, sendChSize int, maxMsgSize int64, hbInterval, hbTimeout time.Duration) *Connection {
c := &Connection{
id: id,
ws: ws,
sendCh: make(chan []byte, sendChSize),
handler: handler,
maxMessageSize: maxMsgSize,
heartbeatInterval: hbInterval,
heartbeatTimeout: hbTimeout,
}
c.state.Store(int32(ConnStateActive))
return c
}
// ID returns the connection's unique identifier.
func (c *Connection) ID() uint64 { return c.id }
// Start launches the read and write goroutines.
func (c *Connection) Start() {
go c.readLoop()
go c.writeLoop()
}
// Send encodes and queues a message for sending. Non-blocking: drops if buffer is full.
func (c *Connection) Send(msgType uint16, msg proto.Message) {
if c.IsClosed() {
return
}
data, err := Encode(msgType, msg)
if err != nil {
logger.Error("encode failed", "connID", c.id, "msgType", msgType, "error", err)
return
}
select {
case c.sendCh <- data:
default:
logger.Warn("send buffer full, dropping message", "connID", c.id, "msgType", msgType)
}
}
// SendRaw queues pre-encoded data for sending. Non-blocking.
func (c *Connection) SendRaw(data []byte) {
if c.IsClosed() {
return
}
select {
case c.sendCh <- data:
default:
logger.Warn("send buffer full, dropping raw message", "connID", c.id)
}
}
// Close terminates the connection.
func (c *Connection) Close() {
c.closeOnce.Do(func() {
c.state.Store(int32(ConnStateClosed))
close(c.sendCh)
_ = c.ws.Close()
})
}
// IsClosed returns true if the connection has been closed.
func (c *Connection) IsClosed() bool {
return ConnState(c.state.Load()) == ConnStateClosed
}
func (c *Connection) readLoop() {
defer func() {
c.handler.OnDisconnect(c)
c.Close()
}()
c.ws.SetReadLimit(c.maxMessageSize)
_ = c.ws.SetReadDeadline(time.Now().Add(c.heartbeatTimeout))
c.ws.SetPongHandler(func(string) error {
_ = c.ws.SetReadDeadline(time.Now().Add(c.heartbeatTimeout))
return nil
})
for {
msgType, data, err := c.ws.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
logger.Debug("read error", "connID", c.id, "error", err)
}
return
}
if msgType != websocket.BinaryMessage {
continue
}
pkt, err := Decode(data)
if err != nil {
logger.Warn("decode error", "connID", c.id, "error", err)
continue
}
c.handler.OnPacket(c, pkt)
}
}
func (c *Connection) writeLoop() {
ticker := time.NewTicker(c.heartbeatInterval)
defer ticker.Stop()
for {
select {
case data, ok := <-c.sendCh:
if !ok {
_ = c.ws.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
}
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.ws.WriteMessage(websocket.BinaryMessage, data); err != nil {
logger.Debug("write error", "connID", c.id, "error", err)
return
}
case <-ticker.C:
_ = c.ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}

121
internal/network/packet.go Normal file
View File

@@ -0,0 +1,121 @@
package network
import (
"encoding/binary"
"errors"
"fmt"
"google.golang.org/protobuf/proto"
pb "a301_game_server/proto/gen/pb"
)
// Message type IDs — the wire protocol uses 2-byte type prefixes.
const (
// Auth
MsgLoginRequest uint16 = 0x0001
MsgLoginResponse uint16 = 0x0002
MsgEnterWorldRequest uint16 = 0x0003
MsgEnterWorldResponse uint16 = 0x0004
// Movement
MsgMoveRequest uint16 = 0x0010
MsgStateUpdate uint16 = 0x0011
MsgSpawnEntity uint16 = 0x0012
MsgDespawnEntity uint16 = 0x0013
// Zone Transfer
MsgZoneTransferNotify uint16 = 0x0014
// System
MsgPing uint16 = 0x0020
MsgPong uint16 = 0x0021
// Combat
MsgUseSkillRequest uint16 = 0x0040
MsgUseSkillResponse uint16 = 0x0041
MsgCombatEvent uint16 = 0x0042
MsgBuffApplied uint16 = 0x0043
MsgBuffRemoved uint16 = 0x0044
MsgRespawnRequest uint16 = 0x0045
MsgRespawnResponse uint16 = 0x0046
// Admin / Debug
MsgAOIToggleRequest uint16 = 0x0030
MsgAOIToggleResponse uint16 = 0x0031
MsgMetricsRequest uint16 = 0x0032
MsgServerMetrics uint16 = 0x0033
)
var (
ErrUnknownMessageType = errors.New("unknown message type")
ErrMessageTooShort = errors.New("message too short")
)
// Packet is a decoded network message.
type Packet struct {
Type uint16
Payload proto.Message
}
// messageFactory maps type IDs to protobuf message constructors.
var messageFactory = map[uint16]func() proto.Message{
MsgLoginRequest: func() proto.Message { return &pb.LoginRequest{} },
MsgLoginResponse: func() proto.Message { return &pb.LoginResponse{} },
MsgEnterWorldRequest: func() proto.Message { return &pb.EnterWorldRequest{} },
MsgEnterWorldResponse: func() proto.Message { return &pb.EnterWorldResponse{} },
MsgMoveRequest: func() proto.Message { return &pb.MoveRequest{} },
MsgStateUpdate: func() proto.Message { return &pb.StateUpdate{} },
MsgSpawnEntity: func() proto.Message { return &pb.SpawnEntity{} },
MsgDespawnEntity: func() proto.Message { return &pb.DespawnEntity{} },
MsgPing: func() proto.Message { return &pb.Ping{} },
MsgPong: func() proto.Message { return &pb.Pong{} },
MsgZoneTransferNotify: func() proto.Message { return &pb.ZoneTransferNotify{} },
MsgUseSkillRequest: func() proto.Message { return &pb.UseSkillRequest{} },
MsgUseSkillResponse: func() proto.Message { return &pb.UseSkillResponse{} },
MsgCombatEvent: func() proto.Message { return &pb.CombatEvent{} },
MsgBuffApplied: func() proto.Message { return &pb.BuffApplied{} },
MsgBuffRemoved: func() proto.Message { return &pb.BuffRemoved{} },
MsgRespawnRequest: func() proto.Message { return &pb.RespawnRequest{} },
MsgRespawnResponse: func() proto.Message { return &pb.RespawnResponse{} },
MsgAOIToggleRequest: func() proto.Message { return &pb.AOIToggleRequest{} },
MsgAOIToggleResponse: func() proto.Message { return &pb.AOIToggleResponse{} },
MsgMetricsRequest: func() proto.Message { return &pb.MetricsRequest{} },
MsgServerMetrics: func() proto.Message { return &pb.ServerMetrics{} },
}
// Encode serializes a packet into a wire-format byte slice: [2-byte type][protobuf payload].
func Encode(msgType uint16, msg proto.Message) ([]byte, error) {
payload, err := proto.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("marshal message 0x%04X: %w", msgType, err)
}
buf := make([]byte, 2+len(payload))
binary.BigEndian.PutUint16(buf[:2], msgType)
copy(buf[2:], payload)
return buf, nil
}
// Decode parses a wire-format byte slice into a Packet.
func Decode(data []byte) (*Packet, error) {
if len(data) < 2 {
return nil, ErrMessageTooShort
}
msgType := binary.BigEndian.Uint16(data[:2])
factory, ok := messageFactory[msgType]
if !ok {
return nil, fmt.Errorf("%w: 0x%04X", ErrUnknownMessageType, msgType)
}
msg := factory()
if len(data) > 2 {
if err := proto.Unmarshal(data[2:], msg); err != nil {
return nil, fmt.Errorf("unmarshal message 0x%04X: %w", msgType, err)
}
}
return &Packet{Type: msgType, Payload: msg}, nil
}

View File

@@ -0,0 +1,89 @@
package network
import (
"context"
"fmt"
"net/http"
"sync/atomic"
"github.com/gorilla/websocket"
"a301_game_server/config"
"a301_game_server/pkg/logger"
)
// Server listens for WebSocket connections and creates Connection objects.
type Server struct {
cfg *config.Config
upgrader websocket.Upgrader
handler PacketHandler
nextID atomic.Uint64
srv *http.Server
}
// NewServer creates a new WebSocket server.
func NewServer(cfg *config.Config, handler PacketHandler) *Server {
return &Server{
cfg: cfg,
handler: handler,
upgrader: websocket.Upgrader{
ReadBufferSize: cfg.Network.ReadBufferSize,
WriteBufferSize: cfg.Network.WriteBufferSize,
CheckOrigin: func(r *http.Request) bool { return true },
},
}
}
// Start begins listening for connections. Blocks until the context is cancelled.
func (s *Server) Start(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/ws", s.handleWebSocket)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
addr := s.cfg.Server.Address()
s.srv = &http.Server{
Addr: addr,
Handler: mux,
}
errCh := make(chan error, 1)
go func() {
logger.Info("websocket server starting", "address", addr)
if err := s.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- fmt.Errorf("listen: %w", err)
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
logger.Info("shutting down websocket server")
return s.srv.Shutdown(context.Background())
}
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
ws, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
logger.Error("websocket upgrade failed", "error", err, "remote", r.RemoteAddr)
return
}
connID := s.nextID.Add(1)
conn := NewConnection(
connID,
ws,
s.handler,
s.cfg.Network.SendChannelSize,
s.cfg.Network.MaxMessageSize,
s.cfg.Network.HeartbeatInterval,
s.cfg.Network.HeartbeatTimeout,
)
logger.Info("client connected", "connID", connID, "remote", r.RemoteAddr)
conn.Start()
}