diff --git a/internal/network/multiplayer_test.go b/internal/network/multiplayer_test.go index c88860c..213dfba 100644 --- a/internal/network/multiplayer_test.go +++ b/internal/network/multiplayer_test.go @@ -270,6 +270,47 @@ func TestIntegrationFourClientsVisibilityMovementLeaveAndLight(t *testing.T) { srv.Entities().Remove(mobID) } +func TestEntityVisibilityWaitsForResidentChunk(t *testing.T) { + cfg := server.DefaultConfig() + cfg.WorldDir = "" + srv, err := server.NewWithCache(cfg, world.NewCache(-1, world.GenerateFlat)) + if err != nil { + t.Fatal(err) + } + recorder := &recordingConn{} + h := &handler{conn: NewConn(recorder), srv: srv, log: slog.Default(), viewDistance: 2} + h.conn.Profile = server.Profile{Name: "Alice", UUID: server.OfflineUUID("Alice")} + h.session, err = srv.RegisterPlayer(h.conn.Profile, h.conn.Send) + if err != nil { + t.Fatal(err) + } + defer srv.UnregisterPlayer(h.session) + srv.SetPlayerTransform(h.session, 8, 80, 8, 0, 0, true) + h.streamer = &streamer{resident: make(map[[2]int32]bool)} + srv.Entities().Add(&world.Entity{ + TypeID: 100, TypeName: "minecraft:pig", X: 24, Y: 80, Z: 8, + }) + + if err := h.syncVisibleEntities(); err != nil { + t.Fatal(err) + } + if got := countPackets(recorder.take(t), protocol.PlayAddEntity); got != 0 { + t.Fatalf("add-entity packets before chunk = %d, want 0", got) + } + + h.streamer.setResident([2]int32{1, 0}, true) + if err := h.syncVisibleEntities(); err != nil { + t.Fatal(err) + } + assertPacketIDs(t, recorder.take(t), protocol.PlayAddEntity) + + h.streamer.setResident([2]int32{1, 0}, false) + if err := h.syncVisibleEntities(); err != nil { + t.Fatal(err) + } + assertPacketIDs(t, recorder.take(t), protocol.PlayRemoveEntities) +} + func countPackets(packets []protocol.Packet, id int32) int { count := 0 for _, packet := range packets { diff --git a/internal/network/play.go b/internal/network/play.go index b878be3..e6072fc 100644 --- a/internal/network/play.go +++ b/internal/network/play.go @@ -261,7 +261,8 @@ func (h *handler) syncVisibleEntities() error { currentEntities := make(map[int32]visibleEntity) for _, player := range players { - if player.EntityID == viewer.EntityID || !playerVisible(viewer, player, h.visibilityRadius()) { + if player.EntityID == viewer.EntityID || !playerVisible(viewer, player, h.visibilityRadius()) || + !h.entityChunkResident(player.X, player.Z) { continue } currentEntities[player.EntityID] = visibleEntity{ @@ -275,7 +276,7 @@ func (h *handler) syncVisibleEntities() error { } } for _, entity := range h.srv.Entities().All() { - if entityVisible(viewer, entity, h.visibilityRadius()) { + if entityVisible(viewer, entity, h.visibilityRadius()) && h.entityChunkResident(entity.X, entity.Z) { currentEntities[entity.ID] = visibleEntity{entity: entity, onGround: true} } } @@ -314,6 +315,15 @@ func (h *handler) syncVisibleEntities() error { return nil } +func (h *handler) entityChunkResident(x, z float64) bool { + if h.streamer == nil { + return true + } + cx := int32(int64(math.Floor(x)) >> 4) + cz := int32(int64(math.Floor(z)) >> 4) + return h.streamer.isResident(cx, cz) +} + func playerVisible(viewer, target server.PlayerSnapshot, radius int) bool { return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius) } diff --git a/internal/network/streamer.go b/internal/network/streamer.go index dd1df8e..959d123 100644 --- a/internal/network/streamer.go +++ b/internal/network/streamer.go @@ -41,6 +41,12 @@ type streamer struct { centerZ int32 hasCenter bool + // resident is the cross-goroutine view of chunks whose level_chunk packet + // has reached the connection. Entity sync uses it to avoid exposing mobs or + // players before the terrain under them exists client-side. + residentMu sync.RWMutex + resident map[[2]int32]bool + viewRadius int // chunks within this Chebyshev radius are sent to the client genRadius int // viewRadius + 1: pre-generated but not sent (predictive ring) poolSize int // parallel generation workers @@ -75,6 +81,7 @@ func newStreamer(cache *world.Cache, conn *Conn, log *slog.Logger, viewDistance log: log, recenter: make(chan recenterReq, 4), loaded: make(map[[2]int32]bool), + resident: make(map[[2]int32]bool), viewRadius: viewDistance, genRadius: viewDistance + 1, poolSize: pool, @@ -173,6 +180,7 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) (recenterR // only server-side by tickets and never left loaded on the client. for key := range s.loaded { if !view[key] { + s.setResident(key, false) s.sendForgetLevelChunk(key[0], key[1]) delete(s.loaded, key) } @@ -344,9 +352,27 @@ func (s *streamer) parallelSend(ctx context.Context, keys [][2]int32) { } } s.loaded[[2]int32{j.cx, j.cz}] = true + s.setResident([2]int32{j.cx, j.cz}, true) } } +func (s *streamer) setResident(key [2]int32, present bool) { + s.residentMu.Lock() + if present { + s.resident[key] = true + } else { + delete(s.resident, key) + } + s.residentMu.Unlock() +} + +func (s *streamer) isResident(cx, cz int32) bool { + s.residentMu.RLock() + present := s.resident[[2]int32{cx, cz}] + s.residentMu.RUnlock() + return present +} + // parallelPreload warms terrain for the given chunks without calculating light, // encoding frames, or sending packets. Errors are ignored. func (s *streamer) parallelPreload(ctx context.Context, keys [][2]int32) {