diff --git a/server/block/lever.go b/server/block/lever.go index 10a2cacbe..5a1e395c3 100644 --- a/server/block/lever.go +++ b/server/block/lever.go @@ -23,22 +23,17 @@ type Lever struct { Direction cube.Direction } -// RedstoneSource ... -func (l Lever) RedstoneSource() bool { - return true -} - -// WeakPower ... -func (l Lever) WeakPower(cube.Pos, cube.Face, *world.Tx, bool) int { +// RedstonePower returns maximum power while the lever is active. +func (l Lever) RedstonePower(cube.Pos, *world.Tx, cube.Face) int { if l.Powered { return 15 } return 0 } -// StrongPower ... -func (l Lever) StrongPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { - if l.Powered && l.Facing == face { +// RedstoneStrongPower strongly powers the block the lever is attached to. +func (l Lever) RedstoneStrongPower(_ cube.Pos, _ *world.Tx, face cube.Face) int { + if l.Powered && l.Facing.Opposite() == face { return 15 } return 0 @@ -87,14 +82,13 @@ func (l Lever) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ } else { tx.PlaySound(pos.Vec3Centre(), sound.PowerOff{}) } - updateDirectionalRedstone(pos, tx, l.Facing.Opposite()) return true } // BreakInfo ... func (l Lever) BreakInfo() BreakInfo { return newBreakInfo(0.5, alwaysHarvestable, nothingEffective, oneOf(Lever{})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { - updateDirectionalRedstone(pos, tx, l.Facing.Opposite()) + tx.ScheduleRedstoneUpdate(pos) }) } diff --git a/server/block/note.go b/server/block/note.go index 719d9cd52..f32ea434a 100644 --- a/server/block/note.go +++ b/server/block/note.go @@ -61,19 +61,17 @@ func (n Note) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, _ item.User, _ * return true } -// RedstoneUpdate updates the note block's powered state and plays the note when it first becomes powered. -func (n Note) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { - poweredFaces := n.poweredFaces(pos, tx) - powered := len(poweredFaces) > 0 +// RedstonePowerUpdate updates the note block's powered state and plays the note when it first becomes powered. +func (n Note) RedstonePowerUpdate(pos cube.Pos, tx *world.Tx, power int) (world.Block, bool) { + powered := power > 0 if powered == n.Powered { - return + return n, false } n.Powered = powered if powered && n.canPlay(pos, tx) { n.playNote(pos, tx) } - tx.SetBlock(pos, n, &world.SetOpts{DisableBlockUpdates: true}) - updateAroundRedstone(pos, tx, poweredFaces...) + return n, true } // canPlay reports whether the block above the note block is air. @@ -82,17 +80,6 @@ func (n Note) canPlay(pos cube.Pos, tx *world.Tx) bool { return ok } -func (n Note) poweredFaces(pos cube.Pos, tx *world.Tx) []cube.Face { - var faces []cube.Face - for _, face := range cube.Faces() { - adjacentPos := pos.Side(face) - if power := tx.RedstonePower(adjacentPos, face, true); power > 0 { - faces = append(faces, face) - } - } - return faces -} - // BreakInfo ... func (n Note) BreakInfo() BreakInfo { return newBreakInfo(0.8, alwaysHarvestable, axeEffective, oneOf(Note{})) diff --git a/server/block/redstone.go b/server/block/redstone.go index 4ba84dd68..c8ad9bd68 100644 --- a/server/block/redstone.go +++ b/server/block/redstone.go @@ -1,380 +1,13 @@ package block import ( - "slices" - - "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/block/model" - "github.com/df-mc/dragonfly/server/event" - "github.com/df-mc/dragonfly/server/world" -) - -// wireNetwork implements a minimally-invasive bolt-on accelerator that performs a breadth-first search through redstone -// wires in order to more efficiently and compute new redstone wire power levels and determine the order in which other -// blocks should be updated. This implementation is heavily based off of RedstoneWireTurbo and MCHPRS. -type wireNetwork struct { - nodes []*wireNode - nodeCache map[cube.Pos]*wireNode - updateQueue [3][]*wireNode - currentWalkLayer uint32 -} - -// wireNode is a data structure to keep track of redstone wires and neighbours that will receive updates. -type wireNode struct { - visited bool - - pos cube.Pos - block world.Block - source cube.Pos - - neighbours []*wireNode - oriented bool - - xBias int32 - zBias int32 - - layer uint32 -} - -const ( - wireHeadingNorth = 0 - wireHeadingEast = 1 - wireHeadingSouth = 2 - wireHeadingWest = 3 + "time" ) -// updateStrongRedstone sets off the breadth-first walk through all redstone wires connected to the initial position -// triggered. This is the main entry point for the redstone update algorithm. -func updateStrongRedstone(pos cube.Pos, tx *world.Tx) { - n := &wireNetwork{ - nodeCache: make(map[cube.Pos]*wireNode), - } - - root := &wireNode{ - block: tx.Block(pos), - pos: pos, - visited: true, - } - n.nodeCache[pos] = root - n.nodes = append(n.nodes, root) - - n.propagateChanges(tx, root, 0) - n.breadthFirstWalk(tx) -} - -// updateAroundRedstone updates redstone components around the given center position. It will also ignore any faces -// provided within the ignoredFaces parameter. This implementation is based off of RedstoneCircuit and Java 1.19. -func updateAroundRedstone(centre cube.Pos, tx *world.Tx, ignoredFaces ...cube.Face) { - // Order matches Java 1.19's RedstoneCircuit traversal. - for _, face := range []cube.Face{ - cube.FaceWest, - cube.FaceEast, - cube.FaceDown, - cube.FaceUp, - cube.FaceNorth, - cube.FaceSouth, - } { - if slices.Contains(ignoredFaces, face) { - continue - } - pos := centre.Side(face) - updateRedstoneFrom(pos, centre, tx) - updateRedstoneFrom(pos.Side(cube.FaceUp), centre, tx) - updateRedstoneFrom(pos.Side(cube.FaceDown), centre, tx) - updateReceiversAroundPoweredBlock(pos, tx, face.Opposite()) - } -} - -// updateReceiversAroundPoweredBlock updates redstone receivers directly adjacent to an indirectly powered solid block. -// This keeps mechanisms behind the powered block in sync without walking corner positions around the original update. -// For example, this covers the following vertical path: torch -> stone -> note block. -func updateReceiversAroundPoweredBlock(pos cube.Pos, tx *world.Tx, ignoredFaces ...cube.Face) { - if _, ok := tx.Block(pos).Model().(model.Solid); !ok { - return - } - for _, face := range cube.Faces() { - if slices.Contains(ignoredFaces, face) || tx.RedstonePower(pos, face, true) == 0 { - continue - } - updateRedstoneFrom(pos.Side(face), pos, tx) - } -} - -// updateRedstone dispatches a cancellable redstone update to the block at pos, if it handles redstone updates. -// Prefer updateRedstone over calling RedstoneUpdate directly so HandleRedstoneUpdate gets a chance to observe -// and optionally cancel the update. Direct RedstoneUpdate calls are reserved for internal state initialisation -// (e.g. seeding a freshly placed block) where handler cancellation isn't appropriate. -func updateRedstone(pos cube.Pos, tx *world.Tx) { - updateRedstoneFrom(pos, pos, tx) -} - -// updateRedstoneFrom dispatches a cancellable redstone update and records the position that caused it. -func updateRedstoneFrom(pos, source cube.Pos, tx *world.Tx) { - r, ok := tx.Block(pos).(world.RedstoneUpdater) - if !ok { - return - } - if redstoneUpdateCancelled(pos, tx) { - return - } - tx.Redstone().WithUpdateSource(source, func() { - r.RedstoneUpdate(pos, tx) - }) -} - -// redstoneUpdateCancelled checks if the redstone update has been cancelled by the HandleRedstoneUpdate handler. -func redstoneUpdateCancelled(pos cube.Pos, tx *world.Tx) bool { - ctx := event.C(tx) - tx.World().Handler().HandleRedstoneUpdate(ctx, pos) - return ctx.Cancelled() -} - -// updateDirectionalRedstone updates redstone components through the given face. This implementation is based off of -// RedstoneCircuit and Java 1.19. -func updateDirectionalRedstone(pos cube.Pos, tx *world.Tx, face cube.Face) { - updateAroundRedstone(pos, tx) - updateAroundRedstone(pos.Side(face), tx, face.Opposite()) -} - -// identifyNeighbours identifies the neighbouring positions of a given node, determines their types, and links them into -// the graph. After that, based on what nodes in the graph have been visited, the neighbours are reordered left-to-right -// relative to the direction of information flow. -func (n *wireNetwork) identifyNeighbours(tx *world.Tx, node *wireNode) { - var neighboursVisited [24]bool - var neighbourNodes [24]*wireNode - for i, offset := range redstoneNeighbourOffsets { - neighbourPos := node.pos.Add(offset) - neighbour, ok := n.nodeCache[neighbourPos] - if !ok { - neighbour = &wireNode{ - pos: neighbourPos, - block: tx.Block(neighbourPos), - } - n.nodeCache[neighbourPos] = neighbour - n.nodes = append(n.nodes, neighbour) - } - neighbourNodes[i] = neighbour - neighboursVisited[i] = neighbour.visited - } - - fromWest := neighboursVisited[0] || neighboursVisited[7] || neighboursVisited[8] - fromEast := neighboursVisited[1] || neighboursVisited[12] || neighboursVisited[13] - fromNorth := neighboursVisited[4] || neighboursVisited[17] || neighboursVisited[20] - fromSouth := neighboursVisited[5] || neighboursVisited[18] || neighboursVisited[21] - - var cX, cZ int32 - if fromWest { - cX++ - } - if fromEast { - cX-- - } - if fromNorth { - cZ++ - } - if fromSouth { - cZ-- - } - - var heading uint32 - if cX == 0 && cZ == 0 { - heading = computeRedstoneHeading(node.xBias, node.zBias) - for _, neighbourNode := range neighbourNodes { - neighbourNode.xBias = node.xBias - neighbourNode.zBias = node.zBias - } - } else { - if cX != 0 && cZ != 0 { - if node.xBias != 0 { - cZ = 0 - } - if node.zBias != 0 { - cX = 0 - } - } - heading = computeRedstoneHeading(cX, cZ) - for _, neighbourNode := range neighbourNodes { - neighbourNode.xBias = cX - neighbourNode.zBias = cZ - } - } - - n.orientNeighbours(neighbourNodes, node, heading) -} - -// redstoneNeighbourOffsets lists the 24 positions visited around a redstone wire node: the 6 immediate neighbours -// followed by the unique neighbours-of-neighbours, in the order west, east, down, up, north, south. The fixed -// indices here are referenced directly by identifyNeighbours and the redstoneReordering tables below. -var redstoneNeighbourOffsets = [...]cube.Pos{ - // Immediate neighbours, in the order of west, east, down, up, north, and south. - {-1, 0, 0}, - {1, 0, 0}, - {0, -1, 0}, - {0, 1, 0}, - {0, 0, -1}, - {0, 0, 1}, - - // Neighbours of neighbours, in the same order, except that duplicates are omitted. - {-2, 0, 0}, - {-1, -1, 0}, - {-1, 1, 0}, - {-1, 0, -1}, - {-1, 0, 1}, - - {2, 0, 0}, - {1, -1, 0}, - {1, 1, 0}, - {1, 0, -1}, - {1, 0, 1}, - - {0, -2, 0}, - {0, -1, -1}, - {0, -1, 1}, - - {0, 2, 0}, - {0, 1, -1}, - {0, 1, 1}, - - {0, 0, -2}, - {0, 0, 2}, -} - -// redstoneReordering contains lookup tables that completely remap neighbour positions into a left-to-right ordering, -// based on the cardinal direction that is determined to be forward. -var redstoneReordering = [...][24]uint32{ - {2, 3, 16, 19, 0, 4, 1, 5, 7, 8, 17, 20, 12, 13, 18, 21, 6, 9, 22, 14, 11, 10, 23, 15}, - {2, 3, 16, 19, 4, 1, 5, 0, 17, 20, 12, 13, 18, 21, 7, 8, 22, 14, 11, 15, 23, 9, 6, 10}, - {2, 3, 16, 19, 1, 5, 0, 4, 12, 13, 18, 21, 7, 8, 17, 20, 11, 15, 23, 10, 6, 14, 22, 9}, - {2, 3, 16, 19, 5, 0, 4, 1, 18, 21, 7, 8, 17, 20, 12, 13, 23, 10, 6, 9, 22, 15, 11, 14}, -} - -// orientNeighbours reorders the neighbours of a node based on the direction that is determined to be forward. -func (n *wireNetwork) orientNeighbours(src [24]*wireNode, dst *wireNode, heading uint32) { - dst.oriented = true - dst.neighbours = make([]*wireNode, 0, 24) - for _, i := range redstoneReordering[heading] { - dst.neighbours = append(dst.neighbours, src[i]) - } -} - -// propagateChanges propagates changes for any redstone wire in layer N, informing the neighbours to recompute their -// states in layers N + 1 and N + 2. -func (n *wireNetwork) propagateChanges(tx *world.Tx, node *wireNode, layer uint32) { - if !node.oriented { - n.identifyNeighbours(tx, node) - } - - layerOne := layer + 1 - for _, neighbour := range node.neighbours { - if layerOne > neighbour.layer { - neighbour.layer = layerOne - neighbour.source = node.pos - n.updateQueue[1] = append(n.updateQueue[1], neighbour) - } - } - - layerTwo := layer + 2 - for _, neighbour := range node.neighbours[:4] { - if layerTwo > neighbour.layer { - neighbour.layer = layerTwo - neighbour.source = node.pos - n.updateQueue[2] = append(n.updateQueue[2], neighbour) - } - } -} - -// breadthFirstWalk performs a breadth-first (layer by layer) traversal through redstone wires, propagating value -// changes to neighbours in the order that they are visited. -func (n *wireNetwork) breadthFirstWalk(tx *world.Tx) { - n.shiftQueue() - n.currentWalkLayer = 1 - - for len(n.updateQueue[0]) > 0 || len(n.updateQueue[1]) > 0 { - for _, node := range n.updateQueue[0] { - if _, ok := node.block.(RedstoneWire); ok { - n.updateNode(tx, node, n.currentWalkLayer) - continue - } - updateRedstoneFrom(node.pos, node.source, tx) - } - - n.shiftQueue() - n.currentWalkLayer++ - } - - n.currentWalkLayer = 0 -} - -// shiftQueue shifts the update queue, moving all nodes from the current layer to the next layer. The last queue is then -// simply invalidated. -func (n *wireNetwork) shiftQueue() { - n.updateQueue[0] = n.updateQueue[1] - n.updateQueue[1] = n.updateQueue[2] - n.updateQueue[2] = nil -} - -// updateNode processes a node which has had neighbouring redstone wires that have experienced value changes. -func (n *wireNetwork) updateNode(tx *world.Tx, node *wireNode, layer uint32) { - node.visited = true - if redstoneUpdateCancelled(node.pos, tx) { - return - } - - newWire, changed := n.calculateCurrentChanges(tx, node) - if !changed { - return - } - node.block = newWire - n.propagateChanges(tx, node, layer) -} - -// calculateCurrentChanges computes redstone wire power levels from neighboring blocks. Modifications cut the number of -// power level changes by about 45% from vanilla, and also synergies well with the breadth-first search implementation. -// It returns the new redstone wire block and a boolean indicating whether the power level changed. -func (n *wireNetwork) calculateCurrentChanges(tx *world.Tx, node *wireNode) (RedstoneWire, bool) { - wire := node.block.(RedstoneWire) - i := wire.Power - - if !node.oriented { - n.identifyNeighbours(tx, node) - } - - j := calculateRedstoneWirePower(node.pos, tx, func(pos cube.Pos) world.Block { - if cached, ok := n.nodeCache[pos]; ok { - return cached.block - } - return tx.Block(pos) - }) - - if i == j { - return wire, false - } - wire.Power = j - tx.SetBlock(node.pos, wire, &world.SetOpts{DisableBlockUpdates: true}) - return wire, true -} - -// maxRedstoneWirePower returns the greater of strength and the power level of b if it is redstone wire. -func maxRedstoneWirePower(b world.Block, strength int) int { - if wire, ok := b.(RedstoneWire); ok { - return max(wire.Power, strength) - } - return strength +func redstonePower(power int) int { + return min(max(power, 0), 15) } -// computeRedstoneHeading computes the cardinal direction that is "forward" given which redstone wires have been visited -// and which have not around the position currently being processed. -func computeRedstoneHeading(rX, rZ int32) uint32 { - code := (rX + 1) + 3*(rZ+1) - switch code { - case 0, 1: - return wireHeadingNorth - case 2, 5: - return wireHeadingEast - case 3, 4: - return wireHeadingWest - case 6, 7, 8: - return wireHeadingSouth - } - panic("should never happen") +func redstoneTicks(ticks int) time.Duration { + return time.Duration(max(ticks, 1)) * time.Second / 10 } diff --git a/server/block/redstone_block.go b/server/block/redstone_block.go index 284122842..9a0f4ef98 100644 --- a/server/block/redstone_block.go +++ b/server/block/redstone_block.go @@ -16,7 +16,7 @@ type RedstoneBlock struct { // BreakInfo ... func (r RedstoneBlock) BreakInfo() BreakInfo { return newBreakInfo(5, pickaxeHarvestable, pickaxeEffective, oneOf(r)).withBlastResistance(30).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { - updateAroundRedstone(pos, tx) + tx.ScheduleRedstoneUpdate(pos) }) } @@ -37,24 +37,16 @@ func (r RedstoneBlock) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx return false } place(tx, pos, r, user, ctx) - if placed(ctx) { - updateAroundRedstone(pos, tx) - return true - } - return false -} - -// RedstoneSource ... -func (r RedstoneBlock) RedstoneSource() bool { - return true + return placed(ctx) } -// WeakPower ... -func (r RedstoneBlock) WeakPower(_ cube.Pos, _ cube.Face, _ *world.Tx, _ bool) int { +// RedstonePower always returns maximum power. +func (RedstoneBlock) RedstonePower(cube.Pos, *world.Tx, cube.Face) int { return 15 } -// StrongPower ... -func (r RedstoneBlock) StrongPower(_ cube.Pos, _ cube.Face, _ *world.Tx, _ bool) int { +// RedstoneStrongPower returns no strong power. Redstone blocks power adjacent components directly, but do not power +// adjacent opaque blocks. +func (RedstoneBlock) RedstoneStrongPower(cube.Pos, *world.Tx, cube.Face) int { return 0 } diff --git a/server/block/redstone_test.go b/server/block/redstone_test.go new file mode 100644 index 000000000..7ac44d525 --- /dev/null +++ b/server/block/redstone_test.go @@ -0,0 +1,284 @@ +package block + +import ( + "fmt" + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +func TestRedstoneBlockPower(t *testing.T) { + for _, face := range cube.Faces() { + if power := (RedstoneBlock{}).RedstonePower(cube.Pos{}, nil, face); power != 15 { + t.Fatalf("RedstoneBlock power from %v = %d, want 15", face, power) + } + if power := (RedstoneBlock{}).RedstoneStrongPower(cube.Pos{}, nil, face); power != 0 { + t.Fatalf("RedstoneBlock strong power from %v = %d, want 0", face, power) + } + } + + w := world.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + source := cube.Pos{0, 1, 0} + target := source.Side(cube.FaceEast) + tx.SetBlock(source, RedstoneBlock{}, nil) + if power := tx.RedstonePower(target); power != 15 { + err = fmt.Errorf("RedstoneBlock weak power = %d, want 15", power) + return + } + if power := tx.RedstoneDirectPower(target); power != 15 { + err = fmt.Errorf("RedstoneBlock direct power = %d, want 15", power) + return + } + if power := tx.RedstoneStrongPower(target); power != 0 { + err = fmt.Errorf("RedstoneBlock strong power = %d, want 0", power) + } + }) + if err != nil { + t.Fatal(err) + } +} + +func TestLeverPower(t *testing.T) { + if power := (Lever{}).RedstonePower(cube.Pos{}, nil, cube.FaceUp); power != 0 { + t.Fatalf("unpowered lever power = %d, want 0", power) + } + if power := (Lever{Powered: true}).RedstonePower(cube.Pos{}, nil, cube.FaceUp); power != 15 { + t.Fatalf("powered lever power = %d, want 15", power) + } + if power := (Lever{Facing: cube.FaceWest, Powered: true}).RedstoneStrongPower(cube.Pos{}, nil, cube.FaceEast); power != 15 { + t.Fatalf("attached-face lever strong power = %d, want 15", power) + } + if power := (Lever{Facing: cube.FaceWest, Powered: true}).RedstoneStrongPower(cube.Pos{}, nil, cube.FaceWest); power != 0 { + t.Fatalf("opposite-face lever strong power = %d, want 0", power) + } +} + +func TestLeverEncodeBlock(t *testing.T) { + tests := []struct { + name string + l Lever + want string + }{ + {name: "wall", l: Lever{Facing: cube.FaceEast}, want: "east"}, + {name: "floor east west", l: Lever{Facing: cube.FaceUp, Direction: cube.West}, want: "up_east_west"}, + {name: "floor north south", l: Lever{Facing: cube.FaceUp, Direction: cube.North}, want: "up_north_south"}, + {name: "ceiling east west", l: Lever{Facing: cube.FaceDown, Direction: cube.West}, want: "down_east_west"}, + {name: "ceiling north south", l: Lever{Facing: cube.FaceDown, Direction: cube.North}, want: "down_north_south"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, props := test.l.EncodeBlock() + if direction := props["lever_direction"]; direction != test.want { + t.Fatalf("lever_direction = %v, want %s", direction, test.want) + } + }) + } +} + +func TestRedstoneWirePowerUpdate(t *testing.T) { + wire := RedstoneWire{Power: 3} + after, changed := wire.RedstonePowerUpdate(cube.Pos{}, nil, 12) + if !changed { + t.Fatal("RedstoneWire update did not report a change") + } + if got := after.(RedstoneWire).Power; got != 12 { + t.Fatalf("RedstoneWire power = %d, want 12", got) + } + + after, changed = wire.RedstonePowerUpdate(cube.Pos{}, nil, 24) + if !changed { + t.Fatal("RedstoneWire clamped update did not report a change") + } + if got := after.(RedstoneWire).Power; got != 15 { + t.Fatalf("RedstoneWire clamped power = %d, want 15", got) + } + + _, changed = (RedstoneWire{Power: 15}).RedstonePowerUpdate(cube.Pos{}, nil, 15) + if changed { + t.Fatal("RedstoneWire unchanged power reported a change") + } +} + +func TestRedstoneWireDoesNotPowerDown(t *testing.T) { + wire := RedstoneWire{Power: 15} + if power := wire.RedstonePower(cube.Pos{}, nil, cube.FaceDown); power != 0 { + t.Fatalf("wire downward power = %d, want 0", power) + } + if power := wire.RedstonePower(cube.Pos{}, nil, cube.FaceUp); power != 15 { + t.Fatalf("wire upward power = %d, want 15", power) + } +} + +func TestRedstoneWireRequiresSolidSupport(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + pos := cube.Pos{0, 1, 0} + if redstoneWireSupported(tx, pos) { + err = fmt.Errorf("wire without support was supported") + return + } + tx.SetBlock(pos.Side(cube.FaceDown), Stone{}, nil) + if !redstoneWireSupported(tx, pos) { + err = fmt.Errorf("wire on solid support was not supported") + return + } + tx.SetBlock(pos.Side(cube.FaceDown), RedstoneWire{}, nil) + if redstoneWireSupported(tx, pos) { + err = fmt.Errorf("wire on top of wire was supported") + } + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRedstoneWireConnectsUpBlocks(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + pos := cube.Pos{0, 1, 0} + up := cube.Pos{1, 2, 0} + tx.SetBlock(pos.Side(cube.FaceDown), Stone{}, nil) + tx.SetBlock(pos, RedstoneWire{}, nil) + tx.SetBlock(up.Side(cube.FaceDown), Stone{}, nil) + tx.SetBlock(up, RedstoneWire{}, nil) + + for _, neighbour := range (RedstoneWire{}).RedstoneRelayerNeighbours(pos, tx) { + if neighbour == up { + return + } + } + err = fmt.Errorf("wire neighbours did not include upward step") + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRedstoneWireRelayerNeighboursDoNotLoadAdjacentChunks(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + pos := cube.Pos{15, 1, 0} + unloadedNeighbour := pos.Side(cube.FaceEast) + tx.SetBlock(pos.Side(cube.FaceDown), Stone{}, nil) + tx.SetBlock(pos, RedstoneWire{}, nil) + + if _, ok := tx.BlockLoaded(unloadedNeighbour); ok { + err = fmt.Errorf("adjacent chunk was loaded before relayer neighbour lookup") + return + } + _ = (RedstoneWire{}).RedstoneRelayerNeighbours(pos, tx) + if _, ok := tx.BlockLoaded(unloadedNeighbour); ok { + err = fmt.Errorf("relayer neighbour lookup loaded adjacent chunk") + } + }) + if err != nil { + t.Fatal(err) + } +} + +func TestStrongPowerConductsThroughSolidBlocks(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + source := cube.Pos{0, 1, 0} + conductor := source.Side(cube.FaceEast) + target := conductor.Side(cube.FaceEast) + tx.SetBlock(source, Lever{Facing: cube.FaceWest, Powered: true}, nil) + tx.SetBlock(conductor, Stone{}, nil) + + if power := tx.RedstonePower(target); power != 15 { + err = fmt.Errorf("conducted strong power = %d, want 15", power) + } + }) + if err != nil { + t.Fatal(err) + } +} + +func TestStrongPowerDoesNotConductThroughTransparentFullBlocks(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + source := cube.Pos{0, 1, 0} + conductor := source.Side(cube.FaceEast) + target := conductor.Side(cube.FaceEast) + tx.SetBlock(source, Lever{Facing: cube.FaceWest, Powered: true}, nil) + tx.SetBlock(conductor, Glass{}, nil) + + if power := tx.RedstonePower(target); power != 0 { + err = fmt.Errorf("power conducted through glass = %d, want 0", power) + } + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRedstoneTorchBurnout(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + pos := cube.Pos{0, 1, 0} + tx.SetBlock(pos.Side(cube.FaceDown), RedstoneBlock{}, nil) + torch := RedstoneTorch{Facing: cube.FaceDown, Lit: true} + tx.SetBlock(pos, torch, nil) + + for i := 0; i < 7; i++ { + if tx.RecordRedstoneTorchToggle(pos) { + err = fmt.Errorf("torch burned out after %d toggles, want below threshold", i+1) + return + } + } + torch.ScheduledTick(pos, tx, nil) + after, ok := tx.Block(pos).(RedstoneTorch) + if !ok { + err = fmt.Errorf("redstone torch missing after burnout tick") + return + } + if after.Lit { + err = fmt.Errorf("redstone torch stayed lit after burnout") + return + } + burnedOut, recoverable := tx.RedstoneTorchBurnoutStatus(pos) + if !burnedOut || recoverable { + err = fmt.Errorf("burnout status = burnedOut %t recoverable %t, want true false", burnedOut, recoverable) + } + }) + if err != nil { + t.Fatal(err) + } +} diff --git a/server/block/redstone_torch.go b/server/block/redstone_torch.go index 6aef00182..a7bf4f317 100644 --- a/server/block/redstone_torch.go +++ b/server/block/redstone_torch.go @@ -2,7 +2,6 @@ package block import ( "math/rand/v2" - "time" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item" @@ -11,23 +10,32 @@ import ( "github.com/go-gl/mathgl/mgl64" ) -// RedstoneTorch is a non-solid block that emits light and provides a full-strength redstone signal when lit. +var ( + _ world.RedstonePowerSource = RedstoneTorch{} + _ world.RedstonePowerConsumer = RedstoneTorch{} + _ world.ScheduledTicker = RedstoneTorch{} +) + +// RedstoneTorch is a torch-like inverter that turns off while the block it is attached to is powered. type RedstoneTorch struct { transparent empty // Facing is the direction from the torch to the block it is attached to. Facing cube.Face - // Lit indicates whether the redstone torch is currently lit and emitting power. + // Lit is true if the torch is currently emitting redstone power. Lit bool } -// HasLiquidDrops returns whether the redstone torch drops its item when flowing liquid breaks it. -func (RedstoneTorch) HasLiquidDrops() bool { - return true +// BreakInfo ... +func (t RedstoneTorch) BreakInfo() BreakInfo { + return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(t)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { + tx.ClearRedstoneTorchBurnout(pos) + tx.ScheduleRedstoneUpdate(pos) + }) } -// LightEmissionLevel returns the light level emitted by the redstone torch (7 when lit, 0 when unlit). +// LightEmissionLevel ... func (t RedstoneTorch) LightEmissionLevel() uint8 { if t.Lit { return 7 @@ -35,260 +43,190 @@ func (t RedstoneTorch) LightEmissionLevel() uint8 { return 0 } -// BreakInfo returns information about breaking the redstone torch. -func (t RedstoneTorch) BreakInfo() BreakInfo { - return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(t)).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { - tx.Redstone().ClearTorchBurnout(pos) - updateTorchRedstone(pos, tx) - }) -} - -// UseOnBlock handles the placement of a redstone torch on a block surface. +// UseOnBlock places a redstone torch on the clicked block face. func (t RedstoneTorch) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { pos, face, used := firstReplaceable(tx, pos, face, t) - if !used { - return false - } - if face == cube.FaceDown { + if !used || face == cube.FaceDown { return false } if _, ok := tx.Block(pos).(world.Liquid); ok { return false } if !tx.Block(pos.Side(face.Opposite())).Model().FaceSolid(pos.Side(face.Opposite()), face, tx) { - fallbackFace, ok := findTorchPlacementFace(pos, tx) - if !ok { + found := false + for _, i := range []cube.Face{cube.FaceSouth, cube.FaceWest, cube.FaceNorth, cube.FaceEast, cube.FaceDown} { + if tx.Block(pos.Side(i)).Model().FaceSolid(pos.Side(i), i.Opposite(), tx) { + found = true + face = i.Opposite() + break + } + } + if !found { return false } - face = fallbackFace } t.Facing = face.Opposite() t.Lit = true place(tx, pos, t, user, ctx) if placed(ctx) { - // Initialise the freshly placed torch state before propagating its output. - t.RedstoneUpdate(pos, tx) - updateTorchRedstone(pos, tx) - return true + if t.attachmentPowered(pos, tx) { + tx.ScheduleBlockUpdate(pos, t, redstoneTicks(1)) + } } - return false + return placed(ctx) } -// NeighbourUpdateTick is called when a neighbouring block is updated. +// NeighbourUpdateTick breaks unsupported torches and otherwise schedules inverse-state refreshes. func (t RedstoneTorch) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { if !tx.Block(pos.Side(t.Facing)).Model().FaceSolid(pos.Side(t.Facing), t.Facing.Opposite(), tx) { - tx.Redstone().ClearTorchBurnout(pos) + tx.ClearRedstoneTorchBurnout(pos) breakBlock(t, pos, tx) return } if t.recoverFromBurnout(pos, tx) { return } - updateRedstone(pos, tx) + tx.ScheduleBlockUpdate(pos, t, redstoneTicks(1)) } -// RedstoneUpdate is called when the redstone power state changes nearby. This method ignores burned-out torches and -// schedules state changes for active torches. -func (t RedstoneTorch) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { - currentTick := tx.CurrentTick() - if burnedOut, _ := tx.Redstone().TorchBurnoutStatus(pos, currentTick); burnedOut { - if t.updateSourceTouchesInput(pos, tx) { - t.recoverFromBurnout(pos, tx) +// ScheduledTick refreshes the lit state after the torch's one-redstone-tick inversion delay. +func (t RedstoneTorch) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { + if tx == nil { + return + } + torch, ok := tx.Block(pos).(RedstoneTorch) + if !ok { + tx.ClearRedstoneTorchBurnout(pos) + return + } + if burnedOut, _ := tx.RedstoneTorchBurnoutStatus(pos); burnedOut { + return + } + t = torch + lit := !t.attachmentPowered(pos, tx) + if t.Lit != lit { + if tx.RecordRedstoneTorchToggle(pos) { + t.burnOut(pos, tx) + return } + t.Lit = lit + tx.SetBlock(pos, t, nil) return } + tx.PruneRedstoneTorchBurnout(pos) +} - shouldBeLit := t.inputStrength(pos, tx) == 0 - if shouldBeLit == t.Lit { - return +// RedstonePower emits power from every side except the attached block while lit. +func (t RedstoneTorch) RedstonePower(_ cube.Pos, _ *world.Tx, face cube.Face) int { + if t.Lit && face != t.Facing { + return 15 } - tx.Redstone().MarkTorchSelfTriggeredIfActive(pos) - tx.ScheduleBlockUpdate(pos, t, time.Millisecond*100) + return 0 } -// recoverFromBurnout relights a burned-out torch after a real neighbouring block update once its rapid-toggle history -// has expired. Redstone propagation alone may visit calculation-only positions, so it must not recover burned-out -// torches that did not receive an actual neighbour update. -func (RedstoneTorch) recoverFromBurnout(pos cube.Pos, tx *world.Tx) bool { - torch, ok := redstoneTorchAt(pos, tx) - if !ok { - return false +// RedstoneStrongPower strongly powers the block above the torch while lit. +func (t RedstoneTorch) RedstoneStrongPower(pos cube.Pos, tx *world.Tx, face cube.Face) int { + if face == cube.FaceUp { + return t.RedstonePower(pos, tx, face) } + return 0 +} - currentTick := tx.CurrentTick() - burnedOut, recoverable := tx.Redstone().TorchBurnoutStatus(pos, currentTick) - if !burnedOut { - return false +// RedstonePowerUpdate schedules the delayed inverse-state refresh. +func (t RedstoneTorch) RedstonePowerUpdate(pos cube.Pos, tx *world.Tx, _ int) (world.Block, bool) { + if tx == nil || t.Lit == !t.attachmentPowered(pos, tx) { + return t, false } - if !recoverable { - return true + if t.recoverFromBurnout(pos, tx) { + return t, false } - tx.Redstone().ClearTorchBurnout(pos) - - torch.Lit = torch.inputStrength(pos, tx) == 0 - tx.SetBlock(pos, torch, nil) - updateTorchRedstone(pos, tx) - return true + tx.ScheduleBlockUpdate(pos, t, redstoneTicks(1)) + return t, false } -// updateSourceTouchesInput reports whether the current redstone update came from the block the torch is attached to, -// or from a block directly beside it. Dust on top of the attached block can legitimately recover a burnout loop, while -// disconnected dust visited by the broad wire walk should not. -func (t RedstoneTorch) updateSourceTouchesInput(pos cube.Pos, tx *world.Tx) bool { - source, ok := tx.Redstone().UpdateSource() - if !ok { +func (t RedstoneTorch) recoverFromBurnout(pos cube.Pos, tx *world.Tx) bool { + burnedOut, recoverable := tx.RedstoneTorchBurnoutStatus(pos) + if !burnedOut { return false } - inputPos := pos.Side(t.Facing) - if source == inputPos { + if !recoverable { return true } - for _, face := range cube.Faces() { - if source == inputPos.Side(face) { - return true - } - } - return false -} - -// ScheduledTick is called when a scheduled block update occurs. -// This method handles state changes and checks for burnout conditions. -func (RedstoneTorch) ScheduledTick(pos cube.Pos, tx *world.Tx, _ *rand.Rand) { - torch, ok := redstoneTorchAt(pos, tx) + tx.ClearRedstoneTorchBurnout(pos) + live, ok := tx.Block(pos).(RedstoneTorch) if !ok { - return - } - - currentTick := tx.CurrentTick() - if burnedOut, _ := tx.Redstone().TorchBurnoutStatus(pos, currentTick); burnedOut { - return - } - - shouldBeLit := torch.inputStrength(pos, tx) == 0 - if shouldBeLit == torch.Lit { - tx.Redstone().PruneTorchBurnout(pos, currentTick) - return + return true } - - if tx.Redstone().RecordTorchToggle(pos, currentTick) { - torch.burnOut(pos, tx) - return + lit := !live.attachmentPowered(pos, tx) + if live.Lit != lit { + live.Lit = lit + tx.SetBlock(pos, live, nil) } - - torch.Lit = !torch.Lit - tx.SetBlock(pos, torch, nil) - updateTorchRedstone(pos, tx) + return true } -// burnOut puts the redstone torch into burnout state, turning it off and playing effects. -func (RedstoneTorch) burnOut(pos cube.Pos, tx *world.Tx) { - torch, ok := redstoneTorchAt(pos, tx) - if !ok { - return - } - - tx.Redstone().BurnOutTorch(pos) - torch.Lit = false +func (t RedstoneTorch) burnOut(pos cube.Pos, tx *world.Tx) { + tx.BurnOutRedstoneTorch(pos) + t.Lit = false tx.PlaySound(pos.Vec3Centre(), sound.Fizz{}) - tx.SetBlock(pos, torch, nil) - updateTorchRedstone(pos, tx) + tx.SetBlock(pos, t, nil) } -// redstoneTorchAt returns the current torch at pos. Scheduled redstone updates carry an old block value, so mutation -// paths must reload the live world block before writing torch state back. -func redstoneTorchAt(pos cube.Pos, tx *world.Tx) (RedstoneTorch, bool) { - t, ok := tx.Block(pos).(RedstoneTorch) - if !ok { - tx.Redstone().ClearTorchBurnout(pos) +func (t RedstoneTorch) attachmentPowered(pos cube.Pos, tx *world.Tx) bool { + if tx == nil { + return false } - return t, ok -} - -// updateTorchRedstone updates receivers around the torch and behind the block it strongly powers above. -func updateTorchRedstone(pos cube.Pos, tx *world.Tx) { - tx.Redstone().WithActiveTorchUpdate(pos, func() { - updateDirectionalRedstone(pos, tx, cube.FaceUp) - }) -} - -// EncodeItem encodes the redstone torch as an item. -func (RedstoneTorch) EncodeItem() (name string, meta int16) { - return "minecraft:redstone_torch", 0 -} - -// EncodeBlock encodes the redstone torch as a block for network transmission. -func (t RedstoneTorch) EncodeBlock() (name string, properties map[string]any) { - face := "unknown" - if t.Facing != unknownFace { - face = t.Facing.String() - if t.Facing == cube.FaceDown { - face = "top" - } + attached := pos.Side(t.Facing) + if tx.RedstonePower(attached) > 0 { + return true } - if t.Lit { - return "minecraft:redstone_torch", map[string]any{"torch_facing_direction": face} + if source, ok := tx.Block(attached).(world.RedstonePowerSource); ok { + for _, face := range cube.Faces() { + if redstonePower(source.RedstonePower(attached, tx, face)) > 0 { + return true + } + } } - return "minecraft:unlit_redstone_torch", map[string]any{"torch_facing_direction": face} + return false } -// RedstoneSource ... -func (t RedstoneTorch) RedstoneSource() bool { +// HasLiquidDrops ... +func (t RedstoneTorch) HasLiquidDrops() bool { return true } -// WeakPower returns the weak redstone power level provided to adjacent blocks. -func (t RedstoneTorch) WeakPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { - if !t.Lit { - return 0 - } - if face.Opposite() == t.Facing { - return 0 - } - return 15 +// EncodeItem ... +func (t RedstoneTorch) EncodeItem() (name string, meta int16) { + return "minecraft:redstone_torch", 0 } -// StrongPower returns the strong redstone power level provided to the block above the torch. -func (t RedstoneTorch) StrongPower(_ cube.Pos, face cube.Face, _ *world.Tx, _ bool) int { - if t.Lit && face == cube.FaceDown { - return 15 +// EncodeBlock ... +func (t RedstoneTorch) EncodeBlock() (name string, properties map[string]any) { + name = "minecraft:unlit_redstone_torch" + if t.Lit { + name = "minecraft:redstone_torch" } - return 0 + return name, map[string]any{"torch_facing_direction": torchFacingDirection(t.Facing)} } -// inputStrength returns the redstone power level received by the block the torch is attached to. -func (t RedstoneTorch) inputStrength(pos cube.Pos, tx *world.Tx) int { - return tx.RedstonePower(pos.Side(t.Facing), t.Facing, true) -} - -// redstoneTorchFallbackSides lists the faces to check for placing a redstone torch on a non-solid block. -var redstoneTorchFallbackSides = [...]cube.Face{ - cube.FaceSouth, - cube.FaceWest, - cube.FaceNorth, - cube.FaceEast, - cube.FaceDown, -} - -// findTorchPlacementFace finds a valid face for placing a redstone torch on a non-solid block. -// It returns the face the torch should be placed on and whether it was found. -func findTorchPlacementFace(pos cube.Pos, tx *world.Tx) (cube.Face, bool) { - for _, side := range redstoneTorchFallbackSides { - if tx.Block(pos.Side(side)).Model().FaceSolid(pos.Side(side), side.Opposite(), tx) { - return side.Opposite(), true - } +func torchFacingDirection(face cube.Face) string { + switch face { + case cube.FaceDown: + return "top" + case unknownFace: + return "unknown" + default: + return face.String() } - return 0, false } -// allRedstoneTorches returns all possible redstone torch block states. -func allRedstoneTorches() (all []world.Block) { - for _, f := range append(cube.Faces(), unknownFace) { - if f == cube.FaceUp { - continue +func allRedstoneTorches() (torches []world.Block) { + for _, face := range cube.Faces() { + if face == cube.FaceUp { + face = unknownFace } - all = append(all, RedstoneTorch{Facing: f, Lit: true}) - all = append(all, RedstoneTorch{Facing: f}) + torches = append(torches, RedstoneTorch{Facing: face}, RedstoneTorch{Facing: face, Lit: true}) } return } diff --git a/server/block/redstone_torch_test.go b/server/block/redstone_torch_test.go new file mode 100644 index 000000000..3a4a6effe --- /dev/null +++ b/server/block/redstone_torch_test.go @@ -0,0 +1,93 @@ +package block + +import ( + "fmt" + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +func TestRedstoneTorchPowerAndEncode(t *testing.T) { + torch := RedstoneTorch{Facing: cube.FaceDown, Lit: true} + if power := torch.RedstonePower(cube.Pos{}, nil, cube.FaceUp); power != 15 { + t.Fatalf("lit redstone torch output = %d, want 15", power) + } + if power := torch.RedstonePower(cube.Pos{}, nil, cube.FaceDown); power != 0 { + t.Fatalf("redstone torch attached-face output = %d, want 0", power) + } + if power := (RedstoneTorch{Facing: cube.FaceDown}).RedstonePower(cube.Pos{}, nil, cube.FaceUp); power != 0 { + t.Fatalf("unlit redstone torch output = %d, want 0", power) + } + if light := torch.LightEmissionLevel(); light != 7 { + t.Fatalf("lit redstone torch light = %d, want 7", light) + } + + name, props := torch.EncodeBlock() + if name != "minecraft:redstone_torch" { + t.Fatalf("redstone torch block name = %q, want minecraft:redstone_torch", name) + } + if facing := props["torch_facing_direction"]; facing != "top" { + t.Fatalf("torch_facing_direction = %v, want top", facing) + } + if _, ok := world.BlockByName(name, props); !ok { + t.Fatalf("BlockByName(%s, %#v) was not found", name, props) + } + + name, props = (RedstoneTorch{Facing: cube.FaceNorth}).EncodeBlock() + if name != "minecraft:unlit_redstone_torch" { + t.Fatalf("unlit redstone torch block name = %q, want minecraft:unlit_redstone_torch", name) + } + if facing := props["torch_facing_direction"]; facing != "north" { + t.Fatalf("unlit torch_facing_direction = %v, want north", facing) + } + if count := len(allRedstoneTorches()); count != len(cube.Faces())*2 { + t.Fatalf("allRedstoneTorches returned %d states, want %d", count, len(cube.Faces())*2) + } +} + +func TestRedstoneTorchInverseScheduledTick(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + support := cube.Pos{0, 1, 0} + pos := support.Side(cube.FaceUp) + tx.SetBlock(support, RedstoneBlock{}, nil) + torch := RedstoneTorch{Facing: cube.FaceDown, Lit: true} + tx.SetBlock(pos, torch, nil) + torch.ScheduledTick(pos, tx, nil) + after, ok := tx.Block(pos).(RedstoneTorch) + if !ok { + err = fmt.Errorf("redstone torch missing after scheduled tick") + return + } + if after.Lit { + err = fmt.Errorf("redstone torch stayed lit while attached block was powered") + } + }) + if err != nil { + t.Fatal(err) + } +} + +func TestRedstoneTorchRegisteredStates(t *testing.T) { + if b, ok := world.BlockByName("minecraft:redstone_torch", map[string]any{ + "torch_facing_direction": "west", + }); !ok { + t.Fatal("lit redstone torch state was not registered") + } else if torch, ok := b.(RedstoneTorch); !ok || !torch.Lit || torch.Facing != cube.FaceWest { + t.Fatalf("registered lit redstone torch = %#v, want west lit RedstoneTorch", b) + } + + if b, ok := world.BlockByName("minecraft:unlit_redstone_torch", map[string]any{ + "torch_facing_direction": "top", + }); !ok { + t.Fatal("unlit redstone torch state was not registered") + } else if torch, ok := b.(RedstoneTorch); !ok || torch.Lit || torch.Facing != cube.FaceDown { + t.Fatalf("registered unlit redstone torch = %#v, want standing unlit RedstoneTorch", b) + } +} diff --git a/server/block/redstone_wire.go b/server/block/redstone_wire.go index 3c110dcc4..c9511bcb6 100644 --- a/server/block/redstone_wire.go +++ b/server/block/redstone_wire.go @@ -2,7 +2,6 @@ package block import ( "github.com/df-mc/dragonfly/server/block/cube" - "github.com/df-mc/dragonfly/server/block/model" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" @@ -28,14 +27,14 @@ func (RedstoneWire) HasLiquidDrops() bool { // BreakInfo ... func (r RedstoneWire) BreakInfo() BreakInfo { return newBreakInfo(0, alwaysHarvestable, nothingEffective, oneOf(RedstoneWire{})).withBreakHandler(func(pos cube.Pos, tx *world.Tx, _ item.User) { - updateStrongRedstone(pos, tx) + tx.ScheduleRedstoneUpdate(pos) }) } // EncodeBlock ... func (r RedstoneWire) EncodeBlock() (string, map[string]any) { return "minecraft:redstone_wire", map[string]any{ - "redstone_signal": int32(r.Power), + "redstone_signal": int32(redstonePower(r.Power)), } } @@ -47,211 +46,225 @@ func (RedstoneWire) EncodeItem() (name string, meta int16) { // UseOnBlock ... func (r RedstoneWire) UseOnBlock(pos cube.Pos, face cube.Face, _ mgl64.Vec3, tx *world.Tx, user item.User, ctx *item.UseContext) bool { pos, _, used := firstReplaceable(tx, pos, face, r) - if !used { + if !used || !redstoneWireSupported(tx, pos) { return false } - belowPos := pos.Side(cube.FaceDown) - if !tx.Block(belowPos).Model().FaceSolid(belowPos, cube.FaceUp, tx) { - return false - } - r.Power = r.calculatePower(pos, tx) + r.Power = tx.RedstonePower(pos) place(tx, pos, r, user, ctx) - if placed(ctx) { - updateStrongRedstone(pos, tx) - return true - } - return false + return placed(ctx) } // NeighbourUpdateTick ... -func (r RedstoneWire) NeighbourUpdateTick(pos, neighbour cube.Pos, tx *world.Tx) { - if pos == neighbour { - // Ignore the self-update sent after this wire's block state changes. - return - } - below := pos.Side(cube.FaceDown) - if !tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) { +func (r RedstoneWire) NeighbourUpdateTick(pos, _ cube.Pos, tx *world.Tx) { + if !redstoneWireSupported(tx, pos) { breakBlock(r, pos, tx) - return - } - if changed, ok := r.updateFromNeighbour(pos, tx); ok && !changed { - updateStrongRedstone(pos, tx) } } -// RedstoneUpdate ... -func (r RedstoneWire) RedstoneUpdate(pos cube.Pos, tx *world.Tx) { - r.updatePower(pos, tx) +// RedstonePower returns the wire's current signal strength from connected faces. +func (r RedstoneWire) RedstonePower(pos cube.Pos, tx *world.Tx, face cube.Face) int { + if face == cube.FaceDown { + return 0 + } + if tx != nil && redstoneWireFaceHorizontal(face) && !redstoneWirePowersHorizontalFace(pos, tx, face) { + return 0 + } + return redstonePower(r.Power) } -// updateFromNeighbour updates the wire after a neighbour change. changed reports whether the wire's power changed, -// and ok reports whether the update was allowed by the redstone update handler. -func (r RedstoneWire) updateFromNeighbour(pos cube.Pos, tx *world.Tx) (changed bool, ok bool) { - if redstoneUpdateCancelled(pos, tx) { - return false, false - } - return r.updatePower(pos, tx), true +// RedstoneSignalLoss returns the signal loss through a wire segment. +func (RedstoneWire) RedstoneSignalLoss(cube.Pos, *world.Tx, cube.Face, cube.Face) int { + return 1 } -// updatePower recalculates the wire's power and propagates the network when the power changes. -func (r RedstoneWire) updatePower(pos cube.Pos, tx *world.Tx) bool { - if power := r.calculatePower(pos, tx); r.Power != power { - r.Power = power - tx.SetBlock(pos, r, &world.SetOpts{DisableBlockUpdates: true}) - updateStrongRedstone(pos, tx) - return true +// RedstoneRelayerNeighbours returns all wire positions directly connected to this dust, including dust stepping up or +// down adjacent blocks. +func (RedstoneWire) RedstoneRelayerNeighbours(pos cube.Pos, tx *world.Tx) []cube.Pos { + neighbours := make([]cube.Pos, 0, 12) + faces := redstoneWirePoweredHorizontalFaces(pos, tx) + for _, face := range cube.HorizontalFaces() { + if !faces[face] { + continue + } + side := pos.Side(face) + if side.OutOfBounds(tx.Range()) { + continue + } + positions := redstoneWireHorizontalConnectionPositions(pos, tx, face) + if len(positions) != 0 { + neighbours = append(neighbours, positions...) + continue + } + if redstoneWireRelevantLoaded(tx, side) { + neighbours = append(neighbours, side) + } } - return false + return neighbours } -// RedstoneSource ... -func (RedstoneWire) RedstoneSource() bool { - return false +// RedstonePowerUpdate updates the wire strength to match its strongest input. +func (r RedstoneWire) RedstonePowerUpdate(_ cube.Pos, _ *world.Tx, power int) (world.Block, bool) { + power = redstonePower(power) + if r.Power == power { + return r, false + } + r.Power = power + return r, true } -// WeaklyPowersBlocks returns true because powered redstone dust weakly powers conductive blocks it points into or rests on top of. -func (RedstoneWire) WeaklyPowersBlocks() bool { - return true +func redstoneWireSupported(tx *world.Tx, pos cube.Pos) bool { + below := pos.Side(cube.FaceDown) + if below.OutOfBounds(tx.Range()) { + return false + } + return tx.Block(below).Model().FaceSolid(below, cube.FaceUp, tx) } -// WeakPower returns the power emitted by the wire toward a neighbouring receiver. Dust powers upward, never powers -// downward, and only powers horizontal receivers in connected directions. A powered wire with no horizontal -// connections behaves as an unconnected cross and powers every horizontal side. -func (r RedstoneWire) WeakPower(pos cube.Pos, face cube.Face, tx *world.Tx, accountForDust bool) int { - if !accountForDust { - return 0 - } - if face == cube.FaceUp { - return r.Power - } - if face == cube.FaceDown { - return 0 - } - if !r.hasHorizontalRedstoneConnection(pos, tx) { - return r.Power - } - if r.connection(pos, face.Opposite(), tx) { - return r.Power - } - if r.connection(pos, face, tx) && !r.connection(pos, face.RotateLeft(), tx) && !r.connection(pos, face.RotateRight(), tx) { - return r.Power +func redstoneWireSupportedLoaded(tx *world.Tx, pos cube.Pos) bool { + below := pos.Side(cube.FaceDown) + if below.OutOfBounds(tx.Range()) { + return false } - return 0 + b, ok := tx.BlockLoaded(below) + return ok && b.Model().FaceSolid(below, cube.FaceUp, tx) } -// StrongPower returns 0 because redstone dust weakly powers conductive blocks rather than strongly powering them. -func (RedstoneWire) StrongPower(cube.Pos, cube.Face, *world.Tx, bool) int { - return 0 +func redstoneWireBlocksConnectionLoaded(tx *world.Tx, pos cube.Pos, face cube.Face) bool { + if pos.OutOfBounds(tx.Range()) { + return true + } + b, ok := tx.BlockLoaded(pos) + return ok && b.Model().FaceSolid(pos, face, tx) } -// calculatePower returns the highest level of received redstone power at the provided position. -func (r RedstoneWire) calculatePower(pos cube.Pos, tx *world.Tx) int { - return calculateRedstoneWirePower(pos, tx, tx.Block) +func redstoneWirePowersHorizontalFace(pos cube.Pos, tx *world.Tx, face cube.Face) bool { + return redstoneWirePoweredHorizontalFaces(pos, tx)[face] } -// calculateRedstoneWirePower returns the highest level of received redstone power at the provided position. blockAt is -// injected so direct updates and the BFS wire network share the same rules while the BFS path may still read cached -// node state. -func calculateRedstoneWirePower(pos cube.Pos, tx *world.Tx, blockAt func(cube.Pos) world.Block) int { - aboveBlocksVerticalTravel := blocksRedstoneWireVerticalTravel(blockAt(pos.Side(cube.FaceUp))) - var blockPower, wirePower int - for _, side := range cube.Faces() { - neighbourPos := pos.Side(side) - neighbour := blockAt(neighbourPos) - - wirePower = maxRedstoneWirePower(neighbour, wirePower) - blockPower = max(blockPower, tx.RedstonePower(neighbourPos, side, false)) - - if side.Axis() == cube.Y { - // Only check horizontal neighbours from here on. - continue - } - - if canRedstoneWireStepDown(pos, neighbourPos, neighbour, tx) && !aboveBlocksVerticalTravel { - wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceUp)), wirePower) +func redstoneWirePoweredHorizontalFaces(pos cube.Pos, tx *world.Tx) map[cube.Face]bool { + connections := make(map[cube.Face]bool, len(cube.HorizontalFaces())) + for _, face := range cube.HorizontalFaces() { + if len(redstoneWireHorizontalConnectionPositions(pos, tx, face)) != 0 { + connections[face] = true } - if canRedstoneWireStepDown(neighbourPos.Side(cube.FaceDown), neighbourPos, neighbour, tx) && !blocksRedstoneWireVerticalTravel(neighbour) { - wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceDown)), wirePower) + } + switch len(connections) { + case 0: + for _, face := range cube.HorizontalFaces() { + connections[face] = true } - - if _, neighbourSolid := neighbour.Model().(model.Solid); !neighbourSolid { - wirePower = maxRedstoneWirePower(blockAt(neighbourPos.Side(cube.FaceDown)), wirePower) + case 1: + for face := range connections { + connections[face.Opposite()] = true } } - return max(blockPower, wirePower-1) + return connections } -// hasHorizontalRedstoneConnection checks if the dust connects horizontally to redstone wire or a redstone source. It -// does not include passive receivers such as doors, trapdoors, or note blocks. -func (r RedstoneWire) hasHorizontalRedstoneConnection(pos cube.Pos, tx *world.Tx) bool { - for _, face := range cube.HorizontalFaces() { - if r.connection(pos, face, tx) { - return true +func redstoneWireHorizontalConnectionPositions(pos cube.Pos, tx *world.Tx, face cube.Face) []cube.Pos { + side := pos.Side(face) + if side.OutOfBounds(tx.Range()) { + return nil + } + positions := make([]cube.Pos, 0, 3) + if redstoneWireDirectConnectionLoaded(tx, side, face.Opposite()) { + positions = append(positions, side) + } + + above := pos.Side(cube.FaceUp) + sideAbove := side.Side(cube.FaceUp) + if !redstoneWireBlocksConnectionLoaded(tx, above, cube.FaceDown) && redstoneWireAtLoaded(tx, sideAbove) && redstoneWireSupportedLoaded(tx, sideAbove) { + positions = append(positions, sideAbove) + } + if !redstoneWireBlocksConnectionLoaded(tx, side, cube.FaceUp) { + down := side.Side(cube.FaceDown) + if !down.OutOfBounds(tx.Range()) && redstoneWireAtLoaded(tx, down) { + positions = append(positions, down) } } - return false + return positions } -// connection returns true if the dust shape connects through the given face to another wire or a redstone source. It -// also accounts for valid one-block vertical wire connections. -func (r RedstoneWire) connection(pos cube.Pos, face cube.Face, tx *world.Tx) bool { - sidePos := pos.Side(face) - sideBlock := tx.Block(sidePos) - if r.connectsAbove(pos, sidePos, sideBlock, tx) || r.connectsTo(sideBlock, true) { +func redstoneWireDirectConnectionLoaded(tx *world.Tx, pos cube.Pos, face cube.Face) bool { + b, ok := tx.BlockLoaded(pos) + if !ok { + return false + } + if _, ok := b.(RedstoneWire); ok { return true } - return r.connectsBelow(sidePos, sideBlock, tx) + if _, ok := b.(world.RedstonePowerSource); ok { + return true + } + if _, ok := b.(world.RedstoneStrongPowerSource); ok { + return true + } + if _, ok := b.(world.RedstonePowerRelayer); ok { + return true + } + return redstoneWireNonSolidComponent(pos, b, tx, face) } -// connectsAbove checks if the redstone wire can connect to the block above it. -func (r RedstoneWire) connectsAbove(pos, sidePos cube.Pos, sideBlock world.Block, tx *world.Tx) bool { - if blocksRedstoneWireVerticalTravel(tx.Block(pos.Side(cube.FaceUp))) || !r.canRunOnTop(tx, sidePos, sideBlock) { +func redstoneWireNonSolidComponent(pos cube.Pos, b world.Block, tx *world.Tx, face cube.Face) bool { + model := b.Model() + if model == nil || model.FaceSolid(pos, face, tx) { return false } - return r.connectsTo(tx.Block(sidePos.Side(cube.FaceUp)), false) -} - -// connectsBelow checks if the redstone wire can connect to the block below it. -func (r RedstoneWire) connectsBelow(sidePos cube.Pos, sideBlock world.Block, tx *world.Tx) bool { - _, sideSolid := sideBlock.Model().(model.Solid) - return !sideSolid && r.connectsTo(tx.Block(sidePos.Side(cube.FaceDown)), false) + if _, ok := b.(world.RedstonePowerConsumer); ok { + return true + } + if _, ok := b.(world.RedstonePowerTransitionConsumer); ok { + return true + } + if _, ok := b.(world.RedstonePowerAction); ok { + return true + } + return false } -// connectsTo reports whether a block is part of the redstone wire connection graph. Passive redstone receivers are not -// connections; direct source conductors count only when allowDirectSources is true. -func (RedstoneWire) connectsTo(block world.Block, allowDirectSources bool) bool { - if _, ok := block.(RedstoneWire); ok { - return true +func redstoneWireAtLoaded(tx *world.Tx, pos cube.Pos) bool { + b, ok := tx.BlockLoaded(pos) + if !ok { + return false } - c, ok := block.(world.Conductor) - return ok && allowDirectSources && c.RedstoneSource() + _, ok = b.(RedstoneWire) + return ok } -// canRunOnTop checks whether redstone dust can be placed on top of the block. -func (RedstoneWire) canRunOnTop(tx *world.Tx, pos cube.Pos, block world.Block) bool { - return block.Model().FaceSolid(pos, cube.FaceUp, tx) +func redstoneWireRelevantLoaded(tx *world.Tx, pos cube.Pos) bool { + b, ok := tx.BlockLoaded(pos) + return ok && redstoneWireRelevant(b) } -// blocksRedstoneWireVerticalTravel checks if the block above redstone wire blocks vertical wire travel. -func blocksRedstoneWireVerticalTravel(block world.Block) bool { - if _, ok := block.Model().(model.Solid); !ok { - return false +func redstoneWireRelevant(b world.Block) bool { + if _, ok := b.(world.RedstonePowerSource); ok { + return true + } + if _, ok := b.(world.RedstoneStrongPowerSource); ok { + return true + } + if _, ok := b.(world.RedstonePowerRelayer); ok { + return true } - diffuser, ok := block.(LightDiffuser) - return !ok || diffuser.LightDiffusionLevel() != 0 + if _, ok := b.(world.RedstonePowerConsumer); ok { + return true + } + if _, ok := b.(world.RedstonePowerTransitionConsumer); ok { + return true + } + if _, ok := b.(world.RedstonePowerAction); ok { + return true + } + return false } -// canRedstoneWireStepDown checks if redstone dust can provide power while travelling down around the side block. -func canRedstoneWireStepDown(from, side cube.Pos, block world.Block, tx *world.Tx) bool { - if stepDowner, ok := block.(RedstoneWireStepDowner); ok { - return stepDowner.CanRedstoneWireStepDown(side, from, tx) - } - for _, face := range cube.Faces() { - if !block.Model().FaceSolid(side, face, tx) { - return false - } +func redstoneWireFaceHorizontal(face cube.Face) bool { + switch face { + case cube.FaceNorth, cube.FaceSouth, cube.FaceWest, cube.FaceEast: + return true + default: + return false } - return true } // TrimMaterial delegates to item.RedstoneWire so the block form stays valid for smithing trim decoding too. diff --git a/server/world/block.go b/server/world/block.go index 165cce560..21882b214 100644 --- a/server/world/block.go +++ b/server/world/block.go @@ -71,56 +71,6 @@ type Liquid interface { LiquidRemoveBlock(pos cube.Pos, tx *Tx, removed Block) } -// Conductor represents a block that can conduct a redstone signal. -type Conductor interface { - Block - // RedstoneSource returns true if the conductor is a signal source. - RedstoneSource() bool - - // WeakPower returns the weak power level emitted by this conductor toward a neighbouring receiver. - // The face argument is relative to the receiving block, not this conductor. - // Weak power can pass through a solid block to power redstone components on the other side, but - // cannot power solid blocks themselves or travel further. - // The accountForDust parameter indicates whether redstone dust should be considered when - // calculating power levels. - WeakPower(pos cube.Pos, face cube.Face, tx *Tx, accountForDust bool) int - - // StrongPower returns the strong power level emitted by this conductor toward a neighbouring - // receiver. The face argument uses the same convention as WeakPower. - // Strong power can be transmitted through solid blocks. When a solid block receives strong power - // through one of its faces, it can provide weak power to adjacent redstone components on all other - // faces. Strong power can also directly power any redstone component. - // The accountForDust parameter indicates whether redstone dust should be considered when - // calculating power levels. - StrongPower(pos cube.Pos, face cube.Face, tx *Tx, accountForDust bool) int -} - -// WeakBlockPowerer represents a conductor whose weak power may weakly power an adjacent conductive block. Weakly -// powered blocks may activate mechanisms and repeaters, but do not power adjacent redstone dust. For example, -// dust pointing into a stone block opens a door on the stone's far side, but a second stretch of dust there stays -// dark. -type WeakBlockPowerer interface { - Conductor - // WeaklyPowersBlocks returns true if this conductor's WeakPower can make an adjacent conductive block weakly powered. - WeaklyPowersBlocks() bool -} - -// RedstonePowerRelayer represents a block with custom behaviour for whether -// neighbouring redstone power may be relayed through it by Tx.RedstonePower. -type RedstonePowerRelayer interface { - Block - // RelaysRedstonePowerThrough reports whether this non-conductor block may - // relay neighbouring redstone power through itself to receivers on its other sides. - RelaysRedstonePowerThrough() bool -} - -// RedstoneUpdater represents a block that reacts to nearby redstone power changes. -type RedstoneUpdater interface { - Block - // RedstoneUpdate is called when a change in redstone signal is computed. - RedstoneUpdate(pos cube.Pos, tx *Tx) -} - // RegisterBlock registers the Block passed in the DefaultBlockRegistry. // // This function exists for backwards compatibility and works well for the common "single server per process" setup, diff --git a/server/world/conf.go b/server/world/conf.go index 94bede71e..b2772f2d7 100644 --- a/server/world/conf.go +++ b/server/world/conf.go @@ -119,6 +119,7 @@ func (conf Config) New() *World { s := conf.Provider.Settings() w := &World{ scheduledUpdates: newScheduledTickQueue(s.CurrentTick), + redstone: newRedstoneEngine(s.CurrentTick), entities: make(map[*EntityHandle]ChunkPos), viewers: make(map[*Loader]Viewer), chunks: make(map[ChunkPos]*Column), diff --git a/server/world/handler.go b/server/world/handler.go index bdd248ae1..80a8f4c68 100644 --- a/server/world/handler.go +++ b/server/world/handler.go @@ -8,6 +8,14 @@ import ( type Context = event.Context[*Tx] +// RedstoneHandler may be implemented by world Handlers to receive detailed redstone updates. It is intentionally +// separate from Handler so existing handlers remain source-compatible. +type RedstoneHandler interface { + // HandleRedstoneUpdate handles a redstone update proposed by the World redstone engine. ctx.Cancel() may be + // called to suppress the proposed redstone mutation and any propagation from that mutation. + HandleRedstoneUpdate(ctx *Context, update RedstoneUpdate) +} + // Handler handles events that are called by a world. Implementations of // Handler may be used to listen to specific events such as when an Entity is // added to the world. @@ -63,9 +71,6 @@ type Handler interface { // The affected entities, affected blocks, item drop chance, and whether the // explosion spawns fire may be altered. HandleExplosion(ctx *Context, position mgl64.Vec3, entities *[]Entity, blocks *[]cube.Pos, itemDropChance *float64, spawnFire *bool) - // HandleRedstoneUpdate handles a redstone update at a position. ctx.Cancel() may be called - // to cancel the redstone update. - HandleRedstoneUpdate(ctx *Context, pos cube.Pos) // HandleClose handles the World being closed. HandleClose may be used as a // moment to finish code running on other goroutines that operates on the // World specifically. HandleClose is called directly before the World stops @@ -81,6 +86,7 @@ var _ Handler = (*NopHandler)(nil) // Users may embed NopHandler to avoid having to implement each method. type NopHandler struct{} +func (NopHandler) HandleRedstoneUpdate(*Context, cube.Pos) {} func (NopHandler) HandleLiquidFlow(*Context, cube.Pos, cube.Pos, Liquid, Block) {} func (NopHandler) HandleLiquidDecay(*Context, cube.Pos, Liquid, Liquid) {} func (NopHandler) HandleLiquidHarden(*Context, cube.Pos, Block, Block, Block) {} @@ -92,5 +98,4 @@ func (NopHandler) HandleLeavesDecay(*Context, cube.Pos) func (NopHandler) HandleEntitySpawn(*Tx, Entity) {} func (NopHandler) HandleEntityDespawn(*Tx, Entity) {} func (NopHandler) HandleExplosion(*Context, mgl64.Vec3, *[]Entity, *[]cube.Pos, *float64, *bool) {} -func (NopHandler) HandleRedstoneUpdate(*Context, cube.Pos) {} func (NopHandler) HandleClose(*Tx) {} diff --git a/server/world/redstone.go b/server/world/redstone.go new file mode 100644 index 000000000..31ca73970 --- /dev/null +++ b/server/world/redstone.go @@ -0,0 +1,820 @@ +package world + +import ( + "encoding/binary" + "hash/fnv" + "maps" + "slices" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/event" +) + +// RedstoneUpdateCause describes the world event that caused a redstone update to be evaluated. +type RedstoneUpdateCause uint8 + +const ( + // RedstoneUpdateCauseBlockUpdate means a block or liquid change invalidated nearby redstone. + RedstoneUpdateCauseBlockUpdate RedstoneUpdateCause = iota + // RedstoneUpdateCauseScheduledTick means a scheduled redstone tick invalidated a component. + RedstoneUpdateCauseScheduledTick +) + +// RedstoneUpdate represents a redstone state transition proposed by the world redstone engine. Handlers may cancel +// the event to suppress the proposed mutation and any propagation from that mutation. +type RedstoneUpdate struct { + // Pos is the block position that will receive the update. + Pos cube.Pos + // ChangedNeighbour is the neighbouring block that caused the update, if any. + ChangedNeighbour cube.Pos + // Before is the block currently at Pos. + Before Block + // After is the block that will replace Before, if the update is a block-state update. After is nil for updates + // that perform side effects instead of replacing the block. + After Block + // OldPower is the last redstone power observed by the engine at Pos. + OldPower int + // NewPower is the redstone power observed by the engine at Pos for this update. + NewPower int + // CurrentTick is the world tick during which the update was evaluated. + CurrentTick int64 + // NetworkID identifies the compiled dynamic redstone region that produced the update. + NetworkID uint64 + // Cause identifies why the update was evaluated. + Cause RedstoneUpdateCause +} + +// RedstonePowerSource is implemented by blocks that emit redstone power. The face passed is the face of the source +// block that power is being read from. +type RedstonePowerSource interface { + RedstonePower(pos cube.Pos, tx *Tx, face cube.Face) int +} + +// RedstoneStrongPowerSource is implemented by sources that strongly power blocks from specific faces. Strong power may +// pass through solid blocks, unlike weak redstone wire power. +type RedstoneStrongPowerSource interface { + RedstoneStrongPower(pos cube.Pos, tx *Tx, face cube.Face) int +} + +// RedstonePowerRelayer is implemented by redstone wire-like blocks that relay power through a compiled redstone +// network. The returned value is the signal loss when power enters through from and leaves through to. +type RedstonePowerRelayer interface { + RedstoneSignalLoss(pos cube.Pos, tx *Tx, from, to cube.Face) int +} + +// RedstonePowerRelayerNeighbourer may be implemented by relayers with non-adjacent connections, such as redstone +// wire stepping up and down block edges. +type RedstonePowerRelayerNeighbourer interface { + RedstoneRelayerNeighbours(pos cube.Pos, tx *Tx) []cube.Pos +} + +// RedstonePowerConsumer is implemented by blocks whose block state changes when their input power changes. The +// returned block is written to the world if changed is true and the redstone update event is not cancelled. +type RedstonePowerConsumer interface { + RedstonePowerUpdate(pos cube.Pos, tx *Tx, power int) (after Block, changed bool) +} + +// RedstonePowerTransitionConsumer may be implemented by consumers that need the previous and new input power to +// distinguish a real redstone transition from a same-power block update. +type RedstonePowerTransitionConsumer interface { + RedstonePowerTransitionUpdate(pos cube.Pos, tx *Tx, oldPower, newPower int) (after Block, changed bool) +} + +// RedstonePowerSounder may be implemented by consumers that play a sound after an uncancelled redstone-driven state +// change. +type RedstonePowerSounder interface { + RedstonePowerUpdateSound(pos cube.Pos, tx *Tx, before, after Block, oldPower, newPower int) Sound +} + +// RedstonePowerPostUpdater may be implemented by consumers that need to apply side effects after an uncancelled +// redstone state update, such as syncing the other half of a door. +type RedstonePowerPostUpdater interface { + RedstonePowerPostUpdate(pos cube.Pos, tx *Tx, before, after Block, oldPower, newPower int) +} + +// RedstonePowerAction is implemented by blocks that perform a side effect when their input power changes, such as TNT +// priming on a rising edge. The action is run only if the redstone update event is not cancelled. The returned bool +// reports whether a side effect was performed. +type RedstonePowerAction interface { + RedstonePowerAction(pos cube.Pos, tx *Tx, oldPower, newPower int) bool +} + +type redstoneEngine struct { + currentTick int64 + dirty map[cube.Pos]redstoneDirty + power map[cube.Pos]int + output map[cube.Pos]int + evaluating map[cube.Pos]struct{} + suppressedSources map[cube.Pos]int + torchBurnout map[cube.Pos]redstoneTorchBurnout +} + +type redstoneDirty struct { + changed cube.Pos + cause RedstoneUpdateCause +} + +type redstoneGraph struct { + id uint64 + nodes []redstoneNode + edges []redstoneEdge +} + +type redstoneNode struct { + pos cube.Pos + power int + source bool + sink bool +} + +type redstoneEdge struct { + from, to int + weight int +} + +func newRedstoneEngine(tick int64) *redstoneEngine { + return &redstoneEngine{ + currentTick: tick, + dirty: make(map[cube.Pos]redstoneDirty), + power: make(map[cube.Pos]int), + output: make(map[cube.Pos]int), + evaluating: make(map[cube.Pos]struct{}), + } +} + +func (e *redstoneEngine) invalidateAround(pos, changed cube.Pos, cause RedstoneUpdateCause, r cube.Range) { + if e == nil || pos.OutOfBounds(r) { + return + } + e.invalidate(pos, changed, cause, r) + pos.Neighbours(func(neighbour cube.Pos) { + e.invalidate(neighbour, changed, cause, r) + }, r) +} + +func (e *redstoneEngine) invalidate(pos, changed cube.Pos, cause RedstoneUpdateCause, r cube.Range) { + if pos.OutOfBounds(r) { + return + } + e.dirty[pos] = redstoneDirty{changed: changed, cause: cause} +} + +func (e *redstoneEngine) removeChunk(chunkPos ChunkPos) { + if e == nil { + return + } + maps.DeleteFunc(e.dirty, func(pos cube.Pos, _ redstoneDirty) bool { + return chunkPosFromBlockPos(pos) == chunkPos + }) + maps.DeleteFunc(e.dirty, func(_ cube.Pos, dirty redstoneDirty) bool { + return chunkPosFromBlockPos(dirty.changed) == chunkPos + }) + maps.DeleteFunc(e.power, func(pos cube.Pos, _ int) bool { + return chunkPosFromBlockPos(pos) == chunkPos + }) + maps.DeleteFunc(e.output, func(pos cube.Pos, _ int) bool { + return chunkPosFromBlockPos(pos) == chunkPos + }) + maps.DeleteFunc(e.torchBurnout, func(pos cube.Pos, _ redstoneTorchBurnout) bool { + return chunkPosFromBlockPos(pos) == chunkPos + }) +} + +func (e *redstoneEngine) forget(pos cube.Pos) { + if e == nil { + return + } + delete(e.power, pos) + delete(e.output, pos) + delete(e.evaluating, pos) +} + +func (e *redstoneEngine) tick(tx *Tx, tick int64) { + if e == nil || len(e.dirty) == 0 { + return + } + e.currentTick = tick + dirty := maps.Clone(e.dirty) + clear(e.dirty) + + candidates := slices.Collect(maps.Keys(dirty)) + slices.SortFunc(candidates, compareBlockPos) + + graph := e.compile(tx, candidates) + cancelledSources, checkedSources := e.updateGraphSources(tx, graph, dirty) + previousSuppressed := e.suppressedSources + e.suppressedSources = cancelledSources + defer func() { + e.suppressedSources = previousSuppressed + }() + + powers := e.graphPower(tx, graph) + for i, node := range graph.nodes { + d := dirty[node.pos] + if node.sink { + e.update(tx, node.pos, d.changed, d.cause, graph.id, powers[i]) + } + } + for _, node := range graph.nodes { + if _, ok := checkedSources[node.pos]; ok { + continue + } + d := dirty[node.pos] + if node.source { + e.updateSource(tx, node.pos, d.changed, d.cause, graph.id) + } + } +} + +func (e *redstoneEngine) compile(tx *Tx, candidates []cube.Pos) redstoneGraph { + nodes := make([]redstoneNode, 0, len(candidates)) + seen := make(map[cube.Pos]struct{}, len(candidates)*2) + for _, pos := range candidates { + e.compileRegion(tx, pos, seen, &nodes) + pos.Neighbours(func(neighbour cube.Pos) { + if b, ok := tx.World().blockLoaded(neighbour); ok && isRedstoneRelevant(b) { + e.compileRegion(tx, neighbour, seen, &nodes) + } + }, tx.Range()) + } + slices.SortFunc(nodes, func(a, b redstoneNode) int { + return compareBlockPos(a.pos, b.pos) + }) + edges := e.compileEdges(tx, nodes) + return redstoneGraph{id: redstoneGraphID(nodes, edges), nodes: nodes, edges: edges} +} + +func (e *redstoneEngine) compileRegion(tx *Tx, pos cube.Pos, seen map[cube.Pos]struct{}, nodes *[]redstoneNode) { + if _, ok := seen[pos]; ok || pos.OutOfBounds(tx.Range()) { + return + } + queue := []cube.Pos{pos} + for len(queue) != 0 { + p := queue[0] + queue = queue[1:] + if _, ok := seen[p]; ok || p.OutOfBounds(tx.Range()) { + continue + } + seen[p] = struct{}{} + + b, ok := tx.World().blockLoaded(p) + if !ok { + continue + } + source, consumer, action, relayer := classifyRedstoneBlock(b) + if !source && !consumer && !action && !relayer { + continue + } + *nodes = append(*nodes, redstoneNode{ + pos: p, + source: source, + sink: consumer || action, + }) + if !relayer { + continue + } + for _, neighbour := range e.redstoneRelayerNeighbourPositions(tx, p, b) { + if b, ok := tx.World().blockLoaded(neighbour); ok && isRedstoneRelevant(b) { + queue = append(queue, neighbour) + } + } + } +} + +func (e *redstoneEngine) update(tx *Tx, pos, changed cube.Pos, cause RedstoneUpdateCause, graphID uint64, newPower int) { + b := tx.Block(pos) + oldPower, newPower := e.power[pos], clampRedstonePower(newPower) + + action, hasAction := b.(RedstonePowerAction) + actionChanged := hasAction && oldPower != newPower + after, blockChanged := b, false + if consumer, ok := b.(RedstonePowerTransitionConsumer); ok { + after, blockChanged = consumer.RedstonePowerTransitionUpdate(pos, tx, oldPower, newPower) + } else if consumer, ok := b.(RedstonePowerConsumer); ok { + after, blockChanged = consumer.RedstonePowerUpdate(pos, tx, newPower) + } + + if oldPower != newPower || blockChanged || actionChanged { + var updateAfter Block + if blockChanged { + updateAfter = after + } + update := RedstoneUpdate{ + Pos: pos, + ChangedNeighbour: changed, + Before: b, + After: updateAfter, + OldPower: oldPower, + NewPower: newPower, + CurrentTick: e.currentTick, + NetworkID: graphID, + Cause: cause, + } + if !e.redstoneUpdateAllowed(tx, update) { + return + } + } + + if !blockChanged && !actionChanged { + e.power[pos] = newPower + return + } + + if blockChanged { + tx.SetBlock(pos, after, &SetOpts{DisableRedstoneUpdates: true}) + e.invalidateAround(pos, pos, RedstoneUpdateCauseBlockUpdate, tx.Range()) + } + if blockChanged { + if postUpdater, ok := b.(RedstonePowerPostUpdater); ok { + postUpdater.RedstonePowerPostUpdate(pos, tx, b, after, oldPower, newPower) + } + if sounder, ok := b.(RedstonePowerSounder); ok { + if s := sounder.RedstonePowerUpdateSound(pos, tx, b, after, oldPower, newPower); s != nil { + tx.PlaySound(pos.Vec3Centre(), s) + } + } + } + acted := false + if actionChanged { + acted = action.RedstonePowerAction(pos, tx, oldPower, newPower) + } + if blockChanged || actionChanged || acted { + e.power[pos] = newPower + } +} + +func (e *redstoneEngine) updateGraphSources(tx *Tx, graph redstoneGraph, dirty map[cube.Pos]redstoneDirty) (map[cube.Pos]int, map[cube.Pos]struct{}) { + var cancelled map[cube.Pos]int + var checked map[cube.Pos]struct{} + for _, node := range graph.nodes { + if !node.source { + continue + } + b, ok := tx.World().blockLoaded(node.pos) + if !ok { + continue + } + if _, ok := b.(RedstonePowerRelayer); ok { + continue + } + if checked == nil { + checked = make(map[cube.Pos]struct{}) + } + checked[node.pos] = struct{}{} + + d := dirty[node.pos] + if !e.updateSource(tx, node.pos, d.changed, d.cause, graph.id) { + if cancelled == nil { + cancelled = make(map[cube.Pos]int) + } + cancelled[node.pos] = e.output[node.pos] + } + } + return cancelled, checked +} + +func (e *redstoneEngine) updateSource(tx *Tx, pos, changed cube.Pos, cause RedstoneUpdateCause, graphID uint64) bool { + b := tx.Block(pos) + oldPower, newPower := e.output[pos], e.sourcePower(pos, tx) + if oldPower == newPower { + return true + } + update := RedstoneUpdate{ + Pos: pos, + ChangedNeighbour: changed, + Before: b, + OldPower: oldPower, + NewPower: newPower, + CurrentTick: e.currentTick, + NetworkID: graphID, + Cause: cause, + } + if !e.redstoneUpdateAllowed(tx, update) { + return false + } + e.output[pos] = newPower + e.invalidateAround(pos, pos, RedstoneUpdateCauseBlockUpdate, tx.Range()) + return true +} + +func (e *redstoneEngine) directPower(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.directPowerFrom(pos, tx, face)) + } + return power +} + +func (e *redstoneEngine) directPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + neighbour := pos.Side(face) + if neighbour.OutOfBounds(tx.Range()) { + return 0 + } + b, ok := tx.World().blockLoaded(neighbour) + if !ok { + return 0 + } + if source, ok := b.(RedstonePowerSource); ok { + return clampRedstonePower(e.redstonePower(source, neighbour, tx, face.Opposite())) + } + return 0 +} + +func (e *redstoneEngine) strongPower(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.strongPowerFrom(pos, tx, face)) + } + return power +} + +func (e *redstoneEngine) strongPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + neighbour := pos.Side(face) + if neighbour.OutOfBounds(tx.Range()) { + return 0 + } + b, ok := tx.World().blockLoaded(neighbour) + if !ok { + return 0 + } + if source, ok := b.(RedstoneStrongPowerSource); ok { + if power, ok := e.suppressedSources[neighbour]; ok { + return clampRedstonePower(power) + } + return clampRedstonePower(source.RedstoneStrongPower(neighbour, tx, face.Opposite())) + } + return 0 +} + +func (e *redstoneEngine) conductedStrongPower(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.conductedStrongPowerFrom(pos, tx, face)) + } + return power +} + +func (e *redstoneEngine) conductedStrongPowerFrom(pos cube.Pos, tx *Tx, face cube.Face) int { + conductorPos := pos.Side(face) + if conductorPos.OutOfBounds(tx.Range()) { + return 0 + } + conductor, ok := tx.World().blockLoaded(conductorPos) + if !ok || !redstoneStrongPowerConductor(conductorPos, conductor, tx, face.Opposite()) { + return 0 + } + power := 0 + for _, sourceFace := range cube.Faces() { + power = max(power, e.strongPowerFrom(conductorPos, tx, sourceFace)) + } + return power +} + +func (e *redstoneEngine) sourcePower(pos cube.Pos, tx *Tx) int { + b, ok := tx.World().blockLoaded(pos) + if !ok { + return 0 + } + source, ok := b.(RedstonePowerSource) + if !ok { + return 0 + } + power := 0 + for _, face := range cube.Faces() { + power = max(power, clampRedstonePower(e.redstonePower(source, pos, tx, face))) + } + return power +} + +func (e *redstoneEngine) graphPower(tx *Tx, graph redstoneGraph) []int { + powers := make([]int, len(graph.nodes)) + if len(graph.nodes) == 0 { + return powers + } + + index := make(map[cube.Pos]int, len(graph.nodes)) + sources := make([]RedstonePowerSource, len(graph.nodes)) + relayers := make([]RedstonePowerRelayer, len(graph.nodes)) + edges := make([][]redstoneEdge, len(graph.nodes)) + for i, node := range graph.nodes { + index[node.pos] = i + if b, ok := tx.World().blockLoaded(node.pos); ok { + sources[i], _ = b.(RedstonePowerSource) + relayers[i], _ = b.(RedstonePowerRelayer) + } + } + for _, edge := range graph.edges { + edges[edge.from] = append(edges[edge.from], edge) + } + + queue := make([]int, 0, len(graph.nodes)) + push := func(i, power int) { + power = clampRedstonePower(power) + if power <= powers[i] { + return + } + powers[i] = power + queue = append(queue, i) + } + + for i, node := range graph.nodes { + push(i, e.conductedStrongPower(node.pos, tx)) + } + + for i, source := range sources { + // Relayers such as redstone wire store their previous output as RedstonePower. + // They must be recomputed from real sources, not used as seeds themselves. + if source == nil || relayers[i] != nil { + continue + } + pos := graph.nodes[i].pos + for _, face := range cube.Faces() { + j, ok := index[pos.Side(face)] + if !ok { + continue + } + power := clampRedstonePower(e.redstonePower(source, pos, tx, face)) + push(j, power) + } + } + + for head := 0; head < len(queue); head++ { + i := queue[head] + if relayers[i] == nil { + continue + } + for _, edge := range edges[i] { + push(edge.to, powers[i]-edge.weight) + } + } + return powers +} + +func (e *redstoneEngine) powerTo(pos cube.Pos, tx *Tx) int { + power := 0 + for _, face := range cube.Faces() { + power = max(power, e.powerFrom(pos, tx, face, false)) + } + power = max(power, e.conductedStrongPower(pos, tx)) + return clampRedstonePower(power) +} + +func (e *redstoneEngine) powerFrom(pos cube.Pos, tx *Tx, face cube.Face, relayerSources bool) int { + power := e.conductedStrongPowerFrom(pos, tx, face) + type step struct { + pos cube.Pos + from cube.Face + loss int + depth int + } + queue := []step{{pos: pos.Side(face), from: face.Opposite(), loss: 0, depth: 0}} + seen := make(map[cube.Pos]int, 16) + for len(queue) != 0 { + s := queue[0] + queue = queue[1:] + if s.pos.OutOfBounds(tx.Range()) || s.loss >= 15 || s.depth >= 15 { + continue + } + if s.pos == pos { + continue + } + if loss, ok := seen[s.pos]; ok && loss <= s.loss { + continue + } + seen[s.pos] = s.loss + + b, ok := tx.World().blockLoaded(s.pos) + if !ok { + continue + } + relayer, isRelayer := b.(RedstonePowerRelayer) + // See graphPower: relayers carry recomputed power through edges, so their + // stored RedstonePower should not count as an independent source here. + if source, ok := b.(RedstonePowerSource); ok && (!isRelayer || relayerSources) { + power = max(power, clampRedstonePower(e.redstonePower(source, s.pos, tx, s.from)-s.loss)) + } + if !isRelayer { + continue + } + for _, next := range e.redstoneRelayerNeighbourPositions(tx, s.pos, b) { + to := redstoneStepFace(s.pos, next) + if to == s.from { + continue + } + nextBlock, ok := tx.World().blockLoaded(next) + if !ok { + continue + } + loss := s.loss + if _, nextRelayer := nextBlock.(RedstonePowerRelayer); nextRelayer { + loss += max(relayer.RedstoneSignalLoss(s.pos, tx, s.from, to), 1) + } + if loss <= 15 { + queue = append(queue, step{pos: next, from: to.Opposite(), loss: loss, depth: s.depth + 1}) + } + } + } + return clampRedstonePower(power) +} + +func (e *redstoneEngine) redstonePower(source RedstonePowerSource, pos cube.Pos, tx *Tx, face cube.Face) int { + if power, ok := e.suppressedSources[pos]; ok { + return clampRedstonePower(power) + } + if _, ok := e.evaluating[pos]; ok { + return 0 + } + e.evaluating[pos] = struct{}{} + defer delete(e.evaluating, pos) + return source.RedstonePower(pos, tx, face) +} + +type legacyRedstoneHandler interface { + HandleRedstoneUpdate(ctx *Context, pos cube.Pos) +} + +func (e *redstoneEngine) redstoneUpdateAllowed(tx *Tx, update RedstoneUpdate) bool { + ctx := event.C(tx) + handler := tx.World().Handler() + if legacy, ok := handler.(legacyRedstoneHandler); ok { + legacy.HandleRedstoneUpdate(ctx, update.Pos) + } + if rich, ok := handler.(RedstoneHandler); ok { + rich.HandleRedstoneUpdate(ctx, update) + } + return !ctx.Cancelled() +} + +type redstoneLightDiffuser interface { + LightDiffusionLevel() uint8 +} + +func redstoneStrongPowerConductor(pos cube.Pos, b Block, tx *Tx, face cube.Face) bool { + if !b.Model().FaceSolid(pos, face, tx) { + return false + } + if redstoneExplicitNonConductor(b) { + return false + } + if diffuser, ok := b.(redstoneLightDiffuser); ok && diffuser.LightDiffusionLevel() == 0 { + return false + } + return true +} + +func redstoneExplicitNonConductor(b Block) bool { + name, _ := b.EncodeBlock() + return name == "minecraft:redstone_block" +} + +func (e *redstoneEngine) compileEdges(tx *Tx, nodes []redstoneNode) []redstoneEdge { + index := make(map[cube.Pos]int, len(nodes)) + for i, node := range nodes { + index[node.pos] = i + } + edges := make([]redstoneEdge, 0, len(nodes)) + for i, node := range nodes { + b, loaded := tx.World().blockLoaded(node.pos) + if !loaded { + continue + } + relayer, ok := b.(RedstonePowerRelayer) + if !ok { + continue + } + for _, neighbour := range e.redstoneRelayerNeighbourPositions(tx, node.pos, b) { + j, ok := index[neighbour] + if !ok { + continue + } + face := redstoneStepFace(node.pos, neighbour) + weight := 0 + if neighbourBlock, ok := tx.World().blockLoaded(neighbour); ok { + if _, neighbourRelayer := neighbourBlock.(RedstonePowerRelayer); neighbourRelayer { + weight = max(relayer.RedstoneSignalLoss(node.pos, tx, face.Opposite(), face), 1) + } + } + edges = append(edges, redstoneEdge{from: i, to: j, weight: weight}) + } + } + slices.SortFunc(edges, compareRedstoneEdge) + return edges +} + +func (e *redstoneEngine) redstoneRelayerNeighbourPositions(tx *Tx, pos cube.Pos, b Block) []cube.Pos { + if neighbourer, ok := b.(RedstonePowerRelayerNeighbourer); ok { + neighbours := slices.Clone(neighbourer.RedstoneRelayerNeighbours(pos, tx)) + slices.SortFunc(neighbours, compareBlockPos) + return neighbours + } + neighbours := make([]cube.Pos, 0, len(cube.Faces())) + e.redstoneRelayerNeighbours(tx, pos, b, func(neighbour cube.Pos) { + neighbours = append(neighbours, neighbour) + }) + slices.SortFunc(neighbours, compareBlockPos) + return neighbours +} + +func (e *redstoneEngine) redstoneRelayerNeighbours(tx *Tx, pos cube.Pos, _ Block, f func(cube.Pos)) { + for _, face := range cube.Faces() { + neighbour := pos.Side(face) + if !neighbour.OutOfBounds(tx.Range()) { + f(neighbour) + } + } +} + +func redstoneStepFace(from, to cube.Pos) cube.Face { + dx, dy, dz := to[0]-from[0], to[1]-from[1], to[2]-from[2] + switch { + case dx > 0: + return cube.FaceEast + case dx < 0: + return cube.FaceWest + case dz > 0: + return cube.FaceSouth + case dz < 0: + return cube.FaceNorth + case dy > 0: + return cube.FaceUp + case dy < 0: + return cube.FaceDown + default: + return cube.FaceUp + } +} + +func redstoneGraphID(nodes []redstoneNode, edges []redstoneEdge) uint64 { + if len(nodes) == 0 { + return 0 + } + h := fnv.New64a() + var buf [32]byte + for _, node := range nodes { + binary.LittleEndian.PutUint64(buf[0:], uint64(node.pos[0])) + binary.LittleEndian.PutUint64(buf[8:], uint64(node.pos[1])) + binary.LittleEndian.PutUint64(buf[16:], uint64(node.pos[2])) + binary.LittleEndian.PutUint64(buf[24:], uint64(boolInt(node.source)<<1|boolInt(node.sink))) + _, _ = h.Write(buf[:]) + } + for _, edge := range edges { + binary.LittleEndian.PutUint64(buf[0:], uint64(edge.from)) + binary.LittleEndian.PutUint64(buf[8:], uint64(edge.to)) + binary.LittleEndian.PutUint64(buf[16:], uint64(edge.weight)) + _, _ = h.Write(buf[:24]) + } + return h.Sum64() +} + +func classifyRedstoneBlock(b Block) (source, consumer, action, relayer bool) { + _, source = b.(RedstonePowerSource) + _, consumer = b.(RedstonePowerConsumer) + if !consumer { + _, consumer = b.(RedstonePowerTransitionConsumer) + } + _, action = b.(RedstonePowerAction) + _, relayer = b.(RedstonePowerRelayer) + return +} + +func isRedstoneRelevant(b Block) bool { + source, consumer, action, relayer := classifyRedstoneBlock(b) + return source || consumer || action || relayer +} + +func compareBlockPos(a, b cube.Pos) int { + if a[1] != b[1] { + return a[1] - b[1] + } + if a[2] != b[2] { + return a[2] - b[2] + } + return a[0] - b[0] +} + +func compareRedstoneEdge(a, b redstoneEdge) int { + if a.from != b.from { + return a.from - b.from + } + if a.to != b.to { + return a.to - b.to + } + return a.weight - b.weight +} + +func clampRedstonePower(power int) int { + if power < 0 { + return 0 + } + if power > 15 { + return 15 + } + return power +} + +func boolInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/server/world/redstone/state.go b/server/world/redstone/state.go deleted file mode 100644 index 359038b23..000000000 --- a/server/world/redstone/state.go +++ /dev/null @@ -1,165 +0,0 @@ -package redstone - -import ( - "slices" - - "github.com/df-mc/dragonfly/server/block/cube" -) - -const ( - // torchBurnoutThreshold is the maximum number of state changes allowed before burnout occurs. - torchBurnoutThreshold = 8 - // torchBurnoutWindowTicks is the time window during which state changes are counted. - torchBurnoutWindowTicks = 60 -) - -// State holds transient redstone runtime state owned by a world. -type State struct { - torchBurnout map[cube.Pos]torchBurnout - activeTorchUpdates map[cube.Pos]int - updateSources []cube.Pos -} - -// torchBurnout holds the burnout state and state change history for a redstone torch. -type torchBurnout struct { - expirationTicks []int64 - burnedOut bool - // selfTriggered is set when the next scheduled toggle was caused by this torch's own outgoing propagation. - selfTriggered bool -} - -// TorchBurnoutStatus returns the current transient burnout state for a redstone torch. Expired state-change -// history is pruned before the state is returned. -func (s *State) TorchBurnoutStatus(pos cube.Pos, currentTick int64) (burnedOut, recoverable bool) { - data, ok := s.pruneTorchBurnoutData(pos, currentTick) - if !ok { - return false, false - } - return data.burnedOut, len(data.expirationTicks) < torchBurnoutThreshold -} - -// PruneTorchBurnout removes idle redstone torch burnout state once all tracked state changes have expired. -func (s *State) PruneTorchBurnout(pos cube.Pos, currentTick int64) { - data, ok := s.torchBurnout[pos] - if !ok { - return - } - remaining := data.removeExpired(currentTick) - data.selfTriggered = false - if remaining == 0 && !data.burnedOut { - s.ClearTorchBurnout(pos) - return - } - s.torchBurnout[pos] = data -} - -// RecordTorchToggle records a self-triggered redstone torch toggle and reports whether it should burn out. -func (s *State) RecordTorchToggle(pos cube.Pos, currentTick int64) (burnsOut bool) { - data, ok := s.torchBurnout[pos] - if !ok { - return false - } - data.removeExpired(currentTick) - if data.selfTriggered { - data.expirationTicks = append(data.expirationTicks, currentTick+torchBurnoutWindowTicks) - } - data.selfTriggered = false - if len(data.expirationTicks) == 0 && !data.burnedOut { - s.ClearTorchBurnout(pos) - return false - } - s.torchBurnout[pos] = data - return len(data.expirationTicks) >= torchBurnoutThreshold -} - -// BurnOutTorch marks a redstone torch as burned out until a later redstone update recovers it. -func (s *State) BurnOutTorch(pos cube.Pos) { - data := s.torchBurnoutData(pos) - data.burnedOut = true - data.selfTriggered = false - s.torchBurnout[pos] = data -} - -// ClearTorchBurnout removes transient burnout state for a redstone torch. -func (s *State) ClearTorchBurnout(pos cube.Pos) { - delete(s.torchBurnout, pos) -} - -// WithActiveTorchUpdate marks redstone propagation as originating from the redstone torch at pos for the duration of fn. -func (s *State) WithActiveTorchUpdate(pos cube.Pos, fn func()) { - if s.activeTorchUpdates == nil { - s.activeTorchUpdates = make(map[cube.Pos]int) - } - s.activeTorchUpdates[pos]++ - defer func() { - if s.activeTorchUpdates[pos] <= 1 { - delete(s.activeTorchUpdates, pos) - return - } - s.activeTorchUpdates[pos]-- - }() - fn() -} - -// WithUpdateSource marks redstone propagation as originating from pos for the duration of fn. -func (s *State) WithUpdateSource(pos cube.Pos, fn func()) { - s.updateSources = append(s.updateSources, pos) - defer func() { - s.updateSources = s.updateSources[:len(s.updateSources)-1] - }() - fn() -} - -// UpdateSource returns the position that caused the current redstone update. -func (s *State) UpdateSource() (cube.Pos, bool) { - if len(s.updateSources) == 0 { - return cube.Pos{}, false - } - return s.updateSources[len(s.updateSources)-1], true -} - -// MarkTorchSelfTriggeredIfActive marks the next scheduled tick for the redstone torch at pos as self-triggered if the -// current redstone propagation originated from that torch. -func (s *State) MarkTorchSelfTriggeredIfActive(pos cube.Pos) { - if s.activeTorchUpdates[pos] == 0 { - return - } - data := s.torchBurnoutData(pos) - data.selfTriggered = true - s.torchBurnout[pos] = data -} - -// torchBurnoutData retrieves or creates burnout tracking data for the given torch position. -func (s *State) torchBurnoutData(pos cube.Pos) torchBurnout { - if s.torchBurnout == nil { - s.torchBurnout = make(map[cube.Pos]torchBurnout) - } - data, ok := s.torchBurnout[pos] - if !ok { - data.expirationTicks = make([]int64, 0, torchBurnoutThreshold+1) - } - return data -} - -// pruneTorchBurnoutData removes expired burnout history and returns the remaining torch data if it is still relevant. -func (s *State) pruneTorchBurnoutData(pos cube.Pos, currentTick int64) (torchBurnout, bool) { - data, ok := s.torchBurnout[pos] - if !ok { - return torchBurnout{}, false - } - data.removeExpired(currentTick) - if len(data.expirationTicks) == 0 && !data.burnedOut && !data.selfTriggered { - s.ClearTorchBurnout(pos) - return torchBurnout{}, false - } - s.torchBurnout[pos] = data - return data, true -} - -// removeExpired removes expired state change entries and returns the number of entries that remain. -func (data *torchBurnout) removeExpired(currentTick int64) int { - data.expirationTicks = slices.DeleteFunc(data.expirationTicks, func(t int64) bool { - return t < currentTick - }) - return len(data.expirationTicks) -} diff --git a/server/world/redstone_bench_test.go b/server/world/redstone_bench_test.go new file mode 100644 index 000000000..ae1ae5297 --- /dev/null +++ b/server/world/redstone_bench_test.go @@ -0,0 +1,50 @@ +package world + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +var redstoneGraphIDBenchmarkResult uint64 + +func BenchmarkRedstoneGraphID(b *testing.B) { + nodes := redstoneBenchmarkNodes(256) + edges := redstoneBenchmarkEdges(len(nodes)) + + b.ReportAllocs() + b.SetBytes(int64(len(nodes)*32 + len(edges)*24)) + b.ResetTimer() + + var result uint64 + for i := 0; i < b.N; i++ { + result = redstoneGraphID(nodes, edges) + } + redstoneGraphIDBenchmarkResult = result +} + +func redstoneBenchmarkNodes(n int) []redstoneNode { + nodes := make([]redstoneNode, n) + for i := range nodes { + nodes[i] = redstoneNode{ + pos: cube.Pos{i%16 - 8, i/16 - 8, (i * 7) % 16}, + power: i % 16, + source: i%3 == 0, + sink: i%5 == 0, + } + } + return nodes +} + +func redstoneBenchmarkEdges(nodes int) []redstoneEdge { + edges := make([]redstoneEdge, 0, nodes*2) + for i := 0; i < nodes; i++ { + if i+1 < nodes { + edges = append(edges, redstoneEdge{from: i, to: i + 1, weight: i%3 + 1}) + } + if i+16 < nodes { + edges = append(edges, redstoneEdge{from: i, to: i + 16, weight: i%5 + 1}) + } + } + return edges +} diff --git a/server/world/redstone_test.go b/server/world/redstone_test.go new file mode 100644 index 000000000..d9688aea0 --- /dev/null +++ b/server/world/redstone_test.go @@ -0,0 +1,733 @@ +package world + +import ( + "math/rand/v2" + "slices" + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl64" +) + +var _ Handler = minimalRedstoneTestHandler{} + +type minimalRedstoneTestHandler struct{} + +func (minimalRedstoneTestHandler) HandleLiquidFlow(*Context, cube.Pos, cube.Pos, Liquid, Block) {} +func (minimalRedstoneTestHandler) HandleLiquidDecay(*Context, cube.Pos, Liquid, Liquid) {} +func (minimalRedstoneTestHandler) HandleLiquidHarden(*Context, cube.Pos, Block, Block, Block) {} +func (minimalRedstoneTestHandler) HandleSound(*Context, Sound, mgl64.Vec3) {} +func (minimalRedstoneTestHandler) HandleFireSpread(*Context, cube.Pos, cube.Pos) {} +func (minimalRedstoneTestHandler) HandleBlockBurn(*Context, cube.Pos) {} +func (minimalRedstoneTestHandler) HandleCropTrample(*Context, cube.Pos) {} +func (minimalRedstoneTestHandler) HandleLeavesDecay(*Context, cube.Pos) {} +func (minimalRedstoneTestHandler) HandleEntitySpawn(*Tx, Entity) {} +func (minimalRedstoneTestHandler) HandleEntityDespawn(*Tx, Entity) {} +func (minimalRedstoneTestHandler) HandleExplosion(*Context, mgl64.Vec3, *[]Entity, *[]cube.Pos, *float64, *bool) { +} +func (minimalRedstoneTestHandler) HandleClose(*Tx) {} + +func TestClampRedstonePower(t *testing.T) { + tests := []struct { + name string + power int + want int + }{ + {name: "negative", power: -1, want: 0}, + {name: "zero", power: 0, want: 0}, + {name: "middle", power: 8, want: 8}, + {name: "maximum", power: 15, want: 15}, + {name: "over maximum", power: 16, want: 15}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := clampRedstonePower(test.power); got != test.want { + t.Fatalf("clampRedstonePower(%d) = %d, want %d", test.power, got, test.want) + } + }) + } +} + +func TestCompareBlockPosSortOrder(t *testing.T) { + positions := []cube.Pos{ + {3, 2, 1}, + {2, 1, 2}, + {1, 1, 1}, + {0, 1, 1}, + {0, 0, 9}, + {9, 0, -1}, + } + want := []cube.Pos{ + {9, 0, -1}, + {0, 0, 9}, + {0, 1, 1}, + {1, 1, 1}, + {2, 1, 2}, + {3, 2, 1}, + } + + slices.SortFunc(positions, compareBlockPos) + if !slices.Equal(positions, want) { + t.Fatalf("sorted positions = %v, want %v", positions, want) + } + if got := compareBlockPos(cube.Pos{1, 2, 3}, cube.Pos{1, 2, 3}); got != 0 { + t.Fatalf("compareBlockPos(equal positions) = %d, want 0", got) + } +} + +func TestRedstoneRelayerNeighbourPositionsAreDeterministic(t *testing.T) { + engine := newRedstoneEngine(0) + pos := cube.Pos{0, 64, 0} + got := engine.redstoneRelayerNeighbourPositions(nil, pos, redstoneNeighbourOrderTestBlock{neighbours: []cube.Pos{ + {1, 64, 0}, + {0, 63, 0}, + {0, 64, -1}, + {-1, 64, 0}, + {0, 65, 0}, + {0, 64, 1}, + }}) + want := []cube.Pos{ + {0, 63, 0}, + {0, 64, -1}, + {-1, 64, 0}, + {1, 64, 0}, + {0, 64, 1}, + {0, 65, 0}, + } + if !slices.Equal(got, want) { + t.Fatalf("redstone relayer neighbours = %v, want %v", got, want) + } +} + +func TestRedstoneCompileRegionIncludesCustomRelayerNeighbours(t *testing.T) { + pos, step := cube.Pos{0, 64, 0}, cube.Pos{1, 65, 0} + w := Config{Blocks: redstoneCustomRelayerTestRegistry()}.New() + defer w.Close() + + var nodes []redstoneNode + <-w.Exec(func(tx *Tx) { + tx.SetBlock(pos, redstoneStepRelayer{}, nil) + tx.SetBlock(step, redstoneStepRelayer{}, nil) + + tx.World().redstone.compileRegion(tx, pos, make(map[cube.Pos]struct{}), &nodes) + }) + if !redstoneNodeTestContains(nodes, step) { + t.Fatalf("compiled region nodes = %v, want custom neighbour %v included", nodes, step) + } +} + +func TestRedstoneGraphID(t *testing.T) { + if got := redstoneGraphID(nil, nil); got != 0 { + t.Fatalf("redstoneGraphID(nil) = %d, want 0", got) + } + if got := redstoneGraphID([]redstoneNode{}, []redstoneEdge{{from: 0, to: 1, weight: 1}}); got != 0 { + t.Fatalf("redstoneGraphID(empty) = %d, want 0", got) + } + + nodes := []redstoneNode{ + {pos: cube.Pos{0, 0, 0}, power: 0, source: true}, + {pos: cube.Pos{1, -4, 3}, power: 7, sink: true}, + {pos: cube.Pos{-8, 12, 2}, power: 15, source: true, sink: true}, + } + edges := []redstoneEdge{ + {from: 0, to: 1, weight: 1}, + {from: 1, to: 2, weight: 2}, + } + id := redstoneGraphID(nodes, edges) + if id == 0 { + t.Fatalf("redstoneGraphID(non-empty nodes) = 0, want non-zero") + } + if got := redstoneGraphID(slices.Clone(nodes), slices.Clone(edges)); got != id { + t.Fatalf("redstoneGraphID(cloned nodes) = %d, want %d", got, id) + } + + changedPower := slices.Clone(nodes) + changedPower[1].power++ + if got := redstoneGraphID(changedPower, edges); got != id { + t.Fatalf("redstoneGraphID(nodes with changed power) = %d, want topology ID %d", got, id) + } + + movedNode := slices.Clone(nodes) + movedNode[2].pos[0]++ + if got := redstoneGraphID(movedNode, edges); got == id { + t.Fatalf("redstoneGraphID(nodes with moved position) = %d, want value different from %d", got, id) + } + + changedEdge := slices.Clone(edges) + changedEdge[0].weight++ + if got := redstoneGraphID(nodes, changedEdge); got == id { + t.Fatalf("redstoneGraphID(nodes with changed edge) = %d, want value different from %d", got, id) + } +} + +func TestRedstoneStrongPowerConductorExcludesRedstoneBlock(t *testing.T) { + pos := cube.Pos{0, 64, 0} + if !redstoneStrongPowerConductor(pos, redstoneNamedSolidBlock{name: "minecraft:stone"}, nil, cube.FaceWest) { + t.Fatal("stone was not treated as a strong-power conductor") + } + if redstoneStrongPowerConductor(pos, redstoneNamedSolidBlock{name: "minecraft:redstone_block"}, nil, cube.FaceWest) { + t.Fatal("redstone block was treated as a strong-power conductor") + } +} + +func TestRedstoneTorchBurnoutState(t *testing.T) { + engine := newRedstoneEngine(0) + pos := cube.Pos{0, 64, 0} + for i := range redstoneTorchBurnoutThreshold - 1 { + if engine.recordRedstoneTorchToggle(pos, int64(i)) { + t.Fatalf("torch burned out after %d toggles, want below threshold", i+1) + } + } + if !engine.recordRedstoneTorchToggle(pos, redstoneTorchBurnoutThreshold-1) { + t.Fatal("torch did not burn out at threshold") + } + burnedOut, recoverable := engine.redstoneTorchBurnoutStatus(pos, redstoneTorchBurnoutThreshold) + if !burnedOut || recoverable { + t.Fatalf("burnout status = burnedOut %t recoverable %t, want true false", burnedOut, recoverable) + } + burnedOut, recoverable = engine.redstoneTorchBurnoutStatus(pos, redstoneTorchBurnoutWindowTicks+redstoneTorchBurnoutThreshold) + if !burnedOut || !recoverable { + t.Fatalf("expired burnout status = burnedOut %t recoverable %t, want true true", burnedOut, recoverable) + } + engine.clearRedstoneTorchBurnout(pos) + burnedOut, recoverable = engine.redstoneTorchBurnoutStatus(pos, redstoneTorchBurnoutWindowTicks+redstoneTorchBurnoutThreshold) + if burnedOut || recoverable { + t.Fatalf("cleared burnout status = burnedOut %t recoverable %t, want false false", burnedOut, recoverable) + } +} + +func TestRedstoneEngineInvalidateAround(t *testing.T) { + var nilEngine *redstoneEngine + nilEngine.invalidateAround(cube.Pos{0, 0, 0}, cube.Pos{0, 0, 0}, RedstoneUpdateCauseBlockUpdate, cube.Range{0, 0}) + + engine := newRedstoneEngine(42) + pos, changed := cube.Pos{8, 0, 8}, cube.Pos{9, 0, 8} + engine.invalidateAround(pos, changed, RedstoneUpdateCauseBlockUpdate, cube.Range{0, 1}) + + want := map[cube.Pos]redstoneDirty{ + {8, 0, 8}: redstoneDirty{changed: changed, cause: RedstoneUpdateCauseBlockUpdate}, + {9, 0, 8}: redstoneDirty{changed: changed, cause: RedstoneUpdateCauseBlockUpdate}, + {7, 0, 8}: redstoneDirty{changed: changed, cause: RedstoneUpdateCauseBlockUpdate}, + {8, 1, 8}: redstoneDirty{changed: changed, cause: RedstoneUpdateCauseBlockUpdate}, + {8, 0, 9}: redstoneDirty{changed: changed, cause: RedstoneUpdateCauseBlockUpdate}, + {8, 0, 7}: redstoneDirty{changed: changed, cause: RedstoneUpdateCauseBlockUpdate}, + } + if len(engine.dirty) != len(want) { + t.Fatalf("dirty positions = %v, want %v", engine.dirty, want) + } + for pos, dirty := range want { + if got, ok := engine.dirty[pos]; !ok || got != dirty { + t.Fatalf("dirty[%v] = %v, %t; want %v, true", pos, got, ok, dirty) + } + } + + engine.invalidateAround(cube.Pos{0, -1, 0}, changed, RedstoneUpdateCauseScheduledTick, cube.Range{0, 1}) + if len(engine.dirty) != len(want) { + t.Fatalf("out-of-bounds invalidation changed dirty positions to %v, want %v", engine.dirty, want) + } +} + +func TestRedstoneCancelledSourceDoesNotPropagate(t *testing.T) { + sourcePos, sinkPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + w.Handle(&redstoneCancellationHandler{cancel: map[cube.Pos]struct{}{sourcePos: {}}}) + var sinkPowered bool + var sourceOutput int + <-w.Exec(func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(sinkPos, redstoneCancellationConsumer{}, nil) + tx.World().redstone.tick(tx, 1) + + sinkPowered = tx.Block(sinkPos).(redstoneCancellationConsumer).Powered + sourceOutput = tx.World().redstone.output[sourcePos] + }) + if sinkPowered { + t.Fatalf("sink powered after cancelling source update") + } + if sourceOutput != 0 { + t.Fatalf("stored source output = %d, want 0", sourceOutput) + } +} + +func TestRedstoneCancelledConsumerDoesNotUpdate(t *testing.T) { + sourcePos, sinkPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + w.Handle(&redstoneCancellationHandler{cancel: map[cube.Pos]struct{}{sinkPos: {}}}) + var sinkPowered bool + <-w.Exec(func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(sinkPos, redstoneCancellationConsumer{}, nil) + tx.World().redstone.tick(tx, 1) + + sinkPowered = tx.Block(sinkPos).(redstoneCancellationConsumer).Powered + }) + if sinkPowered { + t.Fatalf("sink powered after cancelling consumer update") + } +} + +func TestRedstoneCancelledActionDoesNotRun(t *testing.T) { + sourcePos, actionPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + actions := 0 + redstoneCancellationActions = &actions + t.Cleanup(func() { + redstoneCancellationActions = nil + }) + w.Handle(&redstoneCancellationHandler{cancel: map[cube.Pos]struct{}{actionPos: {}}}) + <-w.Exec(func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(actionPos, redstoneCancellationAction{}, nil) + tx.World().redstone.tick(tx, 1) + }) + if actions != 0 { + t.Fatalf("actions = %d, want 0", actions) + } +} + +func TestRedstoneRelayerToSinkDoesNotLosePower(t *testing.T) { + sourcePos, relayerPos, sinkPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0}, cube.Pos{2, 64, 0} + w := Config{Blocks: redstoneSignalLossTestRegistry()}.New() + defer w.Close() + + var directPower, sinkPower int + <-w.Exec(func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneLossSource{Power: 15}, nil) + tx.SetBlock(relayerPos, redstoneLossRelayer{}, nil) + tx.SetBlock(sinkPos, redstoneLossConsumer{}, nil) + + directPower = tx.RedstonePower(sinkPos) + tx.World().redstone.tick(tx, 1) + if sink, ok := tx.Block(sinkPos).(redstoneLossConsumer); ok { + sinkPower = sink.Power + } + }) + if directPower != 15 { + t.Fatalf("powerFrom through relayer into sink = %d, want 15", directPower) + } + if sinkPower != 15 { + t.Fatalf("graph power through relayer into sink = %d, want 15", sinkPower) + } +} + +func TestRedstoneUpdateIncludesAfterForConsumerStateChange(t *testing.T) { + sourcePos, sinkPos := cube.Pos{0, 64, 0}, cube.Pos{1, 64, 0} + w := Config{Blocks: redstoneCancellationTestRegistry()}.New() + defer w.Close() + + handler := &redstoneRecordingHandler{} + w.Handle(handler) + <-w.Exec(func(tx *Tx) { + tx.SetBlock(sourcePos, redstoneCancellationSource{Power: 15}, nil) + tx.SetBlock(sinkPos, redstoneCancellationConsumer{}, nil) + tx.World().redstone.tick(tx, 1) + }) + + for _, update := range handler.updates { + if update.Pos != sinkPos { + continue + } + after, ok := update.After.(redstoneCancellationConsumer) + if !ok { + t.Fatalf("consumer update After = %#v, want redstoneCancellationConsumer", update.After) + } + if !after.Powered { + t.Fatalf("consumer update After.Powered = false, want true") + } + return + } + t.Fatalf("no redstone update recorded for sink %v; updates=%v", sinkPos, handler.updates) +} + +func TestScheduledTickQueueKeepsLatestTickForSameBlock(t *testing.T) { + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + + queue.schedule(DefaultBlockRegistry, pos, b, time.Second/20) + queue.schedule(DefaultBlockRegistry, pos, b, time.Second/10) + + index := scheduledTickIndex{pos: pos, hash: DefaultBlockRegistry.BlockHash(b)} + if got, want := queue.furthestTicks[index], int64(102); got != want { + t.Fatalf("furthest tick = %d, want %d", got, want) + } + ticks := queue.fromChunk(chunkPosFromBlockPos(pos)) + if len(ticks) != 1 { + t.Fatalf("active ticks = %v, want one latest tick", ticks) + } + if got, want := ticks[0].t, int64(102); got != want { + t.Fatalf("fromChunk tick = %d, want %d", got, want) + } +} + +func TestScheduledTickQueueFromChunkOmitsSupersededTicks(t *testing.T) { + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + hash := DefaultBlockRegistry.BlockHash(b) + queue.ticks = []scheduledTick{ + {pos: pos, t: 101, b: b, bhash: hash}, + {pos: pos, t: 102, b: b, bhash: hash}, + } + queue.furthestTicks[scheduledTickIndex{pos: pos, hash: hash}] = 102 + + ticks := queue.fromChunk(chunkPosFromBlockPos(pos)) + if len(ticks) != 1 || ticks[0].t != 102 { + t.Fatalf("fromChunk active ticks = %v, want only tick 102", ticks) + } +} + +func TestScheduledTickQueueIgnoresEarlierTickBehindLaterTick(t *testing.T) { + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + + queue.schedule(DefaultBlockRegistry, pos, b, time.Second/10) + queue.schedule(DefaultBlockRegistry, pos, b, time.Second/20) + + index := scheduledTickIndex{pos: pos, hash: DefaultBlockRegistry.BlockHash(b)} + if got, want := queue.furthestTicks[index], int64(102); got != want { + t.Fatalf("furthest tick = %d, want %d", got, want) + } + ticks := queue.fromChunk(chunkPosFromBlockPos(pos)) + if len(ticks) != 1 { + t.Fatalf("active ticks = %v, want one tick", ticks) + } + if got, want := ticks[0].t, int64(102); got != want { + t.Fatalf("fromChunk tick = %d, want %d", got, want) + } +} + +func TestScheduledTickQueueRemoveChunkClearsSchedule(t *testing.T) { + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + + queue.schedule(DefaultBlockRegistry, pos, b, time.Second) + queue.removeChunk(chunkPosFromBlockPos(pos)) + + if len(queue.ticks) != 0 { + t.Fatalf("ticks after removeChunk = %v, want empty", queue.ticks) + } + if len(queue.furthestTicks) != 0 { + t.Fatalf("furthest ticks after removeChunk = %v, want empty", queue.furthestTicks) + } +} + +func TestScheduledTickQueueCanRescheduleWhileCurrentTickIsDue(t *testing.T) { + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + index := scheduledTickIndex{pos: pos, hash: DefaultBlockRegistry.BlockHash(b)} + queue.furthestTicks[index] = 100 + + queue.schedule(DefaultBlockRegistry, pos, b, time.Second/2) + if got, want := queue.furthestTicks[index], int64(110); got != want { + t.Fatalf("rescheduled tick = %d, want %d", got, want) + } +} + +func TestScheduledTickQueueSkipsStaleDueTickBehindLaterTick(t *testing.T) { + registry := scheduledTickTestRegistry() + w := Config{Blocks: registry}.New() + defer w.Close() + + queue := newScheduledTickQueue(100) + pos := cube.Pos{8, 64, 8} + b := scheduledTickTestBlock{} + index := scheduledTickIndex{pos: pos, hash: registry.BlockHash(b)} + + ticks := 0 + scheduledTickTestBlockTicks = &ticks + t.Cleanup(func() { + scheduledTickTestBlockTicks = nil + }) + + var ( + ticksAfterFirst, ticksAfterSecond int + activeAfterFirst, activeAfterSecond []scheduledTick + furthestAfterFirst int64 + hasFurthestAfterFirst bool + ) + <-w.Exec(func(tx *Tx) { + tx.SetBlock(pos, b, nil) + queue.schedule(registry, pos, b, time.Second/20) + queue.schedule(registry, pos, b, time.Second/10) + + queue.tick(tx, 101) + ticksAfterFirst = ticks + activeAfterFirst = queue.fromChunk(chunkPosFromBlockPos(pos)) + furthestAfterFirst, hasFurthestAfterFirst = queue.furthestTicks[index] + + queue.tick(tx, 102) + ticksAfterSecond = ticks + activeAfterSecond = queue.fromChunk(chunkPosFromBlockPos(pos)) + }) + if ticksAfterFirst != 0 { + t.Fatalf("stale due tick executed %d time(s), want 0", ticksAfterFirst) + } + if !hasFurthestAfterFirst || furthestAfterFirst != 102 { + t.Fatalf("furthest tick after stale tick = %d, %t; want 102, true", furthestAfterFirst, hasFurthestAfterFirst) + } + if len(activeAfterFirst) != 1 || activeAfterFirst[0].t != 102 { + t.Fatalf("active ticks after stale tick = %v, want only tick 102", activeAfterFirst) + } + if ticksAfterSecond != 1 { + t.Fatalf("later scheduled tick executed %d time(s), want 1", ticksAfterSecond) + } + if len(activeAfterSecond) != 0 { + t.Fatalf("active ticks after later tick = %v, want empty", activeAfterSecond) + } +} + +type scheduledTickTestBlock struct{} + +var scheduledTickTestBlockTicks *int + +func (scheduledTickTestBlock) ScheduledTick(cube.Pos, *Tx, *rand.Rand) { + if scheduledTickTestBlockTicks != nil { + (*scheduledTickTestBlockTicks)++ + } +} +func (scheduledTickTestBlock) EncodeBlock() (string, map[string]any) { + return "test:scheduled_tick", nil +} +func (scheduledTickTestBlock) Hash() (uint64, uint64) { return 1 << 40, 0 } +func (scheduledTickTestBlock) Model() BlockModel { return nil } + +func scheduledTickTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + registry.RegisterBlockState(BlockState{Name: "test:scheduled_tick", Properties: map[string]any{}}) + registry.RegisterBlock(scheduledTickTestBlock{}) + return registry +} + +type redstoneNeighbourOrderTestBlock struct { + neighbours []cube.Pos +} + +func (b redstoneNeighbourOrderTestBlock) RedstoneRelayerNeighbours(cube.Pos, *Tx) []cube.Pos { + return slices.Clone(b.neighbours) +} +func (redstoneNeighbourOrderTestBlock) EncodeBlock() (string, map[string]any) { + return "test:redstone_neighbour_order", nil +} +func (redstoneNeighbourOrderTestBlock) Hash() (uint64, uint64) { return 1 << 41, 0 } +func (redstoneNeighbourOrderTestBlock) Model() BlockModel { return nil } + +type redstoneStepRelayer struct{} + +func (redstoneStepRelayer) RedstoneRelayerNeighbours(pos cube.Pos, _ *Tx) []cube.Pos { + return []cube.Pos{ + {pos[0] + 1, pos[1] + 1, pos[2]}, + {pos[0] - 1, pos[1] - 1, pos[2]}, + } +} +func (redstoneStepRelayer) RedstoneSignalLoss(cube.Pos, *Tx, cube.Face, cube.Face) int { + return 1 +} +func (redstoneStepRelayer) EncodeBlock() (string, map[string]any) { + return "test:redstone_step_relayer", nil +} +func (redstoneStepRelayer) Hash() (uint64, uint64) { return 1 << 42, 0 } +func (redstoneStepRelayer) Model() BlockModel { return redstoneCancellationModel{} } + +func redstoneCustomRelayerTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + registry.RegisterBlockState(BlockState{Name: "test:redstone_step_relayer", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneStepRelayer{}) + return registry +} + +func redstoneNodeTestContains(nodes []redstoneNode, pos cube.Pos) bool { + for _, node := range nodes { + if node.pos == pos { + return true + } + } + return false +} + +type redstoneCancellationHandler struct { + NopHandler + cancel map[cube.Pos]struct{} +} + +func (h *redstoneCancellationHandler) HandleRedstoneUpdate(ctx *Context, update RedstoneUpdate) { + if _, ok := h.cancel[update.Pos]; ok { + ctx.Cancel() + } +} + +type redstoneRecordingHandler struct { + NopHandler + updates []RedstoneUpdate +} + +func (h *redstoneRecordingHandler) HandleRedstoneUpdate(_ *Context, update RedstoneUpdate) { + h.updates = append(h.updates, update) +} + +var ( + redstoneCancellationConsumerUpdates *int + redstoneCancellationActions *int +) + +func redstoneCancellationTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + for _, power := range []int32{0, 15} { + registry.RegisterBlockState(BlockState{Name: "test:redstone_source", Properties: map[string]any{"power": power}}) + registry.RegisterBlock(redstoneCancellationSource{Power: int(power)}) + } + for _, powered := range []bool{false, true} { + registry.RegisterBlockState(BlockState{Name: "test:redstone_consumer", Properties: map[string]any{"powered": powered}}) + registry.RegisterBlock(redstoneCancellationConsumer{Powered: powered}) + } + registry.RegisterBlockState(BlockState{Name: "test:redstone_action", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneCancellationAction{}) + return registry +} + +type redstoneCancellationSource struct { + Power int +} + +func (b redstoneCancellationSource) RedstonePower(cube.Pos, *Tx, cube.Face) int { + return b.Power +} +func (b redstoneCancellationSource) EncodeBlock() (string, map[string]any) { + return "test:redstone_source", map[string]any{"power": int32(b.Power)} +} +func (b redstoneCancellationSource) Hash() (uint64, uint64) { + return 1 << 43, uint64(b.Power) +} +func (redstoneCancellationSource) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneCancellationConsumer struct { + Powered bool +} + +func (b redstoneCancellationConsumer) RedstonePowerUpdate(_ cube.Pos, _ *Tx, power int) (Block, bool) { + if redstoneCancellationConsumerUpdates != nil { + (*redstoneCancellationConsumerUpdates)++ + } + powered := power > 0 + if b.Powered == powered { + return b, false + } + b.Powered = powered + return b, true +} +func (b redstoneCancellationConsumer) EncodeBlock() (string, map[string]any) { + return "test:redstone_consumer", map[string]any{"powered": b.Powered} +} +func (b redstoneCancellationConsumer) Hash() (uint64, uint64) { + if b.Powered { + return 1 << 44, 1 + } + return 1 << 44, 0 +} +func (redstoneCancellationConsumer) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneCancellationAction struct{} + +func (redstoneCancellationAction) RedstonePowerAction(cube.Pos, *Tx, int, int) bool { + if redstoneCancellationActions != nil { + (*redstoneCancellationActions)++ + } + return true +} +func (redstoneCancellationAction) EncodeBlock() (string, map[string]any) { + return "test:redstone_action", nil +} +func (redstoneCancellationAction) Hash() (uint64, uint64) { return 1 << 45, 0 } +func (redstoneCancellationAction) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneCancellationModel struct{} + +func (redstoneCancellationModel) BBox(cube.Pos, BlockSource) []cube.BBox { return nil } +func (redstoneCancellationModel) FaceSolid(cube.Pos, cube.Face, BlockSource) bool { + return false +} + +func redstoneSignalLossTestRegistry() BlockRegistry { + registry := NewBlockRegistry() + registry.RegisterBlockState(BlockState{Name: "test:redstone_loss_source", Properties: map[string]any{"power": int32(15)}}) + registry.RegisterBlock(redstoneLossSource{Power: 15}) + registry.RegisterBlockState(BlockState{Name: "test:redstone_loss_relayer", Properties: map[string]any{}}) + registry.RegisterBlock(redstoneLossRelayer{}) + for power := int32(0); power <= 15; power++ { + registry.RegisterBlockState(BlockState{Name: "test:redstone_loss_consumer", Properties: map[string]any{"power": power}}) + registry.RegisterBlock(redstoneLossConsumer{Power: int(power)}) + } + return registry +} + +type redstoneLossSource struct { + Power int +} + +func (b redstoneLossSource) RedstonePower(cube.Pos, *Tx, cube.Face) int { + return b.Power +} +func (b redstoneLossSource) EncodeBlock() (string, map[string]any) { + return "test:redstone_loss_source", map[string]any{"power": int32(b.Power)} +} +func (b redstoneLossSource) Hash() (uint64, uint64) { + return 1 << 45, uint64(b.Power) +} +func (redstoneLossSource) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneLossRelayer struct{} + +func (redstoneLossRelayer) RedstoneSignalLoss(cube.Pos, *Tx, cube.Face, cube.Face) int { + return 1 +} +func (redstoneLossRelayer) EncodeBlock() (string, map[string]any) { + return "test:redstone_loss_relayer", nil +} +func (redstoneLossRelayer) Hash() (uint64, uint64) { return 1 << 46, 0 } +func (redstoneLossRelayer) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneLossConsumer struct { + Power int +} + +func (b redstoneLossConsumer) RedstonePowerUpdate(_ cube.Pos, _ *Tx, power int) (Block, bool) { + if b.Power == power { + return b, false + } + b.Power = power + return b, true +} +func (b redstoneLossConsumer) EncodeBlock() (string, map[string]any) { + return "test:redstone_loss_consumer", map[string]any{"power": int32(b.Power)} +} +func (b redstoneLossConsumer) Hash() (uint64, uint64) { + return 1 << 47, uint64(b.Power) +} +func (redstoneLossConsumer) Model() BlockModel { return redstoneCancellationModel{} } + +type redstoneNamedSolidBlock struct { + name string +} + +func (b redstoneNamedSolidBlock) EncodeBlock() (string, map[string]any) { + return b.name, nil +} +func (redstoneNamedSolidBlock) Hash() (uint64, uint64) { return 1 << 48, 0 } +func (redstoneNamedSolidBlock) Model() BlockModel { return redstoneSolidModel{} } + +type redstoneSolidModel struct{} + +func (redstoneSolidModel) BBox(cube.Pos, BlockSource) []cube.BBox { return nil } +func (redstoneSolidModel) FaceSolid(cube.Pos, cube.Face, BlockSource) bool { + return true +} diff --git a/server/world/redstone_torch.go b/server/world/redstone_torch.go new file mode 100644 index 000000000..4ff6f2be6 --- /dev/null +++ b/server/world/redstone_torch.go @@ -0,0 +1,93 @@ +package world + +import ( + "slices" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +const ( + // redstoneTorchBurnoutThreshold is the maximum number of torch state changes allowed before burnout occurs. + redstoneTorchBurnoutThreshold = 8 + // redstoneTorchBurnoutWindowTicks is the window during which torch state changes are counted. + redstoneTorchBurnoutWindowTicks = 60 +) + +type redstoneTorchBurnout struct { + expirationTicks []int64 + burnedOut bool +} + +func (e *redstoneEngine) redstoneTorchBurnoutStatus(pos cube.Pos, currentTick int64) (burnedOut, recoverable bool) { + data, ok := e.pruneRedstoneTorchBurnoutData(pos, currentTick) + if !ok { + return false, false + } + return data.burnedOut, len(data.expirationTicks) < redstoneTorchBurnoutThreshold +} + +func (e *redstoneEngine) pruneRedstoneTorchBurnout(pos cube.Pos, currentTick int64) { + data, ok := e.torchBurnout[pos] + if !ok { + return + } + data.removeExpired(currentTick) + if len(data.expirationTicks) == 0 && !data.burnedOut { + e.clearRedstoneTorchBurnout(pos) + return + } + e.torchBurnout[pos] = data +} + +func (e *redstoneEngine) recordRedstoneTorchToggle(pos cube.Pos, currentTick int64) (burnsOut bool) { + data := e.redstoneTorchBurnoutData(pos) + data.removeExpired(currentTick) + data.expirationTicks = append(data.expirationTicks, currentTick+redstoneTorchBurnoutWindowTicks) + if len(data.expirationTicks) >= redstoneTorchBurnoutThreshold { + data.burnedOut = true + burnsOut = true + } + e.torchBurnout[pos] = data + return burnsOut +} + +func (e *redstoneEngine) burnOutRedstoneTorch(pos cube.Pos) { + data := e.redstoneTorchBurnoutData(pos) + data.burnedOut = true + e.torchBurnout[pos] = data +} + +func (e *redstoneEngine) clearRedstoneTorchBurnout(pos cube.Pos) { + delete(e.torchBurnout, pos) +} + +func (e *redstoneEngine) redstoneTorchBurnoutData(pos cube.Pos) redstoneTorchBurnout { + if e.torchBurnout == nil { + e.torchBurnout = make(map[cube.Pos]redstoneTorchBurnout) + } + data, ok := e.torchBurnout[pos] + if !ok { + data.expirationTicks = make([]int64, 0, redstoneTorchBurnoutThreshold+1) + } + return data +} + +func (e *redstoneEngine) pruneRedstoneTorchBurnoutData(pos cube.Pos, currentTick int64) (redstoneTorchBurnout, bool) { + data, ok := e.torchBurnout[pos] + if !ok { + return redstoneTorchBurnout{}, false + } + data.removeExpired(currentTick) + if len(data.expirationTicks) == 0 && !data.burnedOut { + e.clearRedstoneTorchBurnout(pos) + return redstoneTorchBurnout{}, false + } + e.torchBurnout[pos] = data + return data, true +} + +func (data *redstoneTorchBurnout) removeExpired(currentTick int64) { + data.expirationTicks = slices.DeleteFunc(data.expirationTicks, func(t int64) bool { + return t < currentTick + }) +} diff --git a/server/world/tick.go b/server/world/tick.go index 1088a1156..e9a2fa37b 100644 --- a/server/world/tick.go +++ b/server/world/tick.go @@ -92,6 +92,7 @@ func (t ticker) tick(tx *Tx) { w.scheduledUpdates.tick(tx, tick) t.tickBlocksRandomly(tx, loaders, tick) t.performNeighbourUpdates(tx) + w.redstone.tick(tx, tick) } // performNeighbourUpdates performs all block updates that came as a result of a neighbouring block being changed. @@ -271,6 +272,7 @@ type scheduledTickQueue struct { ticks []scheduledTick furthestTicks map[scheduledTickIndex]int64 currentTick int64 + ticking bool } type scheduledTick struct { @@ -295,12 +297,16 @@ func newScheduledTickQueue(tick int64) *scheduledTickQueue { // queue. func (queue *scheduledTickQueue) tick(tx *Tx, tick int64) { queue.currentTick = tick + queue.ticking = true w := tx.World() for _, t := range queue.ticks { if t.t > tick { continue } + if !queue.active(t) { + continue + } b := tx.Block(t.pos) if ticker, ok := b.(ScheduledTicker); ok && w.conf.Blocks.BlockHash(b) == t.bhash { ticker.ScheduledTick(t.pos, tx, w.r) @@ -310,10 +316,11 @@ func (queue *scheduledTickQueue) tick(tx *Tx, tick int64) { } } } + queue.ticking = false // Clear scheduled ticks that were processed from the queue. queue.ticks = slices.DeleteFunc(queue.ticks, func(t scheduledTick) bool { - return t.t <= tick + return t.t <= tick || !queue.active(t) }) maps.DeleteFunc(queue.furthestTicks, func(index scheduledTickIndex, t int64) bool { return t <= tick @@ -327,13 +334,15 @@ func (queue *scheduledTickQueue) tick(tx *Tx, tick int64) { func (queue *scheduledTickQueue) schedule(br BlockRegistry, pos cube.Pos, b Block, delay time.Duration) { resTick := queue.currentTick + int64(max(delay/(time.Second/20), 1)) index := scheduledTickIndex{pos: pos, hash: br.BlockHash(b)} - if t, ok := queue.furthestTicks[index]; ok && t >= resTick { - // Already have a tick scheduled for this position that will occur after - // the delay passed. Block updates can only be scheduled if they are - // after any currently scheduled updates. + if t, ok := queue.furthestTicks[index]; ok && t >= resTick && t > queue.currentTick { return } queue.furthestTicks[index] = resTick + if !queue.ticking { + queue.ticks = slices.DeleteFunc(queue.ticks, func(t scheduledTick) bool { + return t.pos == pos && t.bhash == index.hash + }) + } queue.ticks = append(queue.ticks, scheduledTick{pos: pos, t: resTick, b: b, bhash: index.hash}) } @@ -341,7 +350,7 @@ func (queue *scheduledTickQueue) schedule(br BlockRegistry, pos cube.Pos, b Bloc func (queue *scheduledTickQueue) fromChunk(pos ChunkPos) []scheduledTick { m := make([]scheduledTick, 0, 8) for _, t := range queue.ticks { - if pos == chunkPosFromBlockPos(t.pos) { + if pos == chunkPosFromBlockPos(t.pos) && queue.active(t) { m = append(m, t) } } @@ -353,6 +362,9 @@ func (queue *scheduledTickQueue) removeChunk(pos ChunkPos) { queue.ticks = slices.DeleteFunc(queue.ticks, func(tick scheduledTick) bool { return chunkPosFromBlockPos(tick.pos) == pos }) + maps.DeleteFunc(queue.furthestTicks, func(index scheduledTickIndex, _ int64) bool { + return chunkPosFromBlockPos(index.pos) == pos + }) } // add adds a slice of scheduled ticks to the queue. It assumes no duplicate @@ -362,10 +374,18 @@ func (queue *scheduledTickQueue) add(ticks []scheduledTick) { for _, t := range ticks { index := scheduledTickIndex{pos: t.pos, hash: t.bhash} if existing, ok := queue.furthestTicks[index]; ok { - // Make sure we find the furthest tick for each of the ticks added. - // Some ticks may have the same block and position, in which case we - // need to set the furthest tick. queue.furthestTicks[index] = max(existing, t.t) + } else { + queue.furthestTicks[index] = t.t } } + queue.ticks = slices.DeleteFunc(queue.ticks, func(t scheduledTick) bool { + return !queue.active(t) + }) +} + +func (queue *scheduledTickQueue) active(t scheduledTick) bool { + index := scheduledTickIndex{pos: t.pos, hash: t.bhash} + furthest, ok := queue.furthestTicks[index] + return ok && furthest == t.t } diff --git a/server/world/tx.go b/server/world/tx.go index 61655893a..7595f6697 100644 --- a/server/world/tx.go +++ b/server/world/tx.go @@ -8,7 +8,6 @@ import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/player/chat" - "github.com/df-mc/dragonfly/server/world/redstone" "github.com/go-gl/mathgl/mgl64" ) @@ -51,6 +50,12 @@ func (tx *Tx) Block(pos cube.Pos) Block { return tx.World().block(pos) } +// BlockLoaded returns the block at the position passed if the chunk containing it is already loaded. It returns false +// without loading or generating the chunk when the block is unavailable. +func (tx *Tx) BlockLoaded(pos cube.Pos) (Block, bool) { + return tx.World().blockLoaded(pos) +} + // Liquid attempts to return a Liquid block at the position passed. This // Liquid may be in the foreground or in any other layer. If found, the Liquid // is returned. If not, the bool returned is false. @@ -58,6 +63,12 @@ func (tx *Tx) Liquid(pos cube.Pos) (Liquid, bool) { return tx.World().liquid(pos) } +// LiquidLoaded attempts to return a Liquid block at the position passed if the chunk containing it is already loaded. +// It returns false without loading or generating the chunk when the liquid is unavailable. +func (tx *Tx) LiquidLoaded(pos cube.Pos) (Liquid, bool) { + return tx.World().liquidLoaded(pos) +} + // SetLiquid sets a Liquid at a specific position in the World. Unlike // SetBlock, SetLiquid will not necessarily overwrite any existing blocks. It // will instead be in the same position as a block currently there, unless @@ -89,6 +100,75 @@ func (tx *Tx) ScheduleBlockUpdate(pos cube.Pos, b Block, delay time.Duration) { tx.World().scheduleBlockUpdate(pos, b, delay) } +// ScheduleRedstoneUpdate schedules a redstone re-evaluation around the position passed in the next redstone phase. +func (tx *Tx) ScheduleRedstoneUpdate(pos cube.Pos) { + tx.World().redstone.invalidateAround(pos, pos, RedstoneUpdateCauseScheduledTick, tx.Range()) +} + +// RedstonePower returns the strongest redstone power currently applied to the position passed. +func (tx *Tx) RedstonePower(pos cube.Pos) int { + return tx.World().redstone.powerTo(pos, tx) +} + +// RedstoneDirectPower returns the strongest direct redstone power currently applied to the position passed, excluding +// power conducted through solid blocks. +func (tx *Tx) RedstoneDirectPower(pos cube.Pos) int { + return tx.World().redstone.directPower(pos, tx) +} + +// RedstoneStrongPower returns the strongest strong redstone power currently applied to the position passed. +func (tx *Tx) RedstoneStrongPower(pos cube.Pos) int { + return tx.World().redstone.strongPower(pos, tx) +} + +// RedstonePowerFrom returns the strongest redstone power reaching pos from the side passed. +func (tx *Tx) RedstonePowerFrom(pos cube.Pos, face cube.Face) int { + return tx.World().redstone.powerFrom(pos, tx, face, false) +} + +// RedstoneDirectPowerFrom returns the strongest direct redstone power reaching pos from the side passed. +func (tx *Tx) RedstoneDirectPowerFrom(pos cube.Pos, face cube.Face) int { + return tx.World().redstone.directPowerFrom(pos, tx, face) +} + +// RedstoneStrongPowerFrom returns the strongest strong redstone power reaching pos from the side passed. +func (tx *Tx) RedstoneStrongPowerFrom(pos cube.Pos, face cube.Face) int { + return tx.World().redstone.strongPowerFrom(pos, tx, face) +} + +// RedstoneStoredPowerFrom returns the strongest redstone power reaching pos from the side passed, allowing stored +// relayer output to contribute. This is used by ticked analog components such as comparators that sample the previous +// wire state for feedback loops. +func (tx *Tx) RedstoneStoredPowerFrom(pos cube.Pos, face cube.Face) int { + return tx.World().redstone.powerFrom(pos, tx, face, true) +} + +// RedstoneTorchBurnoutStatus reports whether the torch at pos is burned out and whether it may recover after its +// recent toggle history expires. +func (tx *Tx) RedstoneTorchBurnoutStatus(pos cube.Pos) (burnedOut, recoverable bool) { + return tx.World().redstone.redstoneTorchBurnoutStatus(pos, tx.CurrentTick()) +} + +// PruneRedstoneTorchBurnout removes expired burnout history for the torch at pos. +func (tx *Tx) PruneRedstoneTorchBurnout(pos cube.Pos) { + tx.World().redstone.pruneRedstoneTorchBurnout(pos, tx.CurrentTick()) +} + +// RecordRedstoneTorchToggle records a redstone torch state transition and reports whether it should burn out. +func (tx *Tx) RecordRedstoneTorchToggle(pos cube.Pos) bool { + return tx.World().redstone.recordRedstoneTorchToggle(pos, tx.CurrentTick()) +} + +// BurnOutRedstoneTorch marks the torch at pos as burned out. +func (tx *Tx) BurnOutRedstoneTorch(pos cube.Pos) { + tx.World().redstone.burnOutRedstoneTorch(pos) +} + +// ClearRedstoneTorchBurnout removes transient burnout history for the torch at pos. +func (tx *Tx) ClearRedstoneTorchBurnout(pos cube.Pos) { + tx.World().redstone.clearRedstoneTorchBurnout(pos) +} + // HighestLightBlocker gets the Y value of the highest fully light blocking // block at the x and z values passed in the World. func (tx *Tx) HighestLightBlocker(x, z int) int { @@ -278,44 +358,6 @@ func (tx *Tx) BroadcastSleepingReminder(sleeper Sleeper) { } } -// RedstonePower returns the redstone power emitted by the block at pos toward a neighbouring receiver. -// The face argument is relative to the receiving block. -func (tx *Tx) RedstonePower(pos cube.Pos, face cube.Face, accountForDust bool) (power int) { - b := tx.Block(pos) - if c, ok := b.(Conductor); ok { - return c.WeakPower(pos, face, tx, accountForDust) - } - // The wiki states that in the future some blocks may be transparent but still relay redstone. - // If a block implements RedstonePowerRelayer, it should always be prioritised over lightDiffuser. - if r, ok := b.(RedstonePowerRelayer); ok { - if !r.RelaysRedstonePowerThrough() { - return 0 - } - } else if d, ok := b.(lightDiffuser); ok && d.LightDiffusionLevel() != 15 { - return 0 - } - for _, f := range cube.Faces() { - if !b.Model().FaceSolid(pos, f, tx) { - return 0 - } - } - for _, f := range cube.Faces() { - c, ok := tx.Block(pos.Side(f)).(Conductor) - if !ok { - continue - } - sourcePos := pos.Side(f) - power = max(power, c.StrongPower(sourcePos, f, tx, accountForDust)) - if !accountForDust { - continue - } - if weakBlockPowerer, ok := c.(WeakBlockPowerer); ok && weakBlockPowerer.WeaklyPowersBlocks() { - power = max(power, c.WeakPower(sourcePos, f, tx, accountForDust)) - } - } - return power -} - // World returns the World of the Tx. It panics if the transaction was already // marked complete. func (tx *Tx) World() *World { @@ -333,11 +375,6 @@ func (tx *Tx) CurrentTick() int64 { return w.set.CurrentTick } -// Redstone returns the transient redstone runtime state owned by the transaction's world. -func (tx *Tx) Redstone() *redstone.State { - return &tx.World().redstone -} - // close finishes the Tx, causing any following call on the Tx to panic. func (tx *Tx) close() { tx.closed = true diff --git a/server/world/world.go b/server/world/world.go index 82290e434..5767b2e1d 100644 --- a/server/world/world.go +++ b/server/world/world.go @@ -16,7 +16,6 @@ import ( "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/internal/sliceutil" "github.com/df-mc/dragonfly/server/world/chunk" - "github.com/df-mc/dragonfly/server/world/redstone" "github.com/df-mc/goleveldb/leveldb" "github.com/go-gl/mathgl/mgl64" "github.com/google/uuid" @@ -65,10 +64,9 @@ type World struct { // tick value passed, the block update will be performed and the entry will // be removed from the map. scheduledUpdates *scheduledTickQueue + redstone *redstoneEngine neighbourUpdates []neighbourUpdate - redstone redstone.State - viewerMu sync.Mutex viewers map[*Loader]Viewer } @@ -160,6 +158,18 @@ func (w *World) block(pos cube.Pos) Block { return w.blockInChunk(w.chunk(chunkPosFromBlockPos(pos)), pos) } +// blockLoaded reads a block from a position only if its chunk is already loaded. +func (w *World) blockLoaded(pos cube.Pos) (Block, bool) { + if pos.OutOfBounds(w.ra) { + return w.conf.Blocks.Air(), false + } + c, ok := w.chunks[chunkPosFromBlockPos(pos)] + if !ok { + return w.conf.Blocks.Air(), false + } + return w.blockInChunkLoaded(c, pos), true +} + // blockInChunk reads a block from a chunk at the position passed. The block // is assumed to be within the chunk passed. func (w *World) blockInChunk(c *Column, pos cube.Pos) Block { @@ -185,6 +195,21 @@ func (w *World) blockInChunk(c *Column, pos cube.Pos) Block { return w.conf.Blocks.BlockByRuntimeIDOrAir(rid) } +// blockInChunkLoaded reads a block from an already-loaded chunk without repairing missing block entity data. It is +// used by loaded-only queries, which must not mutate chunks or broadcast viewer updates. +func (w *World) blockInChunkLoaded(c *Column, pos cube.Pos) Block { + if pos.OutOfBounds(w.ra) { + return w.conf.Blocks.Air() + } + rid := c.Block(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0) + if w.conf.Blocks.NBTBlock(rid) { + if b, ok := c.BlockEntities[pos]; ok { + return b + } + } + return w.conf.Blocks.BlockByRuntimeIDOrAir(rid) +} + // biome reads the Biome at the position passed. If a chunk is not yet loaded // at that position, the chunk is loaded, or generated if it could not be found // in the world save, and the Biome returned. @@ -241,6 +266,10 @@ type SetOpts struct { // performance is very important, or where it is known no liquid can be // present anyway. DisableLiquidDisplacement bool + // DisableRedstoneUpdates makes SetBlock not invalidate the redstone engine + // around the changed block. This is used by the redstone engine while + // applying its own block-state updates to avoid duplicate same-tick work. + DisableRedstoneUpdates bool } // setBlock writes a block to the position passed. If a chunk is not yet loaded @@ -268,12 +297,19 @@ func (w *World) setBlock(pos cube.Pos, b Block, opts *SetOpts) { x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) c := w.chunk(chunkPosFromBlockPos(pos)) + oldRID := c.Block(x, y, z, 0) + oldBlock := w.conf.Blocks.BlockByRuntimeIDOrAir(oldRID) + if w.conf.Blocks.NBTBlock(oldRID) { + if blockEntity, ok := c.BlockEntities[pos]; ok { + oldBlock = blockEntity + } + } rid := w.conf.Blocks.BlockRuntimeID(b) var before uint32 if rid != w.conf.Blocks.AirRuntimeID() && !opts.DisableLiquidDisplacement { - before = c.Block(x, y, z, 0) + before = oldRID } c.modified = true @@ -317,6 +353,10 @@ func (w *World) setBlock(pos cube.Pos, b Block, opts *SetOpts) { } } + if isRedstoneRelevant(oldBlock) || isRedstoneRelevant(b) { + w.redstone.forget(pos) + } + for _, viewer := range viewers { viewer.ViewBlockUpdate(pos, b, 0) } @@ -324,6 +364,9 @@ func (w *World) setBlock(pos cube.Pos, b Block, opts *SetOpts) { if !opts.DisableBlockUpdates { w.doBlockUpdatesAround(pos) } + if !opts.DisableRedstoneUpdates { + w.redstone.invalidateAround(pos, pos, RedstoneUpdateCauseBlockUpdate, w.Range()) + } } // setBiome sets the Biome at the position passed. If a chunk is not yet loaded @@ -456,6 +499,31 @@ func (w *World) liquid(pos cube.Pos) (Liquid, bool) { return liq, ok } +// liquidLoaded attempts to return a Liquid block at the position passed only if the chunk containing the position is +// already loaded. It returns false without loading or generating the chunk when the liquid is unavailable. +func (w *World) liquidLoaded(pos cube.Pos) (Liquid, bool) { + if pos.OutOfBounds(w.Range()) { + return nil, false + } + c, ok := w.chunks[chunkPosFromBlockPos(pos)] + if !ok { + return nil, false + } + x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) + for _, layer := range []uint8{0, 1} { + id := c.Block(x, y, z, layer) + b, ok := w.conf.Blocks.BlockByRuntimeID(id) + if !ok { + w.conf.Log.Error("liquidLoaded: no block with runtime ID", "ID", id) + return nil, false + } + if liq, ok := b.(Liquid); ok { + return liq, true + } + } + return nil, false +} + // setLiquid sets a Liquid at a specific position in the World. Unlike // setBlock, setLiquid will not necessarily overwrite any existing blocks. It // will instead be in the same position as a block currently there, unless @@ -472,6 +540,7 @@ func (w *World) setLiquid(pos cube.Pos, b Liquid) { if b == nil { w.removeLiquids(c, pos) w.doBlockUpdatesAround(pos) + w.redstone.invalidateAround(pos, pos, RedstoneUpdateCauseBlockUpdate, w.Range()) return } x, y, z := uint8(pos[0]), int16(pos[1]), uint8(pos[2]) @@ -495,6 +564,7 @@ func (w *World) setLiquid(pos cube.Pos, b Liquid) { c.modified = true w.doBlockUpdatesAround(pos) + w.redstone.invalidateAround(pos, pos, RedstoneUpdateCauseBlockUpdate, w.Range()) } // removeLiquids removes any liquid blocks that may be present at a specific @@ -1055,6 +1125,7 @@ func (w *World) saveChunk(_ *Tx, pos ChunkPos, c *Column) { func (w *World) closeChunk(tx *Tx, pos ChunkPos, c *Column) { w.saveChunk(tx, pos, c) w.scheduledUpdates.removeChunk(pos) + w.redstone.removeChunk(pos) // Note: We close c.Entities here because some entities may remove // themselves from the world in their Close method, which can lead to // unexpected conditions. diff --git a/server/world/world_test.go b/server/world/world_test.go new file mode 100644 index 000000000..c05c53b70 --- /dev/null +++ b/server/world/world_test.go @@ -0,0 +1,113 @@ +package world + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +func TestLiquidLoadedUsesWorldBlockRegistry(t *testing.T) { + br := NewBlockRegistry() + liquid := customLiquidTestBlock{} + br.RegisterBlockState(BlockState{Name: "test:liquid", Properties: map[string]any{}}) + br.RegisterBlock(liquid) + + w := Config{Blocks: br}.New() + defer func() { + if err := w.Close(); err != nil { + t.Fatalf("close world: %v", err) + } + }() + + pos := cube.Pos{0, 64, 0} + var ( + got Liquid + ok bool + ) + <-w.Exec(func(tx *Tx) { + c := tx.World().chunk(chunkPosFromBlockPos(pos)) + c.SetBlock(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0, tx.World().conf.Blocks.BlockRuntimeID(liquid)) + c.modified = true + + got, ok = tx.LiquidLoaded(pos) + }) + if !ok { + t.Fatal("LiquidLoaded returned ok=false, want true") + } + if got != liquid { + t.Fatalf("LiquidLoaded returned %#v, want %#v", got, liquid) + } +} + +func TestBlockLoadedDoesNotCreateMissingNBTBlockEntity(t *testing.T) { + br := NewBlockRegistry() + block := customNBTTestBlock{} + br.RegisterBlockState(BlockState{Name: "test:nbt_block", Properties: map[string]any{}}) + br.RegisterBlock(block) + + w := Config{Blocks: br}.New() + defer func() { + if err := w.Close(); err != nil { + t.Fatalf("close world: %v", err) + } + }() + + pos := cube.Pos{0, 64, 0} + var ( + got Block + ok, created bool + ) + <-w.Exec(func(tx *Tx) { + c := tx.World().chunk(chunkPosFromBlockPos(pos)) + c.SetBlock(uint8(pos[0]), int16(pos[1]), uint8(pos[2]), 0, tx.World().conf.Blocks.BlockRuntimeID(block)) + c.modified = true + + got, ok = tx.BlockLoaded(pos) + _, created = c.BlockEntities[pos] + }) + if !ok { + t.Fatal("BlockLoaded returned ok=false, want true") + } + if got != block { + t.Fatalf("BlockLoaded returned %#v, want %#v", got, block) + } + if created { + t.Fatal("BlockLoaded created missing block entity data") + } +} + +type customLiquidTestBlock struct{} + +func (customLiquidTestBlock) EncodeBlock() (string, map[string]any) { + return "test:liquid", nil +} +func (customLiquidTestBlock) Hash() (uint64, uint64) { return 1 << 45, 0 } +func (customLiquidTestBlock) Model() BlockModel { return redstoneCancellationModel{} } +func (customLiquidTestBlock) LiquidDepth() int { return 0 } +func (customLiquidTestBlock) SpreadDecay() int { return 1 } +func (customLiquidTestBlock) WithDepth(int, bool) Liquid { + return customLiquidTestBlock{} +} +func (customLiquidTestBlock) LiquidFalling() bool { return false } +func (customLiquidTestBlock) BlastResistance() float64 { return 100 } +func (customLiquidTestBlock) LiquidType() string { return "test" } +func (customLiquidTestBlock) Harden(cube.Pos, *Tx, *cube.Pos) bool { + return false +} +func (customLiquidTestBlock) LiquidRemoveBlock(cube.Pos, *Tx, Block) {} + +type customNBTTestBlock struct { + Decoded bool +} + +func (customNBTTestBlock) EncodeBlock() (string, map[string]any) { + return "test:nbt_block", nil +} +func (customNBTTestBlock) Hash() (uint64, uint64) { return 1 << 46, 0 } +func (customNBTTestBlock) Model() BlockModel { return redstoneCancellationModel{} } +func (customNBTTestBlock) EncodeNBT() map[string]any { + return nil +} +func (customNBTTestBlock) DecodeNBT(map[string]any) any { + return customNBTTestBlock{Decoded: true} +}