RegionIO/internal/server/server.go
2026-06-27 21:16:39 +03:00

165 lines
5.3 KiB
Go

// Package server holds the RegionIO core: configuration, shared state, and the
// data shown to clients (status, MOTD).
package server
import (
"encoding/json"
"sync"
"regionio/internal/protocol"
"regionio/internal/world"
)
// Config holds operator-tunable settings for a RegionIO instance.
type Config struct {
Host string
Port int
MOTD string
MaxPlayers int
// CompressionThreshold is the minimum uncompressed packet size (bytes) that
// triggers zlib compression. Negative disables compression entirely.
CompressionThreshold int
// WorldSeed seeds terrain generation.
WorldSeed int64
// WorldDir is the on-disk world directory. When non-empty, chunks are
// read from and written to <WorldDir>/region/*.mca and player edits
// survive restarts. Empty disables persistence (in-memory only).
WorldDir string
// MaxCachedChunks bounds the in-memory chunk+frame cache (LRU). 0 means
// unbounded (use only for tests/flat worlds). At ~200KiB/chunk, 1024 ≈ 200MB.
MaxCachedChunks int
}
// DefaultConfig returns sensible defaults matching vanilla expectations.
func DefaultConfig() Config {
return Config{
Host: "0.0.0.0",
Port: 25565,
MOTD: "RegionIO — a Minecraft server in Go",
MaxPlayers: 20,
CompressionThreshold: 256,
// WorldSeed defaults to 0 for backward compatibility; operators override
// it via the REGIONIO_SEED env var or the -seed flag.
WorldSeed: 0,
// WorldDir defaults to "world" so the world persists by default;
// set to "" for a throwaway in-memory world.
WorldDir: "world",
// MaxCachedChunks keeps the live cache near 200MB at the default; the
// streamer's pre-gen ring and player view distance comfortably fit.
MaxCachedChunks: 1024,
}
}
// Server is the top-level core shared across all connections.
type Server struct {
cfg Config
chunks *world.Cache
store *world.Store // nil when persistence is disabled
entities *world.EntityManager
// playerPos tracks the last known position of each player by name.
playerPos sync.Map // map[string][3]float64
}
// New constructs a Server from cfg. When cfg.WorldDir is set, the world is
// backed by an on-disk store under that directory; otherwise it is in-memory
// only. A returned error (e.g. the world dir cannot be created) is fatal.
func New(cfg Config) (*Server, error) {
gen := world.NewVanillaGenerator(cfg.WorldSeed)
em := world.NewEntityManager()
if cfg.WorldDir == "" {
// No persistence; keep eviction off too (flat/test worlds expect full
// presence). Real servers set WorldDir and MaxCachedChunks together.
return &Server{cfg: cfg, chunks: world.NewCache(int32(cfg.CompressionThreshold), gen), entities: em}, nil
}
store, err := world.NewStore(cfg.WorldDir)
if err != nil {
return nil, err
}
return &Server{
cfg: cfg,
chunks: world.NewCacheWithLimit(int32(cfg.CompressionThreshold), gen, store, cfg.MaxCachedChunks),
store: store,
entities: em,
}, nil
}
// Config returns the active configuration.
func (s *Server) Config() Config { return s.cfg }
// Chunks returns the shared chunk cache.
func (s *Server) Chunks() *world.Cache { return s.chunks }
// Entities returns the shared entity manager.
func (s *Server) Entities() *world.EntityManager { return s.entities }
// SetPlayerPosition updates the tracked position of a player.
func (s *Server) SetPlayerPosition(name string, x, y, z float64) {
s.playerPos.Store(name, [3]float64{x, y, z})
}
// RemovePlayerPosition removes a player from tracking.
func (s *Server) RemovePlayerPosition(name string) {
s.playerPos.Delete(name)
}
// NearestPlayer returns the position of the nearest player to (x, y, z).
// Returns false if no players are online.
func (s *Server) NearestPlayer(x, y, z float64) (pos [3]float64, ok bool) {
minDist := float64(-1)
s.playerPos.Range(func(key, value any) bool {
p := value.([3]float64)
dist := (p[0]-x)*(p[0]-x) + (p[1]-y)*(p[1]-y) + (p[2]-z)*(p[2]-z)
if minDist < 0 || dist < minDist {
minDist = dist
pos = p
ok = true
}
return true
})
return
}
// Store returns the on-disk world store, or nil if persistence is disabled.
func (s *Server) Store() *world.Store { return s.store }
// statusResponse mirrors the JSON shape the client expects for the server-list
// ping. Field names and nesting are part of the protocol contract.
type statusResponse struct {
Version statusVersion `json:"version"`
Players statusPlayers `json:"players"`
Description statusText `json:"description"`
// EnforcesSecureChat is read by the client; false keeps unsigned chat working.
EnforcesSecureChat bool `json:"enforcesSecureChat"`
}
type statusVersion struct {
Name string `json:"name"`
Protocol int `json:"protocol"`
}
type statusPlayers struct {
Max int `json:"max"`
Online int `json:"online"`
}
type statusText struct {
Text string `json:"text"`
}
// StatusJSON returns the marshaled status response for the server-list ping.
func (s *Server) StatusJSON(onlinePlayers int) ([]byte, error) {
resp := statusResponse{
Version: statusVersion{
Name: protocol.GameVersion,
Protocol: protocol.ProtocolVersion,
},
Players: statusPlayers{
Max: s.cfg.MaxPlayers,
Online: onlinePlayers,
},
Description: statusText{Text: s.cfg.MOTD},
EnforcesSecureChat: false,
}
return json.Marshal(resp)
}