Files
world-seppjm/Backend/main.go

417 lines
8.8 KiB
Go

package main
import (
cryptoRand "crypto/rand"
"encoding/hex"
"flag"
"log"
"math/rand"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/vmihailenco/msgpack/v5"
)
// -------------------- Config --------------------
const (
AddrHTTP = ":8080"
MaxPlayers = 10
TickRateHz = 15
NameMaxLen = 25
ChatMaxLen = 500
StateBufLimit = 256
// Chat Anti-spam
MinChatInterval = 400 * time.Millisecond
)
// Allowed specified origins for browser WS connections
var allowedOrigins = map[string]bool{
"https://world.seppjm.com": true,
"https://seppjm.com": true,
"https://cv.seppjm.com": true,
"https://seppdroid.com": true,
"https://sepp.mx": true,
//"http://world.seppjm.com": true, // (dev)
//"http://localhost:5173": true, // (dev)
//"http://localhost:8080": true, // (dev)
}
// -------------------- Types --------------------
type Role string
const (
RolePlayer Role = "player"
RoleSpectator Role = "spectator"
)
type Vec3 [3]float64
type Player struct {
ID string `msgpack:"id"`
Name string `msgpack:"name"`
Pos Vec3 `msgpack:"pos"`
RotY float64 `msgpack:"rotY"`
}
type Client struct {
Conn *websocket.Conn
Hub *Hub
ID string
Name string
Role Role
Send chan []byte
Closed chan struct{}
LastChat time.Time
connected time.Time
}
type Hub struct {
mu sync.RWMutex
clients map[string]*Client // id -> client
players map[string]*Player // id -> player
joinCh chan *Client
leaveCh chan *Client
broadcast chan []byte
}
func NewHub() *Hub {
return &Hub{
clients: make(map[string]*Client),
players: make(map[string]*Player),
joinCh: make(chan *Client, 64),
leaveCh: make(chan *Client, 64),
broadcast: make(chan []byte, 1024),
}
}
func (h *Hub) Run() {
ticker := time.NewTicker(time.Second / TickRateHz)
defer ticker.Stop()
for {
select {
case c := <-h.joinCh:
h.addClient(c)
case c := <-h.leaveCh:
h.removeClient(c)
case msg := <-h.broadcast:
h.mu.RLock()
for _, cl := range h.clients {
select {
case cl.Send <- msg:
default:
}
}
h.mu.RUnlock()
case <-ticker.C:
h.tickState()
}
}
}
func (h *Hub) addClient(c *Client) {
h.mu.Lock()
h.clients[c.ID] = c
h.mu.Unlock()
}
func (h *Hub) removeClient(c *Client) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.clients, c.ID)
if _, ok := h.players[c.ID]; ok {
delete(h.players, c.ID)
packet := map[string]any{"type": "player_leave", "id": c.ID}
if b, _ := msgpack.Marshal(packet); b != nil {
for _, cl := range h.clients {
select {
case cl.Send <- b:
default:
}
}
}
}
}
func (h *Hub) tickState() {
h.mu.RLock()
if len(h.players) == 0 || len(h.clients) == 0 {
h.mu.RUnlock()
return
}
updates := make([]map[string]any, 0, len(h.players))
for _, p := range h.players {
updates = append(updates, map[string]any{
"id": p.ID,
"pos": p.Pos,
"rotY": p.RotY,
})
}
h.mu.RUnlock()
packet := map[string]any{"type": "state", "updates": updates}
if b, _ := msgpack.Marshal(packet); b != nil {
select {
case h.broadcast <- b:
default:
}
}
}
// -------------------- WS Handling --------------------
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
EnableCompression: true,
HandshakeTimeout: 10 * time.Second,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
return allowedOrigins[origin]
},
}
type inbound struct {
Type string `msgpack:"type"`
Name string `msgpack:"name"`
Role Role `msgpack:"role"`
Pos *Vec3 `msgpack:"pos"`
RotY *float64 `msgpack:"rotY"`
Text string `msgpack:"text"`
}
func wsHandler(h *Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("upgrade:", err)
return
}
id := genID()
c := &Client{
Conn: conn,
Hub: h,
ID: id,
Send: make(chan []byte, StateBufLimit),
Closed: make(chan struct{}),
connected: time.Now(),
}
h.joinCh <- c
go writer(c)
reader(c)
}
}
func reader(c *Client) {
defer func() {
c.Hub.leaveCh <- c
close(c.Closed)
_ = c.Conn.Close()
}()
c.Conn.SetReadLimit(1 << 20)
_ = c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.Conn.SetPongHandler(func(string) error {
return c.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
})
// Expect a join first
mt, data, err := c.Conn.ReadMessage()
if err != nil {
return
}
if mt != websocket.BinaryMessage && mt != websocket.TextMessage {
return
}
var first inbound
if msgpack.Unmarshal(data, &first) != nil || first.Type != "join" {
sendPacket(c, map[string]any{"type": "error", "message": "expected join"})
return
}
name := sanitizeName(first.Name)
if name == "" {
name = randomName()
}
role := first.Role
if role != RoleSpectator {
role = RolePlayer
}
// Enforce player cap
c.Hub.mu.Lock()
nPlayers := 0
for _, cl := range c.Hub.clients {
if cl.Role == RolePlayer {
nPlayers++
}
}
if role == RolePlayer && nPlayers >= MaxPlayers {
c.Hub.mu.Unlock()
sendPacket(c, map[string]any{"type": "full", "maxPlayers": MaxPlayers})
return
}
c.Name = name
c.Role = role
// Register player entity
if c.Role == RolePlayer {
spawn := Player{ID: c.ID, Name: c.Name, Pos: Vec3{0, 1.6, 0}, RotY: 0}
c.Hub.players[c.ID] = &spawn
}
// Welcome snapshot
players := make([]Player, 0, len(c.Hub.players))
for _, p := range c.Hub.players {
players = append(players, *p)
}
c.Hub.mu.Unlock()
sendPacket(c, map[string]any{
"type": "welcome",
"id": c.ID,
"players": players,
"maxPlayers": MaxPlayers,
})
if c.Role == RolePlayer {
announce := map[string]any{"type": "player_join", "player": Player{ID: c.ID, Name: c.Name, Pos: Vec3{0, 1.6, 0}, RotY: 0}}
if b, _ := msgpack.Marshal(announce); b != nil {
select {
case c.Hub.broadcast <- b:
default:
}
}
}
// loop
for {
mt, msg, err := c.Conn.ReadMessage()
if err != nil {
return
}
if mt != websocket.BinaryMessage && mt != websocket.TextMessage {
continue
}
var in inbound
if msgpack.Unmarshal(msg, &in) != nil {
continue
}
switch in.Type {
case "state":
if c.Role != RolePlayer || in.Pos == nil || in.RotY == nil {
continue
}
c.Hub.mu.Lock()
if p, ok := c.Hub.players[c.ID]; ok {
p.Pos = *in.Pos
p.RotY = *in.RotY
}
c.Hub.mu.Unlock()
case "chat":
now := time.Now()
if now.Sub(c.LastChat) < MinChatInterval {
continue
}
c.LastChat = now
text := trimLen(strings.TrimSpace(in.Text), ChatMaxLen)
if text == "" {
continue
}
packet := map[string]any{
"type": "chat", "id": c.ID, "name": c.Name, "text": text, "ts": now.Unix(),
}
if b, _ := msgpack.Marshal(packet); b != nil {
select {
case c.Hub.broadcast <- b:
default:
}
}
}
}
}
func writer(c *Client) {
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case msg := <-c.Send:
_ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.BinaryMessage, msg); err != nil {
return
}
case <-ping.C:
_ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.Conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-c.Closed:
return
}
}
}
func sendPacket(c *Client, v any) {
b, _ := msgpack.Marshal(v)
_ = c.Conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
_ = c.Conn.WriteMessage(websocket.BinaryMessage, b)
}
// -------------------- Utis --------------------
func genID() string {
var b [8]byte
_, _ = cryptoRand.Read(b[:])
return "p_" + hex.EncodeToString(b[:])
}
func sanitizeName(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
if len(s) > NameMaxLen {
s = s[:NameMaxLen]
}
return s
}
func trimLen(s string, max int) string {
if len(s) > max {
return s[:max]
}
return s
}
func randomName() string {
adjs := []string{"Blue", "Crimson", "Silent", "Swift", "Lucky", "Rusty", "Cosmic", "Mossy"}
nouns := []string{"Fox", "Raven", "Badger", "Otter", "Falcon", "Lynx", "Marmot", "Golem"}
return adjs[rand.Intn(len(adjs))] + nouns[rand.Intn(len(nouns))]
}
// -------------------- main --------------------
func main() {
rand.Seed(time.Now().UnixNano())
addr := flag.String("addr", AddrHTTP, "http listen addr")
flag.Parse()
h := NewHub()
go h.Run()
mux := http.NewServeMux()
mux.HandleFunc("/ws", wsHandler(h))
srv := &http.Server{
Addr: *addr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 75 * time.Second,
}
log.Println("SEPPJM-World WebSocket server listening on", *addr, "path /ws")
if err := srv.ListenAndServe(); err != nil {
log.Fatal(err)
}
}