Fix terrain streaming and surface spawning
This commit is contained in:
parent
f0279cdb65
commit
2b07d6be20
17 changed files with 424 additions and 111 deletions
|
|
@ -37,12 +37,15 @@ block editing, persistent worlds, and an overworld generator built on the real
|
||||||
|
|
||||||
```
|
```
|
||||||
go build ./...
|
go build ./...
|
||||||
go run ./cmd/regionio -seed 12345
|
go run ./cmd/regionio -seed 12345 -port 25565 -viewdistance 2
|
||||||
```
|
```
|
||||||
|
|
||||||
The world seed defaults to `0`; override it with the `-seed` flag or the
|
The world seed defaults to `0`; override it with the `-seed` flag or the
|
||||||
`REGIONIO_SEED` environment variable. The server listens on `0.0.0.0:25565`.
|
`REGIONIO_SEED` environment variable. The server listens on `0.0.0.0:25565`.
|
||||||
Changing the seed for an existing world directory is rejected.
|
Changing the seed for an existing world directory is rejected. The server caps
|
||||||
|
the client-requested chunk radius at `2` by default because cold density-based
|
||||||
|
generation is expensive; raise it with `-viewdistance 3` after the surrounding
|
||||||
|
world has been generated and cached.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,18 @@ func main() {
|
||||||
// fatal — a wrong seed silently generates a different world than intended.
|
// fatal — a wrong seed silently generates a different world than intended.
|
||||||
seedFlag := flag.Int64("seed", parseSeedEnv(os.Getenv("REGIONIO_SEED"), cfg.WorldSeed, log),
|
seedFlag := flag.Int64("seed", parseSeedEnv(os.Getenv("REGIONIO_SEED"), cfg.WorldSeed, log),
|
||||||
"world seed (overrides REGIONIO_SEED)")
|
"world seed (overrides REGIONIO_SEED)")
|
||||||
|
port := flag.Int("port", cfg.Port, "TCP listen port")
|
||||||
worldDir := flag.String("world", cfg.WorldDir, "world directory (empty = in-memory only)")
|
worldDir := flag.String("world", cfg.WorldDir, "world directory (empty = in-memory only)")
|
||||||
maxCache := flag.Int("maxcache", cfg.MaxCachedChunks, "max cached chunks, LRU eviction (0 = unbounded)")
|
maxCache := flag.Int("maxcache", cfg.MaxCachedChunks, "max cached chunks, LRU eviction (0 = unbounded)")
|
||||||
|
viewDistance := flag.Int("viewdistance", cfg.MaxViewDistance, "maximum client chunk view radius (2-16)")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
cfg.WorldSeed = *seedFlag
|
cfg.WorldSeed = *seedFlag
|
||||||
|
cfg.Port = *port
|
||||||
cfg.WorldDir = *worldDir
|
cfg.WorldDir = *worldDir
|
||||||
cfg.MaxCachedChunks = *maxCache
|
cfg.MaxCachedChunks = *maxCache
|
||||||
log.Info("using world seed", "seed", cfg.WorldSeed, "worldDir", cfg.WorldDir, "maxcache", cfg.MaxCachedChunks)
|
cfg.MaxViewDistance = *viewDistance
|
||||||
|
log.Info("using world seed", "seed", cfg.WorldSeed, "worldDir", cfg.WorldDir,
|
||||||
|
"maxcache", cfg.MaxCachedChunks, "viewdistance", cfg.MaxViewDistance)
|
||||||
|
|
||||||
srv, err := server.New(cfg)
|
srv, err := server.New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -98,15 +98,15 @@ func (h *handler) handleClientInformation(pkt protocol.Packet) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// view_distance drives the chunk streamer radius. The client sends 2..32;
|
// view_distance drives the chunk streamer radius. The server owns the upper
|
||||||
// clamp into a sane server range so a huge view distance doesn't trigger a
|
// bound because accepting the client's render distance can multiply cold
|
||||||
// generation explosion.
|
// generation work into hundreds of chunks.
|
||||||
vdInt := int(int8(vd))
|
vdInt := int(int8(vd))
|
||||||
if vdInt < 2 {
|
if vdInt < 2 {
|
||||||
vdInt = 2
|
vdInt = 2
|
||||||
}
|
}
|
||||||
if vdInt > 16 {
|
if max := h.srv.Config().MaxViewDistance; vdInt > max {
|
||||||
vdInt = 16
|
vdInt = max
|
||||||
}
|
}
|
||||||
h.viewDistance = vdInt
|
h.viewDistance = vdInt
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type handler struct {
|
||||||
session *server.PlayerSession
|
session *server.PlayerSession
|
||||||
knownPlayers map[[16]byte]bool
|
knownPlayers map[[16]byte]bool
|
||||||
knownEntities map[int32]visibleEntity
|
knownEntities map[int32]visibleEntity
|
||||||
|
spawnY float64
|
||||||
|
|
||||||
// Creative inventory state for block placement.
|
// Creative inventory state for block placement.
|
||||||
heldSlot int32 // selected hotbar index (0-8)
|
heldSlot int32 // selected hotbar index (0-8)
|
||||||
|
|
|
||||||
|
|
@ -150,11 +150,13 @@ func TestBoundaryEditBroadcastsEveryChangedLightChunk(t *testing.T) {
|
||||||
srv.SetPlayerViewDistance(h.session, 2)
|
srv.SetPlayerViewDistance(h.session, 2)
|
||||||
|
|
||||||
valid, lightChunks := cache.SetBlockWithLight(15, 100, 8, world.StateGlowstone)
|
valid, lightChunks := cache.SetBlockWithLight(15, 100, 8, world.StateGlowstone)
|
||||||
if !valid || len(lightChunks) != 2 {
|
if !valid || len(lightChunks) != 6 {
|
||||||
t.Fatalf("boundary edit valid=%v light chunks=%v; want two", valid, lightChunks)
|
t.Fatalf("boundary edit valid=%v light chunks=%v; want six cached neighbors", valid, lightChunks)
|
||||||
}
|
}
|
||||||
h.broadcastBlockUpdate(15, 100, 8, world.StateGlowstone, lightChunks)
|
h.broadcastBlockUpdate(15, 100, 8, world.StateGlowstone, lightChunks)
|
||||||
assertPacketIDs(t, recorder.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate)
|
assertPacketIDs(t, recorder.take(t), protocol.PlayBlockUpdate,
|
||||||
|
protocol.PlayLightUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate,
|
||||||
|
protocol.PlayLightUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntegrationFourClientsVisibilityMovementLeaveAndLight(t *testing.T) {
|
func TestIntegrationFourClientsVisibilityMovementLeaveAndLight(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,10 @@ import (
|
||||||
"regionio/internal/world"
|
"regionio/internal/world"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Spawn coordinates. Y sits above the maximum terrain height so the player
|
// Spawn column. The feet-level Y is resolved from the generated surface when
|
||||||
// drops onto the generated surface rather than spawning inside it.
|
// the player enters the play phase.
|
||||||
const (
|
const (
|
||||||
spawnX = 8.5
|
spawnX = 8.5
|
||||||
spawnY = 200.0
|
|
||||||
spawnZ = 8.5
|
spawnZ = 8.5
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -29,7 +28,12 @@ func (h *handler) beginPlay() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
h.session = session
|
h.session = session
|
||||||
h.srv.SetPlayerTransform(session, spawnX, spawnY, spawnZ, 0, 0, true)
|
spawnY, ok := h.srv.Chunks().SafeSpawnY(int(math.Floor(spawnX)), int(math.Floor(spawnZ)))
|
||||||
|
if !ok {
|
||||||
|
spawnY = world.SeaLevel + 1
|
||||||
|
}
|
||||||
|
h.spawnY = float64(spawnY)
|
||||||
|
h.srv.SetPlayerTransform(session, spawnX, h.spawnY, spawnZ, 0, 0, true)
|
||||||
h.srv.SetPlayerViewDistance(session, h.visibilityRadius())
|
h.srv.SetPlayerViewDistance(session, h.visibilityRadius())
|
||||||
for i := range h.hotbar {
|
for i := range h.hotbar {
|
||||||
h.hotbar[i] = -1 // empty
|
h.hotbar[i] = -1 // empty
|
||||||
|
|
@ -56,7 +60,7 @@ func (h *handler) beginPlay() error {
|
||||||
}
|
}
|
||||||
// Launch the background chunk streamer. It owns generation + sending so the
|
// Launch the background chunk streamer. It owns generation + sending so the
|
||||||
// read loop stays free; requestRecenter is a non-blocking push.
|
// read loop stays free; requestRecenter is a non-blocking push.
|
||||||
h.streamer = newStreamer(h.srv.Chunks(), h.conn, h.log, h.viewDistance)
|
h.streamer = newStreamer(h.srv.Chunks(), h.conn, h.log, h.visibilityRadius())
|
||||||
go h.streamer.run(h.ctx)
|
go h.streamer.run(h.ctx)
|
||||||
h.streamer.requestRecenter(0, 0)
|
h.streamer.requestRecenter(0, 0)
|
||||||
go h.keepAliveLoop()
|
go h.keepAliveLoop()
|
||||||
|
|
@ -76,7 +80,7 @@ func (h *handler) sendDefaultSpawnPosition() error {
|
||||||
// yaw and pitch. GlobalPos starts with the dimension resource key.
|
// yaw and pitch. GlobalPos starts with the dimension resource key.
|
||||||
w := protocol.NewWriter(40)
|
w := protocol.NewWriter(40)
|
||||||
w.String("minecraft:overworld")
|
w.String("minecraft:overworld")
|
||||||
w.Position(8, 100, 8)
|
w.Position(8, int(math.Floor(h.spawnY)), 8)
|
||||||
w.Float32(0.0)
|
w.Float32(0.0)
|
||||||
w.Float32(0.0)
|
w.Float32(0.0)
|
||||||
return h.conn.SendWriter(protocol.PlayDefaultSpawnPos, w)
|
return h.conn.SendWriter(protocol.PlayDefaultSpawnPos, w)
|
||||||
|
|
@ -109,13 +113,14 @@ func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround boo
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *handler) visibilityRadius() int {
|
func (h *handler) visibilityRadius() int {
|
||||||
if h.viewDistance < 2 {
|
distance := h.viewDistance
|
||||||
return defaultViewRadius
|
if distance < 2 {
|
||||||
|
distance = defaultViewRadius
|
||||||
}
|
}
|
||||||
if h.viewDistance > 16 {
|
if max := h.srv.Config().MaxViewDistance; distance > max {
|
||||||
return 16
|
distance = max
|
||||||
}
|
}
|
||||||
return h.viewDistance
|
return distance
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendPlayLogin writes the clientbound play "login" packet. Field layout was
|
// sendPlayLogin writes the clientbound play "login" packet. Field layout was
|
||||||
|
|
@ -176,11 +181,11 @@ func (h *handler) sendGameEvent(event byte, value float32) error {
|
||||||
func (h *handler) sendPlayerPosition(teleportID int32) error {
|
func (h *handler) sendPlayerPosition(teleportID int32) error {
|
||||||
w := protocol.NewWriter(64)
|
w := protocol.NewWriter(64)
|
||||||
w.VarInt(teleportID)
|
w.VarInt(teleportID)
|
||||||
w.Float64(spawnX).Float64(spawnY).Float64(spawnZ) // position
|
w.Float64(spawnX).Float64(h.spawnY).Float64(spawnZ) // position
|
||||||
w.Float64(0).Float64(0).Float64(0) // velocity
|
w.Float64(0).Float64(0).Float64(0) // velocity
|
||||||
w.Float32(0) // yaw
|
w.Float32(0) // yaw
|
||||||
w.Float32(0) // pitch
|
w.Float32(0) // pitch
|
||||||
w.Int32(0) // relative flags
|
w.Int32(0) // relative flags
|
||||||
return h.conn.SendWriter(protocol.PlayPlayerPosition, w)
|
return h.conn.SendWriter(protocol.PlayPlayerPosition, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
package network
|
package network
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"regionio/internal/protocol"
|
"regionio/internal/protocol"
|
||||||
|
"regionio/internal/server"
|
||||||
|
"regionio/internal/world"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSendDefaultSpawnPositionLayout(t *testing.T) {
|
func TestSendDefaultSpawnPositionLayout(t *testing.T) {
|
||||||
recorder := &recordingConn{}
|
recorder := &recordingConn{}
|
||||||
h := &handler{conn: NewConn(recorder)}
|
h := &handler{conn: NewConn(recorder), spawnY: 100}
|
||||||
|
|
||||||
if err := h.sendDefaultSpawnPosition(); err != nil {
|
if err := h.sendDefaultSpawnPosition(); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -38,3 +41,17 @@ func TestSendDefaultSpawnPositionLayout(t *testing.T) {
|
||||||
t.Fatalf("remaining bytes = %d, want 0", remaining)
|
t.Fatalf("remaining bytes = %d, want 0", remaining)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVisibilityRadiusUsesServerLimit(t *testing.T) {
|
||||||
|
cfg := server.DefaultConfig()
|
||||||
|
cfg.WorldDir = ""
|
||||||
|
cfg.MaxViewDistance = 2
|
||||||
|
srv, err := server.NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := &handler{srv: srv, log: slog.Default(), viewDistance: 16}
|
||||||
|
if got := h.visibilityRadius(); got != 2 {
|
||||||
|
t.Fatalf("visibility radius = %d, want server limit 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,8 +48,9 @@ type streamer struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// defaultViewRadius is used when the client hasn't sent client_information or
|
// defaultViewRadius is used when the client hasn't sent client_information or
|
||||||
// sent an implausible value. Matches the legacy chunkRadius.
|
// sent an implausible value. Vanilla generation is expensive, so keep the cold
|
||||||
const defaultViewRadius = 4
|
// start bounded until nearby terrain has warmed in the cache.
|
||||||
|
const defaultViewRadius = 2
|
||||||
|
|
||||||
// newStreamer constructs a streamer for the given cache/conn. viewDistance comes
|
// newStreamer constructs a streamer for the given cache/conn. viewDistance comes
|
||||||
// from the client's client_information (clamped to a safe range); genRadius is
|
// from the client's client_information (clamped to a safe range); genRadius is
|
||||||
|
|
@ -161,6 +162,13 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterR
|
||||||
s.tickets.Replace(viewTickets, prefetchTickets)
|
s.tickets.Replace(viewTickets, prefetchTickets)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The center frame needs a 3x3 terrain neighborhood for lighting. Preload
|
||||||
|
// those chunks concurrently so the first visible chunk is not delayed by
|
||||||
|
// eight sequential generator calls inside the lighting pass.
|
||||||
|
if !s.loaded[[2]int32{cx, cz}] {
|
||||||
|
s.parallelPreload(ctx, spiralOrder(cx, cz, 1))
|
||||||
|
}
|
||||||
|
|
||||||
// Client residency follows viewRadius exactly. The prefetch ring is retained
|
// Client residency follows viewRadius exactly. The prefetch ring is retained
|
||||||
// only server-side by tickets and never left loaded on the client.
|
// only server-side by tickets and never left loaded on the client.
|
||||||
for key := range s.loaded {
|
for key := range s.loaded {
|
||||||
|
|
@ -175,7 +183,8 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterR
|
||||||
if next, superseded := s.streamPriority(ctx, cx, cz, toSend, true); superseded {
|
if next, superseded := s.streamPriority(ctx, cx, cz, toSend, true); superseded {
|
||||||
return next, true
|
return next, true
|
||||||
}
|
}
|
||||||
// Pre-generate the ring so the next recenter finds frames warm in the cache.
|
// Preload terrain only. Building full frames here would calculate lighting
|
||||||
|
// for off-screen chunks and recursively generate yet another outer ring.
|
||||||
if next, superseded := s.streamPriority(ctx, cx, cz, toPreGen, false); superseded {
|
if next, superseded := s.streamPriority(ctx, cx, cz, toPreGen, false); superseded {
|
||||||
return next, true
|
return next, true
|
||||||
}
|
}
|
||||||
|
|
@ -207,7 +216,7 @@ func (s *streamer) streamPriority(ctx context.Context, cx, cz int32, keys [][2]i
|
||||||
if send {
|
if send {
|
||||||
s.parallelSend(ctx, keys[start:end])
|
s.parallelSend(ctx, keys[start:end])
|
||||||
} else {
|
} else {
|
||||||
s.parallelGenerate(ctx, keys[start:end])
|
s.parallelPreload(ctx, keys[start:end])
|
||||||
}
|
}
|
||||||
if next, ok := s.latestRecenter(); ok {
|
if next, ok := s.latestRecenter(); ok {
|
||||||
return next, true
|
return next, true
|
||||||
|
|
@ -255,8 +264,8 @@ func (s *streamer) sendForgetLevelChunk(cx, cz int32) {
|
||||||
_ = s.conn.SendWriter(protocol.PlayForgetLevelChunk, w)
|
_ = s.conn.SendWriter(protocol.PlayForgetLevelChunk, w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// parallelSend generates the given chunks across the worker pool and sends each
|
// parallelSend generates the given chunks across the worker pool, then sends
|
||||||
// frame as soon as it is ready (order is best-effort; the client reassembles).
|
// them in caller order so the client receives a contiguous near-first view.
|
||||||
// Already-loaded chunks are skipped. Returns when all are sent or ctx cancels.
|
// Already-loaded chunks are skipped. Returns when all are sent or ctx cancels.
|
||||||
func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
|
func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
|
||||||
var pending []frameJob
|
var pending []frameJob
|
||||||
|
|
@ -304,13 +313,26 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
close(results)
|
close(results)
|
||||||
}()
|
}()
|
||||||
|
generated := make(map[[2]int32]frameResult, len(pending))
|
||||||
|
failed := false
|
||||||
for r := range results {
|
for r := range results {
|
||||||
if r.err != nil {
|
if r.err != nil {
|
||||||
// Send failed — the connection is likely closing. Bail out; the
|
|
||||||
// serve loop will tear us down via ctx cancel.
|
|
||||||
if s.log != nil {
|
if s.log != nil {
|
||||||
s.log.Debug("streamer frame failed", "cx", r.cx, "cz", r.cz, "err", r.err)
|
s.log.Debug("streamer frame failed", "cx", r.cx, "cz", r.cz, "err", r.err)
|
||||||
}
|
}
|
||||||
|
failed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
generated[[2]int32{r.cx, r.cz}] = r
|
||||||
|
}
|
||||||
|
if failed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Generation completes out of order, but client presentation should not.
|
||||||
|
// Emit the contiguous spiral order supplied by the caller.
|
||||||
|
for _, j := range pending {
|
||||||
|
r, ok := generated[[2]int32{j.cx, j.cz}]
|
||||||
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.conn != nil {
|
if s.conn != nil {
|
||||||
|
|
@ -321,13 +343,13 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.loaded[[2]int32{r.cx, r.cz}] = true
|
s.loaded[[2]int32{j.cx, j.cz}] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parallelGenerate warms the cache for the given chunks without sending them
|
// parallelPreload warms terrain for the given chunks without calculating light,
|
||||||
// (used for the predictive ring). Errors are ignored.
|
// encoding frames, or sending packets. Errors are ignored.
|
||||||
func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) {
|
func (s *streamer) parallelPreload(ctx context.Context, keys [][2]int32) {
|
||||||
var pending []frameJob
|
var pending []frameJob
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
if s.loaded[k] {
|
if s.loaded[k] {
|
||||||
|
|
@ -355,7 +377,7 @@ func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) {
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
_, _ = s.cache.FrameErrContext(ctx, j.cx, j.cz) // warm cache; discard frame
|
_ = s.cache.PreloadErrContext(ctx, j.cx, j.cz)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
@ -381,8 +403,8 @@ type frameResult struct {
|
||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateWorker reads jobs, generates+frames the chunk via the (thread-safe)
|
// generateWorker reads jobs and generates+frames chunks via the thread-safe
|
||||||
// cache, and sends the frame to the conn. It exits when jobs closes.
|
// cache. It exits when jobs closes.
|
||||||
func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, results chan<- frameResult) {
|
func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, results chan<- frameResult) {
|
||||||
for j := range jobs {
|
for j := range jobs {
|
||||||
select {
|
select {
|
||||||
|
|
@ -407,9 +429,7 @@ func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, res
|
||||||
}
|
}
|
||||||
|
|
||||||
// spiralOrder returns chunk coordinates in a square of side (2*radius+1) around
|
// spiralOrder returns chunk coordinates in a square of side (2*radius+1) around
|
||||||
// (cx, cz), ordered from the centre outward (Chebyshev rings). The centre is
|
// (cx, cz), ordered from the centre outward in contiguous Chebyshev rings.
|
||||||
// first, then ring 1, ring 2, … ring `radius`. Within a ring the order is
|
|
||||||
// deterministic but not otherwise constrained — nearest-first is what matters.
|
|
||||||
func spiralOrder(cx, cz int32, radius int) [][2]int32 {
|
func spiralOrder(cx, cz int32, radius int) [][2]int32 {
|
||||||
if radius < 0 {
|
if radius < 0 {
|
||||||
radius = 0
|
radius = 0
|
||||||
|
|
@ -417,14 +437,18 @@ func spiralOrder(cx, cz int32, radius int) [][2]int32 {
|
||||||
out := make([][2]int32, 0, (2*radius+1)*(2*radius+1))
|
out := make([][2]int32, 0, (2*radius+1)*(2*radius+1))
|
||||||
out = append(out, [2]int32{cx, cz})
|
out = append(out, [2]int32{cx, cz})
|
||||||
for r := 1; r <= radius; r++ {
|
for r := 1; r <= radius; r++ {
|
||||||
// Walk the perimeter of the ring at Chebyshev distance r.
|
// Walk one continuous perimeter: top, right, bottom, left.
|
||||||
for d := -r; d <= r; d++ {
|
for x := -r; x <= r; x++ {
|
||||||
out = append(out, [2]int32{cx + int32(d), cz - int32(r)}) // top edge
|
out = append(out, [2]int32{cx + int32(x), cz - int32(r)})
|
||||||
out = append(out, [2]int32{cx + int32(d), cz + int32(r)}) // bottom edge
|
|
||||||
}
|
}
|
||||||
for d := -r + 1; d <= r-1; d++ {
|
for z := -r + 1; z <= r; z++ {
|
||||||
out = append(out, [2]int32{cx - int32(r), cz + int32(d)}) // left edge
|
out = append(out, [2]int32{cx + int32(r), cz + int32(z)})
|
||||||
out = append(out, [2]int32{cx + int32(r), cz + int32(d)}) // right edge
|
}
|
||||||
|
for x := r - 1; x >= -r; x-- {
|
||||||
|
out = append(out, [2]int32{cx + int32(x), cz + int32(r)})
|
||||||
|
}
|
||||||
|
for z := r - 1; z >= -r+1; z-- {
|
||||||
|
out = append(out, [2]int32{cx - int32(r), cz + int32(z)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,28 @@ func TestSpiralOrderRingStructure(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSpiralOrderWalksEachRingContiguously(t *testing.T) {
|
||||||
|
order := spiralOrder(0, 0, 4)
|
||||||
|
for i := 1; i < len(order); i++ {
|
||||||
|
previousRing := chunkDistanceFrom(0, 0, order[i-1])
|
||||||
|
currentRing := chunkDistanceFrom(0, 0, order[i])
|
||||||
|
if previousRing != currentRing {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dx := order[i][0] - order[i-1][0]
|
||||||
|
if dx < 0 {
|
||||||
|
dx = -dx
|
||||||
|
}
|
||||||
|
dz := order[i][1] - order[i-1][1]
|
||||||
|
if dz < 0 {
|
||||||
|
dz = -dz
|
||||||
|
}
|
||||||
|
if dx+dz != 1 {
|
||||||
|
t.Fatalf("ring %d jumps from %v to %v", currentRing, order[i-1], order[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRequestRecenterNonBlocking confirms requestRecenter never blocks the
|
// TestRequestRecenterNonBlocking confirms requestRecenter never blocks the
|
||||||
// caller even when many requests are pushed rapidly (the streamer drains stale
|
// caller even when many requests are pushed rapidly (the streamer drains stale
|
||||||
// ones). This is the property the read loop relies on to stay responsive.
|
// ones). This is the property the read loop relies on to stay responsive.
|
||||||
|
|
@ -104,6 +126,7 @@ func TestStreamerSendsStrictlyNearFirst(t *testing.T) {
|
||||||
|
|
||||||
lastDistance := int32(-1)
|
lastDistance := int32(-1)
|
||||||
chunks := 0
|
chunks := 0
|
||||||
|
wantOrder := spiralOrder(7, -3, 2)
|
||||||
for _, packet := range recorder.take(t) {
|
for _, packet := range recorder.take(t) {
|
||||||
if packet.ID != protocol.PlayLevelChunk {
|
if packet.ID != protocol.PlayLevelChunk {
|
||||||
continue
|
continue
|
||||||
|
|
@ -122,6 +145,9 @@ func TestStreamerSendsStrictlyNearFirst(t *testing.T) {
|
||||||
t.Fatalf("chunk (%d,%d) at distance %d arrived after distance %d", x, z, distance, lastDistance)
|
t.Fatalf("chunk (%d,%d) at distance %d arrived after distance %d", x, z, distance, lastDistance)
|
||||||
}
|
}
|
||||||
lastDistance = distance
|
lastDistance = distance
|
||||||
|
if want := wantOrder[chunks]; x != want[0] || z != want[1] {
|
||||||
|
t.Fatalf("chunk[%d] = (%d,%d), want %v", chunks, x, z, want)
|
||||||
|
}
|
||||||
chunks++
|
chunks++
|
||||||
}
|
}
|
||||||
if chunks != 25 {
|
if chunks != 25 {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,10 @@ type Config struct {
|
||||||
// MaxCachedChunks bounds the in-memory chunk+frame cache (LRU). 0 means
|
// MaxCachedChunks bounds the in-memory chunk+frame cache (LRU). 0 means
|
||||||
// unbounded (use only for tests/flat worlds). At ~200KiB/chunk, 1024 ≈ 200MB.
|
// unbounded (use only for tests/flat worlds). At ~200KiB/chunk, 1024 ≈ 200MB.
|
||||||
MaxCachedChunks int
|
MaxCachedChunks int
|
||||||
|
// MaxViewDistance caps the client-requested chunk radius. Generation is much
|
||||||
|
// more expensive than vanilla's pregenerated worlds, so the server owns the
|
||||||
|
// upper bound instead of accepting the client's render distance verbatim.
|
||||||
|
MaxViewDistance int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns sensible defaults matching vanilla expectations.
|
// DefaultConfig returns sensible defaults matching vanilla expectations.
|
||||||
|
|
@ -51,6 +55,7 @@ func DefaultConfig() Config {
|
||||||
// MaxCachedChunks keeps the live cache near 200MB at the default; the
|
// MaxCachedChunks keeps the live cache near 200MB at the default; the
|
||||||
// streamer's pre-gen ring and player view distance comfortably fit.
|
// streamer's pre-gen ring and player view distance comfortably fit.
|
||||||
MaxCachedChunks: 1024,
|
MaxCachedChunks: 1024,
|
||||||
|
MaxViewDistance: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,6 +148,9 @@ func validateConfig(cfg Config) error {
|
||||||
if cfg.MaxCachedChunks < 0 {
|
if cfg.MaxCachedChunks < 0 {
|
||||||
return fmt.Errorf("server: max cached chunks must not be negative")
|
return fmt.Errorf("server: max cached chunks must not be negative")
|
||||||
}
|
}
|
||||||
|
if cfg.MaxViewDistance < 2 || cfg.MaxViewDistance > 16 {
|
||||||
|
return fmt.Errorf("server: max view distance must be between 2 and 16")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ func (s *Server) StartSpawning(ctx context.Context) {
|
||||||
func (s *Server) entityTickLoop(ctx context.Context) {
|
func (s *Server) entityTickLoop(ctx context.Context) {
|
||||||
ticker := time.NewTicker(50 * time.Millisecond) // 20 TPS
|
ticker := time.NewTicker(50 * time.Millisecond) // 20 TPS
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
|
@ -55,9 +56,9 @@ func (s *Server) entityTickLoop(ctx context.Context) {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Random wander
|
// Random wander
|
||||||
e.X += (rand.Float64() - 0.5) * 0.2
|
e.X += (rng.Float64() - 0.5) * 0.2
|
||||||
e.Z += (rand.Float64() - 0.5) * 0.2
|
e.Z += (rng.Float64() - 0.5) * 0.2
|
||||||
e.Yaw += float32((rand.Float64() - 0.5) * 10.0)
|
e.Yaw += float32((rng.Float64() - 0.5) * 10.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,6 +74,7 @@ func (s *Server) entityTickLoop(ctx context.Context) {
|
||||||
func (s *Server) mobSpawnLoop(ctx context.Context) {
|
func (s *Server) mobSpawnLoop(ctx context.Context) {
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||||
|
|
||||||
pigType := registry.EntityTypeIndex("minecraft:pig")
|
pigType := registry.EntityTypeIndex("minecraft:pig")
|
||||||
zombieType := registry.EntityTypeIndex("minecraft:zombie")
|
zombieType := registry.EntityTypeIndex("minecraft:zombie")
|
||||||
|
|
@ -85,28 +87,41 @@ func (s *Server) mobSpawnLoop(ctx context.Context) {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if s.PlayerCount() == 0 || s.entities.Count() >= 50 {
|
if s.PlayerCount() == 0 || s.entities.Count() >= 20 {
|
||||||
continue // limit to 50 entities
|
continue
|
||||||
}
|
}
|
||||||
|
s.spawnMobNearPlayer(rng, pigType, zombieType)
|
||||||
// Spawn near the spawn point (8.5, 200, 8.5)
|
|
||||||
x := (rand.Float64() - 0.5) * 30.0
|
|
||||||
z := (rand.Float64() - 0.5) * 30.0
|
|
||||||
|
|
||||||
t := pigType
|
|
||||||
name := "minecraft:pig"
|
|
||||||
if rand.Float32() < 0.5 {
|
|
||||||
t = zombieType
|
|
||||||
name = "minecraft:zombie"
|
|
||||||
}
|
|
||||||
|
|
||||||
s.entities.Add(&world.Entity{
|
|
||||||
TypeID: t,
|
|
||||||
TypeName: name,
|
|
||||||
X: x + 8.5,
|
|
||||||
Y: 200.0,
|
|
||||||
Z: z + 8.5,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) spawnMobNearPlayer(rng *rand.Rand, pigType, zombieType int) bool {
|
||||||
|
players := s.PlayerSnapshots()
|
||||||
|
if len(players) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
player := players[rng.Intn(len(players))]
|
||||||
|
angle := rng.Float64() * 2 * math.Pi
|
||||||
|
distance := 16.0 + rng.Float64()*16.0
|
||||||
|
x := int(math.Floor(player.X + math.Cos(angle)*distance))
|
||||||
|
z := int(math.Floor(player.Z + math.Sin(angle)*distance))
|
||||||
|
y, ok := s.chunks.SafeSpawnY(x, z)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
typeID := pigType
|
||||||
|
typeName := "minecraft:pig"
|
||||||
|
if rng.Float32() < 0.5 {
|
||||||
|
typeID = zombieType
|
||||||
|
typeName = "minecraft:zombie"
|
||||||
|
}
|
||||||
|
s.entities.Add(&world.Entity{
|
||||||
|
TypeID: typeID,
|
||||||
|
TypeName: typeName,
|
||||||
|
X: float64(x) + 0.5,
|
||||||
|
Y: float64(y),
|
||||||
|
Z: float64(z) + 0.5,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
|
||||||
39
internal/server/spawner_test.go
Normal file
39
internal/server/spawner_test.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"regionio/internal/world"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSpawnMobNearPlayerUsesSurface(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.WorldDir = ""
|
||||||
|
srv, err := NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
session, err := srv.RegisterPlayer(Profile{Name: "Alice", UUID: OfflineUUID("Alice")}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv.SetPlayerTransform(session, 8.5, 80, 8.5, 0, 0, true)
|
||||||
|
|
||||||
|
if !srv.spawnMobNearPlayer(rand.New(rand.NewSource(1)), 10, 20) {
|
||||||
|
t.Fatal("spawnMobNearPlayer returned false")
|
||||||
|
}
|
||||||
|
entities := srv.Entities().All()
|
||||||
|
if len(entities) != 1 {
|
||||||
|
t.Fatalf("entities = %d, want 1", len(entities))
|
||||||
|
}
|
||||||
|
entity := entities[0]
|
||||||
|
if entity.Y != world.FlatSurfaceY+1 {
|
||||||
|
t.Fatalf("mob Y = %v, want surface Y %d", entity.Y, world.FlatSurfaceY+1)
|
||||||
|
}
|
||||||
|
distance := math.Hypot(entity.X-8.5, entity.Z-8.5)
|
||||||
|
if distance < 15 || distance > 33 {
|
||||||
|
t.Fatalf("mob distance = %v, want near player", distance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,7 +40,7 @@ type Cache struct {
|
||||||
maxChunks int // LRU capacity; 0 = unbounded
|
maxChunks int // LRU capacity; 0 = unbounded
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lightMu sync.Mutex
|
lightMu sync.RWMutex
|
||||||
chunks map[[2]int32]*Chunk
|
chunks map[[2]int32]*Chunk
|
||||||
frames map[[2]int32][]byte
|
frames map[[2]int32][]byte
|
||||||
dirty map[[2]int32]uint64
|
dirty map[[2]int32]uint64
|
||||||
|
|
@ -248,6 +248,22 @@ func (c *Cache) FrameErrContext(ctx context.Context, cx, cz int32) ([]byte, erro
|
||||||
return c.frameErr(cx, cz)
|
return c.frameErr(cx, cz)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreloadErrContext loads or generates a chunk without calculating lighting or
|
||||||
|
// encoding a network frame. Streamers use it for predictive terrain work so a
|
||||||
|
// prefetch ring does not recursively expand through lighting neighborhoods.
|
||||||
|
func (c *Cache) PreloadErrContext(ctx context.Context, cx, cz int32) error {
|
||||||
|
select {
|
||||||
|
case c.frameSlots <- struct{}{}:
|
||||||
|
defer func() { <-c.frameSlots }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
release := c.beginUse([2]int32{cx, cz})
|
||||||
|
defer release()
|
||||||
|
_, err := c.chunkAtErr(cx, cz)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Cache) frameErr(cx, cz int32) ([]byte, error) {
|
func (c *Cache) frameErr(cx, cz int32) ([]byte, error) {
|
||||||
key := [2]int32{cx, cz}
|
key := [2]int32{cx, cz}
|
||||||
|
|
||||||
|
|
@ -307,6 +323,33 @@ func (c *Cache) GetBlock(x, y, z int) uint16 {
|
||||||
return ch.GetBlock(x, y, z)
|
return ch.GetBlock(x, y, z)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SafeSpawnY returns a feet-level Y with a supporting floor and two air blocks
|
||||||
|
// above it. Water columns and decorative plants are skipped rather than
|
||||||
|
// spawning an entity inside them. Loading happens once for the whole column.
|
||||||
|
func (c *Cache) SafeSpawnY(x, z int) (int, bool) {
|
||||||
|
cx := int32(x >> 4)
|
||||||
|
cz := int32(z >> 4)
|
||||||
|
release := c.beginUse([2]int32{cx, cz})
|
||||||
|
defer release()
|
||||||
|
ch, err := c.chunkAtErr(cx, cz)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
ch.mu.RLock()
|
||||||
|
defer ch.mu.RUnlock()
|
||||||
|
for y := MinY + WorldHeight - 3; y >= MinY; y-- {
|
||||||
|
floor := ch.getBlock(x, y, z)
|
||||||
|
if !supportsEntitySpawn(floor) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch.getBlock(x, y+1, z) == StateAir && ch.getBlock(x, y+2, z) == StateAir {
|
||||||
|
return y + 1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
// LightUpdate returns the standalone light_update body for a loaded chunk.
|
// LightUpdate returns the standalone light_update body for a loaded chunk.
|
||||||
func (c *Cache) LightUpdate(cx, cz int32) ([]byte, error) {
|
func (c *Cache) LightUpdate(cx, cz int32) ([]byte, error) {
|
||||||
release := c.beginUse([2]int32{cx, cz})
|
release := c.beginUse([2]int32{cx, cz})
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ import (
|
||||||
// neighborhood. Light attenuates to zero within 15 blocks, so chunks outside
|
// neighborhood. Light attenuates to zero within 15 blocks, so chunks outside
|
||||||
// that neighborhood cannot affect the center chunk.
|
// that neighborhood cannot affect the center chunk.
|
||||||
func (c *Cache) ensureLight(chunk *Chunk) error {
|
func (c *Cache) ensureLight(chunk *Chunk) error {
|
||||||
c.lightMu.Lock()
|
c.lightMu.RLock()
|
||||||
defer c.lightMu.Unlock()
|
defer c.lightMu.RUnlock()
|
||||||
return c.ensureLightLocked(chunk)
|
return c.ensureLightLocked(chunk)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,44 +65,57 @@ func (c *Cache) ensureLightLocked(chunk *Chunk) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// lightInputSnapshot returns blocks for a neighboring chunk without inserting
|
// lightInputSnapshot returns a stable neighbor snapshot. Cache misses are kept
|
||||||
// a cache miss into the LRU. This keeps lighting correct even for very small
|
// in the LRU so adjacent frames reuse the same expensive terrain instead of
|
||||||
// cache limits and avoids an eight-chunk eviction cascade per frame.
|
// regenerating up to eight neighbors for every lighting calculation.
|
||||||
func (c *Cache) lightInputSnapshot(cx, cz int32) (*Chunk, error) {
|
func (c *Cache) lightInputSnapshot(cx, cz int32) (*Chunk, error) {
|
||||||
key := [2]int32{cx, cz}
|
key := [2]int32{cx, cz}
|
||||||
c.mu.Lock()
|
// A cache smaller than the required 3x3 neighborhood cannot retain these
|
||||||
if chunk := c.chunks[key]; chunk != nil {
|
// inputs usefully. Keep misses detached to avoid evicting the requested
|
||||||
c.touch(key)
|
// center chunk and churning the LRU on every frame.
|
||||||
c.mu.Unlock()
|
if c.maxChunks > 0 && c.maxChunks < 9 {
|
||||||
snapshot, _ := chunk.snapshot()
|
c.mu.Lock()
|
||||||
return snapshot, nil
|
if chunk := c.chunks[key]; chunk != nil {
|
||||||
}
|
c.touch(key)
|
||||||
if pending := c.loads[key]; pending != nil {
|
c.mu.Unlock()
|
||||||
c.mu.Unlock()
|
snapshot, _ := chunk.snapshot()
|
||||||
<-pending.done
|
|
||||||
if pending.err != nil {
|
|
||||||
return nil, pending.err
|
|
||||||
}
|
|
||||||
snapshot, _ := pending.ch.snapshot()
|
|
||||||
return snapshot, nil
|
|
||||||
}
|
|
||||||
c.mu.Unlock()
|
|
||||||
|
|
||||||
if c.store != nil {
|
|
||||||
loaded, err := c.store.LoadChunk(cx, cz)
|
|
||||||
if err == nil {
|
|
||||||
snapshot, _ := loaded.snapshot()
|
|
||||||
return snapshot, nil
|
return snapshot, nil
|
||||||
}
|
}
|
||||||
if !errors.Is(err, ErrChunkNotFound) {
|
if pending := c.loads[key]; pending != nil {
|
||||||
return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err)
|
c.mu.Unlock()
|
||||||
|
<-pending.done
|
||||||
|
if pending.err != nil {
|
||||||
|
return nil, pending.err
|
||||||
|
}
|
||||||
|
snapshot, _ := pending.ch.snapshot()
|
||||||
|
return snapshot, nil
|
||||||
}
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
if c.store != nil {
|
||||||
|
loaded, err := c.store.LoadChunk(cx, cz)
|
||||||
|
if err == nil {
|
||||||
|
snapshot, _ := loaded.snapshot()
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, ErrChunkNotFound) {
|
||||||
|
return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
generated := c.gen(cx, cz)
|
||||||
|
if generated == nil {
|
||||||
|
return nil, fmt.Errorf("world: generator returned nil light neighbor (%d,%d)", cx, cz)
|
||||||
|
}
|
||||||
|
snapshot, _ := generated.snapshot()
|
||||||
|
return snapshot, nil
|
||||||
}
|
}
|
||||||
generated := c.gen(cx, cz)
|
|
||||||
if generated == nil {
|
release := c.beginUse(key)
|
||||||
return nil, fmt.Errorf("world: generator returned nil light neighbor (%d,%d)", cx, cz)
|
defer release()
|
||||||
|
chunk, err := c.chunkAtErr(cx, cz)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("world: load light neighbor (%d,%d): %w", cx, cz, err)
|
||||||
}
|
}
|
||||||
snapshot, _ := generated.snapshot()
|
snapshot, _ := chunk.snapshot()
|
||||||
return snapshot, nil
|
return snapshot, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
35
internal/world/cache_light_reuse_test.go
Normal file
35
internal/world/cache_light_reuse_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdjacentFramesReuseGeneratedLightNeighbors(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
generated := make(map[[2]int32]int)
|
||||||
|
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||||
|
mu.Lock()
|
||||||
|
generated[[2]int32{cx, cz}]++
|
||||||
|
mu.Unlock()
|
||||||
|
return NewChunk(cx, cz, BiomePlains)
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(generated) != 12 {
|
||||||
|
t.Fatalf("generated chunks = %d, want 12 shared neighborhood chunks", len(generated))
|
||||||
|
}
|
||||||
|
for pos, count := range generated {
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("chunk %v generated %d times, want once", pos, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
41
internal/world/spawn_surface_test.go
Normal file
41
internal/world/spawn_surface_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
package world
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSafeSpawnYUsesGeneratedSurface(t *testing.T) {
|
||||||
|
cache := NewCache(-1, GenerateFlat)
|
||||||
|
y, ok := cache.SafeSpawnY(8, 8)
|
||||||
|
if !ok || y != FlatSurfaceY+1 {
|
||||||
|
t.Fatalf("SafeSpawnY = %d, %v; want %d, true", y, ok, FlatSurfaceY+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeSpawnYRejectsUnderwaterColumn(t *testing.T) {
|
||||||
|
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||||
|
chunk := NewChunk(cx, cz, BiomePlains)
|
||||||
|
chunk.setBlockRaw(8, 60, 8, StateStone)
|
||||||
|
for y := 61; y <= SeaLevel; y++ {
|
||||||
|
chunk.setBlockRaw(8, y, 8, StateWater)
|
||||||
|
}
|
||||||
|
return chunk
|
||||||
|
})
|
||||||
|
if y, ok := cache.SafeSpawnY(8, 8); ok {
|
||||||
|
t.Fatalf("SafeSpawnY = %d, true; want underwater column rejected", y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSafeSpawnYAcceptsNonOpaqueSolidFloor(t *testing.T) {
|
||||||
|
stairs := nameToStateID("minecraft:oak_stairs", nil)
|
||||||
|
if stairs == StateAir {
|
||||||
|
t.Fatal("oak stairs state is unavailable")
|
||||||
|
}
|
||||||
|
cache := NewCache(-1, func(cx, cz int32) *Chunk {
|
||||||
|
chunk := NewChunk(cx, cz, BiomePlains)
|
||||||
|
chunk.setBlockRaw(8, 70, 8, stairs)
|
||||||
|
return chunk
|
||||||
|
})
|
||||||
|
y, ok := cache.SafeSpawnY(8, 8)
|
||||||
|
if !ok || y != 71 {
|
||||||
|
t.Fatalf("SafeSpawnY = %d, %v; want 71, true for stairs", y, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ package world
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"regionio/internal/nbt"
|
"regionio/internal/nbt"
|
||||||
|
|
@ -39,6 +40,41 @@ func stateByID(id uint16) (stateName, bool) {
|
||||||
return s, ok
|
return s, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// supportsEntitySpawn distinguishes collision floors from decorative blocks.
|
||||||
|
// Light opacity is not sufficient here: stairs and slabs can have opacity zero
|
||||||
|
// while still supporting an entity.
|
||||||
|
func supportsEntitySpawn(id uint16) bool {
|
||||||
|
if id == StateAir || id == StateWater {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if lightOpacity(id) > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
state, ok := stateByID(id)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
name := state.Name
|
||||||
|
for _, suffix := range []string{
|
||||||
|
"_sapling", "_flower", "_tulip", "_mushroom", "_torch",
|
||||||
|
"_rail", "_button", "_pressure_plate", "_carpet", "_banner",
|
||||||
|
"_sign", "_hanging_sign",
|
||||||
|
} {
|
||||||
|
if strings.HasSuffix(name, suffix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "minecraft:short_grass", "minecraft:tall_grass", "minecraft:fern",
|
||||||
|
"minecraft:large_fern", "minecraft:dead_bush", "minecraft:dandelion",
|
||||||
|
"minecraft:poppy", "minecraft:allium", "minecraft:azure_bluet",
|
||||||
|
"minecraft:oxeye_daisy", "minecraft:cornflower",
|
||||||
|
"minecraft:lily_of_the_valley", "minecraft:sunflower":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func buildStateTable() {
|
func buildStateTable() {
|
||||||
var blocks map[string]struct {
|
var blocks map[string]struct {
|
||||||
States []struct {
|
States []struct {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue