diff --git a/server/block/dispenser.go b/server/block/dispenser.go new file mode 100644 index 000000000..649966f9c --- /dev/null +++ b/server/block/dispenser.go @@ -0,0 +1,261 @@ +package block + +import ( + "fmt" + "math/rand/v2" + "strings" + "sync" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/internal/nbtconv" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +// Dispenser is a nine-slot container that dispenses an item when activated by redstone. +type Dispenser struct { + solid + sourceWaterDisplacer + + // Facing is the direction items are dispensed towards. + Facing cube.Face + // Triggered is whether the dispenser currently receives redstone power. + Triggered bool + // CustomName is the custom name displayed when the dispenser is opened. + CustomName string + + inventory *inventory.Inventory + viewerMu *sync.RWMutex + viewers map[ContainerViewer]struct{} +} + +var ( + _ world.RedstonePowerConsumer = Dispenser{} + _ world.RedstonePowerPostUpdater = Dispenser{} +) + +const dispenserDelay = time.Second / 5 + +// NewDispenser creates an initialised dispenser. +func NewDispenser() Dispenser { + m := new(sync.RWMutex) + v := make(map[ContainerViewer]struct{}, 1) + return Dispenser{ + inventory: inventory.New(9, func(slot int, _, stack item.Stack) { + m.RLock() + defer m.RUnlock() + for viewer := range v { + viewer.ViewSlotChange(slot, stack) + } + }), + viewerMu: m, + viewers: v, + } +} + +// Inventory returns the dispenser inventory. +func (d Dispenser) Inventory(*world.Tx, cube.Pos) *inventory.Inventory { return d.inventory } + +// WithName returns the dispenser with a custom name. +func (d Dispenser) WithName(a ...any) world.Item { + d.CustomName = strings.TrimSuffix(fmt.Sprintln(a...), "\n") + return d +} + +// AddViewer adds a viewer to the dispenser inventory. +func (d Dispenser) AddViewer(v ContainerViewer, _ *world.Tx, _ cube.Pos) { + d.viewerMu.Lock() + defer d.viewerMu.Unlock() + d.viewers[v] = struct{}{} +} + +// RemoveViewer removes a viewer from the dispenser inventory. +func (d Dispenser) RemoveViewer(v ContainerViewer, _ *world.Tx, _ cube.Pos) { + d.viewerMu.Lock() + defer d.viewerMu.Unlock() + delete(d.viewers, v) +} + +// Activate opens the dispenser inventory. +func (Dispenser) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, _ *item.UseContext) bool { + if opener, ok := u.(ContainerOpener); ok { + opener.OpenBlockContainer(pos, tx) + return true + } + return false +} + +// RedstonePowerUpdate updates the dispenser's triggered state. In addition to direct power, dispensers accept power at +// the block above them, matching Java Edition's quasi-connectivity behaviour. +func (d Dispenser) RedstonePowerUpdate(pos cube.Pos, tx *world.Tx, power int) (world.Block, bool) { + powered := power > 0 || tx.RedstonePower(pos.Side(cube.FaceUp)) > 0 + if d.Triggered == powered { + return d, false + } + d.Triggered = powered + return d, true +} + +// RedstonePowerPostUpdate schedules a dispense after an uncancelled rising edge. +func (Dispenser) RedstonePowerPostUpdate(pos cube.Pos, tx *world.Tx, before, after world.Block, _, _ int) { + beforeDispenser, beforeOK := before.(Dispenser) + afterDispenser, afterOK := after.(Dispenser) + if !beforeOK || !afterOK || beforeDispenser.Triggered || !afterDispenser.Triggered { + return + } + // Scheduled block updates are keyed by block state. Queue both states so a short pulse still fires after the + // delay, while only the state that remains at execution time is run. + tx.ScheduleBlockUpdate(pos, afterDispenser, dispenserDelay) + afterDispenser.Triggered = false + tx.ScheduleBlockUpdate(pos, afterDispenser, dispenserDelay) +} + +// ScheduledTick dispenses one item after the activation delay. +func (d Dispenser) ScheduledTick(pos cube.Pos, tx *world.Tx, r *rand.Rand) { + if !d.dispense(pos, tx, r) { + tx.PlaySound(pos.Vec3Centre(), sound.ClickFail{}) + } +} + +func (d Dispenser) dispense(pos cube.Pos, tx *world.Tx, r *rand.Rand) bool { + if d.inventory == nil { + return false + } + slots := d.inventory.Slots() + nonEmpty := make([]int, 0, len(slots)) + for slot, stack := range slots { + if !stack.Empty() { + nonEmpty = append(nonEmpty, slot) + } + } + if len(nonEmpty) == 0 { + return false + } + slot := nonEmpty[r.IntN(len(nonEmpty))] + stack := slots[slot] + if behaviour, ok := stack.Item().(item.Dispensable); ok { + ctx := &item.DispenseContext{Rand: r} + switch behaviour.Dispense(pos, d.Facing, tx, ctx) { + case item.DispenseSuccess: + return d.applyDispenseContext(slot, stack, ctx, pos, tx, r) + case item.DispenseFailure: + return false + } + } + return d.dropDispensedItem(slot, stack, pos, tx, r) +} + +func (d Dispenser) applyDispenseContext(slot int, stack item.Stack, ctx *item.DispenseContext, pos cube.Pos, tx *world.Tx, r *rand.Rand) bool { + stack = stack.Damage(ctx.Damage).Grow(-ctx.CountSub) + if ctx.NewItem.Empty() { + return d.inventory.SetItem(slot, stack) == nil + } + if stack.Empty() { + return d.inventory.SetItem(slot, ctx.NewItem) == nil + } + added, err := d.inventory.AddItem(ctx.NewItem) + if err != nil { + create := tx.World().EntityRegistry().Config().Item + if create == nil { + return false + } + remaining := ctx.NewItem.Grow(added - ctx.NewItem.Count()) + tx.AddEntity(create(dispenserDropOpts(pos, d.Facing, r), remaining)) + } + return d.inventory.SetItem(slot, stack) == nil +} + +func (d Dispenser) dropDispensedItem(slot int, stack item.Stack, pos cube.Pos, tx *world.Tx, r *rand.Rand) bool { + create := tx.World().EntityRegistry().Config().Item + if create == nil { + return false + } + + opts := dispenserDropOpts(pos, d.Facing, r) + dropped := stack.Grow(1 - stack.Count()) + if err := d.inventory.SetItem(slot, stack.Grow(-1)); err != nil { + return false + } + tx.AddEntity(create(opts, dropped)) + tx.PlaySound(pos.Vec3Centre(), sound.Click{}) + return true +} + +// dispenserDirection returns the unit vector pointing out of the front of a dispenser with the face passed. An invalid +// face yields a zero vector. +func dispenserDirection(face cube.Face) mgl64.Vec3 { + return cube.Pos{}.Side(face).Vec3() +} + +// dispenserDropOpts returns the spawn options for an item dropped out of the front of a dispenser. +func dispenserDropOpts(pos cube.Pos, facing cube.Face, r *rand.Rand) world.EntitySpawnOpts { + direction := dispenserDirection(facing) + return world.EntitySpawnOpts{ + Position: pos.Vec3Centre().Add(direction.Mul(0.7)), + Velocity: direction.Mul(0.25).Add(mgl64.Vec3{r.Float64()*0.04 - 0.02, 0.1, r.Float64()*0.04 - 0.02}), + } +} + +// UseOnBlock places the dispenser facing the player. +func (d Dispenser) 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, d) + if !used { + return false + } + d = NewDispenser() + d.Facing = calculateFace(user, pos) + place(tx, pos, d, user, ctx) + return placed(ctx) +} + +// BreakInfo returns the dispenser's breaking properties. +func (d Dispenser) BreakInfo() BreakInfo { + return newBreakInfo(3.5, pickaxeHarvestable, pickaxeEffective, oneOf(Dispenser{})).withBlastResistance(17.5).withBreakHandler(func(pos cube.Pos, tx *world.Tx, u item.User) { + for _, stack := range d.Inventory(tx, pos).Clear() { + dropItem(tx, stack, pos.Vec3()) + } + }) +} + +// DecodeNBT decodes dispenser block-entity data. +func (d Dispenser) DecodeNBT(data map[string]any) any { + facing, triggered := d.Facing, d.Triggered + d = NewDispenser() + d.Facing, d.Triggered = facing, triggered + d.CustomName = nbtconv.String(data, "CustomName") + nbtconv.InvFromNBT(d.inventory, nbtconv.Slice(data, "Items")) + return d +} + +// EncodeNBT encodes dispenser block-entity data. +func (d Dispenser) EncodeNBT() map[string]any { + if d.inventory == nil { + facing, triggered, customName := d.Facing, d.Triggered, d.CustomName + d = NewDispenser() + d.Facing, d.Triggered, d.CustomName = facing, triggered, customName + } + m := map[string]any{"Items": nbtconv.InvToNBT(d.inventory), "id": "Dispenser"} + if d.CustomName != "" { + m["CustomName"] = d.CustomName + } + return m +} + +// EncodeBlock encodes the dispenser block state. +func (d Dispenser) EncodeBlock() (string, map[string]any) { + return "minecraft:dispenser", map[string]any{"facing_direction": int32(d.Facing), "triggered_bit": boolByte(d.Triggered)} +} + +// EncodeItem encodes the dispenser item. +func (Dispenser) EncodeItem() (string, int16) { return "minecraft:dispenser", 0 } + +func allDispensers() (blocks []world.Block) { + for _, f := range cube.Faces() { + blocks = append(blocks, Dispenser{Facing: f}, Dispenser{Facing: f, Triggered: true}) + } + return +} diff --git a/server/block/dispenser_behaviour_test.go b/server/block/dispenser_behaviour_test.go new file mode 100644 index 000000000..c07e47351 --- /dev/null +++ b/server/block/dispenser_behaviour_test.go @@ -0,0 +1,380 @@ +package block_test + +import ( + "context" + "math/rand/v2" + "testing" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +func runDispenserWorld(t *testing.T, w *world.World, f func(*world.Tx)) { + t.Helper() + if err := w.Do(f).Wait(context.Background()); err != nil { + t.Fatalf("run world task: %v", err) + } +} + +func TestEmptyDispenserPlaysFailureClick(t *testing.T) { + w := world.Config{Synchronous: true}.New() + defer func() { _ = w.Close() }() + + viewer := &soundViewer{} + loader := world.NewLoader(1, w, viewer) + runDispenserWorld(t, w, func(tx *world.Tx) { + loader.Load(tx, 1) + d := block.NewDispenser() + d.ScheduledTick(cube.Pos{}, tx, rand.New(rand.NewPCG(1, 2))) + loader.Close(tx) + }) + + if len(viewer.sounds) != 1 { + t.Fatalf("expected one dispenser failure sound, got %d", len(viewer.sounds)) + } + if _, ok := viewer.sounds[0].(sound.ClickFail); !ok { + t.Fatalf("expected dispenser failure click, got %T", viewer.sounds[0]) + } +} + +type testDispensable struct{} + +func (testDispensable) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *item.DispenseContext) item.DispenseResult { + ctx.SubtractFromCount(1) + tx.SetBlock(pos.Side(face), block.RedstoneBlock{}, nil) + return item.DispenseSuccess +} + +func (testDispensable) EncodeItem() (string, int16) { return "test:dispensable", 0 } + +func TestDispenserUsesItemDispenseBehaviour(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{} + front := pos.Side(cube.FaceEast) + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(testDispensable{}, 1)) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(19, 20))) + + if _, ok := tx.Block(front).(block.RedstoneBlock); !ok { + t.Fatalf("expected custom dispense behaviour to run, got %T", tx.Block(front)) + } + stack, _ := d.Inventory(tx, pos).Item(0) + if !stack.Empty() { + t.Fatalf("expected custom dispense behaviour to consume its item, got %v", stack) + } + }) +} + +func TestDispenserDoesNotReplayTargetIgnitionSound(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + viewer := &soundViewer{} + loader := world.NewLoader(1, w, viewer) + runDispenserWorld(t, w, func(tx *world.Tx) { + loader.Load(tx, 1) + pos := cube.Pos{} + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(item.FlintAndSteel{}, 1)) + tx.SetBlock(pos.Side(cube.FaceEast), block.Campfire{Extinguished: true}, nil) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(3, 4))) + loader.Close(tx) + }) + + ignitions := 0 + for _, played := range viewer.sounds { + if _, ok := played.(sound.Ignite); ok { + ignitions++ + } + } + if ignitions != 1 { + t.Fatalf("expected target block to own one ignition sound, got %d", ignitions) + } +} + +func TestDispenserRetainsFlintAndSteelWhenFireAlreadyExists(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{} + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + stack := item.NewStack(item.FlintAndSteel{}, 1) + _ = d.Inventory(tx, pos).SetItem(0, stack) + tx.SetBlock(pos.Side(cube.FaceEast), block.Fire{}, nil) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(5, 6))) + + got, _ := d.Inventory(tx, pos).Item(0) + if got.Durability() != stack.Durability() { + t.Fatalf("expected existing fire not to damage flint and steel: durability %d, want %d", got.Durability(), stack.Durability()) + } + }) +} + +type soundViewer struct { + world.NopViewer + sounds []world.Sound +} + +func (v *soundViewer) ViewSound(_ mgl64.Vec3, played world.Sound) { + v.sounds = append(v.sounds, played) +} + +func TestDispenserDispensesAfterFourTickPulse(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + powerPos := cube.Pos{1, 1, 0} + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceNorth + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(item.Stick{}, 1)) + tx.SetBlock(pos, d, nil) + tx.SetBlock(powerPos, block.RedstoneBlock{}, nil) + }) + w.AdvanceTick() + runDispenserWorld(t, w, func(tx *world.Tx) { + tx.SetBlock(powerPos, block.Air{}, nil) + }) + w.AdvanceTick() + + for range 2 { + w.AdvanceTick() + } + if got := entityCount(t, w); got != 0 { + t.Fatalf("dispenser fired before its four-tick delay: got %d entities", got) + } + + w.AdvanceTick() + if got := entityCount(t, w); got != 1 { + t.Fatalf("expected one dispensed item entity after four ticks, got %d", got) + } + runDispenserWorld(t, w, func(tx *world.Tx) { + if stack, _ := tx.Block(pos).(block.Dispenser).Inventory(tx, pos).Item(0); !stack.Empty() { + t.Fatalf("expected selected dispenser slot to be consumed, got %v", stack) + } + }) +} + +// TestDispenserLaunchesOwnerlessProjectiles covers the projectiles a dispenser launches without an owner entity. Every +// one of these reaches its entity constructor with a nil owner, so the constructors must tolerate that. +func TestDispenserLaunchesOwnerlessProjectiles(t *testing.T) { + for _, test := range []struct { + name string + it world.Item + want string + }{ + {name: "arrow", it: item.Arrow{}, want: "minecraft:arrow"}, + {name: "snowball", it: item.Snowball{}, want: "minecraft:snowball"}, + {name: "egg", it: item.Egg{}, want: "minecraft:egg"}, + {name: "splash potion", it: item.SplashPotion{}, want: "minecraft:splash_potion"}, + {name: "lingering potion", it: item.LingeringPotion{}, want: "minecraft:lingering_potion"}, + {name: "bottle of enchanting", it: item.BottleOfEnchanting{}, want: "minecraft:xp_bottle"}, + {name: "firework", it: item.Firework{}, want: "minecraft:fireworks_rocket"}, + } { + t.Run(test.name, func(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(test.it, 1)) + tx.SetBlock(pos, d, nil) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(17, 18))) + + for e := range tx.Entities() { + if got := e.H().Type().EncodeEntity(); got != test.want { + t.Fatalf("expected dispenser to launch %q, got %q", test.want, got) + } + return + } + t.Fatalf("expected dispenser to launch a %s entity", test.name) + }) + }) + } +} + +func TestDispenserFillsBucketFromSource(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + front := pos.Side(cube.FaceEast) + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(item.Bucket{}, 1)) + tx.SetBlock(pos, d, nil) + tx.SetLiquid(front, block.Water{Depth: 8}) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(3, 4))) + + stack, _ := d.Inventory(tx, pos).Item(0) + bucket, ok := stack.Item().(item.Bucket) + if !ok || bucket.Empty() { + t.Fatalf("expected source water to fill the bucket, got %v", stack) + } + if _, ok := tx.Liquid(front); ok { + t.Fatal("expected filled source water to be removed") + } + }) +} + +func TestDispenserPrimesTNT(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceSouth + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(block.TNT{}, 1)) + tx.SetBlock(pos, d, nil) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(5, 6))) + }) + + runDispenserWorld(t, w, func(tx *world.Tx) { + for e := range tx.Entities() { + if got := e.H().Type().EncodeEntity(); got != "minecraft:tnt" { + t.Fatalf("expected dispenser to prime TNT, got %q", got) + } + return + } + t.Fatal("expected dispenser to create a primed TNT entity") + }) +} + +func TestDispenserUsesFlintAndSteel(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + front := pos.Side(cube.FaceEast) + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(item.FlintAndSteel{}, 1)) + tx.SetBlock(pos, d, nil) + tx.SetBlock(front.Side(cube.FaceDown), block.Stone{}, nil) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(7, 8))) + + if _, ok := tx.Block(front).(block.Fire); !ok { + t.Fatalf("expected flint and steel to light fire, got %T", tx.Block(front)) + } + stack, _ := d.Inventory(tx, pos).Item(0) + if stack.Durability() != stack.MaxDurability()-1 { + t.Fatalf("expected flint and steel to take one durability, got %d", stack.Durability()) + } + }) +} + +func TestDispenserFillsGlassBottle(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + front := pos.Side(cube.FaceEast) + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(item.GlassBottle{}, 1)) + tx.SetBlock(pos, d, nil) + tx.SetLiquid(front, block.Water{Depth: 8}) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(9, 10))) + + stack, _ := d.Inventory(tx, pos).Item(0) + if _, ok := stack.Item().(item.Potion); !ok { + t.Fatalf("expected glass bottle to become a water potion, got %v", stack) + } + if _, ok := tx.Liquid(front); !ok { + t.Fatal("expected bottling water not to consume the source") + } + }) +} + +func TestDispenserWaxesCopper(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + front := pos.Side(cube.FaceEast) + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(item.Honeycomb{}, 1)) + tx.SetBlock(pos, d, nil) + tx.SetBlock(front, block.Copper{}, nil) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(11, 12))) + + if copper := tx.Block(front).(block.Copper); !copper.Waxed { + t.Fatal("expected honeycomb to wax the copper block") + } + }) +} + +func TestDispenserAppliesBoneMeal(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + front := pos.Side(cube.FaceEast) + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(item.BoneMeal{}, 1)) + tx.SetBlock(pos, d, nil) + tx.SetBlock(front, block.Carrot{}, nil) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(13, 14))) + + if carrot := tx.Block(front).(block.Carrot); carrot.Growth == 0 { + t.Fatal("expected bone meal to grow the crop") + } + stack, _ := d.Inventory(tx, pos).Item(0) + if !stack.Empty() { + t.Fatalf("expected successful bone meal use to consume one item, got %v", stack) + } + }) +} + +func TestDispenserRetainsBoneMealWhenUseFails(t *testing.T) { + w := world.Config{Synchronous: true, Entities: entity.DefaultRegistry}.New() + defer func() { _ = w.Close() }() + + pos := cube.Pos{0, 0, 0} + runDispenserWorld(t, w, func(tx *world.Tx) { + d := block.NewDispenser() + d.Facing = cube.FaceEast + _ = d.Inventory(tx, pos).SetItem(0, item.NewStack(item.BoneMeal{}, 1)) + tx.SetBlock(pos, d, nil) + d.ScheduledTick(pos, tx, rand.New(rand.NewPCG(15, 16))) + + stack, _ := d.Inventory(tx, pos).Item(0) + if stack.Count() != 1 { + t.Fatalf("expected failed bone meal use to retain the item, got %v", stack) + } + }) +} + +func entityCount(t *testing.T, w *world.World) int { + t.Helper() + count := 0 + runDispenserWorld(t, w, func(tx *world.Tx) { + for range tx.Entities() { + count++ + } + }) + return count +} diff --git a/server/block/hash.go b/server/block/hash.go index 12c33d96d..1962a4dc3 100644 --- a/server/block/hash.go +++ b/server/block/hash.go @@ -72,6 +72,7 @@ const ( hashDiorite hashDirt hashDirtPath + hashDispenser hashDoubleFlower hashDoubleTallGrass hashDragonEgg @@ -503,6 +504,10 @@ func (DirtPath) Hash() (uint64, uint64) { return hashDirtPath, 0 } +func (d Dispenser) Hash() (uint64, uint64) { + return hashDispenser, uint64(d.Facing) | uint64(boolByte(d.Triggered))<<3 +} + func (d DoubleFlower) Hash() (uint64, uint64) { return hashDoubleFlower, uint64(boolByte(d.UpperPart)) | uint64(d.Type.Uint8())<<1 } diff --git a/server/block/hopper.go b/server/block/hopper.go index df6501df9..0dc7b75e4 100644 --- a/server/block/hopper.go +++ b/server/block/hopper.go @@ -38,6 +38,8 @@ type Hopper struct { viewers map[ContainerViewer]struct{} } +var _ world.RedstonePowerConsumer = Hopper{} + // NewHopper creates a new initialised hopper. The inventory is properly initialised. func NewHopper() Hopper { m := new(sync.RWMutex) @@ -156,6 +158,16 @@ func (h Hopper) Tick(_ int64, pos cube.Pos, tx *world.Tx) { } } +// RedstonePowerUpdate updates the hopper's locked state to match the power it receives. +func (h Hopper) RedstonePowerUpdate(_ cube.Pos, _ *world.Tx, power int) (world.Block, bool) { + powered := power > 0 + if h.Powered == powered { + return h, false + } + h.Powered = powered + return h, true +} + // HopperInsertable represents a block that can have its contents inserted into by a hopper. type HopperInsertable interface { // InsertItem handles the insert logic for that block. @@ -177,7 +189,7 @@ func (h Hopper) insertItem(pos cube.Pos, tx *world.Tx) bool { continue } - _, err := container.Inventory(tx, pos).AddItem(sourceStack.Grow(-sourceStack.Count() + 1)) + _, err := container.Inventory(tx, destPos).AddItem(sourceStack.Grow(-sourceStack.Count() + 1)) if err != nil { // The destination is full. return false diff --git a/server/block/register.go b/server/block/register.go index 4522a98cd..0e7e14b20 100644 --- a/server/block/register.go +++ b/server/block/register.go @@ -247,6 +247,7 @@ func init() { registerAll(allWood()) registerAll(allWool()) registerAll(allDecoratedPots()) + registerAll(allDispensers()) registerAll(allCopper()) registerAll(allCopperBars()) registerAll(allCopperChains()) @@ -303,6 +304,7 @@ func init() { world.RegisterItem(CopperTorch{}) world.RegisterItem(CraftingTable{}) world.RegisterItem(DeadBush{}) + world.RegisterItem(Dispenser{}) world.RegisterItem(DeepslateBricks{Cracked: true}) world.RegisterItem(DeepslateBricks{}) world.RegisterItem(DeepslateTiles{Cracked: true}) diff --git a/server/block/tnt.go b/server/block/tnt.go index 8e9923c08..7e5da944d 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -16,6 +16,24 @@ type TNT struct { solid } +// Dispense primes TNT at the block in front of a dispenser. +func (TNT) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *item.DispenseContext) item.DispenseResult { + if tx.World().EntityRegistry().Config().TNT == nil { + return item.DispenseFailure + } + front := pos.Side(face) + b := tx.Block(front) + if _, air := b.(Air); !air { + replaceable, ok := b.(Replaceable) + if !ok || !replaceable.ReplaceableBy(TNT{}) { + return item.DispenseFailure + } + } + ctx.SubtractFromCount(1) + spawnTnt(front, tx, time.Second*4) + return item.DispenseSuccess +} + var _ world.RedstonePowerAction = TNT{} func (TNT) RedstoneNonConductive() {} diff --git a/server/entity/arrow.go b/server/entity/arrow.go index 23714f947..7e2fd0d8e 100644 --- a/server/entity/arrow.go +++ b/server/entity/arrow.go @@ -12,28 +12,28 @@ import ( // NewArrow creates a new Arrow and returns it. It is equivalent to calling NewTippedArrow with `potion.Potion{}` as // tip. -func NewArrow(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { +func NewArrow(opts world.EntitySpawnOpts, owner *world.EntityHandle) *world.EntityHandle { return NewTippedArrowWithDamage(opts, 2.0, owner, potion.Potion{}) } // NewArrowWithDamage creates a new Arrow with the given base damage, and returns it. It is equivalent to calling // NewTippedArrowWithDamage with `potion.Potion{}` as tip. -func NewArrowWithDamage(opts world.EntitySpawnOpts, damage float64, owner world.Entity) *world.EntityHandle { +func NewArrowWithDamage(opts world.EntitySpawnOpts, damage float64, owner *world.EntityHandle) *world.EntityHandle { return NewTippedArrowWithDamage(opts, damage, owner, potion.Potion{}) } // NewTippedArrow creates a new Arrow with a potion effect added to an entity when hit. -func NewTippedArrow(opts world.EntitySpawnOpts, owner world.Entity, tip potion.Potion) *world.EntityHandle { +func NewTippedArrow(opts world.EntitySpawnOpts, owner *world.EntityHandle, tip potion.Potion) *world.EntityHandle { return NewTippedArrowWithDamage(opts, 2.0, owner, tip) } // NewTippedArrowWithDamage creates a new Arrow with a potion effect added to an entity when hit and, and returns it. // It uses the given damage as the base damage. -func NewTippedArrowWithDamage(opts world.EntitySpawnOpts, damage float64, owner world.Entity, tip potion.Potion) *world.EntityHandle { +func NewTippedArrowWithDamage(opts world.EntitySpawnOpts, damage float64, owner *world.EntityHandle, tip potion.Potion) *world.EntityHandle { conf := arrowConf conf.Damage = damage conf.Potion = tip - conf.Owner = owner.H() + conf.Owner = owner return opts.New(ArrowType, conf) } diff --git a/server/entity/bottle_of_enchanting.go b/server/entity/bottle_of_enchanting.go index 153c604d9..d2c947cf2 100644 --- a/server/entity/bottle_of_enchanting.go +++ b/server/entity/bottle_of_enchanting.go @@ -10,9 +10,9 @@ import ( ) // NewBottleOfEnchanting ... -func NewBottleOfEnchanting(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { +func NewBottleOfEnchanting(opts world.EntitySpawnOpts, owner *world.EntityHandle) *world.EntityHandle { conf := bottleOfEnchantingConf - conf.Owner = owner.H() + conf.Owner = owner return opts.New(BottleOfEnchantingType, conf) } diff --git a/server/entity/egg.go b/server/entity/egg.go index ae64b5956..31f278351 100644 --- a/server/entity/egg.go +++ b/server/entity/egg.go @@ -8,9 +8,9 @@ import ( // NewEgg creates an Egg entity. Egg is as a throwable entity that can be used // to spawn chicks. -func NewEgg(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { +func NewEgg(opts world.EntitySpawnOpts, owner *world.EntityHandle) *world.EntityHandle { conf := eggConf - conf.Owner = owner.H() + conf.Owner = owner return opts.New(EggType, conf) } diff --git a/server/entity/ender_pearl.go b/server/entity/ender_pearl.go index 42b905af1..92357f6f4 100644 --- a/server/entity/ender_pearl.go +++ b/server/entity/ender_pearl.go @@ -10,10 +10,13 @@ import ( ) // NewEnderPearl creates an EnderPearl entity. EnderPearl is a smooth, greenish- -// blue item used to teleport. -func NewEnderPearl(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { +// blue item used to teleport. Owner must not be nil. +func NewEnderPearl(opts world.EntitySpawnOpts, owner *world.EntityHandle) *world.EntityHandle { + if owner == nil { + panic("ender pearl owner must not be nil") + } conf := enderPearlConf - conf.Owner = owner.H() + conf.Owner = owner return opts.New(EnderPearlType, conf) } diff --git a/server/entity/firework.go b/server/entity/firework.go index db1353200..46d5a635d 100644 --- a/server/entity/firework.go +++ b/server/entity/firework.go @@ -15,21 +15,22 @@ func NewFirework(opts world.EntitySpawnOpts, firework item.Firework) *world.Enti } // NewFireworkAttached creates a firework entity with an owner that the firework -// may be attached to. -func NewFireworkAttached(opts world.EntitySpawnOpts, firework item.Firework, owner world.Entity) *world.EntityHandle { +// may be attached to. Owner must not be nil. +func NewFireworkAttached(opts world.EntitySpawnOpts, firework item.Firework, owner *world.EntityHandle) *world.EntityHandle { + if owner == nil { + panic("attached firework owner must not be nil") + } return newFirework(opts, firework, owner, 0, 0, true) } -func newFirework(opts world.EntitySpawnOpts, firework item.Firework, owner world.Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *world.EntityHandle { +func newFirework(opts world.EntitySpawnOpts, firework item.Firework, owner *world.EntityHandle, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *world.EntityHandle { conf := fireworkConf conf.SidewaysVelocityMultiplier = sidewaysVelocityMultiplier conf.UpwardsAcceleration = upwardsAcceleration conf.Firework = firework conf.ExistenceDuration = firework.RandomisedDuration() conf.Attached = attached - if attached { - conf.Owner = owner.H() - } + conf.Owner = owner return opts.New(FireworkType, conf) } diff --git a/server/entity/firework_behaviour.go b/server/entity/firework_behaviour.go index ae7f68c42..6e7ecab39 100644 --- a/server/entity/firework_behaviour.go +++ b/server/entity/firework_behaviour.go @@ -13,7 +13,9 @@ import ( // FireworkBehaviourConfig holds optional parameters for a FireworkBehaviour. type FireworkBehaviourConfig struct { Firework item.Firework - Owner *world.EntityHandle + // Owner is the handle of the entity that launched the firework. It may be + // nil if the firework has no owner. + Owner *world.EntityHandle // ExistenceDuration is the duration that an entity with this behaviour // should last. Once this time expires, the entity is closed. If // ExistenceDuration is 0, the entity will never expire automatically. @@ -66,7 +68,8 @@ func (f *FireworkBehaviour) Attached() bool { return f.conf.Attached } -// Owner returns the world.Entity that launched the firework. +// Owner returns the handle of the entity that launched the firework, or nil if +// the firework has no owner. func (f *FireworkBehaviour) Owner() *world.EntityHandle { return f.conf.Owner } diff --git a/server/entity/lingering_potion.go b/server/entity/lingering_potion.go index fea905a77..3bb2f1c1b 100644 --- a/server/entity/lingering_potion.go +++ b/server/entity/lingering_potion.go @@ -12,14 +12,14 @@ import ( // NewLingeringPotion creates a new lingering potion. LingeringPotion is a // variant of a splash potion that can be thrown to leave clouds with status // effects that linger on the ground in an area. -func NewLingeringPotion(opts world.EntitySpawnOpts, t potion.Potion, owner world.Entity) *world.EntityHandle { +func NewLingeringPotion(opts world.EntitySpawnOpts, t potion.Potion, owner *world.EntityHandle) *world.EntityHandle { colour, _ := effect.ResultingColour(t.Effects()) conf := splashPotionConf conf.Potion = t conf.Particle = particle.Splash{Colour: colour} conf.Hit = potionSplash(0.25, t, true) - conf.Owner = owner.H() + conf.Owner = owner return opts.New(LingeringPotionType, conf) } diff --git a/server/entity/projectile.go b/server/entity/projectile.go index 250229957..cb11b5b71 100644 --- a/server/entity/projectile.go +++ b/server/entity/projectile.go @@ -21,6 +21,8 @@ import ( // ProjectileBehaviourConfig.New() creates a ProjectileBehaviour using these // settings. type ProjectileBehaviourConfig struct { + // Owner is the handle of the entity that launched the projectile. It may be + // nil if the projectile has no owner. Owner *world.EntityHandle // Gravity is the amount of Y velocity subtracted every tick. Gravity float64 diff --git a/server/entity/register.go b/server/entity/register.go index 7155ec986..6117d2602 100644 --- a/server/entity/register.go +++ b/server/entity/register.go @@ -35,22 +35,22 @@ var conf = world.EntityRegistryConfig{ EnderPearl: NewEnderPearl, FallingBlock: NewFallingBlock, Lightning: NewLightning, - Firework: func(opts world.EntitySpawnOpts, firework world.Item, owner world.Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *world.EntityHandle { + Firework: func(opts world.EntitySpawnOpts, firework world.Item, owner *world.EntityHandle, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *world.EntityHandle { return newFirework(opts, firework.(item.Firework), owner, sidewaysVelocityMultiplier, upwardsAcceleration, attached) }, Item: func(opts world.EntitySpawnOpts, it any) *world.EntityHandle { return NewItem(opts, it.(item.Stack)) }, - LingeringPotion: func(opts world.EntitySpawnOpts, t any, owner world.Entity) *world.EntityHandle { + LingeringPotion: func(opts world.EntitySpawnOpts, t any, owner *world.EntityHandle) *world.EntityHandle { return NewLingeringPotion(opts, t.(potion.Potion), owner) }, - SplashPotion: func(opts world.EntitySpawnOpts, t any, owner world.Entity) *world.EntityHandle { + SplashPotion: func(opts world.EntitySpawnOpts, t any, owner *world.EntityHandle) *world.EntityHandle { return NewSplashPotion(opts, t.(potion.Potion), owner) }, Arrow: func(opts world.EntitySpawnOpts, arrow world.ArrowSpawnConfig) *world.EntityHandle { tip := arrow.Tip.(potion.Potion) conf := arrowConf - conf.Damage, conf.Potion, conf.Owner = arrow.Damage, tip, arrow.Owner.H() + conf.Damage, conf.Potion, conf.Owner = arrow.Damage, tip, arrow.Owner conf.KnockBackForceAddend = float64(arrow.PunchLevel) * enchantment.Punch.KnockBackMultiplier() conf.DisablePickup = arrow.DisablePickup if arrow.ObtainArrowOnPickup { diff --git a/server/entity/snowball.go b/server/entity/snowball.go index 5733377d6..297c75f0e 100644 --- a/server/entity/snowball.go +++ b/server/entity/snowball.go @@ -6,10 +6,10 @@ import ( "github.com/df-mc/dragonfly/server/world/particle" ) -// NewSnowball creates a snowball entity at a position with an owner entity. -func NewSnowball(opts world.EntitySpawnOpts, owner world.Entity) *world.EntityHandle { +// NewSnowball creates a snowball entity at a position. Owner may be nil. +func NewSnowball(opts world.EntitySpawnOpts, owner *world.EntityHandle) *world.EntityHandle { conf := snowballConf - conf.Owner = owner.H() + conf.Owner = owner return opts.New(SnowballType, conf) } diff --git a/server/entity/splash_potion.go b/server/entity/splash_potion.go index aad54c460..6394cc0cd 100644 --- a/server/entity/splash_potion.go +++ b/server/entity/splash_potion.go @@ -12,14 +12,14 @@ import ( // NewSplashPotion creates a splash potion. SplashPotion is an item that grants // effects when thrown. -func NewSplashPotion(opts world.EntitySpawnOpts, t potion.Potion, owner world.Entity) *world.EntityHandle { +func NewSplashPotion(opts world.EntitySpawnOpts, t potion.Potion, owner *world.EntityHandle) *world.EntityHandle { colour, _ := effect.ResultingColour(t.Effects()) conf := splashPotionConf conf.Potion = t conf.Particle = particle.Splash{Colour: colour} conf.Hit = potionSplash(1, t, false) - conf.Owner = owner.H() + conf.Owner = owner return opts.New(SplashPotionType, conf) } diff --git a/server/item/arrow.go b/server/item/arrow.go index 2243a69a3..97453fd59 100644 --- a/server/item/arrow.go +++ b/server/item/arrow.go @@ -1,6 +1,11 @@ package item -import "github.com/df-mc/dragonfly/server/item/potion" +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item/potion" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" +) // Arrow is used as ammunition for bows, crossbows, and dispensers. Arrows can be modified to // imbue status effects on players and mobs. @@ -9,6 +14,17 @@ type Arrow struct { Tip potion.Potion } +// Dispense launches the arrow from a dispenser. +func (a Arrow) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + create := tx.World().EntityRegistry().Config().Arrow + if create == nil { + return DispenseFailure + } + return dispenseProjectile(pos, face, tx, ctx, sound.BowShoot{}, func(opts world.EntitySpawnOpts) *world.EntityHandle { + return create(opts, world.ArrowSpawnConfig{Damage: 2, ObtainArrowOnPickup: true, Tip: a.Tip}) + }) +} + // EncodeItem ... func (a Arrow) EncodeItem() (name string, meta int16) { if tip := a.Tip.Uint8(); tip > 4 { diff --git a/server/item/bone_meal.go b/server/item/bone_meal.go index 9e7deea18..72b110ecd 100644 --- a/server/item/bone_meal.go +++ b/server/item/bone_meal.go @@ -23,6 +23,22 @@ const ( // BoneMeal is an item used to force growth in plants & crops. type BoneMeal struct{} +// Dispense applies bone meal to the block in front of a dispenser. +func (b BoneMeal) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + front := pos.Side(face) + affected, ok := tx.Block(front).(BoneMealAffected) + if !ok { + return DispenseFailure + } + result := affected.BoneMeal(front, tx) + if result == BoneMealResultNone { + return DispenseFailure + } + ctx.SubtractFromCount(1) + tx.AddParticle(front.Vec3(), particle.BoneMeal{Area: result == BoneMealResultArea}) + return DispenseSuccess +} + // BoneMealAffected represents a block that is affected when bone meal is used on it. type BoneMealAffected interface { // BoneMeal attempts to affect the block using a bone meal item. diff --git a/server/item/bottle_of_enchanting.go b/server/item/bottle_of_enchanting.go index 96ca279c6..ac4e20c98 100644 --- a/server/item/bottle_of_enchanting.go +++ b/server/item/bottle_of_enchanting.go @@ -1,6 +1,7 @@ package item import ( + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" ) @@ -8,11 +9,22 @@ import ( // BottleOfEnchanting is a bottle that releases experience orbs when thrown. type BottleOfEnchanting struct{} +// Dispense launches the bottle from a dispenser. +func (b BottleOfEnchanting) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + create := tx.World().EntityRegistry().Config().BottleOfEnchanting + if create == nil { + return DispenseFailure + } + return dispenseProjectile(pos, face, tx, ctx, sound.ItemThrow{}, func(opts world.EntitySpawnOpts) *world.EntityHandle { + return create(opts, nil) + }) +} + // Use ... func (b BottleOfEnchanting) Use(tx *world.Tx, user User, ctx *UseContext) bool { create := tx.World().EntityRegistry().Config().BottleOfEnchanting opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: throwableOffset(user.Rotation()).Vec3().Mul(0.6)} - tx.AddEntity(create(opts, user)) + tx.AddEntity(create(opts, user.H())) tx.PlaySound(user.Position(), sound.ItemThrow{}) ctx.SubtractFromCount(1) diff --git a/server/item/bow.go b/server/item/bow.go index 30ad644a6..00f50c90e 100644 --- a/server/item/bow.go +++ b/server/item/bow.go @@ -85,7 +85,7 @@ func (Bow) Release(releaser Releaser, tx *world.Tx, ctx *UseContext, duration ti } projectile := tx.AddEntity(create(opts, world.ArrowSpawnConfig{ Damage: damage, - Owner: releaser, + Owner: releaser.H(), Critical: force >= 1, ObtainArrowOnPickup: !creative && consume, PunchLevel: punchLevel, diff --git a/server/item/bucket.go b/server/item/bucket.go index 31c830904..f758054ac 100644 --- a/server/item/bucket.go +++ b/server/item/bucket.go @@ -54,6 +54,37 @@ type Bucket struct { Content BucketContent } +// Dispense fills or empties the bucket at the block in front of a dispenser, or returns DispenseDefault if the bucket +// cannot interact with the target. +func (b Bucket) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + front := pos.Side(face) + if b.Empty() { + liquid, ok := tx.Liquid(front) + if !ok || liquid.LiquidDepth() != 8 || liquid.LiquidFalling() { + return DispenseDefault + } + ctx.NewItem = NewStack(Bucket{Content: LiquidBucketContent(liquid)}, 1) + ctx.SubtractFromCount(1) + tx.SetLiquid(front, nil) + tx.PlaySound(front.Vec3Centre(), sound.BucketFill{Liquid: liquid}) + return DispenseSuccess + } + + liquid, ok := b.Content.Liquid() + if !ok { + return DispenseDefault + } + liquid = liquid.WithDepth(8, false) + if target := tx.Block(front); !canDisplace(target, liquid) && !replaceableWith(target, liquid) { + return DispenseDefault + } + ctx.NewItem = NewStack(Bucket{}, 1) + ctx.SubtractFromCount(1) + tx.SetLiquid(front, liquid) + tx.PlaySound(front.Vec3Centre(), sound.BucketEmpty{Liquid: liquid}) + return DispenseSuccess +} + // MaxCount returns 16. func (b Bucket) MaxCount() int { if b.Empty() { diff --git a/server/item/crossbow.go b/server/item/crossbow.go index 1e173ab2c..1805be5af 100644 --- a/server/item/crossbow.go +++ b/server/item/crossbow.go @@ -134,7 +134,7 @@ func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext arrowConf := world.ArrowSpawnConfig{ Damage: 9, - Owner: releaser, + Owner: releaser.H(), Critical: true, ObtainArrowOnPickup: !creative, PiercingLevel: pierceLevel, @@ -172,7 +172,7 @@ func (c Crossbow) shoot(releaser Releaser, tx *world.Tx, offsetAngle float64, ar Position: torsoPosition(releaser), Velocity: dirVec.Mul(0.8), Rotation: rot.Neg(), - }, firework, releaser, 1.0, 0, false) + }, firework, releaser.H(), 1.0, 0, false) tx.AddEntity(projectile) } else { createArrow := tx.World().EntityRegistry().Config().Arrow diff --git a/server/item/dispenser.go b/server/item/dispenser.go new file mode 100644 index 000000000..3af9be7ca --- /dev/null +++ b/server/item/dispenser.go @@ -0,0 +1,20 @@ +package item + +import ( + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +func dispenseProjectile(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext, played world.Sound, create func(world.EntitySpawnOpts) *world.EntityHandle) DispenseResult { + direction := cube.Pos{}.Side(face).Vec3() + r := ctx.Rand + opts := world.EntitySpawnOpts{ + Position: pos.Vec3Centre().Add(direction.Mul(0.7)), + Velocity: direction.Mul(1.1).Add(mgl64.Vec3{r.Float64()*0.1 - 0.05, r.Float64()*0.1 - 0.05, r.Float64()*0.1 - 0.05}), + } + ctx.SubtractFromCount(1) + tx.AddEntity(create(opts)) + tx.PlaySound(pos.Vec3Centre(), played) + return DispenseSuccess +} diff --git a/server/item/egg.go b/server/item/egg.go index b31de9ff6..9eb2e17dc 100644 --- a/server/item/egg.go +++ b/server/item/egg.go @@ -1,6 +1,7 @@ package item import ( + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" ) @@ -8,6 +9,17 @@ import ( // Egg is an item that can be used to craft food items, or as a throwable entity to spawn chicks. type Egg struct{} +// Dispense launches the egg from a dispenser. +func (e Egg) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + create := tx.World().EntityRegistry().Config().Egg + if create == nil { + return DispenseFailure + } + return dispenseProjectile(pos, face, tx, ctx, sound.ItemThrow{}, func(opts world.EntitySpawnOpts) *world.EntityHandle { + return create(opts, nil) + }) +} + // MaxCount ... func (e Egg) MaxCount() int { return 16 @@ -17,7 +29,7 @@ func (e Egg) MaxCount() int { func (e Egg) Use(tx *world.Tx, user User, ctx *UseContext) bool { create := tx.World().EntityRegistry().Config().Egg opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: user.Rotation().Vec3().Mul(1.5)} - tx.AddEntity(create(opts, user)) + tx.AddEntity(create(opts, user.H())) tx.PlaySound(user.Position(), sound.ItemThrow{}) ctx.SubtractFromCount(1) diff --git a/server/item/ender_pearl.go b/server/item/ender_pearl.go index 061498a50..e37a4ac1b 100644 --- a/server/item/ender_pearl.go +++ b/server/item/ender_pearl.go @@ -13,7 +13,7 @@ type EnderPearl struct{} func (e EnderPearl) Use(tx *world.Tx, user User, ctx *UseContext) bool { create := tx.World().EntityRegistry().Config().EnderPearl opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: user.Rotation().Vec3().Mul(1.5)} - tx.AddEntity(create(opts, user)) + tx.AddEntity(create(opts, user.H())) tx.PlaySound(user.Position(), sound.ItemThrow{}) ctx.SubtractFromCount(1) diff --git a/server/item/firework.go b/server/item/firework.go index 531bf9241..9b180f228 100644 --- a/server/item/firework.go +++ b/server/item/firework.go @@ -18,6 +18,17 @@ type Firework struct { Explosions []FireworkExplosion } +// Dispense launches the firework from a dispenser. +func (f Firework) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + create := tx.World().EntityRegistry().Config().Firework + if create == nil { + return DispenseFailure + } + return dispenseProjectile(pos, face, tx, ctx, sound.FireworkLaunch{}, func(opts world.EntitySpawnOpts) *world.EntityHandle { + return create(opts, f, nil, 1.15, 0.04, false) + }) +} + // Use ... func (f Firework) Use(tx *world.Tx, user User, ctx *UseContext) bool { if g, ok := user.(interface { @@ -31,7 +42,7 @@ func (f Firework) Use(tx *world.Tx, user User, ctx *UseContext) bool { tx.PlaySound(pos, sound.FireworkLaunch{}) create := tx.World().EntityRegistry().Config().Firework opts := world.EntitySpawnOpts{Position: pos, Rotation: user.Rotation()} - tx.AddEntity(create(opts, f, user, 1.15, 0.04, true)) + tx.AddEntity(create(opts, f, user.H(), 1.15, 0.04, true)) ctx.SubtractFromCount(1) return true @@ -42,7 +53,7 @@ func (f Firework) UseOnBlock(pos cube.Pos, _ cube.Face, clickPos mgl64.Vec3, tx fpos := pos.Vec3().Add(clickPos) create := tx.World().EntityRegistry().Config().Firework opts := world.EntitySpawnOpts{Position: fpos, Rotation: cube.Rotation{rand.Float64() * 360, 90}} - tx.AddEntity(create(opts, f, user, 1.15, 0.04, false)) + tx.AddEntity(create(opts, f, user.H(), 1.15, 0.04, false)) tx.PlaySound(fpos, sound.FireworkLaunch{}) ctx.SubtractFromCount(1) diff --git a/server/item/flint_and_steel.go b/server/item/flint_and_steel.go index ea264802c..010e639fa 100644 --- a/server/item/flint_and_steel.go +++ b/server/item/flint_and_steel.go @@ -13,6 +13,37 @@ import ( // FlintAndSteel is an item used to light blocks on fire. type FlintAndSteel struct{} +// Dispense ignites the block in front of a dispenser. +func (FlintAndSteel) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + front := pos.Side(face) + if target, ok := tx.Block(front).(ignitable); ok { + if !target.Ignite(front, tx, nil) { + return DispenseFailure + } + ctx.DamageItem(1) + return DispenseSuccess + } + + beforeName, _ := tx.Block(front).EncodeBlock() + if beforeName == "minecraft:fire" || beforeName == "minecraft:soul_fire" { + return DispenseFailure + } + starter, ok := fire().(interface { + Start(*world.Tx, cube.Pos) + }) + if !ok { + return DispenseFailure + } + starter.Start(tx, front) + afterName, _ := tx.Block(front).EncodeBlock() + if beforeName == afterName { + return DispenseFailure + } + tx.PlaySound(front.Vec3Centre(), sound.Ignite{}) + ctx.DamageItem(1) + return DispenseSuccess +} + // MaxCount ... func (f FlintAndSteel) MaxCount() int { return 1 diff --git a/server/item/glass_bottle.go b/server/item/glass_bottle.go index 70eff88c7..9861ea345 100644 --- a/server/item/glass_bottle.go +++ b/server/item/glass_bottle.go @@ -9,6 +9,34 @@ import ( // GlassBottle is an item that can hold various liquids. type GlassBottle struct{} +// Dispense fills the bottle from the block or liquid in front of a dispenser, falling back to ordinary ejection when +// no bottle-filling target is present. +func (GlassBottle) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + front := pos.Side(face) + b := tx.Block(front) + filler, fromBlock := b.(bottleFiller) + if !fromBlock { + liquid, ok := tx.Liquid(front) + if !ok { + return DispenseDefault + } + filler, ok = liquid.(bottleFiller) + if !ok { + return DispenseDefault + } + } + result, filled, ok := filler.FillBottle() + if !ok { + return DispenseDefault + } + if fromBlock && result != b { + tx.SetBlock(front, result, nil) + } + ctx.NewItem = filled + ctx.SubtractFromCount(1) + return DispenseSuccess +} + // bottleFiller is implemented by blocks that can fill bottles by clicking on them. type bottleFiller interface { // FillBottle fills a GlassBottle by interacting with a block. Blocks that implement this interface return both the diff --git a/server/item/honeycomb.go b/server/item/honeycomb.go index 6938a31ce..6e4b96dec 100644 --- a/server/item/honeycomb.go +++ b/server/item/honeycomb.go @@ -10,18 +10,36 @@ import ( // Honeycomb is an item obtained from bee nests and beehives. type Honeycomb struct{} -// UseOnBlock handles the logic of using an ink sac on a sign. Glowing ink sacs turn the text of these signs glowing, -// whereas normal ink sacs revert them back to non-glowing text. +// Dispense waxes the block in front of a dispenser. +func (Honeycomb) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + if !waxBlock(pos.Side(face), pos.Vec3Centre(), tx) { + return DispenseFailure + } + ctx.SubtractFromCount(1) + return DispenseSuccess +} + +// UseOnBlock waxes the block at pos if it supports waxing. func (Honeycomb) UseOnBlock(pos cube.Pos, _ cube.Face, _ mgl64.Vec3, tx *world.Tx, user User, ctx *UseContext) bool { - if wa, ok := tx.Block(pos).(waxable); ok { - if res, ok := wa.Wax(pos, user.Position()); ok { - tx.SetBlock(pos, res, nil) - tx.PlaySound(pos.Vec3(), sound.SignWaxed{}) - ctx.SubtractFromCount(1) - return true - } + if !waxBlock(pos, user.Position(), tx) { + return false + } + ctx.SubtractFromCount(1) + return true +} + +func waxBlock(pos cube.Pos, source mgl64.Vec3, tx *world.Tx) bool { + wa, ok := tx.Block(pos).(waxable) + if !ok { + return false + } + res, ok := wa.Wax(pos, source) + if !ok { + return false } - return false + tx.SetBlock(pos, res, nil) + tx.PlaySound(pos.Vec3(), sound.SignWaxed{}) + return true } // waxable represents a block that may be waxed. diff --git a/server/item/item.go b/server/item/item.go index 33a503cd2..99b8065fe 100644 --- a/server/item/item.go +++ b/server/item/item.go @@ -3,6 +3,7 @@ package item import ( "encoding/binary" "image/color" + "math/rand/v2" "time" "github.com/df-mc/dragonfly/server/block/cube" @@ -48,6 +49,43 @@ type Usable interface { Use(tx *world.Tx, user User, ctx *UseContext) bool } +// Dispensable represents an item with specialised behaviour when used by a dispenser. +type Dispensable interface { + // Dispense performs the item's dispenser behaviour. The position and face are those of the dispenser. Mutations to + // the dispensed stack are recorded in ctx and applied by the dispenser after Dispense returns successfully. + Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult +} + +// DispenseResult describes the outcome of specialised item behaviour in a dispenser. +type DispenseResult uint8 + +const ( + // DispenseDefault falls back to ejecting the item normally. + DispenseDefault DispenseResult = iota + // DispenseSuccess applies the DispenseContext to the selected dispenser slot. + DispenseSuccess + // DispenseFailure leaves the selected item unchanged and plays the dispenser failure sound. + DispenseFailure +) + +// DispenseContext records mutations to a dispensed item and provides the random source of the dispenser activation. +type DispenseContext struct { + // Damage is the amount of damage applied to the selected item after a successful dispense. + Damage int + // CountSub is the number of items removed from the selected stack after a successful dispense. + CountSub int + // NewItem is returned to the dispenser inventory after a successful dispense. + NewItem Stack + // Rand is the random source of the dispenser activation. + Rand *rand.Rand +} + +// DamageItem damages the selected item by d after a successful dispense. +func (ctx *DispenseContext) DamageItem(d int) { ctx.Damage += d } + +// SubtractFromCount subtracts d from the selected stack after a successful dispense. +func (ctx *DispenseContext) SubtractFromCount(d int) { ctx.CountSub += d } + // Throwable represents a custom item that can be thrown such as a projectile. This will only have an effect on // non-vanilla items. type Throwable interface { diff --git a/server/item/lingering_potion.go b/server/item/lingering_potion.go index d83bac0cf..3814fe9cd 100644 --- a/server/item/lingering_potion.go +++ b/server/item/lingering_potion.go @@ -1,6 +1,7 @@ package item import ( + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/item/potion" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" @@ -13,6 +14,17 @@ type LingeringPotion struct { Type potion.Potion } +// Dispense launches the potion from a dispenser. +func (l LingeringPotion) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + create := tx.World().EntityRegistry().Config().LingeringPotion + if create == nil { + return DispenseFailure + } + return dispenseProjectile(pos, face, tx, ctx, sound.ItemThrow{}, func(opts world.EntitySpawnOpts) *world.EntityHandle { + return create(opts, l.Type, nil) + }) +} + // MaxCount ... func (l LingeringPotion) MaxCount() int { return 1 @@ -22,7 +34,7 @@ func (l LingeringPotion) MaxCount() int { func (l LingeringPotion) Use(tx *world.Tx, user User, ctx *UseContext) bool { create := tx.World().EntityRegistry().Config().LingeringPotion opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: throwableOffset(user.Rotation()).Vec3().Mul(0.5)} - tx.AddEntity(create(opts, l.Type, user)) + tx.AddEntity(create(opts, l.Type, user.H())) tx.PlaySound(user.Position(), sound.ItemThrow{}) ctx.SubtractFromCount(1) diff --git a/server/item/snowball.go b/server/item/snowball.go index a3a66e7e6..8fbd9be70 100644 --- a/server/item/snowball.go +++ b/server/item/snowball.go @@ -1,6 +1,7 @@ package item import ( + "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/df-mc/dragonfly/server/world/sound" ) @@ -8,6 +9,17 @@ import ( // Snowball is a throwable combat item obtained through shovelling snow. type Snowball struct{} +// Dispense launches the snowball from a dispenser. +func (s Snowball) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + create := tx.World().EntityRegistry().Config().Snowball + if create == nil { + return DispenseFailure + } + return dispenseProjectile(pos, face, tx, ctx, sound.ItemThrow{}, func(opts world.EntitySpawnOpts) *world.EntityHandle { + return create(opts, nil) + }) +} + // MaxCount ... func (s Snowball) MaxCount() int { return 16 @@ -17,7 +29,7 @@ func (s Snowball) MaxCount() int { func (s Snowball) Use(tx *world.Tx, user User, ctx *UseContext) bool { create := tx.World().EntityRegistry().Config().Snowball opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: user.Rotation().Vec3().Mul(1.5)} - tx.AddEntity(create(opts, user)) + tx.AddEntity(create(opts, user.H())) tx.PlaySound(user.Position(), sound.ItemThrow{}) ctx.SubtractFromCount(1) diff --git a/server/item/splash_potion.go b/server/item/splash_potion.go index 23311ee0c..1221b030d 100644 --- a/server/item/splash_potion.go +++ b/server/item/splash_potion.go @@ -14,6 +14,17 @@ type SplashPotion struct { Type potion.Potion } +// Dispense launches the potion from a dispenser. +func (s SplashPotion) Dispense(pos cube.Pos, face cube.Face, tx *world.Tx, ctx *DispenseContext) DispenseResult { + create := tx.World().EntityRegistry().Config().SplashPotion + if create == nil { + return DispenseFailure + } + return dispenseProjectile(pos, face, tx, ctx, sound.ItemThrow{}, func(opts world.EntitySpawnOpts) *world.EntityHandle { + return create(opts, s.Type, nil) + }) +} + // MaxCount ... func (s SplashPotion) MaxCount() int { return 1 @@ -23,7 +34,7 @@ func (s SplashPotion) MaxCount() int { func (s SplashPotion) Use(tx *world.Tx, user User, ctx *UseContext) bool { create := tx.World().EntityRegistry().Config().SplashPotion opts := world.EntitySpawnOpts{Position: eyePosition(user), Velocity: throwableOffset(user.Rotation()).Vec3().Mul(0.5)} - tx.AddEntity(create(opts, s.Type, user)) + tx.AddEntity(create(opts, s.Type, user.H())) tx.PlaySound(user.Position(), sound.ItemThrow{}) ctx.SubtractFromCount(1) diff --git a/server/session/world.go b/server/session/world.go index 9e47cbf4b..49aee364e 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -567,6 +567,12 @@ func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) Position: vec64To32(pos), }) return + case sound.ClickFail: + s.writePacket(&packet.LevelEvent{ + EventType: packet.LevelEventSoundClickFail, + Position: vec64To32(pos), + }) + return case sound.SignWaxed: s.writePacket(&packet.LevelEvent{ EventType: packet.LevelEventWaxOn, @@ -1229,6 +1235,8 @@ func (s *Session) openNormalContainer(b block.Container, pos cube.Pos, tx *world containerType = protocol.ContainerTypeSmoker case block.Hopper: containerType = protocol.ContainerTypeHopper + case block.Dispenser: + containerType = protocol.ContainerTypeDispenser } s.openedContainerID.Store(uint32(containerType)) diff --git a/server/world/entity.go b/server/world/entity.go index c150180ba..5c97f8267 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -493,14 +493,14 @@ type EntityRegistryConfig struct { Item func(opts EntitySpawnOpts, it any) *EntityHandle FallingBlock func(opts EntitySpawnOpts, bl Block) *EntityHandle TNT func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle - BottleOfEnchanting func(opts EntitySpawnOpts, owner Entity) *EntityHandle + BottleOfEnchanting func(opts EntitySpawnOpts, owner *EntityHandle) *EntityHandle Arrow func(opts EntitySpawnOpts, conf ArrowSpawnConfig) *EntityHandle - Egg func(opts EntitySpawnOpts, owner Entity) *EntityHandle - EnderPearl func(opts EntitySpawnOpts, owner Entity) *EntityHandle - Firework func(opts EntitySpawnOpts, firework Item, owner Entity, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *EntityHandle - LingeringPotion func(opts EntitySpawnOpts, t any, owner Entity) *EntityHandle - Snowball func(opts EntitySpawnOpts, owner Entity) *EntityHandle - SplashPotion func(opts EntitySpawnOpts, t any, owner Entity) *EntityHandle + Egg func(opts EntitySpawnOpts, owner *EntityHandle) *EntityHandle + EnderPearl func(opts EntitySpawnOpts, owner *EntityHandle) *EntityHandle + Firework func(opts EntitySpawnOpts, firework Item, owner *EntityHandle, sidewaysVelocityMultiplier, upwardsAcceleration float64, attached bool) *EntityHandle + LingeringPotion func(opts EntitySpawnOpts, t any, owner *EntityHandle) *EntityHandle + Snowball func(opts EntitySpawnOpts, owner *EntityHandle) *EntityHandle + SplashPotion func(opts EntitySpawnOpts, t any, owner *EntityHandle) *EntityHandle Lightning func(opts EntitySpawnOpts) *EntityHandle } @@ -508,8 +508,9 @@ type EntityRegistryConfig struct { type ArrowSpawnConfig struct { // Damage specifies the base damage dealt by the arrow. Damage float64 - // Owner is the entity that fired the arrow. - Owner Entity + // Owner is the handle of the entity that fired the arrow. It may be nil if + // the arrow has no owner. + Owner *EntityHandle // Critical specifies if the arrow should deal critical damage. Critical bool // DisablePickup specifies if picking up the arrow should be disabled. diff --git a/server/world/sound/block.go b/server/world/sound/block.go index 02cfad5bb..1daf7035c 100644 --- a/server/world/sound/block.go +++ b/server/world/sound/block.go @@ -126,6 +126,9 @@ type DoorCrash struct{ sound } // Click is a clicking sound. type Click struct{ sound } +// ClickFail is a clicking sound played when a dispenser fails to dispense an item. +type ClickFail struct{ sound } + // Ignite is a sound played when using a flint & steel. type Ignite struct{ sound }