Implement multiplayer persistence and vanilla lighting
This commit is contained in:
parent
8f7cacf9d9
commit
cae06eb97e
47 changed files with 3784 additions and 465 deletions
|
|
@ -7,14 +7,6 @@ import (
|
|||
"regionio/internal/registry"
|
||||
)
|
||||
|
||||
// ClientSettings holds the subset of client_information we currently track.
|
||||
type ClientSettings struct {
|
||||
Locale string
|
||||
ViewDistance int8
|
||||
ChatMode int32
|
||||
MainHand int32
|
||||
}
|
||||
|
||||
// beginConfiguration is called on entering the configuration phase. It mirrors
|
||||
// the vanilla opening sequence: server brand, enabled feature flags, then the
|
||||
// known-packs negotiation. The client's known-packs reply triggers the registry
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"bufio"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/server"
|
||||
|
|
@ -56,7 +57,12 @@ func (c *Conn) RemoteAddr() net.Addr { return c.raw.RemoteAddr() }
|
|||
|
||||
// ReadPacket reads the next frame using the current compression settings.
|
||||
func (c *Conn) ReadPacket() (protocol.Packet, error) {
|
||||
return protocol.ReadPacket(c.br, c.compressionThreshold)
|
||||
if err := c.raw.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
|
||||
return protocol.Packet{}, err
|
||||
}
|
||||
pkt, err := protocol.ReadPacket(c.br, c.compressionThreshold)
|
||||
_ = c.raw.SetReadDeadline(time.Time{})
|
||||
return pkt, err
|
||||
}
|
||||
|
||||
// Send writes a packet with the given ID and pre-encoded body. Safe for
|
||||
|
|
|
|||
276
internal/network/entity_packets_test.go
Normal file
276
internal/network/entity_packets_test.go
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/server"
|
||||
"regionio/internal/world"
|
||||
)
|
||||
|
||||
func readServerPacket(t *testing.T, c net.Conn) protocol.Packet {
|
||||
t.Helper()
|
||||
// net.Pipe has no buffering; read the frame from a goroutine-driven write.
|
||||
br := make([]byte, 4096)
|
||||
n, err := c.Read(br)
|
||||
if err != nil {
|
||||
t.Fatalf("reading packet: %v", err)
|
||||
}
|
||||
pkt, err := protocol.ReadPacket(bufio.NewReader(bytes.NewReader(br[:n])), -1)
|
||||
if err != nil {
|
||||
t.Fatalf("decoding packet: %v", err)
|
||||
}
|
||||
return pkt
|
||||
}
|
||||
|
||||
func TestSendEntityTeleportUsesPositionMoveRotation(t *testing.T) {
|
||||
serverSide, clientSide := net.Pipe()
|
||||
defer serverSide.Close()
|
||||
defer clientSide.Close()
|
||||
|
||||
h := &handler{conn: NewConn(serverSide)}
|
||||
ent := &world.Entity{
|
||||
ID: 42,
|
||||
X: 1.25,
|
||||
Y: 65.5,
|
||||
Z: -3.75,
|
||||
Yaw: 90,
|
||||
Pitch: 15,
|
||||
VelocityX: 80,
|
||||
VelocityY: -160,
|
||||
VelocityZ: 240,
|
||||
}
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- h.sendEntityTeleport(ent) }()
|
||||
|
||||
pkt := readServerPacket(t, clientSide)
|
||||
if err := <-errc; err != nil {
|
||||
t.Fatalf("sendEntityTeleport: %v", err)
|
||||
}
|
||||
if pkt.ID != protocol.PlayTeleportEntity {
|
||||
t.Fatalf("packet id = %#x, want %#x", pkt.ID, protocol.PlayTeleportEntity)
|
||||
}
|
||||
|
||||
r := pkt.Body()
|
||||
if id, err := r.VarInt(); err != nil || id != ent.ID {
|
||||
t.Fatalf("entity id = %d, %v; want %d", id, err, ent.ID)
|
||||
}
|
||||
coords := []struct {
|
||||
name string
|
||||
want float64
|
||||
}{
|
||||
{"x", ent.X},
|
||||
{"y", ent.Y},
|
||||
{"z", ent.Z},
|
||||
}
|
||||
for _, tc := range coords {
|
||||
got, err := r.Float64()
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("%s = %v, %v; want %v", tc.name, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
velocities := []struct {
|
||||
name string
|
||||
want float64
|
||||
}{
|
||||
{"vx", float64(ent.VelocityX) / 8000.0},
|
||||
{"vy", float64(ent.VelocityY) / 8000.0},
|
||||
{"vz", float64(ent.VelocityZ) / 8000.0},
|
||||
}
|
||||
for _, tc := range velocities {
|
||||
got, err := r.Float64()
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("%s = %v, %v; want %v", tc.name, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
if yaw, err := r.Float32(); err != nil || yaw != ent.Yaw {
|
||||
t.Fatalf("yaw = %v, %v; want %v", yaw, err, ent.Yaw)
|
||||
}
|
||||
if pitch, err := r.Float32(); err != nil || pitch != ent.Pitch {
|
||||
t.Fatalf("pitch = %v, %v; want %v", pitch, err, ent.Pitch)
|
||||
}
|
||||
if flags, err := r.Int32(); err != nil || flags != 0 {
|
||||
t.Fatalf("relative flags = %d, %v; want 0", flags, err)
|
||||
}
|
||||
if onGround, err := r.Bool(); err != nil || !onGround {
|
||||
t.Fatalf("onGround = %v, %v; want true", onGround, err)
|
||||
}
|
||||
if rem := r.Remaining(); rem != 0 {
|
||||
t.Fatalf("remaining bytes = %d, want 0", rem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAddEntityLayout(t *testing.T) {
|
||||
serverSide, clientSide := net.Pipe()
|
||||
defer serverSide.Close()
|
||||
defer clientSide.Close()
|
||||
|
||||
uuid := [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}
|
||||
h := &handler{conn: NewConn(serverSide)}
|
||||
ent := &world.Entity{
|
||||
ID: 43,
|
||||
UUID: uuid,
|
||||
TypeID: 77,
|
||||
X: 10.5,
|
||||
Y: 66.25,
|
||||
Z: -20.75,
|
||||
Pitch: 45,
|
||||
Yaw: 180,
|
||||
HeadYaw: 90,
|
||||
VelocityX: 123,
|
||||
VelocityY: -456,
|
||||
VelocityZ: 789,
|
||||
}
|
||||
|
||||
errc := make(chan error, 1)
|
||||
go func() { errc <- h.sendAddEntity(ent) }()
|
||||
|
||||
pkt := readServerPacket(t, clientSide)
|
||||
if err := <-errc; err != nil {
|
||||
t.Fatalf("sendAddEntity: %v", err)
|
||||
}
|
||||
if pkt.ID != protocol.PlayAddEntity {
|
||||
t.Fatalf("packet id = %#x, want %#x", pkt.ID, protocol.PlayAddEntity)
|
||||
}
|
||||
|
||||
r := pkt.Body()
|
||||
if id, err := r.VarInt(); err != nil || id != ent.ID {
|
||||
t.Fatalf("entity id = %d, %v; want %d", id, err, ent.ID)
|
||||
}
|
||||
if got, err := r.UUID(); err != nil || got != uuid {
|
||||
t.Fatalf("uuid = %x, %v; want %x", got, err, uuid)
|
||||
}
|
||||
if typ, err := r.VarInt(); err != nil || typ != int32(ent.TypeID) {
|
||||
t.Fatalf("type id = %d, %v; want %d", typ, err, ent.TypeID)
|
||||
}
|
||||
coords := []struct {
|
||||
name string
|
||||
want float64
|
||||
}{
|
||||
{"x", ent.X},
|
||||
{"y", ent.Y},
|
||||
{"z", ent.Z},
|
||||
}
|
||||
for _, tc := range coords {
|
||||
got, err := r.Float64()
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("%s = %v, %v; want %v", tc.name, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
angles := []struct {
|
||||
name string
|
||||
want byte
|
||||
}{
|
||||
{"pitch", byte(ent.Pitch * 256.0 / 360.0)},
|
||||
{"yaw", byte(ent.Yaw * 256.0 / 360.0)},
|
||||
{"headYaw", byte(ent.HeadYaw * 256.0 / 360.0)},
|
||||
}
|
||||
for _, tc := range angles {
|
||||
got, err := r.ReadByte()
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("%s = %d, %v; want %d", tc.name, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
if data, err := r.VarInt(); err != nil || data != 0 {
|
||||
t.Fatalf("data = %d, %v; want 0", data, err)
|
||||
}
|
||||
encodedVelocities := []struct {
|
||||
name string
|
||||
want uint16
|
||||
}{
|
||||
{"velocityX", uint16(ent.VelocityX)},
|
||||
{"velocityY", uint16(ent.VelocityY)},
|
||||
{"velocityZ", uint16(ent.VelocityZ)},
|
||||
}
|
||||
for _, tc := range encodedVelocities {
|
||||
got, err := r.Uint16()
|
||||
if err != nil || got != tc.want {
|
||||
t.Fatalf("%s = %d, %v; want %d", tc.name, got, err, tc.want)
|
||||
}
|
||||
}
|
||||
if rem := r.Remaining(); rem != 0 {
|
||||
t.Fatalf("remaining bytes = %d, want 0", rem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlayerInfoPacketLayouts(t *testing.T) {
|
||||
serverSide, clientSide := net.Pipe()
|
||||
defer serverSide.Close()
|
||||
defer clientSide.Close()
|
||||
|
||||
profile := server.Profile{Name: "Alice", UUID: server.OfflineUUID("Alice")}
|
||||
h := &handler{conn: NewConn(serverSide)}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() { errCh <- h.sendPlayerInfoAdd(profile) }()
|
||||
pkt := readServerPacket(t, clientSide)
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkt.ID != protocol.PlayPlayerInfoUpdate {
|
||||
t.Fatalf("player info update id = %#x, want %#x", pkt.ID, protocol.PlayPlayerInfoUpdate)
|
||||
}
|
||||
r := pkt.Body()
|
||||
if actions, err := r.ReadByte(); err != nil || actions != 0xff {
|
||||
t.Fatalf("actions = %#x, %v; want 0xff", actions, err)
|
||||
}
|
||||
if count, err := r.VarInt(); err != nil || count != 1 {
|
||||
t.Fatalf("entry count = %d, %v; want 1", count, err)
|
||||
}
|
||||
if uuid, err := r.UUID(); err != nil || uuid != profile.UUID {
|
||||
t.Fatalf("profile UUID = %x, %v; want %x", uuid, err, profile.UUID)
|
||||
}
|
||||
if name, err := r.String(); err != nil || name != profile.Name {
|
||||
t.Fatalf("profile name = %q, %v; want %q", name, err, profile.Name)
|
||||
}
|
||||
if properties, err := r.VarInt(); err != nil || properties != 0 {
|
||||
t.Fatalf("properties = %d, %v; want 0", properties, err)
|
||||
}
|
||||
if chatSession, err := r.Bool(); err != nil || chatSession {
|
||||
t.Fatalf("chat session = %v, %v; want false", chatSession, err)
|
||||
}
|
||||
if gameMode, err := r.VarInt(); err != nil || gameMode != 1 {
|
||||
t.Fatalf("game mode = %d, %v; want 1", gameMode, err)
|
||||
}
|
||||
if listed, err := r.Bool(); err != nil || !listed {
|
||||
t.Fatalf("listed = %v, %v; want true", listed, err)
|
||||
}
|
||||
if latency, err := r.VarInt(); err != nil || latency != 0 {
|
||||
t.Fatalf("latency = %d, %v; want 0", latency, err)
|
||||
}
|
||||
if displayName, err := r.Bool(); err != nil || displayName {
|
||||
t.Fatalf("display name = %v, %v; want absent", displayName, err)
|
||||
}
|
||||
if order, err := r.VarInt(); err != nil || order != 0 {
|
||||
t.Fatalf("list order = %d, %v; want 0", order, err)
|
||||
}
|
||||
if showHat, err := r.Bool(); err != nil || !showHat {
|
||||
t.Fatalf("show hat = %v, %v; want true", showHat, err)
|
||||
}
|
||||
if r.Remaining() != 0 {
|
||||
t.Fatalf("player info update trailing bytes = %d", r.Remaining())
|
||||
}
|
||||
|
||||
go func() { errCh <- h.sendPlayerInfoRemove(profile.UUID) }()
|
||||
pkt = readServerPacket(t, clientSide)
|
||||
if err := <-errCh; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkt.ID != protocol.PlayPlayerInfoRemove {
|
||||
t.Fatalf("player info remove id = %#x, want %#x", pkt.ID, protocol.PlayPlayerInfoRemove)
|
||||
}
|
||||
r = pkt.Body()
|
||||
if count, err := r.VarInt(); err != nil || count != 1 {
|
||||
t.Fatalf("remove count = %d, %v; want 1", count, err)
|
||||
}
|
||||
if uuid, err := r.UUID(); err != nil || uuid != profile.UUID {
|
||||
t.Fatalf("removed UUID = %x, %v; want %x", uuid, err, profile.UUID)
|
||||
}
|
||||
if r.Remaining() != 0 {
|
||||
t.Fatalf("player info remove trailing bytes = %d", r.Remaining())
|
||||
}
|
||||
}
|
||||
|
|
@ -27,11 +27,14 @@ type handler struct {
|
|||
streamer *streamer
|
||||
// viewDistance is the client's requested view distance (from
|
||||
// client_information), clamped; used to size the streamer.
|
||||
viewDistance int
|
||||
viewDistance int
|
||||
session *server.PlayerSession
|
||||
knownPlayers map[[16]byte]bool
|
||||
knownEntities map[int32]visibleEntity
|
||||
|
||||
// Creative inventory state for block placement.
|
||||
heldSlot int32 // selected hotbar index (0-8)
|
||||
hotbar [9]int32 // item network IDs per hotbar slot (-1 = empty)
|
||||
heldSlot int32 // selected hotbar index (0-8)
|
||||
hotbar [9]int32 // item network IDs per hotbar slot (-1 = empty)
|
||||
}
|
||||
|
||||
// serve runs the read/dispatch loop for a single connection. It owns the
|
||||
|
|
@ -44,7 +47,7 @@ func (h *handler) serve() {
|
|||
defer cancel() // stops the streamer when the read loop ends
|
||||
h.ctx = ctx
|
||||
|
||||
defer h.srv.RemovePlayerPosition(h.conn.Profile.Name)
|
||||
defer func() { h.srv.UnregisterPlayer(h.session) }()
|
||||
|
||||
for {
|
||||
pkt, err := h.conn.ReadPacket()
|
||||
|
|
@ -123,7 +126,7 @@ func (h *handler) handleHandshake(pkt protocol.Packet) error {
|
|||
func (h *handler) handleStatus(pkt protocol.Packet) error {
|
||||
switch pkt.ID {
|
||||
case protocol.StatusRequestID:
|
||||
jsonBytes, err := h.srv.StatusJSON(0)
|
||||
jsonBytes, err := h.srv.StatusJSON(h.srv.PlayerCount())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,19 +5,23 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"regionio/internal/server"
|
||||
)
|
||||
|
||||
// Listener accepts TCP connections and serves each in its own goroutine.
|
||||
type Listener struct {
|
||||
srv *server.Server
|
||||
log *slog.Logger
|
||||
srv *server.Server
|
||||
log *slog.Logger
|
||||
mu sync.Mutex
|
||||
conns map[net.Conn]struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewListener constructs a Listener bound to srv.
|
||||
func NewListener(srv *server.Server, log *slog.Logger) *Listener {
|
||||
return &Listener{srv: srv, log: log}
|
||||
return &Listener{srv: srv, log: log, conns: make(map[net.Conn]struct{})}
|
||||
}
|
||||
|
||||
// ListenAndServe binds the configured address and accepts connections until
|
||||
|
|
@ -31,6 +35,11 @@ func (l *Listener) ListenAndServe(ctx context.Context) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", addr, err)
|
||||
}
|
||||
defer func() {
|
||||
_ = ln.Close()
|
||||
l.closeConnections()
|
||||
l.wg.Wait()
|
||||
}()
|
||||
l.log.Info("RegionIO listening", "addr", addr, "version", "26.1.2")
|
||||
|
||||
// Close the listener when the context is cancelled to unblock Accept.
|
||||
|
|
@ -48,12 +57,34 @@ func (l *Listener) ListenAndServe(ctx context.Context) error {
|
|||
l.log.Warn("accept failed", "err", err)
|
||||
continue
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.conns[raw] = struct{}{}
|
||||
l.wg.Add(1)
|
||||
l.mu.Unlock()
|
||||
go l.serveConn(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Listener) closeConnections() {
|
||||
l.mu.Lock()
|
||||
conns := make([]net.Conn, 0, len(l.conns))
|
||||
for conn := range l.conns {
|
||||
conns = append(conns, conn)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
for _, conn := range conns {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// serveConn wraps a raw connection and runs its state-machine handler.
|
||||
func (l *Listener) serveConn(raw net.Conn) {
|
||||
defer func() {
|
||||
l.mu.Lock()
|
||||
delete(l.conns, raw)
|
||||
l.mu.Unlock()
|
||||
l.wg.Done()
|
||||
}()
|
||||
conn := NewConn(raw)
|
||||
h := &handler{
|
||||
conn: conn,
|
||||
|
|
|
|||
321
internal/network/multiplayer_test.go
Normal file
321
internal/network/multiplayer_test.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/server"
|
||||
"regionio/internal/world"
|
||||
)
|
||||
|
||||
type recordingConn struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (c *recordingConn) Read([]byte) (int, error) { return 0, io.EOF }
|
||||
func (c *recordingConn) Close() error { return nil }
|
||||
func (c *recordingConn) LocalAddr() net.Addr { return testAddr("local") }
|
||||
func (c *recordingConn) RemoteAddr() net.Addr { return testAddr("remote") }
|
||||
func (c *recordingConn) SetDeadline(time.Time) error { return nil }
|
||||
func (c *recordingConn) SetReadDeadline(time.Time) error { return nil }
|
||||
func (c *recordingConn) SetWriteDeadline(time.Time) error { return nil }
|
||||
func (c *recordingConn) Write(p []byte) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.buf.Write(p)
|
||||
}
|
||||
|
||||
func (c *recordingConn) take(t *testing.T) []protocol.Packet {
|
||||
t.Helper()
|
||||
c.mu.Lock()
|
||||
raw := append([]byte(nil), c.buf.Bytes()...)
|
||||
c.buf.Reset()
|
||||
c.mu.Unlock()
|
||||
|
||||
reader := bytes.NewReader(raw)
|
||||
br := bufio.NewReader(reader)
|
||||
var packets []protocol.Packet
|
||||
for br.Buffered() > 0 || reader.Len() > 0 {
|
||||
pkt, err := protocol.ReadPacket(br, -1)
|
||||
if err != nil {
|
||||
t.Fatalf("decode recorded packet: %v", err)
|
||||
}
|
||||
packets = append(packets, pkt)
|
||||
}
|
||||
return packets
|
||||
}
|
||||
|
||||
type testAddr string
|
||||
|
||||
func (a testAddr) Network() string { return "test" }
|
||||
func (a testAddr) String() string { return string(a) }
|
||||
|
||||
func TestIntegrationTwoPlayersShareBlockAndChatUpdates(t *testing.T) {
|
||||
cfg := server.DefaultConfig()
|
||||
cfg.WorldDir = ""
|
||||
cfg.MaxPlayers = 4
|
||||
cache := world.NewCache(-1, world.GenerateFlat)
|
||||
srv, err := server.NewWithCache(cfg, cache)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
raw1, raw2 := &recordingConn{}, &recordingConn{}
|
||||
h1 := &handler{conn: NewConn(raw1), srv: srv, log: slog.Default()}
|
||||
h2 := &handler{conn: NewConn(raw2), srv: srv, log: slog.Default()}
|
||||
h1.conn.Profile = server.Profile{Name: "Alice", UUID: server.OfflineUUID("Alice")}
|
||||
h2.conn.Profile = server.Profile{Name: "Bob", UUID: server.OfflineUUID("Bob")}
|
||||
h1.session, err = srv.RegisterPlayer(h1.conn.Profile, h1.conn.Send)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.UnregisterPlayer(h1.session)
|
||||
h2.session, err = srv.RegisterPlayer(h2.conn.Profile, h2.conn.Send)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.UnregisterPlayer(h2.session)
|
||||
|
||||
action := protocol.NewWriter(24)
|
||||
action.VarInt(0).Position(2, world.FlatSurfaceY, 3).Byte(1).VarInt(17)
|
||||
if err := h1.handlePlayerAction(protocol.Packet{ID: protocol.PlayPlayerAction, Data: action.Bytes()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := cache.GetBlock(2, world.FlatSurfaceY, 3); got != world.StateAir {
|
||||
t.Fatalf("shared block state = %d, want air", got)
|
||||
}
|
||||
assertPacketIDs(t, raw1.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayBlockChangedAck)
|
||||
assertPacketIDs(t, raw2.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate)
|
||||
|
||||
chat := protocol.NewWriter(16).String("hello")
|
||||
if err := h1.handleChat(protocol.Packet{ID: protocol.PlayChatMessage, Data: chat.Bytes()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPacketIDs(t, raw1.take(t), protocol.PlaySystemChat)
|
||||
assertPacketIDs(t, raw2.take(t), protocol.PlaySystemChat)
|
||||
}
|
||||
|
||||
func TestBoundaryEditBroadcastsEveryChangedLightChunk(t *testing.T) {
|
||||
cfg := server.DefaultConfig()
|
||||
cfg.WorldDir = ""
|
||||
cache := world.NewCache(-1, func(cx, cz int32) *world.Chunk {
|
||||
return world.NewChunk(cx, cz, world.BiomePlains)
|
||||
})
|
||||
if _, err := cache.FrameErr(0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cache.FrameErr(1, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv, err := server.NewWithCache(cfg, cache)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorder := &recordingConn{}
|
||||
h := &handler{conn: NewConn(recorder), srv: srv, log: slog.Default()}
|
||||
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, 15, 100, 8, 0, 0, true)
|
||||
srv.SetPlayerViewDistance(h.session, 2)
|
||||
|
||||
valid, lightChunks := cache.SetBlockWithLight(15, 100, 8, world.StateGlowstone)
|
||||
if !valid || len(lightChunks) != 2 {
|
||||
t.Fatalf("boundary edit valid=%v light chunks=%v; want two", valid, lightChunks)
|
||||
}
|
||||
h.broadcastBlockUpdate(15, 100, 8, world.StateGlowstone, lightChunks)
|
||||
assertPacketIDs(t, recorder.take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayLightUpdate)
|
||||
}
|
||||
|
||||
func TestIntegrationFourClientsVisibilityMovementLeaveAndLight(t *testing.T) {
|
||||
cfg := server.DefaultConfig()
|
||||
cfg.WorldDir = ""
|
||||
cfg.MaxPlayers = 4
|
||||
cache := world.NewCache(-1, world.GenerateFlat)
|
||||
srv, err := server.NewWithCache(cfg, cache)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
names := []string{"Alice", "Bob", "Carol", "Dave"}
|
||||
positions := [][3]float64{{0, 80, 0}, {16, 80, 0}, {160, 80, 0}, {176, 80, 0}}
|
||||
handlers := make([]*handler, len(names))
|
||||
recorders := make([]*recordingConn, len(names))
|
||||
for i, name := range names {
|
||||
recorders[i] = &recordingConn{}
|
||||
h := &handler{conn: NewConn(recorders[i]), srv: srv, log: slog.Default(), viewDistance: 2}
|
||||
h.conn.Profile = server.Profile{Name: name, UUID: server.OfflineUUID(name)}
|
||||
h.session, err = srv.RegisterPlayer(h.conn.Profile, h.conn.Send)
|
||||
if err != nil {
|
||||
t.Fatalf("register %s: %v", name, err)
|
||||
}
|
||||
srv.SetPlayerTransform(h.session, positions[i][0], positions[i][1], positions[i][2], 0, 0, true)
|
||||
srv.SetPlayerViewDistance(h.session, h.viewDistance)
|
||||
handlers[i] = h
|
||||
}
|
||||
defer func() {
|
||||
for _, h := range handlers {
|
||||
srv.UnregisterPlayer(h.session)
|
||||
}
|
||||
}()
|
||||
|
||||
mobID := srv.Entities().Add(&world.Entity{
|
||||
TypeID: 100, TypeName: "minecraft:pig", X: 8, Y: 80, Z: 8,
|
||||
})
|
||||
for _, h := range handlers {
|
||||
if err := h.syncVisibleEntities(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
for i, recorder := range recorders {
|
||||
packets := recorder.take(t)
|
||||
if got := countPackets(packets, protocol.PlayPlayerInfoUpdate); got != 4 {
|
||||
t.Fatalf("client %s player-info count = %d, want 4", names[i], got)
|
||||
}
|
||||
wantAdds := 1
|
||||
if i < 2 {
|
||||
wantAdds = 2 // nearby player plus the pig
|
||||
}
|
||||
if got := countPackets(packets, protocol.PlayAddEntity); got != wantAdds {
|
||||
t.Fatalf("client %s add-entity count = %d, want %d", names[i], got, wantAdds)
|
||||
}
|
||||
}
|
||||
|
||||
move := protocol.NewWriter(40)
|
||||
move.Float64(32).Float64(80).Float64(0).Float32(90).Float32(15).Byte(1)
|
||||
if err := handlers[1].handlePlay(protocol.Packet{ID: protocol.PlayMovePosRot, Data: move.Bytes()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := handlers[0].syncVisibleEntities(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
packets := recorders[0].take(t)
|
||||
if got := countPackets(packets, protocol.PlayTeleportEntity); got != 1 {
|
||||
t.Fatalf("nearby movement teleports = %d, want 1", got)
|
||||
}
|
||||
assertTeleportTransform(t, firstPacket(t, packets, protocol.PlayTeleportEntity), handlers[1].session.EntityID, 32, 80, 0, 90, 15, true)
|
||||
|
||||
move = protocol.NewWriter(32)
|
||||
move.Float64(64).Float64(80).Float64(0).Byte(1)
|
||||
if err := handlers[1].handlePlay(protocol.Packet{ID: protocol.PlayMovePos, Data: move.Bytes()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := handlers[0].syncVisibleEntities(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPacketIDs(t, recorders[0].take(t), protocol.PlayRemoveEntities)
|
||||
|
||||
move = protocol.NewWriter(32)
|
||||
move.Float64(16).Float64(80).Float64(0).Byte(1)
|
||||
if err := handlers[1].handlePlay(protocol.Packet{ID: protocol.PlayMovePos, Data: move.Bytes()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := handlers[0].syncVisibleEntities(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPacketIDs(t, recorders[0].take(t), protocol.PlayAddEntity)
|
||||
|
||||
srv.UnregisterPlayer(handlers[1].session)
|
||||
if err := handlers[0].syncVisibleEntities(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPacketIDs(t, recorders[0].take(t), protocol.PlayRemoveEntities, protocol.PlayPlayerInfoRemove)
|
||||
|
||||
action := protocol.NewWriter(24)
|
||||
action.VarInt(0).Position(2, world.FlatSurfaceY, 3).Byte(1).VarInt(91)
|
||||
if err := handlers[0].handlePlayerAction(protocol.Packet{ID: protocol.PlayPlayerAction, Data: action.Bytes()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPacketIDs(t, recorders[0].take(t), protocol.PlayBlockUpdate, protocol.PlayLightUpdate, protocol.PlayBlockChangedAck)
|
||||
if packets := recorders[2].take(t); len(packets) != 0 {
|
||||
t.Fatalf("far client Carol received %d block/light packets", len(packets))
|
||||
}
|
||||
if packets := recorders[3].take(t); len(packets) != 0 {
|
||||
t.Fatalf("far client Dave received %d block/light packets", len(packets))
|
||||
}
|
||||
|
||||
srv.Entities().Remove(mobID)
|
||||
}
|
||||
|
||||
func countPackets(packets []protocol.Packet, id int32) int {
|
||||
count := 0
|
||||
for _, packet := range packets {
|
||||
if packet.ID == id {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func firstPacket(t *testing.T, packets []protocol.Packet, id int32) protocol.Packet {
|
||||
t.Helper()
|
||||
for _, packet := range packets {
|
||||
if packet.ID == id {
|
||||
return packet
|
||||
}
|
||||
}
|
||||
t.Fatalf("packet %#x not found", id)
|
||||
return protocol.Packet{}
|
||||
}
|
||||
|
||||
func assertTeleportTransform(t *testing.T, packet protocol.Packet, entityID int32, x, y, z float64, yaw, pitch float32, onGround bool) {
|
||||
t.Helper()
|
||||
r := packet.Body()
|
||||
if got, err := r.VarInt(); err != nil || got != entityID {
|
||||
t.Fatalf("teleport entity = %d, %v; want %d", got, err, entityID)
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
want float64
|
||||
}{{"x", x}, {"y", y}, {"z", z}} {
|
||||
got, err := r.Float64()
|
||||
if err != nil || got != field.want {
|
||||
t.Fatalf("teleport %s = %v, %v; want %v", field.name, got, err, field.want)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if got, err := r.Float64(); err != nil || got != 0 {
|
||||
t.Fatalf("teleport velocity[%d] = %v, %v; want 0", i, got, err)
|
||||
}
|
||||
}
|
||||
if got, err := r.Float32(); err != nil || got != yaw {
|
||||
t.Fatalf("teleport yaw = %v, %v; want %v", got, err, yaw)
|
||||
}
|
||||
if got, err := r.Float32(); err != nil || got != pitch {
|
||||
t.Fatalf("teleport pitch = %v, %v; want %v", got, err, pitch)
|
||||
}
|
||||
if _, err := r.Int32(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := r.Bool(); err != nil || got != onGround {
|
||||
t.Fatalf("teleport onGround = %v, %v; want %v", got, err, onGround)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPacketIDs(t *testing.T, packets []protocol.Packet, want ...int32) {
|
||||
t.Helper()
|
||||
if len(packets) != len(want) {
|
||||
ids := make([]int32, len(packets))
|
||||
for i := range packets {
|
||||
ids[i] = packets[i].ID
|
||||
}
|
||||
t.Fatalf("packet IDs = %v (count %d), want %v (count %d)", ids, len(packets), want, len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if packets[i].ID != want[i] {
|
||||
t.Fatalf("packet[%d] id = %#x, want %#x", i, packets[i].ID, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
package network
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"regionio/internal/nbt"
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/registry"
|
||||
"regionio/internal/server"
|
||||
"regionio/internal/world"
|
||||
)
|
||||
|
||||
|
|
@ -22,6 +24,13 @@ const (
|
|||
// hands chunk streaming off to the background streamer. The streamer stops when
|
||||
// h.ctx (the connection lifetime context) is cancelled.
|
||||
func (h *handler) beginPlay() error {
|
||||
session, err := h.srv.RegisterPlayer(h.conn.Profile, h.conn.Send)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.session = session
|
||||
h.srv.SetPlayerTransform(session, spawnX, spawnY, spawnZ, 0, 0, true)
|
||||
h.srv.SetPlayerViewDistance(session, h.visibilityRadius())
|
||||
for i := range h.hotbar {
|
||||
h.hotbar[i] = -1 // empty
|
||||
}
|
||||
|
|
@ -36,6 +45,15 @@ func (h *handler) beginPlay() error {
|
|||
if err := h.sendPlayerPosition(1); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.sendChunkCacheCenter(0, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.sendDefaultSpawnPosition(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.sendPlayerAbilities(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Launch the background chunk streamer. It owns generation + sending so the
|
||||
// read loop stays free; requestRecenter is a non-blocking push.
|
||||
h.streamer = newStreamer(h.srv.Chunks(), h.conn, h.log, h.viewDistance)
|
||||
|
|
@ -46,10 +64,38 @@ func (h *handler) beginPlay() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) sendChunkCacheCenter(cx, cz int32) error {
|
||||
w := protocol.NewWriter(8)
|
||||
w.VarInt(cx)
|
||||
w.VarInt(cz)
|
||||
return h.conn.SendWriter(protocol.PlayChunkCacheCenter, w)
|
||||
}
|
||||
|
||||
func (h *handler) sendDefaultSpawnPosition() error {
|
||||
w := protocol.NewWriter(12)
|
||||
w.Position(8, 100, 8)
|
||||
w.Float32(0.0)
|
||||
return h.conn.SendWriter(protocol.PlayDefaultSpawnPos, w)
|
||||
}
|
||||
|
||||
func (h *handler) sendPlayerAbilities() error {
|
||||
w := protocol.NewWriter(9)
|
||||
w.Byte(0x0F)
|
||||
w.Float32(0.05)
|
||||
w.Float32(0.1)
|
||||
return h.conn.SendWriter(protocol.PlayAbilities, w)
|
||||
}
|
||||
|
||||
// onPlayerMove recenters the streamer when the player crosses into a new chunk.
|
||||
// It is a non-blocking push; the read loop never waits on generation.
|
||||
func (h *handler) onPlayerMove(x, y, z float64) error {
|
||||
h.srv.SetPlayerPosition(h.conn.Profile.Name, x, y, z)
|
||||
func (h *handler) onPlayerMove(x, y, z float64, yaw, pitch float32, onGround bool) error {
|
||||
if math.IsNaN(x) || math.IsNaN(y) || math.IsNaN(z) ||
|
||||
math.IsInf(x, 0) || math.IsInf(y, 0) || math.IsInf(z, 0) ||
|
||||
math.IsNaN(float64(yaw)) || math.IsNaN(float64(pitch)) ||
|
||||
math.IsInf(float64(yaw), 0) || math.IsInf(float64(pitch), 0) {
|
||||
return errors.New("invalid player position")
|
||||
}
|
||||
h.srv.SetPlayerTransform(h.session, x, y, z, yaw, pitch, onGround)
|
||||
cx := int32(int64(math.Floor(x)) >> 4)
|
||||
cz := int32(int64(math.Floor(z)) >> 4)
|
||||
if h.streamer != nil {
|
||||
|
|
@ -58,6 +104,16 @@ func (h *handler) onPlayerMove(x, y, z float64) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (h *handler) visibilityRadius() int {
|
||||
if h.viewDistance < 2 {
|
||||
return defaultViewRadius
|
||||
}
|
||||
if h.viewDistance > 16 {
|
||||
return 16
|
||||
}
|
||||
return h.viewDistance
|
||||
}
|
||||
|
||||
// sendPlayLogin writes the clientbound play "login" packet. Field layout was
|
||||
// confirmed against the 26.1.2 vanilla server capture.
|
||||
func (h *handler) sendPlayLogin() error {
|
||||
|
|
@ -67,8 +123,12 @@ func (h *handler) sendPlayLogin() error {
|
|||
}
|
||||
|
||||
w := protocol.NewWriter(128)
|
||||
w.Int32(1) // entity ID
|
||||
w.Bool(false) // is hardcore
|
||||
entityID := int32(1)
|
||||
if h.session != nil {
|
||||
entityID = h.session.EntityID
|
||||
}
|
||||
w.Int32(entityID) // entity ID
|
||||
w.Bool(false) // is hardcore
|
||||
|
||||
// Dimension names: the worlds available on this server.
|
||||
dims := []string{"minecraft:overworld", "minecraft:the_end", "minecraft:the_nether"}
|
||||
|
|
@ -78,23 +138,23 @@ func (h *handler) sendPlayLogin() error {
|
|||
}
|
||||
|
||||
w.VarInt(int32(h.srv.Config().MaxPlayers)) // max players (legacy)
|
||||
w.VarInt(10) // view distance
|
||||
w.VarInt(10) // simulation distance
|
||||
w.VarInt(int32(h.visibilityRadius())) // view distance
|
||||
w.VarInt(int32(h.visibilityRadius())) // simulation distance
|
||||
w.Bool(false) // reduced debug info
|
||||
w.Bool(true) // enable respawn screen
|
||||
w.Bool(false) // do limited crafting
|
||||
|
||||
w.VarInt(int32(dimTypeIdx)) // dimension type (registry index)
|
||||
w.VarInt(int32(dimTypeIdx)) // dimension type (registry index)
|
||||
w.String("minecraft:overworld") // dimension name (this world)
|
||||
w.Int64(0) // hashed seed
|
||||
w.Byte(1) // game mode: creative (instant break, creative inventory)
|
||||
w.Byte(0xFF) // previous game mode: -1 (none)
|
||||
w.Bool(false) // is debug
|
||||
w.Bool(false) // is flat
|
||||
w.Bool(false) // has death location
|
||||
w.VarInt(0) // portal cooldown
|
||||
w.VarInt(63) // sea level (overworld)
|
||||
w.Bool(false) // enforces secure chat
|
||||
w.Int64(0) // hashed seed
|
||||
w.Byte(1) // game mode: creative (instant break, creative inventory)
|
||||
w.Byte(0xFF) // previous game mode: -1 (none)
|
||||
w.Bool(false) // is debug
|
||||
w.Bool(false) // is flat
|
||||
w.Bool(false) // has death location
|
||||
w.VarInt(0) // portal cooldown
|
||||
w.VarInt(63) // sea level (overworld)
|
||||
w.Bool(false) // enforces secure chat
|
||||
|
||||
return h.conn.SendWriter(protocol.PlayLogin, w)
|
||||
}
|
||||
|
|
@ -125,50 +185,174 @@ func (h *handler) sendPlayerPosition(teleportID int32) error {
|
|||
func (h *handler) keepAliveLoop() {
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
id := time.Now().UnixMilli()
|
||||
w := protocol.NewWriter(8)
|
||||
w.Int64(id)
|
||||
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
|
||||
for {
|
||||
select {
|
||||
case <-h.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
id := time.Now().UnixMilli()
|
||||
w := protocol.NewWriter(8)
|
||||
w.Int64(id)
|
||||
if err := h.conn.SendWriter(protocol.PlayKeepAliveCB, w); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// entitySyncLoop periodically sends all entities in the world to the client.
|
||||
// In a real server this would track which entities the player can see and send updates.
|
||||
// visibleEntity is the comparable state retained by one client's visibility
|
||||
// tracker. OnGround is separate because mobs currently always use true.
|
||||
type visibleEntity struct {
|
||||
entity world.Entity
|
||||
onGround bool
|
||||
}
|
||||
|
||||
// entitySyncLoop maintains tab-list membership and chunk-scoped entity state.
|
||||
func (h *handler) entitySyncLoop() {
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
known := make(map[int32]bool)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-h.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
all := h.srv.Entities().All()
|
||||
current := make(map[int32]bool)
|
||||
for _, e := range all {
|
||||
current[e.ID] = true
|
||||
if !known[e.ID] {
|
||||
h.sendAddEntity(e)
|
||||
known[e.ID] = true
|
||||
} else {
|
||||
h.sendEntityTeleport(e)
|
||||
}
|
||||
}
|
||||
// remove entities that disappeared
|
||||
for id := range known {
|
||||
if !current[id] {
|
||||
h.sendRemoveEntity(id)
|
||||
delete(known, id)
|
||||
}
|
||||
if err := h.syncVisibleEntities(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncVisibleEntities performs one deterministic visibility pass. Keeping it
|
||||
// separate from the ticker makes the four-client workflow integration-testable.
|
||||
func (h *handler) syncVisibleEntities() error {
|
||||
if h.session == nil {
|
||||
return nil
|
||||
}
|
||||
if h.knownPlayers == nil {
|
||||
h.knownPlayers = make(map[[16]byte]bool)
|
||||
}
|
||||
if h.knownEntities == nil {
|
||||
h.knownEntities = make(map[int32]visibleEntity)
|
||||
}
|
||||
|
||||
viewer := h.session.Snapshot()
|
||||
players := h.srv.PlayerSnapshots()
|
||||
currentPlayers := make(map[[16]byte]bool, len(players))
|
||||
for _, player := range players {
|
||||
currentPlayers[player.Profile.UUID] = true
|
||||
if !h.knownPlayers[player.Profile.UUID] {
|
||||
if err := h.sendPlayerInfoAdd(player.Profile); err != nil {
|
||||
return err
|
||||
}
|
||||
h.knownPlayers[player.Profile.UUID] = true
|
||||
}
|
||||
}
|
||||
|
||||
currentEntities := make(map[int32]visibleEntity)
|
||||
for _, player := range players {
|
||||
if player.EntityID == viewer.EntityID || !playerVisible(viewer, player, h.visibilityRadius()) {
|
||||
continue
|
||||
}
|
||||
currentEntities[player.EntityID] = visibleEntity{
|
||||
entity: world.Entity{
|
||||
ID: player.EntityID, UUID: player.Profile.UUID,
|
||||
TypeID: registry.EntityTypeIndex("minecraft:player"), TypeName: "minecraft:player",
|
||||
X: player.X, Y: player.Y, Z: player.Z,
|
||||
Yaw: player.Yaw, Pitch: player.Pitch, HeadYaw: player.Yaw,
|
||||
},
|
||||
onGround: player.OnGround,
|
||||
}
|
||||
}
|
||||
for _, entity := range h.srv.Entities().All() {
|
||||
if entityVisible(viewer, entity, h.visibilityRadius()) {
|
||||
currentEntities[entity.ID] = visibleEntity{entity: entity, onGround: true}
|
||||
}
|
||||
}
|
||||
|
||||
for id, current := range currentEntities {
|
||||
known, exists := h.knownEntities[id]
|
||||
if !exists {
|
||||
entity := current.entity
|
||||
if err := h.sendAddEntity(&entity); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if known != current {
|
||||
entity := current.entity
|
||||
if err := h.sendEntityTeleportState(&entity, current.onGround); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range h.knownEntities {
|
||||
if _, visible := currentEntities[id]; !visible {
|
||||
if err := h.sendRemoveEntity(id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
h.knownEntities = currentEntities
|
||||
|
||||
for uuid := range h.knownPlayers {
|
||||
if !currentPlayers[uuid] {
|
||||
if err := h.sendPlayerInfoRemove(uuid); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(h.knownPlayers, uuid)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func playerVisible(viewer, target server.PlayerSnapshot, radius int) bool {
|
||||
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
|
||||
}
|
||||
|
||||
func entityVisible(viewer server.PlayerSnapshot, target world.Entity, radius int) bool {
|
||||
return chunksWithin(viewer.X, viewer.Z, target.X, target.Z, radius)
|
||||
}
|
||||
|
||||
func chunksWithin(ax, az, bx, bz float64, radius int) bool {
|
||||
acx := int32(int64(math.Floor(ax)) >> 4)
|
||||
acz := int32(int64(math.Floor(az)) >> 4)
|
||||
bcx := int32(int64(math.Floor(bx)) >> 4)
|
||||
bcz := int32(int64(math.Floor(bz)) >> 4)
|
||||
dx := acx - bcx
|
||||
if dx < 0 {
|
||||
dx = -dx
|
||||
}
|
||||
dz := acz - bcz
|
||||
if dz < 0 {
|
||||
dz = -dz
|
||||
}
|
||||
return dx <= int32(radius) && dz <= int32(radius)
|
||||
}
|
||||
|
||||
func (h *handler) sendPlayerInfoAdd(profile server.Profile) error {
|
||||
w := protocol.NewWriter(64)
|
||||
w.Byte(0xff) // All eight initialization actions, fixed 8-bit EnumSet.
|
||||
w.VarInt(1)
|
||||
w.UUID(profile.UUID)
|
||||
w.String(profile.Name)
|
||||
w.VarInt(0) // profile properties
|
||||
w.Bool(false) // no signed chat session
|
||||
w.VarInt(1) // creative game mode
|
||||
w.Bool(true) // listed
|
||||
w.VarInt(0) // latency
|
||||
w.Bool(false) // no custom display name
|
||||
w.VarInt(0) // list order
|
||||
w.Bool(true) // show hat
|
||||
return h.conn.SendWriter(protocol.PlayPlayerInfoUpdate, w)
|
||||
}
|
||||
|
||||
func (h *handler) sendPlayerInfoRemove(uuid [16]byte) error {
|
||||
w := protocol.NewWriter(20)
|
||||
w.VarInt(1)
|
||||
w.UUID(uuid)
|
||||
return h.conn.SendWriter(protocol.PlayPlayerInfoRemove, w)
|
||||
}
|
||||
|
||||
// sendAddEntity sends the minecraft:add_entity packet.
|
||||
func (h *handler) sendAddEntity(e *world.Entity) error {
|
||||
w := protocol.NewWriter(64)
|
||||
|
|
@ -187,12 +371,21 @@ func (h *handler) sendAddEntity(e *world.Entity) error {
|
|||
}
|
||||
|
||||
func (h *handler) sendEntityTeleport(e *world.Entity) error {
|
||||
return h.sendEntityTeleportState(e, true)
|
||||
}
|
||||
|
||||
func (h *handler) sendEntityTeleportState(e *world.Entity, onGround bool) error {
|
||||
w := protocol.NewWriter(64)
|
||||
w.VarInt(e.ID)
|
||||
// PositionMoveRotation: position, deltaMovement, yRot, xRot.
|
||||
w.Float64(e.X).Float64(e.Y).Float64(e.Z)
|
||||
w.Byte(byte(e.Yaw * 256.0 / 360.0))
|
||||
w.Byte(byte(e.Pitch * 256.0 / 360.0))
|
||||
w.Bool(true) // On ground
|
||||
w.Float64(float64(e.VelocityX) / 8000.0)
|
||||
w.Float64(float64(e.VelocityY) / 8000.0)
|
||||
w.Float64(float64(e.VelocityZ) / 8000.0)
|
||||
w.Float32(e.Yaw)
|
||||
w.Float32(e.Pitch)
|
||||
w.Int32(0) // Relative.SET_STREAM_CODEC uses ByteBufCodecs.INT; no relative flags.
|
||||
w.Bool(onGround)
|
||||
return h.conn.SendWriter(protocol.PlayTeleportEntity, w)
|
||||
}
|
||||
|
||||
|
|
@ -225,7 +418,6 @@ func (h *handler) handlePlay(pkt protocol.Packet) error {
|
|||
return nil
|
||||
|
||||
case protocol.PlayMovePos, protocol.PlayMovePosRot:
|
||||
// Both packets begin with the absolute X, Y, Z position.
|
||||
r := pkt.Body()
|
||||
x, err := r.Float64()
|
||||
if err != nil {
|
||||
|
|
@ -239,7 +431,48 @@ func (h *handler) handlePlay(pkt protocol.Packet) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.onPlayerMove(x, y, z)
|
||||
snapshot := h.session.Snapshot()
|
||||
yaw, pitch := snapshot.Yaw, snapshot.Pitch
|
||||
if pkt.ID == protocol.PlayMovePosRot {
|
||||
yaw, err = r.Float32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pitch, err = r.Float32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flags, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.onPlayerMove(x, y, z, yaw, pitch, flags&1 != 0)
|
||||
|
||||
case protocol.PlayMoveRot:
|
||||
r := pkt.Body()
|
||||
yaw, err := r.Float32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pitch, err := r.Float32()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flags, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot := h.session.Snapshot()
|
||||
return h.onPlayerMove(snapshot.X, snapshot.Y, snapshot.Z, yaw, pitch, flags&1 != 0)
|
||||
|
||||
case protocol.PlayMoveStatusOnly:
|
||||
flags, err := pkt.Body().ReadByte()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshot := h.session.Snapshot()
|
||||
return h.onPlayerMove(snapshot.X, snapshot.Y, snapshot.Z, snapshot.Yaw, snapshot.Pitch, flags&1 != 0)
|
||||
|
||||
case protocol.PlayPlayerAction:
|
||||
return h.handlePlayerAction(pkt)
|
||||
|
|
@ -279,16 +512,15 @@ func (h *handler) handleChat(pkt protocol.Packet) error {
|
|||
}
|
||||
line := "<" + h.conn.Profile.Name + "> " + msg
|
||||
h.log.Info("chat", "msg", line)
|
||||
return h.sendSystemChat(line)
|
||||
return h.broadcastSystemChat(line)
|
||||
}
|
||||
|
||||
// sendSystemChat sends a plain-text system chat message. The text component is
|
||||
// network NBT; a bare string tag is the shorthand for {"text": ...}.
|
||||
func (h *handler) sendSystemChat(text string) error {
|
||||
func (h *handler) broadcastSystemChat(text string) error {
|
||||
w := protocol.NewWriter(len(text) + 8)
|
||||
w.Raw(nbt.Marshal(nbt.String(text)))
|
||||
w.Bool(false) // not an action-bar overlay
|
||||
return h.conn.SendWriter(protocol.PlaySystemChat, w)
|
||||
w.Bool(false)
|
||||
h.srv.Broadcast(protocol.PlaySystemChat, w.Bytes())
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePlayerAction processes digging. In creative the client sends
|
||||
|
|
@ -315,10 +547,8 @@ func (h *handler) handlePlayerAction(pkt protocol.Packet) error {
|
|||
|
||||
const startDig, finishDig = 0, 2
|
||||
if status == startDig || status == finishDig {
|
||||
if h.srv.Chunks().SetBlock(x, y, z, world.StateAir) {
|
||||
if err := h.sendBlockUpdate(x, y, z, world.StateAir); err != nil {
|
||||
return err
|
||||
}
|
||||
if valid, lightChunks := h.srv.Chunks().SetBlockWithLight(x, y, z, world.StateAir); valid {
|
||||
h.broadcastBlockUpdate(x, y, z, world.StateAir, lightChunks)
|
||||
h.log.Debug("block broken", "x", x, "y", y, "z", z)
|
||||
}
|
||||
}
|
||||
|
|
@ -401,10 +631,8 @@ func (h *handler) handleUseItemOn(pkt protocol.Packet) error {
|
|||
if state, ok := h.heldBlock(); ok {
|
||||
off := faceOffsets[face]
|
||||
px, py, pz := x+off[0], y+off[1], z+off[2]
|
||||
if h.srv.Chunks().SetBlock(px, py, pz, state) {
|
||||
if err := h.sendBlockUpdate(px, py, pz, state); err != nil {
|
||||
return err
|
||||
}
|
||||
if valid, lightChunks := h.srv.Chunks().SetBlockWithLight(px, py, pz, state); valid {
|
||||
h.broadcastBlockUpdate(px, py, pz, state, lightChunks)
|
||||
h.log.Debug("block placed", "x", px, "y", py, "z", pz, "state", state)
|
||||
}
|
||||
}
|
||||
|
|
@ -422,12 +650,18 @@ func (h *handler) heldBlock() (uint16, bool) {
|
|||
return world.ItemToBlock(itemID)
|
||||
}
|
||||
|
||||
// sendBlockUpdate notifies the client of a single block change.
|
||||
func (h *handler) sendBlockUpdate(x, y, z int, state uint16) error {
|
||||
func (h *handler) broadcastBlockUpdate(x, y, z int, state uint16, lightChunks []world.ChunkPos) {
|
||||
w := protocol.NewWriter(12)
|
||||
w.Position(x, y, z)
|
||||
w.VarInt(int32(state))
|
||||
return h.conn.SendWriter(protocol.PlayBlockUpdate, w)
|
||||
cx := int32(x >> 4)
|
||||
cz := int32(z >> 4)
|
||||
h.srv.BroadcastChunk(cx, cz, protocol.PlayBlockUpdate, w.Bytes())
|
||||
for _, chunk := range lightChunks {
|
||||
if light, err := h.srv.Chunks().LightUpdate(chunk.X, chunk.Z); err == nil {
|
||||
h.srv.BroadcastChunk(chunk.X, chunk.Z, protocol.PlayLightUpdate, light)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendBlockChangedAck confirms a block-action sequence so the client does not
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"runtime"
|
||||
"sync"
|
||||
|
||||
"regionio/internal/protocol"
|
||||
"regionio/internal/world"
|
||||
)
|
||||
|
||||
|
|
@ -141,6 +142,8 @@ func (s *streamer) run(ctx context.Context) {
|
|||
func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) {
|
||||
s.centerX, s.centerZ, s.hasCenter = cx, cz, true
|
||||
|
||||
s.sendChunkCacheCenter(cx, cz)
|
||||
|
||||
// Build the desired set: everything within genRadius (the union of what we
|
||||
// send + the pre-gen ring). Sent = within viewRadius; pre-gen = the ring.
|
||||
order := spiralOrder(cx, cz, s.genRadius)
|
||||
|
|
@ -179,11 +182,32 @@ func (s *streamer) processRecenter(ctx context.Context, cx, cz int32) {
|
|||
// bounded and avoids re-sending.
|
||||
for key := range s.loaded {
|
||||
if !desired[key] {
|
||||
s.sendForgetLevelChunk(key[0], key[1])
|
||||
delete(s.loaded, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *streamer) sendChunkCacheCenter(cx, cz int32) {
|
||||
if s.conn == nil {
|
||||
return
|
||||
}
|
||||
w := protocol.NewWriter(8)
|
||||
w.VarInt(cx)
|
||||
w.VarInt(cz)
|
||||
_ = s.conn.SendWriter(protocol.PlayChunkCacheCenter, w)
|
||||
}
|
||||
|
||||
func (s *streamer) sendForgetLevelChunk(cx, cz int32) {
|
||||
if s.conn == nil {
|
||||
return
|
||||
}
|
||||
w := protocol.NewWriter(8)
|
||||
w.Int32(cz)
|
||||
w.Int32(cx)
|
||||
_ = s.conn.SendWriter(protocol.PlayForgetLevelChunk, w)
|
||||
}
|
||||
|
||||
// parallelSend generates the given chunks across the worker pool and sends each
|
||||
// frame as soon as it is ready (order is best-effort; the client reassembles).
|
||||
// Already-loaded chunks are skipped. Returns when all are sent or ctx cancels.
|
||||
|
|
@ -274,14 +298,15 @@ func (s *streamer) parallelGenerate(ctx context.Context, keys [][2]int32) {
|
|||
return
|
||||
default:
|
||||
}
|
||||
_ = s.cache.Frame(j.cx, j.cz) // warm the cache; discard the frame
|
||||
_, _ = s.cache.FrameErr(j.cx, j.cz) // warm the cache; discard the frame
|
||||
}
|
||||
}()
|
||||
}
|
||||
Loop:
|
||||
for _, j := range pending {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break
|
||||
break Loop
|
||||
case jobs <- j:
|
||||
}
|
||||
}
|
||||
|
|
@ -307,7 +332,14 @@ func (s *streamer) generateWorker(ctx context.Context, jobs <-chan frameJob, res
|
|||
return
|
||||
default:
|
||||
}
|
||||
frame := s.cache.Frame(j.cx, j.cz)
|
||||
frame, err := s.cache.FrameErr(j.cx, j.cz)
|
||||
if err != nil {
|
||||
select {
|
||||
case results <- frameResult{cx: j.cx, cz: j.cz, err: err}:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := s.conn.SendFramed(frame); err != nil {
|
||||
select {
|
||||
case results <- frameResult{cx: j.cx, cz: j.cz, err: err}:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue