Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions server/entity/projectile.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package entity

import (
"iter"
"math"
"math/rand/v2"
"slices"
"time"

"github.com/df-mc/dragonfly/server/block"
"github.com/df-mc/dragonfly/server/block/cube"
"github.com/df-mc/dragonfly/server/block/cube/trace"
Expand All @@ -9,10 +15,6 @@ import (
"github.com/df-mc/dragonfly/server/item/potion"
"github.com/df-mc/dragonfly/server/world"
"github.com/go-gl/mathgl/mgl64"
"iter"
"math"
"math/rand/v2"
"time"
)

// ProjectileBehaviourConfig allows the configuration of projectiles. Calling
Expand Down Expand Up @@ -80,6 +82,10 @@ type ProjectileBehaviourConfig struct {
// CollisionPosition specifies the position that the projectile is stuck
// in. If non-empty, the entity will not move.
CollisionPosition cube.Pos
// PiercingLevel is the crossbow Piercing enchantment level. The projectile
// passes through PiercingLevel entities and damages PiercingLevel+1 in
// total. A value of 0 means no piercing.
PiercingLevel int
}

func (conf ProjectileBehaviourConfig) Apply(data *world.EntityData) {
Expand Down Expand Up @@ -109,6 +115,8 @@ type ProjectileBehaviour struct {

collisionPos cube.Pos
collided bool

collidedEntities []*world.EntityHandle
}

// Owner returns the owner of the projectile.
Expand Down Expand Up @@ -168,8 +176,11 @@ func (lt *ProjectileBehaviour) Tick(e *Ent, tx *world.Tx) *Movement {

switch r := result.(type) {
case trace.EntityResult:
if l, ok := r.Entity().(Living); ok && lt.conf.Damage >= 0 {
lt.hitEntity(l, e, vel)
if l, ok := r.Entity().(Living); ok {
if lt.conf.Damage >= 0 {
lt.hitEntity(l, e, vel)
}
lt.collidedEntities = append(lt.collidedEntities, l.H())
}
case trace.BlockResult:
bpos := r.BlockPosition()
Expand All @@ -180,12 +191,15 @@ func (lt *ProjectileBehaviour) Tick(e *Ent, tx *world.Tx) *Movement {
lt.hitBlockSurviving(e, r, m, tx)
return m
}
lt.close = true
}
if lt.conf.Hit != nil {
lt.conf.Hit(e, tx, result)
}

lt.close = true
if len(lt.collidedEntities) > lt.conf.PiercingLevel {
lt.close = true
}
return m
}

Expand Down Expand Up @@ -264,6 +278,7 @@ func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) {
if lt.conf.Critical {
dmg += rand.Float64() * dmg / 2
}
// TODO: Piercing arrows should bypass shield blocking when shields are implemented.
if _, vulnerable := l.Hurt(dmg, src); vulnerable {
l.KnockBack(l.Position().Sub(vel), 0.45+lt.conf.KnockBackForceAddend, 0.3608+lt.conf.KnockBackHeightAddend)

Expand Down Expand Up @@ -312,7 +327,7 @@ func (lt *ProjectileBehaviour) tickMovement(e *Ent, tx *world.Tx) (*Movement, tr
mx, my, mz := hit.Face().Axis().Vec3().Mul(-2).Add(mgl64.Vec3{1, 1, 1}).Elem()

vel = mgl64.Vec3{x * mx, y * my, z * mz}
} else {
} else if lt.conf.PiercingLevel == 0 {
vel = zeroVec3
}
end = hit.Position()
Expand All @@ -322,15 +337,19 @@ func (lt *ProjectileBehaviour) tickMovement(e *Ent, tx *world.Tx) (*Movement, tr
}

// ignores returns a function to ignore entities in trace.Perform that are
// either a spectator, not living, the entity itself or its owner in the first
// 5 ticks.
// either a spectator, not living, the entity itself, its owner in the first
// 5 ticks, or an entity it already collided with.
func (lt *ProjectileBehaviour) ignores(e *Ent) trace.EntityFilter {
return func(seq iter.Seq[world.Entity]) iter.Seq[world.Entity] {
return func(yield func(world.Entity) bool) {
for other := range seq {
g, ok := other.(interface{ GameMode() world.GameMode })
spectator := ok && !g.GameMode().HasCollision()
itself := e.H() == other.H()
_, living := other.(Living)
if (ok && !g.GameMode().HasCollision()) || e.H() == other.H() || !living || (e.data.Age < time.Second/4 && lt.conf.Owner == other.H()) {
owner := e.data.Age < time.Second/4 && lt.conf.Owner == other.H()
collidedEntity := slices.Contains(lt.collidedEntities, other.H())
if spectator || itself || !living || owner || collidedEntity {
continue
}
if !yield(other) {
Expand Down
16 changes: 9 additions & 7 deletions server/entity/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,17 @@ var conf = world.EntityRegistryConfig{
SplashPotion: func(opts world.EntitySpawnOpts, t any, owner world.Entity) *world.EntityHandle {
return NewSplashPotion(opts, t.(potion.Potion), owner)
},
Arrow: func(opts world.EntitySpawnOpts, damage float64, owner world.Entity, critical, disallowPickup, obtainArrowOnPickup bool, punchLevel int, tip any) *world.EntityHandle {
Arrow: func(opts world.EntitySpawnOpts, arrow world.ArrowSpawnConfig) *world.EntityHandle {
tip := arrow.Tip.(potion.Potion)
conf := arrowConf
conf.Damage, conf.Potion, conf.Owner = damage, tip.(potion.Potion), owner.H()
conf.KnockBackForceAddend = float64(punchLevel) * enchantment.Punch.KnockBackMultiplier()
conf.DisablePickup = disallowPickup
if obtainArrowOnPickup {
conf.PickupItem = item.NewStack(item.Arrow{Tip: tip.(potion.Potion)}, 1)
conf.Damage, conf.Potion, conf.Owner = arrow.Damage, tip, arrow.Owner.H()
Comment on lines +50 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make ArrowSpawnConfig zero-value safe here.

arrow.Tip.(potion.Potion) and arrow.Owner.H() will panic if a caller omits the tip or spawns an ownerless arrow. The new struct API makes both omissions easy.

💡 Suggested fix
 Arrow: func(opts world.EntitySpawnOpts, arrow world.ArrowSpawnConfig) *world.EntityHandle {
-	tip := arrow.Tip.(potion.Potion)
+	var tip potion.Potion
+	if t, ok := arrow.Tip.(potion.Potion); ok {
+		tip = t
+	}
 	conf := arrowConf
-	conf.Damage, conf.Potion, conf.Owner = arrow.Damage, tip, arrow.Owner.H()
+	conf.Damage, conf.Potion = arrow.Damage, tip
+	if arrow.Owner != nil {
+		conf.Owner = arrow.Owner.H()
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Arrow: func(opts world.EntitySpawnOpts, arrow world.ArrowSpawnConfig) *world.EntityHandle {
tip := arrow.Tip.(potion.Potion)
conf := arrowConf
conf.Damage, conf.Potion, conf.Owner = damage, tip.(potion.Potion), owner.H()
conf.KnockBackForceAddend = float64(punchLevel) * enchantment.Punch.KnockBackMultiplier()
conf.DisablePickup = disallowPickup
if obtainArrowOnPickup {
conf.PickupItem = item.NewStack(item.Arrow{Tip: tip.(potion.Potion)}, 1)
conf.Damage, conf.Potion, conf.Owner = arrow.Damage, tip, arrow.Owner.H()
Arrow: func(opts world.EntitySpawnOpts, arrow world.ArrowSpawnConfig) *world.EntityHandle {
var tip potion.Potion
if t, ok := arrow.Tip.(potion.Potion); ok {
tip = t
}
conf := arrowConf
conf.Damage, conf.Potion = arrow.Damage, tip
if arrow.Owner != nil {
conf.Owner = arrow.Owner.H()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/entity/register.go` around lines 50 - 53, Make ArrowSpawnConfig
handling zero-value safe in the Arrow factory: guard the type assertion on
arrow.Tip and the call to arrow.Owner.H() so they don't panic when omitted—use a
safe type-assertion for tip (e.g., tip, ok := arrow.Tip.(potion.Potion); if !ok
use zero-value potion.Potion{}) and check arrow.Owner for nil (or presence of H)
before calling H(), then assign conf.Damage, conf.Potion and conf.Owner using
those safe values in Arrow (the Arrow function that sets conf from arrow and
arrowConf).

conf.KnockBackForceAddend = float64(arrow.PunchLevel) * enchantment.Punch.KnockBackMultiplier()
conf.DisablePickup = arrow.DisablePickup
if arrow.ObtainArrowOnPickup {
conf.PickupItem = item.NewStack(item.Arrow{Tip: tip}, 1)
}
conf.Critical = critical
conf.Critical = arrow.Critical
conf.PiercingLevel = arrow.PiercingLevel
return opts.New(ArrowType, conf)
},
}
15 changes: 13 additions & 2 deletions server/item/bow.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,19 @@ func (Bow) Release(releaser Releaser, tx *world.Tx, ctx *UseContext, duration ti
}

create := tx.World().EntityRegistry().Config().Arrow
opts := world.EntitySpawnOpts{Position: eyePosition(releaser), Velocity: releaser.Rotation().Vec3().Mul(force * 5), Rotation: releaser.Rotation().Neg()}
projectile := tx.AddEntity(create(opts, damage, releaser, force >= 1, false, !creative && consume, punchLevel, tip))
opts := world.EntitySpawnOpts{
Position: eyePosition(releaser),
Velocity: releaser.Rotation().Vec3().Mul(force * 5),
Rotation: releaser.Rotation().Neg(),
}
projectile := tx.AddEntity(create(opts, world.ArrowSpawnConfig{
Damage: damage,
Owner: releaser,
Critical: force >= 1,
ObtainArrowOnPickup: !creative && consume,
PunchLevel: punchLevel,
Tip: tip,
}))
if f, ok := projectile.(interface{ SetOnFire(duration time.Duration) }); ok {
f.SetOnFire(burnDuration)
}
Expand Down
24 changes: 17 additions & 7 deletions server/item/crossbow.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,18 +122,27 @@ func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext
held, _ := releaser.HeldItems()
creative := releaser.GameMode().CreativeInventory()

multishot := false
pierceLevel, multishot := 0, false
for _, enchant := range held.Enchantments() {
if _, ok := enchant.Type().(interface{ MultipleProjectiles() bool }); ok {
multishot = true
break
}
if _, ok := enchant.Type().(interface{ Pierces() bool }); ok {
pierceLevel = enchant.Level()
}
}

c.shoot(releaser, tx, 0, !creative)
arrowConf := world.ArrowSpawnConfig{
Damage: 9,
Owner: releaser,
ObtainArrowOnPickup: !creative,
PiercingLevel: pierceLevel,
}
c.shoot(releaser, tx, 0, arrowConf)
if multishot {
c.shoot(releaser, tx, -10, false)
c.shoot(releaser, tx, 10, false)
arrowConf.ObtainArrowOnPickup = false
c.shoot(releaser, tx, -10, arrowConf)
c.shoot(releaser, tx, 10, arrowConf)
}
c.applyDamage(ctx)

Expand All @@ -146,7 +155,7 @@ func (c Crossbow) ReleaseCharge(releaser Releaser, tx *world.Tx, ctx *UseContext
}

// shoot fires the crossbow's loaded projectiles.
func (c Crossbow) shoot(releaser Releaser, tx *world.Tx, offsetAngle float64, canObtainPickup bool) {
func (c Crossbow) shoot(releaser Releaser, tx *world.Tx, offsetAngle float64, arrowConf world.ArrowSpawnConfig) {
rot := releaser.Rotation()
dirVec := cube.Rotation{rot[0] + offsetAngle, rot[1]}.Vec3()

Expand All @@ -160,11 +169,12 @@ func (c Crossbow) shoot(releaser Releaser, tx *world.Tx, offsetAngle float64, ca
tx.AddEntity(projectile)
} else {
createArrow := tx.World().EntityRegistry().Config().Arrow
arrowConf.Tip = c.Item.Item().(Arrow).Tip
arrow := createArrow(world.EntitySpawnOpts{
Position: torsoPosition(releaser),
Velocity: dirVec.Mul(5.15),
Rotation: rot.Neg(),
}, 9, releaser, false, false, canObtainPickup, 0, c.Item.Item().(Arrow).Tip)
}, arrowConf)
tx.AddEntity(arrow)
}
}
Expand Down
3 changes: 1 addition & 2 deletions server/item/enchantment/multishot.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,7 @@ func (multishot) Rarity() item.EnchantmentRarity {

// CompatibleWithEnchantment ...
func (multishot) CompatibleWithEnchantment(t item.EnchantmentType) bool {
// TODO: Multishot is incompatible with Piercing
return true
return t != Piercing
}

// CompatibleWithItem ...
Expand Down
40 changes: 40 additions & 0 deletions server/item/enchantment/piercing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package enchantment

import (
"github.com/df-mc/dragonfly/server/item"
"github.com/df-mc/dragonfly/server/world"
)

// Piercing is an enchantment that allows arrows to damage and pierce through multiple entities, including shields.
var Piercing piercing

type piercing struct{}

func (p piercing) Name() string {
return "Piercing"
}

func (p piercing) MaxLevel() int {
return 4
}

func (p piercing) Cost(level int) (int, int) {
return 1 + (level-1)*10, 50
}

func (p piercing) Rarity() item.EnchantmentRarity {
return item.EnchantmentRarityCommon
}

func (p piercing) CompatibleWithEnchantment(t item.EnchantmentType) bool {
return t != Multishot
}

func (p piercing) CompatibleWithItem(i world.Item) bool {
_, ok := i.(item.Crossbow)
return ok
}

func (p piercing) Pierces() bool {
return true
}
2 changes: 1 addition & 1 deletion server/item/enchantment/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func init() {
// TODO: (31) Loyalty.
// TODO: (32) Channeling.
item.RegisterEnchantment(33, Multishot)
// TODO: (34) Piercing.
item.RegisterEnchantment(34, Piercing)
item.RegisterEnchantment(35, QuickCharge)
item.RegisterEnchantment(36, SoulSpeed)
item.RegisterEnchantment(37, SwiftSneak)
Expand Down
15 changes: 15 additions & 0 deletions server/item/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,21 @@ func (s Stack) WithEnchantments(enchants ...Enchantment) Stack {
// Enchantment is not compatible with the item.
continue
}
compatible := true
for _, otherEnchant := range s.enchantments {
addingType := enchant.t
existingType := otherEnchant.Type()
addingAcceptsExisting := addingType.CompatibleWithEnchantment(existingType)
existingAcceptsAdding := existingType.CompatibleWithEnchantment(addingType)
if addingType != existingType && (!addingAcceptsExisting || !existingAcceptsAdding) {
compatible = false
break
}
}
if !compatible {
// Enchantment is not compatible with another enchantment on the item.
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WithItem silently drops incompatible enchantments due to new check

Medium Severity

The new enchantment compatibility check in WithEnchantments causes WithItem (line 374) to silently drop incompatible enchantments when copying them. WithItem calls WithEnchantments(s.Enchantments()...), and since Enchantments() iterates a map in non-deterministic order, which of two incompatible enchantments gets dropped is random. Items loaded from NBT via WithForcedEnchantments can legitimately hold incompatible enchantments; those are now randomly pruned whenever WithItem is called — for example in Crossbow.ReleaseCharge. The copy in WithItem likely needs to use WithForcedEnchantments to preserve existing enchantments faithfully.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0cf3d5a. Configure here.

s.enchantments[enchant.t] = enchant
}
return s
Expand Down
24 changes: 23 additions & 1 deletion server/world/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ type EntityRegistryConfig struct {
FallingBlock func(opts EntitySpawnOpts, bl Block) *EntityHandle
TNT func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle
BottleOfEnchanting func(opts EntitySpawnOpts, owner Entity) *EntityHandle
Arrow func(opts EntitySpawnOpts, damage float64, owner Entity, critical, disallowPickup, obtainArrowOnPickup bool, punchLevel int, tip any) *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
Expand All @@ -376,6 +376,28 @@ type EntityRegistryConfig struct {
Lightning func(opts EntitySpawnOpts) *EntityHandle
}

// ArrowSpawnConfig holds the options used to spawn an arrow entity.
type ArrowSpawnConfig struct {
// Damage specifies the base damage dealt by the arrow.
Damage float64
// Owner is the entity that fired the arrow.
Owner Entity
// Critical specifies if the arrow should deal critical damage.
Critical bool
// DisablePickup specifies if picking up the arrow should be disabled.
DisablePickup bool
// ObtainArrowOnPickup specifies if the arrow should be returned as an item when picked up.
ObtainArrowOnPickup bool
// PunchLevel specifies the level of punch knockback applied to the arrow.
PunchLevel int
// PiercingLevel is the crossbow Piercing enchantment level. The arrow passes
// through PiercingLevel entities and damages PiercingLevel+1 in total. A
// value of 0 means no piercing.
PiercingLevel int
// Tip specifies the potion tip carried by the arrow.
Tip any
}

// New creates an EntityRegistry using conf and the EntityTypes passed.
func (conf EntityRegistryConfig) New(ent []EntityType) EntityRegistry {
m := make(map[string]EntityType, len(ent))
Expand Down