mirror of
https://github.com/tiennm99/260404.git
synced 2026-08-06 14:23:54 +00:00
Implement a real-time game server using protobuf over WebSocket: - Protobuf schema with 14 message types in oneof Envelope - Room-based lobby with join/leave/chat broadcast - ELO-based matchmaking with expanding range (±100 to ±500) - Turn-based game session lifecycle management - Multi-stage Docker build (Alpine) - Unit tests for matchmaking and game session logic
140 lines
3.0 KiB
Go
140 lines
3.0 KiB
Go
package ws
|
|
|
|
import (
|
|
"log"
|
|
"net/http"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/tiennm99/260404/internal/game"
|
|
"github.com/tiennm99/260404/internal/lobby"
|
|
"github.com/tiennm99/260404/internal/matchmaking"
|
|
"github.com/tiennm99/260404/internal/player"
|
|
"github.com/tiennm99/260404/pkg/pb"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
ReadBufferSize: 4096,
|
|
WriteBufferSize: 4096,
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
// Handler manages WebSocket connections and routes messages.
|
|
type Handler struct {
|
|
lobby *lobby.Manager
|
|
matchmaker *matchmaking.Matchmaker
|
|
games *game.Manager
|
|
nextID int
|
|
}
|
|
|
|
// NewHandler creates a WebSocket handler with all subsystems.
|
|
func NewHandler(
|
|
lobby *lobby.Manager,
|
|
matchmaker *matchmaking.Matchmaker,
|
|
games *game.Manager,
|
|
) *Handler {
|
|
return &Handler{
|
|
lobby: lobby,
|
|
matchmaker: matchmaker,
|
|
games: games,
|
|
}
|
|
}
|
|
|
|
// ServeHTTP upgrades the connection and starts the read loop.
|
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("ws upgrade error: %v", err)
|
|
return
|
|
}
|
|
|
|
h.nextID++
|
|
p := player.New(itoa(h.nextID), "", 1000, conn)
|
|
log.Printf("player connected: %s", p.ID)
|
|
|
|
defer h.cleanup(p)
|
|
h.readLoop(p)
|
|
}
|
|
|
|
func (h *Handler) readLoop(p *player.Player) {
|
|
for {
|
|
_, data, err := p.Conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
|
log.Printf("read error player=%s: %v", p.ID, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
var env pb.Envelope
|
|
if err := proto.Unmarshal(data, &env); err != nil {
|
|
_ = p.SendError(400, "invalid message format")
|
|
continue
|
|
}
|
|
|
|
h.route(p, &env)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) route(p *player.Player, env *pb.Envelope) {
|
|
switch msg := env.Payload.(type) {
|
|
case *pb.Envelope_JoinLobby:
|
|
p.Name = msg.JoinLobby.PlayerName
|
|
roomID := msg.JoinLobby.RoomId
|
|
if roomID == "" {
|
|
roomID = "default"
|
|
}
|
|
h.lobby.Join(p, roomID)
|
|
|
|
case *pb.Envelope_LeaveLobby:
|
|
h.lobby.Leave(p)
|
|
|
|
case *pb.Envelope_Chat:
|
|
h.lobby.Broadcast(p, msg.Chat.Text)
|
|
|
|
case *pb.Envelope_Queue:
|
|
p.Elo = msg.Queue.Elo
|
|
h.matchmaker.Enqueue(p, msg.Queue.Elo)
|
|
|
|
case *pb.Envelope_CancelQueue:
|
|
h.matchmaker.Dequeue(p.ID)
|
|
|
|
case *pb.Envelope_GameAction:
|
|
s := h.games.GetByPlayer(p.ID)
|
|
if s == nil {
|
|
_ = p.SendError(404, "not in a game session")
|
|
return
|
|
}
|
|
if !s.ApplyAction(p.ID, msg.GameAction) {
|
|
_ = p.SendError(403, "not your turn or game is over")
|
|
return
|
|
}
|
|
s.BroadcastState()
|
|
|
|
default:
|
|
_ = p.SendError(400, "unknown message type")
|
|
}
|
|
}
|
|
|
|
func (h *Handler) cleanup(p *player.Player) {
|
|
h.lobby.Leave(p)
|
|
h.matchmaker.Dequeue(p.ID)
|
|
_ = p.Conn.Close()
|
|
log.Printf("player disconnected: %s", p.ID)
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
buf := make([]byte, 0, 10)
|
|
for n > 0 {
|
|
buf = append(buf, byte('0'+n%10))
|
|
n /= 10
|
|
}
|
|
for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {
|
|
buf[i], buf[j] = buf[j], buf[i]
|
|
}
|
|
return string(buf)
|
|
}
|