From 379283126bbfb35fb50af751065aeb0899f90143 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 11 May 2026 15:51:43 -0400 Subject: [PATCH 01/59] feat(world/view_layer.go): implement block view layer --- server/player/player.go | 10 +++++ server/session/chunk.go | 54 +++++++++++++++++++++----- server/session/view_layer.go | 45 ++++++++++++++++++++- server/session/world.go | 10 +++++ server/world/chunk/chunk.go | 20 ++++++++++ server/world/chunk/palette.go | 5 +++ server/world/chunk/paletted_storage.go | 5 +++ server/world/chunk/sub_chunk.go | 14 +++++++ server/world/view_layer.go | 49 +++++++++++++++++++++++ 9 files changed, 202 insertions(+), 10 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index d3c535fc3..786b85886 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2612,6 +2612,16 @@ func (p *Player) ViewVisibility(entity world.Entity, level world.VisibilityLevel p.session().ViewVisibility(entity, level) } +// ViewBlock overrides the public block at the position passed for this player. +func (p *Player) ViewBlock(pos cube.Pos, b world.Block) { + p.session().ViewBlock(pos, b) +} + +// ViewPublicBlock removes the block override at the position passed for this player. +func (p *Player) ViewPublicBlock(pos cube.Pos) { + p.session().ViewPublicBlock(pos) +} + // RemoveViewLayer removes all view-layer overrides of the entity for this player. func (p *Player) RemoveViewLayer(entity world.Entity) { p.session().RemoveViewLayer(entity) diff --git a/server/session/chunk.go b/server/session/chunk.go index 150142ae3..24054b03f 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -2,6 +2,7 @@ package session import ( "bytes" + "maps" "github.com/cespare/xxhash/v2" "github.com/df-mc/dragonfly/server/block/cube" @@ -18,6 +19,7 @@ const subChunkRequests = true // ViewChunk ... func (s *Session) ViewChunk(pos world.ChunkPos, dim world.Dimension, blockEntities map[cube.Pos]world.Block, c *chunk.Chunk) { + c, blockEntities = s.applyViewLayerToChunk(pos, c, blockEntities) if !s.conn.ClientCacheEnabled() { s.sendNetworkChunk(pos, dim, c, blockEntities) return @@ -45,7 +47,11 @@ func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.Sub entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultChunkNotFound, Offset: offset}) continue } - entries = append(entries, s.subChunkEntry(offset, ind, col, transaction)) + ch, blockEntities := s.applyViewLayerToChunk(world.ChunkPos{ + centre.X() + int32(offset[0]), + centre.Z() + int32(offset[2]), + }, col.Chunk, col.BlockEntities) + entries = append(entries, s.subChunkEntry(offset, ind, ch, blockEntities, transaction)) } if s.conn.ClientCacheEnabled() && len(transaction) > 0 { s.blobMu.Lock() @@ -61,21 +67,21 @@ func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.Sub }) } -func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col *world.Column, transaction map[uint64]struct{}) protocol.SubChunkEntry { - chunkMap := col.HeightMap() +func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, c *chunk.Chunk, blockEntities map[cube.Pos]world.Block, transaction map[uint64]struct{}) protocol.SubChunkEntry { + chunkMap := c.HeightMap() subMapType, subMap := byte(protocol.HeightMapDataHasData), make([]int8, 256) higher, lower := true, true for x := uint8(0); x < 16; x++ { for z := uint8(0); z < 16; z++ { y, i := chunkMap.At(x, z), (uint16(z)<<4)|uint16(x) - otherInd := col.SubIndex(y) + otherInd := c.SubIndex(y) switch { case otherInd > ind: subMap[i], lower = 16, false case otherInd < ind: subMap[i], higher = -1, false default: - subMap[i], lower, higher = int8(y-col.SubY(otherInd)), false, false + subMap[i], lower, higher = int8(y-c.SubY(otherInd)), false, false } } } @@ -85,7 +91,7 @@ func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col * subMapType, subMap = protocol.HeightMapDataTooLow, nil } - sub := col.Sub()[ind] + sub := c.Sub()[ind] if sub.Empty() { return protocol.SubChunkEntry{ Result: protocol.SubChunkResultSuccessAllAir, @@ -97,12 +103,12 @@ func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col * } } - serialisedSubChunk := chunk.EncodeSubChunk(col.Chunk, chunk.NetworkEncoding, int(ind)) + serialisedSubChunk := chunk.EncodeSubChunk(c, chunk.NetworkEncoding, int(ind)) blockEntityBuf := bytes.NewBuffer(nil) enc := nbt.NewEncoderWithEncoding(blockEntityBuf, nbt.NetworkLittleEndian) - for pos, b := range col.BlockEntities { - if n, ok := b.(world.NBTer); ok && col.SubIndex(int16(pos.Y())) == ind { + for pos, b := range blockEntities { + if n, ok := b.(world.NBTer); ok && c.SubIndex(int16(pos.Y())) == ind { d := n.EncodeNBT() d["x"], d["y"], d["z"] = int32(pos[0]), int32(pos[1]), int32(pos[2]) _ = enc.Encode(d) @@ -129,6 +135,36 @@ func (s *Session) subChunkEntry(offset protocol.SubChunkOffset, ind int16, col * return entry } +// applyViewLayerToChunk returns a chunk and block entity map with this session's view-layer block overrides applied. +func (s *Session) applyViewLayerToChunk(pos world.ChunkPos, c *chunk.Chunk, blockEntities map[cube.Pos]world.Block) (*chunk.Chunk, map[cube.Pos]world.Block) { + if s.viewLayer == nil { + return c, blockEntities + } + overrides := s.viewLayer.Blocks() + if len(overrides) == 0 { + return c, blockEntities + } + + var cloned bool + for blockPos, b := range overrides { + if (world.ChunkPos{int32(blockPos[0] >> 4), int32(blockPos[2] >> 4)}) != pos { + continue + } + if !cloned { + c = c.Clone() + blockEntities = maps.Clone(blockEntities) + cloned = true + } + c.SetBlock(uint8(blockPos[0]), int16(blockPos[1]), uint8(blockPos[2]), 0, s.br.BlockRuntimeID(b)) + if _, ok := b.(world.NBTer); ok { + blockEntities[blockPos] = b + } else { + delete(blockEntities, blockPos) + } + } + return c, blockEntities +} + // dimensionID returns the dimension ID of the world that the session is in. func (s *Session) dimensionID(dim world.Dimension) int32 { d, _ := world.DimensionID(dim) diff --git a/server/session/view_layer.go b/server/session/view_layer.go index 632b58012..94dee2cba 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -1,6 +1,9 @@ package session -import "github.com/df-mc/dragonfly/server/world" +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) // ViewLayer returns the session's ViewLayer. The layer may be used to override how entities are viewed // by this session, such as with a different name tag or visibility state. @@ -48,6 +51,22 @@ func (s *Session) ViewVisibility(entity world.Entity, level world.VisibilityLeve s.viewLayer.ViewVisibility(entity, level) } +// ViewBlock overwrites the public block at the position passed and immediately refreshes it for this session. +func (s *Session) ViewBlock(pos cube.Pos, b world.Block) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewBlock(pos, b) +} + +// ViewPublicBlock removes the block override at the position passed and immediately refreshes it for this session. +func (s *Session) ViewPublicBlock(pos cube.Pos) { + if s.viewLayer == nil { + return + } + s.viewLayer.ViewPublicBlock(pos) +} + // RemoveViewLayer removes all overrides for the entity and immediately refreshes it for this session. func (s *Session) RemoveViewLayer(entity world.Entity) { if s.viewLayer == nil { @@ -64,6 +83,17 @@ func (s *Session) ViewLayerEntityChanged(e world.Entity) { s.ViewEntityState(e) } +// ViewLayerBlockChanged refreshes the block for this session if its chunk is currently visible. +func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { + if b, ok := s.viewLayer.Block(pos); ok { + s.viewBlockUpdate(pos, b, 0) + return + } + if b, ok := s.publicBlock(pos); ok { + s.viewBlockUpdate(pos, b, 0) + } +} + // viewingEntity checks if this session currently has a runtime ID assigned to the entity handle. func (s *Session) viewingEntity(handle *world.EntityHandle) bool { s.entityMutex.RLock() @@ -71,3 +101,16 @@ func (s *Session) viewingEntity(handle *world.EntityHandle) bool { s.entityMutex.RUnlock() return ok } + +// publicBlock returns the public block at pos from the loaded chunk for this session. +func (s *Session) publicBlock(pos cube.Pos) (world.Block, bool) { + col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) + if !ok { + return nil, false + } + if b, ok := col.BlockEntities[pos]; ok { + return b, true + } + b, ok := s.br.BlockByRuntimeID(col.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0)) + return b, ok +} diff --git a/server/session/world.go b/server/session/world.go index 1ecdfb728..69e04778f 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -959,6 +959,16 @@ func (s *Session) ViewBrewingUpdate(prevBrewTime, brewTime time.Duration, prevFu // ViewBlockUpdate ... func (s *Session) ViewBlockUpdate(pos cube.Pos, b world.Block, layer int) { + if s.viewLayer != nil && layer == 0 { + if viewed, ok := s.viewLayer.Block(pos); ok { + b = viewed + } + } + s.viewBlockUpdate(pos, b, layer) +} + +// viewBlockUpdate sends a block update to the session without applying view-layer overrides. +func (s *Session) viewBlockUpdate(pos cube.Pos, b world.Block, layer int) { blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} s.writePacket(&packet.UpdateBlock{ Position: blockPos, diff --git a/server/world/chunk/chunk.go b/server/world/chunk/chunk.go index 17ef1f10f..ab3d6d9ff 100644 --- a/server/world/chunk/chunk.go +++ b/server/world/chunk/chunk.go @@ -49,6 +49,26 @@ func New(br BlockRegistry, r cube.Range) *Chunk { } } +// Clone returns an independent copy of the Chunk. +func (chunk *Chunk) Clone() *Chunk { + clone := &Chunk{ + r: chunk.r, + br: chunk.br, + air: chunk.air, + recalculateHeightMap: chunk.recalculateHeightMap, + heightMap: append(HeightMap(nil), chunk.heightMap...), + sub: make([]*SubChunk, len(chunk.sub)), + biomes: make([]*PalettedStorage, len(chunk.biomes)), + } + for i, sub := range chunk.sub { + clone.sub[i] = sub.Clone() + } + for i, biomes := range chunk.biomes { + clone.biomes[i] = biomes.Clone() + } + return clone +} + // Equals returns if the chunk passed is equal to the current one func (chunk *Chunk) Equals(c *Chunk) bool { if !chunk.recalculateHeightMap && !c.recalculateHeightMap && !slices.Equal(c.heightMap, chunk.heightMap) { diff --git a/server/world/chunk/palette.go b/server/world/chunk/palette.go index 3b3d32b17..a9fe6e2d9 100644 --- a/server/world/chunk/palette.go +++ b/server/world/chunk/palette.go @@ -23,6 +23,11 @@ func newPalette(size paletteSize, values []uint32) *Palette { return &Palette{size: size, values: values, last: math.MaxUint32} } +// Clone returns an independent copy of the Palette. +func (palette *Palette) Clone() *Palette { + return newPalette(palette.size, append([]uint32(nil), palette.values...)) +} + // Len returns the amount of unique values in the Palette. func (palette *Palette) Len() int { return len(palette.values) diff --git a/server/world/chunk/paletted_storage.go b/server/world/chunk/paletted_storage.go index 92153e7fb..a1047c2b1 100644 --- a/server/world/chunk/paletted_storage.go +++ b/server/world/chunk/paletted_storage.go @@ -59,6 +59,11 @@ func emptyStorage(v uint32) *PalettedStorage { return newPalettedStorage([]uint32{}, newPalette(0, []uint32{v})) } +// Clone returns an independent copy of the PalettedStorage. +func (storage *PalettedStorage) Clone() *PalettedStorage { + return newPalettedStorage(append([]uint32(nil), storage.indices...), storage.palette.Clone()) +} + // Palette returns the Palette of the PalettedStorage. func (storage *PalettedStorage) Palette() *Palette { return storage.palette diff --git a/server/world/chunk/sub_chunk.go b/server/world/chunk/sub_chunk.go index 21436cecb..7a118a520 100644 --- a/server/world/chunk/sub_chunk.go +++ b/server/world/chunk/sub_chunk.go @@ -29,6 +29,20 @@ func NewSubChunk(air uint32) *SubChunk { return &SubChunk{air: air} } +// Clone returns an independent copy of the SubChunk. +func (sub *SubChunk) Clone() *SubChunk { + clone := &SubChunk{ + air: sub.air, + blockLight: append([]uint8(nil), sub.blockLight...), + skyLight: append([]uint8(nil), sub.skyLight...), + storages: make([]*PalettedStorage, len(sub.storages)), + } + for i, storage := range sub.storages { + clone.storages[i] = storage.Clone() + } + return clone +} + // Empty checks if the SubChunk is considered empty. This is the case if the SubChunk has 0 block storages or if it has // a single one that is completely filled with air. func (sub *SubChunk) Empty() bool { diff --git a/server/world/view_layer.go b/server/world/view_layer.go index cb9098f1b..9728cd52c 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -4,6 +4,8 @@ import ( "maps" "slices" "sync" + + "github.com/df-mc/dragonfly/server/block/cube" ) // layer stores the appearance overrides that a ViewLayer applies to an entity. @@ -17,6 +19,8 @@ type layer struct { type ViewLayerUpdater interface { // ViewLayerEntityChanged handles an entity whose view-layer overrides changed. ViewLayerEntityChanged(entity Entity) + // ViewLayerBlockChanged handles a block whose view-layer override changed. + ViewLayerBlockChanged(pos cube.Pos) } type viewLayerViewer interface { @@ -28,6 +32,7 @@ type viewLayerViewer interface { type ViewLayer struct { mu sync.RWMutex entities map[*EntityHandle]layer + blocks map[cube.Pos]Block updater ViewLayerUpdater } @@ -35,6 +40,7 @@ type ViewLayer struct { func NewViewLayer(updater ViewLayerUpdater) *ViewLayer { return &ViewLayer{ entities: map[*EntityHandle]layer{}, + blocks: map[cube.Pos]Block{}, updater: updater, } } @@ -119,6 +125,42 @@ func (v *ViewLayer) Visibility(entity Entity) VisibilityLevel { return v.entities[entity.H()].visibility } +// ViewBlock overwrites the public block at the position passed for this ViewLayer. +// Passing nil removes the block override, causing the public block to be viewed again. +func (v *ViewLayer) ViewBlock(pos cube.Pos, b Block) { + v.mu.Lock() + if b == nil { + delete(v.blocks, pos) + } else { + v.blocks[pos] = b + } + v.mu.Unlock() + + v.refreshBlock(pos) +} + +// ViewPublicBlock removes the block override at the position passed, causing the public block to be viewed again. +func (v *ViewLayer) ViewPublicBlock(pos cube.Pos) { + v.ViewBlock(pos, nil) +} + +// Block returns the overwritten block at the position passed and whether an override was set. +func (v *ViewLayer) Block(pos cube.Pos) (Block, bool) { + v.mu.RLock() + defer v.mu.RUnlock() + + b, ok := v.blocks[pos] + return b, ok +} + +// Blocks returns all block overrides in the view layer. +func (v *ViewLayer) Blocks() map[cube.Pos]Block { + v.mu.RLock() + defer v.mu.RUnlock() + + return maps.Clone(v.blocks) +} + // Remove removes all overrides for the entity from the ViewLayer. func (v *ViewLayer) Remove(entity Entity) { if v.remove(entity) { @@ -162,6 +204,7 @@ func (v *ViewLayer) Close() error { defer v.mu.Unlock() clear(v.entities) + clear(v.blocks) return nil } @@ -175,3 +218,9 @@ func (v *ViewLayer) refresh(entity Entity) { v.updater.ViewLayerEntityChanged(entity) } } + +func (v *ViewLayer) refreshBlock(pos cube.Pos) { + if v.updater != nil { + v.updater.ViewLayerBlockChanged(pos) + } +} From 69084c71d35b7fd08e743713b8a0e4f5d256b67d Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 11 May 2026 15:59:07 -0400 Subject: [PATCH 02/59] Update player.go --- server/player/player.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/player/player.go b/server/player/player.go index 786b85886..0360ada05 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2037,6 +2037,9 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl func (p *Player) BreakBlock(pos cube.Pos) { b := p.tx.Block(pos) if _, air := b.(block.Air); air { + if p.viewLayerBlock(pos) { + p.resendNearbyBlocks(pos) + } // Don't do anything if the position broken is already air. return } @@ -2109,6 +2112,15 @@ func (p *Player) drops(held item.Stack, b world.Block) []item.Stack { return drops } +// viewLayerBlock checks if this player has a view-layer block override at pos. +func (p *Player) viewLayerBlock(pos cube.Pos) bool { + if p.session() == session.Nop { + return false + } + _, ok := p.ViewLayer().Block(pos) + return ok +} + // PickBlock makes the player pick a block in the world at a position passed. If the player is unable to // pick the block, the method returns immediately. func (p *Player) PickBlock(pos cube.Pos) { From 6e63e19d75adc65052b9e86f056e376b69031f34 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 11 May 2026 16:00:21 -0400 Subject: [PATCH 03/59] Update player.go --- server/player/player.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/player/player.go b/server/player/player.go index 0360ada05..538c164f8 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1839,6 +1839,9 @@ func (p *Player) AttackEntity(e world.Entity) bool { func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() if _, air := p.tx.Block(pos).(block.Air); air || !p.canReach(pos.Vec3Centre()) { + if air && p.viewLayerBlock(pos) { + p.resendNearbyBlocks(pos) + } // The block was either out of range or air, so it can't be broken by the player. return } From fa7715dcbcc4a163f8c510ade0abdb08bedf0a98 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 11 May 2026 16:13:53 -0400 Subject: [PATCH 04/59] various changes --- server/player/handler.go | 20 ++++++++------ server/player/player.go | 59 +++++++++++++++++++++++++++------------- 2 files changed, 51 insertions(+), 28 deletions(-) diff --git a/server/player/handler.go b/server/player/handler.go index ada7a6546..3329d729e 100644 --- a/server/player/handler.go +++ b/server/player/handler.go @@ -67,13 +67,15 @@ type Handler interface { // be called to cancel the fire being extinguished. // cube.Pos can be used to see where was the fire extinguished, may be used to cancel this on specific positions. HandleFireExtinguish(ctx *Context, pos cube.Pos) - // HandleStartBreak handles the player starting to break a block at the position passed. ctx.Cancel() may - // be called to stop the player from breaking the block completely. - HandleStartBreak(ctx *Context, pos cube.Pos) - // HandleBlockBreak handles a block that is being broken by a player. ctx.Cancel() may be called to cancel - // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered - // to change what items will actually be dropped. - HandleBlockBreak(ctx *Context, pos cube.Pos, drops *[]item.Stack, xp *int) + // HandleStartBreak handles the player starting to break a block at the position passed. Private is true if the + // block is a private view-layer block, and false if it is the public world block. ctx.Cancel() may be called to + // stop the player from breaking the block completely. + HandleStartBreak(ctx *Context, pos cube.Pos, private bool) + // HandleBlockBreak handles a block that is being broken by a player. Private is true if the block broken is + // a private view-layer block, and false if it is the public world block. ctx.Cancel() may be called to cancel + // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered to change + // what items will actually be dropped. + HandleBlockBreak(ctx *Context, pos cube.Pos, private bool, drops *[]item.Stack, xp *int) // HandleBlockPlace handles the player placing a specific block at a position in its world. ctx.Cancel() // may be called to cancel the block being placed. HandleBlockPlace(ctx *Context, pos cube.Pos, b world.Block) @@ -176,8 +178,8 @@ func (NopHandler) HandleTransfer(*Context, *net.UDPAddr) func (NopHandler) HandleChat(*Context, *string) {} func (NopHandler) HandleSkinChange(*Context, *skin.Skin) {} func (NopHandler) HandleFireExtinguish(*Context, cube.Pos) {} -func (NopHandler) HandleStartBreak(*Context, cube.Pos) {} -func (NopHandler) HandleBlockBreak(*Context, cube.Pos, *[]item.Stack, *int) {} +func (NopHandler) HandleStartBreak(*Context, cube.Pos, bool) {} +func (NopHandler) HandleBlockBreak(*Context, cube.Pos, bool, *[]item.Stack, *int) {} func (NopHandler) HandleBlockPlace(*Context, cube.Pos, world.Block) {} func (NopHandler) HandleBlockPick(*Context, cube.Pos, world.Block) {} func (NopHandler) HandleSignEdit(*Context, cube.Pos, bool, string, string) {} diff --git a/server/player/player.go b/server/player/player.go index 538c164f8..55dc0a6fb 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1838,10 +1838,9 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() - if _, air := p.tx.Block(pos).(block.Air); air || !p.canReach(pos.Vec3Centre()) { - if air && p.viewLayerBlock(pos) { - p.resendNearbyBlocks(pos) - } + b := p.breakingBlock(pos) + _, private := p.privateBlock(pos) + if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. return } @@ -1868,10 +1867,10 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.breakingPos = pos ctx := event.C(p) - if p.Handler().HandleStartBreak(ctx, pos); ctx.Cancelled() { + if p.Handler().HandleStartBreak(ctx, pos, private); ctx.Cancelled() { return } - if punchable, ok := p.tx.Block(pos).(block.Punchable); ok { + if punchable, ok := b.(block.Punchable); ok { punchable.Punch(pos, face, p.tx, p) } @@ -1881,7 +1880,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { if p.GameMode().CreativeInventory() { return } - p.lastBreakDuration = p.breakTime(pos) + p.lastBreakDuration = p.breakTime(b) for _, viewer := range p.viewers() { viewer.ViewBlockAction(pos, block.StartCrackAction{BreakTime: p.lastBreakDuration}) } @@ -1889,9 +1888,9 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { // breakTime returns the time needed to break a block at the position passed, taking into account the item // held, if the player is on the ground/underwater and if the player has any effects. -func (p *Player) breakTime(pos cube.Pos) time.Duration { +func (p *Player) breakTime(b world.Block) time.Duration { held, _ := p.HeldItems() - breakTime := block.BreakDuration(p.tx.Block(pos), held) + breakTime := block.BreakDuration(b, held) if !p.OnGround() { breakTime *= 5 } @@ -1917,6 +1916,10 @@ func (p *Player) breakTime(pos cube.Pos) time.Duration { // FinishBreaking will stop the animation and break the block. func (p *Player) FinishBreaking() { if !p.breaking { + if _, private := p.privateBlock(p.breakingPos); private { + p.BreakBlock(p.breakingPos) + return + } p.resendNearbyBlock(p.breakingPos) return } @@ -1945,7 +1948,7 @@ func (p *Player) ContinueBreaking(face cube.Face) { return } pos := p.breakingPos - b := p.tx.Block(pos) + b := p.breakingBlock(pos) p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) if p.breakCounter++; p.breakCounter%5 == 0 { @@ -1955,7 +1958,7 @@ func (p *Player) ContinueBreaking(face cube.Face) { // either. Every 5 ticks seems accurate. p.tx.PlaySound(pos.Vec3(), sound.BlockBreaking{Block: b}) } - if breakTime := p.breakTime(pos); breakTime != p.lastBreakDuration { + if breakTime := p.breakTime(b); breakTime != p.lastBreakDuration { for _, viewer := range p.viewers() { viewer.ViewBlockAction(pos, block.ContinueCrackAction{BreakTime: breakTime}) } @@ -1963,6 +1966,14 @@ func (p *Player) ContinueBreaking(face cube.Face) { } } +// breakingBlock returns the block that should be used for the player's breaking progress. +func (p *Player) breakingBlock(pos cube.Pos) world.Block { + if b, ok := p.privateBlock(pos); ok { + return b + } + return p.tx.Block(pos) +} + // PlaceBlock makes the player place the block passed at the position passed, granted it is within the range // of the player. // An item.UseContext may be passed to obtain information on if the block placement was successful. (SubCount will @@ -2039,10 +2050,21 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl // reach the block passed, the method returns immediately. func (p *Player) BreakBlock(pos cube.Pos) { b := p.tx.Block(pos) - if _, air := b.(block.Air); air { - if p.viewLayerBlock(pos) { + if privateBlock, private := p.privateBlock(pos); private { + var drops []item.Stack + xp := 0 + + ctx := event.C(p) + if p.Handler().HandleBlockBreak(ctx, pos, true, &drops, &xp); ctx.Cancelled() { p.resendNearbyBlocks(pos) + return } + p.SwingArm() + p.ViewPublicBlock(pos) + p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: privateBlock}) + return + } + if _, air := b.(block.Air); air { // Don't do anything if the position broken is already air. return } @@ -2065,7 +2087,7 @@ func (p *Player) BreakBlock(pos cube.Pos) { } ctx := event.C(p) - if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { + if p.Handler().HandleBlockBreak(ctx, pos, false, &drops, &xp); ctx.Cancelled() { p.resendNearbyBlocks(pos) return } @@ -2115,13 +2137,12 @@ func (p *Player) drops(held item.Stack, b world.Block) []item.Stack { return drops } -// viewLayerBlock checks if this player has a view-layer block override at pos. -func (p *Player) viewLayerBlock(pos cube.Pos) bool { +// privateBlock returns this player's view-layer block override at pos, if present. +func (p *Player) privateBlock(pos cube.Pos) (world.Block, bool) { if p.session() == session.Nop { - return false + return nil, false } - _, ok := p.ViewLayer().Block(pos) - return ok + return p.ViewLayer().Block(pos) } // PickBlock makes the player pick a block in the world at a position passed. If the player is unable to From 33d3320d7a0a5aba4e39439ec4775ca1710a30e9 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 11 May 2026 21:26:24 -0400 Subject: [PATCH 05/59] various changes --- server/session/session.go | 14 ++++++------- server/world/view_layer.go | 41 ++++++++++++++++++++++++++++---------- 2 files changed, 38 insertions(+), 17 deletions(-) diff --git a/server/session/session.go b/server/session/session.go index 0bfcc7934..bbe523687 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -178,6 +178,11 @@ func (conf Config) New(conn Conn) *Session { } conf.Log = conf.Log.With("name", conn.IdentityData().DisplayName, "uuid", conn.IdentityData().Identity, "raddr", conn.RemoteAddr().String()) + br := conf.BlockRegistry + if br == nil { + br = world.DefaultBlockRegistry + } + s := &Session{} *s = Session{ openChunkTransactions: make([]map[uint64]struct{}, 0, 8), @@ -200,8 +205,9 @@ func (conf Config) New(conn Conn) *Session { hiddenHud: make(map[hud.Element]struct{}), debugShapes: make(map[int]debug.Shape), debugShapeUpdates: make([]debugShapeUpdate, 0, 256), + br: br, } - s.viewLayer = world.NewViewLayer(s) + s.viewLayer = world.NewViewLayerWithBlockRegistry(s, br) s.openedWindow.Store(inventory.New(1, nil)) s.openedPos.Store(&cube.Pos{}) @@ -210,12 +216,6 @@ func (conf Config) New(conn Conn) *Session { s.currentScoreboard.Store(&scoreboardName) s.currentLines.Store(&scoreboardLines) - if conf.BlockRegistry == nil { - s.br = world.DefaultBlockRegistry - } else { - s.br = conf.BlockRegistry - } - s.registerHandlers() s.sendBiomes() groups, items := creativeContent(s.br) diff --git a/server/world/view_layer.go b/server/world/view_layer.go index 9728cd52c..0b1007701 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -30,18 +30,29 @@ type viewLayerViewer interface { // ViewLayer holds overrides for how entities are viewed by a single viewer. It allows entities to be // viewed differently by different players, such as with a different name tag or visibility state. type ViewLayer struct { - mu sync.RWMutex - entities map[*EntityHandle]layer - blocks map[cube.Pos]Block - updater ViewLayerUpdater + mu sync.RWMutex + entities map[*EntityHandle]layer + blocks map[cube.Pos]uint32 + blockRegistry BlockRegistry + updater ViewLayerUpdater } // NewViewLayer returns a new ViewLayer. func NewViewLayer(updater ViewLayerUpdater) *ViewLayer { + return NewViewLayerWithBlockRegistry(updater, DefaultBlockRegistry) +} + +// NewViewLayerWithBlockRegistry returns a new ViewLayer using the BlockRegistry passed for block runtime ID +// conversions. +func NewViewLayerWithBlockRegistry(updater ViewLayerUpdater, br BlockRegistry) *ViewLayer { + if br == nil { + br = DefaultBlockRegistry + } return &ViewLayer{ - entities: map[*EntityHandle]layer{}, - blocks: map[cube.Pos]Block{}, - updater: updater, + entities: map[*EntityHandle]layer{}, + blocks: map[cube.Pos]uint32{}, + blockRegistry: br, + updater: updater, } } @@ -132,7 +143,7 @@ func (v *ViewLayer) ViewBlock(pos cube.Pos, b Block) { if b == nil { delete(v.blocks, pos) } else { - v.blocks[pos] = b + v.blocks[pos] = v.blockRegistry.BlockRuntimeID(b) } v.mu.Unlock() @@ -149,7 +160,11 @@ func (v *ViewLayer) Block(pos cube.Pos) (Block, bool) { v.mu.RLock() defer v.mu.RUnlock() - b, ok := v.blocks[pos] + rid, ok := v.blocks[pos] + if !ok { + return nil, false + } + b, ok := v.blockRegistry.BlockByRuntimeID(rid) return b, ok } @@ -158,7 +173,13 @@ func (v *ViewLayer) Blocks() map[cube.Pos]Block { v.mu.RLock() defer v.mu.RUnlock() - return maps.Clone(v.blocks) + blocks := make(map[cube.Pos]Block, len(v.blocks)) + for pos, rid := range v.blocks { + if b, ok := v.blockRegistry.BlockByRuntimeID(rid); ok { + blocks[pos] = b + } + } + return blocks } // Remove removes all overrides for the entity from the ViewLayer. From 7e816f3a82f15ec9e052823d92b5a881fe5a5aba Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 11 May 2026 21:28:44 -0400 Subject: [PATCH 06/59] Update player.go --- server/player/player.go | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 55dc0a6fb..73ed90d00 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1916,10 +1916,6 @@ func (p *Player) breakTime(b world.Block) time.Duration { // FinishBreaking will stop the animation and break the block. func (p *Player) FinishBreaking() { if !p.breaking { - if _, private := p.privateBlock(p.breakingPos); private { - p.BreakBlock(p.breakingPos) - return - } p.resendNearbyBlock(p.breakingPos) return } @@ -2051,8 +2047,14 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl func (p *Player) BreakBlock(pos cube.Pos) { b := p.tx.Block(pos) if privateBlock, private := p.privateBlock(pos); private { - var drops []item.Stack + held, _ := p.HeldItems() + drops := p.drops(held, privateBlock) xp := 0 + if breakable, ok := privateBlock.(block.Breakable); ok && !p.GameMode().CreativeInventory() { + if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { + xp = breakable.BreakInfo().XPDrops.RandomValue() + } + } ctx := event.C(p) if p.Handler().HandleBlockBreak(ctx, pos, true, &drops, &xp); ctx.Cancelled() { @@ -2062,6 +2064,13 @@ func (p *Player) BreakBlock(pos cube.Pos) { p.SwingArm() p.ViewPublicBlock(pos) p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: privateBlock}) + for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { + p.tx.AddEntity(orb) + } + for _, drop := range drops { + opts := world.EntitySpawnOpts{Position: pos.Vec3Centre(), Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} + p.tx.AddEntity(entity.NewItem(opts, drop)) + } return } if _, air := b.(block.Air); air { From cb57e6de3909c02af6f7f1f776c0c86ab92dadb6 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Tue, 12 May 2026 12:42:28 -0400 Subject: [PATCH 07/59] Update player.go --- server/player/player.go | 52 ++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 73ed90d00..966624fba 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2046,32 +2046,10 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl // reach the block passed, the method returns immediately. func (p *Player) BreakBlock(pos cube.Pos) { b := p.tx.Block(pos) - if privateBlock, private := p.privateBlock(pos); private { - held, _ := p.HeldItems() - drops := p.drops(held, privateBlock) - xp := 0 - if breakable, ok := privateBlock.(block.Breakable); ok && !p.GameMode().CreativeInventory() { - if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { - xp = breakable.BreakInfo().XPDrops.RandomValue() - } - } - ctx := event.C(p) - if p.Handler().HandleBlockBreak(ctx, pos, true, &drops, &xp); ctx.Cancelled() { - p.resendNearbyBlocks(pos) - return - } - p.SwingArm() - p.ViewPublicBlock(pos) - p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: privateBlock}) - for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { - p.tx.AddEntity(orb) - } - for _, drop := range drops { - opts := world.EntitySpawnOpts{Position: pos.Vec3Centre(), Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} - p.tx.AddEntity(entity.NewItem(opts, drop)) - } - return + privateBlock, private := p.privateBlock(pos) + if private { + b = privateBlock } if _, air := b.(block.Air); air { // Don't do anything if the position broken is already air. @@ -2096,21 +2074,31 @@ func (p *Player) BreakBlock(pos cube.Pos) { } ctx := event.C(p) - if p.Handler().HandleBlockBreak(ctx, pos, false, &drops, &xp); ctx.Cancelled() { + if p.Handler().HandleBlockBreak(ctx, pos, private, &drops, &xp); ctx.Cancelled() { p.resendNearbyBlocks(pos) return } held, left := p.HeldItems() p.SwingArm() - p.tx.SetBlock(pos, nil, nil) - p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + if private { + p.ViewPublicBlock(pos) + } else { + p.tx.SetBlock(pos, nil, nil) - if breakable, ok := b.(block.Breakable); ok { - info := breakable.BreakInfo() - if info.BreakHandler != nil { - info.BreakHandler(pos, p.tx, p) + if breakable, ok := b.(block.Breakable); ok { + info := breakable.BreakInfo() + if info.BreakHandler != nil { + info.BreakHandler(pos, p.tx, p) + } + for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { + p.tx.AddEntity(orb) + } } + } + p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + + if private { for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { p.tx.AddEntity(orb) } From ae99c488630c406b695399174f319709b9651dbd Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Tue, 12 May 2026 12:42:38 -0400 Subject: [PATCH 08/59] Update player.go --- server/player/player.go | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 966624fba..a3606dcd9 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2091,17 +2091,12 @@ func (p *Player) BreakBlock(pos cube.Pos) { if info.BreakHandler != nil { info.BreakHandler(pos, p.tx, p) } - for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { - p.tx.AddEntity(orb) - } } } p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) - if private { - for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { - p.tx.AddEntity(orb) - } + for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { + p.tx.AddEntity(orb) } for _, drop := range drops { opts := world.EntitySpawnOpts{Position: pos.Vec3Centre(), Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} From ba6cea3fc6803cd5ab20a58d093e2f5ac66d818e Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Tue, 12 May 2026 12:45:01 -0400 Subject: [PATCH 09/59] Update player.go --- server/player/player.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index a3606dcd9..8a0e0ad6e 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2085,16 +2085,16 @@ func (p *Player) BreakBlock(pos cube.Pos) { p.ViewPublicBlock(pos) } else { p.tx.SetBlock(pos, nil, nil) + } - if breakable, ok := b.(block.Breakable); ok { - info := breakable.BreakInfo() - if info.BreakHandler != nil { - info.BreakHandler(pos, p.tx, p) - } + if breakable, ok := b.(block.Breakable); ok { + info := breakable.BreakInfo() + if info.BreakHandler != nil { + info.BreakHandler(pos, p.tx, p) } } - p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { p.tx.AddEntity(orb) } From 6de1b8166dcae202e83c0d4ef6f3461571121ceb Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Tue, 12 May 2026 13:01:02 -0400 Subject: [PATCH 10/59] Update player.go --- server/player/player.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index 8a0e0ad6e..0e1de090f 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2083,8 +2083,10 @@ func (p *Player) BreakBlock(pos cube.Pos) { p.SwingArm() if private { p.ViewPublicBlock(pos) + p.s.ViewParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) } else { p.tx.SetBlock(pos, nil, nil) + p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) } if breakable, ok := b.(block.Breakable); ok { @@ -2094,7 +2096,6 @@ func (p *Player) BreakBlock(pos cube.Pos) { } } - p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { p.tx.AddEntity(orb) } From b6593869f104d5925976774217cb137bddfc96d8 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Wed, 13 May 2026 09:09:37 -0400 Subject: [PATCH 11/59] Update player.go --- server/player/player.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 0e1de090f..c47a26a85 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2087,12 +2087,11 @@ func (p *Player) BreakBlock(pos cube.Pos) { } else { p.tx.SetBlock(pos, nil, nil) p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) - } - - if breakable, ok := b.(block.Breakable); ok { - info := breakable.BreakInfo() - if info.BreakHandler != nil { - info.BreakHandler(pos, p.tx, p) + if breakable, ok := b.(block.Breakable); ok { + info := breakable.BreakInfo() + if info.BreakHandler != nil { + info.BreakHandler(pos, p.tx, p) + } } } From 9c4a8fc0b44a20f257a8c886d504be78c219689d Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Wed, 13 May 2026 11:46:20 -0400 Subject: [PATCH 12/59] Update player.go --- server/player/player.go | 42 ++++++++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index c47a26a85..645c70d7f 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1870,7 +1870,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { if p.Handler().HandleStartBreak(ctx, pos, private); ctx.Cancelled() { return } - if punchable, ok := b.(block.Punchable); ok { + if punchable, ok := b.(block.Punchable); ok && !private { punchable.Punch(pos, face, p.tx, p) } @@ -1881,9 +1881,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { return } p.lastBreakDuration = p.breakTime(b) - for _, viewer := range p.viewers() { - viewer.ViewBlockAction(pos, block.StartCrackAction{BreakTime: p.lastBreakDuration}) - } + p.viewBreakingBlockAction(pos, private, block.StartCrackAction{BreakTime: p.lastBreakDuration}) } // breakTime returns the time needed to break a block at the position passed, taking into account the item @@ -1930,10 +1928,9 @@ func (p *Player) AbortBreaking() { if !p.breaking { return } + _, private := p.privateBlock(p.breakingPos) p.breaking, p.breakCounter = false, 0 - for _, viewer := range p.viewers() { - viewer.ViewBlockAction(p.breakingPos, block.StopCrackAction{}) - } + p.viewBreakingBlockAction(p.breakingPos, private, block.StopCrackAction{}) } // ContinueBreaking makes the player continue breaking the block it started breaking after a call to @@ -1945,23 +1942,42 @@ func (p *Player) ContinueBreaking(face cube.Face) { } pos := p.breakingPos b := p.breakingBlock(pos) - p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) + _, private := p.privateBlock(pos) + if private { + p.s.ViewParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) + } else { + p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) + } if p.breakCounter++; p.breakCounter%5 == 0 { p.SwingArm() // We send this sound only every so often. Vanilla doesn't send it every tick while breaking // either. Every 5 ticks seems accurate. - p.tx.PlaySound(pos.Vec3(), sound.BlockBreaking{Block: b}) + if private { + p.s.ViewSound(pos.Vec3(), sound.BlockBreaking{Block: b}) + } else { + p.tx.PlaySound(pos.Vec3(), sound.BlockBreaking{Block: b}) + } } if breakTime := p.breakTime(b); breakTime != p.lastBreakDuration { - for _, viewer := range p.viewers() { - viewer.ViewBlockAction(pos, block.ContinueCrackAction{BreakTime: breakTime}) - } + p.viewBreakingBlockAction(pos, private, block.ContinueCrackAction{BreakTime: breakTime}) p.lastBreakDuration = breakTime } } +// viewBreakingBlockAction shows a breaking action to the player only if the block is a private view-layer +// block, or to all viewers if it is a public world block. +func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.BlockAction) { + if private { + p.s.ViewBlockAction(pos, a) + return + } + for _, viewer := range p.viewers() { + viewer.ViewBlockAction(pos, a) + } +} + // breakingBlock returns the block that should be used for the player's breaking progress. func (p *Player) breakingBlock(pos cube.Pos) world.Block { if b, ok := p.privateBlock(pos); ok { @@ -2087,7 +2103,7 @@ func (p *Player) BreakBlock(pos cube.Pos) { } else { p.tx.SetBlock(pos, nil, nil) p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) - if breakable, ok := b.(block.Breakable); ok { + if breakable, ok := b.(block.Breakable); ok { info := breakable.BreakInfo() if info.BreakHandler != nil { info.BreakHandler(pos, p.tx, p) From 86fa646f066fbf0a6387f764c5ba058e92992549 Mon Sep 17 00:00:00 2001 From: cqdetdev <101936396+cqdetdev@users.noreply.github.com> Date: Tue, 19 May 2026 13:56:41 -0400 Subject: [PATCH 13/59] Fix block view layer behaviour --- server/player/handler.go | 16 ++++++------ server/player/player.go | 23 +++++++++++------ server/session/chunk.go | 3 +++ server/session/session.go | 2 +- server/session/view_layer.go | 24 +++++++++--------- server/world/view_layer.go | 48 +++++++++++------------------------- 6 files changed, 52 insertions(+), 64 deletions(-) diff --git a/server/player/handler.go b/server/player/handler.go index 3329d729e..8d3180686 100644 --- a/server/player/handler.go +++ b/server/player/handler.go @@ -67,15 +67,13 @@ type Handler interface { // be called to cancel the fire being extinguished. // cube.Pos can be used to see where was the fire extinguished, may be used to cancel this on specific positions. HandleFireExtinguish(ctx *Context, pos cube.Pos) - // HandleStartBreak handles the player starting to break a block at the position passed. Private is true if the - // block is a private view-layer block, and false if it is the public world block. ctx.Cancel() may be called to - // stop the player from breaking the block completely. - HandleStartBreak(ctx *Context, pos cube.Pos, private bool) - // HandleBlockBreak handles a block that is being broken by a player. Private is true if the block broken is - // a private view-layer block, and false if it is the public world block. ctx.Cancel() may be called to cancel + // HandleStartBreak handles the player starting to break a block at the position passed. ctx.Cancel() may + // be called to stop the player from breaking the block completely. + HandleStartBreak(ctx *Context, pos cube.Pos) + // HandleBlockBreak handles a block that is being broken by a player. ctx.Cancel() may be called to cancel // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered to change // what items will actually be dropped. - HandleBlockBreak(ctx *Context, pos cube.Pos, private bool, drops *[]item.Stack, xp *int) + HandleBlockBreak(ctx *Context, pos cube.Pos, drops *[]item.Stack, xp *int) // HandleBlockPlace handles the player placing a specific block at a position in its world. ctx.Cancel() // may be called to cancel the block being placed. HandleBlockPlace(ctx *Context, pos cube.Pos, b world.Block) @@ -178,8 +176,8 @@ func (NopHandler) HandleTransfer(*Context, *net.UDPAddr) func (NopHandler) HandleChat(*Context, *string) {} func (NopHandler) HandleSkinChange(*Context, *skin.Skin) {} func (NopHandler) HandleFireExtinguish(*Context, cube.Pos) {} -func (NopHandler) HandleStartBreak(*Context, cube.Pos, bool) {} -func (NopHandler) HandleBlockBreak(*Context, cube.Pos, bool, *[]item.Stack, *int) {} +func (NopHandler) HandleStartBreak(*Context, cube.Pos) {} +func (NopHandler) HandleBlockBreak(*Context, cube.Pos, *[]item.Stack, *int) {} func (NopHandler) HandleBlockPlace(*Context, cube.Pos, world.Block) {} func (NopHandler) HandleBlockPick(*Context, cube.Pos, world.Block) {} func (NopHandler) HandleSignEdit(*Context, cube.Pos, bool, string, string) {} diff --git a/server/player/player.go b/server/player/player.go index 645c70d7f..f60fb0de7 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1844,7 +1844,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { // The block was either out of range or air, so it can't be broken by the player. return } - if _, ok := p.tx.Block(pos.Side(face)).(block.Fire); ok { + if _, ok := p.tx.Block(pos.Side(face)).(block.Fire); ok && !private { ctx := event.C(p) if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { // Resend the block because on client side that was extinguished @@ -1867,7 +1867,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.breakingPos = pos ctx := event.C(p) - if p.Handler().HandleStartBreak(ctx, pos, private); ctx.Cancelled() { + if p.Handler().HandleStartBreak(ctx, pos); ctx.Cancelled() { return } if punchable, ok := b.(block.Punchable); ok && !private { @@ -2080,17 +2080,20 @@ func (p *Player) BreakBlock(pos cube.Pos) { return } held, _ := p.HeldItems() - drops := p.drops(held, b) + var drops []item.Stack xp := 0 - if breakable, ok := b.(block.Breakable); ok && !p.GameMode().CreativeInventory() { - if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { - xp = breakable.BreakInfo().XPDrops.RandomValue() + if !private { + drops = p.drops(held, b) + if breakable, ok := b.(block.Breakable); ok && !p.GameMode().CreativeInventory() { + if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { + xp = breakable.BreakInfo().XPDrops.RandomValue() + } } } ctx := event.C(p) - if p.Handler().HandleBlockBreak(ctx, pos, private, &drops, &xp); ctx.Cancelled() { + if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { p.resendNearbyBlocks(pos) return } @@ -2100,6 +2103,7 @@ func (p *Player) BreakBlock(pos cube.Pos) { if private { p.ViewPublicBlock(pos) p.s.ViewParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + return } else { p.tx.SetBlock(pos, nil, nil) p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) @@ -2160,7 +2164,7 @@ func (p *Player) PickBlock(pos cube.Pos) { return } - b := p.tx.Block(pos) + b := p.breakingBlock(pos) var pickedItem item.Stack if pi, ok := b.(block.Pickable); ok { @@ -2664,6 +2668,9 @@ func (p *Player) ViewBlock(pos cube.Pos, b world.Block) { // ViewPublicBlock removes the block override at the position passed for this player. func (p *Player) ViewPublicBlock(pos cube.Pos) { p.session().ViewPublicBlock(pos) + if p.session() != session.Nop && !pos.OutOfBounds(p.tx.Range()) { + p.session().ViewBlockUpdate(pos, p.tx.Block(pos), 0) + } } // RemoveViewLayer removes all view-layer overrides of the entity for this player. diff --git a/server/session/chunk.go b/server/session/chunk.go index 24054b03f..280e9ac9f 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -150,6 +150,9 @@ func (s *Session) applyViewLayerToChunk(pos world.ChunkPos, c *chunk.Chunk, bloc if (world.ChunkPos{int32(blockPos[0] >> 4), int32(blockPos[2] >> 4)}) != pos { continue } + if blockPos.OutOfBounds(c.Range()) { + continue + } if !cloned { c = c.Clone() blockEntities = maps.Clone(blockEntities) diff --git a/server/session/session.go b/server/session/session.go index bbe523687..62389f532 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -207,7 +207,7 @@ func (conf Config) New(conn Conn) *Session { debugShapeUpdates: make([]debugShapeUpdate, 0, 256), br: br, } - s.viewLayer = world.NewViewLayerWithBlockRegistry(s, br) + s.viewLayer = world.NewViewLayer(s) s.openedWindow.Store(inventory.New(1, nil)) s.openedPos.Store(&cube.Pos{}) diff --git a/server/session/view_layer.go b/server/session/view_layer.go index 94dee2cba..90e6f0c30 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -59,7 +59,7 @@ func (s *Session) ViewBlock(pos cube.Pos, b world.Block) { s.viewLayer.ViewBlock(pos, b) } -// ViewPublicBlock removes the block override at the position passed and immediately refreshes it for this session. +// ViewPublicBlock removes the block override at the position passed. func (s *Session) ViewPublicBlock(pos cube.Pos) { if s.viewLayer == nil { return @@ -83,13 +83,12 @@ func (s *Session) ViewLayerEntityChanged(e world.Entity) { s.ViewEntityState(e) } -// ViewLayerBlockChanged refreshes the block for this session if its chunk is currently visible. +// ViewLayerBlockChanged refreshes a block override for this session if its chunk is currently visible. func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { - if b, ok := s.viewLayer.Block(pos); ok { - s.viewBlockUpdate(pos, b, 0) + if !s.viewingBlock(pos) { return } - if b, ok := s.publicBlock(pos); ok { + if b, ok := s.viewLayer.Block(pos); ok { s.viewBlockUpdate(pos, b, 0) } } @@ -102,15 +101,14 @@ func (s *Session) viewingEntity(handle *world.EntityHandle) bool { return ok } -// publicBlock returns the public block at pos from the loaded chunk for this session. -func (s *Session) publicBlock(pos cube.Pos) (world.Block, bool) { +// viewingBlock returns true if the block position is loaded for this session. +func (s *Session) viewingBlock(pos cube.Pos) bool { + if s.chunkLoader == nil { + return false + } col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) if !ok { - return nil, false - } - if b, ok := col.BlockEntities[pos]; ok { - return b, true + return false } - b, ok := s.br.BlockByRuntimeID(col.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0)) - return b, ok + return !pos.OutOfBounds(col.Range()) } diff --git a/server/world/view_layer.go b/server/world/view_layer.go index 0b1007701..d0d676fef 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -19,6 +19,9 @@ type layer struct { type ViewLayerUpdater interface { // ViewLayerEntityChanged handles an entity whose view-layer overrides changed. ViewLayerEntityChanged(entity Entity) +} + +type viewLayerBlockUpdater interface { // ViewLayerBlockChanged handles a block whose view-layer override changed. ViewLayerBlockChanged(pos cube.Pos) } @@ -30,29 +33,18 @@ type viewLayerViewer interface { // ViewLayer holds overrides for how entities are viewed by a single viewer. It allows entities to be // viewed differently by different players, such as with a different name tag or visibility state. type ViewLayer struct { - mu sync.RWMutex - entities map[*EntityHandle]layer - blocks map[cube.Pos]uint32 - blockRegistry BlockRegistry - updater ViewLayerUpdater + mu sync.RWMutex + entities map[*EntityHandle]layer + blocks map[cube.Pos]Block + updater ViewLayerUpdater } // NewViewLayer returns a new ViewLayer. func NewViewLayer(updater ViewLayerUpdater) *ViewLayer { - return NewViewLayerWithBlockRegistry(updater, DefaultBlockRegistry) -} - -// NewViewLayerWithBlockRegistry returns a new ViewLayer using the BlockRegistry passed for block runtime ID -// conversions. -func NewViewLayerWithBlockRegistry(updater ViewLayerUpdater, br BlockRegistry) *ViewLayer { - if br == nil { - br = DefaultBlockRegistry - } return &ViewLayer{ - entities: map[*EntityHandle]layer{}, - blocks: map[cube.Pos]uint32{}, - blockRegistry: br, - updater: updater, + entities: map[*EntityHandle]layer{}, + blocks: map[cube.Pos]Block{}, + updater: updater, } } @@ -143,7 +135,7 @@ func (v *ViewLayer) ViewBlock(pos cube.Pos, b Block) { if b == nil { delete(v.blocks, pos) } else { - v.blocks[pos] = v.blockRegistry.BlockRuntimeID(b) + v.blocks[pos] = b } v.mu.Unlock() @@ -160,11 +152,7 @@ func (v *ViewLayer) Block(pos cube.Pos) (Block, bool) { v.mu.RLock() defer v.mu.RUnlock() - rid, ok := v.blocks[pos] - if !ok { - return nil, false - } - b, ok := v.blockRegistry.BlockByRuntimeID(rid) + b, ok := v.blocks[pos] return b, ok } @@ -173,13 +161,7 @@ func (v *ViewLayer) Blocks() map[cube.Pos]Block { v.mu.RLock() defer v.mu.RUnlock() - blocks := make(map[cube.Pos]Block, len(v.blocks)) - for pos, rid := range v.blocks { - if b, ok := v.blockRegistry.BlockByRuntimeID(rid); ok { - blocks[pos] = b - } - } - return blocks + return maps.Clone(v.blocks) } // Remove removes all overrides for the entity from the ViewLayer. @@ -241,7 +223,7 @@ func (v *ViewLayer) refresh(entity Entity) { } func (v *ViewLayer) refreshBlock(pos cube.Pos) { - if v.updater != nil { - v.updater.ViewLayerBlockChanged(pos) + if updater, ok := v.updater.(viewLayerBlockUpdater); ok { + updater.ViewLayerBlockChanged(pos) } } From 1f2b063736c82e196dd68287fe8ab3a0c7d5b34f Mon Sep 17 00:00:00 2001 From: cqdetdev <101936396+cqdetdev@users.noreply.github.com> Date: Wed, 20 May 2026 10:11:23 -0400 Subject: [PATCH 14/59] Fix view layer break state refresh --- server/player/player.go | 70 ++++++++++++++++++++++-------------- server/session/chunk.go | 20 +++++++---- server/session/view_layer.go | 24 ++++++++++++- 3 files changed, 79 insertions(+), 35 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index f60fb0de7..48337b9fa 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -98,6 +98,8 @@ type playerData struct { breaking bool breakingPos cube.Pos breakingFace cube.Face + breakingPrivate bool + breakingPosValid bool lastBreakDuration time.Duration breakCounter uint32 @@ -1838,8 +1840,7 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() - b := p.breakingBlock(pos) - _, private := p.privateBlock(pos) + b, private := p.breakingBlock(pos) if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. return @@ -1864,7 +1865,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { } // Note: We intentionally store this regardless of whether the breaking proceeds, so that we // can resend the block to the client when it tries to break the block regardless. - p.breakingPos = pos + p.breakingPos, p.breakingPrivate, p.breakingPosValid = pos, private, true ctx := event.C(p) if p.Handler().HandleStartBreak(ctx, pos); ctx.Cancelled() { @@ -1874,7 +1875,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { punchable.Punch(pos, face, p.tx, p) } - p.breaking, p.breakingFace = true, face + p.breaking, p.breakingFace, p.breakingPrivate = true, face, private p.SwingArm() if p.GameMode().CreativeInventory() { @@ -1914,11 +1915,15 @@ func (p *Player) breakTime(b world.Block) time.Duration { // FinishBreaking will stop the animation and break the block. func (p *Player) FinishBreaking() { if !p.breaking { - p.resendNearbyBlock(p.breakingPos) + if p.breakingPosValid { + p.resendBreakingBlock(p.breakingPos, p.breakingPrivate) + p.breakingPosValid = false + } return } + pos := p.breakingPos p.AbortBreaking() - p.BreakBlock(p.breakingPos) + p.BreakBlock(pos) } // AbortBreaking makes the player stop breaking the block it is currently breaking, or returns immediately @@ -1926,10 +1931,11 @@ func (p *Player) FinishBreaking() { // Unlike FinishBreaking, AbortBreaking does not stop the animation. func (p *Player) AbortBreaking() { if !p.breaking { + p.breakingPosValid = false return } - _, private := p.privateBlock(p.breakingPos) - p.breaking, p.breakCounter = false, 0 + private := p.breakingPrivate + p.breaking, p.breakingPrivate, p.breakingPosValid, p.breakCounter = false, false, false, 0 p.viewBreakingBlockAction(p.breakingPos, private, block.StopCrackAction{}) } @@ -1941,10 +1947,13 @@ func (p *Player) ContinueBreaking(face cube.Face) { return } pos := p.breakingPos - b := p.breakingBlock(pos) - _, private := p.privateBlock(pos) + private := p.breakingPrivate + b := p.tx.Block(pos) + if private { + b, _ = p.breakingBlock(pos) + } if private { - p.s.ViewParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) + p.ShowParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) } else { p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) } @@ -1955,7 +1964,7 @@ func (p *Player) ContinueBreaking(face cube.Face) { // We send this sound only every so often. Vanilla doesn't send it every tick while breaking // either. Every 5 ticks seems accurate. if private { - p.s.ViewSound(pos.Vec3(), sound.BlockBreaking{Block: b}) + p.session().ViewSound(pos.Vec3(), sound.BlockBreaking{Block: b}) } else { p.tx.PlaySound(pos.Vec3(), sound.BlockBreaking{Block: b}) } @@ -1970,7 +1979,7 @@ func (p *Player) ContinueBreaking(face cube.Face) { // block, or to all viewers if it is a public world block. func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.BlockAction) { if private { - p.s.ViewBlockAction(pos, a) + p.session().ViewBlockAction(pos, a) return } for _, viewer := range p.viewers() { @@ -1979,11 +1988,11 @@ func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.Blo } // breakingBlock returns the block that should be used for the player's breaking progress. -func (p *Player) breakingBlock(pos cube.Pos) world.Block { +func (p *Player) breakingBlock(pos cube.Pos) (world.Block, bool) { if b, ok := p.privateBlock(pos); ok { - return b + return b, true } - return p.tx.Block(pos) + return p.tx.Block(pos), false } // PlaceBlock makes the player place the block passed at the position passed, granted it is within the range @@ -2061,22 +2070,20 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl // BreakBlock makes the player break a block in the world at a position passed. If the player is unable to // reach the block passed, the method returns immediately. func (p *Player) BreakBlock(pos cube.Pos) { - b := p.tx.Block(pos) - - privateBlock, private := p.privateBlock(pos) - if private { - b = privateBlock - } + b, private := p.breakingBlock(pos) if _, air := b.(block.Air); air { // Don't do anything if the position broken is already air. return } + resendBrokenBlock := func() { + p.resendBreakingBlock(pos, private) + } if !p.canReach(pos.Vec3Centre()) || !p.GameMode().AllowsEditing() { - p.resendNearbyBlocks(pos) + resendBrokenBlock() return } if _, breakable := b.(block.Breakable); !breakable && !p.GameMode().CreativeInventory() { - p.resendNearbyBlocks(pos) + resendBrokenBlock() return } held, _ := p.HeldItems() @@ -2094,7 +2101,7 @@ func (p *Player) BreakBlock(pos cube.Pos) { ctx := event.C(p) if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { - p.resendNearbyBlocks(pos) + resendBrokenBlock() return } held, left := p.HeldItems() @@ -2102,7 +2109,7 @@ func (p *Player) BreakBlock(pos cube.Pos) { p.SwingArm() if private { p.ViewPublicBlock(pos) - p.s.ViewParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + p.ShowParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) return } else { p.tx.SetBlock(pos, nil, nil) @@ -2157,6 +2164,15 @@ func (p *Player) privateBlock(pos cube.Pos) (world.Block, bool) { return p.ViewLayer().Block(pos) } +// resendBreakingBlock resends the block being broken without overwriting private view-layer overrides. +func (p *Player) resendBreakingBlock(pos cube.Pos, private bool) { + if private { + p.session().ViewLayerBlockChanged(pos) + return + } + p.resendNearbyBlocks(pos) +} + // PickBlock makes the player pick a block in the world at a position passed. If the player is unable to // pick the block, the method returns immediately. func (p *Player) PickBlock(pos cube.Pos) { @@ -2164,7 +2180,7 @@ func (p *Player) PickBlock(pos cube.Pos) { return } - b := p.breakingBlock(pos) + b, _ := p.breakingBlock(pos) var pickedItem item.Stack if pi, ok := b.(block.Pickable); ok { diff --git a/server/session/chunk.go b/server/session/chunk.go index 280e9ac9f..667f2f04f 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -33,25 +33,31 @@ func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.Sub entries := make([]protocol.SubChunkEntry, 0, len(offsets)) transaction := make(map[uint64]struct{}) + viewedChunks := make(map[world.ChunkPos]struct { + chunk *chunk.Chunk + blockEntities map[cube.Pos]world.Block + }) for _, offset := range offsets { ind := int16(centre.Y()) + int16(offset[1]) - int16(r[0])>>4 if ind < 0 || ind > int16(r.Height()>>4) { entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultIndexOutOfBounds, Offset: offset}) continue } - col, ok := s.chunkLoader.Chunk(world.ChunkPos{ + chunkPos := world.ChunkPos{ centre.X() + int32(offset[0]), centre.Z() + int32(offset[2]), - }) + } + col, ok := s.chunkLoader.Chunk(chunkPos) if !ok { entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultChunkNotFound, Offset: offset}) continue } - ch, blockEntities := s.applyViewLayerToChunk(world.ChunkPos{ - centre.X() + int32(offset[0]), - centre.Z() + int32(offset[2]), - }, col.Chunk, col.BlockEntities) - entries = append(entries, s.subChunkEntry(offset, ind, ch, blockEntities, transaction)) + viewed, ok := viewedChunks[chunkPos] + if !ok { + viewed.chunk, viewed.blockEntities = s.applyViewLayerToChunk(chunkPos, col.Chunk, col.BlockEntities) + viewedChunks[chunkPos] = viewed + } + entries = append(entries, s.subChunkEntry(offset, ind, viewed.chunk, viewed.blockEntities, transaction)) } if s.conn.ClientCacheEnabled() && len(transaction) > 0 { s.blobMu.Lock() diff --git a/server/session/view_layer.go b/server/session/view_layer.go index 90e6f0c30..4ee355c94 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -59,7 +59,7 @@ func (s *Session) ViewBlock(pos cube.Pos, b world.Block) { s.viewLayer.ViewBlock(pos, b) } -// ViewPublicBlock removes the block override at the position passed. +// ViewPublicBlock removes the block override at the position passed and immediately refreshes it for this session. func (s *Session) ViewPublicBlock(pos cube.Pos) { if s.viewLayer == nil { return @@ -85,11 +85,18 @@ func (s *Session) ViewLayerEntityChanged(e world.Entity) { // ViewLayerBlockChanged refreshes a block override for this session if its chunk is currently visible. func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { + if s.viewLayer == nil { + return + } if !s.viewingBlock(pos) { return } if b, ok := s.viewLayer.Block(pos); ok { s.viewBlockUpdate(pos, b, 0) + return + } + if b, ok := s.publicBlock(pos); ok { + s.viewBlockUpdate(pos, b, 0) } } @@ -112,3 +119,18 @@ func (s *Session) viewingBlock(pos cube.Pos) bool { } return !pos.OutOfBounds(col.Range()) } + +// publicBlock returns the public block loaded for this session at pos. +func (s *Session) publicBlock(pos cube.Pos) (world.Block, bool) { + if s.chunkLoader == nil { + return nil, false + } + col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) + if !ok || pos.OutOfBounds(col.Range()) { + return nil, false + } + if b, ok := col.BlockEntities[pos]; ok { + return b, true + } + return s.br.BlockByRuntimeID(col.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0)) +} From bebe87c67e7bf2dcee4e0ef83d2bad21b1f45576 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Sun, 24 May 2026 23:55:28 -0400 Subject: [PATCH 15/59] cube/trace: add BlockIntersects and BBoxIntersects methods --- server/block/cube/trace/bbox.go | 35 ++++++++++++++++++++++++++++++++ server/block/cube/trace/block.go | 21 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/server/block/cube/trace/bbox.go b/server/block/cube/trace/bbox.go index 249bef5bc..aef3bbf27 100644 --- a/server/block/cube/trace/bbox.go +++ b/server/block/cube/trace/bbox.go @@ -99,6 +99,41 @@ func BBoxIntercept(bb cube.BBox, start, end mgl64.Vec3) (result BBoxResult, ok b return BBoxResult{bb: bb, pos: *vec, face: f}, true } +// BBoxIntersects checks if the line segment from start to end intersects the BBox. +// Unlike BBoxIntercept, it only reports whether an intersection exists and does not +// calculate the closest hit position or face. +func BBoxIntersects(bb cube.BBox, start, end mgl64.Vec3) bool { + min, max := bb.Min(), bb.Max() + dir := end.Sub(start) + tMin, tMax := 0.0, 1.0 + + for axis := range 3 { + if mgl64.FloatEqual(dir[axis], 0) { + if start[axis] < min[axis] || start[axis] > max[axis] { + return false + } + continue + } + + inv := 1 / dir[axis] + t1 := (min[axis] - start[axis]) * inv + t2 := (max[axis] - start[axis]) * inv + if t1 > t2 { + t1, t2 = t2, t1 + } + if t1 > tMin { + tMin = t1 + } + if t2 < tMax { + tMax = t2 + } + if tMin > tMax { + return false + } + } + return true +} + // vec3OnLineWithX returns an mgl64.Vec3 on the line between mgl64.Vec3 a and b with an X value passed. If no such vec3 // could be found, the bool returned is false. func vec3OnLineWithX(a, b mgl64.Vec3, x float64) *mgl64.Vec3 { diff --git a/server/block/cube/trace/block.go b/server/block/cube/trace/block.go index e9a0f2ce3..3139151eb 100644 --- a/server/block/cube/trace/block.go +++ b/server/block/cube/trace/block.go @@ -2,6 +2,7 @@ package trace import ( "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/model" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" "math" @@ -70,3 +71,23 @@ func BlockIntercept(pos cube.Pos, src world.BlockSource, b world.Block, start, e return BlockResult{bb: hit.BBox(), pos: hit.Position(), face: hit.Face(), blockPos: pos}, true } + +// BlockIntersects checks if the line segment from start to end intersects the block model of b at pos. Unlike +// BlockIntercept, it only reports whether an intersection exists and does not calculate the closest hit position, face, +// or bounding box. +func BlockIntersects(pos cube.Pos, src world.BlockSource, b world.Block, start, end mgl64.Vec3) bool { + m := b.Model() + switch m.(type) { + case model.Empty: + return false + case model.Solid: + return BBoxIntersects(cube.Box(0, 0, 0, 1, 1, 1).Translate(pos.Vec3()), start, end) + } + + for _, bb := range m.BBox(pos, src) { + if BBoxIntersects(bb.Translate(pos.Vec3()), start, end) { + return true + } + } + return false +} From 14e5cc8f120b4ac2df7fc65c13b00245e544deef Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 25 May 2026 19:32:26 -0400 Subject: [PATCH 16/59] fix(player): correct block break updates --- server/player/player.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 48337b9fa..8eaecaae0 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2122,8 +2122,10 @@ func (p *Player) BreakBlock(pos cube.Pos) { } } - for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { - p.tx.AddEntity(orb) + if _, ok := b.(block.Breakable); ok { + for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { + p.tx.AddEntity(orb) + } } for _, drop := range drops { opts := world.EntitySpawnOpts{Position: pos.Vec3Centre(), Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} @@ -2684,9 +2686,6 @@ func (p *Player) ViewBlock(pos cube.Pos, b world.Block) { // ViewPublicBlock removes the block override at the position passed for this player. func (p *Player) ViewPublicBlock(pos cube.Pos) { p.session().ViewPublicBlock(pos) - if p.session() != session.Nop && !pos.OutOfBounds(p.tx.Range()) { - p.session().ViewBlockUpdate(pos, p.tx.Block(pos), 0) - } } // RemoveViewLayer removes all view-layer overrides of the entity for this player. From 0431b1a8c4c3cf225f7e8b02a8328bb0027a2beb Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 25 May 2026 19:33:05 -0400 Subject: [PATCH 17/59] Update .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2502e81c2..fecf38a66 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ /world/ /players/ /resources/ + +# AI +.omx From 08cef406f4f591c2671f4e3797d5b8ada678ba6b Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 10:58:32 -0400 Subject: [PATCH 18/59] Update player.go --- server/player/player.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 8eaecaae0..f2b938ded 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1948,13 +1948,12 @@ func (p *Player) ContinueBreaking(face cube.Face) { } pos := p.breakingPos private := p.breakingPrivate - b := p.tx.Block(pos) + var b world.Block if private { b, _ = p.breakingBlock(pos) - } - if private { p.ShowParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) } else { + b = p.tx.Block(pos) p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) } From ebfe8c5ebb9c14ed7c8303c27a48a04b05dd33c3 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 11:08:59 -0400 Subject: [PATCH 19/59] Update player.go --- server/player/player.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index f2b938ded..8788f8f5a 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1922,7 +1922,14 @@ func (p *Player) FinishBreaking() { return } pos := p.breakingPos + private := p.breakingPrivate p.AbortBreaking() + if private { + if b, ok := p.privateBlock(pos); ok { + p.breakBlock(pos, b, true) + return + } + } p.BreakBlock(pos) } @@ -2069,7 +2076,12 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl // BreakBlock makes the player break a block in the world at a position passed. If the player is unable to // reach the block passed, the method returns immediately. func (p *Player) BreakBlock(pos cube.Pos) { - b, private := p.breakingBlock(pos) + p.breakBlock(pos, p.tx.Block(pos), false) +} + +// breakBlock makes the player break the block passed at the position passed. Private blocks are removed +// from the player's view layer instead of the world. +func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { if _, air := b.(block.Air); air { // Don't do anything if the position broken is already air. return From e0da704c426d43a2a9f9cbff2eaaaec9231f2c04 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 11:49:50 -0400 Subject: [PATCH 20/59] various changes --- server/player/player.go | 28 ++++++++++++++++++---------- server/session/chunk.go | 4 +++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 8788f8f5a..f4f58f648 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1922,14 +1922,7 @@ func (p *Player) FinishBreaking() { return } pos := p.breakingPos - private := p.breakingPrivate p.AbortBreaking() - if private { - if b, ok := p.privateBlock(pos); ok { - p.breakBlock(pos, b, true) - return - } - } p.BreakBlock(pos) } @@ -1989,10 +1982,23 @@ func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.Blo return } for _, viewer := range p.viewers() { + if viewerHasPrivateBlock(viewer, pos) { + continue + } viewer.ViewBlockAction(pos, a) } } +// viewerHasPrivateBlock returns true if the viewer has a private block override at pos. +func viewerHasPrivateBlock(viewer world.Viewer, pos cube.Pos) bool { + s, ok := viewer.(*session.Session) + if !ok || s.ViewLayer() == nil { + return false + } + _, ok = s.ViewLayer().Block(pos) + return ok +} + // breakingBlock returns the block that should be used for the player's breaking progress. func (p *Player) breakingBlock(pos cube.Pos) (world.Block, bool) { if b, ok := p.privateBlock(pos); ok { @@ -2073,10 +2079,12 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl return obstructed, true } -// BreakBlock makes the player break a block in the world at a position passed. If the player is unable to -// reach the block passed, the method returns immediately. +// BreakBlock makes the player break a block at a position passed. If the player has a private block +// override at that position, it is removed instead of breaking the public world block. If the player is +// unable to reach the block passed, the method returns immediately. func (p *Player) BreakBlock(pos cube.Pos) { - p.breakBlock(pos, p.tx.Block(pos), false) + b, private := p.breakingBlock(pos) + p.breakBlock(pos, b, private) } // breakBlock makes the player break the block passed at the position passed. Private blocks are removed diff --git a/server/session/chunk.go b/server/session/chunk.go index 667f2f04f..8745c3d0e 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -164,7 +164,9 @@ func (s *Session) applyViewLayerToChunk(pos world.ChunkPos, c *chunk.Chunk, bloc blockEntities = maps.Clone(blockEntities) cloned = true } - c.SetBlock(uint8(blockPos[0]), int16(blockPos[1]), uint8(blockPos[2]), 0, s.br.BlockRuntimeID(b)) + x, y, z := uint8(blockPos[0]), int16(blockPos[1]), uint8(blockPos[2]) + c.SetBlock(x, y, z, 0, s.br.BlockRuntimeID(b)) + c.SetBlock(x, y, z, 1, s.br.AirRuntimeID()) if _, ok := b.(world.NBTer); ok { blockEntities[blockPos] = b } else { From 0944c93c440bc692d08dcc5c8a24918ecdaa3658 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 11:53:22 -0400 Subject: [PATCH 21/59] Preserve view-layer block semantics Route break and item-use paths through the player-visible block state, and keep live layer updates from leaking stale liquid layers. This prevents private overrides from accidentally interacting with hidden public blocks.\n\nConstraint: Review feedback required view-layer overrides to control break, item-use, and liquid-layer update behavior.\nRejected: Forcing BreakBlock to always use public world blocks | Direct client break paths must still remove private overrides.\nConfidence: high\nScope-risk: moderate\nDirective: Keep player interactions aligned with viewedBlock when client actions target block positions.\nTested: go test ./server/player ./server/session; go test ./server/...\nNot-tested: Manual in-client waterlogged private override interaction. --- server/player/player.go | 23 ++++++++++++++++++----- server/session/view_layer.go | 14 ++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index f4f58f648..bd2a3e859 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1664,7 +1664,8 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - if _, ok := p.tx.Block(pos).(block.Air); ok || !p.canReach(pos.Vec3Centre()) { + b, _ := p.viewedBlock(pos) + if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that does not exist server-side or one it couldn't reach. Stop trying // to use the item immediately. p.resendNearbyBlocks(pos, face) @@ -1676,7 +1677,6 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec return } i, left := p.HeldItems() - b := p.tx.Block(pos) if act, ok := b.(block.Activatable); ok { // If a player is sneaking, it will not activate the block clicked, unless it is not holding any // items, in which case the block will be activated as usual. @@ -1712,7 +1712,8 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec // The block clicked was either not replaceable, or not replaceable using the block passed. replacedPos = pos.Side(face) } - if replaceable, ok := p.tx.Block(replacedPos).(block.Replaceable); !ok || !replaceable.ReplaceableBy(ib) || replacedPos.OutOfBounds(p.tx.Range()) { + replacedBlock, _ := p.viewedBlock(replacedPos) + if replaceable, ok := replacedBlock.(block.Replaceable); !ok || !replaceable.ReplaceableBy(ib) || replacedPos.OutOfBounds(p.tx.Range()) { return } if !p.placeBlock(replacedPos, ib, false) || p.GameMode().CreativeInventory() { @@ -1922,7 +1923,14 @@ func (p *Player) FinishBreaking() { return } pos := p.breakingPos + private := p.breakingPrivate p.AbortBreaking() + if private { + if b, ok := p.privateBlock(pos); ok { + p.breakBlock(pos, b, true) + } + return + } p.BreakBlock(pos) } @@ -1999,14 +2007,19 @@ func viewerHasPrivateBlock(viewer world.Viewer, pos cube.Pos) bool { return ok } -// breakingBlock returns the block that should be used for the player's breaking progress. -func (p *Player) breakingBlock(pos cube.Pos) (world.Block, bool) { +// viewedBlock returns the block currently shown to the player at pos. +func (p *Player) viewedBlock(pos cube.Pos) (world.Block, bool) { if b, ok := p.privateBlock(pos); ok { return b, true } return p.tx.Block(pos), false } +// breakingBlock returns the block that should be used for the player's breaking progress. +func (p *Player) breakingBlock(pos cube.Pos) (world.Block, bool) { + return p.viewedBlock(pos) +} + // PlaceBlock makes the player place the block passed at the position passed, granted it is within the range // of the player. // An item.UseContext may be passed to obtain information on if the block placement was successful. (SubCount will diff --git a/server/session/view_layer.go b/server/session/view_layer.go index 4ee355c94..dc0b56491 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -93,13 +93,27 @@ func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { } if b, ok := s.viewLayer.Block(pos); ok { s.viewBlockUpdate(pos, b, 0) + s.viewBlockUpdate(pos, s.br.Air(), 1) return } if b, ok := s.publicBlock(pos); ok { s.viewBlockUpdate(pos, b, 0) + s.viewBlockUpdate(pos, s.publicLiquid(pos), 1) } } +// publicLiquid returns the public liquid layer loaded for this session at pos, or air if no liquid is present. +func (s *Session) publicLiquid(pos cube.Pos) world.Block { + if s.chunkLoader == nil { + return s.br.Air() + } + col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) + if !ok || pos.OutOfBounds(col.Range()) { + return s.br.Air() + } + return s.br.BlockByRuntimeIDOrAir(col.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 1)) +} + // viewingEntity checks if this session currently has a runtime ID assigned to the entity handle. func (s *Session) viewingEntity(handle *world.EntityHandle) bool { s.entityMutex.RLock() From 75b759003a66197b29d25e17e8e661f0434dfbee Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 12:04:41 -0400 Subject: [PATCH 22/59] Keep private item use non-mutating Private view-layer blocks are allowed to satisfy client-visible validation, but public transaction mutations must not run against them. Return before activation/use/place paths when the clicked or replacement target block is private.\n\nConstraint: Review feedback flagged private viewed blocks flowing into p.tx mutation paths.\nRejected: Mutating private overrides through p.tx | p.tx represents public world state, not per-player view state.\nConfidence: high\nScope-risk: narrow\nDirective: Any future view-layer interaction that mutates world state must explicitly re-check public block state or operate on the view layer.\nTested: go test ./server/player ./server/session; go test ./server/...\nNot-tested: Manual in-client fake activatable/fake replaceable right-click scenarios. --- server/player/player.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index bd2a3e859..56b39646f 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1664,7 +1664,7 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - b, _ := p.viewedBlock(pos) + b, private := p.viewedBlock(pos) if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that does not exist server-side or one it couldn't reach. Stop trying // to use the item immediately. @@ -1676,6 +1676,9 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec p.resendNearbyBlocks(pos, face) return } + if private { + return + } i, left := p.HeldItems() if act, ok := b.(block.Activatable); ok { // If a player is sneaking, it will not activate the block clicked, unless it is not holding any @@ -1712,7 +1715,10 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec // The block clicked was either not replaceable, or not replaceable using the block passed. replacedPos = pos.Side(face) } - replacedBlock, _ := p.viewedBlock(replacedPos) + replacedBlock, replacedPrivate := p.viewedBlock(replacedPos) + if replacedPrivate { + return + } if replaceable, ok := replacedBlock.(block.Replaceable); !ok || !replaceable.ReplaceableBy(ib) || replacedPos.OutOfBounds(p.tx.Range()) { return } From 241284295ea493dee09f721752479f5df60b606f Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 12:05:36 -0400 Subject: [PATCH 23/59] Mask liquid updates behind private blocks Incremental block updates now mirror chunk/view-layer refresh behavior by forcing non-zero layers to air while a private block override exists at the same position.\n\nConstraint: Chunk sends already clear layer 1 for private overrides; live world updates needed the same masking.\nRejected: Only masking layer 0 | Liquid/second-layer updates can leak through behind private blocks.\nConfidence: high\nScope-risk: narrow\nDirective: Keep all block update paths consistent with view-layer masking across block layers.\nTested: go test ./server/session; go test ./server/...\nNot-tested: Manual in-client SetLiquid/secondLayer update while private override is active. --- server/session/world.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/server/session/world.go b/server/session/world.go index 69e04778f..98adfea75 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -959,9 +959,13 @@ func (s *Session) ViewBrewingUpdate(prevBrewTime, brewTime time.Duration, prevFu // ViewBlockUpdate ... func (s *Session) ViewBlockUpdate(pos cube.Pos, b world.Block, layer int) { - if s.viewLayer != nil && layer == 0 { + if s.viewLayer != nil { if viewed, ok := s.viewLayer.Block(pos); ok { - b = viewed + if layer == 0 { + b = viewed + } else { + b = s.br.Air() + } } } s.viewBlockUpdate(pos, b, layer) From 8af8f8c2a194a342137dac4475cd2cddccc1eec1 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 12:07:02 -0400 Subject: [PATCH 24/59] Suppress public actions behind private blocks Session block actions now respect view-layer block overrides, while private breaking animations use an explicit bypass for the private block path. This prevents public chest/pot/etc actions from leaking through fake blocks.\n\nConstraint: Public block actions are broadcast through the generic Session.ViewBlockAction interface.\nRejected: Filtering in individual block action emitters | Central session filtering covers all current and future public action sources.\nConfidence: high\nScope-risk: narrow\nDirective: Use ViewPrivateBlockAction only for actions that intentionally target the session's private override.\nTested: go test ./server/player ./server/session; go test ./server/...\nNot-tested: Manual in-client fake block over chest/decorated pot action scenarios. --- server/player/player.go | 2 +- server/session/world.go | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index 56b39646f..1025e93ec 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1992,7 +1992,7 @@ func (p *Player) ContinueBreaking(face cube.Face) { // block, or to all viewers if it is a public world block. func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.BlockAction) { if private { - p.session().ViewBlockAction(pos, a) + p.session().ViewPrivateBlockAction(pos, a) return } for _, viewer := range p.viewers() { diff --git a/server/session/world.go b/server/session/world.go index 98adfea75..3555d326c 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -1258,6 +1258,21 @@ func (s *Session) ViewSlotChange(slot int, newItem item.Stack) { // ViewBlockAction ... func (s *Session) ViewBlockAction(pos cube.Pos, a world.BlockAction) { + if s.viewLayer != nil { + if _, ok := s.viewLayer.Block(pos); ok { + return + } + } + s.viewBlockAction(pos, a) +} + +// ViewPrivateBlockAction views an action performed by a private view-layer block. +func (s *Session) ViewPrivateBlockAction(pos cube.Pos, a world.BlockAction) { + s.viewBlockAction(pos, a) +} + +// viewBlockAction sends a block action without applying view-layer filters. +func (s *Session) viewBlockAction(pos cube.Pos, a world.BlockAction) { blockPos := protocol.BlockPos{int32(pos[0]), int32(pos[1]), int32(pos[2])} switch t := a.(type) { case block.OpenAction: From c98f0de527f1efec6d88e4e651039f1e7c832072 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 12:09:28 -0400 Subject: [PATCH 25/59] Advertise private override subchunks Immediate view-layer block refresh now resends the chunk height advert before block updates when a private override occupies a subchunk above the public chunk's highest filled subchunk. This lets clients request/load the subchunk before receiving the private block update.\n\nConstraint: Sub-chunk request mode only loads up to the advertised highest subchunk from the chunk send.\nRejected: Sending only UpdateBlock | Updates can be ignored for subchunks the client was never asked to load.\nConfidence: medium\nScope-risk: moderate\nDirective: Keep private override refreshes ordered as subchunk advert before per-block updates when they expand visible chunk height.\nTested: go test ./server/session; go test ./server/...\nNot-tested: Manual client subchunk request flow for high private override in otherwise empty subchunk. --- server/session/view_layer.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/server/session/view_layer.go b/server/session/view_layer.go index dc0b56491..fd05bbf1c 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -92,6 +92,7 @@ func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { return } if b, ok := s.viewLayer.Block(pos); ok { + s.advertisePrivateBlockSubChunk(pos) s.viewBlockUpdate(pos, b, 0) s.viewBlockUpdate(pos, s.br.Air(), 1) return @@ -102,6 +103,28 @@ func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { } } +// advertisePrivateBlockSubChunk resends the chunk height advert if a private block override occupies a +// sub-chunk the client may not have loaded from the public chunk state. +func (s *Session) advertisePrivateBlockSubChunk(pos cube.Pos) { + if !subChunkRequests || s.chunkLoader == nil { + return + } + chunkPos := world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)} + col, ok := s.chunkLoader.Chunk(chunkPos) + if !ok || pos.OutOfBounds(col.Range()) { + return + } + if uint16(col.SubIndex(int16(pos[1]))) <= col.HighestFilledSubChunk() { + return + } + w := s.chunkLoader.World() + if w == nil { + return + } + c, blockEntities := s.applyViewLayerToChunk(chunkPos, col.Chunk, col.BlockEntities) + s.sendNetworkChunk(chunkPos, w.Dimension(), c, blockEntities) +} + // publicLiquid returns the public liquid layer loaded for this session at pos, or air if no liquid is present. func (s *Session) publicLiquid(pos cube.Pos) world.Block { if s.chunkLoader == nil { From 01a098443ada0e4710e46ee40e7c3ea6b02fb153 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 12:24:58 -0400 Subject: [PATCH 26/59] Refresh item-use block after handlers UseItemOnBlock now re-reads the player-visible block after HandleItemUseOnBlock before activation or placement checks. This preserves handler-side world/view-layer changes and avoids mutating against stale state.\n\nConstraint: Handlers may modify world or view-layer block state before default item-use logic continues.\nRejected: Reusing the pre-handler viewed block | It can be stale after event handlers run.\nConfidence: high\nScope-risk: narrow\nDirective: Re-read mutable world/view state after cancellable handlers before applying default mutations.\nTested: go test ./server/player ./server/session; go test ./server/...\nNot-tested: Manual plugin handler that swaps the clicked block during item use. --- server/player/player.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/server/player/player.go b/server/player/player.go index 6398cf6ca..8c61f149b 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1680,6 +1680,11 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec p.resendNearbyBlocks(pos, face) return } + b, private = p.viewedBlock(pos) + if _, ok := b.(block.Air); ok { + p.resendNearbyBlocks(pos, face) + return + } if private { return } From 6cd74acd1a4e7e3e2162b1abeedc3052e282bcea Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 12:27:24 -0400 Subject: [PATCH 27/59] Satisfy item-use lint Avoid carrying an unused private flag from the pre-handler viewed block lookup; the flag is only needed after handlers may have changed visible state.\n\nConstraint: make lint failed on ineffassign for the initial private assignment.\nRejected: Keeping the initial private value | It is intentionally superseded after HandleItemUseOnBlock.\nConfidence: high\nScope-risk: narrow\nDirective: Keep pre-handler lookup limited to values used before the handler.\nTested: make lint; go test ./server/player ./server/session; go test ./server/...\nNot-tested: Manual client interaction flows. --- server/player/player.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 8c61f149b..cdaf317fa 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1668,7 +1668,7 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - b, private := p.viewedBlock(pos) + b, _ := p.viewedBlock(pos) if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that does not exist server-side or one it couldn't reach. Stop trying // to use the item immediately. @@ -1680,7 +1680,7 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec p.resendNearbyBlocks(pos, face) return } - b, private = p.viewedBlock(pos) + b, private := p.viewedBlock(pos) if _, ok := b.(block.Air); ok { p.resendNearbyBlocks(pos, face) return From 7e3eff4581b5de7c1fdf98471aa060ec4790c4e5 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 12:35:23 -0400 Subject: [PATCH 28/59] Avoid redundant registry precompute Restore the existing session constructor pattern for selecting the block registry after initialising the session. NewViewLayer no longer needs the registry, so the temporary local was unnecessary.\n\nConstraint: Review feedback identified the precomputed block registry local as unnecessary.\nRejected: Keeping br in the struct literal | It creates avoidable churn from the existing constructor flow.\nConfidence: high\nScope-risk: narrow\nDirective: Keep registry fallback assignment near other post-construction session setup unless a constructor dependency requires it earlier.\nTested: make lint; go test ./server/session; go test ./server/...\nNot-tested: Custom block registry integration smoke test. --- server/session/session.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/session/session.go b/server/session/session.go index 62389f532..0bfcc7934 100644 --- a/server/session/session.go +++ b/server/session/session.go @@ -178,11 +178,6 @@ func (conf Config) New(conn Conn) *Session { } conf.Log = conf.Log.With("name", conn.IdentityData().DisplayName, "uuid", conn.IdentityData().Identity, "raddr", conn.RemoteAddr().String()) - br := conf.BlockRegistry - if br == nil { - br = world.DefaultBlockRegistry - } - s := &Session{} *s = Session{ openChunkTransactions: make([]map[uint64]struct{}, 0, 8), @@ -205,7 +200,6 @@ func (conf Config) New(conn Conn) *Session { hiddenHud: make(map[hud.Element]struct{}), debugShapes: make(map[int]debug.Shape), debugShapeUpdates: make([]debugShapeUpdate, 0, 256), - br: br, } s.viewLayer = world.NewViewLayer(s) s.openedWindow.Store(inventory.New(1, nil)) @@ -216,6 +210,12 @@ func (conf Config) New(conn Conn) *Session { s.currentScoreboard.Store(&scoreboardName) s.currentLines.Store(&scoreboardLines) + if conf.BlockRegistry == nil { + s.br = world.DefaultBlockRegistry + } else { + s.br = conf.BlockRegistry + } + s.registerHandlers() s.sendBiomes() groups, items := creativeContent(s.br) From 223aae41b79fa191605da19ff56e19794de10078 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 12:42:06 -0400 Subject: [PATCH 29/59] Resend vanished private break target When a private block override disappears while the player is still finishing a private break, resend the public block instead of silently returning. This corrects client prediction without breaking the public block.\n\nConstraint: Private break completion must not mutate the public block if the private override vanished mid-break.\nRejected: Falling through to BreakBlock | It can break the public block for a stale private interaction.\nConfidence: high\nScope-risk: narrow\nDirective: Stale private interactions should resynchronise the client rather than mutate public world state.\nTested: make lint; go test ./server/player ./server/session; go test ./server/...\nNot-tested: Manual client finish packet after plugin removes private override mid-break. --- server/player/player.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/player/player.go b/server/player/player.go index cdaf317fa..73090710d 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1943,6 +1943,8 @@ func (p *Player) FinishBreaking() { if private { if b, ok := p.privateBlock(pos); ok { p.breakBlock(pos, b, true) + } else { + p.resendBreakingBlock(pos, false) } return } From 04ba75cbf50080da0c928f9471ae6b417098e4fa Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 13:42:34 -0400 Subject: [PATCH 30/59] Update player.go --- server/player/player.go | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 73090710d..3b3ed0f84 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1861,17 +1861,20 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { // The block was either out of range or air, so it can't be broken by the player. return } - if _, ok := p.tx.Block(pos.Side(face)).(block.Fire); ok && !private { - ctx := event.C(p) - if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { - // Resend the block because on client side that was extinguished - p.resendNearbyBlocks(pos, face) + firePos := pos.Side(face) + if fireBlock, firePrivate := p.viewedBlock(firePos); !firePrivate { + if _, ok := fireBlock.(block.Fire); ok { + ctx := event.C(p) + if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { + // Resend the block because on client side that was extinguished + p.resendNearbyBlocks(pos, face) + return + } + + p.tx.SetBlock(firePos, nil, nil) + p.tx.PlaySound(pos.Vec3(), sound.FireExtinguish{}) return } - - p.tx.SetBlock(pos.Side(face), nil, nil) - p.tx.PlaySound(pos.Vec3(), sound.FireExtinguish{}) - return } held, _ := p.HeldItems() From 8a2132eba3f684f5b6a93738f97eb8653a95a090 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Thu, 28 May 2026 14:06:14 -0400 Subject: [PATCH 31/59] various changes --- server/player/handler.go | 3 ++- server/player/player.go | 15 ++++++++++++--- server/session/controllable.go | 11 +++++++++++ server/session/handler_inventory_transaction.go | 2 +- server/session/handler_player_auth_input.go | 2 +- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/server/player/handler.go b/server/player/handler.go index 8d3180686..996976205 100644 --- a/server/player/handler.go +++ b/server/player/handler.go @@ -72,7 +72,8 @@ type Handler interface { HandleStartBreak(ctx *Context, pos cube.Pos) // HandleBlockBreak handles a block that is being broken by a player. ctx.Cancel() may be called to cancel // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered to change - // what items will actually be dropped. + // what items will actually be dropped. If the block being broken is a private view-layer block, drops and xp + // start empty and modifications to them are ignored: Private breaks only remove the viewer's override. HandleBlockBreak(ctx *Context, pos cube.Pos, drops *[]item.Stack, xp *int) // HandleBlockPlace handles the player placing a specific block at a position in its world. ctx.Cancel() // may be called to cancel the block being placed. diff --git a/server/player/player.go b/server/player/player.go index 3b3ed0f84..1ec4e87a1 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1686,6 +1686,7 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec return } if private { + p.resendNearbyBlocks(pos, face) return } i, left := p.HeldItems() @@ -1726,6 +1727,7 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec } replacedBlock, replacedPrivate := p.viewedBlock(replacedPos) if replacedPrivate { + p.resendNearbyBlocks(replacedPos) return } if replaceable, ok := replacedBlock.(block.Replaceable); !ok || !replaceable.ReplaceableBy(ib) || replacedPos.OutOfBounds(p.tx.Range()) { @@ -2112,10 +2114,17 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl return obstructed, true } -// BreakBlock makes the player break a block at a position passed. If the player has a private block -// override at that position, it is removed instead of breaking the public world block. If the player is -// unable to reach the block passed, the method returns immediately. +// BreakBlock makes the player break the public world block at the position passed. Private view-layer +// overrides are ignored by this method: Call BreakViewedBlock to break what the player currently sees instead. +// If the player is unable to reach the block passed, the method returns immediately. func (p *Player) BreakBlock(pos cube.Pos) { + p.breakBlock(pos, p.tx.Block(pos), false) +} + +// BreakViewedBlock makes the player break the block currently shown to them at the position passed. If the +// player has a private block override at that position, it is removed instead of breaking the public world block. +// If the player is unable to reach the block passed, the method returns immediately. +func (p *Player) BreakViewedBlock(pos cube.Pos) { b, private := p.breakingBlock(pos) p.breakBlock(pos, b, private) } diff --git a/server/session/controllable.go b/server/session/controllable.go index 8a924bf60..71dce7443 100644 --- a/server/session/controllable.go +++ b/server/session/controllable.go @@ -125,3 +125,14 @@ type Controllable interface { UpdateDiagnostics(Diagnostics) } + +// breakViewedBlock breaks the block currently shown to the controllable when supported. This preserves +// session-driven client semantics for players with private view-layer block overrides while keeping BreakBlock +// as the public-world block break API for generic Controllable implementations. +func breakViewedBlock(c Controllable, pos cube.Pos) { + if breaker, ok := c.(interface{ BreakViewedBlock(cube.Pos) }); ok { + breaker.BreakViewedBlock(pos) + return + } + c.BreakBlock(pos) +} diff --git a/server/session/handler_inventory_transaction.go b/server/session/handler_inventory_transaction.go index 9cb4c3820..328005180 100644 --- a/server/session/handler_inventory_transaction.go +++ b/server/session/handler_inventory_transaction.go @@ -187,7 +187,7 @@ func (h *InventoryTransactionHandler) handleUseItemTransaction(data *protocol.Us switch data.ActionType { case protocol.UseItemActionBreakBlock: - c.BreakBlock(pos) + breakViewedBlock(c, pos) case protocol.UseItemActionClickBlock: c.UseItemOnBlock(pos, cube.Face(data.BlockFace), vec32To64(data.ClickedPosition)) case protocol.UseItemActionClickAir: diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index f75fcba2f..050ec4e9b 100644 --- a/server/session/handler_player_auth_input.go +++ b/server/session/handler_player_auth_input.go @@ -164,7 +164,7 @@ func (h PlayerAuthInputHandler) handleUseItemData(data protocol.UseItemTransacti // Seems like this is only used for breaking blocks at the moment. switch data.ActionType { case protocol.UseItemActionBreakBlock: - c.BreakBlock(pos) + breakViewedBlock(c, pos) default: return fmt.Errorf("unhandled UseItem ActionType for PlayerAuthInput packet %v", data.ActionType) } From 8742c814f1fe6d9da6d7c7524a8109087648edad Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:12:53 -0400 Subject: [PATCH 32/59] perf(world): index view layer block overrides by chunk --- server/session/chunk.go | 5 +--- server/world/view_layer.go | 47 ++++++++++++++++++++++++++++---------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/server/session/chunk.go b/server/session/chunk.go index 8745c3d0e..eabe2af42 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -146,16 +146,13 @@ func (s *Session) applyViewLayerToChunk(pos world.ChunkPos, c *chunk.Chunk, bloc if s.viewLayer == nil { return c, blockEntities } - overrides := s.viewLayer.Blocks() + overrides := s.viewLayer.ChunkBlocks(pos) if len(overrides) == 0 { return c, blockEntities } var cloned bool for blockPos, b := range overrides { - if (world.ChunkPos{int32(blockPos[0] >> 4), int32(blockPos[2] >> 4)}) != pos { - continue - } if blockPos.OutOfBounds(c.Range()) { continue } diff --git a/server/world/view_layer.go b/server/world/view_layer.go index d0d676fef..3b921f5fb 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -33,18 +33,18 @@ type viewLayerViewer interface { // ViewLayer holds overrides for how entities are viewed by a single viewer. It allows entities to be // viewed differently by different players, such as with a different name tag or visibility state. type ViewLayer struct { - mu sync.RWMutex - entities map[*EntityHandle]layer - blocks map[cube.Pos]Block - updater ViewLayerUpdater + mu sync.RWMutex + entities map[*EntityHandle]layer + blocksByChunk map[ChunkPos]map[cube.Pos]Block + updater ViewLayerUpdater } // NewViewLayer returns a new ViewLayer. func NewViewLayer(updater ViewLayerUpdater) *ViewLayer { return &ViewLayer{ - entities: map[*EntityHandle]layer{}, - blocks: map[cube.Pos]Block{}, - updater: updater, + entities: map[*EntityHandle]layer{}, + blocksByChunk: map[ChunkPos]map[cube.Pos]Block{}, + updater: updater, } } @@ -132,10 +132,17 @@ func (v *ViewLayer) Visibility(entity Entity) VisibilityLevel { // Passing nil removes the block override, causing the public block to be viewed again. func (v *ViewLayer) ViewBlock(pos cube.Pos, b Block) { v.mu.Lock() + chunkPos := ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)} if b == nil { - delete(v.blocks, pos) + delete(v.blocksByChunk[chunkPos], pos) + if len(v.blocksByChunk[chunkPos]) == 0 { + delete(v.blocksByChunk, chunkPos) + } } else { - v.blocks[pos] = b + if v.blocksByChunk[chunkPos] == nil { + v.blocksByChunk[chunkPos] = map[cube.Pos]Block{} + } + v.blocksByChunk[chunkPos][pos] = b } v.mu.Unlock() @@ -152,7 +159,7 @@ func (v *ViewLayer) Block(pos cube.Pos) (Block, bool) { v.mu.RLock() defer v.mu.RUnlock() - b, ok := v.blocks[pos] + b, ok := v.blocksByChunk[ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}][pos] return b, ok } @@ -161,7 +168,23 @@ func (v *ViewLayer) Blocks() map[cube.Pos]Block { v.mu.RLock() defer v.mu.RUnlock() - return maps.Clone(v.blocks) + blocks := make(map[cube.Pos]Block) + for _, chunkBlocks := range v.blocksByChunk { + maps.Copy(blocks, chunkBlocks) + } + return blocks +} + +// ChunkBlocks returns all block overrides in a chunk. +func (v *ViewLayer) ChunkBlocks(pos ChunkPos) map[cube.Pos]Block { + v.mu.RLock() + defer v.mu.RUnlock() + + blocks := v.blocksByChunk[pos] + if len(blocks) == 0 { + return nil + } + return maps.Clone(blocks) } // Remove removes all overrides for the entity from the ViewLayer. @@ -207,7 +230,7 @@ func (v *ViewLayer) Close() error { defer v.mu.Unlock() clear(v.entities) - clear(v.blocks) + clear(v.blocksByChunk) return nil } From 42b05eeedeaf8b3eda10bc80a9a451b72c0e8fac Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:20:04 -0400 Subject: [PATCH 33/59] fix(player): handle private block interaction edge cases --- server/player/player.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 1ec4e87a1..bc9fa6652 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1668,19 +1668,23 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - b, _ := p.viewedBlock(pos) + b, private := p.viewedBlock(pos) if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that does not exist server-side or one it couldn't reach. Stop trying // to use the item immediately. p.resendNearbyBlocks(pos, face) return } + if private { + p.resendNearbyBlocks(pos, face) + return + } ctx := event.C(p) if p.Handler().HandleItemUseOnBlock(ctx, pos, face, clickPos); ctx.Cancelled() { p.resendNearbyBlocks(pos, face) return } - b, private := p.viewedBlock(pos) + b, private = p.viewedBlock(pos) if _, ok := b.(block.Air); ok { p.resendNearbyBlocks(pos, face) return @@ -1980,7 +1984,13 @@ func (p *Player) ContinueBreaking(face cube.Face) { private := p.breakingPrivate var b world.Block if private { - b, _ = p.breakingBlock(pos) + var ok bool + b, ok = p.privateBlock(pos) + if !ok { + p.AbortBreaking() + p.resendBreakingBlock(pos, false) + return + } p.ShowParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) } else { b = p.tx.Block(pos) From 06bad5d55e779b0f2eddf5b5cbc1013354ebcf4d Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:25:12 -0400 Subject: [PATCH 34/59] refactor: collapse redundant view layer guards --- server/player/player.go | 15 +++------------ server/session/view_layer.go | 5 +---- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index bc9fa6652..274ceaa4c 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1669,13 +1669,8 @@ func (p *Player) UsingItem() bool { // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { b, private := p.viewedBlock(pos) - if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { - // The client used its item on a block that does not exist server-side or one it couldn't reach. Stop trying - // to use the item immediately. - p.resendNearbyBlocks(pos, face) - return - } - if private { + if _, ok := b.(block.Air); ok || private || !p.canReach(pos.Vec3Centre()) { + // The client used its item on a block that cannot be interacted with. Stop trying to use the item immediately. p.resendNearbyBlocks(pos, face) return } @@ -1685,11 +1680,7 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec return } b, private = p.viewedBlock(pos) - if _, ok := b.(block.Air); ok { - p.resendNearbyBlocks(pos, face) - return - } - if private { + if _, ok := b.(block.Air); ok || private { p.resendNearbyBlocks(pos, face) return } diff --git a/server/session/view_layer.go b/server/session/view_layer.go index fd05bbf1c..805ef4cc2 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -85,10 +85,7 @@ func (s *Session) ViewLayerEntityChanged(e world.Entity) { // ViewLayerBlockChanged refreshes a block override for this session if its chunk is currently visible. func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { - if s.viewLayer == nil { - return - } - if !s.viewingBlock(pos) { + if s.viewLayer == nil || !s.viewingBlock(pos) { return } if b, ok := s.viewLayer.Block(pos); ok { From 4aecd602f106d24c20626245db0f71f71dde16f2 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:35:16 -0400 Subject: [PATCH 35/59] fix(player): extinguish private fire overrides --- server/player/player.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index 274ceaa4c..e64008b0e 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1859,7 +1859,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { return } firePos := pos.Side(face) - if fireBlock, firePrivate := p.viewedBlock(firePos); !firePrivate { + if fireBlock, firePrivate := p.viewedBlock(firePos); fireBlock != nil { if _, ok := fireBlock.(block.Fire); ok { ctx := event.C(p) if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { @@ -1867,6 +1867,11 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.resendNearbyBlocks(pos, face) return } + if firePrivate { + p.ViewPublicBlock(firePos) + p.session().ViewSound(pos.Vec3(), sound.FireExtinguish{}) + return + } p.tx.SetBlock(firePos, nil, nil) p.tx.PlaySound(pos.Vec3(), sound.FireExtinguish{}) From 6f16a0ecf95d8149de9e19d5de7e29a6f5a309d0 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:43:42 -0400 Subject: [PATCH 36/59] fix(player): handle private block item use events --- server/player/player.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index e64008b0e..9d38c8678 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1669,7 +1669,7 @@ func (p *Player) UsingItem() bool { // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { b, private := p.viewedBlock(pos) - if _, ok := b.(block.Air); ok || private || !p.canReach(pos.Vec3Centre()) { + if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that cannot be interacted with. Stop trying to use the item immediately. p.resendNearbyBlocks(pos, face) return From b2ba70c484664f81fdcbce06a433a62601ee15f1 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:54:05 -0400 Subject: [PATCH 37/59] docs(player): document private block break detection --- server/player/handler.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/player/handler.go b/server/player/handler.go index 996976205..6951b200c 100644 --- a/server/player/handler.go +++ b/server/player/handler.go @@ -73,7 +73,8 @@ type Handler interface { // HandleBlockBreak handles a block that is being broken by a player. ctx.Cancel() may be called to cancel // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered to change // what items will actually be dropped. If the block being broken is a private view-layer block, drops and xp - // start empty and modifications to them are ignored: Private breaks only remove the viewer's override. + // start empty and modifications to them are ignored: Private breaks only remove the viewer's override. Use + // ctx.Val().ViewLayer().Block(pos) to check if the block being broken is private. HandleBlockBreak(ctx *Context, pos cube.Pos, drops *[]item.Stack, xp *int) // HandleBlockPlace handles the player placing a specific block at a position in its world. ctx.Cancel() // may be called to cancel the block being placed. From 8c21a0fddac90553bdd77ccf68d6014e9c33d3a0 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 15:57:07 -0400 Subject: [PATCH 38/59] fix(player): avoid unused private block assignment --- server/player/player.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index 9d38c8678..905de7409 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1668,7 +1668,8 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - b, private := p.viewedBlock(pos) + b, _ := p.viewedBlock(pos) + var private bool if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that cannot be interacted with. Stop trying to use the item immediately. p.resendNearbyBlocks(pos, face) From 5b79a3dbe9f643df1cb02d4ac3b17263d299c3d5 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:51:06 -0400 Subject: [PATCH 39/59] push changes --- server/player/player.go | 88 +++++++------------ server/session/controllable.go | 12 +-- .../session/handler_inventory_transaction.go | 2 +- server/session/handler_player_auth_input.go | 2 +- server/world/view_layer.go | 5 +- 5 files changed, 38 insertions(+), 71 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 5e834fe08..12624624c 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1854,30 +1854,29 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() - b, private := p.breakingBlock(pos) + b, private := p.viewedBlock(pos) if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. return } firePos := pos.Side(face) - if fireBlock, firePrivate := p.viewedBlock(firePos); fireBlock != nil { - if _, ok := fireBlock.(block.Fire); ok { - ctx := event.C(p) - if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { - // Resend the block because on client side that was extinguished - p.resendNearbyBlocks(pos, face) - return - } - if firePrivate { - p.ViewPublicBlock(firePos) - p.session().ViewSound(pos.Vec3(), sound.FireExtinguish{}) - return - } - - p.tx.SetBlock(firePos, nil, nil) - p.tx.PlaySound(pos.Vec3(), sound.FireExtinguish{}) + fireBlock, firePrivate := p.viewedBlock(firePos) + if _, ok := fireBlock.(block.Fire); ok { + ctx := event.C(p) + if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { + // Resend the block because on client side that was extinguished + p.resendNearbyBlocks(pos, face) + return + } + if firePrivate { + p.ViewPublicBlock(firePos) + p.session().ViewSound(pos.Vec3(), sound.FireExtinguish{}) return } + + p.tx.SetBlock(firePos, nil, nil) + p.tx.PlaySound(pos.Vec3(), sound.FireExtinguish{}) + return } held, _ := p.HeldItems() @@ -1897,7 +1896,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { punchable.Punch(pos, face, p.tx, p) } - p.breaking, p.breakingFace, p.breakingPrivate = true, face, private + p.breaking, p.breakingFace = true, face p.SwingArm() if p.GameMode().CreativeInventory() { @@ -1947,14 +1946,12 @@ func (p *Player) FinishBreaking() { private := p.breakingPrivate p.AbortBreaking() if private { - if b, ok := p.privateBlock(pos); ok { - p.breakBlock(pos, b, true) - } else { + if _, ok := p.privateBlock(pos); !ok { p.resendBreakingBlock(pos, false) + return } - return } - p.BreakBlock(pos) + p.BreakViewedBlock(pos) } // AbortBreaking makes the player stop breaking the block it is currently breaking, or returns immediately @@ -2019,23 +2016,10 @@ func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.Blo return } for _, viewer := range p.viewers() { - if viewerHasPrivateBlock(viewer, pos) { - continue - } viewer.ViewBlockAction(pos, a) } } -// viewerHasPrivateBlock returns true if the viewer has a private block override at pos. -func viewerHasPrivateBlock(viewer world.Viewer, pos cube.Pos) bool { - s, ok := viewer.(*session.Session) - if !ok || s.ViewLayer() == nil { - return false - } - _, ok = s.ViewLayer().Block(pos) - return ok -} - // viewedBlock returns the block currently shown to the player at pos. func (p *Player) viewedBlock(pos cube.Pos) (world.Block, bool) { if b, ok := p.privateBlock(pos); ok { @@ -2044,11 +2028,6 @@ func (p *Player) viewedBlock(pos cube.Pos) (world.Block, bool) { return p.tx.Block(pos), false } -// breakingBlock returns the block that should be used for the player's breaking progress. -func (p *Player) breakingBlock(pos cube.Pos) (world.Block, bool) { - return p.viewedBlock(pos) -} - // PlaceBlock makes the player place the block passed at the position passed, granted it is within the range // of the player. // An item.UseContext may be passed to obtain information on if the block placement was successful. (SubCount will @@ -2132,7 +2111,7 @@ func (p *Player) BreakBlock(pos cube.Pos) { // player has a private block override at that position, it is removed instead of breaking the public world block. // If the player is unable to reach the block passed, the method returns immediately. func (p *Player) BreakViewedBlock(pos cube.Pos) { - b, private := p.breakingBlock(pos) + b, private := p.viewedBlock(pos) p.breakBlock(pos, b, private) } @@ -2150,7 +2129,8 @@ func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { resendBrokenBlock() return } - if _, breakable := b.(block.Breakable); !breakable && !p.GameMode().CreativeInventory() { + breakable, ok := b.(block.Breakable) + if !ok && !p.GameMode().CreativeInventory() { resendBrokenBlock() return } @@ -2160,7 +2140,7 @@ func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { xp := 0 if !private { drops = p.drops(held, b) - if breakable, ok := b.(block.Breakable); ok && !p.GameMode().CreativeInventory() { + if ok && !p.GameMode().CreativeInventory() { if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { xp = breakable.BreakInfo().XPDrops.RandomValue() } @@ -2179,18 +2159,14 @@ func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { p.ViewPublicBlock(pos) p.ShowParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) return - } else { - p.tx.SetBlock(pos, nil, nil) - p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) - if breakable, ok := b.(block.Breakable); ok { - info := breakable.BreakInfo() - if info.BreakHandler != nil { - info.BreakHandler(pos, p.tx, p) - } - } } - - if _, ok := b.(block.Breakable); ok { + p.tx.SetBlock(pos, nil, nil) + p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + if ok { + info := breakable.BreakInfo() + if info.BreakHandler != nil { + info.BreakHandler(pos, p.tx, p) + } for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { p.tx.AddEntity(orb) } @@ -2250,7 +2226,7 @@ func (p *Player) PickBlock(pos cube.Pos) { return } - b, _ := p.breakingBlock(pos) + b, _ := p.viewedBlock(pos) var pickedItem item.Stack if pi, ok := b.(block.Pickable); ok { diff --git a/server/session/controllable.go b/server/session/controllable.go index 71dce7443..cffa63070 100644 --- a/server/session/controllable.go +++ b/server/session/controllable.go @@ -57,6 +57,7 @@ type Controllable interface { UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) UseItemOnEntity(e world.Entity) bool BreakBlock(pos cube.Pos) + BreakViewedBlock(pos cube.Pos) PickBlock(pos cube.Pos) AttackEntity(e world.Entity) bool Drop(s item.Stack) (n int) @@ -125,14 +126,3 @@ type Controllable interface { UpdateDiagnostics(Diagnostics) } - -// breakViewedBlock breaks the block currently shown to the controllable when supported. This preserves -// session-driven client semantics for players with private view-layer block overrides while keeping BreakBlock -// as the public-world block break API for generic Controllable implementations. -func breakViewedBlock(c Controllable, pos cube.Pos) { - if breaker, ok := c.(interface{ BreakViewedBlock(cube.Pos) }); ok { - breaker.BreakViewedBlock(pos) - return - } - c.BreakBlock(pos) -} diff --git a/server/session/handler_inventory_transaction.go b/server/session/handler_inventory_transaction.go index 328005180..f69d7c277 100644 --- a/server/session/handler_inventory_transaction.go +++ b/server/session/handler_inventory_transaction.go @@ -187,7 +187,7 @@ func (h *InventoryTransactionHandler) handleUseItemTransaction(data *protocol.Us switch data.ActionType { case protocol.UseItemActionBreakBlock: - breakViewedBlock(c, pos) + c.BreakViewedBlock(pos) case protocol.UseItemActionClickBlock: c.UseItemOnBlock(pos, cube.Face(data.BlockFace), vec32To64(data.ClickedPosition)) case protocol.UseItemActionClickAir: diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index 050ec4e9b..23c9fd907 100644 --- a/server/session/handler_player_auth_input.go +++ b/server/session/handler_player_auth_input.go @@ -164,7 +164,7 @@ func (h PlayerAuthInputHandler) handleUseItemData(data protocol.UseItemTransacti // Seems like this is only used for breaking blocks at the moment. switch data.ActionType { case protocol.UseItemActionBreakBlock: - breakViewedBlock(c, pos) + c.BreakViewedBlock(pos) default: return fmt.Errorf("unhandled UseItem ActionType for PlayerAuthInput packet %v", data.ActionType) } diff --git a/server/world/view_layer.go b/server/world/view_layer.go index 3b921f5fb..d2b160fa3 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -128,8 +128,9 @@ func (v *ViewLayer) Visibility(entity Entity) VisibilityLevel { return v.entities[entity.H()].visibility } -// ViewBlock overwrites the public block at the position passed for this ViewLayer. -// Passing nil removes the block override, causing the public block to be viewed again. +// ViewBlock overwrites the public block at the position passed for this ViewLayer. Liquid or waterlogged +// state at layer 1 is not represented for overrides. Passing nil removes the block override, causing the +// public block to be viewed again. func (v *ViewLayer) ViewBlock(pos cube.Pos, b Block) { v.mu.Lock() chunkPos := ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)} From 050d9ccbe8272520225e1267aa2748109c1472b8 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:56:38 -0400 Subject: [PATCH 40/59] Update view_layer.go --- server/session/view_layer.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/session/view_layer.go b/server/session/view_layer.go index 805ef4cc2..a444eb368 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -89,7 +89,7 @@ func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { return } if b, ok := s.viewLayer.Block(pos); ok { - s.advertisePrivateBlockSubChunk(pos) + s.broadcastPrivateBlockSubChunk(pos) s.viewBlockUpdate(pos, b, 0) s.viewBlockUpdate(pos, s.br.Air(), 1) return @@ -100,9 +100,9 @@ func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { } } -// advertisePrivateBlockSubChunk resends the chunk height advert if a private block override occupies a +// broadcastPrivateBlockSubChunk resends the chunk height advert if a private block override occupies a // sub-chunk the client may not have loaded from the public chunk state. -func (s *Session) advertisePrivateBlockSubChunk(pos cube.Pos) { +func (s *Session) broadcastPrivateBlockSubChunk(pos cube.Pos) { if !subChunkRequests || s.chunkLoader == nil { return } From 1402f374863a9be39f2a396e423dc3aa3f77b1e7 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:27:16 -0400 Subject: [PATCH 41/59] Update player.go --- server/player/player.go | 1 - 1 file changed, 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index 12624624c..eff1e0d6d 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1959,7 +1959,6 @@ func (p *Player) FinishBreaking() { // Unlike FinishBreaking, AbortBreaking does not stop the animation. func (p *Player) AbortBreaking() { if !p.breaking { - p.breakingPosValid = false return } private := p.breakingPrivate From 9695f5b2f53b88bc18c72a2570ad9ae4609a6822 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:32:03 -0400 Subject: [PATCH 42/59] Update player.go --- server/player/player.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index eff1e0d6d..d1a0907df 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1870,8 +1870,10 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { } if firePrivate { p.ViewPublicBlock(firePos) - p.session().ViewSound(pos.Vec3(), sound.FireExtinguish{}) - return + if _, ok := p.tx.Block(firePos).(block.Fire); !ok { + p.session().ViewSound(pos.Vec3(), sound.FireExtinguish{}) + return + } } p.tx.SetBlock(firePos, nil, nil) From 5cdb832f044c5e5b17ac3ed02dcae22d1386335e Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:37:20 -0400 Subject: [PATCH 43/59] Update player.go --- server/player/player.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index d1a0907df..2f18ab295 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1681,7 +1681,7 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec return } b, private = p.viewedBlock(pos) - if _, ok := b.(block.Air); ok || private { + if _, ok := b.(block.Air); ok { p.resendNearbyBlocks(pos, face) return } @@ -1948,12 +1948,15 @@ func (p *Player) FinishBreaking() { private := p.breakingPrivate p.AbortBreaking() if private { - if _, ok := p.privateBlock(pos); !ok { + b, ok := p.privateBlock(pos) + if !ok { p.resendBreakingBlock(pos, false) return } + p.breakBlock(pos, b, true) + return } - p.BreakViewedBlock(pos) + p.BreakBlock(pos) } // AbortBreaking makes the player stop breaking the block it is currently breaking, or returns immediately From 91c23b15d7340db5cb9c32849293f0f1cac32ff0 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:41:16 -0400 Subject: [PATCH 44/59] Update player.go --- server/player/player.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 2f18ab295..16a7cc80f 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1669,7 +1669,6 @@ func (p *Player) UsingItem() bool { // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { b, _ := p.viewedBlock(pos) - var private bool if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that cannot be interacted with. Stop trying to use the item immediately. p.resendNearbyBlocks(pos, face) @@ -1680,7 +1679,7 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec p.resendNearbyBlocks(pos, face) return } - b, private = p.viewedBlock(pos) + b, _ = p.viewedBlock(pos) if _, ok := b.(block.Air); ok { p.resendNearbyBlocks(pos, face) return From ae34ed322ec6ab300e246cfffd67b50c2d065ee6 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:41:32 -0400 Subject: [PATCH 45/59] Update player.go --- server/player/player.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index 16a7cc80f..ffd122e24 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1853,7 +1853,7 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() - b, private := p.viewedBlock(pos) + b, _ = p.viewedBlock(pos) if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. return From b93daaa4657ec6b745a54a75b1dbc01de7e30a3a Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:41:36 -0400 Subject: [PATCH 46/59] Update player.go --- server/player/player.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index ffd122e24..425f96433 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1853,7 +1853,7 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() - b, _ = p.viewedBlock(pos) + b, _ := p.viewedBlock(pos) if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. return From fe383e0118fd32c45028f073c4c3d72555a36454 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:41:48 -0400 Subject: [PATCH 47/59] Update player.go --- server/player/player.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index 425f96433..13a771a32 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1668,7 +1668,7 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - b, _ := p.viewedBlock(pos) + b, private := p.viewedBlock(pos) if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that cannot be interacted with. Stop trying to use the item immediately. p.resendNearbyBlocks(pos, face) From e3e7cec9f494d8a43f31260ac993fd753c76cb69 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:42:14 -0400 Subject: [PATCH 48/59] Update player.go --- server/player/player.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 13a771a32..16a7cc80f 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1668,7 +1668,7 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - b, private := p.viewedBlock(pos) + b, _ := p.viewedBlock(pos) if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that cannot be interacted with. Stop trying to use the item immediately. p.resendNearbyBlocks(pos, face) @@ -1853,7 +1853,7 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() - b, _ := p.viewedBlock(pos) + b, private := p.viewedBlock(pos) if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. return From d735af18a970d6a2760c45fffc0a88359ec6ba14 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:50:14 -0400 Subject: [PATCH 49/59] test(player): guard view-layer block breaking --- .github/workflows/pr.yml | 3 + .github/workflows/push.yml | 3 + Makefile | 3 + server/player/player.go | 9 +- server/player/player_view_layer_test.go | 111 ++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 server/player/player_view_layer_test.go diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b9f1cf130..2dae4541e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,3 +17,6 @@ jobs: - name: Lint run: make lint + + - name: Test + run: go test ./... diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 2107ea1ff..c0955b250 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -18,6 +18,9 @@ jobs: - name: Lint run: make lint + - name: Test + run: go test ./... + deploy: name: Deploy needs: build diff --git a/Makefile b/Makefile index 30cf54b3d..938fa8c2c 100644 --- a/Makefile +++ b/Makefile @@ -2,3 +2,6 @@ lint: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run ./... + +tests: + go test ./... diff --git a/server/player/player.go b/server/player/player.go index 16a7cc80f..072adb82a 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2125,16 +2125,13 @@ func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { // Don't do anything if the position broken is already air. return } - resendBrokenBlock := func() { - p.resendBreakingBlock(pos, private) - } if !p.canReach(pos.Vec3Centre()) || !p.GameMode().AllowsEditing() { - resendBrokenBlock() + p.resendBreakingBlock(pos, private) return } breakable, ok := b.(block.Breakable) if !ok && !p.GameMode().CreativeInventory() { - resendBrokenBlock() + p.resendBreakingBlock(pos, private) return } held, _ := p.HeldItems() @@ -2152,7 +2149,7 @@ func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { ctx := event.C(p) if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { - resendBrokenBlock() + p.resendBreakingBlock(pos, private) return } held, left := p.HeldItems() diff --git a/server/player/player_view_layer_test.go b/server/player/player_view_layer_test.go new file mode 100644 index 000000000..796980345 --- /dev/null +++ b/server/player/player_view_layer_test.go @@ -0,0 +1,111 @@ +package player + +import ( + "context" + "net" + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/session" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "github.com/sandertv/gophertunnel/minecraft" + "github.com/sandertv/gophertunnel/minecraft/protocol/login" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +func TestBreakViewedBlockRemovesPrivateOverrideWithoutMutatingWorld(t *testing.T) { + withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { + pos := cube.Pos{0, 64, 0} + tx.SetBlock(pos, block.Dirt{}, nil) + p.ViewBlock(pos, block.Stone{}) + + p.BreakViewedBlock(pos) + + if _, ok := p.ViewLayer().Block(pos); ok { + t.Fatal("expected private override to be removed") + } + if _, ok := tx.Block(pos).(block.Dirt); !ok { + t.Fatalf("expected public block to remain dirt, got %#v", tx.Block(pos)) + } + }) +} + +func TestBreakBlockIgnoresPrivateOverrideAndBreaksPublicBlock(t *testing.T) { + withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { + pos := cube.Pos{0, 64, 0} + tx.SetBlock(pos, block.Dirt{}, nil) + p.ViewBlock(pos, block.Stone{}) + + p.BreakBlock(pos) + + if _, ok := p.ViewLayer().Block(pos); !ok { + t.Fatal("expected private override to remain") + } + if _, ok := tx.Block(pos).(block.Air); !ok { + t.Fatalf("expected public block to be broken, got %#v", tx.Block(pos)) + } + }) +} + +func TestFinishBreakingUsesStartedBreakMode(t *testing.T) { + withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { + pos := cube.Pos{0, 64, 0} + tx.SetBlock(pos, block.Dirt{}, nil) + + p.StartBreaking(pos, cube.FaceUp) + p.ViewBlock(pos, block.Stone{}) + p.FinishBreaking() + + if _, ok := p.ViewLayer().Block(pos); !ok { + t.Fatal("expected private override added after StartBreaking to remain") + } + if _, ok := tx.Block(pos).(block.Air); !ok { + t.Fatalf("expected originally public break to remove public block, got %#v", tx.Block(pos)) + } + }) +} + +func withViewLayerTestPlayer(t *testing.T, f func(*Player, *world.Tx)) { + t.Helper() + + s := session.Config{MaxChunkRadius: 1}.New(fakeConn{}) + w := world.New() + + <-w.Exec(func(worldTx *world.Tx) { + data := &world.EntityData{} + conf := Config{ + Session: s, + GameMode: world.GameModeCreative, + Position: mgl64.Vec3{0.5, 64, 0.5}, + } + conf.Apply(data) + f(&Player{ + tx: worldTx, + handle: world.NewEntity(Type, conf), + data: data, + playerData: data.Data.(*playerData), + }, worldTx) + }) +} + +type fakeConn struct{} + +func (fakeConn) Close() error { return nil } +func (fakeConn) IdentityData() login.IdentityData { return login.IdentityData{DisplayName: "test"} } +func (fakeConn) ClientData() login.ClientData { return login.ClientData{} } +func (fakeConn) ClientCacheEnabled() bool { return false } +func (fakeConn) ChunkRadius() int { return 1 } +func (fakeConn) Latency() time.Duration { return 0 } +func (fakeConn) Flush() error { return nil } +func (fakeConn) RemoteAddr() net.Addr { return fakeAddr("test") } +func (fakeConn) ReadPacket() (packet.Packet, error) { return nil, net.ErrClosed } +func (fakeConn) WritePacket(packet.Packet) error { return nil } +func (fakeConn) StartGameContext(context.Context, minecraft.GameData) error { return nil } + +type fakeAddr string + +func (a fakeAddr) Network() string { return string(a) } +func (a fakeAddr) String() string { return string(a) } From 09ad9ab3fc06c320fd1be1b05d5eedeb6319745c Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:51:51 -0400 Subject: [PATCH 50/59] fix(player): block private item use world mutation --- server/player/player.go | 22 +++++++++++++--------- server/player/player_view_layer_test.go | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 072adb82a..68c072081 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1668,7 +1668,7 @@ func (p *Player) UsingItem() bool { // returns immediately. // UseItemOnBlock does nothing if the block at the cube.Pos passed is of the type block.Air. func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) { - b, _ := p.viewedBlock(pos) + b, _ := p.visibleBlock(pos) if _, ok := b.(block.Air); ok || !p.canReach(pos.Vec3Centre()) { // The client used its item on a block that cannot be interacted with. Stop trying to use the item immediately. p.resendNearbyBlocks(pos, face) @@ -1679,11 +1679,15 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec p.resendNearbyBlocks(pos, face) return } - b, _ = p.viewedBlock(pos) + b, private := p.visibleBlock(pos) if _, ok := b.(block.Air); ok { p.resendNearbyBlocks(pos, face) return } + if private { + p.resendBreakingBlock(pos, true) + return + } i, left := p.HeldItems() if act, ok := b.(block.Activatable); ok { // If a player is sneaking, it will not activate the block clicked, unless it is not holding any @@ -1720,7 +1724,7 @@ func (p *Player) UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec // The block clicked was either not replaceable, or not replaceable using the block passed. replacedPos = pos.Side(face) } - replacedBlock, replacedPrivate := p.viewedBlock(replacedPos) + replacedBlock, replacedPrivate := p.visibleBlock(replacedPos) if replacedPrivate { p.resendNearbyBlocks(replacedPos) return @@ -1853,13 +1857,13 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() - b, private := p.viewedBlock(pos) + b, private := p.visibleBlock(pos) if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. return } firePos := pos.Side(face) - fireBlock, firePrivate := p.viewedBlock(firePos) + fireBlock, firePrivate := p.visibleBlock(firePos) if _, ok := fireBlock.(block.Fire); ok { ctx := event.C(p) if p.Handler().HandleFireExtinguish(ctx, pos); ctx.Cancelled() { @@ -2023,8 +2027,8 @@ func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.Blo } } -// viewedBlock returns the block currently shown to the player at pos. -func (p *Player) viewedBlock(pos cube.Pos) (world.Block, bool) { +// visibleBlock returns the block currently shown to the player at pos. +func (p *Player) visibleBlock(pos cube.Pos) (world.Block, bool) { if b, ok := p.privateBlock(pos); ok { return b, true } @@ -2114,7 +2118,7 @@ func (p *Player) BreakBlock(pos cube.Pos) { // player has a private block override at that position, it is removed instead of breaking the public world block. // If the player is unable to reach the block passed, the method returns immediately. func (p *Player) BreakViewedBlock(pos cube.Pos) { - b, private := p.viewedBlock(pos) + b, private := p.visibleBlock(pos) p.breakBlock(pos, b, private) } @@ -2226,7 +2230,7 @@ func (p *Player) PickBlock(pos cube.Pos) { return } - b, _ := p.viewedBlock(pos) + b, _ := p.visibleBlock(pos) var pickedItem item.Stack if pi, ok := b.(block.Pickable); ok { diff --git a/server/player/player_view_layer_test.go b/server/player/player_view_layer_test.go index 796980345..6b70edf43 100644 --- a/server/player/player_view_layer_test.go +++ b/server/player/player_view_layer_test.go @@ -68,6 +68,23 @@ func TestFinishBreakingUsesStartedBreakMode(t *testing.T) { }) } +func TestUseItemOnPrivateBlockDoesNotMutatePublicWorld(t *testing.T) { + withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { + pos := cube.Pos{0, 64, 0} + tx.SetBlock(pos, block.Stone{}, nil) + p.ViewBlock(pos, block.Lever{Facing: cube.FaceUp, Direction: cube.North}) + + p.UseItemOnBlock(pos, cube.FaceUp, mgl64.Vec3{}) + + if _, ok := tx.Block(pos).(block.Stone); !ok { + t.Fatalf("expected public block to remain stone, got %#v", tx.Block(pos)) + } + if _, ok := p.ViewLayer().Block(pos); !ok { + t.Fatal("expected private override to remain") + } + }) +} + func withViewLayerTestPlayer(t *testing.T, f func(*Player, *world.Tx)) { t.Helper() From 28fed505e2db71f8c321cc56e90f930ed8f282d0 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:57:54 -0400 Subject: [PATCH 51/59] test(player): use require assertions --- go.mod | 4 +++ go.sum | 8 ++++++ server/player/player_view_layer_test.go | 37 +++++++++---------------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index 9afcbf709..edc1f7e87 100644 --- a/go.mod +++ b/go.mod @@ -20,15 +20,19 @@ require ( require ( github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/df-mc/go-playfab v1.0.0 // indirect github.com/df-mc/go-xsapi v1.0.1 // indirect github.com/df-mc/jsonc v1.0.5 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/klauspost/compress v1.18.4 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217 // indirect + github.com/stretchr/testify v1.11.1 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sync v0.12.0 // indirect gopkg.in/yaml.v2 v2.3.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 17f22d869..6efe29a2c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/df-mc/go-playfab v1.0.0 h1:6gVukk3aQbJ934GJFdcZJHVIw9lhauK+KHOevbwJA10= github.com/df-mc/go-playfab v1.0.0/go.mod h1:nGOlE+JFGOH5Z0iidEgJapHhndFi/oNk17RN9pKCF+k= github.com/df-mc/go-xsapi v1.0.1 h1:H1SbxYr4rXOqZSB8MwiODbDUsHRihxbHf+YOljUWgXw= @@ -39,12 +41,16 @@ github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217 h1:UZQq2253Q+7co/C9Et62RYPBggzz+L+2yqGlvQhSNM8= github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217/go.mod h1:/yysjwfCXm2+2OY8mBazLzcxJ3irnylKCyG3FLgUPVU= github.com/sandertv/gophertunnel v1.56.2 h1:eFc58AkMQo43ntR0Wmvz8GRFSdOgABKVDn52GMbIYag= github.com/sandertv/gophertunnel v1.56.2/go.mod h1:F8+ZPbzxJ0LqunXEaDjqeyUgHVB0rI5ZU+PHnptXGfI= github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 h1:9kj3STMvgqy3YA4VQXBrN7925ICMxD5wzMRcgA30588= golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= @@ -73,3 +79,5 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWD gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/server/player/player_view_layer_test.go b/server/player/player_view_layer_test.go index 6b70edf43..8e86982c9 100644 --- a/server/player/player_view_layer_test.go +++ b/server/player/player_view_layer_test.go @@ -14,6 +14,7 @@ import ( "github.com/sandertv/gophertunnel/minecraft" "github.com/sandertv/gophertunnel/minecraft/protocol/login" "github.com/sandertv/gophertunnel/minecraft/protocol/packet" + "github.com/stretchr/testify/require" ) func TestBreakViewedBlockRemovesPrivateOverrideWithoutMutatingWorld(t *testing.T) { @@ -24,12 +25,9 @@ func TestBreakViewedBlockRemovesPrivateOverrideWithoutMutatingWorld(t *testing.T p.BreakViewedBlock(pos) - if _, ok := p.ViewLayer().Block(pos); ok { - t.Fatal("expected private override to be removed") - } - if _, ok := tx.Block(pos).(block.Dirt); !ok { - t.Fatalf("expected public block to remain dirt, got %#v", tx.Block(pos)) - } + _, ok := p.ViewLayer().Block(pos) + require.False(t, ok, "expected private override to be removed") + require.IsType(t, block.Dirt{}, tx.Block(pos)) }) } @@ -41,12 +39,9 @@ func TestBreakBlockIgnoresPrivateOverrideAndBreaksPublicBlock(t *testing.T) { p.BreakBlock(pos) - if _, ok := p.ViewLayer().Block(pos); !ok { - t.Fatal("expected private override to remain") - } - if _, ok := tx.Block(pos).(block.Air); !ok { - t.Fatalf("expected public block to be broken, got %#v", tx.Block(pos)) - } + _, ok := p.ViewLayer().Block(pos) + require.True(t, ok, "expected private override to remain") + require.IsType(t, block.Air{}, tx.Block(pos)) }) } @@ -59,12 +54,9 @@ func TestFinishBreakingUsesStartedBreakMode(t *testing.T) { p.ViewBlock(pos, block.Stone{}) p.FinishBreaking() - if _, ok := p.ViewLayer().Block(pos); !ok { - t.Fatal("expected private override added after StartBreaking to remain") - } - if _, ok := tx.Block(pos).(block.Air); !ok { - t.Fatalf("expected originally public break to remove public block, got %#v", tx.Block(pos)) - } + _, ok := p.ViewLayer().Block(pos) + require.True(t, ok, "expected private override added after StartBreaking to remain") + require.IsType(t, block.Air{}, tx.Block(pos)) }) } @@ -76,12 +68,9 @@ func TestUseItemOnPrivateBlockDoesNotMutatePublicWorld(t *testing.T) { p.UseItemOnBlock(pos, cube.FaceUp, mgl64.Vec3{}) - if _, ok := tx.Block(pos).(block.Stone); !ok { - t.Fatalf("expected public block to remain stone, got %#v", tx.Block(pos)) - } - if _, ok := p.ViewLayer().Block(pos); !ok { - t.Fatal("expected private override to remain") - } + require.IsType(t, block.Stone{}, tx.Block(pos)) + _, ok := p.ViewLayer().Block(pos) + require.True(t, ok, "expected private override to remain") }) } From faeb7f17775a1331a9d73384d5da3e58e40b8d9f Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:01:10 -0400 Subject: [PATCH 52/59] test(player): table drive view-layer interactions --- server/player/player_view_layer_test.go | 116 +++++++++++++----------- 1 file changed, 61 insertions(+), 55 deletions(-) diff --git a/server/player/player_view_layer_test.go b/server/player/player_view_layer_test.go index 8e86982c9..0390d163c 100644 --- a/server/player/player_view_layer_test.go +++ b/server/player/player_view_layer_test.go @@ -17,61 +17,67 @@ import ( "github.com/stretchr/testify/require" ) -func TestBreakViewedBlockRemovesPrivateOverrideWithoutMutatingWorld(t *testing.T) { - withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { - pos := cube.Pos{0, 64, 0} - tx.SetBlock(pos, block.Dirt{}, nil) - p.ViewBlock(pos, block.Stone{}) - - p.BreakViewedBlock(pos) - - _, ok := p.ViewLayer().Block(pos) - require.False(t, ok, "expected private override to be removed") - require.IsType(t, block.Dirt{}, tx.Block(pos)) - }) -} - -func TestBreakBlockIgnoresPrivateOverrideAndBreaksPublicBlock(t *testing.T) { - withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { - pos := cube.Pos{0, 64, 0} - tx.SetBlock(pos, block.Dirt{}, nil) - p.ViewBlock(pos, block.Stone{}) - - p.BreakBlock(pos) - - _, ok := p.ViewLayer().Block(pos) - require.True(t, ok, "expected private override to remain") - require.IsType(t, block.Air{}, tx.Block(pos)) - }) -} - -func TestFinishBreakingUsesStartedBreakMode(t *testing.T) { - withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { - pos := cube.Pos{0, 64, 0} - tx.SetBlock(pos, block.Dirt{}, nil) - - p.StartBreaking(pos, cube.FaceUp) - p.ViewBlock(pos, block.Stone{}) - p.FinishBreaking() - - _, ok := p.ViewLayer().Block(pos) - require.True(t, ok, "expected private override added after StartBreaking to remain") - require.IsType(t, block.Air{}, tx.Block(pos)) - }) -} - -func TestUseItemOnPrivateBlockDoesNotMutatePublicWorld(t *testing.T) { - withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { - pos := cube.Pos{0, 64, 0} - tx.SetBlock(pos, block.Stone{}, nil) - p.ViewBlock(pos, block.Lever{Facing: cube.FaceUp, Direction: cube.North}) - - p.UseItemOnBlock(pos, cube.FaceUp, mgl64.Vec3{}) - - require.IsType(t, block.Stone{}, tx.Block(pos)) - _, ok := p.ViewLayer().Block(pos) - require.True(t, ok, "expected private override to remain") - }) +func TestViewLayerBlockInteractions(t *testing.T) { + tests := []struct { + name string + publicBlock world.Block + privateBlock world.Block + action func(*Player, cube.Pos) + expectedPublicBlock world.Block + expectPrivateBlock bool + }{ + { + name: "break viewed block removes private override without mutating world", + publicBlock: block.Dirt{}, + privateBlock: block.Stone{}, + action: func(p *Player, pos cube.Pos) { p.BreakViewedBlock(pos) }, + expectedPublicBlock: block.Dirt{}, + }, + { + name: "break block ignores private override and breaks public block", + publicBlock: block.Dirt{}, + privateBlock: block.Stone{}, + action: func(p *Player, pos cube.Pos) { p.BreakBlock(pos) }, + expectedPublicBlock: block.Air{}, + expectPrivateBlock: true, + }, + { + name: "finish breaking uses started break mode", + publicBlock: block.Dirt{}, + action: func(p *Player, pos cube.Pos) { + p.StartBreaking(pos, cube.FaceUp) + p.ViewBlock(pos, block.Stone{}) + p.FinishBreaking() + }, + expectedPublicBlock: block.Air{}, + expectPrivateBlock: true, + }, + { + name: "use item on private block does not mutate public world", + publicBlock: block.Stone{}, + privateBlock: block.Lever{Facing: cube.FaceUp, Direction: cube.North}, + action: func(p *Player, pos cube.Pos) { p.UseItemOnBlock(pos, cube.FaceUp, mgl64.Vec3{}) }, + expectedPublicBlock: block.Stone{}, + expectPrivateBlock: true, + }, + } + for i, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { + pos := cube.Pos{i, 64, 0} + tx.SetBlock(pos, tt.publicBlock, nil) + if tt.privateBlock != nil { + p.ViewBlock(pos, tt.privateBlock) + } + + tt.action(p, pos) + + require.IsType(t, tt.expectedPublicBlock, tx.Block(pos)) + _, ok := p.ViewLayer().Block(pos) + require.Equal(t, tt.expectPrivateBlock, ok) + }) + }) + } } func withViewLayerTestPlayer(t *testing.T, f func(*Player, *world.Tx)) { From 12ab081a2b2828606506d2fda2c4f79552e95957 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:04:42 -0400 Subject: [PATCH 53/59] test(player): assert expected private block --- server/player/player_view_layer_test.go | 49 ++++++++++++++----------- 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/server/player/player_view_layer_test.go b/server/player/player_view_layer_test.go index 0390d163c..4d751f398 100644 --- a/server/player/player_view_layer_test.go +++ b/server/player/player_view_layer_test.go @@ -19,12 +19,12 @@ import ( func TestViewLayerBlockInteractions(t *testing.T) { tests := []struct { - name string - publicBlock world.Block - privateBlock world.Block - action func(*Player, cube.Pos) - expectedPublicBlock world.Block - expectPrivateBlock bool + name string + publicBlock world.Block + privateBlock world.Block + action func(*Player, cube.Pos) + expectedPublicBlock world.Block + expectedPrivateBlock world.Block }{ { name: "break viewed block removes private override without mutating world", @@ -34,12 +34,12 @@ func TestViewLayerBlockInteractions(t *testing.T) { expectedPublicBlock: block.Dirt{}, }, { - name: "break block ignores private override and breaks public block", - publicBlock: block.Dirt{}, - privateBlock: block.Stone{}, - action: func(p *Player, pos cube.Pos) { p.BreakBlock(pos) }, - expectedPublicBlock: block.Air{}, - expectPrivateBlock: true, + name: "break block ignores private override and breaks public block", + publicBlock: block.Dirt{}, + privateBlock: block.Stone{}, + action: func(p *Player, pos cube.Pos) { p.BreakBlock(pos) }, + expectedPublicBlock: block.Air{}, + expectedPrivateBlock: block.Stone{}, }, { name: "finish breaking uses started break mode", @@ -49,16 +49,16 @@ func TestViewLayerBlockInteractions(t *testing.T) { p.ViewBlock(pos, block.Stone{}) p.FinishBreaking() }, - expectedPublicBlock: block.Air{}, - expectPrivateBlock: true, + expectedPublicBlock: block.Air{}, + expectedPrivateBlock: block.Stone{}, }, { - name: "use item on private block does not mutate public world", - publicBlock: block.Stone{}, - privateBlock: block.Lever{Facing: cube.FaceUp, Direction: cube.North}, - action: func(p *Player, pos cube.Pos) { p.UseItemOnBlock(pos, cube.FaceUp, mgl64.Vec3{}) }, - expectedPublicBlock: block.Stone{}, - expectPrivateBlock: true, + name: "use item on private block does not mutate public world", + publicBlock: block.Stone{}, + privateBlock: block.Lever{Facing: cube.FaceUp, Direction: cube.North}, + action: func(p *Player, pos cube.Pos) { p.UseItemOnBlock(pos, cube.FaceUp, mgl64.Vec3{}) }, + expectedPublicBlock: block.Stone{}, + expectedPrivateBlock: block.Lever{}, }, } for i, tt := range tests { @@ -73,8 +73,13 @@ func TestViewLayerBlockInteractions(t *testing.T) { tt.action(p, pos) require.IsType(t, tt.expectedPublicBlock, tx.Block(pos)) - _, ok := p.ViewLayer().Block(pos) - require.Equal(t, tt.expectPrivateBlock, ok) + privateBlock, ok := p.ViewLayer().Block(pos) + if tt.expectedPrivateBlock == nil { + require.False(t, ok) + return + } + require.True(t, ok) + require.IsType(t, tt.expectedPrivateBlock, privateBlock) }) }) } From 5d0a5cd05927dfbd1753d66040dc05788822631c Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:06:22 -0400 Subject: [PATCH 54/59] test(player): clean up view-layer test helper --- server/player/player_view_layer_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/player/player_view_layer_test.go b/server/player/player_view_layer_test.go index 4d751f398..1f6dae7e9 100644 --- a/server/player/player_view_layer_test.go +++ b/server/player/player_view_layer_test.go @@ -90,6 +90,10 @@ func withViewLayerTestPlayer(t *testing.T, f func(*Player, *world.Tx)) { s := session.Config{MaxChunkRadius: 1}.New(fakeConn{}) w := world.New() + defer func() { + _ = w.Close() + s.CloseConnection() + }() <-w.Exec(func(worldTx *world.Tx) { data := &world.EntityData{} From 2f7c3927da4b7fac7afa7fcc99c7d807a7a92a47 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:17:16 -0400 Subject: [PATCH 55/59] refactor(player): centralize view-layer block handling --- server/player/player.go | 211 +++-------------- server/player/player_view_layer_test.go | 4 +- server/player/view_layer_block.go | 216 ++++++++++++++++++ server/session/chunk.go | 10 +- server/session/controllable.go | 2 +- .../session/handler_inventory_transaction.go | 2 +- server/session/handler_player_auth_input.go | 2 +- server/session/view_layer.go | 32 ++- server/session/world.go | 6 +- server/world/view_layer.go | 7 +- 10 files changed, 273 insertions(+), 219 deletions(-) create mode 100644 server/player/view_layer_block.go diff --git a/server/player/player.go b/server/player/player.go index 68c072081..d96d70b72 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -96,10 +96,8 @@ type playerData struct { collidedVertically, collidedHorizontally bool breaking bool - breakingPos cube.Pos + blockBreakTarget *blockBreakTarget breakingFace cube.Face - breakingPrivate bool - breakingPosValid bool lastBreakDuration time.Duration breakCounter uint32 @@ -1857,6 +1855,7 @@ func (p *Player) AttackEntity(e world.Entity) bool { // player might be breaking before this method is called. func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { p.AbortBreaking() + p.blockBreakTarget = nil b, private := p.visibleBlock(pos) if _, air := b.(block.Air); air || !p.canReach(pos.Vec3Centre()) { // The block was either out of range or air, so it can't be broken by the player. @@ -1873,14 +1872,11 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { } if firePrivate { p.ViewPublicBlock(firePos) - if _, ok := p.tx.Block(firePos).(block.Fire); !ok { - p.session().ViewSound(pos.Vec3(), sound.FireExtinguish{}) - return - } + p.blockAudience(true).PlaySound(pos.Vec3(), sound.FireExtinguish{}) + return } - p.tx.SetBlock(firePos, nil, nil) - p.tx.PlaySound(pos.Vec3(), sound.FireExtinguish{}) + p.blockAudience(false).PlaySound(pos.Vec3(), sound.FireExtinguish{}) return } @@ -1891,7 +1887,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { } // Note: We intentionally store this regardless of whether the breaking proceeds, so that we // can resend the block to the client when it tries to break the block regardless. - p.breakingPos, p.breakingPrivate, p.breakingPosValid = pos, private, true + p.blockBreakTarget = &blockBreakTarget{pos: pos, block: b, private: private} ctx := event.C(p) if p.Handler().HandleStartBreak(ctx, pos); ctx.Cancelled() { @@ -1908,7 +1904,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { return } p.lastBreakDuration = p.breakTime(b) - p.viewBreakingBlockAction(pos, private, block.StartCrackAction{BreakTime: p.lastBreakDuration}) + p.blockAudience(private).ViewBlockAction(pos, block.StartCrackAction{BreakTime: p.lastBreakDuration}) } // breakTime returns the time needed to break a block at the position passed, taking into account the item @@ -1941,25 +1937,18 @@ func (p *Player) breakTime(b world.Block) time.Duration { // FinishBreaking will stop the animation and break the block. func (p *Player) FinishBreaking() { if !p.breaking { - if p.breakingPosValid { - p.resendBreakingBlock(p.breakingPos, p.breakingPrivate) - p.breakingPosValid = false + if target := p.blockBreakTarget; target != nil { + p.blockAudience(target.private).Resend(target.pos) + p.blockBreakTarget = nil } return } - pos := p.breakingPos - private := p.breakingPrivate + target := p.blockBreakTarget p.AbortBreaking() - if private { - b, ok := p.privateBlock(pos) - if !ok { - p.resendBreakingBlock(pos, false) - return - } - p.breakBlock(pos, b, true) + if target == nil { return } - p.BreakBlock(pos) + p.breakTarget(*target) } // AbortBreaking makes the player stop breaking the block it is currently breaking, or returns immediately @@ -1969,72 +1958,44 @@ func (p *Player) AbortBreaking() { if !p.breaking { return } - private := p.breakingPrivate - p.breaking, p.breakingPrivate, p.breakingPosValid, p.breakCounter = false, false, false, 0 - p.viewBreakingBlockAction(p.breakingPos, private, block.StopCrackAction{}) + target := p.blockBreakTarget + p.breaking, p.blockBreakTarget, p.breakCounter = false, nil, 0 + if target != nil { + p.blockAudience(target.private).ViewBlockAction(target.pos, block.StopCrackAction{}) + } } // ContinueBreaking makes the player continue breaking the block it started breaking after a call to // Player.StartBreaking(). // The face passed is used to display particles on the side of the block broken. func (p *Player) ContinueBreaking(face cube.Face) { - if !p.breaking { + target := p.blockBreakTarget + if !p.breaking || target == nil { return } - pos := p.breakingPos - private := p.breakingPrivate - var b world.Block - if private { - var ok bool - b, ok = p.privateBlock(pos) - if !ok { + if target.private { + if _, ok := p.privateBlock(target.pos); !ok { p.AbortBreaking() - p.resendBreakingBlock(pos, false) + p.blockAudience(false).Resend(target.pos) return } - p.ShowParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) - } else { - b = p.tx.Block(pos) - p.tx.AddParticle(pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) } + audience := p.blockAudience(target.private) + audience.AddParticle(target.pos.Vec3(), particle.PunchBlock{Block: target.block, Face: face}) if p.breakCounter++; p.breakCounter%5 == 0 { p.SwingArm() // We send this sound only every so often. Vanilla doesn't send it every tick while breaking // either. Every 5 ticks seems accurate. - if private { - p.session().ViewSound(pos.Vec3(), sound.BlockBreaking{Block: b}) - } else { - p.tx.PlaySound(pos.Vec3(), sound.BlockBreaking{Block: b}) - } + audience.PlaySound(target.pos.Vec3(), sound.BlockBreaking{Block: target.block}) } - if breakTime := p.breakTime(b); breakTime != p.lastBreakDuration { - p.viewBreakingBlockAction(pos, private, block.ContinueCrackAction{BreakTime: breakTime}) + if breakTime := p.breakTime(target.block); breakTime != p.lastBreakDuration { + audience.ViewBlockAction(target.pos, block.ContinueCrackAction{BreakTime: breakTime}) p.lastBreakDuration = breakTime } } -// viewBreakingBlockAction shows a breaking action to the player only if the block is a private view-layer -// block, or to all viewers if it is a public world block. -func (p *Player) viewBreakingBlockAction(pos cube.Pos, private bool, a world.BlockAction) { - if private { - p.session().ViewPrivateBlockAction(pos, a) - return - } - for _, viewer := range p.viewers() { - viewer.ViewBlockAction(pos, a) - } -} - -// visibleBlock returns the block currently shown to the player at pos. -func (p *Player) visibleBlock(pos cube.Pos) (world.Block, bool) { - if b, ok := p.privateBlock(pos); ok { - return b, true - } - return p.tx.Block(pos), false -} - // PlaceBlock makes the player place the block passed at the position passed, granted it is within the range // of the player. // An item.UseContext may be passed to obtain information on if the block placement was successful. (SubCount will @@ -2107,122 +2068,6 @@ func (p *Player) obstructedPos(pos cube.Pos, b world.Block) (obstructed, selfOnl return obstructed, true } -// BreakBlock makes the player break the public world block at the position passed. Private view-layer -// overrides are ignored by this method: Call BreakViewedBlock to break what the player currently sees instead. -// If the player is unable to reach the block passed, the method returns immediately. -func (p *Player) BreakBlock(pos cube.Pos) { - p.breakBlock(pos, p.tx.Block(pos), false) -} - -// BreakViewedBlock makes the player break the block currently shown to them at the position passed. If the -// player has a private block override at that position, it is removed instead of breaking the public world block. -// If the player is unable to reach the block passed, the method returns immediately. -func (p *Player) BreakViewedBlock(pos cube.Pos) { - b, private := p.visibleBlock(pos) - p.breakBlock(pos, b, private) -} - -// breakBlock makes the player break the block passed at the position passed. Private blocks are removed -// from the player's view layer instead of the world. -func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { - if _, air := b.(block.Air); air { - // Don't do anything if the position broken is already air. - return - } - if !p.canReach(pos.Vec3Centre()) || !p.GameMode().AllowsEditing() { - p.resendBreakingBlock(pos, private) - return - } - breakable, ok := b.(block.Breakable) - if !ok && !p.GameMode().CreativeInventory() { - p.resendBreakingBlock(pos, private) - return - } - held, _ := p.HeldItems() - var drops []item.Stack - - xp := 0 - if !private { - drops = p.drops(held, b) - if ok && !p.GameMode().CreativeInventory() { - if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { - xp = breakable.BreakInfo().XPDrops.RandomValue() - } - } - } - - ctx := event.C(p) - if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { - p.resendBreakingBlock(pos, private) - return - } - held, left := p.HeldItems() - - p.SwingArm() - if private { - p.ViewPublicBlock(pos) - p.ShowParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) - return - } - p.tx.SetBlock(pos, nil, nil) - p.tx.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) - if ok { - info := breakable.BreakInfo() - if info.BreakHandler != nil { - info.BreakHandler(pos, p.tx, p) - } - for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { - p.tx.AddEntity(orb) - } - } - for _, drop := range drops { - opts := world.EntitySpawnOpts{Position: pos.Vec3Centre(), Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} - p.tx.AddEntity(entity.NewItem(opts, drop)) - } - - p.Exhaust(0.005) - if block.BreaksInstantly(b, held) { - return - } - if durable, ok := held.Item().(item.Durable); ok { - p.SetHeldItems(p.damageItem(held, durable.DurabilityInfo().BreakDurability), left) - } -} - -// drops returns the drops that the player can get from the block passed using the item held. -func (p *Player) drops(held item.Stack, b world.Block) []item.Stack { - t, ok := held.Item().(item.Tool) - if !ok { - t = item.ToolNone{} - } - var drops []item.Stack - if breakable, ok := b.(block.Breakable); ok && !p.GameMode().CreativeInventory() { - if breakable.BreakInfo().Harvestable(t) { - drops = breakable.BreakInfo().Drops(t, held.Enchantments()) - } - } else if it, ok := b.(world.Item); ok && !p.GameMode().CreativeInventory() { - drops = []item.Stack{item.NewStack(it, 1)} - } - return drops -} - -// privateBlock returns this player's view-layer block override at pos, if present. -func (p *Player) privateBlock(pos cube.Pos) (world.Block, bool) { - if p.session() == session.Nop { - return nil, false - } - return p.ViewLayer().Block(pos) -} - -// resendBreakingBlock resends the block being broken without overwriting private view-layer overrides. -func (p *Player) resendBreakingBlock(pos cube.Pos, private bool) { - if private { - p.session().ViewLayerBlockChanged(pos) - return - } - p.resendNearbyBlocks(pos) -} - // PickBlock makes the player pick a block in the world at a position passed. If the player is unable to // pick the block, the method returns immediately. func (p *Player) PickBlock(pos cube.Pos) { diff --git a/server/player/player_view_layer_test.go b/server/player/player_view_layer_test.go index 1f6dae7e9..2a6b65560 100644 --- a/server/player/player_view_layer_test.go +++ b/server/player/player_view_layer_test.go @@ -27,10 +27,10 @@ func TestViewLayerBlockInteractions(t *testing.T) { expectedPrivateBlock world.Block }{ { - name: "break viewed block removes private override without mutating world", + name: "break visible block removes private override without mutating world", publicBlock: block.Dirt{}, privateBlock: block.Stone{}, - action: func(p *Player, pos cube.Pos) { p.BreakViewedBlock(pos) }, + action: func(p *Player, pos cube.Pos) { p.BreakVisibleBlock(pos) }, expectedPublicBlock: block.Dirt{}, }, { diff --git a/server/player/view_layer_block.go b/server/player/view_layer_block.go new file mode 100644 index 000000000..829470df2 --- /dev/null +++ b/server/player/view_layer_block.go @@ -0,0 +1,216 @@ +package player + +import ( + "math/rand/v2" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/event" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/enchantment" + "github.com/df-mc/dragonfly/server/session" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/particle" + "github.com/go-gl/mathgl/mgl64" +) + +type blockBreakTarget struct { + pos cube.Pos + block world.Block + private bool +} + +// visibleBlock returns the block currently shown to the player at pos. +func (p *Player) visibleBlock(pos cube.Pos) (world.Block, bool) { + if b, ok := p.privateBlock(pos); ok { + return b, true + } + return p.tx.Block(pos), false +} + +type blockAudience interface { + PlaySound(pos mgl64.Vec3, s world.Sound) + AddParticle(pos mgl64.Vec3, particle world.Particle) + ViewBlockAction(pos cube.Pos, action world.BlockAction) + Resend(pos cube.Pos) + ClearOverride(pos cube.Pos) +} + +func (p *Player) blockAudience(private bool) blockAudience { + if private { + return privateBlockAudience{p: p} + } + return publicBlockAudience{p: p} +} + +type privateBlockAudience struct { + p *Player +} + +func (a privateBlockAudience) PlaySound(pos mgl64.Vec3, s world.Sound) { + a.p.session().ViewSound(pos, s) +} + +func (a privateBlockAudience) AddParticle(pos mgl64.Vec3, particle world.Particle) { + a.p.ShowParticle(pos, particle) +} + +func (a privateBlockAudience) ViewBlockAction(pos cube.Pos, action world.BlockAction) { + a.p.session().ViewPrivateBlockAction(pos, action) +} + +func (a privateBlockAudience) Resend(pos cube.Pos) { + a.p.session().ViewLayerBlockChanged(pos) +} + +func (a privateBlockAudience) ClearOverride(pos cube.Pos) { + a.p.ViewPublicBlock(pos) +} + +type publicBlockAudience struct { + p *Player +} + +func (a publicBlockAudience) PlaySound(pos mgl64.Vec3, s world.Sound) { + a.p.tx.PlaySound(pos, s) +} + +func (a publicBlockAudience) AddParticle(pos mgl64.Vec3, particle world.Particle) { + a.p.tx.AddParticle(pos, particle) +} + +func (a publicBlockAudience) ViewBlockAction(pos cube.Pos, action world.BlockAction) { + for _, viewer := range a.p.viewers() { + viewer.ViewBlockAction(pos, action) + } +} + +func (a publicBlockAudience) Resend(pos cube.Pos) { + a.p.resendNearbyBlocks(pos) +} + +func (a publicBlockAudience) ClearOverride(cube.Pos) {} + +// BreakBlock makes the player break the public world block at the position passed. Private view-layer +// overrides are ignored by this method: Call BreakVisibleBlock to break what the player currently sees instead. +// If the player is unable to reach the block passed, the method returns immediately. +func (p *Player) BreakBlock(pos cube.Pos) { + p.breakBlock(pos, p.tx.Block(pos), false) +} + +// BreakVisibleBlock makes the player break the block currently shown to them at the position passed. If the +// player has a private block override at that position, it is removed instead of breaking the public world block. +// If the player is unable to reach the block passed, the method returns immediately. +func (p *Player) BreakVisibleBlock(pos cube.Pos) { + b, private := p.visibleBlock(pos) + p.breakBlock(pos, b, private) +} + +func (p *Player) breakTarget(target blockBreakTarget) { + if target.private { + if _, ok := p.privateBlock(target.pos); !ok { + p.blockAudience(false).Resend(target.pos) + return + } + } + p.breakBlock(target.pos, target.block, target.private) +} + +// breakBlock makes the player break the block passed at the position passed. Private blocks are removed +// from the player's view layer instead of the world. +func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { + audience := p.blockAudience(private) + if _, air := b.(block.Air); air { + // Don't do anything if the position broken is already air. + return + } + if !p.canReach(pos.Vec3Centre()) || !p.GameMode().AllowsEditing() { + audience.Resend(pos) + return + } + breakable, ok := b.(block.Breakable) + if !ok && !p.GameMode().CreativeInventory() { + audience.Resend(pos) + return + } + held, _ := p.HeldItems() + var drops []item.Stack + + xp := 0 + if !private { + drops = p.drops(held, b) + if ok && !p.GameMode().CreativeInventory() { + if _, hasSilkTouch := held.Enchantment(enchantment.SilkTouch); !hasSilkTouch { + xp = breakable.BreakInfo().XPDrops.RandomValue() + } + } + } + + ctx := event.C(p) + if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { + audience.Resend(pos) + return + } + held, left := p.HeldItems() + + p.SwingArm() + if private { + audience.ClearOverride(pos) + audience.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + return + } + p.tx.SetBlock(pos, nil, nil) + audience.AddParticle(pos.Vec3Centre(), particle.BlockBreak{Block: b}) + if ok { + info := breakable.BreakInfo() + if info.BreakHandler != nil { + info.BreakHandler(pos, p.tx, p) + } + for _, orb := range entity.NewExperienceOrbs(pos.Vec3Centre(), xp) { + p.tx.AddEntity(orb) + } + } + for _, drop := range drops { + opts := world.EntitySpawnOpts{Position: pos.Vec3Centre(), Velocity: mgl64.Vec3{rand.Float64()*0.2 - 0.1, 0.2, rand.Float64()*0.2 - 0.1}} + p.tx.AddEntity(entity.NewItem(opts, drop)) + } + + p.Exhaust(0.005) + if block.BreaksInstantly(b, held) { + return + } + if durable, ok := held.Item().(item.Durable); ok { + p.SetHeldItems(p.damageItem(held, durable.DurabilityInfo().BreakDurability), left) + } +} + +// drops returns the drops that the player can get from the block passed using the item held. +func (p *Player) drops(held item.Stack, b world.Block) []item.Stack { + t, ok := held.Item().(item.Tool) + if !ok { + t = item.ToolNone{} + } + var drops []item.Stack + if breakable, ok := b.(block.Breakable); ok && !p.GameMode().CreativeInventory() { + if breakable.BreakInfo().Harvestable(t) { + drops = breakable.BreakInfo().Drops(t, held.Enchantments()) + } + } else if it, ok := b.(world.Item); ok && !p.GameMode().CreativeInventory() { + drops = []item.Stack{item.NewStack(it, 1)} + } + return drops +} + +// privateBlock returns this player's view-layer block override at pos, if present. +func (p *Player) privateBlock(pos cube.Pos) (world.Block, bool) { + if p.session() == session.Nop { + return nil, false + } + return p.ViewLayer().Block(pos) +} + +// resendBreakingBlock resends the block being broken without overwriting private view-layer overrides. +func (p *Player) resendBreakingBlock(pos cube.Pos, private bool) { + p.blockAudience(private).Resend(pos) +} diff --git a/server/session/chunk.go b/server/session/chunk.go index eabe2af42..6d577c051 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -33,7 +33,7 @@ func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.Sub entries := make([]protocol.SubChunkEntry, 0, len(offsets)) transaction := make(map[uint64]struct{}) - viewedChunks := make(map[world.ChunkPos]struct { + visibleChunks := make(map[world.ChunkPos]struct { chunk *chunk.Chunk blockEntities map[cube.Pos]world.Block }) @@ -52,12 +52,12 @@ func (s *Session) ViewSubChunks(centre world.SubChunkPos, offsets []protocol.Sub entries = append(entries, protocol.SubChunkEntry{Result: protocol.SubChunkResultChunkNotFound, Offset: offset}) continue } - viewed, ok := viewedChunks[chunkPos] + visible, ok := visibleChunks[chunkPos] if !ok { - viewed.chunk, viewed.blockEntities = s.applyViewLayerToChunk(chunkPos, col.Chunk, col.BlockEntities) - viewedChunks[chunkPos] = viewed + visible.chunk, visible.blockEntities = s.applyViewLayerToChunk(chunkPos, col.Chunk, col.BlockEntities) + visibleChunks[chunkPos] = visible } - entries = append(entries, s.subChunkEntry(offset, ind, viewed.chunk, viewed.blockEntities, transaction)) + entries = append(entries, s.subChunkEntry(offset, ind, visible.chunk, visible.blockEntities, transaction)) } if s.conn.ClientCacheEnabled() && len(transaction) > 0 { s.blobMu.Lock() diff --git a/server/session/controllable.go b/server/session/controllable.go index cffa63070..2c8df1396 100644 --- a/server/session/controllable.go +++ b/server/session/controllable.go @@ -57,7 +57,7 @@ type Controllable interface { UseItemOnBlock(pos cube.Pos, face cube.Face, clickPos mgl64.Vec3) UseItemOnEntity(e world.Entity) bool BreakBlock(pos cube.Pos) - BreakViewedBlock(pos cube.Pos) + BreakVisibleBlock(pos cube.Pos) PickBlock(pos cube.Pos) AttackEntity(e world.Entity) bool Drop(s item.Stack) (n int) diff --git a/server/session/handler_inventory_transaction.go b/server/session/handler_inventory_transaction.go index f69d7c277..34e9291a0 100644 --- a/server/session/handler_inventory_transaction.go +++ b/server/session/handler_inventory_transaction.go @@ -187,7 +187,7 @@ func (h *InventoryTransactionHandler) handleUseItemTransaction(data *protocol.Us switch data.ActionType { case protocol.UseItemActionBreakBlock: - c.BreakViewedBlock(pos) + c.BreakVisibleBlock(pos) case protocol.UseItemActionClickBlock: c.UseItemOnBlock(pos, cube.Face(data.BlockFace), vec32To64(data.ClickedPosition)) case protocol.UseItemActionClickAir: diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index 23c9fd907..f8f7fd07c 100644 --- a/server/session/handler_player_auth_input.go +++ b/server/session/handler_player_auth_input.go @@ -164,7 +164,7 @@ func (h PlayerAuthInputHandler) handleUseItemData(data protocol.UseItemTransacti // Seems like this is only used for breaking blocks at the moment. switch data.ActionType { case protocol.UseItemActionBreakBlock: - c.BreakViewedBlock(pos) + c.BreakVisibleBlock(pos) default: return fmt.Errorf("unhandled UseItem ActionType for PlayerAuthInput packet %v", data.ActionType) } diff --git a/server/session/view_layer.go b/server/session/view_layer.go index a444eb368..0cea97bc4 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -85,7 +85,10 @@ func (s *Session) ViewLayerEntityChanged(e world.Entity) { // ViewLayerBlockChanged refreshes a block override for this session if its chunk is currently visible. func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { - if s.viewLayer == nil || !s.viewingBlock(pos) { + if s.viewLayer == nil { + return + } + if _, ok := s.loadedColumnAt(pos); !ok { return } if b, ok := s.viewLayer.Block(pos); ok { @@ -107,8 +110,8 @@ func (s *Session) broadcastPrivateBlockSubChunk(pos cube.Pos) { return } chunkPos := world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)} - col, ok := s.chunkLoader.Chunk(chunkPos) - if !ok || pos.OutOfBounds(col.Range()) { + col, ok := s.loadedColumnAt(pos) + if !ok { return } if uint16(col.SubIndex(int16(pos[1]))) <= col.HighestFilledSubChunk() { @@ -124,11 +127,8 @@ func (s *Session) broadcastPrivateBlockSubChunk(pos cube.Pos) { // publicLiquid returns the public liquid layer loaded for this session at pos, or air if no liquid is present. func (s *Session) publicLiquid(pos cube.Pos) world.Block { - if s.chunkLoader == nil { - return s.br.Air() - } - col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) - if !ok || pos.OutOfBounds(col.Range()) { + col, ok := s.loadedColumnAt(pos) + if !ok { return s.br.Air() } return s.br.BlockByRuntimeIDOrAir(col.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 1)) @@ -142,25 +142,21 @@ func (s *Session) viewingEntity(handle *world.EntityHandle) bool { return ok } -// viewingBlock returns true if the block position is loaded for this session. -func (s *Session) viewingBlock(pos cube.Pos) bool { +func (s *Session) loadedColumnAt(pos cube.Pos) (*world.Column, bool) { if s.chunkLoader == nil { - return false + return nil, false } col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) if !ok { - return false + return nil, false } - return !pos.OutOfBounds(col.Range()) + return col, !pos.OutOfBounds(col.Range()) } // publicBlock returns the public block loaded for this session at pos. func (s *Session) publicBlock(pos cube.Pos) (world.Block, bool) { - if s.chunkLoader == nil { - return nil, false - } - col, ok := s.chunkLoader.Chunk(world.ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}) - if !ok || pos.OutOfBounds(col.Range()) { + col, ok := s.loadedColumnAt(pos) + if !ok { return nil, false } if b, ok := col.BlockEntities[pos]; ok { diff --git a/server/session/world.go b/server/session/world.go index 89a874ee1..74966b802 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -961,9 +961,9 @@ func (s *Session) ViewBrewingUpdate(prevBrewTime, brewTime time.Duration, prevFu // ViewBlockUpdate ... func (s *Session) ViewBlockUpdate(pos cube.Pos, b world.Block, layer int) { if s.viewLayer != nil { - if viewed, ok := s.viewLayer.Block(pos); ok { + if visible, ok := s.viewLayer.Block(pos); ok { if layer == 0 { - b = viewed + b = visible } else { b = s.br.Air() } @@ -1091,7 +1091,7 @@ func (s *Session) ViewEntityState(e world.Entity) { }) } -// entityMetadata returns the metadata of an entity as viewed by the session, including any overrides +// entityMetadata returns the metadata of an entity as visible by the session, including any overrides // applied through its ViewLayer. func (s *Session) entityMetadata(e world.Entity) protocol.EntityMetadata { metadata := s.parseEntityMetadata(e) diff --git a/server/world/view_layer.go b/server/world/view_layer.go index d2b160fa3..83b055e4c 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -19,9 +19,6 @@ type layer struct { type ViewLayerUpdater interface { // ViewLayerEntityChanged handles an entity whose view-layer overrides changed. ViewLayerEntityChanged(entity Entity) -} - -type viewLayerBlockUpdater interface { // ViewLayerBlockChanged handles a block whose view-layer override changed. ViewLayerBlockChanged(pos cube.Pos) } @@ -247,7 +244,7 @@ func (v *ViewLayer) refresh(entity Entity) { } func (v *ViewLayer) refreshBlock(pos cube.Pos) { - if updater, ok := v.updater.(viewLayerBlockUpdater); ok { - updater.ViewLayerBlockChanged(pos) + if v.updater != nil { + v.updater.ViewLayerBlockChanged(pos) } } From 3ace8770e302d8cb115761fdc3d4248ddd3ae483 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:23:33 -0400 Subject: [PATCH 56/59] refactor(player): isolate visible block handling --- .../{player_view_layer_test.go => view_layer_block_test.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename server/player/{player_view_layer_test.go => view_layer_block_test.go} (100%) diff --git a/server/player/player_view_layer_test.go b/server/player/view_layer_block_test.go similarity index 100% rename from server/player/player_view_layer_test.go rename to server/player/view_layer_block_test.go From d5a42f4638acb9d3cc17e3434e3bd699cb641656 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:46:51 -0400 Subject: [PATCH 57/59] fix(player): re-read block when finishing break --- server/player/view_layer_block.go | 7 +++-- server/player/view_layer_block_test.go | 43 ++++++++++++++++++++------ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/server/player/view_layer_block.go b/server/player/view_layer_block.go index 829470df2..138d5f22a 100644 --- a/server/player/view_layer_block.go +++ b/server/player/view_layer_block.go @@ -109,12 +109,15 @@ func (p *Player) BreakVisibleBlock(pos cube.Pos) { func (p *Player) breakTarget(target blockBreakTarget) { if target.private { - if _, ok := p.privateBlock(target.pos); !ok { + b, ok := p.privateBlock(target.pos) + if !ok { p.blockAudience(false).Resend(target.pos) return } + p.breakBlock(target.pos, b, true) + return } - p.breakBlock(target.pos, target.block, target.private) + p.BreakBlock(target.pos) } // breakBlock makes the player break the block passed at the position passed. Private blocks are removed diff --git a/server/player/view_layer_block_test.go b/server/player/view_layer_block_test.go index 2a6b65560..5d1c25313 100644 --- a/server/player/view_layer_block_test.go +++ b/server/player/view_layer_block_test.go @@ -8,6 +8,7 @@ import ( "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/session" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" @@ -22,7 +23,7 @@ func TestViewLayerBlockInteractions(t *testing.T) { name string publicBlock world.Block privateBlock world.Block - action func(*Player, cube.Pos) + action func(*testing.T, *Player, *world.Tx, cube.Pos) expectedPublicBlock world.Block expectedPrivateBlock world.Block }{ @@ -30,21 +31,21 @@ func TestViewLayerBlockInteractions(t *testing.T) { name: "break visible block removes private override without mutating world", publicBlock: block.Dirt{}, privateBlock: block.Stone{}, - action: func(p *Player, pos cube.Pos) { p.BreakVisibleBlock(pos) }, + action: func(_ *testing.T, p *Player, _ *world.Tx, pos cube.Pos) { p.BreakVisibleBlock(pos) }, expectedPublicBlock: block.Dirt{}, }, { name: "break block ignores private override and breaks public block", publicBlock: block.Dirt{}, privateBlock: block.Stone{}, - action: func(p *Player, pos cube.Pos) { p.BreakBlock(pos) }, + action: func(_ *testing.T, p *Player, _ *world.Tx, pos cube.Pos) { p.BreakBlock(pos) }, expectedPublicBlock: block.Air{}, expectedPrivateBlock: block.Stone{}, }, { name: "finish breaking uses started break mode", publicBlock: block.Dirt{}, - action: func(p *Player, pos cube.Pos) { + action: func(_ *testing.T, p *Player, _ *world.Tx, pos cube.Pos) { p.StartBreaking(pos, cube.FaceUp) p.ViewBlock(pos, block.Stone{}) p.FinishBreaking() @@ -53,10 +54,25 @@ func TestViewLayerBlockInteractions(t *testing.T) { expectedPrivateBlock: block.Stone{}, }, { - name: "use item on private block does not mutate public world", - publicBlock: block.Stone{}, - privateBlock: block.Lever{Facing: cube.FaceUp, Direction: cube.North}, - action: func(p *Player, pos cube.Pos) { p.UseItemOnBlock(pos, cube.FaceUp, mgl64.Vec3{}) }, + name: "finish breaking re-reads public block before break", + publicBlock: block.Dirt{}, + action: func(t *testing.T, p *Player, tx *world.Tx, pos cube.Pos) { + h := &blockBreakTestHandler{} + p.Handle(h) + p.StartBreaking(pos, cube.FaceUp) + tx.SetBlock(pos, nil, nil) + p.FinishBreaking() + require.False(t, h.blockBreakCalled) + }, + expectedPublicBlock: block.Air{}, + }, + { + name: "use item on private block does not mutate public world", + publicBlock: block.Stone{}, + privateBlock: block.Lever{Facing: cube.FaceUp, Direction: cube.North}, + action: func(_ *testing.T, p *Player, _ *world.Tx, pos cube.Pos) { + p.UseItemOnBlock(pos, cube.FaceUp, mgl64.Vec3{}) + }, expectedPublicBlock: block.Stone{}, expectedPrivateBlock: block.Lever{}, }, @@ -70,7 +86,7 @@ func TestViewLayerBlockInteractions(t *testing.T) { p.ViewBlock(pos, tt.privateBlock) } - tt.action(p, pos) + tt.action(t, p, tx, pos) require.IsType(t, tt.expectedPublicBlock, tx.Block(pos)) privateBlock, ok := p.ViewLayer().Block(pos) @@ -112,6 +128,15 @@ func withViewLayerTestPlayer(t *testing.T, f func(*Player, *world.Tx)) { }) } +type blockBreakTestHandler struct { + NopHandler + blockBreakCalled bool +} + +func (h *blockBreakTestHandler) HandleBlockBreak(*Context, cube.Pos, *[]item.Stack, *int) { + h.blockBreakCalled = true +} + type fakeConn struct{} func (fakeConn) Close() error { return nil } From d37f3abf94b97d9ca564a01edcd65178528ca1e8 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:45:45 -0400 Subject: [PATCH 58/59] fix(view-layer)!: scope block overrides by world Block overrides are now keyed by world while entity overrides remain session-wide. HandleBlockBreak receives the private break mode directly. BREAKING CHANGE: HandleBlockBreak now receives a private bool argument, and ViewLayer block override methods take a *world.World. --- .../{view_layer_block.go => block_break.go} | 22 ++++++- ...ayer_block_test.go => block_break_test.go} | 23 ++++++- server/player/handler.go | 7 +-- server/player/player.go | 8 ++- server/session/chunk.go | 2 +- server/session/view_layer.go | 21 +++++-- server/session/world.go | 4 +- server/world/view_layer.go | 63 +++++++++++-------- server/world/view_layer_test.go | 39 ++++++++++++ 9 files changed, 143 insertions(+), 46 deletions(-) rename server/player/{view_layer_block.go => block_break.go} (81%) rename server/player/{view_layer_block_test.go => block_break_test.go} (88%) create mode 100644 server/world/view_layer_test.go diff --git a/server/player/view_layer_block.go b/server/player/block_break.go similarity index 81% rename from server/player/view_layer_block.go rename to server/player/block_break.go index 138d5f22a..44381b587 100644 --- a/server/player/view_layer_block.go +++ b/server/player/block_break.go @@ -15,6 +15,7 @@ import ( "github.com/go-gl/mathgl/mgl64" ) +// blockBreakTarget holds the block and break mode that a player started breaking. type blockBreakTarget struct { pos cube.Pos block world.Block @@ -29,6 +30,7 @@ func (p *Player) visibleBlock(pos cube.Pos) (world.Block, bool) { return p.tx.Block(pos), false } +// blockAudience handles block breaking side effects for either public world blocks or private view-layer blocks. type blockAudience interface { PlaySound(pos mgl64.Vec3, s world.Sound) AddParticle(pos mgl64.Vec3, particle world.Particle) @@ -37,6 +39,7 @@ type blockAudience interface { ClearOverride(pos cube.Pos) } +// blockAudience returns the audience to use for the break mode passed. func (p *Player) blockAudience(private bool) blockAudience { if private { return privateBlockAudience{p: p} @@ -44,52 +47,64 @@ func (p *Player) blockAudience(private bool) blockAudience { return publicBlockAudience{p: p} } +// privateBlockAudience handles block breaking side effects for private view-layer blocks. type privateBlockAudience struct { p *Player } +// PlaySound plays the sound only to the player breaking the private block. func (a privateBlockAudience) PlaySound(pos mgl64.Vec3, s world.Sound) { a.p.session().ViewSound(pos, s) } +// AddParticle shows the particle only to the player breaking the private block. func (a privateBlockAudience) AddParticle(pos mgl64.Vec3, particle world.Particle) { a.p.ShowParticle(pos, particle) } +// ViewBlockAction shows the block action only to the player breaking the private block. func (a privateBlockAudience) ViewBlockAction(pos cube.Pos, action world.BlockAction) { a.p.session().ViewPrivateBlockAction(pos, action) } +// Resend resends the private block override to the player. func (a privateBlockAudience) Resend(pos cube.Pos) { - a.p.session().ViewLayerBlockChanged(pos) + a.p.session().ViewLayerBlockChanged(a.p.tx.World(), pos) } +// ClearOverride removes the private block override for the player. func (a privateBlockAudience) ClearOverride(pos cube.Pos) { a.p.ViewPublicBlock(pos) } +// publicBlockAudience handles block breaking side effects for public world blocks. type publicBlockAudience struct { p *Player } +// PlaySound plays the sound to all players viewing the public block. func (a publicBlockAudience) PlaySound(pos mgl64.Vec3, s world.Sound) { a.p.tx.PlaySound(pos, s) } +// AddParticle adds the particle to all players viewing the public block. func (a publicBlockAudience) AddParticle(pos mgl64.Vec3, particle world.Particle) { a.p.tx.AddParticle(pos, particle) } +// ViewBlockAction shows the block action to all players viewing the public block. func (a publicBlockAudience) ViewBlockAction(pos cube.Pos, action world.BlockAction) { for _, viewer := range a.p.viewers() { viewer.ViewBlockAction(pos, action) } } +// Resend resends the public block to the player. func (a publicBlockAudience) Resend(pos cube.Pos) { a.p.resendNearbyBlocks(pos) } +// ClearOverride does nothing for public blocks. func (a publicBlockAudience) ClearOverride(cube.Pos) {} // BreakBlock makes the player break the public world block at the position passed. Private view-layer @@ -107,6 +122,7 @@ func (p *Player) BreakVisibleBlock(pos cube.Pos) { p.breakBlock(pos, b, private) } +// breakTarget breaks the target using the same mode as when breaking started. func (p *Player) breakTarget(target blockBreakTarget) { if target.private { b, ok := p.privateBlock(target.pos) @@ -151,7 +167,7 @@ func (p *Player) breakBlock(pos cube.Pos, b world.Block, private bool) { } ctx := event.C(p) - if p.Handler().HandleBlockBreak(ctx, pos, &drops, &xp); ctx.Cancelled() { + if p.Handler().HandleBlockBreak(ctx, pos, private, &drops, &xp); ctx.Cancelled() { audience.Resend(pos) return } @@ -210,7 +226,7 @@ func (p *Player) privateBlock(pos cube.Pos) (world.Block, bool) { if p.session() == session.Nop { return nil, false } - return p.ViewLayer().Block(pos) + return p.ViewLayer().Block(p.tx.World(), pos) } // resendBreakingBlock resends the block being broken without overwriting private view-layer overrides. diff --git a/server/player/view_layer_block_test.go b/server/player/block_break_test.go similarity index 88% rename from server/player/view_layer_block_test.go rename to server/player/block_break_test.go index 5d1c25313..4cf6a587b 100644 --- a/server/player/view_layer_block_test.go +++ b/server/player/block_break_test.go @@ -89,7 +89,7 @@ func TestViewLayerBlockInteractions(t *testing.T) { tt.action(t, p, tx, pos) require.IsType(t, tt.expectedPublicBlock, tx.Block(pos)) - privateBlock, ok := p.ViewLayer().Block(pos) + privateBlock, ok := p.ViewLayer().Block(tx.World(), pos) if tt.expectedPrivateBlock == nil { require.False(t, ok) return @@ -101,6 +101,23 @@ func TestViewLayerBlockInteractions(t *testing.T) { } } +func TestHandleBlockBreakReceivesPrivateBreakMode(t *testing.T) { + withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { + pos := cube.Pos{0, 64, 0} + tx.SetBlock(pos, block.Dirt{}, nil) + p.ViewBlock(pos, block.Stone{}) + + h := &blockBreakTestHandler{} + p.Handle(h) + + p.BreakVisibleBlock(pos) + p.ViewBlock(pos, block.Stone{}) + p.BreakBlock(pos) + + require.Equal(t, []bool{true, false}, h.private) + }) +} + func withViewLayerTestPlayer(t *testing.T, f func(*Player, *world.Tx)) { t.Helper() @@ -131,10 +148,12 @@ func withViewLayerTestPlayer(t *testing.T, f func(*Player, *world.Tx)) { type blockBreakTestHandler struct { NopHandler blockBreakCalled bool + private []bool } -func (h *blockBreakTestHandler) HandleBlockBreak(*Context, cube.Pos, *[]item.Stack, *int) { +func (h *blockBreakTestHandler) HandleBlockBreak(_ *Context, _ cube.Pos, private bool, _ *[]item.Stack, _ *int) { h.blockBreakCalled = true + h.private = append(h.private, private) } type fakeConn struct{} diff --git a/server/player/handler.go b/server/player/handler.go index 6951b200c..014ebf353 100644 --- a/server/player/handler.go +++ b/server/player/handler.go @@ -73,9 +73,8 @@ type Handler interface { // HandleBlockBreak handles a block that is being broken by a player. ctx.Cancel() may be called to cancel // the block being broken. A pointer to a slice of the block's drops is passed, and may be altered to change // what items will actually be dropped. If the block being broken is a private view-layer block, drops and xp - // start empty and modifications to them are ignored: Private breaks only remove the viewer's override. Use - // ctx.Val().ViewLayer().Block(pos) to check if the block being broken is private. - HandleBlockBreak(ctx *Context, pos cube.Pos, drops *[]item.Stack, xp *int) + // start empty and modifications to them are ignored: Private breaks only remove the viewer's override. + HandleBlockBreak(ctx *Context, pos cube.Pos, private bool, drops *[]item.Stack, xp *int) // HandleBlockPlace handles the player placing a specific block at a position in its world. ctx.Cancel() // may be called to cancel the block being placed. HandleBlockPlace(ctx *Context, pos cube.Pos, b world.Block) @@ -179,7 +178,7 @@ func (NopHandler) HandleChat(*Context, *string) func (NopHandler) HandleSkinChange(*Context, *skin.Skin) {} func (NopHandler) HandleFireExtinguish(*Context, cube.Pos) {} func (NopHandler) HandleStartBreak(*Context, cube.Pos) {} -func (NopHandler) HandleBlockBreak(*Context, cube.Pos, *[]item.Stack, *int) {} +func (NopHandler) HandleBlockBreak(*Context, cube.Pos, bool, *[]item.Stack, *int) {} func (NopHandler) HandleBlockPlace(*Context, cube.Pos, world.Block) {} func (NopHandler) HandleBlockPick(*Context, cube.Pos, world.Block) {} func (NopHandler) HandleSignEdit(*Context, cube.Pos, bool, string, string) {} diff --git a/server/player/player.go b/server/player/player.go index d96d70b72..504645ad5 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -2573,12 +2573,16 @@ func (p *Player) ViewVisibility(entity world.Entity, level world.VisibilityLevel // ViewBlock overrides the public block at the position passed for this player. func (p *Player) ViewBlock(pos cube.Pos, b world.Block) { - p.session().ViewBlock(pos, b) + if l := p.ViewLayer(); l != nil { + l.ViewBlock(p.tx.World(), pos, b) + } } // ViewPublicBlock removes the block override at the position passed for this player. func (p *Player) ViewPublicBlock(pos cube.Pos) { - p.session().ViewPublicBlock(pos) + if l := p.ViewLayer(); l != nil { + l.ViewPublicBlock(p.tx.World(), pos) + } } // RemoveViewLayer removes all view-layer overrides of the entity for this player. diff --git a/server/session/chunk.go b/server/session/chunk.go index 6d577c051..b15dc85e2 100644 --- a/server/session/chunk.go +++ b/server/session/chunk.go @@ -146,7 +146,7 @@ func (s *Session) applyViewLayerToChunk(pos world.ChunkPos, c *chunk.Chunk, bloc if s.viewLayer == nil { return c, blockEntities } - overrides := s.viewLayer.ChunkBlocks(pos) + overrides := s.viewLayer.ChunkBlocks(s.viewLayerWorld(), pos) if len(overrides) == 0 { return c, blockEntities } diff --git a/server/session/view_layer.go b/server/session/view_layer.go index 0cea97bc4..f878c3b51 100644 --- a/server/session/view_layer.go +++ b/server/session/view_layer.go @@ -11,6 +11,14 @@ func (s *Session) ViewLayer() *world.ViewLayer { return s.viewLayer } +// viewLayerWorld returns the world whose blocks are currently viewed by the session. +func (s *Session) viewLayerWorld() *world.World { + if s.chunkLoader == nil { + return nil + } + return s.chunkLoader.World() +} + // ViewNameTag overwrites the public name tag of the entity and immediately refreshes it for this session. func (s *Session) ViewNameTag(entity world.Entity, nameTag string) { if s.viewLayer == nil { @@ -56,7 +64,7 @@ func (s *Session) ViewBlock(pos cube.Pos, b world.Block) { if s.viewLayer == nil { return } - s.viewLayer.ViewBlock(pos, b) + s.viewLayer.ViewBlock(s.viewLayerWorld(), pos, b) } // ViewPublicBlock removes the block override at the position passed and immediately refreshes it for this session. @@ -64,7 +72,7 @@ func (s *Session) ViewPublicBlock(pos cube.Pos) { if s.viewLayer == nil { return } - s.viewLayer.ViewPublicBlock(pos) + s.viewLayer.ViewPublicBlock(s.viewLayerWorld(), pos) } // RemoveViewLayer removes all overrides for the entity and immediately refreshes it for this session. @@ -83,15 +91,18 @@ func (s *Session) ViewLayerEntityChanged(e world.Entity) { s.ViewEntityState(e) } -// ViewLayerBlockChanged refreshes a block override for this session if its chunk is currently visible. -func (s *Session) ViewLayerBlockChanged(pos cube.Pos) { +// ViewLayerBlockChanged refreshes a block override for this session if its world and chunk are currently visible. +func (s *Session) ViewLayerBlockChanged(w *world.World, pos cube.Pos) { if s.viewLayer == nil { return } + if current := s.viewLayerWorld(); current != w { + return + } if _, ok := s.loadedColumnAt(pos); !ok { return } - if b, ok := s.viewLayer.Block(pos); ok { + if b, ok := s.viewLayer.Block(w, pos); ok { s.broadcastPrivateBlockSubChunk(pos) s.viewBlockUpdate(pos, b, 0) s.viewBlockUpdate(pos, s.br.Air(), 1) diff --git a/server/session/world.go b/server/session/world.go index 225deebf3..0847d87c8 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -961,7 +961,7 @@ func (s *Session) ViewBrewingUpdate(prevBrewTime, brewTime time.Duration, prevFu // ViewBlockUpdate ... func (s *Session) ViewBlockUpdate(pos cube.Pos, b world.Block, layer int) { if s.viewLayer != nil { - if visible, ok := s.viewLayer.Block(pos); ok { + if visible, ok := s.viewLayer.Block(s.viewLayerWorld(), pos); ok { if layer == 0 { b = visible } else { @@ -1260,7 +1260,7 @@ func (s *Session) ViewSlotChange(slot int, newItem item.Stack) { // ViewBlockAction ... func (s *Session) ViewBlockAction(pos cube.Pos, a world.BlockAction) { if s.viewLayer != nil { - if _, ok := s.viewLayer.Block(pos); ok { + if _, ok := s.viewLayer.Block(s.viewLayerWorld(), pos); ok { return } } diff --git a/server/world/view_layer.go b/server/world/view_layer.go index 83b055e4c..211c4465f 100644 --- a/server/world/view_layer.go +++ b/server/world/view_layer.go @@ -20,7 +20,7 @@ type ViewLayerUpdater interface { // ViewLayerEntityChanged handles an entity whose view-layer overrides changed. ViewLayerEntityChanged(entity Entity) // ViewLayerBlockChanged handles a block whose view-layer override changed. - ViewLayerBlockChanged(pos cube.Pos) + ViewLayerBlockChanged(w *World, pos cube.Pos) } type viewLayerViewer interface { @@ -29,10 +29,11 @@ type viewLayerViewer interface { // ViewLayer holds overrides for how entities are viewed by a single viewer. It allows entities to be // viewed differently by different players, such as with a different name tag or visibility state. +// Block overrides are scoped by world. type ViewLayer struct { mu sync.RWMutex entities map[*EntityHandle]layer - blocksByChunk map[ChunkPos]map[cube.Pos]Block + blocksByWorld map[*World]map[ChunkPos]map[cube.Pos]Block updater ViewLayerUpdater } @@ -40,7 +41,7 @@ type ViewLayer struct { func NewViewLayer(updater ViewLayerUpdater) *ViewLayer { return &ViewLayer{ entities: map[*EntityHandle]layer{}, - blocksByChunk: map[ChunkPos]map[cube.Pos]Block{}, + blocksByWorld: map[*World]map[ChunkPos]map[cube.Pos]Block{}, updater: updater, } } @@ -125,60 +126,68 @@ func (v *ViewLayer) Visibility(entity Entity) VisibilityLevel { return v.entities[entity.H()].visibility } -// ViewBlock overwrites the public block at the position passed for this ViewLayer. Liquid or waterlogged +// ViewBlock overwrites the public block at the position passed in the world passed for this ViewLayer. Liquid or waterlogged // state at layer 1 is not represented for overrides. Passing nil removes the block override, causing the // public block to be viewed again. -func (v *ViewLayer) ViewBlock(pos cube.Pos, b Block) { +func (v *ViewLayer) ViewBlock(w *World, pos cube.Pos, b Block) { v.mu.Lock() chunkPos := ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)} + blocksByChunk := v.blocksByWorld[w] if b == nil { - delete(v.blocksByChunk[chunkPos], pos) - if len(v.blocksByChunk[chunkPos]) == 0 { - delete(v.blocksByChunk, chunkPos) + delete(blocksByChunk[chunkPos], pos) + if len(blocksByChunk[chunkPos]) == 0 { + delete(blocksByChunk, chunkPos) + } + if len(blocksByChunk) == 0 { + delete(v.blocksByWorld, w) } } else { - if v.blocksByChunk[chunkPos] == nil { - v.blocksByChunk[chunkPos] = map[cube.Pos]Block{} + if blocksByChunk == nil { + blocksByChunk = map[ChunkPos]map[cube.Pos]Block{} + v.blocksByWorld[w] = blocksByChunk + } + if blocksByChunk[chunkPos] == nil { + blocksByChunk[chunkPos] = map[cube.Pos]Block{} } - v.blocksByChunk[chunkPos][pos] = b + blocksByChunk[chunkPos][pos] = b } v.mu.Unlock() - v.refreshBlock(pos) + v.refreshBlock(w, pos) } -// ViewPublicBlock removes the block override at the position passed, causing the public block to be viewed again. -func (v *ViewLayer) ViewPublicBlock(pos cube.Pos) { - v.ViewBlock(pos, nil) +// ViewPublicBlock removes the block override at the position passed in the world passed, causing the public block to be viewed again. +func (v *ViewLayer) ViewPublicBlock(w *World, pos cube.Pos) { + v.ViewBlock(w, pos, nil) } -// Block returns the overwritten block at the position passed and whether an override was set. -func (v *ViewLayer) Block(pos cube.Pos) (Block, bool) { +// Block returns the overwritten block at the position passed in the world passed and whether an override was set. +func (v *ViewLayer) Block(w *World, pos cube.Pos) (Block, bool) { v.mu.RLock() defer v.mu.RUnlock() - b, ok := v.blocksByChunk[ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}][pos] + b, ok := v.blocksByWorld[w][ChunkPos{int32(pos[0] >> 4), int32(pos[2] >> 4)}][pos] return b, ok } -// Blocks returns all block overrides in the view layer. -func (v *ViewLayer) Blocks() map[cube.Pos]Block { +// Blocks returns all block overrides in the world passed. +func (v *ViewLayer) Blocks(w *World) map[cube.Pos]Block { v.mu.RLock() defer v.mu.RUnlock() blocks := make(map[cube.Pos]Block) - for _, chunkBlocks := range v.blocksByChunk { + for _, chunkBlocks := range v.blocksByWorld[w] { maps.Copy(blocks, chunkBlocks) } return blocks } -// ChunkBlocks returns all block overrides in a chunk. -func (v *ViewLayer) ChunkBlocks(pos ChunkPos) map[cube.Pos]Block { +// ChunkBlocks returns all block overrides in a chunk in the world passed. +func (v *ViewLayer) ChunkBlocks(w *World, pos ChunkPos) map[cube.Pos]Block { v.mu.RLock() defer v.mu.RUnlock() - blocks := v.blocksByChunk[pos] + blocks := v.blocksByWorld[w][pos] if len(blocks) == 0 { return nil } @@ -228,7 +237,7 @@ func (v *ViewLayer) Close() error { defer v.mu.Unlock() clear(v.entities) - clear(v.blocksByChunk) + clear(v.blocksByWorld) return nil } @@ -243,8 +252,8 @@ func (v *ViewLayer) refresh(entity Entity) { } } -func (v *ViewLayer) refreshBlock(pos cube.Pos) { +func (v *ViewLayer) refreshBlock(w *World, pos cube.Pos) { if v.updater != nil { - v.updater.ViewLayerBlockChanged(pos) + v.updater.ViewLayerBlockChanged(w, pos) } } diff --git a/server/world/view_layer_test.go b/server/world/view_layer_test.go new file mode 100644 index 000000000..54f04f82d --- /dev/null +++ b/server/world/view_layer_test.go @@ -0,0 +1,39 @@ +package world + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +func TestViewLayerScopesBlockOverridesByWorld(t *testing.T) { + v := NewViewLayer(nil) + w1, w2 := &World{}, &World{} + pos := cube.Pos{1, 64, 1} + b1, b2 := viewLayerTestBlock(1), viewLayerTestBlock(2) + + v.ViewBlock(w1, pos, b1) + if b, ok := v.Block(w1, pos); !ok || b != b1 { + t.Fatalf("expected block %v in first world, got %v, %v", b1, b, ok) + } + + if _, ok := v.Block(w2, pos); ok { + t.Fatal("expected no block override in second world") + } + v.ViewBlock(w2, pos, b2) + if b, ok := v.Block(w2, pos); !ok || b != b2 { + t.Fatalf("expected block %v in second world, got %v, %v", b2, b, ok) + } + + if b, ok := v.Block(w1, pos); !ok || b != b1 { + t.Fatalf("expected block %v in first world after switch back, got %v, %v", b1, b, ok) + } +} + +type viewLayerTestBlock uint64 + +func (b viewLayerTestBlock) EncodeBlock() (string, map[string]any) { return "test:block", nil } + +func (b viewLayerTestBlock) Hash() (uint64, uint64) { return uint64(b), 0 } + +func (viewLayerTestBlock) Model() BlockModel { return unknownModel{} } From 60fb06e7ffe4bd828bdcfcfa7fa77bd0f88ca9d5 Mon Sep 17 00:00:00 2001 From: RestartFU <45609733+RestartFU@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:22:13 -0400 Subject: [PATCH 59/59] fix(player): refresh visible block break effects ContinueBreaking now re-reads the current public or private break target before computing effects and break duration. Public break sounds, particles, and actions skip viewers with private block overrides. go mod tidy is clean. --- go.mod | 2 +- go.sum | 1 + server/player/block_break.go | 61 ++++++++++++++++++++++------ server/player/block_break_test.go | 66 +++++++++++++++++++++++++++++++ server/player/player.go | 19 +++++---- 5 files changed, 125 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index cfed137ff..0cc1effbd 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pelletier/go-toml v1.9.5 github.com/sandertv/gophertunnel v1.57.0 github.com/segmentio/fasthash v1.0.3 + github.com/stretchr/testify v1.11.1 golang.org/x/exp v0.0.0-20250103183323-7d7fa50e5329 golang.org/x/mod v0.22.0 golang.org/x/text v0.23.0 @@ -29,7 +30,6 @@ require ( github.com/klauspost/compress v1.18.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sandertv/go-raknet v1.15.1-0.20260112202637-beca0b10c217 // indirect - github.com/stretchr/testify v1.11.1 // indirect golang.org/x/net v0.38.0 // indirect golang.org/x/oauth2 v0.28.0 // indirect golang.org/x/sync v0.12.0 // indirect diff --git a/go.sum b/go.sum index ef1451364..6a6cf35a6 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,7 @@ golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= diff --git a/server/player/block_break.go b/server/player/block_break.go index 44381b587..23556bbbf 100644 --- a/server/player/block_break.go +++ b/server/player/block_break.go @@ -15,10 +15,9 @@ import ( "github.com/go-gl/mathgl/mgl64" ) -// blockBreakTarget holds the block and break mode that a player started breaking. +// blockBreakTarget holds the position and break mode that a player started breaking. type blockBreakTarget struct { pos cube.Pos - block world.Block private bool } @@ -84,17 +83,23 @@ type publicBlockAudience struct { // PlaySound plays the sound to all players viewing the public block. func (a publicBlockAudience) PlaySound(pos mgl64.Vec3, s world.Sound) { - a.p.tx.PlaySound(pos, s) + s.Play(a.p.tx.World(), pos) + for _, viewer := range a.viewers(cube.PosFromVec3(pos)) { + viewer.ViewSound(pos, s) + } } // AddParticle adds the particle to all players viewing the public block. func (a publicBlockAudience) AddParticle(pos mgl64.Vec3, particle world.Particle) { - a.p.tx.AddParticle(pos, particle) + particle.Spawn(a.p.tx.World(), pos) + for _, viewer := range a.viewers(cube.PosFromVec3(pos)) { + viewer.ViewParticle(pos, particle) + } } // ViewBlockAction shows the block action to all players viewing the public block. func (a publicBlockAudience) ViewBlockAction(pos cube.Pos, action world.BlockAction) { - for _, viewer := range a.p.viewers() { + for _, viewer := range a.viewers(pos) { viewer.ViewBlockAction(pos, action) } } @@ -107,6 +112,32 @@ func (a publicBlockAudience) Resend(pos cube.Pos) { // ClearOverride does nothing for public blocks. func (a publicBlockAudience) ClearOverride(cube.Pos) {} +// viewers returns all viewers that do not have a private block override at pos. +func (a publicBlockAudience) viewers(pos cube.Pos) []world.Viewer { + viewers := a.p.viewers() + filtered := viewers[:0] + for _, viewer := range viewers { + if viewerViewsPublicBlock(viewer, a.p.tx.World(), pos) { + filtered = append(filtered, viewer) + } + } + return filtered +} + +type blockOverrideViewer interface { + ViewLayer() *world.ViewLayer +} + +// viewerViewsPublicBlock returns whether viewer sees the public block at pos. +func viewerViewsPublicBlock(viewer world.Viewer, w *world.World, pos cube.Pos) bool { + v, ok := viewer.(blockOverrideViewer) + if !ok || v.ViewLayer() == nil { + return true + } + _, ok = v.ViewLayer().Block(w, pos) + return !ok +} + // BreakBlock makes the player break the public world block at the position passed. Private view-layer // overrides are ignored by this method: Call BreakVisibleBlock to break what the player currently sees instead. // If the player is unable to reach the block passed, the method returns immediately. @@ -124,16 +155,20 @@ func (p *Player) BreakVisibleBlock(pos cube.Pos) { // breakTarget breaks the target using the same mode as when breaking started. func (p *Player) breakTarget(target blockBreakTarget) { - if target.private { - b, ok := p.privateBlock(target.pos) - if !ok { - p.blockAudience(false).Resend(target.pos) - return - } - p.breakBlock(target.pos, b, true) + b, ok := p.breakTargetBlock(target) + if !ok { + p.blockAudience(false).Resend(target.pos) return } - p.BreakBlock(target.pos) + p.breakBlock(target.pos, b, target.private) +} + +// breakTargetBlock returns the current block matching the break mode of target. +func (p *Player) breakTargetBlock(target blockBreakTarget) (world.Block, bool) { + if target.private { + return p.privateBlock(target.pos) + } + return p.tx.Block(target.pos), true } // breakBlock makes the player break the block passed at the position passed. Private blocks are removed diff --git a/server/player/block_break_test.go b/server/player/block_break_test.go index 4cf6a587b..cbaa5bedd 100644 --- a/server/player/block_break_test.go +++ b/server/player/block_break_test.go @@ -118,6 +118,63 @@ func TestHandleBlockBreakReceivesPrivateBreakMode(t *testing.T) { }) } +func TestContinueBreakingReReadsTargetBlock(t *testing.T) { + tests := []struct { + name string + private bool + changeBlock func(*Player, *world.Tx, cube.Pos) + expectedType world.Block + }{ + { + name: "public", + private: false, + changeBlock: func(_ *Player, tx *world.Tx, pos cube.Pos) { + tx.SetBlock(pos, block.Obsidian{}, nil) + }, + expectedType: block.Obsidian{}, + }, + { + name: "private", + private: true, + changeBlock: func(p *Player, _ *world.Tx, pos cube.Pos) { + p.ViewBlock(pos, block.Obsidian{}) + }, + expectedType: block.Obsidian{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withViewLayerTestPlayer(t, func(p *Player, tx *world.Tx) { + p.gameMode = world.GameModeSurvival + pos := cube.Pos{0, 64, 0} + tx.SetBlock(pos, block.Dirt{}, nil) + if tt.private { + p.ViewBlock(pos, block.Dirt{}) + } + p.StartBreaking(pos, cube.FaceUp) + + tt.changeBlock(p, tx, pos) + p.ContinueBreaking(cube.FaceUp) + + require.Equal(t, p.breakTime(tt.expectedType), p.lastBreakDuration) + }) + }) + } +} + +func TestViewerViewsPublicBlock(t *testing.T) { + w := world.New() + defer w.Close() + + pos := cube.Pos{0, 64, 0} + viewer := &blockBreakTestViewer{viewLayer: world.NewViewLayer(nil)} + require.True(t, viewerViewsPublicBlock(viewer, w, pos)) + + viewer.viewLayer.ViewBlock(w, pos, block.Stone{}) + require.False(t, viewerViewsPublicBlock(viewer, w, pos)) + require.True(t, viewerViewsPublicBlock(world.NopViewer{}, w, pos)) +} + func withViewLayerTestPlayer(t *testing.T, f func(*Player, *world.Tx)) { t.Helper() @@ -158,6 +215,15 @@ func (h *blockBreakTestHandler) HandleBlockBreak(_ *Context, _ cube.Pos, private type fakeConn struct{} +type blockBreakTestViewer struct { + world.NopViewer + viewLayer *world.ViewLayer +} + +func (v *blockBreakTestViewer) ViewLayer() *world.ViewLayer { + return v.viewLayer +} + func (fakeConn) Close() error { return nil } func (fakeConn) IdentityData() login.IdentityData { return login.IdentityData{DisplayName: "test"} } func (fakeConn) ClientData() login.ClientData { return login.ClientData{} } diff --git a/server/player/player.go b/server/player/player.go index 504645ad5..1e5a7cfb1 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1887,7 +1887,7 @@ func (p *Player) StartBreaking(pos cube.Pos, face cube.Face) { } // Note: We intentionally store this regardless of whether the breaking proceeds, so that we // can resend the block to the client when it tries to break the block regardless. - p.blockBreakTarget = &blockBreakTarget{pos: pos, block: b, private: private} + p.blockBreakTarget = &blockBreakTarget{pos: pos, private: private} ctx := event.C(p) if p.Handler().HandleStartBreak(ctx, pos); ctx.Cancelled() { @@ -1973,24 +1973,23 @@ func (p *Player) ContinueBreaking(face cube.Face) { if !p.breaking || target == nil { return } - if target.private { - if _, ok := p.privateBlock(target.pos); !ok { - p.AbortBreaking() - p.blockAudience(false).Resend(target.pos) - return - } + b, ok := p.breakTargetBlock(*target) + if !ok { + p.AbortBreaking() + p.blockAudience(false).Resend(target.pos) + return } audience := p.blockAudience(target.private) - audience.AddParticle(target.pos.Vec3(), particle.PunchBlock{Block: target.block, Face: face}) + audience.AddParticle(target.pos.Vec3(), particle.PunchBlock{Block: b, Face: face}) if p.breakCounter++; p.breakCounter%5 == 0 { p.SwingArm() // We send this sound only every so often. Vanilla doesn't send it every tick while breaking // either. Every 5 ticks seems accurate. - audience.PlaySound(target.pos.Vec3(), sound.BlockBreaking{Block: target.block}) + audience.PlaySound(target.pos.Vec3(), sound.BlockBreaking{Block: b}) } - if breakTime := p.breakTime(target.block); breakTime != p.lastBreakDuration { + if breakTime := p.breakTime(b); breakTime != p.lastBreakDuration { audience.ViewBlockAction(target.pos, block.ContinueCrackAction{BreakTime: breakTime}) p.lastBreakDuration = breakTime }